forked from finn/tinyboard
Compare commits
99 Commits
88fabcf25f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7c5d2bf8d | ||
|
|
85def22fca | ||
|
|
80d5f1d1fd | ||
|
|
56e0fc38c0 | ||
|
|
1c6e12e2d6 | ||
|
|
40d24158b6 | ||
|
|
5bc33b28f4 | ||
|
|
21a1c7e922 | ||
|
|
4586a0f598 | ||
|
|
2999c464fa | ||
|
|
dfa3c1ce6d | ||
|
|
9dc2b221d3 | ||
|
|
89e84c41c1 | ||
|
|
2d2b19b2db | ||
|
|
78d4373c0d | ||
|
|
132d15357c | ||
|
|
8acfc3269a | ||
|
|
ad15498bb9 | ||
|
|
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
|
||||
|
||||
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
|
||||
./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 on spoke 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
|
||||
|
||||
### `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.
|
||||
### `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. 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
|
||||
|
||||
231
health-check.sh
Executable file
231
health-check.sh
Executable file
@@ -0,0 +1,231 @@
|
||||
#!/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_disk() {
|
||||
header "Disk Space"
|
||||
while IFS= read -r line; do
|
||||
local pct mount
|
||||
pct=$(echo "$line" | awk '{print $5}' | tr -d '%')
|
||||
mount=$(echo "$line" | awk '{print $6}')
|
||||
if [ "$pct" -ge 90 ]; then
|
||||
fail "Disk usage at ${pct}% on $mount — critically low"
|
||||
elif [ "$pct" -ge 80 ]; then
|
||||
warn "Disk usage at ${pct}% on $mount — getting full"
|
||||
else
|
||||
ok "Disk usage at ${pct}% on $mount"
|
||||
fi
|
||||
done < <(df -h | awk 'NR>1' | grep -v ' /dev' | grep -v ' /sys' | grep -v ' /proc' | grep -v ' /run' | grep -v ' /snap' | grep -v 'overlay2' | grep -v 'docker')
|
||||
}
|
||||
|
||||
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
|
||||
check_disk
|
||||
|
||||
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
|
||||
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
|
||||
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"
|
||||
|
||||
@@ -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'
|
||||
@@ -73,31 +74,47 @@ 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."
|
||||
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"
|
||||
fi
|
||||
chmod 600 "$KEY_PATH"
|
||||
info "Permissions set: $KEY_PATH is 600"
|
||||
|
||||
header "Copying Hub Key to Spoke"
|
||||
info "Running ssh-copy-id to $SPOKE_USER@localhost:$TUNNEL_PORT..."
|
||||
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)"
|
||||
ssh-copy-id -i "$KEY_PATH.pub" -p "$TUNNEL_PORT" "$SPOKE_USER"@localhost
|
||||
info "Key copied."
|
||||
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 "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."
|
||||
@@ -107,20 +124,92 @@ header "Adding rclone Remote"
|
||||
if grep -q "\[${SPOKE_NAME}-remote\]" "$RCLONE_CONF" 2>/dev/null; then
|
||||
warn "Remote [${SPOKE_NAME}-remote] already exists in $RCLONE_CONF, skipping."
|
||||
else
|
||||
cat >> "$RCLONE_CONF" <<EOF
|
||||
|
||||
[${SPOKE_NAME}-remote]
|
||||
type = sftp
|
||||
host = localhost
|
||||
port = $TUNNEL_PORT
|
||||
key_file = $KEY_PATH
|
||||
shell_type = unix
|
||||
md5sum_command = md5sum
|
||||
sha1sum_command = sha1sum
|
||||
EOF
|
||||
[ -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
|
||||
|
||||
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."
|
||||
@@ -128,12 +217,39 @@ else
|
||||
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="/usr/bin/rclone mount ${SPOKE_NAME}-remote: ${MOUNT_POINT} --config ${HOME}/.config/rclone/rclone.conf --vfs-cache-mode writes --allow-other --allow-non-empty --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 ""
|
||||
|
||||
@@ -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}"
|
||||
|
||||
363
hub/setup-opds.sh
Executable file
363
hub/setup-opds.sh
Executable file
@@ -0,0 +1,363 @@
|
||||
#!/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}"; }
|
||||
|
||||
[ "$(id -u)" -eq 0 ] || die "Run as root"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
warn "Docker is not installed."
|
||||
read -rp "Install Docker now? [Y/n]: " INSTALL_DOCKER
|
||||
INSTALL_DOCKER="${INSTALL_DOCKER:-y}"
|
||||
if [[ "${INSTALL_DOCKER,,}" == "y" ]]; then
|
||||
header "Installing Docker"
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -q
|
||||
apt-get install -y -q docker.io docker-cli docker-compose
|
||||
else
|
||||
curl -fsSL https://get.docker.com | bash
|
||||
fi
|
||||
command -v docker >/dev/null 2>&1 || die "Docker installation failed."
|
||||
info "Docker installed."
|
||||
else
|
||||
die "Docker is required. Aborting."
|
||||
fi
|
||||
fi
|
||||
|
||||
header "TinyBoard OPDS Setup"
|
||||
echo ""
|
||||
|
||||
EXISTING_SERVER=""
|
||||
if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qE '^(dir2opds|stump)$'; then
|
||||
EXISTING_SERVER=$(docker ps -a --format '{{.Names}}' 2>/dev/null | grep -E '^(dir2opds|stump)$' | head -1)
|
||||
fi
|
||||
|
||||
if [ -n "$EXISTING_SERVER" ]; then
|
||||
echo "An existing OPDS server was found: ${EXISTING_SERVER}"
|
||||
echo ""
|
||||
echo " 1) Reconfigure — update books path, domain, SSL, or auth"
|
||||
echo " 2) Fresh install — remove existing and start over"
|
||||
echo " q) Quit"
|
||||
echo ""
|
||||
read -rp "Choose [1/2/q]: " RECONF_CHOICE
|
||||
case "$RECONF_CHOICE" in
|
||||
1) info "Reconfiguring existing setup..." ;;
|
||||
2)
|
||||
docker rm -f dir2opds stump caddy-opds 2>/dev/null || true
|
||||
info "Existing containers removed."
|
||||
;;
|
||||
q|Q) exit 0 ;;
|
||||
*) die "Invalid choice." ;;
|
||||
esac
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "Choose an OPDS server:"
|
||||
echo " 1) dir2opds — lightweight, no database, serves files directly from a folder"
|
||||
echo " 2) Stump — full-featured book/comic server with web UI and OPDS support"
|
||||
echo ""
|
||||
read -rp "Choose [1/2]: " OPDS_CHOICE
|
||||
|
||||
case "$OPDS_CHOICE" in
|
||||
1)
|
||||
OPDS_SERVER="dir2opds"
|
||||
OPDS_IMAGE="ghcr.io/dubyte/dir2opds:latest"
|
||||
OPDS_INTERNAL_PORT="8080"
|
||||
;;
|
||||
2)
|
||||
OPDS_SERVER="stump"
|
||||
OPDS_IMAGE="aaronleopold/stump"
|
||||
OPDS_INTERNAL_PORT="10801"
|
||||
;;
|
||||
*)
|
||||
die "Invalid choice."
|
||||
;;
|
||||
esac
|
||||
|
||||
info "Selected: $OPDS_SERVER"
|
||||
echo ""
|
||||
|
||||
read -rp "OPDS domain (e.g. opds.yourdomain.com): " OPDS_DOMAIN
|
||||
[ -n "$OPDS_DOMAIN" ] || die "Domain cannot be empty"
|
||||
|
||||
read -rp "Hub user [armbian]: " HUB_USER
|
||||
HUB_USER="${HUB_USER:-armbian}"
|
||||
HUB_HOME="/home/$HUB_USER"
|
||||
|
||||
MOUNT_BASE="${HUB_HOME}/mnt"
|
||||
AVAILABLE_MOUNTS=()
|
||||
if [ -d "$MOUNT_BASE" ]; then
|
||||
while IFS= read -r mountdir; do
|
||||
AVAILABLE_MOUNTS+=("$mountdir")
|
||||
done < <(find "$MOUNT_BASE" -mindepth 1 -maxdepth 1 -type d | sort)
|
||||
fi
|
||||
|
||||
if [ ${#AVAILABLE_MOUNTS[@]} -gt 0 ]; then
|
||||
echo "Available spoke mounts:"
|
||||
for i in "${!AVAILABLE_MOUNTS[@]}"; do
|
||||
echo " $i) ${AVAILABLE_MOUNTS[$i]}"
|
||||
done
|
||||
echo " m) Enter path manually"
|
||||
echo ""
|
||||
read -rp "Choose a mount [0]: " MOUNT_CHOICE
|
||||
MOUNT_CHOICE="${MOUNT_CHOICE:-0}"
|
||||
if [[ "$MOUNT_CHOICE" == "m" ]]; then
|
||||
read -rp "Path to books directory: " BOOKS_PATH
|
||||
elif [[ "$MOUNT_CHOICE" =~ ^[0-9]+$ ]] && [ "$MOUNT_CHOICE" -lt "${#AVAILABLE_MOUNTS[@]}" ]; then
|
||||
BASE="${AVAILABLE_MOUNTS[$MOUNT_CHOICE]}"
|
||||
read -rp "Subdirectory within ${BASE} (leave blank for root): " SUBDIR
|
||||
if [ -n "$SUBDIR" ]; then
|
||||
BOOKS_PATH="${BASE}/${SUBDIR}"
|
||||
else
|
||||
BOOKS_PATH="$BASE"
|
||||
fi
|
||||
else
|
||||
die "Invalid choice."
|
||||
fi
|
||||
else
|
||||
warn "No spoke mounts found in ${MOUNT_BASE}."
|
||||
read -rp "Path to books directory: " BOOKS_PATH
|
||||
fi
|
||||
|
||||
[ -n "$BOOKS_PATH" ] || die "Books path cannot be empty."
|
||||
[ -d "$BOOKS_PATH" ] || die "Books directory not found: $BOOKS_PATH"
|
||||
|
||||
echo ""
|
||||
echo "SSL certificate management:"
|
||||
echo " 1) Automatic — Caddy obtains and renews certs from Let's Encrypt (recommended)"
|
||||
echo " 2) Manual — provide paths to existing certificate files"
|
||||
echo ""
|
||||
read -rp "Choose [1/2]: " SSL_CHOICE
|
||||
|
||||
AUTO_HTTPS=true
|
||||
CERT_PATH=""
|
||||
KEY_PATH=""
|
||||
|
||||
case "$SSL_CHOICE" in
|
||||
1)
|
||||
AUTO_HTTPS=true
|
||||
info "Automatic HTTPS selected — DNS must point ${OPDS_DOMAIN} to this server."
|
||||
;;
|
||||
2)
|
||||
AUTO_HTTPS=false
|
||||
read -rp "Path to fullchain.pem: " CERT_PATH
|
||||
[ -f "$CERT_PATH" ] || die "Certificate file not found: $CERT_PATH"
|
||||
read -rp "Path to privkey.pem: " KEY_PATH
|
||||
[ -f "$KEY_PATH" ] || die "Key file not found: $KEY_PATH"
|
||||
;;
|
||||
*)
|
||||
die "Invalid choice."
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
read -rp "Protect with a password? [y/N]: " USE_AUTH
|
||||
USE_AUTH="${USE_AUTH:-n}"
|
||||
OPDS_USER=""
|
||||
OPDS_PASS=""
|
||||
HASHED_PASS=""
|
||||
if [[ "${USE_AUTH,,}" == "y" ]]; then
|
||||
read -rp "Username [opds]: " OPDS_USER
|
||||
OPDS_USER="${OPDS_USER:-opds}"
|
||||
read -rsp "Password: " OPDS_PASS
|
||||
echo ""
|
||||
[ -n "$OPDS_PASS" ] || die "Password cannot be empty"
|
||||
fi
|
||||
|
||||
OPDS_DIR="${HUB_HOME}/opds"
|
||||
mkdir -p "$OPDS_DIR"
|
||||
chown "$HUB_USER":"$HUB_USER" "$OPDS_DIR"
|
||||
|
||||
header "Creating Docker Network"
|
||||
docker network create opds-net 2>/dev/null || info "Network opds-net already exists."
|
||||
|
||||
header "Starting $OPDS_SERVER"
|
||||
docker rm -f "$OPDS_SERVER" 2>/dev/null || true
|
||||
|
||||
case "$OPDS_SERVER" in
|
||||
dir2opds)
|
||||
docker run -d \
|
||||
--name dir2opds \
|
||||
--restart unless-stopped \
|
||||
--network opds-net \
|
||||
--mount "type=bind,source=${BOOKS_PATH},target=/books,readonly,bind-propagation=shared" \
|
||||
"$OPDS_IMAGE" \
|
||||
/dir2opds -dir /books -hide-dot-files -calibre
|
||||
;;
|
||||
stump)
|
||||
mkdir -p "${OPDS_DIR}/stump-config"
|
||||
chown -R "$HUB_USER":"$HUB_USER" "${OPDS_DIR}"
|
||||
docker run -d \
|
||||
--name stump \
|
||||
--restart unless-stopped \
|
||||
--network opds-net \
|
||||
-v "${BOOKS_PATH}:/books:ro" \
|
||||
-v "${OPDS_DIR}/stump-config:/config" \
|
||||
-e PUID=1000 \
|
||||
-e PGID=1000 \
|
||||
"$OPDS_IMAGE"
|
||||
;;
|
||||
esac
|
||||
|
||||
info "$OPDS_SERVER started on internal network opds-net."
|
||||
|
||||
header "Writing Caddyfile"
|
||||
CADDY_DIR="${OPDS_DIR}/caddy"
|
||||
mkdir -p "${CADDY_DIR}/data" "${CADDY_DIR}/config"
|
||||
|
||||
if [ "$AUTO_HTTPS" = false ]; then
|
||||
TLS_BLOCK=" tls ${CERT_PATH} ${KEY_PATH}"
|
||||
else
|
||||
TLS_BLOCK=""
|
||||
fi
|
||||
|
||||
if [[ "${USE_AUTH,,}" == "y" ]]; then
|
||||
docker run --rm caddy:alpine caddy hash-password --plaintext "$OPDS_PASS" > /tmp/opds_hash.txt 2>/dev/null
|
||||
HASHED_PASS=$(cat /tmp/opds_hash.txt)
|
||||
rm -f /tmp/opds_hash.txt
|
||||
AUTH_BLOCK=" basicauth {
|
||||
${OPDS_USER} ${HASHED_PASS}
|
||||
}"
|
||||
else
|
||||
AUTH_BLOCK=""
|
||||
fi
|
||||
|
||||
if [ "$AUTO_HTTPS" = false ]; then
|
||||
cat > "${CADDY_DIR}/Caddyfile" << EOF
|
||||
{
|
||||
auto_https off
|
||||
}
|
||||
|
||||
:80 {
|
||||
redir https://{host}{uri} permanent
|
||||
}
|
||||
|
||||
${OPDS_DOMAIN} {
|
||||
encode gzip
|
||||
${TLS_BLOCK}
|
||||
${AUTH_BLOCK}
|
||||
reverse_proxy http://${OPDS_SERVER}:${OPDS_INTERNAL_PORT} {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
else
|
||||
cat > "${CADDY_DIR}/Caddyfile" << EOF
|
||||
${OPDS_DOMAIN} {
|
||||
encode gzip
|
||||
${AUTH_BLOCK}
|
||||
reverse_proxy http://${OPDS_SERVER}:${OPDS_INTERNAL_PORT} {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
info "Caddyfile written to ${CADDY_DIR}/Caddyfile"
|
||||
|
||||
header "Firewall Check"
|
||||
warn "Caddy requires ports 80 and 443 to be open on this server."
|
||||
warn "If using a cloud firewall (e.g. Linode), ensure inbound TCP rules allow:"
|
||||
warn " Port 80 — required for Let's Encrypt HTTP challenge and HTTP→HTTPS redirect"
|
||||
warn " Port 443 — required for HTTPS"
|
||||
echo ""
|
||||
read -rp "Press ENTER to continue once ports are open..."
|
||||
|
||||
header "Starting Caddy"
|
||||
docker rm -f caddy-opds 2>/dev/null || true
|
||||
|
||||
if [ "$AUTO_HTTPS" = true ]; then
|
||||
docker run -d \
|
||||
--name caddy-opds \
|
||||
--restart unless-stopped \
|
||||
--network opds-net \
|
||||
-p 80:80 \
|
||||
-p 443:443 \
|
||||
-v "${CADDY_DIR}/Caddyfile:/etc/caddy/Caddyfile:ro" \
|
||||
-v "${CADDY_DIR}/data:/data" \
|
||||
-v "${CADDY_DIR}/config:/config" \
|
||||
caddy:alpine
|
||||
else
|
||||
CERT_DIR=$(dirname "$CERT_PATH")
|
||||
docker run -d \
|
||||
--name caddy-opds \
|
||||
--restart unless-stopped \
|
||||
--network opds-net \
|
||||
-p 80:80 \
|
||||
-p 443:443 \
|
||||
-v "${CADDY_DIR}/Caddyfile:/etc/caddy/Caddyfile:ro" \
|
||||
-v "${CADDY_DIR}/data:/data" \
|
||||
-v "${CADDY_DIR}/config:/config" \
|
||||
-v "${CERT_DIR}:/certs:ro" \
|
||||
caddy:alpine
|
||||
fi
|
||||
|
||||
sleep 3
|
||||
docker logs caddy-opds --tail 5 2>/dev/null || true
|
||||
|
||||
header "Setting Up Mount Watchdog"
|
||||
|
||||
WATCHDOG_SCRIPT="/usr/local/bin/opds-watchdog.sh"
|
||||
cat > "$WATCHDOG_SCRIPT" << EOF
|
||||
#!/usr/bin/env bash
|
||||
SERVER_NAME="${OPDS_SERVER}"
|
||||
MAX_WAIT=300
|
||||
ELAPSED=0
|
||||
while [ \$ELAPSED -lt \$MAX_WAIT ]; do
|
||||
COUNT=\$(docker exec "\$SERVER_NAME" ls /books 2>/dev/null | wc -l)
|
||||
if [ "\$COUNT" -gt 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
docker restart "\$SERVER_NAME" 2>/dev/null || true
|
||||
sleep 15
|
||||
ELAPSED=\$((ELAPSED + 15))
|
||||
done
|
||||
EOF
|
||||
chmod +x "$WATCHDOG_SCRIPT"
|
||||
|
||||
MOUNT_SCRIPT="/home/${HUB_USER}/mount-$(basename ${MOUNT_POINT:-grace}).sh"
|
||||
cat > "$MOUNT_SCRIPT" << EOF
|
||||
#!/usr/bin/env bash
|
||||
sleep 90
|
||||
/usr/bin/rclone mount ${OPDS_SERVER}-remote: ${MOUNT_POINT:-/home/${HUB_USER}/mnt/grace} --config /home/${HUB_USER}/.config/rclone/rclone.conf --vfs-cache-mode writes --allow-other --allow-non-empty --daemon
|
||||
EOF
|
||||
chmod +x "$MOUNT_SCRIPT"
|
||||
chown "$HUB_USER":"$HUB_USER" "$MOUNT_SCRIPT"
|
||||
|
||||
CRON_LINE="@reboot $WATCHDOG_SCRIPT"
|
||||
EXISTING_ROOT_CRON=$(crontab -l 2>/dev/null || true)
|
||||
if ! echo "$EXISTING_ROOT_CRON" | grep -qF "opds-watchdog"; then
|
||||
{ echo "$EXISTING_ROOT_CRON"; echo "$CRON_LINE"; } | crontab -
|
||||
info "Watchdog crontab entry added — restarts $OPDS_SERVER until books are visible."
|
||||
else
|
||||
crontab -l 2>/dev/null | grep -v "opds-watchdog" | { cat; echo "$CRON_LINE"; } | crontab -
|
||||
info "Watchdog crontab entry updated."
|
||||
fi
|
||||
|
||||
header "OPDS Setup Complete"
|
||||
echo ""
|
||||
echo -e " OPDS URL: ${GREEN}https://${OPDS_DOMAIN}${NC}"
|
||||
if [[ "${USE_AUTH,,}" == "y" ]]; then
|
||||
echo -e " Username: ${GREEN}${OPDS_USER}${NC}"
|
||||
echo -e " Password: ${GREEN}(as entered)${NC}"
|
||||
else
|
||||
echo -e " Auth: ${YELLOW}None — publicly accessible${NC}"
|
||||
fi
|
||||
if [ "$AUTO_HTTPS" = true ]; then
|
||||
echo -e " SSL: ${GREEN}Automatic (Let's Encrypt)${NC}"
|
||||
warn "DNS must point ${OPDS_DOMAIN} to this server's IP for HTTPS to work."
|
||||
else
|
||||
echo -e " SSL: ${GREEN}Manual certificates${NC}"
|
||||
fi
|
||||
echo ""
|
||||
8
setup.sh
8
setup.sh
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
@@ -197,6 +268,6 @@ echo -e "${YELLOW}════════════════════
|
||||
echo -e "${YELLOW} Network reconfigured.${NC}"
|
||||
echo -e "${YELLOW} If you are connected via SSH, your session${NC}"
|
||||
echo -e "${YELLOW} may drop. Reconnect to: ${STATIC_ADDR}${NC}"
|
||||
echo -e "${YELLOW} Then run: ./setup.sh${NC}"
|
||||
echo -e "${YELLOW} Then run: cd .. && ./setup.sh${NC}"
|
||||
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
@@ -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 ""
|
||||
|
||||
605
syncthing.sh
Executable file
605
syncthing.sh
Executable file
@@ -0,0 +1,605 @@
|
||||
#!/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
|
||||
}
|
||||
|
||||
show_ignored_folders() {
|
||||
local config
|
||||
config=$(st_get /rest/config)
|
||||
local ignored
|
||||
ignored=$(echo "$config" | python3 -c '
|
||||
import sys,json
|
||||
d=json.load(sys.stdin)
|
||||
folders = d.get("ignoredFolders", [])
|
||||
for f in folders:
|
||||
print(f.get("id","?") + " — " + f.get("label","(no label)"))
|
||||
' 2>/dev/null || true)
|
||||
if [ -z "$ignored" ]; then
|
||||
info "No ignored folders."
|
||||
return
|
||||
fi
|
||||
echo ""
|
||||
warn "Ignored folders (click Ignore in WUI causes this):"
|
||||
echo "$ignored" | sed 's/^/ /'
|
||||
echo ""
|
||||
read -rp "Un-ignore a folder? [y/N]: " UNIGNORE
|
||||
UNIGNORE="${UNIGNORE:-n}"
|
||||
if [[ "${UNIGNORE,,}" != "y" ]]; then return; fi
|
||||
|
||||
local folder_ids
|
||||
folder_ids=$(echo "$config" | python3 -c '
|
||||
import sys,json
|
||||
d=json.load(sys.stdin)
|
||||
for f in d.get("ignoredFolders",[]):
|
||||
print(f.get("id",""))
|
||||
' 2>/dev/null || true)
|
||||
|
||||
echo "Ignored folder IDs:"
|
||||
local i=1
|
||||
while IFS= read -r fid; do
|
||||
echo " $i) $fid"
|
||||
i=$((i+1))
|
||||
done <<< "$folder_ids"
|
||||
read -rp "Choose folder number to un-ignore: " CHOICE
|
||||
local TARGET_ID
|
||||
TARGET_ID=$(echo "$folder_ids" | sed -n "${CHOICE}p")
|
||||
[ -n "$TARGET_ID" ] || { warn "Invalid choice."; return; }
|
||||
|
||||
local new_config
|
||||
new_config=$(echo "$config" | python3 -c "
|
||||
import sys,json
|
||||
d=json.load(sys.stdin)
|
||||
d['ignoredFolders'] = [f for f in d.get('ignoredFolders',[]) if f.get('id') != sys.argv[1]]
|
||||
print(json.dumps(d))
|
||||
" "$TARGET_ID")
|
||||
st_put /rest/config "$new_config"
|
||||
info "Folder $TARGET_ID removed from ignored list. It should now appear as pending."
|
||||
}
|
||||
|
||||
show_pending_folders() {
|
||||
header "Pending Folders"
|
||||
local pending
|
||||
pending=$(st_get /rest/cluster/pending/folders)
|
||||
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 folders."
|
||||
echo ""
|
||||
read -rp "Check ignored folders? [y/N]: " CHECK_IGNORED
|
||||
if [[ "${CHECK_IGNORED,,}" == "y" ]]; then
|
||||
show_ignored_folders
|
||||
fi
|
||||
return
|
||||
fi
|
||||
echo "$pending" | python3 -c '
|
||||
import sys,json
|
||||
d=json.load(sys.stdin)
|
||||
for fid,info in d.items():
|
||||
label = info.get("label", "") or info.get("offeredBy", {})
|
||||
label = list(info.get("offeredBy",{}).values())[0].get("receiveEncrypted","") if info.get("offeredBy") else ""
|
||||
offered_by = list(info.get("offeredBy",{}).keys())
|
||||
folder_label = ""
|
||||
for dev_info in info.get("offeredBy",{}).values():
|
||||
if dev_info.get("folderLabel"):
|
||||
folder_label = dev_info["folderLabel"]
|
||||
break
|
||||
if folder_label:
|
||||
print(f" Folder Name: {folder_label}")
|
||||
print(f" Folder ID: {fid}")
|
||||
print(f" Offered by: {offered_by}")
|
||||
print("")
|
||||
'
|
||||
read -rp "Accept a pending folder? [y/N/R (retry/check ignored)]: " ACCEPT
|
||||
ACCEPT="${ACCEPT:-n}"
|
||||
if [[ "${ACCEPT,,}" == "r" ]]; then
|
||||
show_ignored_folders
|
||||
return
|
||||
fi
|
||||
if [[ "${ACCEPT,,}" != "y" ]]; then return; fi
|
||||
|
||||
local folder_ids folder_labels
|
||||
folder_ids=$(echo "$pending" | python3 -c 'import sys,json; [print(k) for k in json.load(sys.stdin)]')
|
||||
folder_labels=$(echo "$pending" | python3 -c '
|
||||
import sys,json
|
||||
d=json.load(sys.stdin)
|
||||
for fid,info in d.items():
|
||||
label=""
|
||||
for dev_info in info.get("offeredBy",{}).values():
|
||||
if dev_info.get("folderLabel"):
|
||||
label=dev_info["folderLabel"]
|
||||
break
|
||||
print(label if label else fid)
|
||||
')
|
||||
local FOLDER_ID
|
||||
if [ "$(echo "$folder_ids" | wc -l)" -eq 1 ]; then
|
||||
FOLDER_ID="$folder_ids"
|
||||
else
|
||||
echo "Available pending folders:"
|
||||
local i=1
|
||||
while IFS= read -r fid && IFS= read -r flabel <&3; do
|
||||
echo " $i) $flabel ($fid)"
|
||||
i=$((i+1))
|
||||
done <<< "$folder_ids" 3<<< "$folder_labels"
|
||||
read -rp "Choose folder number: " FOLDER_NUM
|
||||
FOLDER_ID=$(echo "$folder_ids" | sed -n "${FOLDER_NUM}p")
|
||||
fi
|
||||
[ -n "$FOLDER_ID" ] || { warn "No folder selected."; return; }
|
||||
|
||||
echo ""
|
||||
info "Available directories in /var/syncthing/data/:"
|
||||
ls /var/syncthing/data/ 2>/dev/null | sed 's/^/ /' || true
|
||||
echo ""
|
||||
read -rp "Folder path on this device: " FOLDER_PATH
|
||||
[ -n "$FOLDER_PATH" ] || { warn "Path cannot be empty."; return; }
|
||||
|
||||
read -rp "Folder label [$FOLDER_ID]: " FOLDER_LABEL
|
||||
FOLDER_LABEL="${FOLDER_LABEL:-$FOLDER_ID}"
|
||||
|
||||
st_post /rest/config/folders "$(python3 -c "
|
||||
import json
|
||||
print(json.dumps({'id': '$FOLDER_ID', 'label': '$FOLDER_LABEL', 'path': '$FOLDER_PATH'}))
|
||||
")"
|
||||
info "Folder '$FOLDER_LABEL' added at $FOLDER_PATH."
|
||||
}
|
||||
|
||||
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 & Folders"
|
||||
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; show_pending_folders ;;
|
||||
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