forked from finn/tinyboard
Compare commits
82 Commits
88fabcf25f
...
rclone-twe
| Author | SHA1 | Date | |
|---|---|---|---|
| dea8d2df4d | |||
|
|
8a7fe7b4de | ||
|
|
e3a12c0f6e | ||
|
|
a8b10a1814 | ||
|
|
92a2af8c5c | ||
|
|
a5cf3d1f8b | ||
|
|
ebb366e4bc | ||
|
|
84b3b7ce1d | ||
|
|
86688c43c7 | ||
|
|
972dbef11c | ||
|
|
e55ab898ef | ||
|
|
81d0bebd5e | ||
|
|
6fe164a6ae | ||
|
|
b76e890857 | ||
|
|
4e2f17266a | ||
|
|
0af3c30f79 | ||
|
|
b0e63a2e01 | ||
|
|
22eced7607 | ||
|
|
58c641d603 | ||
|
|
1cc50f8ff0 | ||
|
|
97aff6a741 | ||
|
|
eaff38477c | ||
|
|
e2ed499e58 | ||
|
|
48ba67e351 | ||
|
|
5a9e55b673 | ||
|
|
e5bdf95dcf | ||
|
|
0553420d04 | ||
|
|
4cdddd649d | ||
|
|
0fd7d94d58 | ||
|
|
f3c9cf2344 | ||
|
|
f486795154 | ||
|
|
fe3f2c5b77 | ||
|
|
4e1e9282ac | ||
|
|
07f4601bad | ||
|
|
9bdd12ebbd | ||
|
|
d3a6d406d8 | ||
|
|
e74c9b45d5 | ||
|
|
600f6044ce | ||
|
|
2fe94dfe9d | ||
|
|
b735c58446 | ||
|
|
524321fa97 | ||
|
|
e9c1daccce | ||
|
|
6db5e9769e | ||
|
|
c75b29a5ea | ||
|
|
92b74d8f67 | ||
|
|
1d4e25b6a5 | ||
|
|
0c784a672c | ||
|
|
410def45c3 | ||
|
|
f1d818eae6 | ||
|
|
5017af57c9 | ||
|
|
9e4fba591a | ||
|
|
982b8a8641 | ||
|
|
c2aec56490 | ||
|
|
e4db257f53 | ||
|
|
b2932286d0 | ||
|
|
866f8af073 | ||
|
|
e6720804dc | ||
|
|
63197799b8 | ||
|
|
128b41ede9 | ||
|
|
f3792a38fc | ||
|
|
e450456638 | ||
|
|
d925cd944a | ||
|
|
74e1a9d1a0 | ||
|
|
535c8a47cb | ||
|
|
1b4a2c7ab5 | ||
|
|
72a58cc390 | ||
|
|
9e6a6f2222 | ||
|
|
99c006747a | ||
|
|
e3bb7fb1ca | ||
|
|
aeda90799d | ||
|
|
26b623eef7 | ||
|
|
8ee67739f7 | ||
|
|
39f8f64351 | ||
|
|
e924579b2e | ||
|
|
912e553e06 | ||
|
|
98986e615b | ||
|
|
0e792be751 | ||
|
|
835793d396 | ||
|
|
11f9586c5e | ||
|
|
3e351f925d | ||
|
|
a197b7881b | ||
|
|
60feeca65e |
158
README.md
158
README.md
@@ -1,8 +1,8 @@
|
|||||||
# TinyBoard
|
# TinyBoard
|
||||||
|
|
||||||
A hub-spoke architecture for secure file sharing over SSH tunnels using autossh and rclone.
|
A hub-spoke architecture for secure file sharing and sync over SSH tunnels using autossh, rclone, and Syncthing.
|
||||||
|
|
||||||
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.
|
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. Syncthing runs on each spoke for bidirectional file sync.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,14 +16,14 @@ On a fresh Debian/Ubuntu VPS or server:
|
|||||||
apt install git
|
apt install git
|
||||||
git clone https://gut.oily.dad/justin/tinyboard
|
git clone https://gut.oily.dad/justin/tinyboard
|
||||||
cd tinyboard
|
cd tinyboard
|
||||||
./setup.sh # choose option 4
|
./setup.sh # option 4 (setup new hub)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Setting up a new Spoke
|
### Setting up a new Spoke
|
||||||
|
|
||||||
On a fresh Armbian device:
|
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)
|
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
|
2. Boot, SSH in as root
|
||||||
3. Run:
|
3. Run:
|
||||||
|
|
||||||
@@ -31,26 +31,59 @@ On a fresh Armbian device:
|
|||||||
apt install git
|
apt install git
|
||||||
git clone https://gut.oily.dad/justin/tinyboard
|
git clone https://gut.oily.dad/justin/tinyboard
|
||||||
cd tinyboard
|
cd tinyboard
|
||||||
./setup-network.sh # configure static IP — SSH session will drop, reconnect
|
./setup.sh # option 0 (configure network)
|
||||||
./setup.sh # choose option 1
|
./setup.sh # option 1 (configure new spoke)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Adding the Spoke's Public Key to the Hub
|
||||||
|
|
||||||
|
During `setup-spoke.sh`, a key pair is generated on the spoke for the autossh tunnel. The script will display the public key and pause. Before pressing ENTER, the hub owner must add the public key to the hub user's `authorized_keys`. Run this on the hub as the hub user (e.g. `armbian`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "<paste public key here>" >> ~/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
|
||||||
|
Or as root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "<paste public key here>" >> /home/armbian/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the key is added, press ENTER on the spoke to continue. The script will test the SSH connection and if successful, bring up the tunnel.
|
||||||
|
|
||||||
|
The private key never leaves the spoke — only the public key is shared.
|
||||||
|
|
||||||
### Onboarding a Spoke from the Hub
|
### Onboarding a Spoke from the Hub
|
||||||
|
|
||||||
Once the spoke tunnel is up, run on the hub:
|
Once the spoke tunnel is up, run on the hub:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd tinyboard
|
cd tinyboard
|
||||||
./setup.sh # choose option 2
|
./setup.sh # option 2 (onboard spoke)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Offboarding a Spoke from the Hub
|
### Offboarding a Spoke from the Hub
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd tinyboard
|
cd tinyboard
|
||||||
./setup.sh # choose option 3
|
./setup.sh # option 3 (offboard spoke)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Configuring Syncthing
|
||||||
|
|
||||||
|
After the hub and at least one spoke are set up, run `syncthing.sh` on either device to manage Syncthing devices and folders interactively:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./syncthing.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The typical pairing flow:
|
||||||
|
1. Run option 0 (Show This Device's ID) on the spoke — copy the ID
|
||||||
|
2. Run option 3 (Add Device) on the hub — paste the spoke's ID
|
||||||
|
3. Run option 0 (Show This Device's ID) on the hub — copy the ID
|
||||||
|
4. Run option 3 (Add Device) on the spoke — paste the hub's ID
|
||||||
|
5. On both devices, run option 6 (Add Folder) or option 8 (Share Folder with Device) to share folders between them
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -63,6 +96,9 @@ cd tinyboard
|
|||||||
autossh container ──────────► sshd (GatewayPorts)
|
autossh container ──────────► sshd (GatewayPorts)
|
||||||
reverse tunnel port 111xx
|
reverse tunnel port 111xx
|
||||||
|
|
||||||
|
syncthing container ◄──────────► syncthing (hub or other spokes)
|
||||||
|
file sync
|
||||||
|
|
||||||
rclone SFTP mount
|
rclone SFTP mount
|
||||||
~/mnt/<spoke-name>/
|
~/mnt/<spoke-name>/
|
||||||
```
|
```
|
||||||
@@ -75,11 +111,12 @@ Spokes initiate outbound SSH connections to the hub, creating reverse tunnels. T
|
|||||||
|
|
||||||
```
|
```
|
||||||
tinyboard/
|
tinyboard/
|
||||||
├── setup.sh ← entry point
|
├── setup.sh ← entry point for hub/spoke setup
|
||||||
├── setup-network.sh ← configure static IP on spoke before setup
|
├── syncthing.sh ← manage Syncthing devices and folders
|
||||||
├── spoke/
|
├── spoke/
|
||||||
|
│ ├── setup-network.sh ← configure static IP before setup
|
||||||
│ ├── setup-spoke.sh ← automated spoke setup
|
│ ├── setup-spoke.sh ← automated spoke setup
|
||||||
│ ├── compose.yaml ← Docker Compose for autossh + syncthing
|
│ ├── compose.yaml ← Docker Compose for autossh + Syncthing
|
||||||
│ ├── Dockerfile ← autossh container
|
│ ├── Dockerfile ← autossh container
|
||||||
│ └── armbian.not_logged_in_yet ← Armbian first-boot WiFi config template
|
│ └── armbian.not_logged_in_yet ← Armbian first-boot WiFi config template
|
||||||
└── hub/
|
└── hub/
|
||||||
@@ -94,22 +131,38 @@ tinyboard/
|
|||||||
|
|
||||||
### `setup.sh`
|
### `setup.sh`
|
||||||
Entry point. Presents a menu:
|
Entry point. Presents a menu:
|
||||||
1. Set up this device as a new spoke
|
- 0) Reconfigure network (static IP via netplan — SSH session will drop, reconnect)
|
||||||
2. Onboard a new spoke from the hub
|
- 1) Set up this device as a new spoke
|
||||||
3. Offboard a spoke from the hub
|
- 2) Onboard a new spoke from the hub
|
||||||
4. Set up this device as a new hub
|
- 3) Offboard a spoke from the hub
|
||||||
|
- 4) Set up this device as a new hub
|
||||||
|
|
||||||
### `setup-network.sh`
|
### `syncthing.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.
|
Interactive Syncthing management. Can be run on the hub or any spoke. Presents a menu:
|
||||||
|
- 0) Show This Device's ID
|
||||||
|
- 1) Pending Devices
|
||||||
|
- 2) List Devices
|
||||||
|
- 3) Add Device
|
||||||
|
- 4) Remove Device
|
||||||
|
- 5) List Folders
|
||||||
|
- 6) Add Folder
|
||||||
|
- 7) Remove Folder
|
||||||
|
- 8) Share Folder with Device
|
||||||
|
- 9) Unshare Folder from Device
|
||||||
|
|
||||||
|
Requires Docker and a running Syncthing container. Auto-discovers the container and API key.
|
||||||
|
|
||||||
|
### `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. Backs up the existing netplan config with a timestamp before writing. Automatically reverts if network connectivity is lost after applying.
|
||||||
|
|
||||||
### `spoke/setup-spoke.sh`
|
### `spoke/setup-spoke.sh`
|
||||||
Run as root on a new spoke. Handles:
|
Run as root on a new spoke. Handles:
|
||||||
- Package installation (apt/dnf/yum/pacman)
|
- Package installation (apt/dnf/yum/pacman)
|
||||||
- Docker installation
|
- Docker installation
|
||||||
- SSH server setup
|
- SSH server setup
|
||||||
- Hostname configuration
|
- Hostname configuration (validated for safe characters)
|
||||||
- SSH key generation and hub authorization
|
- SSH key generation and hub authorization
|
||||||
- Tunnel port auto-detection on the hub
|
- Tunnel port auto-detection on the hub (scans up to 100 ports)
|
||||||
- Docker image build and container start
|
- Docker image build and container start
|
||||||
- Optional password auth disable
|
- Optional password auth disable
|
||||||
|
|
||||||
@@ -118,7 +171,7 @@ Run as root on a new hub server. Handles:
|
|||||||
- Package installation (apt/dnf/yum/pacman)
|
- Package installation (apt/dnf/yum/pacman)
|
||||||
- rclone installation
|
- rclone installation
|
||||||
- Hub user creation
|
- Hub user creation
|
||||||
- SSH server configuration (GatewayPorts, AllowTcpForwarding)
|
- SSH server configuration (GatewayPorts, AllowTcpForwarding, ClientAliveInterval)
|
||||||
- FUSE configuration
|
- FUSE configuration
|
||||||
- rclone config directory setup
|
- rclone config directory setup
|
||||||
- Optional password auth disable
|
- Optional password auth disable
|
||||||
@@ -126,15 +179,25 @@ Run as root on a new hub server. Handles:
|
|||||||
### `hub/onboard-spoke.sh`
|
### `hub/onboard-spoke.sh`
|
||||||
Run as the hub user after a spoke connects. Handles:
|
Run as the hub user after a spoke connects. Handles:
|
||||||
- SSH key generation and deployment to spoke
|
- SSH key generation and deployment to spoke
|
||||||
- rclone remote configuration
|
- rclone remote configuration (with trailing newline guard)
|
||||||
|
- Optional union remote setup with configurable upstream access mode (none, `:ro`, `:nc`, `:writeback`)
|
||||||
- Spoke registration in `~/.config/tinyboard/spokes`
|
- Spoke registration in `~/.config/tinyboard/spokes`
|
||||||
- Per-spoke crontab entry for auto-mount on reboot
|
|
||||||
|
#### Union Remote
|
||||||
|
During onboarding, the user is optionally prompted to add the spoke to an rclone union remote for redundancy. If multiple spokes share the same files (via Syncthing), a union remote merges them into a single path so that if one spoke goes offline, the other can serve the files. Each upstream can be configured with an access mode:
|
||||||
|
- `none` — full read/write (default)
|
||||||
|
- `:ro` — read only
|
||||||
|
- `:nc` — no create (read/write existing files, no new files)
|
||||||
|
- `:writeback` — writeback cache
|
||||||
|
|
||||||
|
The union remote is automatically updated when a spoke is offboarded.
|
||||||
|
|
||||||
### `hub/offboard-spoke.sh`
|
### `hub/offboard-spoke.sh`
|
||||||
Run as the hub user to remove a spoke. Handles:
|
Run as the hub user to remove a spoke. Handles:
|
||||||
- Unmounting the spoke filesystem
|
- Unmounting the spoke filesystem
|
||||||
- Removing the crontab entry
|
- Crontab backup (timestamped to `~/.config/tinyboard/`) then entry removal
|
||||||
- Removing the rclone remote
|
- Removing the rclone remote
|
||||||
|
- Removing the spoke from any union remotes in `rclone.conf`
|
||||||
- Optionally removing the hub SSH key
|
- Optionally removing the hub SSH key
|
||||||
- Removing from the spoke registry
|
- Removing from the spoke registry
|
||||||
|
|
||||||
@@ -146,21 +209,35 @@ The hub maintains a registry of connected spokes at `~/.config/tinyboard/spokes`
|
|||||||
|
|
||||||
```
|
```
|
||||||
rocky 11113 /home/armbian/.ssh/armbian-rocky-202504 /home/armbian/mnt/rocky
|
rocky 11113 /home/armbian/.ssh/armbian-rocky-202504 /home/armbian/mnt/rocky
|
||||||
gouda 11114 /home/armbian/.ssh/armbian-gouda-202504 /home/armbian/mnt/gouda
|
grace 11114 /home/armbian/.ssh/armbian-grace-202504 /home/armbian/mnt/grace
|
||||||
```
|
```
|
||||||
|
|
||||||
Each spoke gets its own mount point at `~/mnt/<spoke-name>/` and a dedicated rclone crontab entry.
|
Each spoke gets its own mount point at `~/mnt/<spoke-name>/` and a dedicated rclone remote.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
Scripts that modify critical configs create timestamped backups before writing:
|
||||||
|
|
||||||
|
- **Netplan:** `/root/.config/tinyboard/netplan-backups/<filename>.<datetime>`
|
||||||
|
- **Crontab:** `~/.config/tinyboard/crontab.<datetime>`
|
||||||
|
|
||||||
|
Restore hints are printed to the terminal after each backup.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- All communication is over SSH tunnels — no spoke ports exposed to the internet
|
- All communication is over SSH tunnels — no spoke ports exposed to the internet
|
||||||
- SSH keys are used for all authentication
|
- SSH keys used for all authentication
|
||||||
- Scripts check and auto-fix unsafe file permissions (600/400)
|
- Scripts check and auto-fix unsafe file permissions (600/400)
|
||||||
- Password authentication can be disabled during setup
|
- Password authentication can be disabled during setup
|
||||||
- Scripts refuse to disable password auth if no authorized keys are present (lockout prevention)
|
- 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
|
- Netplan changes verified with a 30-second connectivity check before being made permanent
|
||||||
|
- Spoke names validated against `^[a-zA-Z0-9._-]+$` to prevent injection into hostnames and container names
|
||||||
|
- Syncthing admin UI bound to `127.0.0.1:8384` only (not exposed on the network)
|
||||||
|
- Syncthing config and certs stored in a Docker-managed named volume, separate from the data directory
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -169,11 +246,34 @@ Each spoke gets its own mount point at `~/mnt/<spoke-name>/` and a dedicated rcl
|
|||||||
Before committing, ensure the following do not contain real credentials:
|
Before committing, ensure the following do not contain real credentials:
|
||||||
|
|
||||||
- `spoke/armbian.not_logged_in_yet` — contains WiFi SSID, password, and user passwords
|
- `spoke/armbian.not_logged_in_yet` — contains WiFi SSID, password, and user passwords
|
||||||
|
- `spoke/compose.yaml` — may contain hub hostname after spoke setup runs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### `apt update` fails with beta.armbian.com error
|
||||||
|
|
||||||
|
On some Armbian images, a beta apt repository is enabled by default and may cause `apt update` to fail. Comment it out:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -r "beta.armbian" /etc/apt/sources.list /etc/apt/sources.list.d/
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Tunnel is up but rclone mount fails
|
||||||
|
|
||||||
|
Check that FUSE is configured on the hub (`user_allow_other` in `/etc/fuse.conf`) and that the hub user is in the `fuse` group. You may need to log out and back in for group membership to take effect.
|
||||||
|
|
||||||
|
### Syncthing container not found by syncthing.sh
|
||||||
|
|
||||||
|
The script looks for a running container with "syncthing" in its name. Run `docker ps` to confirm the container is running. If it stopped, run `docker compose up -d` from the `spoke/` directory.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
**Spoke:** Armbian (Debian-based), ARM device, Docker, autossh, git
|
**Spoke:** Armbian (or any Debian/Ubuntu/RHEL/Arch Linux), ARM device, Docker, autossh, git
|
||||||
|
|
||||||
**Hub:** Any Linux server (Debian/Ubuntu/RHEL/Arch), rclone, fuse, openssh-server
|
**Hub:** Any Linux server (Debian/Ubuntu/RHEL/Arch), rclone, fuse, openssh-server, python3
|
||||||
|
|||||||
214
health-check.sh
Executable file
214
health-check.sh
Executable file
@@ -0,0 +1,214 @@
|
|||||||
|
#!/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'
|
||||||
|
|
||||||
|
ok() { echo -e " ${GREEN}[OK]${NC} $*"; }
|
||||||
|
fail() { echo -e " ${RED}[FAIL]${NC} $*"; }
|
||||||
|
warn() { echo -e " ${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; }
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
HUB_USER="${SUDO_USER:-${USER}}"
|
||||||
|
if [ "$(id -u)" -eq 0 ] && [ -n "${SUDO_USER:-}" ]; then
|
||||||
|
HUB_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6)
|
||||||
|
else
|
||||||
|
HUB_HOME="$HOME"
|
||||||
|
fi
|
||||||
|
if [ "$(id -u)" -eq 0 ] && [ "$HUB_HOME" = "/root" ]; then
|
||||||
|
for u in armbian; do
|
||||||
|
CANDIDATE=$(getent passwd "$u" 2>/dev/null | cut -d: -f6)
|
||||||
|
if [ -f "${CANDIDATE}/.config/tinyboard/spokes" ]; then
|
||||||
|
HUB_HOME="$CANDIDATE"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
REGISTRY="${HUB_HOME}/.config/tinyboard/spokes"
|
||||||
|
COMPOSE="$SCRIPT_DIR/spoke/compose.yaml"
|
||||||
|
RCLONE_CONF="${HUB_HOME}/.config/rclone/rclone.conf"
|
||||||
|
|
||||||
|
IS_SPOKE=false
|
||||||
|
IS_HUB=false
|
||||||
|
|
||||||
|
if docker ps --format '{{.Names}}' 2>/dev/null | grep -qi autossh; then
|
||||||
|
IS_SPOKE=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$REGISTRY" ]; then
|
||||||
|
IS_HUB=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$IS_SPOKE" = false ] && [ "$IS_HUB" = false ]; then
|
||||||
|
echo -e "${YELLOW}Could not detect hub or spoke configuration. Is tinyboard set up?${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
check_common() {
|
||||||
|
header "System"
|
||||||
|
|
||||||
|
local ssh_svc=""
|
||||||
|
if systemctl list-unit-files ssh.service >/dev/null 2>&1; then
|
||||||
|
ssh_svc="ssh"
|
||||||
|
elif systemctl list-unit-files sshd.service >/dev/null 2>&1; then
|
||||||
|
ssh_svc="sshd"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$ssh_svc" ]; then
|
||||||
|
if systemctl is-active "$ssh_svc" >/dev/null 2>&1; then
|
||||||
|
ok "SSH server running ($ssh_svc)"
|
||||||
|
else
|
||||||
|
fail "SSH server not running ($ssh_svc)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Could not detect SSH service"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$IS_SPOKE" = true ]; then
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
ok "docker installed"
|
||||||
|
else
|
||||||
|
fail "docker not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if docker info >/dev/null 2>&1; then
|
||||||
|
ok "docker daemon running"
|
||||||
|
else
|
||||||
|
fail "docker daemon not running"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local st_container
|
||||||
|
st_container=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -i syncthing | head -1 || true)
|
||||||
|
if [ -n "$st_container" ]; then
|
||||||
|
ok "Syncthing container running ($st_container)"
|
||||||
|
if curl -sf http://127.0.0.1:8384 >/dev/null 2>&1; then
|
||||||
|
ok "Syncthing API reachable"
|
||||||
|
else
|
||||||
|
warn "Syncthing container running but API not reachable on :8384"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "No Syncthing container running"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_spoke() {
|
||||||
|
header "Spoke"
|
||||||
|
|
||||||
|
local autossh_container
|
||||||
|
autossh_container=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -i autossh | head -1 || true)
|
||||||
|
if [ -n "$autossh_container" ]; then
|
||||||
|
ok "autossh container running ($autossh_container)"
|
||||||
|
|
||||||
|
local logs
|
||||||
|
logs=$(docker logs "$autossh_container" 2>&1 | tail -20 || true)
|
||||||
|
if echo "$logs" | grep -q "remote port forwarding failed"; then
|
||||||
|
fail "Tunnel reports port forwarding failed — check hub authorized_keys"
|
||||||
|
else
|
||||||
|
ok "No tunnel errors in recent logs"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "No autossh container running"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$autossh_container" ]; then
|
||||||
|
local tunnel_port hub_host
|
||||||
|
tunnel_port=$(docker inspect "$autossh_container" 2>/dev/null | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
cmd = ' '.join(data[0].get('Config', {}).get('Cmd', []))
|
||||||
|
import re
|
||||||
|
m = re.search(r'-R (\d+):localhost', cmd)
|
||||||
|
print(m.group(1) if m else '')
|
||||||
|
" 2>/dev/null || true)
|
||||||
|
hub_host=$(docker inspect "$autossh_container" 2>/dev/null | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
cmd = ' '.join(data[0].get('Config', {}).get('Cmd', []))
|
||||||
|
import re
|
||||||
|
m = re.search(r'[a-zA-Z0-9._-]+@([a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+)', cmd)
|
||||||
|
print(m.group(1) if m else '')
|
||||||
|
" 2>/dev/null || true)
|
||||||
|
if [ -n "$tunnel_port" ] && [ -n "$hub_host" ]; then
|
||||||
|
ok "Tunnel configured: port $tunnel_port → $hub_host"
|
||||||
|
else
|
||||||
|
warn "Could not parse tunnel config from running container"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local st_data="/home/armbian/st/data"
|
||||||
|
if [ -d "$st_data" ]; then
|
||||||
|
ok "Syncthing data directory exists ($st_data)"
|
||||||
|
else
|
||||||
|
warn "Syncthing data directory not found ($st_data)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_hub() {
|
||||||
|
header "Hub"
|
||||||
|
|
||||||
|
local spoke_count
|
||||||
|
spoke_count=$(wc -l < "$REGISTRY" 2>/dev/null || echo 0)
|
||||||
|
ok "$spoke_count spoke(s) in registry"
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -n "$line" ] || continue
|
||||||
|
local spoke_name tunnel_port key_path mount_point
|
||||||
|
spoke_name=$(echo "$line" | awk '{print $1}')
|
||||||
|
tunnel_port=$(echo "$line" | awk '{print $2}')
|
||||||
|
key_path=$(echo "$line" | awk '{print $3}')
|
||||||
|
mount_point=$(echo "$line" | awk '{print $4}')
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e " ${CYAN}Spoke: $spoke_name${NC}"
|
||||||
|
|
||||||
|
if ss -tlnp 2>/dev/null | grep -q ":${tunnel_port}"; then
|
||||||
|
ok "Tunnel port $tunnel_port is listening"
|
||||||
|
else
|
||||||
|
fail "Tunnel port $tunnel_port not listening — is the spoke connected?"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$key_path" ]; then
|
||||||
|
ok "Hub key exists ($key_path)"
|
||||||
|
else
|
||||||
|
fail "Hub key missing ($key_path)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if mountpoint -q "$mount_point" 2>/dev/null; then
|
||||||
|
ok "Mounted at $mount_point"
|
||||||
|
else
|
||||||
|
fail "Not mounted at $mount_point"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -q "\[${spoke_name}-remote\]" "$RCLONE_CONF" 2>/dev/null; then
|
||||||
|
ok "rclone remote [${spoke_name}-remote] configured"
|
||||||
|
else
|
||||||
|
fail "rclone remote [${spoke_name}-remote] not found in rclone.conf"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if crontab -u "$(basename "$HUB_HOME")" -l 2>/dev/null | grep -q "${spoke_name}-remote:"; then
|
||||||
|
ok "Auto-mount crontab entry present"
|
||||||
|
else
|
||||||
|
warn "No auto-mount crontab entry for $spoke_name"
|
||||||
|
fi
|
||||||
|
|
||||||
|
done < "$REGISTRY"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
check_common
|
||||||
|
|
||||||
|
if [ "$IS_SPOKE" = true ]; then
|
||||||
|
check_spoke
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$IS_HUB" = true ]; then
|
||||||
|
check_hub
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
@@ -29,10 +29,23 @@ check_deps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if [ "$(id -u)" -eq 0 ]; then
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
die "Run as the hub user, not root."
|
die "Running as root — run as the hub user instead."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
check_deps rclone crontab fusermount python3
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
|
die "python3 not found — please install it and re-run"
|
||||||
|
fi
|
||||||
|
|
||||||
|
check_deps rclone crontab python3
|
||||||
|
|
||||||
|
FUSERMOUNT=""
|
||||||
|
if command -v fusermount3 >/dev/null 2>&1; then
|
||||||
|
FUSERMOUNT="fusermount3"
|
||||||
|
elif command -v fusermount >/dev/null 2>&1; then
|
||||||
|
FUSERMOUNT="fusermount"
|
||||||
|
else
|
||||||
|
die "Neither fusermount nor fusermount3 found"
|
||||||
|
fi
|
||||||
|
|
||||||
header "TinyBoard Hub — Offboard Spoke"
|
header "TinyBoard Hub — Offboard Spoke"
|
||||||
|
|
||||||
@@ -45,6 +58,7 @@ echo ""
|
|||||||
|
|
||||||
read -rp "Spoke name to offboard: " SPOKE_NAME
|
read -rp "Spoke name to offboard: " SPOKE_NAME
|
||||||
[ -n "$SPOKE_NAME" ] || die "Spoke name cannot be empty"
|
[ -n "$SPOKE_NAME" ] || die "Spoke name cannot be empty"
|
||||||
|
[[ "$SPOKE_NAME" =~ ^[a-zA-Z0-9._-]+$ ]] || die "Invalid spoke name — use only letters, numbers, dots, underscores, hyphens."
|
||||||
|
|
||||||
SPOKE_LINE=$(grep "^$SPOKE_NAME " "$REGISTRY" 2>/dev/null || true)
|
SPOKE_LINE=$(grep "^$SPOKE_NAME " "$REGISTRY" 2>/dev/null || true)
|
||||||
[ -n "$SPOKE_LINE" ] || die "Spoke '$SPOKE_NAME' not found in registry."
|
[ -n "$SPOKE_LINE" ] || die "Spoke '$SPOKE_NAME' not found in registry."
|
||||||
@@ -64,7 +78,7 @@ read -rp "Are you sure you want to offboard $SPOKE_NAME? [y/N]: " CONFIRM
|
|||||||
|
|
||||||
header "Unmounting Spoke"
|
header "Unmounting Spoke"
|
||||||
if mountpoint -q "$MOUNT_POINT" 2>/dev/null; then
|
if mountpoint -q "$MOUNT_POINT" 2>/dev/null; then
|
||||||
if fusermount -u "$MOUNT_POINT" 2>/dev/null; then
|
if $FUSERMOUNT -u "$MOUNT_POINT" 2>/dev/null; then
|
||||||
info "Unmounted $MOUNT_POINT."
|
info "Unmounted $MOUNT_POINT."
|
||||||
else
|
else
|
||||||
warn "Could not unmount $MOUNT_POINT — may already be unmounted."
|
warn "Could not unmount $MOUNT_POINT — may already be unmounted."
|
||||||
@@ -75,15 +89,24 @@ fi
|
|||||||
|
|
||||||
header "Removing Crontab Entry"
|
header "Removing Crontab Entry"
|
||||||
EXISTING=$(crontab -l 2>/dev/null || true)
|
EXISTING=$(crontab -l 2>/dev/null || true)
|
||||||
UPDATED=$(echo "$EXISTING" | grep -v "${SPOKE_NAME}-remote:" || true)
|
if [ -z "$EXISTING" ]; then
|
||||||
if [ "$EXISTING" = "$UPDATED" ]; then
|
|
||||||
warn "No crontab entry found for $SPOKE_NAME."
|
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
|
else
|
||||||
echo "$UPDATED" | crontab -
|
CRONTAB_BACKUP="${HOME}/.config/tinyboard/crontab.$(date +%Y%m%d%H%M%S)"
|
||||||
info "Crontab entry for $SPOKE_NAME removed."
|
mkdir -p "$(dirname "$CRONTAB_BACKUP")"
|
||||||
|
echo "$EXISTING" > "$CRONTAB_BACKUP"
|
||||||
|
info "Crontab backed up to $CRONTAB_BACKUP"
|
||||||
|
info "To restore: crontab $CRONTAB_BACKUP"
|
||||||
|
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
|
||||||
|
info "Crontab entry for $SPOKE_NAME removed (crontab now empty)."
|
||||||
|
else
|
||||||
|
echo "$UPDATED" | crontab -
|
||||||
|
info "Crontab entry for $SPOKE_NAME removed."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
header "Removing rclone Remote"
|
header "Removing rclone Remote"
|
||||||
@@ -121,7 +144,8 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
header "Removing from Registry"
|
header "Removing from Registry"
|
||||||
(grep -v "^$SPOKE_NAME " "$REGISTRY" || true) > "${REGISTRY}.tmp" && mv "${REGISTRY}.tmp" "$REGISTRY"
|
grep -v "^${SPOKE_NAME} " "$REGISTRY" > "${REGISTRY}.tmp" 2>/dev/null || true
|
||||||
|
mv "${REGISTRY}.tmp" "$REGISTRY"
|
||||||
info "$SPOKE_NAME removed from registry."
|
info "$SPOKE_NAME removed from registry."
|
||||||
|
|
||||||
header "Offboarding Complete"
|
header "Offboarding Complete"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
RCLONE_CONF="${HOME}/.config/rclone/rclone.conf"
|
RCLONE_CONF="${HOME}/.config/rclone/rclone.conf"
|
||||||
SSH_DIR="${HOME}/.ssh"
|
SSH_DIR="${HOME}/.ssh"
|
||||||
|
REGISTRY="${HOME}/.config/tinyboard/spokes"
|
||||||
|
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
@@ -10,44 +11,51 @@ YELLOW='\033[1;33m'
|
|||||||
CYAN='\033[0;36m'
|
CYAN='\033[0;36m'
|
||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||||
die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
|
die() {
|
||||||
header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; }
|
echo -e "${RED}[ERROR]${NC} $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
header() {
|
||||||
|
echo -e "\n${CYAN}══════════════════════════════════════════${NC}"
|
||||||
|
echo -e "${CYAN} $*${NC}"
|
||||||
|
echo -e "${CYAN}══════════════════════════════════════════${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
check_deps() {
|
check_deps() {
|
||||||
local missing=()
|
local missing=()
|
||||||
for cmd in "$@"; do
|
for cmd in "$@"; do
|
||||||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||||||
missing+=("$cmd")
|
missing+=("$cmd")
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ ${#missing[@]} -gt 0 ]; then
|
|
||||||
die "Missing required dependencies: ${missing[*]}"
|
|
||||||
fi
|
fi
|
||||||
|
done
|
||||||
|
if [ ${#missing[@]} -gt 0 ]; then
|
||||||
|
die "Missing required dependencies: ${missing[*]}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
retry_or_abort() {
|
retry_or_abort() {
|
||||||
local test_cmd="$1"
|
local test_cmd="$1"
|
||||||
local fail_msg="$2"
|
local fail_msg="$2"
|
||||||
while true; do
|
while true; do
|
||||||
if eval "$test_cmd" 2>/dev/null; then
|
if eval "$test_cmd" 2>/dev/null; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
warn "$fail_msg"
|
warn "$fail_msg"
|
||||||
echo -e " ${YELLOW}[R]${NC} Retry ${RED}[A]${NC} Abort"
|
echo -e " ${YELLOW}[R]${NC} Retry ${RED}[A]${NC} Abort"
|
||||||
read -rp "Choice: " CHOICE
|
read -rp "Choice: " CHOICE
|
||||||
case "${CHOICE,,}" in
|
case "${CHOICE,,}" in
|
||||||
r) info "Retrying..." ;;
|
r) info "Retrying..." ;;
|
||||||
a) die "Aborted." ;;
|
a) die "Aborted." ;;
|
||||||
*) warn "Press R to retry or A to abort." ;;
|
*) warn "Press R to retry or A to abort." ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
if [ "$(id -u)" -eq 0 ]; then
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
die "Running as root — keys will be written to /root/.ssh. Run as the hub user instead."
|
die "Running as root — keys will be written to /root/.ssh. Run as the hub user instead."
|
||||||
fi
|
fi
|
||||||
mkdir -p "$SSH_DIR"
|
mkdir -p "$SSH_DIR"
|
||||||
touch "$SSH_DIR/known_hosts"
|
touch "$SSH_DIR/known_hosts"
|
||||||
@@ -73,67 +81,190 @@ KEY_PATH="$SSH_DIR/$KEY_NAME"
|
|||||||
mkdir -p "$(dirname "$RCLONE_CONF")"
|
mkdir -p "$(dirname "$RCLONE_CONF")"
|
||||||
|
|
||||||
header "Checking Tunnel"
|
header "Checking Tunnel"
|
||||||
|
info "Verifying spoke SSH service is reachable on port $TUNNEL_PORT..."
|
||||||
|
if ! timeout 5 bash -c "cat < /dev/null > /dev/tcp/localhost/$TUNNEL_PORT" 2>/dev/null; then
|
||||||
|
die "Cannot connect to port $TUNNEL_PORT on localhost — is the tunnel up?"
|
||||||
|
fi
|
||||||
info "Scanning spoke host key..."
|
info "Scanning spoke host key..."
|
||||||
KEYSCAN=$(ssh-keyscan -p "$TUNNEL_PORT" -H localhost 2>/dev/null)
|
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?"
|
[ -n "$KEYSCAN" ] || die "Spoke not reachable on port $TUNNEL_PORT — is the tunnel up?"
|
||||||
echo "$KEYSCAN" >> "$SSH_DIR/known_hosts"
|
while IFS= read -r KEYSCAN_LINE; do
|
||||||
|
KEYSCAN_KEY=$(echo "$KEYSCAN_LINE" | awk '{print $2, $3}')
|
||||||
|
if ! grep -qF "$KEYSCAN_KEY" "$SSH_DIR/known_hosts" 2>/dev/null; then
|
||||||
|
echo "$KEYSCAN_LINE" >>"$SSH_DIR/known_hosts"
|
||||||
|
fi
|
||||||
|
done <<<"$KEYSCAN"
|
||||||
|
|
||||||
info "Verifying spoke is reachable on port $TUNNEL_PORT..."
|
header "Generating Hub-to-Spoke Access Key"
|
||||||
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
|
if [ -f "$KEY_PATH" ]; then
|
||||||
warn "Key $KEY_PATH already exists, skipping generation."
|
warn "Key $KEY_PATH already exists, skipping generation."
|
||||||
else
|
else
|
||||||
ssh-keygen -t ed25519 -f "$KEY_PATH" -N ""
|
ssh-keygen -t ed25519 -f "$KEY_PATH" -N "" -C "$KEY_NAME"
|
||||||
info "Key generated: $KEY_PATH"
|
info "Key generated: $KEY_PATH"
|
||||||
|
fi
|
||||||
|
chmod 600 "$KEY_PATH"
|
||||||
|
info "Permissions set: $KEY_PATH is 600"
|
||||||
|
|
||||||
|
header "Installing Hub-to-Spoke Access Key on Spoke"
|
||||||
|
info "Copying hub public key to spoke's authorized_keys so the hub can SSH in for rclone..."
|
||||||
|
info "(You will be prompted for the $SPOKE_USER password on the spoke)"
|
||||||
|
if ssh-copy-id -i "$KEY_PATH.pub" -p "$TUNNEL_PORT" "$SPOKE_USER"@localhost; then
|
||||||
|
info "Key copied."
|
||||||
|
else
|
||||||
|
warn "ssh-copy-id failed — password auth may be disabled on the spoke."
|
||||||
|
warn "Manually append the hub public key to the spoke's authorized_keys:"
|
||||||
|
echo ""
|
||||||
|
echo " cat $KEY_PATH.pub"
|
||||||
|
echo " Then on the spoke, append the output to:"
|
||||||
|
echo " /home/$SPOKE_USER/.ssh/authorized_keys"
|
||||||
|
echo ""
|
||||||
|
read -rp "Press ENTER once the key has been added to the spoke..."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
header "Copying Hub Key to Spoke"
|
header "Testing Hub-to-Spoke Key Auth"
|
||||||
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 \
|
retry_or_abort \
|
||||||
"ssh -i \"$KEY_PATH\" -o BatchMode=yes -o ConnectTimeout=10 -p \"$TUNNEL_PORT\" \"$SPOKE_USER\"@localhost exit" \
|
"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."
|
"Key auth failed. Check authorized_keys on the spoke."
|
||||||
info "Key auth to spoke successful."
|
info "Key auth to spoke successful."
|
||||||
|
|
||||||
header "Adding rclone Remote"
|
header "Adding rclone Remote"
|
||||||
if grep -q "\[${SPOKE_NAME}-remote\]" "$RCLONE_CONF" 2>/dev/null; then
|
if grep -q "\[${SPOKE_NAME}-remote\]" "$RCLONE_CONF" 2>/dev/null; then
|
||||||
warn "Remote [${SPOKE_NAME}-remote] already exists in $RCLONE_CONF, skipping."
|
warn "Remote [${SPOKE_NAME}-remote] already exists in $RCLONE_CONF, skipping."
|
||||||
else
|
else
|
||||||
cat >> "$RCLONE_CONF" <<EOF
|
[ -s "$RCLONE_CONF" ] && tail -c1 "$RCLONE_CONF" | grep -qv $'\n' && echo "" >>"$RCLONE_CONF"
|
||||||
|
python3 - "$RCLONE_CONF" "$SPOKE_NAME" "$TUNNEL_PORT" "$KEY_PATH" <<'PYEOF'
|
||||||
|
import sys
|
||||||
|
path, name, port, key = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
|
||||||
|
with open(path, 'a') as f:
|
||||||
|
f.write(f"\n[{name}-remote]\ntype = sftp\nhost = localhost\nport = {port}\nkey_file = {key}\nshell_type = unix\nmd5sum_command = md5sum\nsha1sum_command = sha1sum\n")
|
||||||
|
PYEOF
|
||||||
|
info "Remote [${SPOKE_NAME}-remote] added to $RCLONE_CONF."
|
||||||
|
fi
|
||||||
|
|
||||||
[${SPOKE_NAME}-remote]
|
header "Union Remote (optional)"
|
||||||
type = sftp
|
read -rp "Add this spoke to a union remote for redundancy? [y/N]: " ADD_UNION
|
||||||
host = localhost
|
ADD_UNION="${ADD_UNION:-n}"
|
||||||
port = $TUNNEL_PORT
|
if [[ "${ADD_UNION,,}" == "y" ]]; then
|
||||||
key_file = $KEY_PATH
|
read -rp "Union remote name [shared-union]: " UNION_NAME
|
||||||
shell_type = unix
|
UNION_NAME="${UNION_NAME:-shared-union}"
|
||||||
md5sum_command = md5sum
|
read -rp "Subfolder path on the spoke being onboarded (e.g. books, leave blank for root): " UNION_PATH
|
||||||
sha1sum_command = sha1sum
|
echo ""
|
||||||
EOF
|
echo "Upstream access mode for this spoke:"
|
||||||
info "Remote [${SPOKE_NAME}-remote] added to $RCLONE_CONF."
|
echo " 0) None - full read/write (default)"
|
||||||
|
echo " 1) :ro - read only"
|
||||||
|
echo " 2) :nc - no create (read/write existing, no new files)"
|
||||||
|
echo " 3) :writeback - writeback cache"
|
||||||
|
echo ""
|
||||||
|
read -rp "Choose [0-3]: " UNION_MODE
|
||||||
|
UNION_MODE="${UNION_MODE:-0}"
|
||||||
|
case "$UNION_MODE" in
|
||||||
|
0) UPSTREAM_TAG="" ;;
|
||||||
|
1) UPSTREAM_TAG=":ro" ;;
|
||||||
|
2) UPSTREAM_TAG=":nc" ;;
|
||||||
|
3) UPSTREAM_TAG=":writeback" ;;
|
||||||
|
*)
|
||||||
|
warn "Invalid choice, defaulting to full read/write."
|
||||||
|
UPSTREAM_TAG=""
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [ -n "$UNION_PATH" ]; then
|
||||||
|
UPSTREAM="${SPOKE_NAME}-remote:${UNION_PATH}${UPSTREAM_TAG}"
|
||||||
|
else
|
||||||
|
UPSTREAM="${SPOKE_NAME}-remote:${UPSTREAM_TAG}"
|
||||||
|
fi
|
||||||
|
if grep -q "^\[${UNION_NAME}\]" "$RCLONE_CONF" 2>/dev/null; then
|
||||||
|
ALREADY=$(
|
||||||
|
python3 - "$RCLONE_CONF" "$UNION_NAME" "${SPOKE_NAME}-remote:" <<'PYEOF2'
|
||||||
|
import sys
|
||||||
|
path, section, prefix = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
with open(path) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
in_section = False
|
||||||
|
for line in lines:
|
||||||
|
if line.strip() == f"[{section}]":
|
||||||
|
in_section = True
|
||||||
|
elif line.strip().startswith("["):
|
||||||
|
in_section = False
|
||||||
|
if in_section and line.startswith("upstreams =") and prefix in line:
|
||||||
|
print("yes")
|
||||||
|
sys.exit(0)
|
||||||
|
print("no")
|
||||||
|
PYEOF2
|
||||||
|
)
|
||||||
|
if [ "$ALREADY" = "yes" ]; then
|
||||||
|
warn "Upstream for ${SPOKE_NAME}-remote already in union remote [${UNION_NAME}], skipping."
|
||||||
|
else
|
||||||
|
python3 - "$RCLONE_CONF" "$UNION_NAME" "$UPSTREAM" <<'PYEOF2'
|
||||||
|
import sys
|
||||||
|
path, section, upstream = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||||
|
with open(path) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
out = []
|
||||||
|
in_section = False
|
||||||
|
for line in lines:
|
||||||
|
if line.strip() == f"[{section}]":
|
||||||
|
in_section = True
|
||||||
|
elif line.strip().startswith("["):
|
||||||
|
in_section = False
|
||||||
|
if in_section and line.startswith("upstreams ="):
|
||||||
|
line = line.rstrip() + " " + upstream + "\n"
|
||||||
|
out.append(line)
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.writelines(out)
|
||||||
|
PYEOF2
|
||||||
|
info "Added '$UPSTREAM' to union remote [${UNION_NAME}]."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
[ -s "$RCLONE_CONF" ] && tail -c1 "$RCLONE_CONF" | grep -qv $'\n' && echo "" >>"$RCLONE_CONF"
|
||||||
|
printf '\n[%s]\ntype = union\nupstreams = %s\n' "$UNION_NAME" "$UPSTREAM" >>"$RCLONE_CONF"
|
||||||
|
info "Union remote [${UNION_NAME}] created with upstream '$UPSTREAM'."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
header "Testing rclone Connection"
|
header "Testing rclone Connection"
|
||||||
if rclone lsd "${SPOKE_NAME}-remote:" --config "$RCLONE_CONF" 2>/dev/null; then
|
if rclone lsd "${SPOKE_NAME}-remote:" --config "$RCLONE_CONF" 2>/dev/null; then
|
||||||
info "rclone connection to $SPOKE_NAME successful."
|
info "rclone connection to $SPOKE_NAME successful."
|
||||||
else
|
else
|
||||||
warn "rclone test failed. Check the remote config in $RCLONE_CONF."
|
warn "rclone test failed. Check the remote config in $RCLONE_CONF."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
header "Registering Spoke"
|
||||||
|
mkdir -p "$(dirname "$REGISTRY")"
|
||||||
|
MOUNT_POINT="${HOME}/mnt/${SPOKE_NAME}"
|
||||||
|
mkdir -p "$MOUNT_POINT"
|
||||||
|
if grep -q "^${SPOKE_NAME} " "$REGISTRY" 2>/dev/null; then
|
||||||
|
warn "$SPOKE_NAME already in registry, updating."
|
||||||
|
grep -v "^${SPOKE_NAME} " "$REGISTRY" >"${REGISTRY}.tmp" 2>/dev/null || true
|
||||||
|
mv "${REGISTRY}.tmp" "$REGISTRY"
|
||||||
|
fi
|
||||||
|
echo "${SPOKE_NAME} ${TUNNEL_PORT} ${KEY_PATH} ${MOUNT_POINT}" >>"$REGISTRY"
|
||||||
|
info "$SPOKE_NAME registered."
|
||||||
|
|
||||||
|
header "Setting Up Auto-Mount"
|
||||||
|
#MOUNT_CMD="rclone mount ${SPOKE_NAME}-remote: ${MOUNT_POINT} --config ${HOME}/.config/rclone/rclone.conf --vfs-cache-mode writes --allow-other --daemon"
|
||||||
|
MOUNT_CMD="sleep 55 && rclone mount ${SPOKE_NAME}-remote: ${MOUNT_POINT} --config ${HOME}/.config/rclone/rclone.conf --vfs-cache-mode full --vfs-cache-max-size 2G --vfs-read-ahead 256M --allow-other --daemon"
|
||||||
|
CRON_ENTRY="@reboot ${MOUNT_CMD}"
|
||||||
|
EXISTING=$(crontab -l 2>/dev/null || true)
|
||||||
|
if echo "$EXISTING" | grep -qF "${SPOKE_NAME}-remote:"; then
|
||||||
|
warn "Crontab entry for ${SPOKE_NAME}-remote already exists, skipping."
|
||||||
|
else
|
||||||
|
CRONTAB_BACKUP="${HOME}/.config/tinyboard/crontab.$(date +%Y%m%d%H%M%S)"
|
||||||
|
mkdir -p "$(dirname "$CRONTAB_BACKUP")"
|
||||||
|
echo "$EXISTING" >"$CRONTAB_BACKUP"
|
||||||
|
info "Crontab backed up to $CRONTAB_BACKUP"
|
||||||
|
{
|
||||||
|
echo "$EXISTING"
|
||||||
|
echo "$CRON_ENTRY"
|
||||||
|
} | crontab -
|
||||||
|
info "Auto-mount crontab entry added for ${SPOKE_NAME}."
|
||||||
|
fi
|
||||||
|
info "Starting mount now..."
|
||||||
|
mkdir -p "$MOUNT_POINT"
|
||||||
|
eval "$MOUNT_CMD" 2>/dev/null && info "Mounted ${SPOKE_NAME} at ${MOUNT_POINT}." || warn "Mount failed — will retry on next reboot."
|
||||||
|
|
||||||
header "Onboarding Complete"
|
header "Onboarding Complete"
|
||||||
echo -e " Spoke: ${GREEN}$SPOKE_NAME${NC}"
|
echo -e " Spoke: ${GREEN}$SPOKE_NAME${NC}"
|
||||||
echo -e " Port: ${GREEN}$TUNNEL_PORT${NC}"
|
echo -e " Port: ${GREEN}$TUNNEL_PORT${NC}"
|
||||||
echo -e " Hub key: ${GREEN}$KEY_PATH${NC}"
|
echo -e " Hub key: ${GREEN}$KEY_PATH${NC}"
|
||||||
echo -e " rclone: ${GREEN}${SPOKE_NAME}-remote${NC}"
|
echo -e " rclone: ${GREEN}${SPOKE_NAME}-remote${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${YELLOW}To mount this spoke:${NC}"
|
|
||||||
echo " RCLONE_REMOTE=${SPOKE_NAME}-remote hubspoke-helper.sh hub start"
|
|
||||||
echo ""
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ check_permissions() {
|
|||||||
|
|
||||||
[ "$(id -u)" -eq 0 ] || die "Run as root"
|
[ "$(id -u)" -eq 0 ] || die "Run as root"
|
||||||
|
|
||||||
check_deps ssh ssh-keygen systemctl useradd groupadd
|
check_deps ssh ssh-keygen systemctl useradd groupadd file
|
||||||
|
|
||||||
header "TinyBoard Hub Setup"
|
header "TinyBoard Hub Setup"
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ if command -v apt-get >/dev/null 2>&1; then
|
|||||||
PKG_MANAGER="apt"
|
PKG_MANAGER="apt"
|
||||||
PKG_INSTALL="apt-get install -y -q"
|
PKG_INSTALL="apt-get install -y -q"
|
||||||
OPENSSH_PKG="openssh-server"
|
OPENSSH_PKG="openssh-server"
|
||||||
FUSE_PKG="fuse"
|
FUSE_PKG="fuse3"
|
||||||
info "Detected: apt (Debian/Ubuntu)"
|
info "Detected: apt (Debian/Ubuntu)"
|
||||||
apt-get update -q
|
apt-get update -q
|
||||||
elif command -v dnf >/dev/null 2>&1; then
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
@@ -118,21 +118,6 @@ else
|
|||||||
groupadd -g 1000 "$HUB_USER" 2>/dev/null || true
|
groupadd -g 1000 "$HUB_USER" 2>/dev/null || true
|
||||||
useradd -m -u 1000 -g 1000 -s /bin/bash "$HUB_USER"
|
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."
|
info "$HUB_USER user created."
|
||||||
echo ""
|
echo ""
|
||||||
warn "Set a password for the $HUB_USER user:"
|
warn "Set a password for the $HUB_USER user:"
|
||||||
@@ -142,7 +127,7 @@ fi
|
|||||||
ARMBIAN_HOME="/home/$HUB_USER"
|
ARMBIAN_HOME="/home/$HUB_USER"
|
||||||
SSH_DIR="$ARMBIAN_HOME/.ssh"
|
SSH_DIR="$ARMBIAN_HOME/.ssh"
|
||||||
mkdir -p "$SSH_DIR"
|
mkdir -p "$SSH_DIR"
|
||||||
touch "$SSH_DIR/authorized_keys"
|
[ -f "$SSH_DIR/authorized_keys" ] || touch "$SSH_DIR/authorized_keys"
|
||||||
chown -R "$HUB_USER":"$HUB_USER" "$SSH_DIR"
|
chown -R "$HUB_USER":"$HUB_USER" "$SSH_DIR"
|
||||||
chmod 700 "$SSH_DIR"
|
chmod 700 "$SSH_DIR"
|
||||||
chmod 600 "$SSH_DIR/authorized_keys"
|
chmod 600 "$SSH_DIR/authorized_keys"
|
||||||
@@ -151,39 +136,44 @@ header "SSH Server Configuration"
|
|||||||
SSHD_CONF="/etc/ssh/sshd_config"
|
SSHD_CONF="/etc/ssh/sshd_config"
|
||||||
[ -f "$SSHD_CONF" ] || die "sshd_config not found at $SSHD_CONF"
|
[ -f "$SSHD_CONF" ] || die "sshd_config not found at $SSHD_CONF"
|
||||||
|
|
||||||
for DIRECTIVE in "GatewayPorts yes" "AllowTcpForwarding yes"; do
|
for DIRECTIVE in "GatewayPorts no" "AllowTcpForwarding yes" "ClientAliveInterval 60" "ClientAliveCountMax 3"; do
|
||||||
KEY="${DIRECTIVE%% *}"
|
KEY="${DIRECTIVE%% *}"
|
||||||
if grep -q "^$KEY" "$SSHD_CONF"; then
|
if grep -q "^$KEY" "$SSHD_CONF"; then
|
||||||
sed -i "s/^$KEY.*/$DIRECTIVE/" "$SSHD_CONF"
|
sed -i "s|^$KEY.*|$DIRECTIVE|" "$SSHD_CONF"
|
||||||
else
|
else
|
||||||
echo "$DIRECTIVE" >> "$SSHD_CONF"
|
echo "$DIRECTIVE" >> "$SSHD_CONF"
|
||||||
fi
|
fi
|
||||||
info "$DIRECTIVE set."
|
info "$DIRECTIVE set."
|
||||||
done
|
done
|
||||||
|
|
||||||
if systemctl enable ssh 2>/dev/null; then
|
SSH_SVC=""
|
||||||
systemctl restart ssh
|
if systemctl list-unit-files ssh.service >/dev/null 2>&1; then
|
||||||
elif systemctl enable sshd 2>/dev/null; then
|
SSH_SVC="ssh"
|
||||||
systemctl restart sshd
|
elif systemctl list-unit-files sshd.service >/dev/null 2>&1; then
|
||||||
|
SSH_SVC="sshd"
|
||||||
|
fi
|
||||||
|
if [ -n "$SSH_SVC" ]; then
|
||||||
|
systemctl enable "$SSH_SVC" 2>/dev/null || true
|
||||||
|
systemctl restart "$SSH_SVC"
|
||||||
|
info "SSH server restarted."
|
||||||
else
|
else
|
||||||
warn "Could not enable/restart SSH service — please start it manually."
|
warn "Could not enable/restart SSH service — please start it manually."
|
||||||
fi
|
fi
|
||||||
info "SSH server restarted."
|
|
||||||
|
|
||||||
header "Password Authentication"
|
header "Password Authentication"
|
||||||
read -rp "Disable password auth for $HUB_USER and use keys only? [Y/n]: " DISABLE_PASS
|
read -rp "Disable password auth for $HUB_USER and use keys only? [y/N]: " DISABLE_PASS
|
||||||
DISABLE_PASS="${DISABLE_PASS:-y}"
|
DISABLE_PASS="${DISABLE_PASS:-n}"
|
||||||
if [[ "${DISABLE_PASS,,}" == "y" ]]; then
|
if [[ "${DISABLE_PASS,,}" == "y" ]]; then
|
||||||
if [ ! -s "$SSH_DIR/authorized_keys" ]; then
|
if [ ! -s "$SSH_DIR/authorized_keys" ]; then
|
||||||
warn "No keys found in $SSH_DIR/authorized_keys — skipping password auth disable to avoid lockout."
|
warn "No keys found in $SSH_DIR/authorized_keys — skipping password auth disable to avoid lockout."
|
||||||
else
|
else
|
||||||
if grep -q "^PasswordAuthentication" "$SSHD_CONF"; then
|
if grep -q "^PasswordAuthentication" "$SSHD_CONF"; then
|
||||||
sed -i "s/^PasswordAuthentication.*/PasswordAuthentication no/" "$SSHD_CONF"
|
sed -i "s|^PasswordAuthentication.*|PasswordAuthentication no|" "$SSHD_CONF"
|
||||||
else
|
else
|
||||||
echo "PasswordAuthentication no" >> "$SSHD_CONF"
|
echo "PasswordAuthentication no" >> "$SSHD_CONF"
|
||||||
fi
|
fi
|
||||||
if grep -q "^PubkeyAuthentication" "$SSHD_CONF"; then
|
if grep -q "^PubkeyAuthentication" "$SSHD_CONF"; then
|
||||||
sed -i "s/^PubkeyAuthentication.*/PubkeyAuthentication yes/" "$SSHD_CONF"
|
sed -i "s|^PubkeyAuthentication.*|PubkeyAuthentication yes|" "$SSHD_CONF"
|
||||||
else
|
else
|
||||||
echo "PubkeyAuthentication yes" >> "$SSHD_CONF"
|
echo "PubkeyAuthentication yes" >> "$SSHD_CONF"
|
||||||
fi
|
fi
|
||||||
@@ -193,9 +183,7 @@ if [[ "${DISABLE_PASS,,}" == "y" ]]; then
|
|||||||
warn "If you are connected via SSH, your session may drop."
|
warn "If you are connected via SSH, your session may drop."
|
||||||
warn "Make sure you can reconnect using your key before continuing."
|
warn "Make sure you can reconnect using your key before continuing."
|
||||||
read -rp "Press ENTER to restart SSH or CTRL+C to abort..."
|
read -rp "Press ENTER to restart SSH or CTRL+C to abort..."
|
||||||
if systemctl restart ssh 2>/dev/null; then
|
if [ -n "$SSH_SVC" ] && systemctl restart "$SSH_SVC" 2>/dev/null; then
|
||||||
info "SSH restarted."
|
|
||||||
elif systemctl restart sshd 2>/dev/null; then
|
|
||||||
info "SSH restarted."
|
info "SSH restarted."
|
||||||
else
|
else
|
||||||
warn "Could not restart SSH — please restart it manually."
|
warn "Could not restart SSH — please restart it manually."
|
||||||
@@ -243,6 +231,16 @@ header "Permission Checks"
|
|||||||
info "Checking SSH directory permissions..."
|
info "Checking SSH directory permissions..."
|
||||||
check_permissions "$SSH_DIR/authorized_keys" "authorized_keys"
|
check_permissions "$SSH_DIR/authorized_keys" "authorized_keys"
|
||||||
check_permissions "$RCLONE_CONF" "rclone.conf"
|
check_permissions "$RCLONE_CONF" "rclone.conf"
|
||||||
|
for PRIVKEY in "$SSH_DIR"/*; do
|
||||||
|
[ -e "$PRIVKEY" ] || continue
|
||||||
|
[[ "$PRIVKEY" == *.pub ]] && continue
|
||||||
|
[ -f "$PRIVKEY" ] || continue
|
||||||
|
case "$(file -b "$PRIVKEY" 2>/dev/null)" in
|
||||||
|
*"private key"*|*"PRIVATE KEY"*)
|
||||||
|
check_permissions "$PRIVKEY" "SSH private key $(basename "$PRIVKEY")"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
header "Mount Point Setup"
|
header "Mount Point Setup"
|
||||||
read -rp "Mount point for spoke filesystems [/mnt/hub]: " MOUNT_POINT
|
read -rp "Mount point for spoke filesystems [/mnt/hub]: " MOUNT_POINT
|
||||||
@@ -253,7 +251,7 @@ info "Mount point created at $MOUNT_POINT."
|
|||||||
|
|
||||||
header "Hub Setup Complete"
|
header "Hub Setup Complete"
|
||||||
echo -e " Hub user: ${GREEN}$HUB_USER${NC}"
|
echo -e " Hub user: ${GREEN}$HUB_USER${NC}"
|
||||||
echo -e " SSH config: ${GREEN}GatewayPorts yes, AllowTcpForwarding yes${NC}"
|
echo -e " SSH config: ${GREEN}GatewayPorts no, AllowTcpForwarding yes, ClientAliveInterval 60${NC}"
|
||||||
echo -e " FUSE: ${GREEN}user_allow_other enabled${NC}"
|
echo -e " FUSE: ${GREEN}user_allow_other enabled${NC}"
|
||||||
echo -e " rclone config: ${GREEN}$RCLONE_CONF${NC}"
|
echo -e " rclone config: ${GREEN}$RCLONE_CONF${NC}"
|
||||||
echo -e " Mount point: ${GREEN}$MOUNT_POINT${NC}"
|
echo -e " Mount point: ${GREEN}$MOUNT_POINT${NC}"
|
||||||
|
|||||||
8
setup.sh
8
setup.sh
@@ -15,14 +15,20 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
|
|
||||||
header "TinyBoard Setup"
|
header "TinyBoard Setup"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo " 0) Reconfigure network"
|
||||||
echo " 1) Set up this device as a new spoke"
|
echo " 1) Set up this device as a new spoke"
|
||||||
echo " 2) Onboard a new spoke from the hub"
|
echo " 2) Onboard a new spoke from the hub"
|
||||||
echo " 3) Offboard a spoke from the hub"
|
echo " 3) Offboard a spoke from the hub"
|
||||||
echo " 4) Set up this device as a new hub"
|
echo " 4) Set up this device as a new hub"
|
||||||
echo ""
|
echo ""
|
||||||
read -rp "Choose [1/2/3/4]: " CHOICE
|
read -rp "Choose [0/1/2/3/4]: " CHOICE
|
||||||
|
|
||||||
case "$CHOICE" in
|
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)
|
1)
|
||||||
[ "$(id -u)" -eq 0 ] || die "Spoke setup must be run as root"
|
[ "$(id -u)" -eq 0 ] || die "Spoke setup must be run as root"
|
||||||
info "Starting spoke setup..."
|
info "Starting spoke setup..."
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y autossh openssh-client && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y autossh openssh-client && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN echo "PermitRootLogin no" >> /etc/ssh/sshd_config
|
|
||||||
RUN echo "PasswordAuthentication no" >> /etc/ssh/sshd_config
|
|
||||||
RUN echo "Subsystem sftp internal-sftp" >> /etc/ssh/sshd_config
|
|
||||||
|
|
||||||
ARG UID=1000
|
ARG UID=1000
|
||||||
ARG GID=1000
|
ARG GID=1000
|
||||||
RUN groupadd -g ${GID} armbian && useradd -m -u ${UID} -g armbian armbian
|
RUN groupadd -g ${GID} armbian && useradd -m -u ${UID} -g armbian armbian
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
services:
|
services:
|
||||||
autossh:
|
autossh:
|
||||||
image: spoke-autossh
|
image: spoke-autossh
|
||||||
@@ -7,26 +6,27 @@ services:
|
|||||||
network_mode: host
|
network_mode: host
|
||||||
environment:
|
environment:
|
||||||
- AUTOSSH_GATETIME=0
|
- AUTOSSH_GATETIME=0
|
||||||
# @@@@@@@@@ BEWARE THE REVERSE TUNNEL PORT AND KEYS WHEN RUNNING THIS ON A NEW SPOKE @@@@@@@@@@
|
|
||||||
command: >
|
command: >
|
||||||
autossh -M 0 -NT
|
autossh -M 0 -NT
|
||||||
-o "ServerAliveInterval=60"
|
-o "ServerAliveInterval=60"
|
||||||
-o "ServerAliveCountMax=3"
|
-o "ServerAliveCountMax=3"
|
||||||
-R 11111:localhost:22
|
-R 11111:localhost:22
|
||||||
-i /home/armbian/.ssh/oilykey2026
|
-i /home/armbian/.ssh/hubkey
|
||||||
armbian@oily.dad
|
armbian@hub.example.com
|
||||||
volumes:
|
volumes:
|
||||||
- /home/armbian/.ssh/oilykey2026:/home/armbian/.ssh/oilykey2026:ro
|
- /home/armbian/.ssh/hubkey:/home/armbian/.ssh/hubkey:ro
|
||||||
- /home/armbian/.ssh/known_hosts:/home/armbian/.ssh/known_hosts:ro
|
- /home/armbian/.ssh/known_hosts:/home/armbian/.ssh/known_hosts:ro
|
||||||
# - /home/armbian/share:/home/armbian/
|
|
||||||
syncthing:
|
syncthing:
|
||||||
image: syncthing/syncthing
|
image: syncthing/syncthing
|
||||||
container_name: spoke-syncthing
|
container_name: spoke-syncthing
|
||||||
hostname: spoke-syncthing
|
hostname: spoke-syncthing
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
user: "1000:1000"
|
||||||
environment:
|
environment:
|
||||||
- PUID=1000
|
- HOME=/var/syncthing
|
||||||
- PGID=1000
|
ports:
|
||||||
|
- "8384:8384"
|
||||||
|
- "22000:22000"
|
||||||
volumes:
|
volumes:
|
||||||
- /home/armbian/st:/var/syncthing
|
- /home/armbian/st/config:/var/syncthing/config
|
||||||
|
- /home/armbian/st/data:/var/syncthing/data
|
||||||
|
|||||||
@@ -26,9 +26,58 @@ check_deps() {
|
|||||||
|
|
||||||
[ "$(id -u)" -eq 0 ] || die "Run as root"
|
[ "$(id -u)" -eq 0 ] || die "Run as root"
|
||||||
|
|
||||||
check_deps ip netplan systemctl ping
|
check_deps ip netplan systemctl ping hostnamectl
|
||||||
|
|
||||||
header "TinyBoard Network Setup"
|
header "TinyBoard Network Setup"
|
||||||
|
echo ""
|
||||||
|
echo " 0) Change hostname"
|
||||||
|
echo " 1) Configure static IP"
|
||||||
|
echo " 2) Prefer IPv4 over IPv6"
|
||||||
|
echo " 3) Prefer IPv6 over IPv4"
|
||||||
|
echo " q) Quit"
|
||||||
|
echo ""
|
||||||
|
read -rp "Choose: " NET_OPT
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
case "$NET_OPT" in
|
||||||
|
0)
|
||||||
|
header "Change Hostname"
|
||||||
|
CURRENT_HOSTNAME=$(hostname)
|
||||||
|
echo -e "Current hostname: ${YELLOW}$CURRENT_HOSTNAME${NC}"
|
||||||
|
read -rp "Enter new hostname (e.g. rocky): " NEW_HOSTNAME
|
||||||
|
[ -n "$NEW_HOSTNAME" ] || die "Hostname cannot be empty."
|
||||||
|
[[ "$NEW_HOSTNAME" =~ ^[a-zA-Z0-9._-]+$ ]] || die "Invalid hostname — use only letters, numbers, dots, underscores, hyphens."
|
||||||
|
hostnamectl set-hostname "$NEW_HOSTNAME"
|
||||||
|
echo "$NEW_HOSTNAME" > /etc/hostname
|
||||||
|
sed -i "s/${CURRENT_HOSTNAME}/${NEW_HOSTNAME}/g" /etc/hosts
|
||||||
|
info "Hostname changed to: $NEW_HOSTNAME"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
1)
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
header "Prefer IPv4 over IPv6"
|
||||||
|
if grep -q "precedence ::ffff:0:0/96" /etc/gai.conf 2>/dev/null; then
|
||||||
|
warn "IPv4 preference already set."
|
||||||
|
else
|
||||||
|
echo "precedence ::ffff:0:0/96 100" >> /etc/gai.conf
|
||||||
|
info "IPv4 preference set. Outgoing connections will prefer IPv4."
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
header "Prefer IPv6 over IPv4"
|
||||||
|
sed -i '/precedence ::ffff:0:0\/96/d' /etc/gai.conf 2>/dev/null || true
|
||||||
|
info "IPv4 preference removed. System will use default IPv6-first behavior."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
q|Q)
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "Invalid choice."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
info "Available interfaces:"
|
info "Available interfaces:"
|
||||||
ip -o link show | awk -F': ' 'NR>1 {print " " $2}'
|
ip -o link show | awk -F': ' 'NR>1 {print " " $2}'
|
||||||
@@ -78,7 +127,10 @@ DNS_YAML=""
|
|||||||
IFS=',' read -ra DNS_LIST <<< "$DNS_INPUT"
|
IFS=',' read -ra DNS_LIST <<< "$DNS_INPUT"
|
||||||
for DNS in "${DNS_LIST[@]}"; do
|
for DNS in "${DNS_LIST[@]}"; do
|
||||||
DNS=$(echo "$DNS" | tr -d ' ')
|
DNS=$(echo "$DNS" | tr -d ' ')
|
||||||
DNS_YAML="${DNS_YAML} - ${DNS}\n"
|
if [ -n "$DNS_YAML" ]; then
|
||||||
|
DNS_YAML="${DNS_YAML}"$'\n'
|
||||||
|
fi
|
||||||
|
DNS_YAML="${DNS_YAML} - ${DNS}"
|
||||||
done
|
done
|
||||||
|
|
||||||
info "Current netplan configs:"
|
info "Current netplan configs:"
|
||||||
@@ -95,7 +147,13 @@ if $IS_WIFI; then
|
|||||||
header "WiFi Credentials"
|
header "WiFi Credentials"
|
||||||
CURRENT_SSID=""
|
CURRENT_SSID=""
|
||||||
if [ -f "$NETPLAN_FILE" ]; then
|
if [ -f "$NETPLAN_FILE" ]; then
|
||||||
CURRENT_SSID=$(grep -A1 'access-points:' "$NETPLAN_FILE" 2>/dev/null | tail -1 | tr -d ' "' | sed 's/:$//' || true)
|
CURRENT_SSID=$(python3 - "$NETPLAN_FILE" <<'PYEOF'
|
||||||
|
import sys, re
|
||||||
|
txt = open(sys.argv[1]).read()
|
||||||
|
m = re.search(r'access-points:\s*\n\s+["\']{0,1}([^"\':\n]+)["\']{0,1}:', txt)
|
||||||
|
print(m.group(1).strip() if m else "")
|
||||||
|
PYEOF
|
||||||
|
)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
KEEP_WIFI="n"
|
KEEP_WIFI="n"
|
||||||
@@ -113,17 +171,29 @@ if $IS_WIFI; then
|
|||||||
[ -n "$WIFI_PASS" ] || die "Password cannot be empty"
|
[ -n "$WIFI_PASS" ] || die "Password cannot be empty"
|
||||||
else
|
else
|
||||||
WIFI_SSID="$CURRENT_SSID"
|
WIFI_SSID="$CURRENT_SSID"
|
||||||
WIFI_PASS=$(grep -A2 "\"${WIFI_SSID}\"" "$NETPLAN_FILE" 2>/dev/null | grep password | awk -F': ' '{print $2}' | tr -d '"' || true)
|
WIFI_PASS=$(grep -FA2 "\"${WIFI_SSID}\"" "$NETPLAN_FILE" 2>/dev/null | grep -F "password" | sed 's/^[^:]*: *//' | tr -d '"' || true)
|
||||||
[ -n "$WIFI_PASS" ] || die "Could not extract WiFi password from existing config — please re-enter credentials."
|
[ -n "$WIFI_PASS" ] || die "Could not extract WiFi password from existing config — please re-enter credentials."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
header "Writing Netplan Config"
|
header "Writing Netplan Config"
|
||||||
|
NETPLAN_BACKUP_DIR="/root/.config/tinyboard/netplan-backups"
|
||||||
|
mkdir -p "$NETPLAN_BACKUP_DIR"
|
||||||
BACKUP_FILE=""
|
BACKUP_FILE=""
|
||||||
|
|
||||||
|
for OTHER_FILE in /etc/netplan/*.yaml; do
|
||||||
|
[ "$OTHER_FILE" = "$NETPLAN_FILE" ] && continue
|
||||||
|
BACKUP_OTHER="$NETPLAN_BACKUP_DIR/$(basename "${OTHER_FILE}").$(date +%Y%m%d%H%M%S)"
|
||||||
|
cp "$OTHER_FILE" "$BACKUP_OTHER"
|
||||||
|
rm "$OTHER_FILE"
|
||||||
|
warn "Removed conflicting netplan file: $OTHER_FILE (backed up to $BACKUP_OTHER)"
|
||||||
|
done
|
||||||
|
|
||||||
if [ -f "$NETPLAN_FILE" ]; then
|
if [ -f "$NETPLAN_FILE" ]; then
|
||||||
BACKUP_FILE="/root/$(basename "${NETPLAN_FILE}").bak"
|
BACKUP_FILE="$NETPLAN_BACKUP_DIR/$(basename "${NETPLAN_FILE}").$(date +%Y%m%d%H%M%S)"
|
||||||
cp "$NETPLAN_FILE" "$BACKUP_FILE"
|
cp "$NETPLAN_FILE" "$BACKUP_FILE"
|
||||||
info "Backup saved to $BACKUP_FILE"
|
info "Netplan config backed up to $BACKUP_FILE"
|
||||||
|
info "To restore: cp $BACKUP_FILE $NETPLAN_FILE && netplan apply"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if $IS_WIFI; then
|
if $IS_WIFI; then
|
||||||
@@ -140,7 +210,8 @@ network:
|
|||||||
via: ${GATEWAY}
|
via: ${GATEWAY}
|
||||||
nameservers:
|
nameservers:
|
||||||
addresses:
|
addresses:
|
||||||
$(printf '%b' "$DNS_YAML") access-points:
|
${DNS_YAML}
|
||||||
|
access-points:
|
||||||
"${WIFI_SSID}":
|
"${WIFI_SSID}":
|
||||||
password: "${WIFI_PASS}"
|
password: "${WIFI_PASS}"
|
||||||
NETEOF
|
NETEOF
|
||||||
@@ -158,7 +229,7 @@ network:
|
|||||||
via: ${GATEWAY}
|
via: ${GATEWAY}
|
||||||
nameservers:
|
nameservers:
|
||||||
addresses:
|
addresses:
|
||||||
$(printf '%b' "$DNS_YAML")
|
${DNS_YAML}
|
||||||
NETEOF
|
NETEOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -178,11 +249,11 @@ for i in $(seq 1 6); do
|
|||||||
warn "Network check $i/6 failed, retrying..."
|
warn "Network check $i/6 failed, retrying..."
|
||||||
done
|
done
|
||||||
|
|
||||||
if $CONNECTED; then
|
if [ "$CONNECTED" = "true" ]; then
|
||||||
info "Network connectivity confirmed — config applied permanently."
|
info "Network connectivity confirmed — config applied permanently."
|
||||||
else
|
else
|
||||||
warn "No network connectivity detected after 30 seconds — reverting to backup config."
|
warn "No network connectivity detected after 30 seconds — reverting to backup config."
|
||||||
if [ -f "$BACKUP_FILE" ]; then
|
if [ -n "$BACKUP_FILE" ] && [ -f "$BACKUP_FILE" ]; then
|
||||||
cp "$BACKUP_FILE" "$NETPLAN_FILE"
|
cp "$BACKUP_FILE" "$NETPLAN_FILE"
|
||||||
netplan apply
|
netplan apply
|
||||||
die "Config reverted to backup. Check your settings and try again."
|
die "Config reverted to backup. Check your settings and try again."
|
||||||
@@ -197,6 +268,6 @@ echo -e "${YELLOW}════════════════════
|
|||||||
echo -e "${YELLOW} Network reconfigured.${NC}"
|
echo -e "${YELLOW} Network reconfigured.${NC}"
|
||||||
echo -e "${YELLOW} If you are connected via SSH, your session${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} may drop. Reconnect to: ${STATIC_ADDR}${NC}"
|
||||||
echo -e "${YELLOW} Then run: ./setup.sh${NC}"
|
echo -e "${YELLOW} Then run: cd .. && ./setup.sh${NC}"
|
||||||
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
|
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -4,6 +4,8 @@ set -euo pipefail
|
|||||||
HUB_HOST=""
|
HUB_HOST=""
|
||||||
HUB_USER=""
|
HUB_USER=""
|
||||||
SPOKE_USER=""
|
SPOKE_USER=""
|
||||||
|
KEY_PATH=""
|
||||||
|
KEY_NAME=""
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
SPOKE_DIR="$SCRIPT_DIR"
|
SPOKE_DIR="$SCRIPT_DIR"
|
||||||
COMPOSE="$SPOKE_DIR/compose.yaml"
|
COMPOSE="$SPOKE_DIR/compose.yaml"
|
||||||
@@ -134,7 +136,7 @@ $PKG_INSTALL vim "$AUTOSSH_PKG" "$OPENSSH_PKG" git
|
|||||||
info "Installing Docker..."
|
info "Installing Docker..."
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
if [ "$PKG_MANAGER" = "apt" ]; then
|
if [ "$PKG_MANAGER" = "apt" ]; then
|
||||||
$PKG_INSTALL docker.io docker-compose-plugin
|
$PKG_INSTALL docker.io docker-cli docker-compose
|
||||||
else
|
else
|
||||||
curl -fsSL https://get.docker.com | bash
|
curl -fsSL https://get.docker.com | bash
|
||||||
fi
|
fi
|
||||||
@@ -144,7 +146,7 @@ fi
|
|||||||
|
|
||||||
if ! docker compose version >/dev/null 2>&1; then
|
if ! docker compose version >/dev/null 2>&1; then
|
||||||
if [ "$PKG_MANAGER" = "apt" ]; then
|
if [ "$PKG_MANAGER" = "apt" ]; then
|
||||||
$PKG_INSTALL docker-compose-plugin
|
$PKG_INSTALL docker-compose
|
||||||
else
|
else
|
||||||
warn "docker compose not available — Docker install script should have included it."
|
warn "docker compose not available — Docker install script should have included it."
|
||||||
fi
|
fi
|
||||||
@@ -154,10 +156,15 @@ info "Adding $SPOKE_USER to docker group..."
|
|||||||
usermod -aG docker "$SPOKE_USER" 2>/dev/null || true
|
usermod -aG docker "$SPOKE_USER" 2>/dev/null || true
|
||||||
|
|
||||||
info "Enabling SSH server..."
|
info "Enabling SSH server..."
|
||||||
if systemctl enable ssh 2>/dev/null; then
|
SSH_SVC=""
|
||||||
systemctl start ssh
|
if systemctl list-unit-files ssh.service >/dev/null 2>&1; then
|
||||||
elif systemctl enable sshd 2>/dev/null; then
|
SSH_SVC="ssh"
|
||||||
systemctl start sshd
|
elif systemctl list-unit-files sshd.service >/dev/null 2>&1; then
|
||||||
|
SSH_SVC="sshd"
|
||||||
|
fi
|
||||||
|
if [ -n "$SSH_SVC" ]; then
|
||||||
|
systemctl enable "$SSH_SVC" 2>/dev/null || true
|
||||||
|
systemctl start "$SSH_SVC"
|
||||||
else
|
else
|
||||||
warn "Could not enable SSH service — please start it manually."
|
warn "Could not enable SSH service — please start it manually."
|
||||||
fi
|
fi
|
||||||
@@ -167,8 +174,9 @@ SSHD_CONF="/etc/ssh/sshd_config"
|
|||||||
header "Hostname Setup"
|
header "Hostname Setup"
|
||||||
CURRENT_HOSTNAME=$(hostname)
|
CURRENT_HOSTNAME=$(hostname)
|
||||||
echo -e "Current hostname: ${YELLOW}$CURRENT_HOSTNAME${NC}"
|
echo -e "Current hostname: ${YELLOW}$CURRENT_HOSTNAME${NC}"
|
||||||
read -rp "Enter a hostname for this spoke (e.g. rocky, gouda, camembert): " SPOKE_NAME
|
read -rp "Enter a hostname for this spoke [${CURRENT_HOSTNAME}]: " SPOKE_NAME
|
||||||
SPOKE_NAME="${SPOKE_NAME:-$CURRENT_HOSTNAME}"
|
SPOKE_NAME="${SPOKE_NAME:-$CURRENT_HOSTNAME}"
|
||||||
|
[[ "$SPOKE_NAME" =~ ^[a-zA-Z0-9._-]+$ ]] || die "Spoke name '$SPOKE_NAME' contains invalid characters. Use only letters, numbers, dots, underscores, hyphens."
|
||||||
hostnamectl set-hostname "$SPOKE_NAME"
|
hostnamectl set-hostname "$SPOKE_NAME"
|
||||||
echo "$SPOKE_NAME" > /etc/hostname
|
echo "$SPOKE_NAME" > /etc/hostname
|
||||||
info "Hostname set to: $SPOKE_NAME"
|
info "Hostname set to: $SPOKE_NAME"
|
||||||
@@ -176,9 +184,10 @@ info "Hostname set to: $SPOKE_NAME"
|
|||||||
header "SSH Key Setup"
|
header "SSH Key Setup"
|
||||||
echo "How would you like to handle the SSH key for the tunnel to $HUB_HOST?"
|
echo "How would you like to handle the SSH key for the tunnel to $HUB_HOST?"
|
||||||
echo " 1) Generate a new key automatically"
|
echo " 1) Generate a new key automatically"
|
||||||
echo " 2) Use an existing key (paste the private key)"
|
echo " 2) Choose an existing key from $SSH_DIR"
|
||||||
|
echo " 3) Paste a private key manually"
|
||||||
echo ""
|
echo ""
|
||||||
read -rp "Choose [1/2]: " KEY_CHOICE
|
read -rp "Choose [1/2/3]: " KEY_CHOICE
|
||||||
|
|
||||||
case "$KEY_CHOICE" in
|
case "$KEY_CHOICE" in
|
||||||
1)
|
1)
|
||||||
@@ -206,9 +215,41 @@ case "$KEY_CHOICE" in
|
|||||||
cat "$KEY_PATH.pub"
|
cat "$KEY_PATH.pub"
|
||||||
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
|
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo -e "${YELLOW} On the hub, run as ${HUB_USER}:${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " echo "$(cat "$KEY_PATH.pub")" >> /home/${HUB_USER}/.ssh/authorized_keys"
|
||||||
|
echo ""
|
||||||
read -rp "Press ENTER once the key has been added to ${HUB_HOST}..."
|
read -rp "Press ENTER once the key has been added to ${HUB_HOST}..."
|
||||||
;;
|
;;
|
||||||
2)
|
2)
|
||||||
|
mkdir -p "$SSH_DIR"
|
||||||
|
chown "$SPOKE_USER":"$SPOKE_USER" "$SSH_DIR"
|
||||||
|
chmod 700 "$SSH_DIR"
|
||||||
|
|
||||||
|
AVAILABLE_KEYS=()
|
||||||
|
while IFS= read -r keyfile; do
|
||||||
|
AVAILABLE_KEYS+=("$keyfile")
|
||||||
|
done < <(find "$SSH_DIR" -maxdepth 1 -type f ! -name "*.pub" ! -name "known_hosts" ! -name "authorized_keys" ! -name "config" | sort)
|
||||||
|
|
||||||
|
if [ ${#AVAILABLE_KEYS[@]} -eq 0 ]; then
|
||||||
|
die "No private keys found in $SSH_DIR."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Available keys:"
|
||||||
|
for i in "${!AVAILABLE_KEYS[@]}"; do
|
||||||
|
echo " $i) ${AVAILABLE_KEYS[$i]}"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
read -rp "Choose key [0]: " KEY_IDX
|
||||||
|
KEY_IDX="${KEY_IDX:-0}"
|
||||||
|
[[ "$KEY_IDX" =~ ^[0-9]+$ ]] && [ "$KEY_IDX" -lt "${#AVAILABLE_KEYS[@]}" ] || die "Invalid choice."
|
||||||
|
KEY_PATH="${AVAILABLE_KEYS[$KEY_IDX]}"
|
||||||
|
KEY_NAME="$(basename "$KEY_PATH")"
|
||||||
|
info "Using existing key: $KEY_PATH"
|
||||||
|
echo ""
|
||||||
|
read -rp "Press ENTER once the public key has been added to ${HUB_HOST} authorized_keys..."
|
||||||
|
;;
|
||||||
|
3)
|
||||||
read -rp "Enter a name for the key file [hubkey]: " KEY_NAME
|
read -rp "Enter a name for the key file [hubkey]: " KEY_NAME
|
||||||
KEY_NAME="${KEY_NAME:-hubkey}"
|
KEY_NAME="${KEY_NAME:-hubkey}"
|
||||||
KEY_PATH="$SSH_DIR/$KEY_NAME"
|
KEY_PATH="$SSH_DIR/$KEY_NAME"
|
||||||
@@ -229,19 +270,22 @@ case "$KEY_CHOICE" in
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
header "Password Authentication"
|
header "Password Authentication"
|
||||||
read -rp "Disable password auth for $SPOKE_USER and use keys only? [Y/n]: " DISABLE_PASS
|
warn "Do not disable password auth yet — the hub still needs password access to install its key via ssh-copy-id."
|
||||||
DISABLE_PASS="${DISABLE_PASS:-y}"
|
warn "Only disable this after running onboard-spoke.sh on the hub."
|
||||||
|
echo ""
|
||||||
|
read -rp "Disable password auth for $SPOKE_USER and use keys only? [y/N]: " DISABLE_PASS
|
||||||
|
DISABLE_PASS="${DISABLE_PASS:-n}"
|
||||||
if [[ "${DISABLE_PASS,,}" == "y" ]]; then
|
if [[ "${DISABLE_PASS,,}" == "y" ]]; then
|
||||||
if [ ! -f "$KEY_PATH" ]; then
|
if [ ! -f "$KEY_PATH" ]; then
|
||||||
warn "No key found at $KEY_PATH — skipping password auth disable to avoid lockout."
|
warn "No key found at $KEY_PATH — skipping password auth disable to avoid lockout."
|
||||||
else
|
else
|
||||||
if grep -q "^PasswordAuthentication" "$SSHD_CONF"; then
|
if grep -q "^PasswordAuthentication" "$SSHD_CONF"; then
|
||||||
sed -i "s/^PasswordAuthentication.*/PasswordAuthentication no/" "$SSHD_CONF"
|
sed -i "s|^PasswordAuthentication.*|PasswordAuthentication no|" "$SSHD_CONF"
|
||||||
else
|
else
|
||||||
echo "PasswordAuthentication no" >> "$SSHD_CONF"
|
echo "PasswordAuthentication no" >> "$SSHD_CONF"
|
||||||
fi
|
fi
|
||||||
if grep -q "^PubkeyAuthentication" "$SSHD_CONF"; then
|
if grep -q "^PubkeyAuthentication" "$SSHD_CONF"; then
|
||||||
sed -i "s/^PubkeyAuthentication.*/PubkeyAuthentication yes/" "$SSHD_CONF"
|
sed -i "s|^PubkeyAuthentication.*|PubkeyAuthentication yes|" "$SSHD_CONF"
|
||||||
else
|
else
|
||||||
echo "PubkeyAuthentication yes" >> "$SSHD_CONF"
|
echo "PubkeyAuthentication yes" >> "$SSHD_CONF"
|
||||||
fi
|
fi
|
||||||
@@ -251,9 +295,7 @@ if [[ "${DISABLE_PASS,,}" == "y" ]]; then
|
|||||||
warn "If you are connected via SSH, your session may drop."
|
warn "If you are connected via SSH, your session may drop."
|
||||||
warn "Make sure you can reconnect using your key before continuing."
|
warn "Make sure you can reconnect using your key before continuing."
|
||||||
read -rp "Press ENTER to restart SSH or CTRL+C to abort..."
|
read -rp "Press ENTER to restart SSH or CTRL+C to abort..."
|
||||||
if systemctl restart ssh 2>/dev/null; then
|
if [ -n "$SSH_SVC" ] && systemctl restart "$SSH_SVC" 2>/dev/null; then
|
||||||
info "SSH restarted."
|
|
||||||
elif systemctl restart sshd 2>/dev/null; then
|
|
||||||
info "SSH restarted."
|
info "SSH restarted."
|
||||||
else
|
else
|
||||||
warn "Could not restart SSH — please restart it manually."
|
warn "Could not restart SSH — please restart it manually."
|
||||||
@@ -265,15 +307,26 @@ fi
|
|||||||
|
|
||||||
info "Checking SSH key permissions..."
|
info "Checking SSH key permissions..."
|
||||||
check_permissions "$KEY_PATH" "spoke SSH private key"
|
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..."
|
info "Scanning hub host key..."
|
||||||
sudo -u "$SPOKE_USER" touch "$SSH_DIR/known_hosts"
|
sudo -u "$SPOKE_USER" touch "$SSH_DIR/known_hosts"
|
||||||
chown "$SPOKE_USER":"$SPOKE_USER" "$SSH_DIR/known_hosts"
|
chown "$SPOKE_USER":"$SPOKE_USER" "$SSH_DIR/known_hosts"
|
||||||
chmod 600 "$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
|
HUB_KEYSCAN=$(ssh-keyscan -H "$HUB_HOST" 2>/dev/null)
|
||||||
|
if [ -n "$HUB_KEYSCAN" ]; then
|
||||||
|
while IFS= read -r KEYSCAN_LINE; do
|
||||||
|
KEYSCAN_KEY=$(echo "$KEYSCAN_LINE" | awk '{print $2, $3}')
|
||||||
|
if ! grep -qF "$KEYSCAN_KEY" "$SSH_DIR/known_hosts" 2>/dev/null; then
|
||||||
|
echo "$KEYSCAN_LINE" >> "$SSH_DIR/known_hosts"
|
||||||
|
fi
|
||||||
|
mkdir -p /root/.ssh
|
||||||
|
touch /root/.ssh/known_hosts
|
||||||
|
chmod 600 /root/.ssh/known_hosts
|
||||||
|
if ! grep -qF "$KEYSCAN_KEY" /root/.ssh/known_hosts 2>/dev/null; then
|
||||||
|
echo "$KEYSCAN_LINE" >> /root/.ssh/known_hosts
|
||||||
|
fi
|
||||||
|
done <<< "$HUB_KEYSCAN"
|
||||||
|
fi
|
||||||
check_permissions "$SSH_DIR/known_hosts" "known_hosts"
|
check_permissions "$SSH_DIR/known_hosts" "known_hosts"
|
||||||
|
|
||||||
header "Testing SSH Connection"
|
header "Testing SSH Connection"
|
||||||
@@ -284,34 +337,47 @@ retry_or_abort \
|
|||||||
|
|
||||||
header "Finding Available Tunnel Port"
|
header "Finding Available Tunnel Port"
|
||||||
info "Scanning for a free port on $HUB_HOST starting from $START_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."
|
find_free_port() {
|
||||||
|
local start="$1"
|
||||||
|
local port result
|
||||||
|
for port in $(seq "$start" $((start + 99))); 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
|
||||||
|
echo "$port"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo -e "${YELLOW}[!]${NC} Port $port is in use, trying next..." >&2
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
TUNNEL_PORT=$(find_free_port "$START_PORT") || die "Could not find a free port starting from $START_PORT. Ask the hub owner to free up a port."
|
||||||
|
info "Port $TUNNEL_PORT is available."
|
||||||
|
|
||||||
header "Configuring compose.yaml"
|
header "Configuring compose.yaml"
|
||||||
info "Setting port to $TUNNEL_PORT and key to $KEY_NAME..."
|
info "Setting port to $TUNNEL_PORT and key to $KEY_NAME..."
|
||||||
|
|
||||||
|
SYNCTHING_MOUNT="$ARMBIAN_HOME/st/data"
|
||||||
|
SYNCTHING_CONFIG="$ARMBIAN_HOME/st/config"
|
||||||
|
mkdir -p "$SYNCTHING_MOUNT" "$SYNCTHING_CONFIG"
|
||||||
|
chown "$SPOKE_USER":"$SPOKE_USER" "$SYNCTHING_MOUNT" "$SYNCTHING_CONFIG"
|
||||||
|
|
||||||
|
SPOKE_UID=$(id -u "$SPOKE_USER")
|
||||||
|
SPOKE_GID=$(id -g "$SPOKE_USER")
|
||||||
|
|
||||||
sed -i "s|-R [0-9]*:localhost:22|-R ${TUNNEL_PORT}:localhost:22|g" "$COMPOSE"
|
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|-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 "/known_hosts/!s|/home/[^/]*/\.ssh/[^:]*:/home/[^/]*/\.ssh/[^:]*:ro|${SSH_DIR}/${KEY_NAME}:${SSH_DIR}/${KEY_NAME}:ro|g" "$COMPOSE"
|
||||||
|
sed -i "s|/home/[^/]*/\.ssh/known_hosts|${SSH_DIR}/known_hosts|g" "$COMPOSE"
|
||||||
|
sed -i "s| [a-zA-Z0-9._-]*@[a-zA-Z0-9._-]*\.[a-zA-Z0-9._-]*[[:space:]]*\$| ${HUB_USER}@${HUB_HOST}|" "$COMPOSE"
|
||||||
|
sed -i "s|/home/[^/]*/st/data:|${SYNCTHING_MOUNT}:|g" "$COMPOSE"
|
||||||
|
sed -i "s|/home/[^/]*/st/config:|${SYNCTHING_CONFIG}:|g" "$COMPOSE"
|
||||||
|
sed -i "s|user: \"[0-9]*:[0-9]*\"|user: \"${SPOKE_UID}:${SPOKE_GID}\"|" "$COMPOSE"
|
||||||
sed -i "s|container_name: spoke-autossh|container_name: ${SPOKE_NAME}-autossh|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|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 "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"
|
header "Building Docker Image"
|
||||||
cd "$SPOKE_DIR"
|
cd "$SPOKE_DIR"
|
||||||
@@ -321,17 +387,28 @@ docker build \
|
|||||||
-t spoke-autossh .
|
-t spoke-autossh .
|
||||||
|
|
||||||
header "Starting Containers"
|
header "Starting Containers"
|
||||||
docker compose up -d
|
TUNNEL_UP="false"
|
||||||
info "Waiting for tunnel to establish..."
|
for ATTEMPT in 1 2 3; do
|
||||||
sleep 6
|
docker compose up -d
|
||||||
|
info "Waiting for tunnel to establish..."
|
||||||
|
sleep 6
|
||||||
|
LOGS=$(docker logs "${SPOKE_NAME}-autossh" 2>&1 || true)
|
||||||
|
if echo "$LOGS" | grep -q "remote port forwarding failed"; then
|
||||||
|
warn "Tunnel failed on attempt $ATTEMPT — port $TUNNEL_PORT may have been taken."
|
||||||
|
docker compose down 2>/dev/null || true
|
||||||
|
TUNNEL_PORT=$(find_free_port $((TUNNEL_PORT + 1))) || die "Could not find a free port. Ask the hub owner to free up a port."
|
||||||
|
warn "Retrying with port $TUNNEL_PORT..."
|
||||||
|
sed -i "s|-R [0-9]*:localhost:22|-R ${TUNNEL_PORT}:localhost:22|g" "$COMPOSE"
|
||||||
|
else
|
||||||
|
TUNNEL_UP="true"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
LOGS=$(docker logs "${SPOKE_NAME}-autossh" 2>&1 || docker logs spoke-autossh 2>&1 || true)
|
if [ "$TUNNEL_UP" = "true" ]; then
|
||||||
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."
|
info "Tunnel is up on port $TUNNEL_PORT."
|
||||||
|
else
|
||||||
|
die "Tunnel failed after 3 attempts. Run: docker compose down && docker compose up -d"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
header "Setup Complete"
|
header "Setup Complete"
|
||||||
@@ -339,21 +416,7 @@ echo -e " Spoke name: ${GREEN}$SPOKE_NAME${NC}"
|
|||||||
echo -e " Tunnel port: ${GREEN}$TUNNEL_PORT${NC} on $HUB_HOST"
|
echo -e " Tunnel port: ${GREEN}$TUNNEL_PORT${NC} on $HUB_HOST"
|
||||||
echo -e " SSH key: ${GREEN}$KEY_PATH${NC}"
|
echo -e " SSH key: ${GREEN}$KEY_PATH${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${YELLOW}The hub owner needs to do the following on ${HUB_HOST}:${NC}"
|
echo -e "${YELLOW}Next step — on the hub, run as ${HUB_USER}:${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo " 1. Generate a hub->spoke key:"
|
echo " cd tinyboard && ./setup.sh # choose option 2 (onboard spoke)"
|
||||||
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 ""
|
echo ""
|
||||||
|
|||||||
466
syncthing.sh
Executable file
466
syncthing.sh
Executable file
@@ -0,0 +1,466 @@
|
|||||||
|
#!/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}"; }
|
||||||
|
|
||||||
|
ST_URL="http://127.0.0.1:8384"
|
||||||
|
APIKEY=""
|
||||||
|
ST_CONTAINER=""
|
||||||
|
|
||||||
|
get_apikey() {
|
||||||
|
ST_CONTAINER=$(docker ps --format '{{.Names}}' | grep -i syncthing | head -1 || true)
|
||||||
|
[ -n "$ST_CONTAINER" ] || die "No running Syncthing container found."
|
||||||
|
APIKEY=$(docker exec "$ST_CONTAINER" cat /var/syncthing/config/config.xml 2>/dev/null | grep -o '<apikey>[^<]*</apikey>' | sed 's/<[^>]*>//g' || true)
|
||||||
|
[ -n "$APIKEY" ] || die "Could not read Syncthing API key from container '$ST_CONTAINER'."
|
||||||
|
}
|
||||||
|
|
||||||
|
st_get() {
|
||||||
|
curl -sf -H "X-API-Key: $APIKEY" "${ST_URL}${1}" || die "API call failed: GET ${1}"
|
||||||
|
}
|
||||||
|
|
||||||
|
st_post() {
|
||||||
|
curl -sf -X POST -H "X-API-Key: $APIKEY" -H "Content-Type: application/json" -d "$2" "${ST_URL}${1}" || die "API call failed: POST ${1}"
|
||||||
|
}
|
||||||
|
|
||||||
|
st_put() {
|
||||||
|
curl -sf -X PUT -H "X-API-Key: $APIKEY" -H "Content-Type: application/json" -d "$2" "${ST_URL}${1}" || die "API call failed: PUT ${1}"
|
||||||
|
}
|
||||||
|
|
||||||
|
st_delete() {
|
||||||
|
curl -sf -X DELETE -H "X-API-Key: $APIKEY" "${ST_URL}${1}" || die "API call failed: DELETE ${1}"
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
show_own_device_id() {
|
||||||
|
header "This Device's ID"
|
||||||
|
local id
|
||||||
|
id=$(st_get /rest/system/status | python3 -c 'import sys,json; print(json.load(sys.stdin)["myID"])')
|
||||||
|
echo -e " ${GREEN}${id}${NC}"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
show_pending_devices() {
|
||||||
|
header "Pending Devices"
|
||||||
|
local pending
|
||||||
|
pending=$(st_get /rest/cluster/pending/devices)
|
||||||
|
local count
|
||||||
|
count=$(echo "$pending" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d))')
|
||||||
|
if [ "$count" -eq 0 ]; then
|
||||||
|
warn "No pending devices."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo "$pending" | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
devices = json.load(sys.stdin)
|
||||||
|
for device_id, info in devices.items():
|
||||||
|
name = info.get("name", "(unknown)")
|
||||||
|
print(f" Name: {name}")
|
||||||
|
print(f" ID: {device_id}")
|
||||||
|
print()
|
||||||
|
'
|
||||||
|
read -rp "Add a pending device? [y/N]: " ADD_PENDING
|
||||||
|
if [[ "${ADD_PENDING,,}" == "y" ]]; then
|
||||||
|
add_device_by_pending "$pending"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
add_device_by_pending() {
|
||||||
|
local pending="$1"
|
||||||
|
local ids
|
||||||
|
ids=$(echo "$pending" | python3 -c 'import sys,json; [print(k) for k in json.load(sys.stdin)]')
|
||||||
|
local id_list=()
|
||||||
|
while IFS= read -r line; do
|
||||||
|
id_list+=("$line")
|
||||||
|
done <<< "$ids"
|
||||||
|
|
||||||
|
if [ ${#id_list[@]} -eq 1 ]; then
|
||||||
|
DEVICE_ID="${id_list[0]}"
|
||||||
|
local pending_name
|
||||||
|
pending_name=$(echo "$pending" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[sys.argv[1]].get('name',''))" "$DEVICE_ID")
|
||||||
|
else
|
||||||
|
echo "Pending devices:"
|
||||||
|
local i=1
|
||||||
|
for id in "${id_list[@]}"; do
|
||||||
|
local name
|
||||||
|
name=$(echo "$pending" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[sys.argv[1]].get('name','(unknown)'))" "$id")
|
||||||
|
echo " $i) $name — $id"
|
||||||
|
i=$((i+1))
|
||||||
|
done
|
||||||
|
read -rp "Choose [1-${#id_list[@]}]: " CHOICE
|
||||||
|
[[ "$CHOICE" =~ ^[0-9]+$ ]] && [ "$CHOICE" -ge 1 ] && [ "$CHOICE" -le "${#id_list[@]}" ] || die "Invalid choice."
|
||||||
|
DEVICE_ID="${id_list[$((CHOICE-1))]}"
|
||||||
|
pending_name=$(echo "$pending" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[sys.argv[1]].get('name',''))" "$DEVICE_ID")
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -rp "Device name [${pending_name:-new-device}]: " DEVICE_NAME
|
||||||
|
DEVICE_NAME="${DEVICE_NAME:-${pending_name:-new-device}}"
|
||||||
|
_do_add_device "$DEVICE_ID" "$DEVICE_NAME"
|
||||||
|
}
|
||||||
|
|
||||||
|
list_devices() {
|
||||||
|
header "Devices"
|
||||||
|
st_get /rest/config/devices | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
devices = json.load(sys.stdin)
|
||||||
|
if not devices:
|
||||||
|
print(" No devices configured.")
|
||||||
|
else:
|
||||||
|
for d in devices:
|
||||||
|
print(" Name: " + d["name"])
|
||||||
|
print(" ID: " + d["deviceID"])
|
||||||
|
print()
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
add_device() {
|
||||||
|
header "Add Device"
|
||||||
|
read -rp "Device ID: " DEVICE_ID
|
||||||
|
[ -n "$DEVICE_ID" ] || die "Device ID cannot be empty."
|
||||||
|
read -rp "Device name: " DEVICE_NAME
|
||||||
|
[ -n "$DEVICE_NAME" ] || die "Device name cannot be empty."
|
||||||
|
_do_add_device "$DEVICE_ID" "$DEVICE_NAME"
|
||||||
|
}
|
||||||
|
|
||||||
|
_do_add_device() {
|
||||||
|
local device_id="$1"
|
||||||
|
local device_name="$2"
|
||||||
|
local existing
|
||||||
|
existing=$(st_get /rest/config/devices)
|
||||||
|
local already
|
||||||
|
already=$(echo "$existing" | python3 -c "import sys,json; devs=json.load(sys.stdin); print(any(d['deviceID']==sys.argv[1] for d in devs))" "$device_id")
|
||||||
|
if [ "$already" = "True" ]; then
|
||||||
|
warn "Device '${device_name}' is already configured."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local payload
|
||||||
|
payload=$(python3 -c "import sys,json; print(json.dumps({'deviceID':sys.argv[1],'name':sys.argv[2],'addresses':['dynamic'],'autoAcceptFolders':False}))" "$device_id" "$device_name")
|
||||||
|
st_post /rest/config/devices "$payload" >/dev/null
|
||||||
|
info "Device '${device_name}' added."
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_device() {
|
||||||
|
header "Remove Device"
|
||||||
|
local devices
|
||||||
|
devices=$(st_get /rest/config/devices)
|
||||||
|
local count
|
||||||
|
count=$(echo "$devices" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))')
|
||||||
|
[ "$count" -gt 0 ] || { warn "No devices configured."; return; }
|
||||||
|
|
||||||
|
echo "$devices" | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
for i, d in enumerate(json.load(sys.stdin), 1):
|
||||||
|
print(" " + str(i) + ") " + d["name"] + " — " + d["deviceID"])
|
||||||
|
'
|
||||||
|
echo ""
|
||||||
|
read -rp "Choose device to remove [1-${count}]: " CHOICE
|
||||||
|
[[ "$CHOICE" =~ ^[0-9]+$ ]] && [ "$CHOICE" -ge 1 ] && [ "$CHOICE" -le "$count" ] || die "Invalid choice."
|
||||||
|
|
||||||
|
local device_id device_name
|
||||||
|
device_id=$(echo "$devices" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[$((CHOICE-1))]['deviceID'])")
|
||||||
|
device_name=$(echo "$devices" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[$((CHOICE-1))]['name'])")
|
||||||
|
|
||||||
|
local folders
|
||||||
|
folders=$(st_get /rest/config/folders)
|
||||||
|
local shared_folders
|
||||||
|
shared_folders=$(echo "$folders" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
folders = json.load(sys.stdin)
|
||||||
|
shared = [f['label'] or f['id'] for f in folders if any(dev['deviceID']==sys.argv[1] for dev in f.get('devices',[]))]
|
||||||
|
print('\n'.join(shared))
|
||||||
|
" "$device_id")
|
||||||
|
if [ -n "$shared_folders" ]; then
|
||||||
|
warn "Device '${device_name}' is still sharing these folders:"
|
||||||
|
echo "$shared_folders" | sed 's/^/ /'
|
||||||
|
warn "Removing the device will unshare these folders from it."
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -rp "Remove '${device_name}'? [y/N]: " CONFIRM
|
||||||
|
[[ "${CONFIRM,,}" == "y" ]] || { info "Aborted."; return; }
|
||||||
|
|
||||||
|
st_delete "/rest/config/devices/${device_id}" >/dev/null
|
||||||
|
info "Device '${device_name}' removed."
|
||||||
|
}
|
||||||
|
|
||||||
|
list_folders() {
|
||||||
|
header "Folders"
|
||||||
|
st_get /rest/config/folders | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
folders = json.load(sys.stdin)
|
||||||
|
if not folders:
|
||||||
|
print(" No folders configured.")
|
||||||
|
else:
|
||||||
|
for f in folders:
|
||||||
|
label = f["label"] or f["id"]
|
||||||
|
shared = len(f.get("devices", []))
|
||||||
|
print(" Label: " + label)
|
||||||
|
print(" ID: " + f["id"])
|
||||||
|
print(" Path: " + f["path"])
|
||||||
|
print(" Shared: " + str(shared) + " device(s)")
|
||||||
|
print()
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
add_folder() {
|
||||||
|
header "Add Folder"
|
||||||
|
if [ -n "$ST_CONTAINER" ]; then
|
||||||
|
echo "Available folders in /var/syncthing/data/:"
|
||||||
|
docker exec "$ST_CONTAINER" ls /var/syncthing/data/ 2>/dev/null | sed 's/^/ /' || warn "Could not list data directory."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
read -rp "Folder path on this device (e.g. /var/syncthing/data/books): " FOLDER_PATH
|
||||||
|
[ -n "$FOLDER_PATH" ] || die "Path cannot be empty."
|
||||||
|
read -rp "Folder label (human-readable name): " FOLDER_LABEL
|
||||||
|
[ -n "$FOLDER_LABEL" ] || die "Label cannot be empty."
|
||||||
|
read -rp "Folder ID (leave blank to use label): " FOLDER_ID
|
||||||
|
FOLDER_ID="${FOLDER_ID:-$FOLDER_LABEL}"
|
||||||
|
|
||||||
|
local existing
|
||||||
|
existing=$(st_get /rest/config/folders)
|
||||||
|
local already
|
||||||
|
already=$(echo "$existing" | python3 -c "import sys,json; folders=json.load(sys.stdin); print(any(f['id']==sys.argv[1] for f in folders))" "$FOLDER_ID")
|
||||||
|
[ "$already" = "False" ] || die "Folder ID '${FOLDER_ID}' already exists."
|
||||||
|
|
||||||
|
local payload
|
||||||
|
payload=$(python3 -c "import sys,json; print(json.dumps({'id':sys.argv[1],'label':sys.argv[2],'path':sys.argv[3],'type':'sendreceive','devices':[]}))" "$FOLDER_ID" "$FOLDER_LABEL" "$FOLDER_PATH")
|
||||||
|
st_post /rest/config/folders "$payload" >/dev/null
|
||||||
|
info "Folder '${FOLDER_LABEL}' added."
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_folder() {
|
||||||
|
header "Remove Folder"
|
||||||
|
local folders
|
||||||
|
folders=$(st_get /rest/config/folders)
|
||||||
|
local count
|
||||||
|
count=$(echo "$folders" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))')
|
||||||
|
[ "$count" -gt 0 ] || { warn "No folders configured."; return; }
|
||||||
|
|
||||||
|
echo "$folders" | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
for i, f in enumerate(json.load(sys.stdin), 1):
|
||||||
|
label = f["label"] or f["id"]
|
||||||
|
print(" " + str(i) + ") " + label + " (" + f["path"] + ")")
|
||||||
|
'
|
||||||
|
echo ""
|
||||||
|
read -rp "Choose folder to remove [1-${count}]: " CHOICE
|
||||||
|
[[ "$CHOICE" =~ ^[0-9]+$ ]] && [ "$CHOICE" -ge 1 ] && [ "$CHOICE" -le "$count" ] || die "Invalid choice."
|
||||||
|
|
||||||
|
local folder_id folder_label
|
||||||
|
folder_id=$(echo "$folders" | python3 -c "import sys,json; f=json.load(sys.stdin)[$((CHOICE-1))]; print(f['id'])")
|
||||||
|
folder_label=$(echo "$folders" | python3 -c "import sys,json; f=json.load(sys.stdin)[$((CHOICE-1))]; print(f['label'] or f['id'])")
|
||||||
|
|
||||||
|
local folder_config shared_devices
|
||||||
|
folder_config=$(st_get "/rest/config/folders/${folder_id}")
|
||||||
|
shared_devices=$(echo "$folder_config" | python3 -c "import sys,json; [print(d['deviceID']) for d in json.load(sys.stdin).get('devices',[])]")
|
||||||
|
|
||||||
|
if [ -n "$shared_devices" ]; then
|
||||||
|
local devices
|
||||||
|
devices=$(st_get /rest/config/devices)
|
||||||
|
warn "Folder '${folder_label}' is currently shared with:"
|
||||||
|
while IFS= read -r did; do
|
||||||
|
local dname
|
||||||
|
dname=$(echo "$devices" | python3 -c "import sys,json; devs=json.load(sys.stdin); match=[d['name'] for d in devs if d['deviceID']==sys.argv[1]]; print(match[0] if match else sys.argv[1])" "$did")
|
||||||
|
echo " - $dname"
|
||||||
|
done <<< "$shared_devices"
|
||||||
|
warn "It will be unshared from all devices before removal."
|
||||||
|
read -rp "Proceed? [y/N]: " CONFIRM
|
||||||
|
[[ "${CONFIRM,,}" == "y" ]] || { info "Aborted."; return; }
|
||||||
|
local updated
|
||||||
|
updated=$(echo "$folder_config" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
f = json.load(sys.stdin)
|
||||||
|
f['devices'] = []
|
||||||
|
print(json.dumps(f))
|
||||||
|
")
|
||||||
|
st_put "/rest/config/folders/${folder_id}" "$updated" >/dev/null
|
||||||
|
info "Folder '${folder_label}' unshared from all devices."
|
||||||
|
else
|
||||||
|
read -rp "Remove folder '${folder_label}'? [y/N]: " CONFIRM
|
||||||
|
[[ "${CONFIRM,,}" == "y" ]] || { info "Aborted."; return; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
st_delete "/rest/config/folders/${folder_id}" >/dev/null
|
||||||
|
info "Folder '${folder_label}' removed."
|
||||||
|
}
|
||||||
|
|
||||||
|
share_folder() {
|
||||||
|
header "Share Folder with Device"
|
||||||
|
local folders devices
|
||||||
|
folders=$(st_get /rest/config/folders)
|
||||||
|
devices=$(st_get /rest/config/devices)
|
||||||
|
|
||||||
|
local f_count d_count
|
||||||
|
f_count=$(echo "$folders" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))')
|
||||||
|
d_count=$(echo "$devices" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))')
|
||||||
|
[ "$f_count" -gt 0 ] || { warn "No folders configured."; return; }
|
||||||
|
[ "$d_count" -gt 0 ] || { warn "No devices configured."; return; }
|
||||||
|
|
||||||
|
echo "Folders:"
|
||||||
|
echo "$folders" | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
for i, f in enumerate(json.load(sys.stdin), 1):
|
||||||
|
label = f["label"] or f["id"]
|
||||||
|
print(" " + str(i) + ") " + label)
|
||||||
|
'
|
||||||
|
echo ""
|
||||||
|
read -rp "Choose folder [1-${f_count}]: " F_CHOICE
|
||||||
|
[[ "$F_CHOICE" =~ ^[0-9]+$ ]] && [ "$F_CHOICE" -ge 1 ] && [ "$F_CHOICE" -le "$f_count" ] || die "Invalid choice."
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Devices:"
|
||||||
|
echo "$devices" | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
for i, d in enumerate(json.load(sys.stdin), 1):
|
||||||
|
print(" " + str(i) + ") " + d["name"])
|
||||||
|
'
|
||||||
|
echo ""
|
||||||
|
read -rp "Choose device [1-${d_count}]: " D_CHOICE
|
||||||
|
[[ "$D_CHOICE" =~ ^[0-9]+$ ]] && [ "$D_CHOICE" -ge 1 ] && [ "$D_CHOICE" -le "$d_count" ] || die "Invalid choice."
|
||||||
|
|
||||||
|
local folder_id device_id device_name folder_label
|
||||||
|
folder_id=$(echo "$folders" | python3 -c "import sys,json; f=json.load(sys.stdin)[$((F_CHOICE-1))]; print(f['id'])")
|
||||||
|
folder_label=$(echo "$folders" | python3 -c "import sys,json; f=json.load(sys.stdin)[$((F_CHOICE-1))]; print(f['label'] or f['id'])")
|
||||||
|
device_id=$(echo "$devices" | python3 -c "import sys,json; d=json.load(sys.stdin)[$((D_CHOICE-1))]; print(d['deviceID'])")
|
||||||
|
device_name=$(echo "$devices" | python3 -c "import sys,json; d=json.load(sys.stdin)[$((D_CHOICE-1))]; print(d['name'])")
|
||||||
|
|
||||||
|
local folder_config already
|
||||||
|
folder_config=$(st_get "/rest/config/folders/${folder_id}")
|
||||||
|
already=$(echo "$folder_config" | python3 -c "import sys,json; f=json.load(sys.stdin); print(any(d['deviceID']==sys.argv[1] for d in f.get('devices',[])))" "$device_id")
|
||||||
|
if [ "$already" = "True" ]; then
|
||||||
|
warn "Folder '${folder_label}' is already shared with '${device_name}'."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local updated
|
||||||
|
updated=$(echo "$folder_config" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
f = json.load(sys.stdin)
|
||||||
|
f['devices'].append({'deviceID': sys.argv[1], 'introducedBy': ''})
|
||||||
|
print(json.dumps(f))
|
||||||
|
" "$device_id")
|
||||||
|
st_put "/rest/config/folders/${folder_id}" "$updated" >/dev/null
|
||||||
|
info "Folder '${folder_label}' shared with '${device_name}'."
|
||||||
|
}
|
||||||
|
|
||||||
|
unshare_folder() {
|
||||||
|
header "Unshare Folder from Device"
|
||||||
|
local folders devices
|
||||||
|
folders=$(st_get /rest/config/folders)
|
||||||
|
devices=$(st_get /rest/config/devices)
|
||||||
|
|
||||||
|
local f_count
|
||||||
|
f_count=$(echo "$folders" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))')
|
||||||
|
[ "$f_count" -gt 0 ] || { warn "No folders configured."; return; }
|
||||||
|
|
||||||
|
echo "Folders:"
|
||||||
|
echo "$folders" | python3 -c '
|
||||||
|
import sys, json
|
||||||
|
for i, f in enumerate(json.load(sys.stdin), 1):
|
||||||
|
label = f["label"] or f["id"]
|
||||||
|
shared = len(f.get("devices", []))
|
||||||
|
print(" " + str(i) + ") " + label + " (" + str(shared) + " device(s))")
|
||||||
|
'
|
||||||
|
echo ""
|
||||||
|
read -rp "Choose folder [1-${f_count}]: " F_CHOICE
|
||||||
|
[[ "$F_CHOICE" =~ ^[0-9]+$ ]] && [ "$F_CHOICE" -ge 1 ] && [ "$F_CHOICE" -le "$f_count" ] || die "Invalid choice."
|
||||||
|
|
||||||
|
local folder_id folder_label folder_config
|
||||||
|
folder_id=$(echo "$folders" | python3 -c "import sys,json; f=json.load(sys.stdin)[$((F_CHOICE-1))]; print(f['id'])")
|
||||||
|
folder_label=$(echo "$folders" | python3 -c "import sys,json; f=json.load(sys.stdin)[$((F_CHOICE-1))]; print(f['label'] or f['id'])")
|
||||||
|
folder_config=$(st_get "/rest/config/folders/${folder_id}")
|
||||||
|
|
||||||
|
local shared_count
|
||||||
|
shared_count=$(echo "$folder_config" | python3 -c 'import sys,json; print(len(json.load(sys.stdin).get("devices",[])))')
|
||||||
|
[ "$shared_count" -gt 0 ] || { warn "Folder '${folder_label}' is not shared with any devices."; return; }
|
||||||
|
|
||||||
|
local shared_ids
|
||||||
|
shared_ids=$(echo "$folder_config" | python3 -c "import sys,json; [print(d['deviceID']) for d in json.load(sys.stdin).get('devices',[])]")
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Shared with:"
|
||||||
|
local i=1
|
||||||
|
while IFS= read -r did; do
|
||||||
|
local dname
|
||||||
|
dname=$(echo "$devices" | python3 -c "import sys,json; devs=json.load(sys.stdin); match=[d['name'] for d in devs if d['deviceID']==sys.argv[1]]; print(match[0] if match else sys.argv[1])" "$did")
|
||||||
|
echo " $i) $dname — $did"
|
||||||
|
i=$((i+1))
|
||||||
|
done <<< "$shared_ids"
|
||||||
|
local shared_count_actual=$((i-1))
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
read -rp "Choose device to unshare [1-${shared_count_actual}]: " D_CHOICE
|
||||||
|
[[ "$D_CHOICE" =~ ^[0-9]+$ ]] && [ "$D_CHOICE" -ge 1 ] && [ "$D_CHOICE" -le "$shared_count_actual" ] || die "Invalid choice."
|
||||||
|
|
||||||
|
local target_id
|
||||||
|
target_id=$(echo "$shared_ids" | sed -n "${D_CHOICE}p")
|
||||||
|
local target_name
|
||||||
|
target_name=$(echo "$devices" | python3 -c "import sys,json; devs=json.load(sys.stdin); match=[d['name'] for d in devs if d['deviceID']==sys.argv[1]]; print(match[0] if match else sys.argv[1])" "$target_id")
|
||||||
|
|
||||||
|
read -rp "Unshare '${folder_label}' from '${target_name}'? [y/N]: " CONFIRM
|
||||||
|
[[ "${CONFIRM,,}" == "y" ]] || { info "Aborted."; return; }
|
||||||
|
|
||||||
|
local updated
|
||||||
|
updated=$(echo "$folder_config" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
f = json.load(sys.stdin)
|
||||||
|
f['devices'] = [d for d in f['devices'] if d['deviceID'] != sys.argv[1]]
|
||||||
|
print(json.dumps(f))
|
||||||
|
" "$target_id")
|
||||||
|
st_put "/rest/config/folders/${folder_id}" "$updated" >/dev/null
|
||||||
|
info "Folder '${folder_label}' unshared from '${target_name}'."
|
||||||
|
}
|
||||||
|
|
||||||
|
check_deps curl docker python3
|
||||||
|
|
||||||
|
get_apikey
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
header "Syncthing Manager"
|
||||||
|
echo " 0) Show This Device's ID"
|
||||||
|
echo " 1) Pending Devices"
|
||||||
|
echo " 2) List Devices"
|
||||||
|
echo " 3) Add Device"
|
||||||
|
echo " 4) Remove Device"
|
||||||
|
echo " 5) List Folders"
|
||||||
|
echo " 6) Add Folder"
|
||||||
|
echo " 7) Remove Folder"
|
||||||
|
echo " 8) Share Folder with Device"
|
||||||
|
echo " 9) Unshare Folder from Device"
|
||||||
|
echo " q) Quit"
|
||||||
|
echo ""
|
||||||
|
read -rp "Choose: " OPT
|
||||||
|
echo ""
|
||||||
|
case "$OPT" in
|
||||||
|
0) show_own_device_id ;;
|
||||||
|
1) show_pending_devices ;;
|
||||||
|
2) list_devices ;;
|
||||||
|
3) add_device ;;
|
||||||
|
4) remove_device ;;
|
||||||
|
5) list_folders ;;
|
||||||
|
6) add_folder ;;
|
||||||
|
7) remove_folder ;;
|
||||||
|
8) share_folder ;;
|
||||||
|
9) unshare_folder ;;
|
||||||
|
q|Q) echo "Bye."; exit 0 ;;
|
||||||
|
*) warn "Invalid choice." ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
Reference in New Issue
Block a user