79 Commits

Author SHA1 Message Date
dea8d2df4d add sleep to prevent rclone before fuse mod, and add performance caching 2026-04-20 00:40:38 -07:00
Justin Oros
8a7fe7b4de setup-network.sh: back up and remove conflicting netplan files before writing static IP config 2026-04-19 22:45:24 -07:00
Justin Oros
e3a12c0f6e health-check.sh: remove union remote check 2026-04-19 22:39:04 -07:00
Justin Oros
a8b10a1814 health-check.sh: check hub user's crontab instead of root's when running as root 2026-04-19 22:36:46 -07:00
Justin Oros
92a2af8c5c health-check.sh: auto-detect hub user home directory when running as root 2026-04-19 22:35:42 -07:00
Justin Oros
a5cf3d1f8b health-check.sh: only show docker and Syncthing checks on spokes, not hubs 2026-04-19 22:34:21 -07:00
Justin Oros
ebb366e4bc chmod +x health-check.sh 2026-04-19 22:31:10 -07:00
Justin Oros
84b3b7ce1d health-check.sh: fix spoke detection to check for running autossh container instead of compose.yaml presence 2026-04-19 22:29:02 -07:00
Justin Oros
86688c43c7 setup-hub.sh: use fuse3 for apt systems 2026-04-19 22:26:11 -07:00
Justin Oros
972dbef11c setup-hub.sh: change AllowTcpForwarding from local to yes to allow reverse tunnels from spokes 2026-04-19 22:11:02 -07:00
Justin Oros
e55ab898ef setup-spoke.sh: show current hostname as default in hostname prompt 2026-04-19 22:01:53 -07:00
Justin Oros
81d0bebd5e setup-spoke.sh: write hub host key to root's known_hosts during keyscan to prevent host key prompt during tunnel test 2026-04-19 21:58:29 -07:00
Justin Oros
6fe164a6ae setup-network.sh: add IPv4/IPv6 preference options to network setup menu 2026-04-19 21:49:13 -07:00
Justin Oros
b76e890857 setup-spoke.sh: print exact authorized_keys command with public key when displaying hub key instructions 2026-04-19 21:32:23 -07:00
Justin Oros
4e2f17266a compose.yaml: bind Syncthing web UI to all interfaces instead of localhost only 2026-04-19 21:12:40 -07:00
Justin Oros
0af3c30f79 setup-hub.sh: change password auth disable default to N 2026-04-19 20:58:50 -07:00
Justin Oros
b0e63a2e01 syncthing.sh: replace python3 XML parsing with grep/sed for API key extraction since Syncthing container has no python3 2026-04-19 15:50:41 -07:00
Justin Oros
22eced7607 health-check.sh: fix duplicate autossh_container variable declaration in check_spoke 2026-04-19 15:44:21 -07:00
Justin Oros
58c641d603 health-check.sh: auto-detects hub/spoke role and reports status of Docker, SSH, Syncthing, autossh tunnel, rclone mounts, and crontab entries 2026-04-19 15:41:50 -07:00
Justin Oros
1cc50f8ff0 compose.yaml, setup-spoke.sh: replace named Docker volume with host directory for syncthing config, reset compose.yaml to generic placeholders, remove volume permission fix step 2026-04-19 15:35:13 -07:00
Justin Oros
97aff6a741 onboard-spoke.sh: replace printf with python3 to correctly write rclone remote config with real newlines 2026-04-19 15:04:39 -07:00
Justin Oros
eaff38477c onboard-spoke.sh: restore union remote, rclone test, registry, auto-mount, and completion sections lost during rewrite 2026-04-19 14:43:05 -07:00
Justin Oros
e2ed499e58 onboard-spoke.sh: adopt Finn's cleaner tunnel verification flow, remove key selection prompt, add TCP pre-check before keyscan 2026-04-19 14:38:10 -07:00
Justin Oros
48ba67e351 setup-spoke.sh: fix syncthing-config volume ownership before starting containers 2026-04-19 14:22:49 -07:00
Justin Oros
5a9e55b673 setup-spoke.sh: replace manual hub instructions with onboard-spoke.sh next step prompt 2026-04-19 14:09:30 -07:00
Justin Oros
e5bdf95dcf setup-spoke.sh: replace ~ with full paths and clarify hub user in completion message 2026-04-19 14:07:17 -07:00
Justin Oros
0553420d04 setup-spoke.sh: add docker-cli to apt install list 2026-04-19 14:00:25 -07:00
Justin Oros
4cdddd649d setup-spoke.sh: add option to choose existing key from ~/.ssh/ in SSH key setup menu 2026-04-19 13:52:29 -07:00
Justin Oros
0fd7d94d58 setup-spoke.sh: redirect find_free_port warn output to stderr to prevent contamination of TUNNEL_PORT variable 2026-04-19 13:46:07 -07:00
Justin Oros
f3c9cf2344 setup-spoke.sh: change password auth disable default to N and add warning to wait until after onboard-spoke.sh 2026-04-19 13:14:52 -07:00
Justin Oros
f486795154 onboard-spoke.sh: add key selection prompt for tunnel auth, use explicit -i flag for all SSH calls, clarify hub key installation header 2026-04-19 13:05:29 -07:00
Justin Oros
fe3f2c5b77 Update readme 2026-04-19 12:52:40 -07:00
Justin Oros
4e1e9282ac setup-spoke.sh: use docker.io and docker-compose instead of docker-compose-plugin for apt installs 2026-04-19 12:37:19 -07:00
Justin Oros
07f4601bad setup-spoke.sh: replace docker.io apt install with Docker official install script to fix docker-compose-plugin availability 2026-04-19 11:45:07 -07:00
Justin Oros
9bdd12ebbd onboard-spoke.sh: add rclone auto-mount via crontab @reboot entry and immediate mount on onboarding 2026-04-19 11:40:47 -07:00
Justin Oros
d3a6d406d8 setup-hub.sh: change AllowTcpForwarding from yes to local to restrict forwarding to local connections only 2026-04-19 11:26:15 -07:00
Justin Oros
e74c9b45d5 etup-hub.sh: change GatewayPorts from yes to no for improved security 2026-04-19 11:21:52 -07:00
Justin Oros
600f6044ce setup-network.sh: add menu with hostname change and static IP options, fix SSID extraction quoting using heredoc 2026-04-19 11:03:56 -07:00
Justin Oros
2fe94dfe9d setup-hub.sh: remove unnecessary sudo/wheel group membership for hub user 2026-04-19 10:36:32 -07:00
Justin Oros
b735c58446 Update readme 2026-04-18 22:04:23 -07:00
Justin Oros
524321fa97 Update readme 2026-04-18 21:50:28 -07:00
Justin Oros
e9c1daccce onboard-spoke.sh: replace raise SystemExit with sys.exit(0) in union duplicate check 2026-04-18 21:47:55 -07:00
Justin Oros
6db5e9769e onboard-spoke.sh: replace grep -A5 union duplicate check with python3 for reliable section parsing 2026-04-18 21:46:48 -07:00
Justin Oros
c75b29a5ea onboard-spoke.sh: fix upstream construction for empty path with tag, replace fragile sed range with python3 for reliable union upstream append 2026-04-18 21:45:02 -07:00
Justin Oros
92b74d8f67 onboard-spoke.sh: add upstream access mode prompt (ro, nc, writeback) when creating or joining a union remote 2026-04-18 21:43:24 -07:00
Justin Oros
1d4e25b6a5 syncthing.sh: auto-unshare folder from all devices before removal if shared 2026-04-18 21:21:24 -07:00
Justin Oros
0c784a672c syncthing.sh: fix remaining shell variable interpolation in python3 -c strings across add_device_by_pending, remove_device, and unshare_folder 2026-04-18 21:18:04 -07:00
Justin Oros
410def45c3 syncthing.sh: fix grep -oP portability, add curl error messages, pass shell vars as sys.argv to prevent python injection 2026-04-18 21:15:46 -07:00
Justin Oros
f1d818eae6 syncthing.sh: store container name as global ST_CONTAINER 2026-04-18 21:13:34 -07:00
Justin Oros
5017af57c9 syncthing.sh: List available folders when on Add a folder 2026-04-18 21:12:47 -07:00
Justin Oros
9e4fba591a compose.yaml, setup-spoke.sh: scope syncthing mount to data directory only, move config/certs to named Docker volume, syncthing.sh: update add folder path example 2026-04-18 21:08:22 -07:00
Justin Oros
982b8a8641 syncthing.sh, README.md: change syncthing menu to 0-based indexing 2026-04-18 18:57:36 -07:00
Justin Oros
c2aec56490 README.md: document syncthing.sh, update architecture, directory structure, script docs, backups, security, and troubleshooting sections 2026-04-18 18:50:43 -07:00
Justin Oros
e4db257f53 syncthing.sh: fix remaining f-string backslash escape in remove_folder 2026-04-18 18:44:59 -07:00
Justin Oros
b2932286d0 syncthing.sh: fix Python f-string backslash escaping in all python3 -c blocks 2026-04-18 18:42:00 -07:00
Justin Oros
866f8af073 chmod +x syncthing.sh 2026-04-18 18:39:48 -07:00
Justin Oros
e6720804dc syncthing.sh: new script for managing Syncthing devices and folders via REST API with interactive menu 2026-04-18 18:36:01 -07:00
Justin Oros
63197799b8 setup-hub.sh: fix sed delimiter for PasswordAuthentication/PubkeyAuthentication, guard authorized_keys creation, setup-spoke.sh: fix sed delimiter, validate spoke name charset, make find_free_port vars local, offboard-spoke.sh: validate spoke name charset, setup-network.sh: replace brittle SSID grep with python3 regex 2026-04-18 14:39:01 -07:00
Justin Oros
128b41ede9 setup-hub.sh: fix sed delimiter and add file dep, onboard-spoke.sh: fix rclone append newline guard and keyscan key-type dedup, offboard-spoke.sh: fix crontab empty check and add timestamped backup, setup-network.sh: replace single bak with timestamped backup, compose.yaml: replace syncthing host network with explicit port bindings 2026-04-18 14:31:10 -07:00
Justin Oros
f3792a38fc setup-spoke.sh: fix port scan range and user@host sed regex, offboard/onboard-spoke.sh: fix registry grep-v empty-output clobber, setup-network.sh: fix wifi password colon handling 2026-04-18 14:25:24 -07:00
Justin Oros
e450456638 spoke/setup-spoke.sh
Fix check_permissions to check group bits; fix ssh-keyscan dedup to iterate per key type; fix HUB_USER@HUB_HOST sed regex to handle trailing whitespace
hub/offboard-spoke.sh
Drop root requirement; fix crontab running as root; fix registry .tmp not cleaned on failure
hub/onboard-spoke.sh
Fix registry .tmp not cleaned on failure; chmod 600 key immediately after generation
hub/setup-hub.sh
Check permissions on existing SSH private keys in setup
2026-04-18 14:12:05 -07:00
Justin Oros
d925cd944a onboard-spoke.sh: remove comment syntax from manual key instructions
setup-spoke.sh, setup-network.sh: fix check_permissions false alarm on pubkeys, TUNNEL_UP boolean comparison, DNS_YAML trailing newline, backup file guard
2026-04-18 14:07:02 -07:00
Justin Oros
74e1a9d1a0 offboard-spoke.sh: run as root, remove sudo from python3 install 2026-04-18 14:04:24 -07:00
Justin Oros
535c8a47cb fix hardcoded armbian hub key name in setup instructions; init KEY_NAME 2026-04-18 13:54:22 -07:00
Justin Oros
1b4a2c7ab5 fix hardcoded syncthing PUID/PGID in compose.yaml sed 2026-04-18 13:47:12 -07:00
Justin Oros
72a58cc390 fix SSH service detection across distros; fix misleading key copied message 2026-04-18 13:43:33 -07:00
Justin Oros
9e6a6f2222 fix compose.yaml sed: known_hosts collision, hub user/host, syncthing mount path, var ordering 2026-04-18 13:41:13 -07:00
Justin Oros
99c006747a fix compose.yaml key volume sed pattern; clean up compose.yaml 2026-04-18 13:39:30 -07:00
Justin Oros
e3bb7fb1ca fix known_hosts dedup in setup-spoke; handle ssh-copy-id failure in onboard-spoke 2026-04-18 13:37:35 -07:00
Justin Oros
aeda90799d fix KEY_PATH init, compose.yaml sed, registry write, known_hosts dedup, fusermount3 compat 2026-04-18 13:34:59 -07:00
Justin Oros
26b623eef7 configure ClientAliveInterval/CountMax in setup-hub.sh 2026-04-18 13:31:14 -07:00
Justin Oros
8ee67739f7 Update readme 2026-04-16 16:04:43 -07:00
Justin Oros
39f8f64351 clean up readme setup.sh option comments 2026-04-16 16:03:32 -07:00
Justin Oros
e924579b2e clean up readme setup.sh option comments 2026-04-16 16:02:37 -07:00
Justin Oros
912e553e06 add option 0 to reconfigure network via setup.sh 2026-04-16 15:59:35 -07:00
Justin Oros
98986e615b remove spoke/README.md 2026-04-16 15:03:47 -07:00
Justin Oros
0e792be751 add troubleshooting section for beta.armbian.com apt repo issue 2026-04-16 14:46:25 -07:00
Justin Oros
835793d396 add Armbian autoconfig docs link to README 2026-04-16 14:42:10 -07:00
Justin Oros
11f9586c5e fix directory tree in README for setup-network.sh move 2026-04-16 14:37:06 -07:00
11 changed files with 1293 additions and 224 deletions

156
README.md
View File

@@ -1,8 +1,8 @@
# 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
git clone https://gut.oily.dad/justin/tinyboard
cd tinyboard
./setup.sh # choose option 4
./setup.sh # option 4 (setup new hub)
```
### Setting up a new Spoke
On a fresh Armbian device:
1. Modify `spoke/armbian.not_logged_in_yet` accordingly, then drop it onto the SD card as `/root/.not_logged_in_yet` before first boot (WiFi credentials)
1. Modify `spoke/armbian.not_logged_in_yet` accordingly, then drop it onto the SD card as `/root/.not_logged_in_yet` before first boot (WiFi credentials) — see [Armbian Autoconfig docs](https://docs.armbian.com/User-Guide_Autoconfig/)
2. Boot, SSH in as root
3. Run:
@@ -31,26 +31,59 @@ On a fresh Armbian device:
apt install git
git clone https://gut.oily.dad/justin/tinyboard
cd tinyboard
./spoke/setup-network.sh # configure static IP — SSH session will drop, reconnect
./setup.sh # choose option 1
./setup.sh # option 0 (configure network)
./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
Once the spoke tunnel is up, run on the hub:
```bash
cd tinyboard
./setup.sh # choose option 2
./setup.sh # option 2 (onboard spoke)
```
### Offboarding a Spoke from the Hub
```bash
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
@@ -63,6 +96,9 @@ cd tinyboard
autossh container ──────────► sshd (GatewayPorts)
reverse tunnel port 111xx
syncthing container ◄──────────► syncthing (hub or other spokes)
file sync
rclone SFTP mount
~/mnt/<spoke-name>/
```
@@ -75,11 +111,12 @@ Spokes initiate outbound SSH connections to the hub, creating reverse tunnels. T
```
tinyboard/
├── setup.sh ← entry point
├── setup-network.sh ← configure static IP before setup
├── setup.sh ← entry point for hub/spoke setup
├── syncthing.sh ← manage Syncthing devices and folders
├── spoke/
│ ├── setup-network.sh ← configure static IP before setup
│ ├── setup-spoke.sh ← automated spoke setup
│ ├── compose.yaml ← Docker Compose for autossh + syncthing
│ ├── compose.yaml ← Docker Compose for autossh + Syncthing
│ ├── Dockerfile ← autossh container
│ └── armbian.not_logged_in_yet ← Armbian first-boot WiFi config template
└── hub/
@@ -94,22 +131,38 @@ tinyboard/
### `setup.sh`
Entry point. Presents a menu:
1. Set up this device as a new spoke
2. Onboard a new spoke from the hub
3. Offboard a spoke from the hub
4. Set up this device as a new hub
- 0) Reconfigure network (static IP via netplan — SSH session will drop, reconnect)
- 1) Set up this device as a new spoke
- 2) Onboard a new spoke from the hub
- 3) Offboard a spoke from the hub
- 4) Set up this device as a new hub
### `syncthing.sh`
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. Automatically reverts if network connectivity is lost after applying the new config.
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`
Run as root on a new spoke. Handles:
- Package installation (apt/dnf/yum/pacman)
- Docker installation
- SSH server setup
- Hostname configuration
- Hostname configuration (validated for safe characters)
- 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
- Optional password auth disable
@@ -118,7 +171,7 @@ Run as root on a new hub server. Handles:
- Package installation (apt/dnf/yum/pacman)
- rclone installation
- Hub user creation
- SSH server configuration (GatewayPorts, AllowTcpForwarding)
- SSH server configuration (GatewayPorts, AllowTcpForwarding, ClientAliveInterval)
- FUSE configuration
- rclone config directory setup
- Optional password auth disable
@@ -126,15 +179,25 @@ Run as root on a new hub server. Handles:
### `hub/onboard-spoke.sh`
Run as the hub user after a spoke connects. Handles:
- 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`
- 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`
Run as the hub user to remove a spoke. Handles:
- Unmounting the spoke filesystem
- Removing the crontab entry
- Crontab backup (timestamped to `~/.config/tinyboard/`) then entry removal
- Removing the rclone remote
- Removing the spoke from any union remotes in `rclone.conf`
- Optionally removing the hub SSH key
- 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
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
- 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)
- Password authentication can be disabled during setup
- Scripts refuse to disable password auth if no authorized keys are present (lockout prevention)
- Netplan changes are verified with a 30-second connectivity check before being made permanent
- 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:
- `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
**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
View 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 ""

View File

@@ -29,10 +29,23 @@ check_deps() {
}
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
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"
@@ -45,6 +58,7 @@ echo ""
read -rp "Spoke name to offboard: " SPOKE_NAME
[ -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)
[ -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"
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."
else
warn "Could not unmount $MOUNT_POINT — may already be unmounted."
@@ -75,15 +89,24 @@ fi
header "Removing Crontab Entry"
EXISTING=$(crontab -l 2>/dev/null || true)
UPDATED=$(echo "$EXISTING" | grep -v "${SPOKE_NAME}-remote:" || true)
if [ "$EXISTING" = "$UPDATED" ]; then
if [ -z "$EXISTING" ]; then
warn "No crontab entry found for $SPOKE_NAME."
elif [ -z "$UPDATED" ]; then
crontab -r 2>/dev/null || true
info "Crontab entry for $SPOKE_NAME removed (crontab now empty)."
else
echo "$UPDATED" | crontab -
info "Crontab entry for $SPOKE_NAME removed."
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"
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
header "Removing rclone Remote"
@@ -121,7 +144,8 @@ else
fi
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."
header "Offboarding Complete"

View File

@@ -3,6 +3,7 @@ set -euo pipefail
RCLONE_CONF="${HOME}/.config/rclone/rclone.conf"
SSH_DIR="${HOME}/.ssh"
REGISTRY="${HOME}/.config/tinyboard/spokes"
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -10,44 +11,51 @@ 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}"; }
info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
die() {
echo -e "${RED}[ERROR]${NC} $*" >&2
exit 1
}
header() {
echo -e "\n${CYAN}══════════════════════════════════════════${NC}"
echo -e "${CYAN} $*${NC}"
echo -e "${CYAN}══════════════════════════════════════════${NC}"
}
check_deps() {
local missing=()
for cmd in "$@"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
die "Missing required dependencies: ${missing[*]}"
local missing=()
for cmd in "$@"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
die "Missing required dependencies: ${missing[*]}"
fi
}
retry_or_abort() {
local test_cmd="$1"
local fail_msg="$2"
while true; do
if eval "$test_cmd" 2>/dev/null; then
return 0
fi
echo ""
warn "$fail_msg"
echo -e " ${YELLOW}[R]${NC} Retry ${RED}[A]${NC} Abort"
read -rp "Choice: " CHOICE
case "${CHOICE,,}" in
r) info "Retrying..." ;;
a) die "Aborted." ;;
*) warn "Press R to retry or A to abort." ;;
esac
done
local test_cmd="$1"
local fail_msg="$2"
while true; do
if eval "$test_cmd" 2>/dev/null; then
return 0
fi
echo ""
warn "$fail_msg"
echo -e " ${YELLOW}[R]${NC} Retry ${RED}[A]${NC} Abort"
read -rp "Choice: " CHOICE
case "${CHOICE,,}" in
r) info "Retrying..." ;;
a) die "Aborted." ;;
*) warn "Press R to retry or A to abort." ;;
esac
done
}
if [ "$(id -u)" -eq 0 ]; then
die "Running as root — keys will be written to /root/.ssh. Run as the hub user instead."
die "Running as root — keys will be written to /root/.ssh. Run as the hub user instead."
fi
mkdir -p "$SSH_DIR"
touch "$SSH_DIR/known_hosts"
@@ -73,67 +81,190 @@ KEY_PATH="$SSH_DIR/$KEY_NAME"
mkdir -p "$(dirname "$RCLONE_CONF")"
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..."
KEYSCAN=$(ssh-keyscan -p "$TUNNEL_PORT" -H localhost 2>/dev/null)
[ -n "$KEYSCAN" ] || die "Spoke not reachable on port $TUNNEL_PORT — is the tunnel up?"
echo "$KEYSCAN" >> "$SSH_DIR/known_hosts"
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..."
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"
header "Generating Hub-to-Spoke Access Key"
if [ -f "$KEY_PATH" ]; then
warn "Key $KEY_PATH already exists, skipping generation."
warn "Key $KEY_PATH already exists, skipping generation."
else
ssh-keygen -t ed25519 -f "$KEY_PATH" -N ""
info "Key generated: $KEY_PATH"
ssh-keygen -t ed25519 -f "$KEY_PATH" -N "" -C "$KEY_NAME"
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
header "Copying Hub Key to Spoke"
info "Running ssh-copy-id to $SPOKE_USER@localhost:$TUNNEL_PORT..."
info "(You will be prompted for the $SPOKE_USER password on the spoke)"
ssh-copy-id -i "$KEY_PATH.pub" -p "$TUNNEL_PORT" "$SPOKE_USER"@localhost
info "Key copied."
header "Testing Hub -> Spoke Key Auth"
header "Testing Hub-to-Spoke Key Auth"
retry_or_abort \
"ssh -i \"$KEY_PATH\" -o BatchMode=yes -o ConnectTimeout=10 -p \"$TUNNEL_PORT\" \"$SPOKE_USER\"@localhost exit" \
"Key auth failed. Check authorized_keys on the spoke."
"ssh -i \"$KEY_PATH\" -o BatchMode=yes -o ConnectTimeout=10 -p \"$TUNNEL_PORT\" \"$SPOKE_USER\"@localhost exit" \
"Key auth failed. Check authorized_keys on the spoke."
info "Key auth to spoke successful."
header "Adding rclone Remote"
if grep -q "\[${SPOKE_NAME}-remote\]" "$RCLONE_CONF" 2>/dev/null; then
warn "Remote [${SPOKE_NAME}-remote] already exists in $RCLONE_CONF, skipping."
warn "Remote [${SPOKE_NAME}-remote] already exists in $RCLONE_CONF, skipping."
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]
type = sftp
host = localhost
port = $TUNNEL_PORT
key_file = $KEY_PATH
shell_type = unix
md5sum_command = md5sum
sha1sum_command = sha1sum
EOF
info "Remote [${SPOKE_NAME}-remote] added to $RCLONE_CONF."
header "Union Remote (optional)"
read -rp "Add this spoke to a union remote for redundancy? [y/N]: " ADD_UNION
ADD_UNION="${ADD_UNION:-n}"
if [[ "${ADD_UNION,,}" == "y" ]]; then
read -rp "Union remote name [shared-union]: " UNION_NAME
UNION_NAME="${UNION_NAME:-shared-union}"
read -rp "Subfolder path on the spoke being onboarded (e.g. books, leave blank for root): " UNION_PATH
echo ""
echo "Upstream access mode for this spoke:"
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
header "Testing rclone Connection"
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
warn "rclone test failed. Check the remote config in $RCLONE_CONF."
warn "rclone test failed. Check the remote config in $RCLONE_CONF."
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"
echo -e " Spoke: ${GREEN}$SPOKE_NAME${NC}"
echo -e " Port: ${GREEN}$TUNNEL_PORT${NC}"
echo -e " Hub key: ${GREEN}$KEY_PATH${NC}"
echo -e " rclone: ${GREEN}${SPOKE_NAME}-remote${NC}"
echo ""
echo -e "${YELLOW}To mount this spoke:${NC}"
echo " RCLONE_REMOTE=${SPOKE_NAME}-remote hubspoke-helper.sh hub start"
echo ""

View File

@@ -51,7 +51,7 @@ check_permissions() {
[ "$(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"
@@ -63,7 +63,7 @@ if command -v apt-get >/dev/null 2>&1; then
PKG_MANAGER="apt"
PKG_INSTALL="apt-get install -y -q"
OPENSSH_PKG="openssh-server"
FUSE_PKG="fuse"
FUSE_PKG="fuse3"
info "Detected: apt (Debian/Ubuntu)"
apt-get update -q
elif command -v dnf >/dev/null 2>&1; then
@@ -118,21 +118,6 @@ else
groupadd -g 1000 "$HUB_USER" 2>/dev/null || true
useradd -m -u 1000 -g 1000 -s /bin/bash "$HUB_USER"
ADDED_TO_GROUP=false
if getent group sudo >/dev/null 2>&1; then
if usermod -aG sudo "$HUB_USER" 2>/dev/null; then
ADDED_TO_GROUP=true
fi
fi
if [ "$ADDED_TO_GROUP" = false ] && getent group wheel >/dev/null 2>&1; then
if usermod -aG wheel "$HUB_USER" 2>/dev/null; then
ADDED_TO_GROUP=true
fi
fi
if [ "$ADDED_TO_GROUP" = false ]; then
warn "Neither sudo nor wheel group found — $HUB_USER user has no sudo access."
fi
info "$HUB_USER user created."
echo ""
warn "Set a password for the $HUB_USER user:"
@@ -142,7 +127,7 @@ fi
ARMBIAN_HOME="/home/$HUB_USER"
SSH_DIR="$ARMBIAN_HOME/.ssh"
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"
chmod 700 "$SSH_DIR"
chmod 600 "$SSH_DIR/authorized_keys"
@@ -151,39 +136,44 @@ header "SSH Server Configuration"
SSHD_CONF="/etc/ssh/sshd_config"
[ -f "$SSHD_CONF" ] || die "sshd_config not found at $SSHD_CONF"
for DIRECTIVE in "GatewayPorts yes" "AllowTcpForwarding yes"; do
for DIRECTIVE in "GatewayPorts no" "AllowTcpForwarding yes" "ClientAliveInterval 60" "ClientAliveCountMax 3"; do
KEY="${DIRECTIVE%% *}"
if grep -q "^$KEY" "$SSHD_CONF"; then
sed -i "s/^$KEY.*/$DIRECTIVE/" "$SSHD_CONF"
sed -i "s|^$KEY.*|$DIRECTIVE|" "$SSHD_CONF"
else
echo "$DIRECTIVE" >> "$SSHD_CONF"
fi
info "$DIRECTIVE set."
done
if systemctl enable ssh 2>/dev/null; then
systemctl restart ssh
elif systemctl enable sshd 2>/dev/null; then
systemctl restart sshd
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
systemctl enable "$SSH_SVC" 2>/dev/null || true
systemctl restart "$SSH_SVC"
info "SSH server restarted."
else
warn "Could not enable/restart SSH service — please start it manually."
fi
info "SSH server restarted."
header "Password Authentication"
read -rp "Disable password auth for $HUB_USER and use keys only? [Y/n]: " DISABLE_PASS
DISABLE_PASS="${DISABLE_PASS:-y}"
read -rp "Disable password auth for $HUB_USER and use keys only? [y/N]: " DISABLE_PASS
DISABLE_PASS="${DISABLE_PASS:-n}"
if [[ "${DISABLE_PASS,,}" == "y" ]]; then
if [ ! -s "$SSH_DIR/authorized_keys" ]; then
warn "No keys found in $SSH_DIR/authorized_keys — skipping password auth disable to avoid lockout."
else
if grep -q "^PasswordAuthentication" "$SSHD_CONF"; then
sed -i "s/^PasswordAuthentication.*/PasswordAuthentication no/" "$SSHD_CONF"
sed -i "s|^PasswordAuthentication.*|PasswordAuthentication no|" "$SSHD_CONF"
else
echo "PasswordAuthentication no" >> "$SSHD_CONF"
fi
if grep -q "^PubkeyAuthentication" "$SSHD_CONF"; then
sed -i "s/^PubkeyAuthentication.*/PubkeyAuthentication yes/" "$SSHD_CONF"
sed -i "s|^PubkeyAuthentication.*|PubkeyAuthentication yes|" "$SSHD_CONF"
else
echo "PubkeyAuthentication yes" >> "$SSHD_CONF"
fi
@@ -193,9 +183,7 @@ if [[ "${DISABLE_PASS,,}" == "y" ]]; then
warn "If you are connected via SSH, your session may drop."
warn "Make sure you can reconnect using your key before continuing."
read -rp "Press ENTER to restart SSH or CTRL+C to abort..."
if systemctl restart ssh 2>/dev/null; then
info "SSH restarted."
elif systemctl restart sshd 2>/dev/null; then
if [ -n "$SSH_SVC" ] && systemctl restart "$SSH_SVC" 2>/dev/null; then
info "SSH restarted."
else
warn "Could not restart SSH — please restart it manually."
@@ -243,6 +231,16 @@ header "Permission Checks"
info "Checking SSH directory permissions..."
check_permissions "$SSH_DIR/authorized_keys" "authorized_keys"
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"
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"
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 " rclone config: ${GREEN}$RCLONE_CONF${NC}"
echo -e " Mount point: ${GREEN}$MOUNT_POINT${NC}"

View File

@@ -15,14 +15,20 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
header "TinyBoard Setup"
echo ""
echo " 0) Reconfigure network"
echo " 1) Set up this device as a new spoke"
echo " 2) Onboard a new spoke from the hub"
echo " 3) Offboard a spoke from the hub"
echo " 4) Set up this device as a new hub"
echo ""
read -rp "Choose [1/2/3/4]: " CHOICE
read -rp "Choose [0/1/2/3/4]: " CHOICE
case "$CHOICE" in
0)
[ "$(id -u)" -eq 0 ] || die "Network reconfiguration must be run as root"
info "Starting network reconfiguration..."
exec "$SCRIPT_DIR/spoke/setup-network.sh"
;;
1)
[ "$(id -u)" -eq 0 ] || die "Spoke setup must be run as root"
info "Starting spoke setup..."

View File

@@ -1,10 +1,6 @@
FROM debian:bookworm-slim
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 GID=1000
RUN groupadd -g ${GID} armbian && useradd -m -u ${UID} -g armbian armbian

View File

@@ -1,4 +1,3 @@
version: '3.8'
services:
autossh:
image: spoke-autossh
@@ -7,26 +6,27 @@ services:
network_mode: host
environment:
- AUTOSSH_GATETIME=0
# @@@@@@@@@ BEWARE THE REVERSE TUNNEL PORT AND KEYS WHEN RUNNING THIS ON A NEW SPOKE @@@@@@@@@@
command: >
autossh -M 0 -NT
-o "ServerAliveInterval=60"
-o "ServerAliveCountMax=3"
-R 11111:localhost:22
-i /home/armbian/.ssh/oilykey2026
armbian@oily.dad
-i /home/armbian/.ssh/hubkey
armbian@hub.example.com
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/share:/home/armbian/
syncthing:
image: syncthing/syncthing
container_name: spoke-syncthing
hostname: spoke-syncthing
restart: unless-stopped
network_mode: host
user: "1000:1000"
environment:
- PUID=1000
- PGID=1000
- HOME=/var/syncthing
ports:
- "8384:8384"
- "22000:22000"
volumes:
- /home/armbian/st:/var/syncthing
- /home/armbian/st/config:/var/syncthing/config
- /home/armbian/st/data:/var/syncthing/data

View File

@@ -26,9 +26,58 @@ check_deps() {
[ "$(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"
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:"
ip -o link show | awk -F': ' 'NR>1 {print " " $2}'
@@ -78,7 +127,10 @@ DNS_YAML=""
IFS=',' read -ra DNS_LIST <<< "$DNS_INPUT"
for DNS in "${DNS_LIST[@]}"; do
DNS=$(echo "$DNS" | tr -d ' ')
DNS_YAML="${DNS_YAML} - ${DNS}\n"
if [ -n "$DNS_YAML" ]; then
DNS_YAML="${DNS_YAML}"$'\n'
fi
DNS_YAML="${DNS_YAML} - ${DNS}"
done
info "Current netplan configs:"
@@ -95,7 +147,13 @@ if $IS_WIFI; then
header "WiFi Credentials"
CURRENT_SSID=""
if [ -f "$NETPLAN_FILE" ]; then
CURRENT_SSID=$(grep -A1 'access-points:' "$NETPLAN_FILE" 2>/dev/null | tail -1 | tr -d ' "' | sed 's/:$//' || true)
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
KEEP_WIFI="n"
@@ -113,17 +171,29 @@ if $IS_WIFI; then
[ -n "$WIFI_PASS" ] || die "Password cannot be empty"
else
WIFI_SSID="$CURRENT_SSID"
WIFI_PASS=$(grep -A2 "\"${WIFI_SSID}\"" "$NETPLAN_FILE" 2>/dev/null | grep password | awk -F': ' '{print $2}' | tr -d '"' || true)
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."
fi
fi
header "Writing Netplan Config"
NETPLAN_BACKUP_DIR="/root/.config/tinyboard/netplan-backups"
mkdir -p "$NETPLAN_BACKUP_DIR"
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
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"
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
if $IS_WIFI; then
@@ -140,7 +210,8 @@ network:
via: ${GATEWAY}
nameservers:
addresses:
$(printf '%b' "$DNS_YAML") access-points:
${DNS_YAML}
access-points:
"${WIFI_SSID}":
password: "${WIFI_PASS}"
NETEOF
@@ -158,7 +229,7 @@ network:
via: ${GATEWAY}
nameservers:
addresses:
$(printf '%b' "$DNS_YAML")
${DNS_YAML}
NETEOF
fi
@@ -178,11 +249,11 @@ for i in $(seq 1 6); do
warn "Network check $i/6 failed, retrying..."
done
if $CONNECTED; then
if [ "$CONNECTED" = "true" ]; then
info "Network connectivity confirmed — config applied permanently."
else
warn "No network connectivity detected after 30 seconds — reverting to backup config."
if [ -f "$BACKUP_FILE" ]; then
if [ -n "$BACKUP_FILE" ] && [ -f "$BACKUP_FILE" ]; then
cp "$BACKUP_FILE" "$NETPLAN_FILE"
netplan apply
die "Config reverted to backup. Check your settings and try again."

View File

@@ -4,6 +4,8 @@ set -euo pipefail
HUB_HOST=""
HUB_USER=""
SPOKE_USER=""
KEY_PATH=""
KEY_NAME=""
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SPOKE_DIR="$SCRIPT_DIR"
COMPOSE="$SPOKE_DIR/compose.yaml"
@@ -134,7 +136,7 @@ $PKG_INSTALL vim "$AUTOSSH_PKG" "$OPENSSH_PKG" git
info "Installing Docker..."
if ! command -v docker >/dev/null 2>&1; then
if [ "$PKG_MANAGER" = "apt" ]; then
$PKG_INSTALL docker.io docker-compose-plugin
$PKG_INSTALL docker.io docker-cli docker-compose
else
curl -fsSL https://get.docker.com | bash
fi
@@ -144,7 +146,7 @@ fi
if ! docker compose version >/dev/null 2>&1; then
if [ "$PKG_MANAGER" = "apt" ]; then
$PKG_INSTALL docker-compose-plugin
$PKG_INSTALL docker-compose
else
warn "docker compose not available — Docker install script should have included it."
fi
@@ -154,10 +156,15 @@ info "Adding $SPOKE_USER to docker group..."
usermod -aG docker "$SPOKE_USER" 2>/dev/null || true
info "Enabling SSH server..."
if systemctl enable ssh 2>/dev/null; then
systemctl start ssh
elif systemctl enable sshd 2>/dev/null; then
systemctl start sshd
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
systemctl enable "$SSH_SVC" 2>/dev/null || true
systemctl start "$SSH_SVC"
else
warn "Could not enable SSH service — please start it manually."
fi
@@ -167,8 +174,9 @@ SSHD_CONF="/etc/ssh/sshd_config"
header "Hostname Setup"
CURRENT_HOSTNAME=$(hostname)
echo -e "Current hostname: ${YELLOW}$CURRENT_HOSTNAME${NC}"
read -rp "Enter a hostname for this spoke (e.g. rocky, gouda, camembert): " SPOKE_NAME
read -rp "Enter a hostname for this spoke [${CURRENT_HOSTNAME}]: " SPOKE_NAME
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"
echo "$SPOKE_NAME" > /etc/hostname
info "Hostname set to: $SPOKE_NAME"
@@ -176,9 +184,10 @@ info "Hostname set to: $SPOKE_NAME"
header "SSH Key Setup"
echo "How would you like to handle the SSH key for the tunnel to $HUB_HOST?"
echo " 1) Generate a new key automatically"
echo " 2) Use an existing key (paste the private key)"
echo " 2) Choose an existing key from $SSH_DIR"
echo " 3) Paste a private key manually"
echo ""
read -rp "Choose [1/2]: " KEY_CHOICE
read -rp "Choose [1/2/3]: " KEY_CHOICE
case "$KEY_CHOICE" in
1)
@@ -206,9 +215,41 @@ case "$KEY_CHOICE" in
cat "$KEY_PATH.pub"
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
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}..."
;;
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
KEY_NAME="${KEY_NAME:-hubkey}"
KEY_PATH="$SSH_DIR/$KEY_NAME"
@@ -229,19 +270,22 @@ case "$KEY_CHOICE" in
esac
header "Password Authentication"
read -rp "Disable password auth for $SPOKE_USER and use keys only? [Y/n]: " DISABLE_PASS
DISABLE_PASS="${DISABLE_PASS:-y}"
warn "Do not disable password auth yet — the hub still needs password access to install its key via ssh-copy-id."
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 [ ! -f "$KEY_PATH" ]; then
warn "No key found at $KEY_PATH — skipping password auth disable to avoid lockout."
else
if grep -q "^PasswordAuthentication" "$SSHD_CONF"; then
sed -i "s/^PasswordAuthentication.*/PasswordAuthentication no/" "$SSHD_CONF"
sed -i "s|^PasswordAuthentication.*|PasswordAuthentication no|" "$SSHD_CONF"
else
echo "PasswordAuthentication no" >> "$SSHD_CONF"
fi
if grep -q "^PubkeyAuthentication" "$SSHD_CONF"; then
sed -i "s/^PubkeyAuthentication.*/PubkeyAuthentication yes/" "$SSHD_CONF"
sed -i "s|^PubkeyAuthentication.*|PubkeyAuthentication yes|" "$SSHD_CONF"
else
echo "PubkeyAuthentication yes" >> "$SSHD_CONF"
fi
@@ -251,9 +295,7 @@ if [[ "${DISABLE_PASS,,}" == "y" ]]; then
warn "If you are connected via SSH, your session may drop."
warn "Make sure you can reconnect using your key before continuing."
read -rp "Press ENTER to restart SSH or CTRL+C to abort..."
if systemctl restart ssh 2>/dev/null; then
info "SSH restarted."
elif systemctl restart sshd 2>/dev/null; then
if [ -n "$SSH_SVC" ] && systemctl restart "$SSH_SVC" 2>/dev/null; then
info "SSH restarted."
else
warn "Could not restart SSH — please restart it manually."
@@ -265,15 +307,26 @@ fi
info "Checking SSH key permissions..."
check_permissions "$KEY_PATH" "spoke SSH private key"
if [ -f "$KEY_PATH.pub" ]; then
check_permissions "$KEY_PATH.pub" "spoke SSH public key"
fi
info "Scanning hub host key..."
sudo -u "$SPOKE_USER" touch "$SSH_DIR/known_hosts"
chown "$SPOKE_USER":"$SPOKE_USER" "$SSH_DIR/known_hosts"
chmod 600 "$SSH_DIR/known_hosts"
sudo -u "$SPOKE_USER" ssh-keyscan -H "$HUB_HOST" >> "$SSH_DIR/known_hosts" 2>/dev/null
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"
header "Testing SSH Connection"
@@ -284,34 +337,47 @@ retry_or_abort \
header "Finding Available Tunnel Port"
info "Scanning for a free port on $HUB_HOST starting from $START_PORT..."
TUNNEL_PORT=""
for PORT in $(seq "$START_PORT" $((START_PORT + 20))); do
RESULT=$(sudo -u "$SPOKE_USER" ssh -i "$KEY_PATH" "$HUB_USER@$HUB_HOST" "ss -tlnp | grep :$PORT" 2>/dev/null || true)
if [ -z "$RESULT" ]; then
TUNNEL_PORT="$PORT"
info "Port $TUNNEL_PORT is available."
break
else
warn "Port $PORT is in use, trying next..."
fi
done
[ -n "$TUNNEL_PORT" ] || die "Could not find a free port between $START_PORT and $((START_PORT + 20)). Ask the hub owner to free up a port."
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"
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|-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-syncthing|container_name: ${SPOKE_NAME}-syncthing|g" "$COMPOSE"
sed -i "s|hostname: spoke-syncthing|hostname: ${SPOKE_NAME}-syncthing|g" "$COMPOSE"
sed -i '/^version:/d' "$COMPOSE"
SYNCTHING_MOUNT="$ARMBIAN_HOME/st"
mkdir -p "$SYNCTHING_MOUNT"
chown "$SPOKE_USER":"$SPOKE_USER" "$SYNCTHING_MOUNT"
header "Building Docker Image"
cd "$SPOKE_DIR"
@@ -321,17 +387,28 @@ docker build \
-t spoke-autossh .
header "Starting Containers"
docker compose up -d
info "Waiting for tunnel to establish..."
sleep 6
TUNNEL_UP="false"
for ATTEMPT in 1 2 3; do
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 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
if [ "$TUNNEL_UP" = "true" ]; then
info "Tunnel is up on port $TUNNEL_PORT."
else
die "Tunnel failed after 3 attempts. Run: docker compose down && docker compose up -d"
fi
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 " SSH key: ${GREEN}$KEY_PATH${NC}"
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 " 1. Generate a hub->spoke key:"
echo " ssh-keygen -t ed25519 -f ~/.ssh/armbian-${SPOKE_NAME}-$(date +%Y%m)"
echo ""
echo " 2. Copy it to this spoke through the tunnel:"
echo " ssh-copy-id -i ~/.ssh/armbian-${SPOKE_NAME}-$(date +%Y%m).pub -p $TUNNEL_PORT ${HUB_USER}@localhost"
echo ""
echo " 3. Add an rclone remote in ~/.config/rclone/rclone.conf:"
echo " [${SPOKE_NAME}-remote]"
echo " type = sftp"
echo " host = localhost"
echo " port = $TUNNEL_PORT"
echo " key_file = /home/$HUB_USER/.ssh/armbian-${SPOKE_NAME}-$(date +%Y%m)"
echo " shell_type = unix"
echo " md5sum_command = md5sum"
echo " sha1sum_command = sha1sum"
echo " cd tinyboard && ./setup.sh # choose option 2 (onboard spoke)"
echo ""

466
syncthing.sh Executable file
View 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