forked from finn/tinyboard
Compare commits
69 Commits
b706dd211d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
88fabcf25f | ||
|
|
51f661766f | ||
|
|
5326823b81 | ||
|
|
0f76283605 | ||
|
|
a02a83cae4 | ||
|
|
4a1983d46d | ||
|
|
395ab4ed0e | ||
|
|
4c08f3b389 | ||
|
|
ccd324dc79 | ||
|
|
664bdeaed4 | ||
|
|
ae49c58b13 | ||
|
|
119b747dda | ||
|
|
ea72b14696 | ||
|
|
26110ce8d3 | ||
|
|
58f6445c72 | ||
|
|
08799f0f7f | ||
|
|
a79b1c59b8 | ||
|
|
7e64156026 | ||
|
|
3d366cd74a | ||
|
|
d080db1db8 | ||
|
|
37e3e91239 | ||
|
|
7676a907ee | ||
|
|
e5ecdca3ff | ||
|
|
50fb313f9a | ||
|
|
d21997af43 | ||
|
|
95a56ef4f0 |
365
README.md
365
README.md
@@ -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):
|
### Setting up a new Hub
|
||||||
- Docker-based autossh tunnel container
|
|
||||||
- Configuration files for spoke setup
|
|
||||||
- Hostname assignment based on MAC address
|
|
||||||
|
|
||||||
2. **Hub Side** (`hub/` directory):
|
On a fresh Debian/Ubuntu VPS or server:
|
||||||
- Rclone SFTP mount configuration
|
|
||||||
- Systemd user service for automatic mounting
|
|
||||||
|
|
||||||
3. **Management Script** (`hubspoke-helper.sh`):
|
```bash
|
||||||
- Unified interface for managing both hub and spoke components
|
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
|
## Directory Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
tinyboard/
|
tinyboard/
|
||||||
├── hubspoke-helper.sh # Main management script
|
├── setup.sh ← entry point for hub/spoke setup
|
||||||
├── hub/
|
├── syncthing.sh ← manage Syncthing devices and folders
|
||||||
│ └── rclone.conf # Rclone SFTP configuration
|
|
||||||
├── spoke/
|
├── spoke/
|
||||||
│ ├── compose.yaml # Docker Compose for autossh tunnel
|
│ ├── setup-network.sh ← configure static IP before setup
|
||||||
│ ├── Dockerfile # autossh container image
|
│ ├── setup-spoke.sh ← automated spoke setup
|
||||||
│ ├── autohostname.sh # Hostname assignment by MAC address
|
│ ├── compose.yaml ← Docker Compose for autossh + Syncthing
|
||||||
│ ├── aptprimary.sh # Initial package installation
|
│ ├── Dockerfile ← autossh container
|
||||||
│ ├── clean_sensitive.sh # Clean WiFi/password from configs
|
│ └── armbian.not_logged_in_yet ← Armbian first-boot WiFi config template
|
||||||
│ └── armb-not_logged_in_yet # Armbian first-boot configuration
|
└── hub/
|
||||||
└── README.md # This file
|
├── 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
|
### `setup.sh`
|
||||||
- `~/.ssh/oilykey2026` on spokes (referenced in `spoke/compose.yaml`)
|
Entry point. Presents a menu:
|
||||||
- `~/.ssh/armbian-brie-202604` on hub (referenced in `hub/rclone.conf`)
|
- 0) Reconfigure network (static IP via netplan — SSH session will drop, reconnect)
|
||||||
- These keys must be manually generated and distributed
|
- 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
|
### `syncthing.sh`
|
||||||
- `~/.config/rclone/rclone.conf` on hub must be manually created
|
Interactive Syncthing management. Can be run on the hub or any spoke. Presents a menu:
|
||||||
- Use `hub/rclone.conf` as a template
|
- 0) Show This Device's ID
|
||||||
- Update host, port, and key_file paths as needed
|
- 1) Pending Devices
|
||||||
- Manually create rclone mount and permission it (`/mnt/hub` for example)
|
- 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
|
Requires Docker and a running Syncthing container. Auto-discovers the container and API key.
|
||||||
- `~/.config/systemd/user/rclone-mount@.service` must be manually copied from `hub/rclone-mount@.service`
|
|
||||||
|
|
||||||
## 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
|
### `spoke/setup-spoke.sh`
|
||||||
1. Write Armbian minimal image to SD card
|
Run as root on a new spoke. Handles:
|
||||||
2. Copy `spoke/armb-not_logged_in_yet` to SD card root `/root/.not_logged_in_yet` (contains WiFi credentials)
|
- Package installation (apt/dnf/yum/pacman)
|
||||||
3. Boot device, SSH in as root with password "1234"
|
- Docker installation
|
||||||
4. After first login and setup tasks, `.not_logged_in_yet` will be processed for root and armbian user credentials
|
- SSH server setup
|
||||||
5. Clone this repository: `git clone <repo-url>`
|
- Hostname configuration (validated for safe characters)
|
||||||
6. Run `spoke/aptprimary.sh` to install required packages
|
- SSH key generation and hub authorization
|
||||||
7. Run `spoke/autohostname.sh` to assign hostname based on MAC address
|
- Tunnel port auto-detection on the hub (scans up to 100 ports)
|
||||||
8. Reboot and test as armbian user
|
- Docker image build and container start
|
||||||
|
- Optional password auth disable
|
||||||
|
|
||||||
### SSH Key Setup
|
### `hub/setup-hub.sh`
|
||||||
1. Generate SSH key pair on hub: `ssh-keygen -t ed25519 -f ~/.ssh/armbian-brie-202604`
|
Run as root on a new hub server. Handles:
|
||||||
2. Copy public key to spoke: `ssh-copy-id -i ~/.ssh/armbian-brie-202604.pub armbian@<spoke-ip>`
|
- Package installation (apt/dnf/yum/pacman)
|
||||||
3. Generate spoke key: `ssh-keygen -t ed25519 -f ~/.ssh/oilykey2026`
|
- rclone installation
|
||||||
4. Copy public key to hub for reverse tunnel authentication
|
- Hub user creation
|
||||||
|
- SSH server configuration (GatewayPorts, AllowTcpForwarding, ClientAliveInterval)
|
||||||
|
- FUSE configuration
|
||||||
|
- rclone config directory setup
|
||||||
|
- Optional password auth disable
|
||||||
|
|
||||||
### Docker Tunnel Setup
|
### `hub/onboard-spoke.sh`
|
||||||
```bash
|
Run as the hub user after a spoke connects. Handles:
|
||||||
# Build the autossh container
|
- SSH key generation and deployment to spoke
|
||||||
./hubspoke-helper.sh spoke build
|
- 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
|
#### Union Remote
|
||||||
./hubspoke-helper.sh spoke start
|
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
|
The union remote is automatically updated when a spoke is offboarded.
|
||||||
./hubspoke-helper.sh spoke status
|
|
||||||
|
|
||||||
# View logs
|
### `hub/offboard-spoke.sh`
|
||||||
./hubspoke-helper.sh spoke logs
|
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
|
## Backups
|
||||||
```bash
|
|
||||||
# Allow other users to access mounts (if needed)
|
|
||||||
sudo sed -i 's/^#user_allow_other/user_allow_other/' /etc/fuse.conf
|
|
||||||
|
|
||||||
# Add user to fuse group
|
Scripts that modify critical configs create timestamped backups before writing:
|
||||||
sudo groupadd fuse
|
|
||||||
sudo usermod -aG fuse $USER
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
- **Netplan:** `/root/.config/tinyboard/netplan-backups/<filename>.<datetime>`
|
||||||
|
- **Crontab:** `~/.config/tinyboard/crontab.<datetime>`
|
||||||
|
|
||||||
### Managing Spoke Tunnels
|
Restore hints are printed to the terminal after each backup.
|
||||||
- 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.
|
|
||||||
|
|
||||||
```bash
|
---
|
||||||
# Build autossh container
|
|
||||||
./hubspoke-helper.sh spoke build
|
|
||||||
|
|
||||||
# Start/stop/restart tunnel
|
## Security
|
||||||
./hubspoke-helper.sh spoke start
|
|
||||||
./hubspoke-helper.sh spoke stop
|
|
||||||
./hubspoke-helper.sh spoke restart
|
|
||||||
|
|
||||||
# Check status and logs
|
- All communication is over SSH tunnels — no spoke ports exposed to the internet
|
||||||
./hubspoke-helper.sh spoke status
|
- SSH keys used for all authentication
|
||||||
./hubspoke-helper.sh spoke logs
|
- 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:
|
Before committing, ensure the following do not contain real credentials:
|
||||||
```
|
|
||||||
@reboot /home/armbian/tinyboard/hubspoke-helper.sh hub start-background
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Deprecated: systemd
|
- `spoke/armbian.not_logged_in_yet` — contains WiFi SSID, password, and user passwords
|
||||||
```bash
|
- `spoke/compose.yaml` — may contain hub hostname after spoke setup runs
|
||||||
# Install systemd service (after manual file placement)
|
|
||||||
./hubspoke-helper.sh hub install
|
|
||||||
|
|
||||||
# Start/stop rclone mount
|
---
|
||||||
./hubspoke-helper.sh hub start
|
|
||||||
./hubspoke-helper.sh hub stop
|
|
||||||
|
|
||||||
# Check service status
|
|
||||||
./hubspoke-helper.sh hub status
|
|
||||||
|
|
||||||
# Manual mount/unmount for testing
|
|
||||||
./hubspoke-helper.sh hub mount
|
|
||||||
./hubspoke-helper.sh hub unmount
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration Variables
|
|
||||||
|
|
||||||
Environment variables can override defaults:
|
|
||||||
- `TUNNEL_DIR`: Directory containing spoke Docker files (default: `~/tinyboard/spoke`)
|
|
||||||
- `COMPOSE_FILE`: Docker compose file path (default: `$TUNNEL_DIR/compose.yaml`)
|
|
||||||
- `RCLONE_REMOTE`: Rclone remote name (default: `brie-remote`)
|
|
||||||
- `MOUNT_POINT`: Mount point on hub (default: `~/mnt/hub`)
|
|
||||||
|
|
||||||
## Security Notes
|
|
||||||
|
|
||||||
1. **SSH Keys**: Always use strong key pairs and protect private keys
|
|
||||||
2. **Configuration Files**: Use `spoke/clean_sensitive.sh` to remove WiFi credentials before committing
|
|
||||||
3. **Firewall**: Ensure proper firewall rules on hub (port 11111 for reverse tunnels)
|
|
||||||
4. **User Permissions**: Run services with minimal required privileges
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Spoke Tunnel Issues
|
### `apt update` fails with beta.armbian.com error
|
||||||
- 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`
|
|
||||||
|
|
||||||
### Hub Mount Issues
|
On some Armbian images, a beta apt repository is enabled by default and may cause `apt update` to fail. Comment it out:
|
||||||
- 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`
|
|
||||||
|
|
||||||
### Network Issues
|
```bash
|
||||||
- Ensure spokes can reach hub on SSH port (22)
|
grep -r "beta.armbian" /etc/apt/sources.list /etc/apt/sources.list.d/
|
||||||
- Verify reverse tunnel port (11111) is not blocked by firewall
|
```
|
||||||
- Check DNS resolution on spokes for hub hostname
|
|
||||||
|
|
||||||
## 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
|
### Tunnel is up but rclone mount fails
|
||||||
1. Update `spoke/compose.yaml` for new spoke hostnames
|
|
||||||
2. Update `hub/rclone.conf` for new spoke connections
|
|
||||||
3. Update `spoke/autohostname.sh` for new MAC addresses
|
|
||||||
|
|
||||||
### Adding New Spokes
|
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.
|
||||||
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
|
|
||||||
|
|
||||||
## 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
153
hub/offboard-spoke.sh
Executable 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
166
hub/onboard-spoke.sh
Normal file → Executable file
@@ -3,15 +3,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
RCLONE_CONF="${HOME}/.config/rclone/rclone.conf"
|
RCLONE_CONF="${HOME}/.config/rclone/rclone.conf"
|
||||||
SSH_DIR="${HOME}/.ssh"
|
SSH_DIR="${HOME}/.ssh"
|
||||||
|
REGISTRY="${HOME}/.config/tinyboard/spokes"
|
||||||
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"
|
|
||||||
|
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
@@ -19,6 +11,23 @@ YELLOW='\033[1;33m'
|
|||||||
CYAN='\033[0;36m'
|
CYAN='\033[0;36m'
|
||||||
NC='\033[0m'
|
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() {
|
retry_or_abort() {
|
||||||
local test_cmd="$1"
|
local test_cmd="$1"
|
||||||
local fail_msg="$2"
|
local fail_msg="$2"
|
||||||
@@ -38,34 +47,46 @@ retry_or_abort() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
die "Running as root — keys will be written to /root/.ssh. Run as the hub user instead."
|
||||||
die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
|
fi
|
||||||
header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; }
|
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"
|
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
|
read -rp "Spoke name (e.g. rocky): " SPOKE_NAME
|
||||||
[ -n "$SPOKE_NAME" ] || die "Spoke name cannot be empty"
|
[ -n "$SPOKE_NAME" ] || die "Spoke name cannot be empty"
|
||||||
|
|
||||||
read -rp "Tunnel port for $SPOKE_NAME: " TUNNEL_PORT
|
read -rp "Tunnel port for $SPOKE_NAME: " TUNNEL_PORT
|
||||||
[[ "$TUNNEL_PORT" =~ ^[0-9]+$ ]] || die "Invalid 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"
|
KEY_PATH="$SSH_DIR/$KEY_NAME"
|
||||||
|
|
||||||
command -v rclone >/dev/null || die "rclone is not installed"
|
|
||||||
mkdir -p "$(dirname "$RCLONE_CONF")"
|
mkdir -p "$(dirname "$RCLONE_CONF")"
|
||||||
|
|
||||||
header "Checking Tunnel"
|
header "Checking Tunnel"
|
||||||
info "Scanning spoke host key..."
|
info "Scanning spoke host key..."
|
||||||
KEYSCAN=$(ssh-keyscan -p "$TUNNEL_PORT" -H localhost 2>/dev/null)
|
KEYSCAN=$(ssh-keyscan -p "$TUNNEL_PORT" -H localhost 2>/dev/null)
|
||||||
[ -n "$KEYSCAN" ] || die "Spoke not reachable on port $TUNNEL_PORT — is the tunnel up?"
|
[ -n "$KEYSCAN" ] || die "Spoke not reachable on port $TUNNEL_PORT — is the tunnel up?"
|
||||||
echo "$KEYSCAN" >> "$SSH_DIR/known_hosts"
|
while IFS= read -r KEYSCAN_LINE; do
|
||||||
|
KEYSCAN_KEY=$(echo "$KEYSCAN_LINE" | awk '{print $2, $3}')
|
||||||
|
if ! grep -qF "$KEYSCAN_KEY" "$SSH_DIR/known_hosts" 2>/dev/null; then
|
||||||
|
echo "$KEYSCAN_LINE" >> "$SSH_DIR/known_hosts"
|
||||||
|
fi
|
||||||
|
done <<< "$KEYSCAN"
|
||||||
|
|
||||||
info "Verifying spoke is reachable on port $TUNNEL_PORT..."
|
info "Verifying spoke is reachable on port $TUNNEL_PORT..."
|
||||||
retry_or_abort \
|
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."
|
"Spoke not reachable on port $TUNNEL_PORT. Make sure the tunnel is up."
|
||||||
|
|
||||||
header "Generating Hub SSH Key"
|
header "Generating Hub SSH Key"
|
||||||
@@ -75,16 +96,28 @@ else
|
|||||||
ssh-keygen -t ed25519 -f "$KEY_PATH" -N ""
|
ssh-keygen -t ed25519 -f "$KEY_PATH" -N ""
|
||||||
info "Key generated: $KEY_PATH"
|
info "Key generated: $KEY_PATH"
|
||||||
fi
|
fi
|
||||||
|
chmod 600 "$KEY_PATH"
|
||||||
|
info "Permissions set: $KEY_PATH is 600"
|
||||||
|
|
||||||
header "Copying Hub Key to Spoke"
|
header "Copying Hub Key to Spoke"
|
||||||
info "Running ssh-copy-id to armbian@localhost:$TUNNEL_PORT..."
|
info "Running ssh-copy-id to $SPOKE_USER@localhost:$TUNNEL_PORT..."
|
||||||
info "(You will be prompted for the armbian password on the spoke)"
|
info "(You will be prompted for the $SPOKE_USER password on the spoke)"
|
||||||
ssh-copy-id -i "$KEY_PATH.pub" -p "$TUNNEL_PORT" armbian@localhost
|
if ssh-copy-id -i "$KEY_PATH.pub" -p "$TUNNEL_PORT" "$SPOKE_USER"@localhost; then
|
||||||
info "Key copied."
|
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 -> Spoke Key Auth"
|
||||||
retry_or_abort \
|
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."
|
"Key auth failed. Check authorized_keys on the spoke."
|
||||||
info "Key auth to spoke successful."
|
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
|
if grep -q "\[${SPOKE_NAME}-remote\]" "$RCLONE_CONF" 2>/dev/null; then
|
||||||
warn "Remote [${SPOKE_NAME}-remote] already exists in $RCLONE_CONF, skipping."
|
warn "Remote [${SPOKE_NAME}-remote] already exists in $RCLONE_CONF, skipping."
|
||||||
else
|
else
|
||||||
|
[ -s "$RCLONE_CONF" ] && tail -c1 "$RCLONE_CONF" | grep -qv $'\n' && echo "" >> "$RCLONE_CONF"
|
||||||
cat >> "$RCLONE_CONF" <<EOF
|
cat >> "$RCLONE_CONF" <<EOF
|
||||||
|
|
||||||
[${SPOKE_NAME}-remote]
|
[${SPOKE_NAME}-remote]
|
||||||
@@ -106,6 +140,82 @@ EOF
|
|||||||
info "Remote [${SPOKE_NAME}-remote] added to $RCLONE_CONF."
|
info "Remote [${SPOKE_NAME}-remote] added to $RCLONE_CONF."
|
||||||
fi
|
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"
|
header "Testing rclone Connection"
|
||||||
if rclone lsd "${SPOKE_NAME}-remote:" --config "$RCLONE_CONF" 2>/dev/null; then
|
if rclone lsd "${SPOKE_NAME}-remote:" --config "$RCLONE_CONF" 2>/dev/null; then
|
||||||
info "rclone connection to $SPOKE_NAME successful."
|
info "rclone connection to $SPOKE_NAME successful."
|
||||||
@@ -113,6 +223,18 @@ else
|
|||||||
warn "rclone test failed. Check the remote config in $RCLONE_CONF."
|
warn "rclone test failed. Check the remote config in $RCLONE_CONF."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
header "Registering Spoke"
|
||||||
|
mkdir -p "$(dirname "$REGISTRY")"
|
||||||
|
MOUNT_POINT="${HOME}/mnt/${SPOKE_NAME}"
|
||||||
|
mkdir -p "$MOUNT_POINT"
|
||||||
|
if grep -q "^${SPOKE_NAME} " "$REGISTRY" 2>/dev/null; then
|
||||||
|
warn "$SPOKE_NAME already in registry, updating."
|
||||||
|
grep -v "^${SPOKE_NAME} " "$REGISTRY" > "${REGISTRY}.tmp" 2>/dev/null || true
|
||||||
|
mv "${REGISTRY}.tmp" "$REGISTRY"
|
||||||
|
fi
|
||||||
|
echo "${SPOKE_NAME} ${TUNNEL_PORT} ${KEY_PATH} ${MOUNT_POINT}" >> "$REGISTRY"
|
||||||
|
info "$SPOKE_NAME registered."
|
||||||
|
|
||||||
header "Onboarding Complete"
|
header "Onboarding Complete"
|
||||||
echo -e " Spoke: ${GREEN}$SPOKE_NAME${NC}"
|
echo -e " Spoke: ${GREEN}$SPOKE_NAME${NC}"
|
||||||
echo -e " Port: ${GREEN}$TUNNEL_PORT${NC}"
|
echo -e " Port: ${GREEN}$TUNNEL_PORT${NC}"
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
166
hub/setup-hub.sh
Normal file → Executable file
166
hub/setup-hub.sh
Normal file → Executable file
@@ -12,10 +12,52 @@ warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
|||||||
die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
|
die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
|
||||||
header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; }
|
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"
|
[ "$(id -u)" -eq 0 ] || die "Run as root"
|
||||||
|
|
||||||
|
check_deps ssh ssh-keygen systemctl useradd groupadd file
|
||||||
|
|
||||||
header "TinyBoard Hub Setup"
|
header "TinyBoard Hub Setup"
|
||||||
|
|
||||||
|
read -rp "Hub username [armbian]: " HUB_USER
|
||||||
|
HUB_USER="${HUB_USER:-armbian}"
|
||||||
|
|
||||||
header "Detecting Package Manager"
|
header "Detecting Package Manager"
|
||||||
if command -v apt-get >/dev/null 2>&1; then
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
PKG_MANAGER="apt"
|
PKG_MANAGER="apt"
|
||||||
@@ -69,35 +111,39 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
header "Armbian User Setup"
|
header "Armbian User Setup"
|
||||||
if id armbian >/dev/null 2>&1; then
|
if id "$HUB_USER" >/dev/null 2>&1; then
|
||||||
warn "User 'armbian' already exists, skipping creation."
|
warn "User '$HUB_USER' already exists, skipping creation."
|
||||||
else
|
else
|
||||||
info "Creating armbian user..."
|
info "Creating $HUB_USER user..."
|
||||||
groupadd -g 1000 armbian 2>/dev/null || true
|
groupadd -g 1000 "$HUB_USER" 2>/dev/null || true
|
||||||
useradd -m -u 1000 -g 1000 -s /bin/bash armbian
|
useradd -m -u 1000 -g 1000 -s /bin/bash "$HUB_USER"
|
||||||
|
|
||||||
ADDED_TO_GROUP=false
|
ADDED_TO_GROUP=false
|
||||||
if getent group sudo >/dev/null 2>&1; then
|
if getent group sudo >/dev/null 2>&1; then
|
||||||
usermod -aG sudo armbian && ADDED_TO_GROUP=true
|
if usermod -aG sudo "$HUB_USER" 2>/dev/null; then
|
||||||
|
ADDED_TO_GROUP=true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
if [ "$ADDED_TO_GROUP" = false ] && getent group wheel >/dev/null 2>&1; then
|
if [ "$ADDED_TO_GROUP" = false ] && getent group wheel >/dev/null 2>&1; then
|
||||||
usermod -aG wheel armbian && ADDED_TO_GROUP=true
|
if usermod -aG wheel "$HUB_USER" 2>/dev/null; then
|
||||||
|
ADDED_TO_GROUP=true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
if [ "$ADDED_TO_GROUP" = false ]; then
|
if [ "$ADDED_TO_GROUP" = false ]; then
|
||||||
warn "Neither sudo nor wheel group found — armbian user has no sudo access."
|
warn "Neither sudo nor wheel group found — $HUB_USER user has no sudo access."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
info "armbian user created."
|
info "$HUB_USER user created."
|
||||||
echo ""
|
echo ""
|
||||||
warn "Set a password for the armbian user:"
|
warn "Set a password for the $HUB_USER user:"
|
||||||
passwd armbian
|
passwd "$HUB_USER"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ARMBIAN_HOME="/home/armbian"
|
ARMBIAN_HOME="/home/$HUB_USER"
|
||||||
SSH_DIR="$ARMBIAN_HOME/.ssh"
|
SSH_DIR="$ARMBIAN_HOME/.ssh"
|
||||||
mkdir -p "$SSH_DIR"
|
mkdir -p "$SSH_DIR"
|
||||||
touch "$SSH_DIR/authorized_keys"
|
touch "$SSH_DIR/authorized_keys"
|
||||||
chown -R armbian:armbian "$SSH_DIR"
|
chown -R "$HUB_USER":"$HUB_USER" "$SSH_DIR"
|
||||||
chmod 700 "$SSH_DIR"
|
chmod 700 "$SSH_DIR"
|
||||||
chmod 600 "$SSH_DIR/authorized_keys"
|
chmod 600 "$SSH_DIR/authorized_keys"
|
||||||
|
|
||||||
@@ -105,24 +151,62 @@ header "SSH Server Configuration"
|
|||||||
SSHD_CONF="/etc/ssh/sshd_config"
|
SSHD_CONF="/etc/ssh/sshd_config"
|
||||||
[ -f "$SSHD_CONF" ] || die "sshd_config not found at $SSHD_CONF"
|
[ -f "$SSHD_CONF" ] || die "sshd_config not found at $SSHD_CONF"
|
||||||
|
|
||||||
for DIRECTIVE in "GatewayPorts yes" "AllowTcpForwarding yes"; do
|
for DIRECTIVE in "GatewayPorts yes" "AllowTcpForwarding yes" "ClientAliveInterval 60" "ClientAliveCountMax 3"; do
|
||||||
KEY="${DIRECTIVE%% *}"
|
KEY="${DIRECTIVE%% *}"
|
||||||
if grep -q "^$KEY" "$SSHD_CONF"; then
|
if grep -q "^$KEY" "$SSHD_CONF"; then
|
||||||
sed -i "s/^$KEY.*/$DIRECTIVE/" "$SSHD_CONF"
|
sed -i "s|^$KEY.*|$DIRECTIVE|" "$SSHD_CONF"
|
||||||
else
|
else
|
||||||
echo "$DIRECTIVE" >> "$SSHD_CONF"
|
echo "$DIRECTIVE" >> "$SSHD_CONF"
|
||||||
fi
|
fi
|
||||||
info "$DIRECTIVE set."
|
info "$DIRECTIVE set."
|
||||||
done
|
done
|
||||||
|
|
||||||
if systemctl enable ssh 2>/dev/null; then
|
SSH_SVC=""
|
||||||
systemctl restart ssh
|
if systemctl list-unit-files ssh.service >/dev/null 2>&1; then
|
||||||
elif systemctl enable sshd 2>/dev/null; then
|
SSH_SVC="ssh"
|
||||||
systemctl restart sshd
|
elif systemctl list-unit-files sshd.service >/dev/null 2>&1; then
|
||||||
|
SSH_SVC="sshd"
|
||||||
|
fi
|
||||||
|
if [ -n "$SSH_SVC" ]; then
|
||||||
|
systemctl enable "$SSH_SVC" 2>/dev/null || true
|
||||||
|
systemctl restart "$SSH_SVC"
|
||||||
|
info "SSH server restarted."
|
||||||
else
|
else
|
||||||
warn "Could not enable/restart SSH service — please start it manually."
|
warn "Could not enable/restart SSH service — please start it manually."
|
||||||
fi
|
fi
|
||||||
info "SSH server restarted."
|
|
||||||
|
header "Password Authentication"
|
||||||
|
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"
|
header "FUSE Configuration"
|
||||||
FUSE_CONF="/etc/fuse.conf"
|
FUSE_CONF="/etc/fuse.conf"
|
||||||
@@ -142,45 +226,47 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
groupadd fuse 2>/dev/null || true
|
groupadd fuse 2>/dev/null || true
|
||||||
usermod -aG fuse armbian 2>/dev/null || true
|
usermod -aG fuse "$HUB_USER" 2>/dev/null || true
|
||||||
info "armbian added to fuse group."
|
info "$HUB_USER added to fuse group."
|
||||||
|
|
||||||
header "Rclone Setup"
|
header "Rclone Setup"
|
||||||
RCLONE_CONF="$ARMBIAN_HOME/.config/rclone/rclone.conf"
|
RCLONE_CONF="$ARMBIAN_HOME/.config/rclone/rclone.conf"
|
||||||
mkdir -p "$(dirname "$RCLONE_CONF")"
|
mkdir -p "$(dirname "$RCLONE_CONF")"
|
||||||
chown -R armbian:armbian "$ARMBIAN_HOME/.config"
|
chown -R "$HUB_USER":"$HUB_USER" "$ARMBIAN_HOME/.config"
|
||||||
|
|
||||||
if [ ! -f "$RCLONE_CONF" ]; then
|
if [ ! -f "$RCLONE_CONF" ]; then
|
||||||
touch "$RCLONE_CONF"
|
touch "$RCLONE_CONF"
|
||||||
chown armbian:armbian "$RCLONE_CONF"
|
chown "$HUB_USER":"$HUB_USER" "$RCLONE_CONF"
|
||||||
info "Empty rclone.conf created at $RCLONE_CONF."
|
info "Empty rclone.conf created at $RCLONE_CONF."
|
||||||
else
|
else
|
||||||
warn "rclone.conf already exists, skipping."
|
warn "rclone.conf already exists, skipping."
|
||||||
fi
|
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"
|
header "Mount Point Setup"
|
||||||
read -rp "Mount point for spoke filesystems [/mnt/hub]: " MOUNT_POINT
|
read -rp "Mount point for spoke filesystems [/mnt/hub]: " MOUNT_POINT
|
||||||
MOUNT_POINT="${MOUNT_POINT:-/mnt/hub}"
|
MOUNT_POINT="${MOUNT_POINT:-/mnt/hub}"
|
||||||
mkdir -p "$MOUNT_POINT"
|
mkdir -p "$MOUNT_POINT"
|
||||||
chown armbian:armbian "$MOUNT_POINT"
|
chown "$HUB_USER":"$HUB_USER" "$MOUNT_POINT"
|
||||||
info "Mount point created at $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 armbian -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 armbian -
|
|
||||||
info "Added @reboot crontab entry for rclone mount."
|
|
||||||
fi
|
|
||||||
|
|
||||||
header "Hub Setup Complete"
|
header "Hub Setup Complete"
|
||||||
echo -e " Armbian user: ${GREEN}armbian${NC}"
|
echo -e " Hub user: ${GREEN}$HUB_USER${NC}"
|
||||||
echo -e " SSH config: ${GREEN}GatewayPorts yes, AllowTcpForwarding yes${NC}"
|
echo -e " SSH config: ${GREEN}GatewayPorts yes, AllowTcpForwarding yes, ClientAliveInterval 60${NC}"
|
||||||
echo -e " FUSE: ${GREEN}user_allow_other enabled${NC}"
|
echo -e " FUSE: ${GREEN}user_allow_other enabled${NC}"
|
||||||
echo -e " rclone config: ${GREEN}$RCLONE_CONF${NC}"
|
echo -e " rclone config: ${GREEN}$RCLONE_CONF${NC}"
|
||||||
echo -e " Mount point: ${GREEN}$MOUNT_POINT${NC}"
|
echo -e " Mount point: ${GREEN}$MOUNT_POINT${NC}"
|
||||||
|
|||||||
@@ -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
|
|
||||||
15
setup.sh
15
setup.sh
@@ -15,13 +15,20 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
|
|
||||||
header "TinyBoard Setup"
|
header "TinyBoard Setup"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo " 0) Reconfigure network"
|
||||||
echo " 1) Set up this device as a new spoke"
|
echo " 1) Set up this device as a new spoke"
|
||||||
echo " 2) Onboard a new spoke from the hub"
|
echo " 2) Onboard a new spoke from the hub"
|
||||||
echo " 3) 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 ""
|
echo ""
|
||||||
read -rp "Choose [1/2/3]: " CHOICE
|
read -rp "Choose [0/1/2/3/4]: " CHOICE
|
||||||
|
|
||||||
case "$CHOICE" in
|
case "$CHOICE" in
|
||||||
|
0)
|
||||||
|
[ "$(id -u)" -eq 0 ] || die "Network reconfiguration must be run as root"
|
||||||
|
info "Starting network reconfiguration..."
|
||||||
|
exec "$SCRIPT_DIR/spoke/setup-network.sh"
|
||||||
|
;;
|
||||||
1)
|
1)
|
||||||
[ "$(id -u)" -eq 0 ] || die "Spoke setup must be run as root"
|
[ "$(id -u)" -eq 0 ] || die "Spoke setup must be run as root"
|
||||||
info "Starting spoke setup..."
|
info "Starting spoke setup..."
|
||||||
@@ -32,6 +39,10 @@ case "$CHOICE" in
|
|||||||
exec "$SCRIPT_DIR/hub/onboard-spoke.sh"
|
exec "$SCRIPT_DIR/hub/onboard-spoke.sh"
|
||||||
;;
|
;;
|
||||||
3)
|
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"
|
[ "$(id -u)" -eq 0 ] || die "Hub setup must be run as root"
|
||||||
info "Starting hub setup..."
|
info "Starting hub setup..."
|
||||||
exec "$SCRIPT_DIR/hub/setup-hub.sh"
|
exec "$SCRIPT_DIR/hub/setup-hub.sh"
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y autossh openssh-client && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y autossh openssh-client && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN echo "PermitRootLogin no" >> /etc/ssh/sshd_config
|
|
||||||
RUN echo "PasswordAuthentication no" >> /etc/ssh/sshd_config
|
|
||||||
RUN echo "Subsystem sftp internal-sftp" >> /etc/ssh/sshd_config
|
|
||||||
|
|
||||||
ARG UID=1000
|
ARG UID=1000
|
||||||
ARG GID=1000
|
ARG GID=1000
|
||||||
RUN groupadd -g ${GID} armbian && useradd -m -u ${UID} -g armbian armbian
|
RUN groupadd -g ${GID} armbian && useradd -m -u ${UID} -g armbian armbian
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Need armbian-config?
|
|
||||||
|
|
||||||
apt install -y vim
|
|
||||||
apt install -y autossh
|
|
||||||
apt install -y docker.io docker-cli docker-compose
|
|
||||||
usermod -aG docker armbian
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
# Copy this along with .not_logged_in_yet to armbian root dir, then run after successful login
|
|
||||||
|
|
||||||
# Refresh: extract MAC address of wlan0
|
|
||||||
MAC=$(netplan status -f json | jq -r '.wlan0.macaddress')
|
|
||||||
|
|
||||||
# Check that we actually got a MAC address
|
|
||||||
if [[ -z "$MAC" ]]; then
|
|
||||||
echo "Error: Could not retrieve MAC address from netplan." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Detected MAC address: $MAC"
|
|
||||||
|
|
||||||
# Assign cheese hostname based on MAC address
|
|
||||||
case "$MAC" in
|
|
||||||
38:9c:80:46:26:c8) # ← Replace with your first real MAC
|
|
||||||
HOSTNAME="brie"
|
|
||||||
;;
|
|
||||||
68:f8:ea:22:e1:3d) # ← Replace with your second real MAC
|
|
||||||
HOSTNAME="gouda"
|
|
||||||
;;
|
|
||||||
99:88:77:66:55:44) # ← Replace with your third real MAC
|
|
||||||
HOSTNAME="camembert"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown MAC address: $MAC ... hostname not changed." >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
echo "Setting hostname to: $HOSTNAME"
|
|
||||||
sudo hostnamectl set-hostname "$HOSTNAME"
|
|
||||||
|
|
||||||
# Optional: also update /etc/hostname (hostnamectl usually does this, but to be safe)
|
|
||||||
echo "$HOSTNAME" | sudo tee /etc/hostname >/dev/null
|
|
||||||
|
|
||||||
echo "Hostname changed. Reboot or start a new shell to see the change."
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Script to clean sensitive WiFi credentials and passwords from configuration files
|
|
||||||
# Usage: ./clean_sensitive.sh [filename]
|
|
||||||
|
|
||||||
FILE="${1:-/home/finn/code/tinyboard/armb-not_logged_in_yet}"
|
|
||||||
|
|
||||||
echo "Cleaning sensitive data from: $FILE"
|
|
||||||
|
|
||||||
# Clean WiFi SSID (both commented and uncommented lines)
|
|
||||||
sed -i 's/^\(#*PRESET_NET_WIFI_SSID=\).*$/\1"[REDACTED]"/' "$FILE"
|
|
||||||
|
|
||||||
# Clean WiFi KEY (both commented and uncommented lines)
|
|
||||||
sed -i 's/^\(#*PRESET_NET_WIFI_KEY=\).*$/\1"[REDACTED]"/' "$FILE"
|
|
||||||
|
|
||||||
# Clean root password
|
|
||||||
sed -i 's/^\(PRESET_ROOT_PASSWORD=\).*$/\1"[REDACTED]"/' "$FILE"
|
|
||||||
|
|
||||||
# Clean user password
|
|
||||||
sed -i 's/^\(PRESET_USER_PASSWORD=\).*$/\1"[REDACTED]"/' "$FILE"
|
|
||||||
|
|
||||||
echo "wiped fields"
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
services:
|
services:
|
||||||
autossh:
|
autossh:
|
||||||
image: spoke-autossh
|
image: spoke-autossh
|
||||||
@@ -7,7 +6,6 @@ services:
|
|||||||
network_mode: host
|
network_mode: host
|
||||||
environment:
|
environment:
|
||||||
- AUTOSSH_GATETIME=0
|
- AUTOSSH_GATETIME=0
|
||||||
# @@@@@@@@@ BEWARE THE REVERSE TUNNEL PORT AND KEYS WHEN RUNNING THIS ON A NEW SPOKE @@@@@@@@@@
|
|
||||||
command: >
|
command: >
|
||||||
autossh -M 0 -NT
|
autossh -M 0 -NT
|
||||||
-o "ServerAliveInterval=60"
|
-o "ServerAliveInterval=60"
|
||||||
@@ -18,15 +16,20 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /home/armbian/.ssh/oilykey2026:/home/armbian/.ssh/oilykey2026:ro
|
- /home/armbian/.ssh/oilykey2026:/home/armbian/.ssh/oilykey2026:ro
|
||||||
- /home/armbian/.ssh/known_hosts:/home/armbian/.ssh/known_hosts:ro
|
- /home/armbian/.ssh/known_hosts:/home/armbian/.ssh/known_hosts:ro
|
||||||
# - /home/armbian/share:/home/armbian/
|
|
||||||
syncthing:
|
syncthing:
|
||||||
image: syncthing/syncthing
|
image: syncthing/syncthing
|
||||||
container_name: spoke-syncthing
|
container_name: spoke-syncthing
|
||||||
hostname: spoke-syncthing
|
hostname: spoke-syncthing
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
|
||||||
environment:
|
environment:
|
||||||
- PUID=1000
|
- PUID=1000
|
||||||
- PGID=1000
|
- PGID=1000
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8384:8384"
|
||||||
|
- "22000:22000"
|
||||||
volumes:
|
volumes:
|
||||||
- /home/armbian/st:/var/syncthing
|
- syncthing-config:/var/syncthing/config
|
||||||
|
- /home/armbian/st/data:/var/syncthing/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
syncthing-config:
|
||||||
|
|||||||
@@ -12,8 +12,22 @@ warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
|||||||
die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
|
die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
|
||||||
header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; }
|
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"
|
[ "$(id -u)" -eq 0 ] || die "Run as root"
|
||||||
|
|
||||||
|
check_deps ip netplan systemctl ping
|
||||||
|
|
||||||
header "TinyBoard Network Setup"
|
header "TinyBoard Network Setup"
|
||||||
|
|
||||||
info "Available interfaces:"
|
info "Available interfaces:"
|
||||||
@@ -64,7 +78,10 @@ DNS_YAML=""
|
|||||||
IFS=',' read -ra DNS_LIST <<< "$DNS_INPUT"
|
IFS=',' read -ra DNS_LIST <<< "$DNS_INPUT"
|
||||||
for DNS in "${DNS_LIST[@]}"; do
|
for DNS in "${DNS_LIST[@]}"; do
|
||||||
DNS=$(echo "$DNS" | tr -d ' ')
|
DNS=$(echo "$DNS" | tr -d ' ')
|
||||||
DNS_YAML="${DNS_YAML} - ${DNS}\n"
|
if [ -n "$DNS_YAML" ]; then
|
||||||
|
DNS_YAML="${DNS_YAML}"$'\n'
|
||||||
|
fi
|
||||||
|
DNS_YAML="${DNS_YAML} - ${DNS}"
|
||||||
done
|
done
|
||||||
|
|
||||||
info "Current netplan configs:"
|
info "Current netplan configs:"
|
||||||
@@ -99,14 +116,20 @@ if $IS_WIFI; then
|
|||||||
[ -n "$WIFI_PASS" ] || die "Password cannot be empty"
|
[ -n "$WIFI_PASS" ] || die "Password cannot be empty"
|
||||||
else
|
else
|
||||||
WIFI_SSID="$CURRENT_SSID"
|
WIFI_SSID="$CURRENT_SSID"
|
||||||
WIFI_PASS=$(grep -A2 "\"${WIFI_SSID}\"" "$NETPLAN_FILE" 2>/dev/null | grep password | awk -F': ' '{print $2}' | tr -d '"' || true)
|
WIFI_PASS=$(grep -FA2 "\"${WIFI_SSID}\"" "$NETPLAN_FILE" 2>/dev/null | grep -F "password" | sed 's/^[^:]*: *//' | tr -d '"' || true)
|
||||||
|
[ -n "$WIFI_PASS" ] || die "Could not extract WiFi password from existing config — please re-enter credentials."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
header "Writing Netplan Config"
|
header "Writing Netplan Config"
|
||||||
|
BACKUP_FILE=""
|
||||||
if [ -f "$NETPLAN_FILE" ]; then
|
if [ -f "$NETPLAN_FILE" ]; then
|
||||||
cp "$NETPLAN_FILE" "${NETPLAN_FILE}.bak"
|
NETPLAN_BACKUP_DIR="/root/.config/tinyboard/netplan-backups"
|
||||||
info "Backup saved to ${NETPLAN_FILE}.bak"
|
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
|
fi
|
||||||
|
|
||||||
if $IS_WIFI; then
|
if $IS_WIFI; then
|
||||||
@@ -123,7 +146,8 @@ network:
|
|||||||
via: ${GATEWAY}
|
via: ${GATEWAY}
|
||||||
nameservers:
|
nameservers:
|
||||||
addresses:
|
addresses:
|
||||||
$(printf '%b' "$DNS_YAML") access-points:
|
${DNS_YAML}
|
||||||
|
access-points:
|
||||||
"${WIFI_SSID}":
|
"${WIFI_SSID}":
|
||||||
password: "${WIFI_PASS}"
|
password: "${WIFI_PASS}"
|
||||||
NETEOF
|
NETEOF
|
||||||
@@ -141,19 +165,37 @@ network:
|
|||||||
via: ${GATEWAY}
|
via: ${GATEWAY}
|
||||||
nameservers:
|
nameservers:
|
||||||
addresses:
|
addresses:
|
||||||
$(printf '%b' "$DNS_YAML")
|
${DNS_YAML}
|
||||||
NETEOF
|
NETEOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
info "Netplan config written to $NETPLAN_FILE"
|
info "Netplan config written to $NETPLAN_FILE"
|
||||||
|
|
||||||
header "Applying Configuration"
|
header "Applying Configuration"
|
||||||
warn "Testing netplan config..."
|
warn "Applying netplan config — will revert automatically if network is lost..."
|
||||||
if netplan try --timeout 10 2>/dev/null; then
|
netplan apply
|
||||||
info "Netplan config applied successfully."
|
|
||||||
|
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
|
else
|
||||||
warn "netplan try timed out or failed — applying anyway..."
|
warn "No network connectivity detected after 30 seconds — reverting to backup config."
|
||||||
netplan apply
|
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
|
fi
|
||||||
|
|
||||||
STATIC_ADDR="${STATIC_IP%%/*}"
|
STATIC_ADDR="${STATIC_IP%%/*}"
|
||||||
@@ -162,6 +204,6 @@ echo -e "${YELLOW}════════════════════
|
|||||||
echo -e "${YELLOW} Network reconfigured.${NC}"
|
echo -e "${YELLOW} Network reconfigured.${NC}"
|
||||||
echo -e "${YELLOW} If you are connected via SSH, your session${NC}"
|
echo -e "${YELLOW} If you are connected via SSH, your session${NC}"
|
||||||
echo -e "${YELLOW} may drop. Reconnect to: ${STATIC_ADDR}${NC}"
|
echo -e "${YELLOW} may drop. Reconnect to: ${STATIC_ADDR}${NC}"
|
||||||
echo -e "${YELLOW} Then run: ./setup.sh${NC}"
|
echo -e "${YELLOW} Then run: cd .. && ./setup.sh${NC}"
|
||||||
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
|
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
290
spoke/setup-spoke.sh
Normal file → Executable file
290
spoke/setup-spoke.sh
Normal file → Executable file
@@ -3,8 +3,9 @@ set -euo pipefail
|
|||||||
|
|
||||||
HUB_HOST=""
|
HUB_HOST=""
|
||||||
HUB_USER=""
|
HUB_USER=""
|
||||||
ARMBIAN_HOME="/home/armbian"
|
SPOKE_USER=""
|
||||||
SSH_DIR="$ARMBIAN_HOME/.ssh"
|
KEY_PATH=""
|
||||||
|
KEY_NAME=""
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
SPOKE_DIR="$SCRIPT_DIR"
|
SPOKE_DIR="$SCRIPT_DIR"
|
||||||
COMPOSE="$SPOKE_DIR/compose.yaml"
|
COMPOSE="$SPOKE_DIR/compose.yaml"
|
||||||
@@ -16,6 +17,23 @@ YELLOW='\033[1;33m'
|
|||||||
CYAN='\033[0;36m'
|
CYAN='\033[0;36m'
|
||||||
NC='\033[0m'
|
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() {
|
retry_or_abort() {
|
||||||
local test_cmd="$1"
|
local test_cmd="$1"
|
||||||
local fail_msg="$2"
|
local fail_msg="$2"
|
||||||
@@ -35,36 +53,130 @@ retry_or_abort() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
check_permissions() {
|
||||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
local file="$1"
|
||||||
die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
|
local label="$2"
|
||||||
header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; }
|
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"
|
[ "$(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
|
read -rp "Hub hostname [oily.dad]: " HUB_HOST
|
||||||
HUB_HOST="${HUB_HOST:-oily.dad}"
|
HUB_HOST="${HUB_HOST:-oily.dad}"
|
||||||
read -rp "Hub SSH user [armbian]: " HUB_USER
|
read -rp "Hub SSH user [armbian]: " HUB_USER
|
||||||
HUB_USER="${HUB_USER:-armbian}"
|
HUB_USER="${HUB_USER:-armbian}"
|
||||||
|
read -rp "Spoke local user [armbian]: " SPOKE_USER
|
||||||
|
SPOKE_USER="${SPOKE_USER:-armbian}"
|
||||||
|
ARMBIAN_HOME="/home/$SPOKE_USER"
|
||||||
|
SSH_DIR="$ARMBIAN_HOME/.ssh"
|
||||||
|
|
||||||
header "TinyBoard Spoke Setup"
|
header "TinyBoard Spoke Setup"
|
||||||
|
|
||||||
info "Installing packages..."
|
header "Detecting Package Manager"
|
||||||
apt-get update -q
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
apt-get install -y -q vim autossh docker.io docker-compose-plugin git openssh-server
|
PKG_MANAGER="apt"
|
||||||
|
PKG_INSTALL="apt-get install -y -q"
|
||||||
|
OPENSSH_PKG="openssh-server"
|
||||||
|
AUTOSSH_PKG="autossh"
|
||||||
|
info "Detected: apt (Debian/Ubuntu)"
|
||||||
|
apt-get update -q
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
PKG_MANAGER="dnf"
|
||||||
|
PKG_INSTALL="dnf install -y -q"
|
||||||
|
OPENSSH_PKG="openssh-server"
|
||||||
|
AUTOSSH_PKG="autossh"
|
||||||
|
info "Detected: dnf (Fedora/RHEL/Alma/Rocky)"
|
||||||
|
dnf check-update -q || true
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
PKG_MANAGER="yum"
|
||||||
|
PKG_INSTALL="yum install -y -q"
|
||||||
|
OPENSSH_PKG="openssh-server"
|
||||||
|
AUTOSSH_PKG="autossh"
|
||||||
|
info "Detected: yum (older RHEL/CentOS)"
|
||||||
|
yum check-update -q || true
|
||||||
|
elif command -v pacman >/dev/null 2>&1; then
|
||||||
|
PKG_MANAGER="pacman"
|
||||||
|
PKG_INSTALL="pacman -S --noconfirm --quiet"
|
||||||
|
OPENSSH_PKG="openssh"
|
||||||
|
AUTOSSH_PKG="autossh"
|
||||||
|
info "Detected: pacman (Arch)"
|
||||||
|
pacman -Sy --quiet
|
||||||
|
else
|
||||||
|
die "No supported package manager found (apt, dnf, yum, pacman)"
|
||||||
|
fi
|
||||||
|
|
||||||
info "Adding armbian to docker group..."
|
header "Installing Packages"
|
||||||
usermod -aG docker armbian 2>/dev/null || true
|
if ! command -v curl >/dev/null 2>&1; then
|
||||||
|
$PKG_INSTALL curl
|
||||||
|
fi
|
||||||
|
|
||||||
|
$PKG_INSTALL vim "$AUTOSSH_PKG" "$OPENSSH_PKG" git
|
||||||
|
|
||||||
|
info "Installing Docker..."
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
if [ "$PKG_MANAGER" = "apt" ]; then
|
||||||
|
$PKG_INSTALL docker.io docker-compose-plugin
|
||||||
|
else
|
||||||
|
curl -fsSL https://get.docker.com | bash
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Docker already installed, skipping."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker compose version >/dev/null 2>&1; then
|
||||||
|
if [ "$PKG_MANAGER" = "apt" ]; then
|
||||||
|
$PKG_INSTALL docker-compose-plugin
|
||||||
|
else
|
||||||
|
warn "docker compose not available — Docker install script should have included it."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Adding $SPOKE_USER to docker group..."
|
||||||
|
usermod -aG docker "$SPOKE_USER" 2>/dev/null || true
|
||||||
|
|
||||||
info "Enabling SSH server..."
|
info "Enabling SSH server..."
|
||||||
systemctl enable ssh
|
SSH_SVC=""
|
||||||
systemctl start ssh
|
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"
|
header "Hostname Setup"
|
||||||
CURRENT_HOSTNAME=$(hostname)
|
CURRENT_HOSTNAME=$(hostname)
|
||||||
echo -e "Current hostname: ${YELLOW}$CURRENT_HOSTNAME${NC}"
|
echo -e "Current hostname: ${YELLOW}$CURRENT_HOSTNAME${NC}"
|
||||||
read -rp "Enter a hostname for this spoke (e.g. rocky, gouda, camembert): " SPOKE_NAME
|
read -rp "Enter a hostname for this spoke (e.g. rocky, gouda, camembert): " SPOKE_NAME
|
||||||
SPOKE_NAME="${SPOKE_NAME:-$CURRENT_HOSTNAME}"
|
SPOKE_NAME="${SPOKE_NAME:-$CURRENT_HOSTNAME}"
|
||||||
|
[[ "$SPOKE_NAME" =~ ^[a-zA-Z0-9._-]+$ ]] || die "Spoke name '$SPOKE_NAME' contains invalid characters. Use only letters, numbers, dots, underscores, hyphens."
|
||||||
hostnamectl set-hostname "$SPOKE_NAME"
|
hostnamectl set-hostname "$SPOKE_NAME"
|
||||||
echo "$SPOKE_NAME" > /etc/hostname
|
echo "$SPOKE_NAME" > /etc/hostname
|
||||||
info "Hostname set to: $SPOKE_NAME"
|
info "Hostname set to: $SPOKE_NAME"
|
||||||
@@ -82,22 +194,22 @@ case "$KEY_CHOICE" in
|
|||||||
KEY_NAME="${KEY_NAME:-hubkey}"
|
KEY_NAME="${KEY_NAME:-hubkey}"
|
||||||
KEY_PATH="$SSH_DIR/$KEY_NAME"
|
KEY_PATH="$SSH_DIR/$KEY_NAME"
|
||||||
mkdir -p "$SSH_DIR"
|
mkdir -p "$SSH_DIR"
|
||||||
chown armbian:armbian "$SSH_DIR"
|
chown "$SPOKE_USER":"$SPOKE_USER" "$SSH_DIR"
|
||||||
chmod 700 "$SSH_DIR"
|
chmod 700 "$SSH_DIR"
|
||||||
|
|
||||||
if [ -f "$KEY_PATH" ]; then
|
if [ -f "$KEY_PATH" ]; then
|
||||||
warn "Key $KEY_PATH already exists, using it."
|
warn "Key $KEY_PATH already exists, using it."
|
||||||
else
|
else
|
||||||
info "Generating new ED25519 key..."
|
info "Generating new ED25519 key..."
|
||||||
sudo -u armbian ssh-keygen -t ed25519 -f "$KEY_PATH" -N ""
|
sudo -u "$SPOKE_USER" ssh-keygen -t ed25519 -f "$KEY_PATH" -N ""
|
||||||
chown armbian:armbian "$KEY_PATH" "$KEY_PATH.pub"
|
chown "$SPOKE_USER":"$SPOKE_USER" "$KEY_PATH" "$KEY_PATH.pub"
|
||||||
chmod 600 "$KEY_PATH"
|
chmod 600 "$KEY_PATH"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
|
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
|
||||||
echo -e "${YELLOW} Send this public key to the hub owner${NC}"
|
echo -e "${YELLOW} Send this public key to the hub owner${NC}"
|
||||||
echo -e "${YELLOW} and ask them to add it to armbian@${HUB_HOST} authorized_keys:${NC}"
|
echo -e "${YELLOW} and ask them to add it to ${HUB_USER}@${HUB_HOST} authorized_keys:${NC}"
|
||||||
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
|
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
|
||||||
cat "$KEY_PATH.pub"
|
cat "$KEY_PATH.pub"
|
||||||
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
|
echo -e "${YELLOW}══════════════════════════════════════════${NC}"
|
||||||
@@ -109,13 +221,13 @@ case "$KEY_CHOICE" in
|
|||||||
KEY_NAME="${KEY_NAME:-hubkey}"
|
KEY_NAME="${KEY_NAME:-hubkey}"
|
||||||
KEY_PATH="$SSH_DIR/$KEY_NAME"
|
KEY_PATH="$SSH_DIR/$KEY_NAME"
|
||||||
mkdir -p "$SSH_DIR"
|
mkdir -p "$SSH_DIR"
|
||||||
chown armbian:armbian "$SSH_DIR"
|
chown "$SPOKE_USER":"$SPOKE_USER" "$SSH_DIR"
|
||||||
chmod 700 "$SSH_DIR"
|
chmod 700 "$SSH_DIR"
|
||||||
|
|
||||||
echo "Paste the private key content below, then press ENTER and CTRL+D:"
|
echo "Paste the private key content below, then press ENTER and CTRL+D:"
|
||||||
KEY_CONTENT=$(cat | tr -d '\r')
|
KEY_CONTENT=$(cat | tr -d '\r')
|
||||||
printf '%s\n' "$KEY_CONTENT" > "$KEY_PATH"
|
printf '%s\n' "$KEY_CONTENT" > "$KEY_PATH"
|
||||||
chown armbian:armbian "$KEY_PATH"
|
chown "$SPOKE_USER":"$SPOKE_USER" "$KEY_PATH"
|
||||||
chmod 600 "$KEY_PATH"
|
chmod 600 "$KEY_PATH"
|
||||||
info "Key saved to $KEY_PATH"
|
info "Key saved to $KEY_PATH"
|
||||||
;;
|
;;
|
||||||
@@ -124,68 +236,136 @@ case "$KEY_CHOICE" in
|
|||||||
;;
|
;;
|
||||||
esac
|
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..."
|
info "Scanning hub host key..."
|
||||||
sudo -u armbian touch "$SSH_DIR/known_hosts"
|
sudo -u "$SPOKE_USER" touch "$SSH_DIR/known_hosts"
|
||||||
chown armbian:armbian "$SSH_DIR/known_hosts"
|
chown "$SPOKE_USER":"$SPOKE_USER" "$SSH_DIR/known_hosts"
|
||||||
chmod 600 "$SSH_DIR/known_hosts"
|
chmod 600 "$SSH_DIR/known_hosts"
|
||||||
sudo -u armbian 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"
|
header "Testing SSH Connection"
|
||||||
info "Testing connection to $HUB_HOST..."
|
info "Testing connection to $HUB_HOST..."
|
||||||
retry_or_abort \
|
retry_or_abort \
|
||||||
"sudo -u armbian ssh -i \"$KEY_PATH\" -o BatchMode=yes -o ConnectTimeout=10 \"$HUB_USER@$HUB_HOST\" exit" \
|
"sudo -u \"$SPOKE_USER\" ssh -i \"$KEY_PATH\" -o BatchMode=yes -o ConnectTimeout=10 \"$HUB_USER@$HUB_HOST\" exit" \
|
||||||
"SSH connection to $HUB_HOST failed. Check that the hub owner added your public key."
|
"SSH connection to $HUB_HOST failed. Check that the hub owner added your public key."
|
||||||
|
|
||||||
header "Finding Available Tunnel Port"
|
header "Finding Available Tunnel Port"
|
||||||
info "Scanning for a free port on $HUB_HOST starting from $START_PORT..."
|
info "Scanning for a free port on $HUB_HOST starting from $START_PORT..."
|
||||||
TUNNEL_PORT=""
|
|
||||||
for PORT in $(seq "$START_PORT" $((START_PORT + 20))); do
|
|
||||||
RESULT=$(sudo -u armbian 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"
|
header "Configuring compose.yaml"
|
||||||
info "Setting port to $TUNNEL_PORT and key to $KEY_NAME..."
|
info "Setting port to $TUNNEL_PORT and key to $KEY_NAME..."
|
||||||
|
|
||||||
|
SYNCTHING_MOUNT="$ARMBIAN_HOME/st/data"
|
||||||
|
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|-R [0-9]*:localhost:22|-R ${TUNNEL_PORT}:localhost:22|g" "$COMPOSE"
|
||||||
sed -i "s|-i /home/armbian/.ssh/[^ ]*|-i /home/armbian/.ssh/${KEY_NAME}|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-autossh|container_name: ${SPOKE_NAME}-autossh|g" "$COMPOSE"
|
||||||
sed -i "s|container_name: spoke-syncthing|container_name: ${SPOKE_NAME}-syncthing|g" "$COMPOSE"
|
sed -i "s|container_name: spoke-syncthing|container_name: ${SPOKE_NAME}-syncthing|g" "$COMPOSE"
|
||||||
sed -i "s|hostname: spoke-syncthing|hostname: ${SPOKE_NAME}-syncthing|g" "$COMPOSE"
|
sed -i "s|hostname: spoke-syncthing|hostname: ${SPOKE_NAME}-syncthing|g" "$COMPOSE"
|
||||||
sed -i '/^version:/d' "$COMPOSE"
|
|
||||||
|
|
||||||
SYNCTHING_MOUNT="$ARMBIAN_HOME/st"
|
|
||||||
mkdir -p "$SYNCTHING_MOUNT"
|
|
||||||
chown armbian:armbian "$SYNCTHING_MOUNT"
|
|
||||||
|
|
||||||
header "Building Docker Image"
|
header "Building Docker Image"
|
||||||
cd "$SPOKE_DIR"
|
cd "$SPOKE_DIR"
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg UID="$(id -u armbian)" \
|
--build-arg UID="$(id -u "$SPOKE_USER")" \
|
||||||
--build-arg GID="$(id -g armbian)" \
|
--build-arg GID="$(id -g "$SPOKE_USER")" \
|
||||||
-t spoke-autossh .
|
-t spoke-autossh .
|
||||||
|
|
||||||
header "Starting Containers"
|
header "Starting Containers"
|
||||||
docker compose up -d
|
TUNNEL_UP="false"
|
||||||
info "Waiting for tunnel to establish..."
|
for ATTEMPT in 1 2 3; do
|
||||||
sleep 6
|
docker compose up -d
|
||||||
|
info "Waiting for tunnel to establish..."
|
||||||
|
sleep 6
|
||||||
|
LOGS=$(docker logs "${SPOKE_NAME}-autossh" 2>&1 || true)
|
||||||
|
if echo "$LOGS" | grep -q "remote port forwarding failed"; then
|
||||||
|
warn "Tunnel failed on attempt $ATTEMPT — port $TUNNEL_PORT may have been taken."
|
||||||
|
docker compose down 2>/dev/null || true
|
||||||
|
TUNNEL_PORT=$(find_free_port $((TUNNEL_PORT + 1))) || die "Could not find a free port. Ask the hub owner to free up a port."
|
||||||
|
warn "Retrying with port $TUNNEL_PORT..."
|
||||||
|
sed -i "s|-R [0-9]*:localhost:22|-R ${TUNNEL_PORT}:localhost:22|g" "$COMPOSE"
|
||||||
|
else
|
||||||
|
TUNNEL_UP="true"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
LOGS=$(docker logs "${SPOKE_NAME}-autossh" 2>&1 || docker logs spoke-autossh 2>&1 || true)
|
if [ "$TUNNEL_UP" = "true" ]; then
|
||||||
if echo "$LOGS" | grep -q "remote port forwarding failed"; then
|
|
||||||
warn "Tunnel failed — port $TUNNEL_PORT may have been taken between check and connect."
|
|
||||||
warn "Try running: docker compose down && docker compose up -d"
|
|
||||||
warn "Or re-run this script."
|
|
||||||
else
|
|
||||||
info "Tunnel is up on port $TUNNEL_PORT."
|
info "Tunnel is up on port $TUNNEL_PORT."
|
||||||
|
else
|
||||||
|
die "Tunnel failed after 3 attempts. Run: docker compose down && docker compose up -d"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
header "Setup Complete"
|
header "Setup Complete"
|
||||||
@@ -196,17 +376,17 @@ echo ""
|
|||||||
echo -e "${YELLOW}The hub owner needs to do the following on ${HUB_HOST}:${NC}"
|
echo -e "${YELLOW}The hub owner needs to do the following on ${HUB_HOST}:${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
echo " 1. Generate a hub->spoke key:"
|
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 ""
|
||||||
echo " 2. Copy it to this spoke through the tunnel:"
|
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 armbian@localhost"
|
echo " ssh-copy-id -i ~/.ssh/${HUB_USER}-${SPOKE_NAME}-$(date +%Y%m).pub -p $TUNNEL_PORT ${HUB_USER}@localhost"
|
||||||
echo ""
|
echo ""
|
||||||
echo " 3. Add an rclone remote in ~/.config/rclone/rclone.conf:"
|
echo " 3. Add an rclone remote in ~/.config/rclone/rclone.conf:"
|
||||||
echo " [${SPOKE_NAME}-remote]"
|
echo " [${SPOKE_NAME}-remote]"
|
||||||
echo " type = sftp"
|
echo " type = sftp"
|
||||||
echo " host = localhost"
|
echo " host = localhost"
|
||||||
echo " port = $TUNNEL_PORT"
|
echo " port = $TUNNEL_PORT"
|
||||||
echo " key_file = /home/armbian/.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 " shell_type = unix"
|
||||||
echo " md5sum_command = md5sum"
|
echo " md5sum_command = md5sum"
|
||||||
echo " sha1sum_command = sha1sum"
|
echo " sha1sum_command = sha1sum"
|
||||||
|
|||||||
466
syncthing.sh
Executable file
466
syncthing.sh
Executable file
@@ -0,0 +1,466 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||||
|
die() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
|
||||||
|
header() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}══════════════════════════════════════════${NC}"; }
|
||||||
|
|
||||||
|
ST_URL="http://127.0.0.1:8384"
|
||||||
|
APIKEY=""
|
||||||
|
ST_CONTAINER=""
|
||||||
|
|
||||||
|
get_apikey() {
|
||||||
|
ST_CONTAINER=$(docker ps --format '{{.Names}}' | grep -i syncthing | head -1 || true)
|
||||||
|
[ -n "$ST_CONTAINER" ] || die "No running Syncthing container found."
|
||||||
|
APIKEY=$(docker exec "$ST_CONTAINER" 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
|
||||||
Reference in New Issue
Block a user