1
0
forked from finn/tinyboard

Compare commits

..

63 Commits

Author SHA1 Message Date
Justin Oros
b735c58446 Update readme 2026-04-18 22:04:23 -07:00
Justin Oros
524321fa97 Update readme 2026-04-18 21:50:28 -07:00
Justin Oros
e9c1daccce onboard-spoke.sh: replace raise SystemExit with sys.exit(0) in union duplicate check 2026-04-18 21:47:55 -07:00
Justin Oros
6db5e9769e onboard-spoke.sh: replace grep -A5 union duplicate check with python3 for reliable section parsing 2026-04-18 21:46:48 -07:00
Justin Oros
c75b29a5ea onboard-spoke.sh: fix upstream construction for empty path with tag, replace fragile sed range with python3 for reliable union upstream append 2026-04-18 21:45:02 -07:00
Justin Oros
92b74d8f67 onboard-spoke.sh: add upstream access mode prompt (ro, nc, writeback) when creating or joining a union remote 2026-04-18 21:43:24 -07:00
Justin Oros
1d4e25b6a5 syncthing.sh: auto-unshare folder from all devices before removal if shared 2026-04-18 21:21:24 -07:00
Justin Oros
0c784a672c syncthing.sh: fix remaining shell variable interpolation in python3 -c strings across add_device_by_pending, remove_device, and unshare_folder 2026-04-18 21:18:04 -07:00
Justin Oros
410def45c3 syncthing.sh: fix grep -oP portability, add curl error messages, pass shell vars as sys.argv to prevent python injection 2026-04-18 21:15:46 -07:00
Justin Oros
f1d818eae6 syncthing.sh: store container name as global ST_CONTAINER 2026-04-18 21:13:34 -07:00
Justin Oros
5017af57c9 syncthing.sh: List available folders when on Add a folder 2026-04-18 21:12:47 -07:00
Justin Oros
9e4fba591a compose.yaml, setup-spoke.sh: scope syncthing mount to data directory only, move config/certs to named Docker volume, syncthing.sh: update add folder path example 2026-04-18 21:08:22 -07:00
Justin Oros
982b8a8641 syncthing.sh, README.md: change syncthing menu to 0-based indexing 2026-04-18 18:57:36 -07:00
Justin Oros
c2aec56490 README.md: document syncthing.sh, update architecture, directory structure, script docs, backups, security, and troubleshooting sections 2026-04-18 18:50:43 -07:00
Justin Oros
e4db257f53 syncthing.sh: fix remaining f-string backslash escape in remove_folder 2026-04-18 18:44:59 -07:00
Justin Oros
b2932286d0 syncthing.sh: fix Python f-string backslash escaping in all python3 -c blocks 2026-04-18 18:42:00 -07:00
Justin Oros
866f8af073 chmod +x syncthing.sh 2026-04-18 18:39:48 -07:00
Justin Oros
e6720804dc syncthing.sh: new script for managing Syncthing devices and folders via REST API with interactive menu 2026-04-18 18:36:01 -07:00
Justin Oros
63197799b8 setup-hub.sh: fix sed delimiter for PasswordAuthentication/PubkeyAuthentication, guard authorized_keys creation, setup-spoke.sh: fix sed delimiter, validate spoke name charset, make find_free_port vars local, offboard-spoke.sh: validate spoke name charset, setup-network.sh: replace brittle SSID grep with python3 regex 2026-04-18 14:39:01 -07:00
Justin Oros
128b41ede9 setup-hub.sh: fix sed delimiter and add file dep, onboard-spoke.sh: fix rclone append newline guard and keyscan key-type dedup, offboard-spoke.sh: fix crontab empty check and add timestamped backup, setup-network.sh: replace single bak with timestamped backup, compose.yaml: replace syncthing host network with explicit port bindings 2026-04-18 14:31:10 -07:00
Justin Oros
f3792a38fc setup-spoke.sh: fix port scan range and user@host sed regex, offboard/onboard-spoke.sh: fix registry grep-v empty-output clobber, setup-network.sh: fix wifi password colon handling 2026-04-18 14:25:24 -07:00
Justin Oros
e450456638 spoke/setup-spoke.sh
Fix check_permissions to check group bits; fix ssh-keyscan dedup to iterate per key type; fix HUB_USER@HUB_HOST sed regex to handle trailing whitespace
hub/offboard-spoke.sh
Drop root requirement; fix crontab running as root; fix registry .tmp not cleaned on failure
hub/onboard-spoke.sh
Fix registry .tmp not cleaned on failure; chmod 600 key immediately after generation
hub/setup-hub.sh
Check permissions on existing SSH private keys in setup
2026-04-18 14:12:05 -07:00
Justin Oros
d925cd944a onboard-spoke.sh: remove comment syntax from manual key instructions
setup-spoke.sh, setup-network.sh: fix check_permissions false alarm on pubkeys, TUNNEL_UP boolean comparison, DNS_YAML trailing newline, backup file guard
2026-04-18 14:07:02 -07:00
Justin Oros
74e1a9d1a0 offboard-spoke.sh: run as root, remove sudo from python3 install 2026-04-18 14:04:24 -07:00
Justin Oros
535c8a47cb fix hardcoded armbian hub key name in setup instructions; init KEY_NAME 2026-04-18 13:54:22 -07:00
Justin Oros
1b4a2c7ab5 fix hardcoded syncthing PUID/PGID in compose.yaml sed 2026-04-18 13:47:12 -07:00
Justin Oros
72a58cc390 fix SSH service detection across distros; fix misleading key copied message 2026-04-18 13:43:33 -07:00
Justin Oros
9e6a6f2222 fix compose.yaml sed: known_hosts collision, hub user/host, syncthing mount path, var ordering 2026-04-18 13:41:13 -07:00
Justin Oros
99c006747a fix compose.yaml key volume sed pattern; clean up compose.yaml 2026-04-18 13:39:30 -07:00
Justin Oros
e3bb7fb1ca fix known_hosts dedup in setup-spoke; handle ssh-copy-id failure in onboard-spoke 2026-04-18 13:37:35 -07:00
Justin Oros
aeda90799d fix KEY_PATH init, compose.yaml sed, registry write, known_hosts dedup, fusermount3 compat 2026-04-18 13:34:59 -07:00
Justin Oros
26b623eef7 configure ClientAliveInterval/CountMax in setup-hub.sh 2026-04-18 13:31:14 -07:00
Justin Oros
8ee67739f7 Update readme 2026-04-16 16:04:43 -07:00
Justin Oros
39f8f64351 clean up readme setup.sh option comments 2026-04-16 16:03:32 -07:00
Justin Oros
e924579b2e clean up readme setup.sh option comments 2026-04-16 16:02:37 -07:00
Justin Oros
912e553e06 add option 0 to reconfigure network via setup.sh 2026-04-16 15:59:35 -07:00
Justin Oros
98986e615b remove spoke/README.md 2026-04-16 15:03:47 -07:00
Justin Oros
0e792be751 add troubleshooting section for beta.armbian.com apt repo issue 2026-04-16 14:46:25 -07:00
Justin Oros
835793d396 add Armbian autoconfig docs link to README 2026-04-16 14:42:10 -07:00
Justin Oros
11f9586c5e fix directory tree in README for setup-network.sh move 2026-04-16 14:37:06 -07:00
Justin Oros
3e351f925d move setup-network.sh to spoke/ directory 2026-04-16 14:35:59 -07:00
Justin Oros
a197b7881b move setup-network.sh to spoke/ directory 2026-04-16 14:35:42 -07:00
Justin Oros
60feeca65e move setup-network.sh to spoke/ directory 2026-04-16 14:35:31 -07:00
Justin Oros
88fabcf25f update repo URL to justin/tinyboard 2026-04-16 14:23:51 -07:00
Justin Oros
51f661766f rename armb-not_logged_in_yet to armbian.not_logged_in_yet 2026-04-16 14:17:58 -07:00
Justin Oros
5326823b81 rewrite README with quickstart and updated architecture docs 2026-04-16 14:15:19 -07:00
Justin Oros
0f76283605 remove stale crontab dep from setup-hub.sh 2026-04-16 14:08:20 -07:00
Justin Oros
a02a83cae4 fix execute permissions on scripts 2026-04-16 13:53:48 -07:00
Justin Oros
4a1983d46d remove unused legacy scripts and rclone template 2026-04-16 13:48:28 -07:00
Justin Oros
395ab4ed0e add spoke registry, per-spoke crontab, and offboard-spoke.sh 2026-04-16 13:41:56 -07:00
Justin Oros
4c08f3b389 fix function ordering, hardcoded armbian user, and key name prefix in onboard-spoke.sh 2026-04-16 13:17:12 -07:00
Justin Oros
ccd324dc79 fix function ordering and RCLONE_CONF used before definition in setup-hub.sh 2026-04-16 13:15:40 -07:00
Justin Oros
664bdeaed4 fix function ordering, permission check chains, and known_hosts check timing in setup-spoke.sh 2026-04-16 13:14:27 -07:00
Justin Oros
ae49c58b13 add WiFi password extraction validation in setup-network.sh 2026-04-16 13:13:18 -07:00
Justin Oros
119b747dda fix BACKUP_FILE unbound variable and add ping to dep checks 2026-04-16 13:11:57 -07:00
Justin Oros
ea72b14696 fix function ordering, remove dead variable, fix netplan rollback approach 2026-04-16 13:10:59 -07:00
Justin Oros
26110ce8d3 add 30s connectivity check with auto-rollback to setup-network.sh 2026-04-16 13:09:40 -07:00
Justin Oros
58f6445c72 add check_deps function and dependency checks to all scripts 2026-04-16 13:05:45 -07:00
Justin Oros
08799f0f7f add SSH key permission checks with auto-fix to hub and spoke scripts 2026-04-16 12:58:06 -07:00
Justin Oros
a79b1c59b8 move password auth prompt to after SSH key setup in setup-spoke.sh 2026-04-16 10:44:12 -07:00
Justin Oros
7e64156026 fix double brace artifacts and missing SSHD_CONF in setup-spoke.sh 2026-04-16 10:42:58 -07:00
Justin Oros
3d366cd74a add disable password auth prompt with SSH restart warning to hub and spoke scripts 2026-04-16 10:42:04 -07:00
Justin Oros
d080db1db8 fix hardcoded armbian path in compose volume mount sed replacement 2026-04-16 10:37:55 -07:00
16 changed files with 1302 additions and 568 deletions

365
README.md
View File

@@ -1,208 +1,261 @@
# TinyBoard Hub-Spoke File Sharing System
# 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.
## Architecture Overview
Spokes are ARM devices (e.g. OrangePi, Raspberry Pi) running Armbian that establish reverse SSH tunnels to a central hub server. The hub mounts spoke filesystems via SFTP using rclone, making files accessible across all devices without exposing them to the internet. Syncthing runs on each spoke for bidirectional file sync.
This system implements a hub-and-spoke model where:
- **Spokes**: Raspberry Pi devices running Armbian that establish reverse SSH tunnels to the hub
- **Hub**: Central server that mounts spoke filesystems via SFTP using rclone
---
### Key Components
## Quickstart
1. **Spoke Side** (`spoke/` directory):
- Docker-based autossh tunnel container
- Configuration files for spoke setup
- Hostname assignment based on MAC address
### Setting up a new Hub
2. **Hub Side** (`hub/` directory):
- Rclone SFTP mount configuration
- Systemd user service for automatic mounting
On a fresh Debian/Ubuntu VPS or server:
3. **Management Script** (`hubspoke-helper.sh`):
- Unified interface for managing both hub and spoke components
```bash
apt install git
git clone https://gut.oily.dad/justin/tinyboard
cd tinyboard
./setup.sh # option 4 (setup new hub)
```
### Setting up a new Spoke
On a fresh Armbian device:
1. Modify `spoke/armbian.not_logged_in_yet` accordingly, then drop it onto the SD card as `/root/.not_logged_in_yet` before first boot (WiFi credentials) — see [Armbian Autoconfig docs](https://docs.armbian.com/User-Guide_Autoconfig/)
2. Boot, SSH in as root
3. Run:
```bash
apt install git
git clone https://gut.oily.dad/justin/tinyboard
cd tinyboard
./setup.sh # option 0 (configure network)
./setup.sh # option 1 (configure new spoke)
```
### Onboarding a Spoke from the Hub
Once the spoke tunnel is up, run on the hub:
```bash
cd tinyboard
./setup.sh # option 2 (onboard spoke)
```
### Offboarding a Spoke from the Hub
```bash
cd tinyboard
./setup.sh # option 3 (offboard spoke)
```
### 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
```
[ Spoke ] [ Hub ]
OrangePi / RPi VPS / Server
Armbian Any Linux
autossh container ──────────► sshd (GatewayPorts)
reverse tunnel port 111xx
syncthing container ◄──────────► syncthing (hub or other spokes)
file sync
rclone SFTP mount
~/mnt/<spoke-name>/
```
Spokes initiate outbound SSH connections to the hub, creating reverse tunnels. The hub then uses rclone to mount each spoke's filesystem over SFTP through the tunnel. No inbound ports need to be open on the spoke.
---
## Directory Structure
```
tinyboard/
├── hubspoke-helper.sh # Main management script
├── hub/
│ └── rclone.conf # Rclone SFTP configuration
├── setup.sh ← entry point for hub/spoke setup
├── syncthing.sh ← manage Syncthing devices and folders
├── spoke/
│ ├── compose.yaml # Docker Compose for autossh tunnel
│ ├── Dockerfile # autossh container image
│ ├── autohostname.sh # Hostname assignment by MAC address
│ ├── aptprimary.sh # Initial package installation
── clean_sensitive.sh # Clean WiFi/password from configs
│ └── armb-not_logged_in_yet # Armbian first-boot configuration
└── README.md # This file
│ ├── setup-network.sh ← configure static IP before setup
│ ├── setup-spoke.sh ← automated spoke setup
│ ├── compose.yaml ← Docker Compose for autossh + Syncthing
│ ├── Dockerfile ← autossh container
── armbian.not_logged_in_yet ← Armbian first-boot WiFi config template
└── hub/
├── setup-hub.sh ← automated hub setup
├── onboard-spoke.sh ← add a new spoke to the hub
└── offboard-spoke.sh ← remove a spoke from the hub
```
## Key File Handling (Manual Setup)
---
**IMPORTANT**: The following files must be manually created/configured as they contain sensitive information:
## Setup Scripts
### SSH Keys
- `~/.ssh/oilykey2026` on spokes (referenced in `spoke/compose.yaml`)
- `~/.ssh/armbian-brie-202604` on hub (referenced in `hub/rclone.conf`)
- These keys must be manually generated and distributed
### `setup.sh`
Entry point. Presents a menu:
- 0) Reconfigure network (static IP via netplan — SSH session will drop, reconnect)
- 1) Set up this device as a new spoke
- 2) Onboard a new spoke from the hub
- 3) Offboard a spoke from the hub
- 4) Set up this device as a new hub
### Rclone Configuration
- `~/.config/rclone/rclone.conf` on hub must be manually created
- Use `hub/rclone.conf` as a template
- Update host, port, and key_file paths as needed
- Manually create rclone mount and permission it (`/mnt/hub` for example)
### `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
### Systemd Service Files
- `~/.config/systemd/user/rclone-mount@.service` must be manually copied from `hub/rclone-mount@.service`
Requires Docker and a running Syncthing container. Auto-discovers the container and API key.
## Spoke Setup (Raspberry Pi / Armbian)
### `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.
### Initial Setup
1. Write Armbian minimal image to SD card
2. Copy `spoke/armb-not_logged_in_yet` to SD card root `/root/.not_logged_in_yet` (contains WiFi credentials)
3. Boot device, SSH in as root with password "1234"
4. After first login and setup tasks, `.not_logged_in_yet` will be processed for root and armbian user credentials
5. Clone this repository: `git clone <repo-url>`
6. Run `spoke/aptprimary.sh` to install required packages
7. Run `spoke/autohostname.sh` to assign hostname based on MAC address
8. Reboot and test as armbian user
### `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 (validated for safe characters)
- SSH key generation and hub authorization
- Tunnel port auto-detection on the hub (scans up to 100 ports)
- Docker image build and container start
- Optional password auth disable
### SSH Key Setup
1. Generate SSH key pair on hub: `ssh-keygen -t ed25519 -f ~/.ssh/armbian-brie-202604`
2. Copy public key to spoke: `ssh-copy-id -i ~/.ssh/armbian-brie-202604.pub armbian@<spoke-ip>`
3. Generate spoke key: `ssh-keygen -t ed25519 -f ~/.ssh/oilykey2026`
4. Copy public key to hub for reverse tunnel authentication
### `hub/setup-hub.sh`
Run as root on a new hub server. Handles:
- Package installation (apt/dnf/yum/pacman)
- rclone installation
- Hub user creation
- SSH server configuration (GatewayPorts, AllowTcpForwarding, ClientAliveInterval)
- FUSE configuration
- rclone config directory setup
- Optional password auth disable
### Docker Tunnel Setup
```bash
# Build the autossh container
./hubspoke-helper.sh spoke build
### `hub/onboard-spoke.sh`
Run as the hub user after a spoke connects. Handles:
- SSH key generation and deployment to spoke
- 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`
# Start the tunnel
./hubspoke-helper.sh spoke start
#### 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
# Check status
./hubspoke-helper.sh spoke status
The union remote is automatically updated when a spoke is offboarded.
# View logs
./hubspoke-helper.sh spoke logs
### `hub/offboard-spoke.sh`
Run as the hub user to remove a spoke. Handles:
- Unmounting the spoke filesystem
- 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
---
## Spoke Registry
The hub maintains a registry of connected spokes at `~/.config/tinyboard/spokes`:
```
rocky 11113 /home/armbian/.ssh/armbian-rocky-202504 /home/armbian/mnt/rocky
grace 11114 /home/armbian/.ssh/armbian-grace-202504 /home/armbian/mnt/grace
```
## Hub Setup (Central Server)
Each spoke gets its own mount point at `~/mnt/<spoke-name>/` and a dedicated rclone remote.
### Rclone Configuration
1. Install rclone: `apt install rclone fuse`
2. Create config directory: `mkdir -p ~/.config/rclone`
3. Copy and customize `hub/rclone.conf` to `~/.config/rclone/rclone.conf`
4. Update key_file path to point to your SSH private key
---
### FUSE Configuration
```bash
# Allow other users to access mounts (if needed)
sudo sed -i 's/^#user_allow_other/user_allow_other/' /etc/fuse.conf
## Backups
# Add user to fuse group
sudo groupadd fuse
sudo usermod -aG fuse $USER
```
Scripts that modify critical configs create timestamped backups before writing:
## Usage
- **Netplan:** `/root/.config/tinyboard/netplan-backups/<filename>.<datetime>`
- **Crontab:** `~/.config/tinyboard/crontab.<datetime>`
### Managing Spoke Tunnels
- Docker on spoke should handle autostart of spoke tunnel
- Syncthing can be combined in this image
- Rename syncthing image and host names per-device in the compose file.
Restore hints are printed to the terminal after each backup.
```bash
# Build autossh container
./hubspoke-helper.sh spoke build
---
# Start/stop/restart tunnel
./hubspoke-helper.sh spoke start
./hubspoke-helper.sh spoke stop
./hubspoke-helper.sh spoke restart
## Security
# Check status and logs
./hubspoke-helper.sh spoke status
./hubspoke-helper.sh spoke logs
- All communication is over SSH tunnels — no spoke ports exposed to the internet
- 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 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
# Show manual autossh command
./hubspoke-helper.sh spoke show-cmd
```
---
### Managing Hub Mounts
## Sensitive Files
#### Crontab entry:
```
@reboot /home/armbian/tinyboard/hubspoke-helper.sh hub start-background
```
Before committing, ensure the following do not contain real credentials:
#### Deprecated: systemd
```bash
# Install systemd service (after manual file placement)
./hubspoke-helper.sh hub install
- `spoke/armbian.not_logged_in_yet` — contains WiFi SSID, password, and user passwords
- `spoke/compose.yaml` — may contain hub hostname after spoke setup runs
# Start/stop rclone mount
./hubspoke-helper.sh hub start
./hubspoke-helper.sh hub stop
# Check service status
./hubspoke-helper.sh hub status
# Manual mount/unmount for testing
./hubspoke-helper.sh hub mount
./hubspoke-helper.sh hub unmount
```
## Configuration Variables
Environment variables can override defaults:
- `TUNNEL_DIR`: Directory containing spoke Docker files (default: `~/tinyboard/spoke`)
- `COMPOSE_FILE`: Docker compose file path (default: `$TUNNEL_DIR/compose.yaml`)
- `RCLONE_REMOTE`: Rclone remote name (default: `brie-remote`)
- `MOUNT_POINT`: Mount point on hub (default: `~/mnt/hub`)
## Security Notes
1. **SSH Keys**: Always use strong key pairs and protect private keys
2. **Configuration Files**: Use `spoke/clean_sensitive.sh` to remove WiFi credentials before committing
3. **Firewall**: Ensure proper firewall rules on hub (port 11111 for reverse tunnels)
4. **User Permissions**: Run services with minimal required privileges
---
## Troubleshooting
### Spoke Tunnel Issues
- Check Docker container logs: `./hubspoke-helper.sh spoke logs`
- Verify SSH key permissions: `chmod 600 ~/.ssh/oilykey2026`
- Test SSH connection manually: `ssh -p 11111 armbian@localhost`
### `apt update` fails with beta.armbian.com error
### Hub Mount Issues
- Check service status: `./hubspoke-helper.sh hub status`
- Test rclone manually: `rclone lsd brie-sftp:`
- Verify fuse configuration: `ls -la /etc/fuse.conf`
- Check user groups: `groups $USER`
On some Armbian images, a beta apt repository is enabled by default and may cause `apt update` to fail. Comment it out:
### Network Issues
- Ensure spokes can reach hub on SSH port (22)
- Verify reverse tunnel port (11111) is not blocked by firewall
- Check DNS resolution on spokes for hub hostname
```bash
grep -r "beta.armbian" /etc/apt/sources.list /etc/apt/sources.list.d/
```
## Maintenance
Open the file that contains it (usually `/etc/apt/sources.list.d/armbian.sources`) and comment out or remove the line referencing `beta.armbian.com`, then run `apt update` again.
### Updating Configuration
1. Update `spoke/compose.yaml` for new spoke hostnames
2. Update `hub/rclone.conf` for new spoke connections
3. Update `spoke/autohostname.sh` for new MAC addresses
### Tunnel is up but rclone mount fails
### Adding New Spokes
1. Follow Spoke Setup steps for new device
2. Add MAC address to `spoke/autohostname.sh`
3. Update hub's SSH authorized_keys with new spoke public key
4. Add new rclone remote configuration if needed
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.
## License
### Syncthing container not found by syncthing.sh
This project is for personal use. Adapt as needed for your environment.
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 (or any Debian/Ubuntu/RHEL/Arch Linux), ARM device, Docker, autossh, git
**Hub:** Any Linux server (Debian/Ubuntu/RHEL/Arch), rclone, fuse, openssh-server, python3

153
hub/offboard-spoke.sh Executable file
View File

@@ -0,0 +1,153 @@
#!/usr/bin/env bash
set -euo pipefail
RCLONE_CONF="${HOME}/.config/rclone/rclone.conf"
SSH_DIR="${HOME}/.ssh"
REGISTRY="${HOME}/.config/tinyboard/spokes"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; }
check_deps() {
local missing=()
for cmd in "$@"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
die "Missing required dependencies: ${missing[*]}"
fi
}
if [ "$(id -u)" -eq 0 ]; then
die "Running as root — run as the hub user instead."
fi
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"
[ -f "$REGISTRY" ] || die "No spoke registry found at $REGISTRY. No spokes to offboard."
echo "Registered spokes:"
echo ""
awk '{print " " $1 " (port " $2 ", mount " $4 ")"}' "$REGISTRY"
echo ""
read -rp "Spoke name to offboard: " SPOKE_NAME
[ -n "$SPOKE_NAME" ] || die "Spoke name cannot be empty"
[[ "$SPOKE_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."
TUNNEL_PORT=$(echo "$SPOKE_LINE" | awk '{print $2}')
KEY_PATH=$(echo "$SPOKE_LINE" | awk '{print $3}')
MOUNT_POINT=$(echo "$SPOKE_LINE" | awk '{print $4}')
echo ""
echo -e " Spoke: ${YELLOW}$SPOKE_NAME${NC}"
echo -e " Port: ${YELLOW}$TUNNEL_PORT${NC}"
echo -e " Key: ${YELLOW}$KEY_PATH${NC}"
echo -e " Mount: ${YELLOW}$MOUNT_POINT${NC}"
echo ""
read -rp "Are you sure you want to offboard $SPOKE_NAME? [y/N]: " CONFIRM
[[ "${CONFIRM,,}" == "y" ]] || die "Aborted."
header "Unmounting Spoke"
if mountpoint -q "$MOUNT_POINT" 2>/dev/null; then
if $FUSERMOUNT -u "$MOUNT_POINT" 2>/dev/null; then
info "Unmounted $MOUNT_POINT."
else
warn "Could not unmount $MOUNT_POINT — may already be unmounted."
fi
else
warn "$MOUNT_POINT is not currently mounted."
fi
header "Removing Crontab Entry"
EXISTING=$(crontab -l 2>/dev/null || true)
if [ -z "$EXISTING" ]; then
warn "No crontab entry found for $SPOKE_NAME."
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"
if grep -q "\[${SPOKE_NAME}-remote\]" "$RCLONE_CONF" 2>/dev/null; then
python3 - "$RCLONE_CONF" "$SPOKE_NAME" <<'PYEOF'
import sys
path, name = sys.argv[1], sys.argv[2]
lines = open(path).readlines()
out, skip = [], False
for line in lines:
if line.strip() == f"[{name}-remote]":
skip = True
elif skip and line.strip().startswith("["):
skip = False
if not skip:
out.append(line)
open(path, 'w').writelines(out)
PYEOF
info "rclone remote [${SPOKE_NAME}-remote] removed from $RCLONE_CONF."
else
warn "rclone remote [${SPOKE_NAME}-remote] not found in $RCLONE_CONF."
fi
header "Removing SSH Key"
read -rp "Remove hub SSH key for $SPOKE_NAME ($KEY_PATH)? [y/N]: " REMOVE_KEY
if [[ "${REMOVE_KEY,,}" == "y" ]]; then
if [ -f "$KEY_PATH" ]; then
rm -f "$KEY_PATH" "$KEY_PATH.pub"
info "SSH key removed."
else
warn "Key not found at $KEY_PATH."
fi
else
info "SSH key left in place."
fi
header "Removing from Registry"
grep -v "^${SPOKE_NAME} " "$REGISTRY" > "${REGISTRY}.tmp" 2>/dev/null || true
mv "${REGISTRY}.tmp" "$REGISTRY"
info "$SPOKE_NAME removed from registry."
header "Offboarding Complete"
echo -e " Spoke ${GREEN}$SPOKE_NAME${NC} has been offboarded."
echo ""

166
hub/onboard-spoke.sh Normal file → Executable file
View File

@@ -3,15 +3,7 @@ set -euo pipefail
RCLONE_CONF="${HOME}/.config/rclone/rclone.conf"
SSH_DIR="${HOME}/.ssh"
if [ "$(id -u)" -eq 0 ]; then
echo -e "\033[0;31m[WARNING]\033[0m Running as root — keys will be written to /root/.ssh. Run as armbian instead."
exit 1
fi
mkdir -p "$SSH_DIR"
touch "$SSH_DIR/known_hosts"
chmod 700 "$SSH_DIR"
chmod 600 "$SSH_DIR/known_hosts"
REGISTRY="${HOME}/.config/tinyboard/spokes"
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -19,6 +11,23 @@ YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; }
check_deps() {
local missing=()
for cmd in "$@"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
die "Missing required dependencies: ${missing[*]}"
fi
}
retry_or_abort() {
local test_cmd="$1"
local fail_msg="$2"
@@ -38,34 +47,46 @@ retry_or_abort() {
done
}
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}"; }
if [ "$(id -u)" -eq 0 ]; then
die "Running as root — keys will be written to /root/.ssh. Run as the hub user instead."
fi
mkdir -p "$SSH_DIR"
touch "$SSH_DIR/known_hosts"
chmod 700 "$SSH_DIR"
chmod 600 "$SSH_DIR/known_hosts"
check_deps ssh ssh-keygen ssh-keyscan ssh-copy-id rclone
header "TinyBoard Hub — Onboard New Spoke"
read -rp "Spoke local user [armbian]: " SPOKE_USER
SPOKE_USER="${SPOKE_USER:-armbian}"
read -rp "Spoke name (e.g. rocky): " SPOKE_NAME
[ -n "$SPOKE_NAME" ] || die "Spoke name cannot be empty"
read -rp "Tunnel port for $SPOKE_NAME: " TUNNEL_PORT
[[ "$TUNNEL_PORT" =~ ^[0-9]+$ ]] || die "Invalid port"
KEY_NAME="armbian-${SPOKE_NAME}-$(date +%Y%m)"
KEY_NAME="${SPOKE_USER}-${SPOKE_NAME}-$(date +%Y%m)"
KEY_PATH="$SSH_DIR/$KEY_NAME"
command -v rclone >/dev/null || die "rclone is not installed"
mkdir -p "$(dirname "$RCLONE_CONF")"
header "Checking Tunnel"
info "Scanning spoke host key..."
KEYSCAN=$(ssh-keyscan -p "$TUNNEL_PORT" -H localhost 2>/dev/null)
[ -n "$KEYSCAN" ] || die "Spoke not reachable on port $TUNNEL_PORT — is the tunnel up?"
echo "$KEYSCAN" >> "$SSH_DIR/known_hosts"
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\" armbian@localhost exit" \
"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"
@@ -75,16 +96,28 @@ else
ssh-keygen -t ed25519 -f "$KEY_PATH" -N ""
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 armbian@localhost:$TUNNEL_PORT..."
info "(You will be prompted for the armbian password on the spoke)"
ssh-copy-id -i "$KEY_PATH.pub" -p "$TUNNEL_PORT" armbian@localhost
info "Key copied."
info "Running ssh-copy-id to $SPOKE_USER@localhost:$TUNNEL_PORT..."
info "(You will be prompted for the $SPOKE_USER password on the spoke)"
if ssh-copy-id -i "$KEY_PATH.pub" -p "$TUNNEL_PORT" "$SPOKE_USER"@localhost; then
info "Key copied."
else
warn "ssh-copy-id failed — password auth may be disabled on the spoke."
warn "Manually append the hub public key to the spoke's authorized_keys:"
echo ""
echo " cat $KEY_PATH.pub"
echo " Then on the spoke, append the output to:"
echo " /home/$SPOKE_USER/.ssh/authorized_keys"
echo ""
read -rp "Press ENTER once the key has been added to the spoke..."
fi
header "Testing Hub -> Spoke Key Auth"
retry_or_abort \
"ssh -i \"$KEY_PATH\" -o BatchMode=yes -o ConnectTimeout=10 -p \"$TUNNEL_PORT\" armbian@localhost exit" \
"ssh -i \"$KEY_PATH\" -o BatchMode=yes -o ConnectTimeout=10 -p \"$TUNNEL_PORT\" \"$SPOKE_USER\"@localhost exit" \
"Key auth failed. Check authorized_keys on the spoke."
info "Key auth to spoke successful."
@@ -92,6 +125,7 @@ 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
[ -s "$RCLONE_CONF" ] && tail -c1 "$RCLONE_CONF" | grep -qv $'\n' && echo "" >> "$RCLONE_CONF"
cat >> "$RCLONE_CONF" <<EOF
[${SPOKE_NAME}-remote]
@@ -106,6 +140,82 @@ EOF
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 this spoke (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:" <<'PYEOF'
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")
PYEOF
)
if [ "$ALREADY" = "yes" ]; then
warn "Upstream for ${SPOKE_NAME}-remote already in union remote [${UNION_NAME}], skipping."
else
python3 - "$RCLONE_CONF" "$UNION_NAME" "$UPSTREAM" <<'PYEOF'
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)
PYEOF
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."
@@ -113,6 +223,18 @@ 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 "Onboarding Complete"
echo -e " Spoke: ${GREEN}$SPOKE_NAME${NC}"
echo -e " Port: ${GREEN}$TUNNEL_PORT${NC}"

View File

@@ -1,18 +0,0 @@
[brie-remote]
type = sftp
host = localhost
port = 11111
key_file = /home/armbian/.ssh/armbian-brie-202604
shell_type = unix
md5sum_command = md5sum
sha1sum_command = sha1sum
#[new-remote]
#type = sftp
#host = localhost
#port = 11112
#key_file = /home/armbian/.ssh/a new priv key for tunnel back to new spoke
#shell_type = unix
#md5sum_command = md5sum
#sha1sum_command = sha1sum

121
hub/setup-hub.sh Normal file → Executable file
View File

@@ -12,8 +12,47 @@ warn() { echo -e "${YELLOW}[!]${NC} $*"; }
die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; }
check_deps() {
local missing=()
for cmd in "$@"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
die "Missing required dependencies: ${missing[*]}"
fi
}
check_permissions() {
local file="$1"
local label="$2"
if [ ! -f "$file" ]; then
warn "Permission check: $label not found at $file"
return
fi
local perms
perms=$(stat -c "%a" "$file" 2>/dev/null || stat -f "%OLp" "$file" 2>/dev/null)
if [ -z "$perms" ]; then
warn "Could not read permissions for $label ($file)"
return
fi
local world="${perms: -1}"
local group="${perms: -2:1}"
if [ "$world" != "0" ] || [ "$group" != "0" ]; then
warn "UNSAFE PERMISSIONS on $label ($file): $perms — should be 600 or 400"
warn "Fixing permissions automatically..."
chmod 600 "$file"
info "Permissions fixed: $file is now 600"
else
info "Permissions OK: $label ($file) = $perms"
fi
}
[ "$(id -u)" -eq 0 ] || die "Run as root"
check_deps ssh ssh-keygen systemctl useradd groupadd file
header "TinyBoard Hub Setup"
read -rp "Hub username [armbian]: " HUB_USER
@@ -112,24 +151,62 @@ 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 yes" "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}"
if [[ "${DISABLE_PASS,,}" == "y" ]]; then
if [ ! -s "$SSH_DIR/authorized_keys" ]; then
warn "No keys found in $SSH_DIR/authorized_keys — skipping password auth disable to avoid lockout."
else
if grep -q "^PasswordAuthentication" "$SSHD_CONF"; then
sed -i "s/^PasswordAuthentication.*/PasswordAuthentication no/" "$SSHD_CONF"
else
echo "PasswordAuthentication no" >> "$SSHD_CONF"
fi
if grep -q "^PubkeyAuthentication" "$SSHD_CONF"; then
sed -i "s/^PubkeyAuthentication.*/PubkeyAuthentication yes/" "$SSHD_CONF"
else
echo "PubkeyAuthentication yes" >> "$SSHD_CONF"
fi
info "Password authentication disabled for $HUB_USER."
echo ""
warn "Restarting SSH will apply the new settings."
warn "If you are connected via SSH, your session may drop."
warn "Make sure you can reconnect using your key before continuing."
read -rp "Press ENTER to restart SSH or CTRL+C to abort..."
if [ -n "$SSH_SVC" ] && systemctl restart "$SSH_SVC" 2>/dev/null; then
info "SSH restarted."
else
warn "Could not restart SSH — please restart it manually."
fi
fi
else
info "Password authentication left enabled."
fi
header "FUSE Configuration"
FUSE_CONF="/etc/fuse.conf"
@@ -165,6 +242,21 @@ else
warn "rclone.conf already exists, skipping."
fi
header "Permission Checks"
info "Checking SSH directory permissions..."
check_permissions "$SSH_DIR/authorized_keys" "authorized_keys"
check_permissions "$RCLONE_CONF" "rclone.conf"
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
MOUNT_POINT="${MOUNT_POINT:-/mnt/hub}"
@@ -172,22 +264,9 @@ mkdir -p "$MOUNT_POINT"
chown "$HUB_USER":"$HUB_USER" "$MOUNT_POINT"
info "Mount point created at $MOUNT_POINT."
header "Crontab Setup"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HELPER="$SCRIPT_DIR/../hubspoke-helper.sh"
CRON_LINE="@reboot $HELPER hub start-background"
EXISTING=$(crontab -u "$HUB_USER" -l 2>/dev/null || true)
if echo "$EXISTING" | grep -qF "hub start-background"; then
warn "Crontab entry already exists, skipping."
else
(echo "$EXISTING"; echo "$CRON_LINE") | crontab -u "$HUB_USER" -
info "Added @reboot crontab entry for rclone mount."
fi
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 yes, AllowTcpForwarding yes, ClientAliveInterval 60${NC}"
echo -e " FUSE: ${GREEN}user_allow_other enabled${NC}"
echo -e " rclone config: ${GREEN}$RCLONE_CONF${NC}"
echo -e " Mount point: ${GREEN}$MOUNT_POINT${NC}"

View File

@@ -1,221 +0,0 @@
#!/usr/bin/env bash
#
# hubspoke-helper.sh - Manage hub/spoke rclone mounts
# Assumes spoke Docker files exist in ~/autossh-tunnel/
# Simplified hub mount uses direct rclone commands (no systemd services)
set -euo pipefail
# ------------------------------------------------------------
# Configuration (override with env vars if needed)
# ------------------------------------------------------------
TUNNEL_DIR="${TUNNEL_DIR:-$HOME/tinyboard/spoke}"
COMPOSE_FILE="${COMPOSE_FILE:-$TUNNEL_DIR/compose.yaml}"
RCLONE_REMOTE="${RCLONE_REMOTE:-brie-remote}"
MOUNT_POINT="${MOUNT_POINT:-/mnt/hub/$RCLONE_REMOTE}"
# ------------------------------------------------------------
# Usage
# ------------------------------------------------------------
usage() {
cat <<EOF
Usage: $0 {hub|spoke} {action}
SPOKE ACTIONS (docker-based, no systemd):
build Build the autossh image (run once)
start Start the tunnel container
stop Stop the tunnel container
restart Restart the tunnel container
status Show container status
logs Show container logs
show-cmd Show manual autossh command (non-docker)
HUB ACTIONS (simplified rclone mount - no systemd):
install Show simplified setup instructions
start Start rclone mount in background (uses nohup)
start-background Start rclone mount in background (for crontab)
stop Stop rclone mount
status Check mount status
mount Manual foreground mount (testing)
unmount Unmount manually
EXAMPLES:
$0 spoke build
$0 spoke start
$0 hub install
$0 hub start
EOF
}
die() {
echo "ERROR: $*" >&2
exit 1
}
# ------------------------------------------------------------
# Spoke actions (docker)
# ------------------------------------------------------------
spoke_build() {
if [ ! -f "$COMPOSE_FILE" ]; then
die "docker-compose.yaml not found at $COMPOSE_FILE"
fi
cd "$TUNNEL_DIR"
docker build --build-arg UID=$(id -u armbian) --build-arg GID=$(id -g armbian) -t spoke-autossh .
echo "Image built. Use '$0 spoke start' to run."
}
spoke_start() {
cd "$TUNNEL_DIR"
docker-compose up -d
}
spoke_stop() {
cd "$TUNNEL_DIR"
docker-compose down
}
spoke_restart() {
cd "$TUNNEL_DIR"
docker-compose restart
}
spoke_status() {
docker ps --filter name=spoke-autossh --format "table {{.Names}}\t{{.Status}}"
}
spoke_logs() {
cd "$TUNNEL_DIR"
docker-compose logs --tail=50 -f
}
spoke_show_cmd() {
cat <<EOF
Manual autossh command (run on spoke):
autossh -M 0 -NT -o "ServerAliveInterval=60" -o "ServerAliveCountMax=3" \\
-R 11111:localhost:22 -i ~/.ssh/oilykey2026 armbian@oily.dad
EOF
}
# ------------------------------------------------------------
# Hub actions (simplified - no systemd templates)
# ------------------------------------------------------------
hub_install() {
echo "Simplified hub setup:"
echo ""
echo "1. Ensure /etc/fuse.conf has 'user_allow_other' uncommented:"
echo " sudo sed -i 's/^#user_allow_other/user_allow_other/' /etc/fuse.conf"
echo ""
echo "2. Ensure you're in the 'fuse' group:"
echo " sudo usermod -aG fuse $USER"
echo " (You may need to log out and back in for this to take effect)"
echo ""
echo "3. Create mount point directory:"
echo " mkdir -p \"$MOUNT_POINT\""
echo ""
echo "4. Test manual mount:"
echo " $0 hub mount"
echo ""
echo "5. For auto-start, consider adding to crontab with @reboot:"
echo " crontab -e"
echo " Add: @reboot $0 hub start-background"
echo ""
echo "Note: This simplified version doesn't use systemd services."
}
hub_start() {
echo "Starting rclone mount in background..."
mkdir -p "$MOUNT_POINT"
nohup rclone mount "${RCLONE_REMOTE}:" "$MOUNT_POINT" \
--config "${HOME}/.config/rclone/rclone.conf" \
--vfs-cache-mode writes \
--allow-other \
--daemon >/dev/null 2>&1 &
echo "Mount started in background (PID: $!)"
echo "Check status with: $0 hub status"
}
hub_start_background() {
# Internal function for crontab/auto-start
mkdir -p "$MOUNT_POINT"
rclone mount "${RCLONE_REMOTE}:" "$MOUNT_POINT" \
--config "${HOME}/.config/rclone/rclone.conf" \
--vfs-cache-mode writes \
--allow-other \
--daemon
}
hub_stop() {
echo "Stopping rclone mount..."
if hub_unmount; then
echo "Mount stopped."
else
echo "Could not unmount. Trying force unmount..."
fusermount -uz "$MOUNT_POINT" 2>/dev/null && echo "Force unmounted." || echo "Still could not unmount."
fi
}
hub_status() {
if mountpoint -q "$MOUNT_POINT" 2>/dev/null; then
echo "Mount point $MOUNT_POINT is mounted."
mount | grep "$MOUNT_POINT"
else
echo "Mount point $MOUNT_POINT is NOT mounted."
echo "Check if rclone process is running:"
pgrep -af rclone || echo "No rclone mount processes found."
fi
}
hub_mount() {
mkdir -p "$MOUNT_POINT"
echo "Mounting in foreground. Press Ctrl+C to unmount."
rclone mount "${RCLONE_REMOTE}:" "$MOUNT_POINT" \
--config "${HOME}/.config/rclone/rclone.conf" \
--vfs-cache-mode writes \
--allow-other
}
hub_unmount() {
fusermount -u "$MOUNT_POINT" 2>/dev/null && echo "Unmounted." || echo "Not mounted."
}
# ------------------------------------------------------------
# Dispatch
# ------------------------------------------------------------
if [ $# -lt 2 ]; then
usage
exit 1
fi
ROLE="$1"
ACTION="$2"
case "$ROLE" in
spoke)
case "$ACTION" in
build) spoke_build ;;
start) spoke_start ;;
stop) spoke_stop ;;
restart) spoke_restart ;;
status) spoke_status ;;
logs) spoke_logs ;;
show-cmd) spoke_show_cmd ;;
*) die "Unknown action for spoke: $ACTION" ;;
esac
;;
hub)
case "$ACTION" in
install) hub_install ;;
start) hub_start ;;
start-background) hub_start_background ;;
stop) hub_stop ;;
status) hub_status ;;
mount) hub_mount ;;
unmount) hub_unmount ;;
*) die "Unknown action for hub: $ACTION" ;;
esac
;;
*)
usage
exit 1
;;
esac

View File

@@ -15,13 +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) Set up this device as a new 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]: " 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..."
@@ -32,6 +39,10 @@ case "$CHOICE" in
exec "$SCRIPT_DIR/hub/onboard-spoke.sh"
;;
3)
info "Starting hub offboarding..."
exec "$SCRIPT_DIR/hub/offboard-spoke.sh"
;;
4)
[ "$(id -u)" -eq 0 ] || die "Hub setup must be run as root"
info "Starting hub setup..."
exec "$SCRIPT_DIR/hub/setup-hub.sh"

View File

@@ -1,10 +1,6 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y autossh openssh-client && rm -rf /var/lib/apt/lists/*
RUN echo "PermitRootLogin no" >> /etc/ssh/sshd_config
RUN echo "PasswordAuthentication no" >> /etc/ssh/sshd_config
RUN echo "Subsystem sftp internal-sftp" >> /etc/ssh/sshd_config
ARG UID=1000
ARG GID=1000
RUN groupadd -g ${GID} armbian && useradd -m -u ${UID} -g armbian armbian

View File

@@ -1,8 +0,0 @@
#!/bin/bash
# Need armbian-config?
apt install -y vim
apt install -y autossh
apt install -y docker.io docker-cli docker-compose
usermod -aG docker armbian

View File

@@ -1,39 +0,0 @@
#!/usr/bin/env bash
# Copy this along with .not_logged_in_yet to armbian root dir, then run after successful login
# Refresh: extract MAC address of wlan0
MAC=$(netplan status -f json | jq -r '.wlan0.macaddress')
# Check that we actually got a MAC address
if [[ -z "$MAC" ]]; then
echo "Error: Could not retrieve MAC address from netplan." >&2
exit 1
fi
echo "Detected MAC address: $MAC"
# Assign cheese hostname based on MAC address
case "$MAC" in
38:9c:80:46:26:c8) # ← Replace with your first real MAC
HOSTNAME="brie"
;;
68:f8:ea:22:e1:3d) # ← Replace with your second real MAC
HOSTNAME="gouda"
;;
99:88:77:66:55:44) # ← Replace with your third real MAC
HOSTNAME="camembert"
;;
*)
echo "Unknown MAC address: $MAC ... hostname not changed." >&2
exit 1
;;
esac
echo "Setting hostname to: $HOSTNAME"
sudo hostnamectl set-hostname "$HOSTNAME"
# Optional: also update /etc/hostname (hostnamectl usually does this, but to be safe)
echo "$HOSTNAME" | sudo tee /etc/hostname >/dev/null
echo "Hostname changed. Reboot or start a new shell to see the change."

View File

@@ -1,22 +0,0 @@
#!/bin/bash
# Script to clean sensitive WiFi credentials and passwords from configuration files
# Usage: ./clean_sensitive.sh [filename]
FILE="${1:-/home/finn/code/tinyboard/armb-not_logged_in_yet}"
echo "Cleaning sensitive data from: $FILE"
# Clean WiFi SSID (both commented and uncommented lines)
sed -i 's/^\(#*PRESET_NET_WIFI_SSID=\).*$/\1"[REDACTED]"/' "$FILE"
# Clean WiFi KEY (both commented and uncommented lines)
sed -i 's/^\(#*PRESET_NET_WIFI_KEY=\).*$/\1"[REDACTED]"/' "$FILE"
# Clean root password
sed -i 's/^\(PRESET_ROOT_PASSWORD=\).*$/\1"[REDACTED]"/' "$FILE"
# Clean user password
sed -i 's/^\(PRESET_USER_PASSWORD=\).*$/\1"[REDACTED]"/' "$FILE"
echo "wiped fields"

View File

@@ -1,4 +1,3 @@
version: '3.8'
services:
autossh:
image: spoke-autossh
@@ -7,7 +6,6 @@ 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"
@@ -18,15 +16,20 @@ services:
volumes:
- /home/armbian/.ssh/oilykey2026:/home/armbian/.ssh/oilykey2026: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
environment:
- PUID=1000
- PGID=1000
ports:
- "127.0.0.1:8384:8384"
- "22000:22000"
volumes:
- /home/armbian/st:/var/syncthing
- syncthing-config:/var/syncthing/config
- /home/armbian/st/data:/var/syncthing/data
volumes:
syncthing-config:

View File

@@ -12,8 +12,22 @@ warn() { echo -e "${YELLOW}[!]${NC} $*"; }
die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; }
check_deps() {
local missing=()
for cmd in "$@"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
die "Missing required dependencies: ${missing[*]}"
fi
}
[ "$(id -u)" -eq 0 ] || die "Run as root"
check_deps ip netplan systemctl ping
header "TinyBoard Network Setup"
info "Available interfaces:"
@@ -64,7 +78,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:"
@@ -99,14 +116,20 @@ 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"
BACKUP_FILE=""
if [ -f "$NETPLAN_FILE" ]; then
cp "$NETPLAN_FILE" "${NETPLAN_FILE}.bak"
info "Backup saved to ${NETPLAN_FILE}.bak"
NETPLAN_BACKUP_DIR="/root/.config/tinyboard/netplan-backups"
mkdir -p "$NETPLAN_BACKUP_DIR"
BACKUP_FILE="$NETPLAN_BACKUP_DIR/$(basename "${NETPLAN_FILE}").$(date +%Y%m%d%H%M%S)"
cp "$NETPLAN_FILE" "$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
@@ -123,7 +146,8 @@ network:
via: ${GATEWAY}
nameservers:
addresses:
$(printf '%b' "$DNS_YAML") access-points:
${DNS_YAML}
access-points:
"${WIFI_SSID}":
password: "${WIFI_PASS}"
NETEOF
@@ -141,19 +165,37 @@ network:
via: ${GATEWAY}
nameservers:
addresses:
$(printf '%b' "$DNS_YAML")
${DNS_YAML}
NETEOF
fi
info "Netplan config written to $NETPLAN_FILE"
header "Applying Configuration"
warn "Testing netplan config..."
if netplan try --timeout 10 2>/dev/null; then
info "Netplan config applied successfully."
warn "Applying netplan config — will revert automatically if network is lost..."
netplan apply
CONNECTED=false
for i in $(seq 1 6); do
sleep 5
if ping -c 1 -W 2 "$GATEWAY" >/dev/null 2>&1; then
CONNECTED=true
break
fi
warn "Network check $i/6 failed, retrying..."
done
if [ "$CONNECTED" = "true" ]; then
info "Network connectivity confirmed — config applied permanently."
else
warn "netplan try timed out or failed — applying anyway..."
warn "No network connectivity detected after 30 seconds — reverting to backup config."
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."
else
die "No backup found to revert to. Restore $NETPLAN_FILE manually."
fi
fi
STATIC_ADDR="${STATIC_IP%%/*}"
@@ -162,6 +204,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 ""

193
spoke/setup-spoke.sh Normal file → Executable file
View File

@@ -4,6 +4,8 @@ set -euo pipefail
HUB_HOST=""
HUB_USER=""
SPOKE_USER=""
KEY_PATH=""
KEY_NAME=""
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SPOKE_DIR="$SCRIPT_DIR"
COMPOSE="$SPOKE_DIR/compose.yaml"
@@ -15,6 +17,23 @@ YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; }
check_deps() {
local missing=()
for cmd in "$@"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
die "Missing required dependencies: ${missing[*]}"
fi
}
retry_or_abort() {
local test_cmd="$1"
local fail_msg="$2"
@@ -34,13 +53,35 @@ retry_or_abort() {
done
}
info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; }
check_permissions() {
local file="$1"
local label="$2"
if [ ! -f "$file" ]; then
warn "Permission check: $label not found at $file"
return
fi
local perms
perms=$(stat -c "%a" "$file" 2>/dev/null || stat -f "%OLp" "$file" 2>/dev/null)
if [ -z "$perms" ]; then
warn "Could not read permissions for $label ($file)"
return
fi
local world="${perms: -1}"
local group="${perms: -2:1}"
if [ "$world" != "0" ] || [ "$group" != "0" ]; then
warn "UNSAFE PERMISSIONS on $label ($file): $perms — should be 600 or 400"
warn "Fixing permissions automatically..."
chmod 600 "$file"
info "Permissions fixed: $file is now 600"
else
info "Permissions OK: $label ($file) = $perms"
fi
}
[ "$(id -u)" -eq 0 ] || die "Run as root"
check_deps ip ssh ssh-keygen ssh-keyscan systemctl hostnamectl
read -rp "Hub hostname [oily.dad]: " HUB_HOST
HUB_HOST="${HUB_HOST:-oily.dad}"
read -rp "Hub SSH user [armbian]: " HUB_USER
@@ -115,19 +156,27 @@ 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
SSHD_CONF="/etc/ssh/sshd_config"
header "Hostname Setup"
CURRENT_HOSTNAME=$(hostname)
echo -e "Current hostname: ${YELLOW}$CURRENT_HOSTNAME${NC}"
read -rp "Enter a hostname for this spoke (e.g. rocky, gouda, camembert): " SPOKE_NAME
SPOKE_NAME="${SPOKE_NAME:-$CURRENT_HOSTNAME}"
[[ "$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"
@@ -187,11 +236,56 @@ 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}"
if [[ "${DISABLE_PASS,,}" == "y" ]]; then
if [ ! -f "$KEY_PATH" ]; then
warn "No key found at $KEY_PATH — skipping password auth disable to avoid lockout."
else
if grep -q "^PasswordAuthentication" "$SSHD_CONF"; then
sed -i "s|^PasswordAuthentication.*|PasswordAuthentication no|" "$SSHD_CONF"
else
echo "PasswordAuthentication no" >> "$SSHD_CONF"
fi
if grep -q "^PubkeyAuthentication" "$SSHD_CONF"; then
sed -i "s|^PubkeyAuthentication.*|PubkeyAuthentication yes|" "$SSHD_CONF"
else
echo "PubkeyAuthentication yes" >> "$SSHD_CONF"
fi
info "Password authentication disabled for $SPOKE_USER."
echo ""
warn "Restarting SSH will apply the new settings."
warn "If you are connected via SSH, your session may drop."
warn "Make sure you can reconnect using your key before continuing."
read -rp "Press ENTER to restart SSH or CTRL+C to abort..."
if [ -n "$SSH_SVC" ] && systemctl restart "$SSH_SVC" 2>/dev/null; then
info "SSH restarted."
else
warn "Could not restart SSH — please restart it manually."
fi
fi
else
info "Password authentication left enabled."
fi
info "Checking SSH key permissions..."
check_permissions "$KEY_PATH" "spoke SSH private key"
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
done <<< "$HUB_KEYSCAN"
fi
check_permissions "$SSH_DIR/known_hosts" "known_hosts"
header "Testing SSH Connection"
info "Testing connection to $HUB_HOST..."
@@ -201,34 +295,46 @@ 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
warn "Port $port is in use, trying next..."
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"
mkdir -p "$SYNCTHING_MOUNT"
chown "$SPOKE_USER":"$SPOKE_USER" "$SYNCTHING_MOUNT"
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/armbian/.ssh/[^:]*:/home/armbian/.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|PUID=[0-9]*|PUID=${SPOKE_UID}|g" "$COMPOSE"
sed -i "s|PGID=[0-9]*|PGID=${SPOKE_GID}|g" "$COMPOSE"
sed -i "s|container_name: spoke-autossh|container_name: ${SPOKE_NAME}-autossh|g" "$COMPOSE"
sed -i "s|container_name: spoke-syncthing|container_name: ${SPOKE_NAME}-syncthing|g" "$COMPOSE"
sed -i "s|hostname: spoke-syncthing|hostname: ${SPOKE_NAME}-syncthing|g" "$COMPOSE"
sed -i '/^version:/d' "$COMPOSE"
SYNCTHING_MOUNT="$ARMBIAN_HOME/st"
mkdir -p "$SYNCTHING_MOUNT"
chown "$SPOKE_USER":"$SPOKE_USER" "$SYNCTHING_MOUNT"
header "Building Docker Image"
cd "$SPOKE_DIR"
@@ -238,17 +344,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"
@@ -259,17 +376,17 @@ echo ""
echo -e "${YELLOW}The hub owner needs to do the following on ${HUB_HOST}:${NC}"
echo ""
echo " 1. Generate a hub->spoke key:"
echo " ssh-keygen -t ed25519 -f ~/.ssh/armbian-${SPOKE_NAME}-$(date +%Y%m)"
echo " ssh-keygen -t ed25519 -f ~/.ssh/${HUB_USER}-${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 " ssh-copy-id -i ~/.ssh/${HUB_USER}-${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 " key_file = /home/$HUB_USER/.ssh/${HUB_USER}-${SPOKE_NAME}-$(date +%Y%m)"
echo " shell_type = unix"
echo " md5sum_command = md5sum"
echo " sha1sum_command = sha1sum"

466
syncthing.sh Executable file
View File

@@ -0,0 +1,466 @@
#!/usr/bin/env bash
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; }
ST_URL="http://127.0.0.1:8384"
APIKEY=""
ST_CONTAINER=""
get_apikey() {
ST_CONTAINER=$(docker ps --format '{{.Names}}' | grep -i syncthing | head -1 || true)
[ -n "$ST_CONTAINER" ] || die "No running Syncthing container found."
APIKEY=$(docker exec "$ST_CONTAINER" python3 -c "import xml.etree.ElementTree as ET; print(ET.parse('/var/syncthing/config/config.xml').find('.//apikey').text)" 2>/dev/null || true)
[ -n "$APIKEY" ] || die "Could not read Syncthing API key from container '$ST_CONTAINER'."
}
st_get() {
curl -sf -H "X-API-Key: $APIKEY" "${ST_URL}${1}" || die "API call failed: GET ${1}"
}
st_post() {
curl -sf -X POST -H "X-API-Key: $APIKEY" -H "Content-Type: application/json" -d "$2" "${ST_URL}${1}" || die "API call failed: POST ${1}"
}
st_put() {
curl -sf -X PUT -H "X-API-Key: $APIKEY" -H "Content-Type: application/json" -d "$2" "${ST_URL}${1}" || die "API call failed: PUT ${1}"
}
st_delete() {
curl -sf -X DELETE -H "X-API-Key: $APIKEY" "${ST_URL}${1}" || die "API call failed: DELETE ${1}"
}
check_deps() {
local missing=()
for cmd in "$@"; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
die "Missing required dependencies: ${missing[*]}"
fi
}
show_own_device_id() {
header "This Device's ID"
local id
id=$(st_get /rest/system/status | python3 -c 'import sys,json; print(json.load(sys.stdin)["myID"])')
echo -e " ${GREEN}${id}${NC}"
echo ""
}
show_pending_devices() {
header "Pending Devices"
local pending
pending=$(st_get /rest/cluster/pending/devices)
local count
count=$(echo "$pending" | python3 -c 'import sys,json; d=json.load(sys.stdin); print(len(d))')
if [ "$count" -eq 0 ]; then
warn "No pending devices."
return
fi
echo "$pending" | python3 -c '
import sys, json
devices = json.load(sys.stdin)
for device_id, info in devices.items():
name = info.get("name", "(unknown)")
print(f" Name: {name}")
print(f" ID: {device_id}")
print()
'
read -rp "Add a pending device? [y/N]: " ADD_PENDING
if [[ "${ADD_PENDING,,}" == "y" ]]; then
add_device_by_pending "$pending"
fi
}
add_device_by_pending() {
local pending="$1"
local ids
ids=$(echo "$pending" | python3 -c 'import sys,json; [print(k) for k in json.load(sys.stdin)]')
local id_list=()
while IFS= read -r line; do
id_list+=("$line")
done <<< "$ids"
if [ ${#id_list[@]} -eq 1 ]; then
DEVICE_ID="${id_list[0]}"
local pending_name
pending_name=$(echo "$pending" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[sys.argv[1]].get('name',''))" "$DEVICE_ID")
else
echo "Pending devices:"
local i=1
for id in "${id_list[@]}"; do
local name
name=$(echo "$pending" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[sys.argv[1]].get('name','(unknown)'))" "$id")
echo " $i) $name$id"
i=$((i+1))
done
read -rp "Choose [1-${#id_list[@]}]: " CHOICE
[[ "$CHOICE" =~ ^[0-9]+$ ]] && [ "$CHOICE" -ge 1 ] && [ "$CHOICE" -le "${#id_list[@]}" ] || die "Invalid choice."
DEVICE_ID="${id_list[$((CHOICE-1))]}"
pending_name=$(echo "$pending" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[sys.argv[1]].get('name',''))" "$DEVICE_ID")
fi
read -rp "Device name [${pending_name:-new-device}]: " DEVICE_NAME
DEVICE_NAME="${DEVICE_NAME:-${pending_name:-new-device}}"
_do_add_device "$DEVICE_ID" "$DEVICE_NAME"
}
list_devices() {
header "Devices"
st_get /rest/config/devices | python3 -c '
import sys, json
devices = json.load(sys.stdin)
if not devices:
print(" No devices configured.")
else:
for d in devices:
print(" Name: " + d["name"])
print(" ID: " + d["deviceID"])
print()
'
}
add_device() {
header "Add Device"
read -rp "Device ID: " DEVICE_ID
[ -n "$DEVICE_ID" ] || die "Device ID cannot be empty."
read -rp "Device name: " DEVICE_NAME
[ -n "$DEVICE_NAME" ] || die "Device name cannot be empty."
_do_add_device "$DEVICE_ID" "$DEVICE_NAME"
}
_do_add_device() {
local device_id="$1"
local device_name="$2"
local existing
existing=$(st_get /rest/config/devices)
local already
already=$(echo "$existing" | python3 -c "import sys,json; devs=json.load(sys.stdin); print(any(d['deviceID']==sys.argv[1] for d in devs))" "$device_id")
if [ "$already" = "True" ]; then
warn "Device '${device_name}' is already configured."
return
fi
local payload
payload=$(python3 -c "import sys,json; print(json.dumps({'deviceID':sys.argv[1],'name':sys.argv[2],'addresses':['dynamic'],'autoAcceptFolders':False}))" "$device_id" "$device_name")
st_post /rest/config/devices "$payload" >/dev/null
info "Device '${device_name}' added."
}
remove_device() {
header "Remove Device"
local devices
devices=$(st_get /rest/config/devices)
local count
count=$(echo "$devices" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))')
[ "$count" -gt 0 ] || { warn "No devices configured."; return; }
echo "$devices" | python3 -c '
import sys, json
for i, d in enumerate(json.load(sys.stdin), 1):
print(" " + str(i) + ") " + d["name"] + " — " + d["deviceID"])
'
echo ""
read -rp "Choose device to remove [1-${count}]: " CHOICE
[[ "$CHOICE" =~ ^[0-9]+$ ]] && [ "$CHOICE" -ge 1 ] && [ "$CHOICE" -le "$count" ] || die "Invalid choice."
local device_id device_name
device_id=$(echo "$devices" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[$((CHOICE-1))]['deviceID'])")
device_name=$(echo "$devices" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[$((CHOICE-1))]['name'])")
local folders
folders=$(st_get /rest/config/folders)
local shared_folders
shared_folders=$(echo "$folders" | python3 -c "
import sys, json
folders = json.load(sys.stdin)
shared = [f['label'] or f['id'] for f in folders if any(dev['deviceID']==sys.argv[1] for dev in f.get('devices',[]))]
print('\n'.join(shared))
" "$device_id")
if [ -n "$shared_folders" ]; then
warn "Device '${device_name}' is still sharing these folders:"
echo "$shared_folders" | sed 's/^/ /'
warn "Removing the device will unshare these folders from it."
fi
read -rp "Remove '${device_name}'? [y/N]: " CONFIRM
[[ "${CONFIRM,,}" == "y" ]] || { info "Aborted."; return; }
st_delete "/rest/config/devices/${device_id}" >/dev/null
info "Device '${device_name}' removed."
}
list_folders() {
header "Folders"
st_get /rest/config/folders | python3 -c '
import sys, json
folders = json.load(sys.stdin)
if not folders:
print(" No folders configured.")
else:
for f in folders:
label = f["label"] or f["id"]
shared = len(f.get("devices", []))
print(" Label: " + label)
print(" ID: " + f["id"])
print(" Path: " + f["path"])
print(" Shared: " + str(shared) + " device(s)")
print()
'
}
add_folder() {
header "Add Folder"
if [ -n "$ST_CONTAINER" ]; then
echo "Available folders in /var/syncthing/data/:"
docker exec "$ST_CONTAINER" ls /var/syncthing/data/ 2>/dev/null | sed 's/^/ /' || warn "Could not list data directory."
echo ""
fi
read -rp "Folder path on this device (e.g. /var/syncthing/data/books): " FOLDER_PATH
[ -n "$FOLDER_PATH" ] || die "Path cannot be empty."
read -rp "Folder label (human-readable name): " FOLDER_LABEL
[ -n "$FOLDER_LABEL" ] || die "Label cannot be empty."
read -rp "Folder ID (leave blank to use label): " FOLDER_ID
FOLDER_ID="${FOLDER_ID:-$FOLDER_LABEL}"
local existing
existing=$(st_get /rest/config/folders)
local already
already=$(echo "$existing" | python3 -c "import sys,json; folders=json.load(sys.stdin); print(any(f['id']==sys.argv[1] for f in folders))" "$FOLDER_ID")
[ "$already" = "False" ] || die "Folder ID '${FOLDER_ID}' already exists."
local payload
payload=$(python3 -c "import sys,json; print(json.dumps({'id':sys.argv[1],'label':sys.argv[2],'path':sys.argv[3],'type':'sendreceive','devices':[]}))" "$FOLDER_ID" "$FOLDER_LABEL" "$FOLDER_PATH")
st_post /rest/config/folders "$payload" >/dev/null
info "Folder '${FOLDER_LABEL}' added."
}
remove_folder() {
header "Remove Folder"
local folders
folders=$(st_get /rest/config/folders)
local count
count=$(echo "$folders" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))')
[ "$count" -gt 0 ] || { warn "No folders configured."; return; }
echo "$folders" | python3 -c '
import sys, json
for i, f in enumerate(json.load(sys.stdin), 1):
label = f["label"] or f["id"]
print(" " + str(i) + ") " + label + " (" + f["path"] + ")")
'
echo ""
read -rp "Choose folder to remove [1-${count}]: " CHOICE
[[ "$CHOICE" =~ ^[0-9]+$ ]] && [ "$CHOICE" -ge 1 ] && [ "$CHOICE" -le "$count" ] || die "Invalid choice."
local folder_id folder_label
folder_id=$(echo "$folders" | python3 -c "import sys,json; f=json.load(sys.stdin)[$((CHOICE-1))]; print(f['id'])")
folder_label=$(echo "$folders" | python3 -c "import sys,json; f=json.load(sys.stdin)[$((CHOICE-1))]; print(f['label'] or f['id'])")
local folder_config shared_devices
folder_config=$(st_get "/rest/config/folders/${folder_id}")
shared_devices=$(echo "$folder_config" | python3 -c "import sys,json; [print(d['deviceID']) for d in json.load(sys.stdin).get('devices',[])]")
if [ -n "$shared_devices" ]; then
local devices
devices=$(st_get /rest/config/devices)
warn "Folder '${folder_label}' is currently shared with:"
while IFS= read -r did; do
local dname
dname=$(echo "$devices" | python3 -c "import sys,json; devs=json.load(sys.stdin); match=[d['name'] for d in devs if d['deviceID']==sys.argv[1]]; print(match[0] if match else sys.argv[1])" "$did")
echo " - $dname"
done <<< "$shared_devices"
warn "It will be unshared from all devices before removal."
read -rp "Proceed? [y/N]: " CONFIRM
[[ "${CONFIRM,,}" == "y" ]] || { info "Aborted."; return; }
local updated
updated=$(echo "$folder_config" | python3 -c "
import sys, json
f = json.load(sys.stdin)
f['devices'] = []
print(json.dumps(f))
")
st_put "/rest/config/folders/${folder_id}" "$updated" >/dev/null
info "Folder '${folder_label}' unshared from all devices."
else
read -rp "Remove folder '${folder_label}'? [y/N]: " CONFIRM
[[ "${CONFIRM,,}" == "y" ]] || { info "Aborted."; return; }
fi
st_delete "/rest/config/folders/${folder_id}" >/dev/null
info "Folder '${folder_label}' removed."
}
share_folder() {
header "Share Folder with Device"
local folders devices
folders=$(st_get /rest/config/folders)
devices=$(st_get /rest/config/devices)
local f_count d_count
f_count=$(echo "$folders" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))')
d_count=$(echo "$devices" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))')
[ "$f_count" -gt 0 ] || { warn "No folders configured."; return; }
[ "$d_count" -gt 0 ] || { warn "No devices configured."; return; }
echo "Folders:"
echo "$folders" | python3 -c '
import sys, json
for i, f in enumerate(json.load(sys.stdin), 1):
label = f["label"] or f["id"]
print(" " + str(i) + ") " + label)
'
echo ""
read -rp "Choose folder [1-${f_count}]: " F_CHOICE
[[ "$F_CHOICE" =~ ^[0-9]+$ ]] && [ "$F_CHOICE" -ge 1 ] && [ "$F_CHOICE" -le "$f_count" ] || die "Invalid choice."
echo ""
echo "Devices:"
echo "$devices" | python3 -c '
import sys, json
for i, d in enumerate(json.load(sys.stdin), 1):
print(" " + str(i) + ") " + d["name"])
'
echo ""
read -rp "Choose device [1-${d_count}]: " D_CHOICE
[[ "$D_CHOICE" =~ ^[0-9]+$ ]] && [ "$D_CHOICE" -ge 1 ] && [ "$D_CHOICE" -le "$d_count" ] || die "Invalid choice."
local folder_id device_id device_name folder_label
folder_id=$(echo "$folders" | python3 -c "import sys,json; f=json.load(sys.stdin)[$((F_CHOICE-1))]; print(f['id'])")
folder_label=$(echo "$folders" | python3 -c "import sys,json; f=json.load(sys.stdin)[$((F_CHOICE-1))]; print(f['label'] or f['id'])")
device_id=$(echo "$devices" | python3 -c "import sys,json; d=json.load(sys.stdin)[$((D_CHOICE-1))]; print(d['deviceID'])")
device_name=$(echo "$devices" | python3 -c "import sys,json; d=json.load(sys.stdin)[$((D_CHOICE-1))]; print(d['name'])")
local folder_config already
folder_config=$(st_get "/rest/config/folders/${folder_id}")
already=$(echo "$folder_config" | python3 -c "import sys,json; f=json.load(sys.stdin); print(any(d['deviceID']==sys.argv[1] for d in f.get('devices',[])))" "$device_id")
if [ "$already" = "True" ]; then
warn "Folder '${folder_label}' is already shared with '${device_name}'."
return
fi
local updated
updated=$(echo "$folder_config" | python3 -c "
import sys, json
f = json.load(sys.stdin)
f['devices'].append({'deviceID': sys.argv[1], 'introducedBy': ''})
print(json.dumps(f))
" "$device_id")
st_put "/rest/config/folders/${folder_id}" "$updated" >/dev/null
info "Folder '${folder_label}' shared with '${device_name}'."
}
unshare_folder() {
header "Unshare Folder from Device"
local folders devices
folders=$(st_get /rest/config/folders)
devices=$(st_get /rest/config/devices)
local f_count
f_count=$(echo "$folders" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))')
[ "$f_count" -gt 0 ] || { warn "No folders configured."; return; }
echo "Folders:"
echo "$folders" | python3 -c '
import sys, json
for i, f in enumerate(json.load(sys.stdin), 1):
label = f["label"] or f["id"]
shared = len(f.get("devices", []))
print(" " + str(i) + ") " + label + " (" + str(shared) + " device(s))")
'
echo ""
read -rp "Choose folder [1-${f_count}]: " F_CHOICE
[[ "$F_CHOICE" =~ ^[0-9]+$ ]] && [ "$F_CHOICE" -ge 1 ] && [ "$F_CHOICE" -le "$f_count" ] || die "Invalid choice."
local folder_id folder_label folder_config
folder_id=$(echo "$folders" | python3 -c "import sys,json; f=json.load(sys.stdin)[$((F_CHOICE-1))]; print(f['id'])")
folder_label=$(echo "$folders" | python3 -c "import sys,json; f=json.load(sys.stdin)[$((F_CHOICE-1))]; print(f['label'] or f['id'])")
folder_config=$(st_get "/rest/config/folders/${folder_id}")
local shared_count
shared_count=$(echo "$folder_config" | python3 -c 'import sys,json; print(len(json.load(sys.stdin).get("devices",[])))')
[ "$shared_count" -gt 0 ] || { warn "Folder '${folder_label}' is not shared with any devices."; return; }
local shared_ids
shared_ids=$(echo "$folder_config" | python3 -c "import sys,json; [print(d['deviceID']) for d in json.load(sys.stdin).get('devices',[])]")
echo ""
echo "Shared with:"
local i=1
while IFS= read -r did; do
local dname
dname=$(echo "$devices" | python3 -c "import sys,json; devs=json.load(sys.stdin); match=[d['name'] for d in devs if d['deviceID']==sys.argv[1]]; print(match[0] if match else sys.argv[1])" "$did")
echo " $i) $dname$did"
i=$((i+1))
done <<< "$shared_ids"
local shared_count_actual=$((i-1))
echo ""
read -rp "Choose device to unshare [1-${shared_count_actual}]: " D_CHOICE
[[ "$D_CHOICE" =~ ^[0-9]+$ ]] && [ "$D_CHOICE" -ge 1 ] && [ "$D_CHOICE" -le "$shared_count_actual" ] || die "Invalid choice."
local target_id
target_id=$(echo "$shared_ids" | sed -n "${D_CHOICE}p")
local target_name
target_name=$(echo "$devices" | python3 -c "import sys,json; devs=json.load(sys.stdin); match=[d['name'] for d in devs if d['deviceID']==sys.argv[1]]; print(match[0] if match else sys.argv[1])" "$target_id")
read -rp "Unshare '${folder_label}' from '${target_name}'? [y/N]: " CONFIRM
[[ "${CONFIRM,,}" == "y" ]] || { info "Aborted."; return; }
local updated
updated=$(echo "$folder_config" | python3 -c "
import sys, json
f = json.load(sys.stdin)
f['devices'] = [d for d in f['devices'] if d['deviceID'] != sys.argv[1]]
print(json.dumps(f))
" "$target_id")
st_put "/rest/config/folders/${folder_id}" "$updated" >/dev/null
info "Folder '${folder_label}' unshared from '${target_name}'."
}
check_deps curl docker python3
get_apikey
while true; do
header "Syncthing Manager"
echo " 0) Show This Device's ID"
echo " 1) Pending Devices"
echo " 2) List Devices"
echo " 3) Add Device"
echo " 4) Remove Device"
echo " 5) List Folders"
echo " 6) Add Folder"
echo " 7) Remove Folder"
echo " 8) Share Folder with Device"
echo " 9) Unshare Folder from Device"
echo " q) Quit"
echo ""
read -rp "Choose: " OPT
echo ""
case "$OPT" in
0) show_own_device_id ;;
1) show_pending_devices ;;
2) list_devices ;;
3) add_device ;;
4) remove_device ;;
5) list_folders ;;
6) add_folder ;;
7) remove_folder ;;
8) share_folder ;;
9) unshare_folder ;;
q|Q) echo "Bye."; exit 0 ;;
*) warn "Invalid choice." ;;
esac
done