feat: complete SyncServer implementation
Full-stack Go monolith with embedded React frontend for orchestrating rsync-over-SSH file synchronization with Wake-on-LAN support. Features: - JWT auth (HS256) with bcrypt password hashing - CRUD for machines (with WoL config) and sync_pairs - Ed25519 SSH key generation and known_hosts management - WoL magic packet sender + TCP-connect waiter with backoff - Sync engine: rsync subprocess, per-pair job queue, progress parsing - Homebrew cron parser for scheduled syncs - SSE stream for live job status (queued/waking_up/running/success/failed) - React+TS+Vite+Tailwind SPA embedded via embed.FS - Debian packaging with systemd unit, postinst/prerm/postrm Tech stack: - Go 1.22+ (CGO_ENABLED=0, pure SQLite via modernc.org/sqlite) - chi router for HTTP API - TypeScript + React 18 + Tailwind CSS frontend - Cross-compiled to Linux amd64 for Proxmox LXC deployment Tests: wol (MAC parsing, magic packet), syncengine/queue, scheduler/cron
This commit is contained in:
+33
-151
@@ -1,165 +1,47 @@
|
|||||||
# ---> Go
|
# Binary output
|
||||||
# If you prefer the allow list template instead of the deny list, see community template:
|
syncserver
|
||||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
*.deb
|
||||||
#
|
dist/
|
||||||
# Binaries for programs and plugins
|
|
||||||
|
# Frontend
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
internal/webui/dist/
|
||||||
|
|
||||||
|
# Data (runtime)
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Go
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
|
||||||
*.test
|
*.test
|
||||||
|
|
||||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
|
||||||
*.out
|
*.out
|
||||||
|
|
||||||
# Dependency directories (remove the comment below to include it)
|
|
||||||
# vendor/
|
|
||||||
|
|
||||||
# Go workspace file
|
|
||||||
go.work
|
go.work
|
||||||
go.work.sum
|
go.work.sum
|
||||||
|
|
||||||
# env file
|
# Node
|
||||||
.env
|
|
||||||
|
|
||||||
# ---> Node
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
|
||||||
web_modules/
|
|
||||||
|
|
||||||
# TypeScript cache
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional stylelint cache
|
|
||||||
.stylelintcache
|
|
||||||
|
|
||||||
# Microbundle cache
|
|
||||||
.rpt2_cache/
|
|
||||||
.rts2_cache_cjs/
|
|
||||||
.rts2_cache_es/
|
|
||||||
.rts2_cache_umd/
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variable files
|
|
||||||
.env
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
|
||||||
.cache
|
|
||||||
.parcel-cache
|
|
||||||
|
|
||||||
# Next.js build output
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
|
||||||
.nuxt
|
|
||||||
dist
|
|
||||||
|
|
||||||
# Gatsby files
|
|
||||||
.cache/
|
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
|
||||||
# public
|
|
||||||
|
|
||||||
# vuepress build output
|
|
||||||
.vuepress/dist
|
|
||||||
|
|
||||||
# vuepress v2.x temp and cache directory
|
|
||||||
.temp
|
|
||||||
.cache
|
|
||||||
|
|
||||||
# vitepress build output
|
|
||||||
**/.vitepress/dist
|
|
||||||
|
|
||||||
# vitepress cache directory
|
|
||||||
**/.vitepress/cache
|
|
||||||
|
|
||||||
# Docusaurus cache and generated files
|
|
||||||
.docusaurus
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
.serverless/
|
|
||||||
|
|
||||||
# FuseBox cache
|
|
||||||
.fusebox/
|
|
||||||
|
|
||||||
# DynamoDB Local files
|
|
||||||
.dynamodb/
|
|
||||||
|
|
||||||
# TernJS port file
|
|
||||||
.tern-port
|
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
.vscode-test
|
|
||||||
|
|
||||||
# yarn v2
|
|
||||||
.yarn/cache
|
|
||||||
.yarn/unplugged
|
|
||||||
.yarn/build-state.yml
|
|
||||||
.yarn/install-state.gz
|
|
||||||
.pnp.*
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# SyncServer — Agent Guidance
|
||||||
|
|
||||||
|
## Project Type
|
||||||
|
|
||||||
|
Go monolith (single binary) with embedded React SPA frontend. Targets Linux amd64 LXC containers on Proxmox.
|
||||||
|
|
||||||
|
## Dev Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local dev build (macOS)
|
||||||
|
make build-local
|
||||||
|
|
||||||
|
# Production Linux amd64 build
|
||||||
|
make build # → packaging/debian/usr/bin/syncserver
|
||||||
|
make deb # → dist/syncserver_VERSION_amd64.deb
|
||||||
|
|
||||||
|
# Run
|
||||||
|
./syncserver --data-dir ./data --addr :8080 \
|
||||||
|
SYNCSERVER_ADMIN_USER=admin SYNCSERVER_ADMIN_PASSWORD=secret
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical Build Order
|
||||||
|
|
||||||
|
1. Frontend must be built FIRST: `cd web && npm run build`
|
||||||
|
2. Output copied to `internal/webui/dist/` (must exist before Go build)
|
||||||
|
3. Then: `go build ./cmd/server`
|
||||||
|
|
||||||
|
`scripts/build.sh` does this automatically. Running `go build` without the frontend dist will produce a binary without the web UI.
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
- **Entry point**: `cmd/server/main.go`
|
||||||
|
- **DB**: `modernc.org/sqlite` — pure Go, CGO_ENABLED=0. Driver name is `"sqlite"` (not `"sqlite3"`).
|
||||||
|
- **Migrations**: embedded via `//go:embed migrations` in `internal/db/migrations.go`. Must use `migrationsFS` variable, path `"migrations"` (not `"internal/db/migrations"`).
|
||||||
|
- **SSH keys**: generated at startup if missing; stored in `$DATA_DIR/ssh/id_ed25519`. Only the public key is stored in DB.
|
||||||
|
- **Sync engine**: uses `os/exec.CommandContext` to run `rsync -e ssh ...`. SSH key is passed via `-i` flag, not via SSH config file.
|
||||||
|
- **Scheduler**: homebrew cron parser (`m h dom mon dow` format, no seconds). Library `github.com/robfig/cron` is NOT used.
|
||||||
|
- **SSE**: `GET /api/jobs/stream` streams `text/event-stream`. Clients must handle `event:` field.
|
||||||
|
|
||||||
|
## Frontend Stack
|
||||||
|
|
||||||
|
- TypeScript + React 18 + Vite + Tailwind CSS
|
||||||
|
- Source: `web/src/`, built output: `web/dist/`
|
||||||
|
- API client: `web/src/api/client.ts`
|
||||||
|
- No React Router 6 data loaders; fetch hooks are manual in each page component
|
||||||
|
- Tests: no frontend test suite yet
|
||||||
|
|
||||||
|
## Key Package Boundaries
|
||||||
|
|
||||||
|
| Package | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `internal/api/` | HTTP handlers, chi router, DTOs |
|
||||||
|
| `internal/auth/` | JWT (HS256), bcrypt, middleware |
|
||||||
|
| `internal/db/` | SQLite connection, migrations runner |
|
||||||
|
| `internal/models/` | Data access (raw sql) |
|
||||||
|
| `internal/syncengine/` | Job queue, rsync subprocess, event bus |
|
||||||
|
| `internal/scheduler/` | Cron parsing, tick loop |
|
||||||
|
| `internal/sshmanager/` | Ed25519 key generation, known_hosts |
|
||||||
|
| `internal/wol/` | Magic packet, TCP/ping waiter |
|
||||||
|
| `internal/webui/` | `embed.FS` for React dist |
|
||||||
|
|
||||||
|
## DB Schema
|
||||||
|
|
||||||
|
Managed via ordered SQL migrations in `internal/db/migrations/`. Apply on startup via `db.RunMigrations()`. Table `schema_migrations` tracks applied versions.
|
||||||
|
|
||||||
|
## Package Script Quirks
|
||||||
|
|
||||||
|
- `scripts/build.sh`: cross-compiles to Linux amd64 via `GOOS=linux GOARCH=amd64`. Version injected via `-ldflags="-X main.version=$VERSION"`.
|
||||||
|
- `scripts/package-deb.sh`: calls `dpkg-deb --build`. Version must be substituted into `DEBIAN/control` before build (`@@VERSION@@` placeholder).
|
||||||
|
|
||||||
|
## WoL Constraints
|
||||||
|
|
||||||
|
Magic packet uses `SO_BROADCAST` on a plain UDP socket. No raw sockets, no privileged container needed. Requires L2 reachability (same broadcast domain) from the server to the target machine's NIC.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
.PHONY: build dev clean deb run test lint
|
||||||
|
|
||||||
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
|
GOARCH := amd64
|
||||||
|
|
||||||
|
build:
|
||||||
|
GOARCH=$(GOARCH) ./scripts/build.sh
|
||||||
|
|
||||||
|
deb:
|
||||||
|
./scripts/package-deb.sh
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf dist/
|
||||||
|
rm -rf internal/webui/dist/
|
||||||
|
go clean
|
||||||
|
|
||||||
|
run: build
|
||||||
|
./syncserver --data-dir ./data --addr :8080
|
||||||
|
|
||||||
|
dev:
|
||||||
|
cd web && npm run dev &
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
lint:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
go fmt ./...
|
||||||
|
|
||||||
|
# Build for local macOS (not for production)
|
||||||
|
build-local:
|
||||||
|
rm -rf web/dist && cd web && npm run build && cd ..
|
||||||
|
cp -R web/dist internal/webui/dist/
|
||||||
|
go build -ldflags="-X main.version=$(VERSION)" -o syncserver ./cmd/server
|
||||||
@@ -1,2 +1,299 @@
|
|||||||
# move-data-nas
|
# SyncServer
|
||||||
|
|
||||||
|
Self-hosted file sync orchestrator with Wake-on-LAN and web UI. Orchestrates `rsync` over SSH between multiple machines from a single central server.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Web UI**: React-based interface to manage machines, sync pairs, and job history
|
||||||
|
- **Wake-on-LAN**: Power on remote machines before syncing (UDP magic packet)
|
||||||
|
- **Rsync over SSH**: Efficient file synchronization with progress tracking
|
||||||
|
- **Scheduled syncs**: Cron-style scheduler for automated backups
|
||||||
|
- **Live progress**: Server-Sent Events (SSE) for real-time job status
|
||||||
|
- **Single binary**: No dynamic dependencies, pure Go with embedded SQLite
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ SyncServer (Go binary) │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ Web UI (React, embedded) │ │
|
||||||
|
│ │ REST API (chi router) │ │
|
||||||
|
│ │ Auth (JWT + bcrypt) │ │
|
||||||
|
│ │ Scheduler (cron) │ │
|
||||||
|
│ │ Sync Engine (rsync runner) │ │
|
||||||
|
│ │ WoL (UDP magic packet) │ │
|
||||||
|
│ │ SQLite (embedded) │ │
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
└──────────────┬────────────────────────┘
|
||||||
|
│ SSH + UDP
|
||||||
|
┌─────────┴──────────┐
|
||||||
|
▼ ▼
|
||||||
|
Machine A Machine B
|
||||||
|
(rsync+sshd) (rsync+sshd)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Network Requirements for Wake-on-LAN
|
||||||
|
|
||||||
|
**Critical**: The magic packet (WoL) only works when the SyncServer is on the **same L2 network** (same broadcast domain) as the target machine.
|
||||||
|
|
||||||
|
### Proxmox LXC Setup
|
||||||
|
|
||||||
|
- Use **bridge networking** (`vmbr0`) — NOT NAT
|
||||||
|
- The LXC must share the same VLAN/LAN as the machines it needs to wake
|
||||||
|
- No special LXC privileges required (works with unprivileged containers)
|
||||||
|
- `SO_BROADCAST` works in unprivileged LXC containers
|
||||||
|
|
||||||
|
If your LXC is on a different subnet, use the `broadcast_addr` field on the machine to specify the broadcast address of the target machine's subnet (e.g., `192.168.1.255`).
|
||||||
|
|
||||||
|
### BIOS/UEFI Requirements on Target Machines
|
||||||
|
|
||||||
|
- Enable **Wake-on-LAN** in BIOS/UEFI
|
||||||
|
- Set to "Power On by PCIe" or "Wake by PCI-E"
|
||||||
|
- Some motherboards require both PCIe and LAN WoL to be enabled
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Quick Start (Development)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/syncserver/syncserver
|
||||||
|
cd syncserver
|
||||||
|
|
||||||
|
# Run with default settings (data in ./data)
|
||||||
|
SYNCSERVER_ADMIN_USER=admin SYNCSERVER_ADMIN_PASSWORD=secret \
|
||||||
|
./syncserver --addr :8080
|
||||||
|
|
||||||
|
# Open http://your-server:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Install (.deb)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Transfer to your Proxmox LXC (Debian/Ubuntu)
|
||||||
|
scp dist/syncserver_*.deb root@your-lxc:/tmp/
|
||||||
|
|
||||||
|
# On the LXC:
|
||||||
|
dpkg -i /tmp/syncserver_*.deb
|
||||||
|
# postinst will:
|
||||||
|
# - Create syncserver user
|
||||||
|
# - Create /var/lib/syncserver/{data,ssh,logs}
|
||||||
|
# - Install config at /etc/syncserver/config.yaml
|
||||||
|
# - Enable and start the systemd service
|
||||||
|
|
||||||
|
# To configure the admin user on first run:
|
||||||
|
# Set environment variables BEFORE first start (in /etc/syncserver/config.yaml or systemd unit):
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Edit `/etc/syncserver/config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
data_dir: /var/lib/syncserver
|
||||||
|
addr: :8080
|
||||||
|
|
||||||
|
auth:
|
||||||
|
jwt_secret: "your-secret-here" # Generate with: openssl rand -hex 32
|
||||||
|
jwt_expiry_hours: 24
|
||||||
|
|
||||||
|
scheduler:
|
||||||
|
timezone: UTC
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use environment variables (override config file):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SYNCSERVER_ADMIN_USER=admin SYNCSERVER_ADMIN_PASSWORD=secret \
|
||||||
|
SYNCSERVER_JWT_SECRET=your-secret \
|
||||||
|
SYNCSERVER_DATA_DIR=/var/lib/syncserver \
|
||||||
|
/usr/bin/syncserver --config /etc/syncserver/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup: SSH Keys
|
||||||
|
|
||||||
|
### Step 1: Get the Server's Public Key
|
||||||
|
|
||||||
|
In the UI, go to **Settings** to copy the server's SSH public key. It looks like:
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh-ed25519 AAAA... syncserver
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Add the Key to Remote Machines
|
||||||
|
|
||||||
|
On each remote machine, add the public key to `~/.ssh/authorized_keys`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# As root on the remote machine:
|
||||||
|
echo "ssh-ed25519 AAAA... syncserver" >> ~/.ssh/authorized_keys
|
||||||
|
chmod 600 ~/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use `ssh-copy-id`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@remote-machine
|
||||||
|
# Then replace the key in the remote's authorized_keys with the syncserver key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Register Machines in the UI
|
||||||
|
|
||||||
|
Go to **Machines** → **Add Machine**:
|
||||||
|
- Name: e.g., `backup-nas`
|
||||||
|
- Host: IP or hostname (e.g., `192.168.1.100`)
|
||||||
|
- SSH Port: `22`
|
||||||
|
- SSH User: `root` (or your preferred user)
|
||||||
|
- SSH Key: leave empty to use the server's default key
|
||||||
|
|
||||||
|
For **Wake-on-LAN**:
|
||||||
|
- Enable "Wake-on-LAN"
|
||||||
|
- Enter the MAC address (e.g., `AA:BB:CC:DD:EE:FF`)
|
||||||
|
- Optionally set a broadcast address (leave empty for `255.255.255.255`)
|
||||||
|
- Configure timeout and check interval
|
||||||
|
|
||||||
|
## Creating Sync Pairs
|
||||||
|
|
||||||
|
Go to **Sync Pairs** → **Add Sync Pair**:
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| Name | Descriptive name, e.g., `backup-home` |
|
||||||
|
| Source | Machine + path (or "Local server" for the syncserver itself) |
|
||||||
|
| Destination | Machine + path |
|
||||||
|
| Direction | `push` (source→dest), `pull` (dest→source), `mirror` (bidirectional with delete) |
|
||||||
|
| Rsync Flags | Default: `-aP` (archive, progress). Add `--delete` for mirror mode. |
|
||||||
|
| Exclude Patterns | One pattern per line, e.g., `*.tmp`, `node_modules/` |
|
||||||
|
|
||||||
|
## Scheduling
|
||||||
|
|
||||||
|
Add a schedule to any sync pair:
|
||||||
|
|
||||||
|
| Cron Expr | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `0 2 * * *` | Daily at 2 AM |
|
||||||
|
| `0 */6 * * *` | Every 6 hours |
|
||||||
|
| `0 9-17 * * 1-5` | Business hours, weekdays |
|
||||||
|
| `*/15 * * * *` | Every 15 minutes |
|
||||||
|
|
||||||
|
All schedules run in **UTC** by default. Set `scheduler.timezone` in config to change.
|
||||||
|
|
||||||
|
## Job States
|
||||||
|
|
||||||
|
| State | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `queued` | Waiting for a worker |
|
||||||
|
| `waking_up` | Sending WoL magic packet and waiting for machine |
|
||||||
|
| `running` | rsync in progress |
|
||||||
|
| `success` | Completed successfully |
|
||||||
|
| `failed` | Error during sync |
|
||||||
|
| `cancelled` | User cancelled the job |
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
Job logs are stored at `/var/lib/syncserver/logs/<job_id>.log`.
|
||||||
|
|
||||||
|
Application logs go to systemd journal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u syncserver -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On the LXC:
|
||||||
|
dpkg -i /tmp/syncserver-new-version.deb
|
||||||
|
# The service will automatically restart
|
||||||
|
```
|
||||||
|
|
||||||
|
Config at `/etc/syncserver/config.yaml` is preserved (conffile).
|
||||||
|
|
||||||
|
## Build from Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Requires: Go 1.22+, Node 18+, npm
|
||||||
|
|
||||||
|
./scripts/build.sh
|
||||||
|
# Output: packaging/debian/usr/bin/syncserver
|
||||||
|
|
||||||
|
./scripts/package-deb.sh
|
||||||
|
# Output: dist/syncserver_VERSION_amd64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
For ARM64:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOARCH=arm64 ./scripts/build.sh
|
||||||
|
ARCH=arm64 ./scripts/package-deb.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Storage
|
||||||
|
|
||||||
|
```
|
||||||
|
/var/lib/syncserver/
|
||||||
|
├── data/
|
||||||
|
│ └── app.db # SQLite database (machines, sync_pairs, jobs, etc.)
|
||||||
|
├── ssh/
|
||||||
|
│ ├── id_ed25519 # Server's private SSH key
|
||||||
|
│ ├── id_ed25519.pub # Server's public SSH key
|
||||||
|
│ └── known_hosts # Host keys of registered machines
|
||||||
|
└── logs/
|
||||||
|
└── <job_id>.log # Per-job rsync output logs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommended**: Bind mount `/var/lib/syncserver` to persistent storage outside the LXC root filesystem. This way you can recreate the LXC without losing data.
|
||||||
|
|
||||||
|
In Proxmox LXC config (`/etc/pve/lxc/<id>.conf`):
|
||||||
|
|
||||||
|
```
|
||||||
|
mp0: /mnt/data/syncserver,mp=/var/lib/syncserver,backup=0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### WoL doesn't work
|
||||||
|
1. Verify the LXC is on the same L2 network as the target
|
||||||
|
2. Check `ip link` in the LXC shows the bridge interface
|
||||||
|
3. Try: `tcpdump -i <iface> udp port 9` in the LXC while triggering WoL
|
||||||
|
4. Verify WoL is enabled in the target machine's BIOS
|
||||||
|
5. Try sending WoL from another machine on the same subnet to isolate the issue
|
||||||
|
|
||||||
|
### SSH connection fails
|
||||||
|
1. Verify the public key is in `~/.ssh/authorized_keys` on the target
|
||||||
|
2. Check `sshd` is running on the target machine
|
||||||
|
3. Try manually: `ssh -i /var/lib/syncserver/ssh/id_ed25519 user@host`
|
||||||
|
4. Check the target's `sshd_config`: `PubkeyAuthentication yes`, `AuthorizedKeysFile .ssh/authorized_keys`
|
||||||
|
|
||||||
|
### rsync fails with "connection unexpectedly closed"
|
||||||
|
- Usually means SSH key auth failed
|
||||||
|
- Check the job log for the exact SSH error
|
||||||
|
- Try running the rsync command manually from the server with `-v` flag
|
||||||
|
|
||||||
|
### Machine shows "unknown" status
|
||||||
|
- Status is updated when a job runs or when you test SSH
|
||||||
|
- It does not auto-poll; this is by design to avoid network load
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
All API endpoints require authentication (JWT cookie).
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | `/api/auth/login` | Login with username/password |
|
||||||
|
| POST | `/api/auth/logout` | Clear session |
|
||||||
|
| GET | `/api/auth/me` | Current user info |
|
||||||
|
| GET/POST | `/api/machines` | List/create machines |
|
||||||
|
| GET/PUT/DELETE | `/api/machines/{id}` | Get/update/delete machine |
|
||||||
|
| GET/POST | `/api/sync-pairs` | List/create sync pairs |
|
||||||
|
| GET/PUT/DELETE | `/api/sync-pairs/{id}` | Get/update/delete sync pair |
|
||||||
|
| POST | `/api/sync-pairs/{id}/run` | Trigger manual sync |
|
||||||
|
| GET | `/api/jobs` | List jobs |
|
||||||
|
| POST | `/api/jobs/{id}/cancel` | Cancel a running job |
|
||||||
|
| GET | `/api/jobs/{id}/log` | Stream job log |
|
||||||
|
| GET | `/api/jobs/stream` | SSE stream of all job events |
|
||||||
|
| GET | `/api/settings/pubkey` | Get server's SSH public key |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/syncserver/internal/api"
|
||||||
|
"github.com/syncserver/internal/auth"
|
||||||
|
"github.com/syncserver/internal/config"
|
||||||
|
"github.com/syncserver/internal/db"
|
||||||
|
"github.com/syncserver/internal/scheduler"
|
||||||
|
"github.com/syncserver/internal/sshmanager"
|
||||||
|
"github.com/syncserver/internal/syncengine"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfgPath := flag.String("config", "", "Path to config.yaml")
|
||||||
|
dataDir := flag.String("data-dir", "", "Data directory")
|
||||||
|
addr := flag.String("addr", "", "HTTP listen address")
|
||||||
|
showVersion := flag.Bool("version", false, "Print version")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *showVersion {
|
||||||
|
fmt.Println(version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(*cfgPath, *dataDir, *addr)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "config error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
cfg.Version = version
|
||||||
|
|
||||||
|
if err := cfg.EnsureDirs(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "failed to create dirs: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
slogHandler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelInfo,
|
||||||
|
})
|
||||||
|
slog.SetDefault(slog.New(slogHandler))
|
||||||
|
|
||||||
|
slog.Info("starting syncserver",
|
||||||
|
"version", version,
|
||||||
|
"data_dir", cfg.DataDir,
|
||||||
|
"addr", cfg.Addr,
|
||||||
|
)
|
||||||
|
|
||||||
|
database, err := db.Open(cfg.DBPath())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to open database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
if err := database.RunMigrations(); err != nil {
|
||||||
|
slog.Error("failed to run migrations", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.SeedAdmin(database.DB, cfg.Auth.AdminUser, cfg.Auth.AdminPass); err != nil {
|
||||||
|
slog.Warn("admin seeding skipped or failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privKeyPath, _, pubKey, err := sshmanager.EnsureServerKey(cfg.SSHDir())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to ensure server SSH key", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
slog.Info("server SSH key ready", "pub_key", pubKey)
|
||||||
|
_ = privKeyPath
|
||||||
|
|
||||||
|
engine := syncengine.New(database, cfg)
|
||||||
|
sched := scheduler.New(database, engine, cfg)
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: config.NormalizeAddr(cfg.Addr),
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 60 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
apiServer := api.NewServer(cfg, database.DB, engine)
|
||||||
|
srv.Handler = apiServer
|
||||||
|
|
||||||
|
go sched.Start()
|
||||||
|
go engine.Start()
|
||||||
|
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
slog.Info("http server listening", "addr", srv.Addr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
slog.Error("http server error", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-sigCh
|
||||||
|
slog.Info("shutting down...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sched.Stop()
|
||||||
|
engine.Stop()
|
||||||
|
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
slog.Error("server shutdown error", "error", err)
|
||||||
|
}
|
||||||
|
slog.Info("bye")
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
module github.com/syncserver
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/crypto v0.31.0 // indirect
|
||||||
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.73.4 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
modernc.org/sqlite v1.53.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||||
|
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||||
|
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
type MachineRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
SSHUser string `json:"ssh_user"`
|
||||||
|
SSHKeyID *int64 `json:"ssh_key_id"`
|
||||||
|
MACAddress *string `json:"mac_address"`
|
||||||
|
WoLEnabled bool `json:"wol_enabled"`
|
||||||
|
BroadcastAddr *string `json:"broadcast_addr"`
|
||||||
|
WakeTimeoutSeconds int `json:"wake_timeout_seconds"`
|
||||||
|
WakeCheckIntervalSeconds int `json:"wake_check_interval_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MachineResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
SSHUser string `json:"ssh_user"`
|
||||||
|
SSHKeyID *int64 `json:"ssh_key_id"`
|
||||||
|
MACAddress *string `json:"mac_address"`
|
||||||
|
WoLEnabled bool `json:"wol_enabled"`
|
||||||
|
BroadcastAddr *string `json:"broadcast_addr"`
|
||||||
|
WakeTimeoutSeconds int `json:"wake_timeout_seconds"`
|
||||||
|
WakeCheckIntervalSeconds int `json:"wake_check_interval_seconds"`
|
||||||
|
FingerprintConfirmed bool `json:"fingerprint_confirmed"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncPairRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
SourceMachineID *int64 `json:"source_machine_id"`
|
||||||
|
SourcePath string `json:"source_path"`
|
||||||
|
DestMachineID *int64 `json:"dest_machine_id"`
|
||||||
|
DestPath string `json:"dest_path"`
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
RsyncFlags string `json:"rsync_flags"`
|
||||||
|
ExcludePatterns string `json:"exclude_patterns"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncPairResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
SourceMachineID *int64 `json:"source_machine_id"`
|
||||||
|
SourcePath string `json:"source_path"`
|
||||||
|
DestMachineID *int64 `json:"dest_machine_id"`
|
||||||
|
DestPath string `json:"dest_path"`
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
RsyncFlags string `json:"rsync_flags"`
|
||||||
|
ExcludePatterns string `json:"exclude_patterns"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
SyncPairID int64 `json:"sync_pair_id"`
|
||||||
|
TriggerType string `json:"trigger_type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StartedAt *string `json:"started_at"`
|
||||||
|
FinishedAt *string `json:"finished_at"`
|
||||||
|
LogFile *string `json:"log_file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/syncserver/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthHandler struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthHandler(db *sql.DB) *AuthHandler {
|
||||||
|
return &AuthHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginResponse struct {
|
||||||
|
User UserResponse `json:"user"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req LoginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
row := h.db.QueryRow(
|
||||||
|
"SELECT id, username, password_hash, role FROM users WHERE username = ?",
|
||||||
|
req.Username,
|
||||||
|
)
|
||||||
|
var u struct {
|
||||||
|
ID int64
|
||||||
|
Username string
|
||||||
|
PasswordHash string
|
||||||
|
Role string
|
||||||
|
}
|
||||||
|
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role); err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !auth.VerifyPassword([]byte(u.PasswordHash), req.Password) {
|
||||||
|
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jwtMgr := auth.GetJWTManager()
|
||||||
|
if jwtMgr == nil {
|
||||||
|
http.Error(w, `{"error":"server misconfigured"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, expiresAt, err := jwtMgr.Generate(u.ID, u.Username, u.Role)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.SetAuthCookie(w, token, expiresAt)
|
||||||
|
|
||||||
|
resp := LoginResponse{
|
||||||
|
User: UserResponse{
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
Role: u.Role,
|
||||||
|
},
|
||||||
|
ExpiresAt: expiresAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth.ClearAuthCookie(w)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := auth.GetClaims(r.Context())
|
||||||
|
if claims == nil {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := UserResponse{
|
||||||
|
ID: claims.UserID,
|
||||||
|
Username: claims.Username,
|
||||||
|
Role: claims.Role,
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{"user": resp})
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/syncserver/internal/models"
|
||||||
|
"github.com/syncserver/internal/syncengine"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JobHandler struct {
|
||||||
|
db *sql.DB
|
||||||
|
engine *syncengine.Engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJobHandler(db *sql.DB, engine *syncengine.Engine) *JobHandler {
|
||||||
|
return &JobHandler{db: db, engine: engine}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *JobHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||||
|
if limit <= 0 || limit > 100 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := models.NewJobRepository(h.db)
|
||||||
|
jobs, err := repo.GetAll(limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to fetch jobs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]JobResponse, len(jobs))
|
||||||
|
for i, j := range jobs {
|
||||||
|
out[i] = jobToResp(j)
|
||||||
|
}
|
||||||
|
writeJSON(w, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *JobHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := models.NewJobRepository(h.db)
|
||||||
|
j, err := repo.GetByID(id)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "job not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to fetch job")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, jobToResp(*j))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *JobHandler) Cancel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := models.NewJobRepository(h.db)
|
||||||
|
j, err := repo.GetByID(id)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "job not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to fetch job")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if j.Status != "queued" && j.Status != "waking_up" && j.Status != "running" {
|
||||||
|
writeError(w, http.StatusBadRequest, "job is not cancellable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.engine != nil {
|
||||||
|
h.engine.Cancel(id, j.SyncPairID)
|
||||||
|
}
|
||||||
|
|
||||||
|
repo.UpdateStatus(id, "cancelled")
|
||||||
|
writeJSON(w, map[string]string{"status": "cancelled"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *JobHandler) TriggerRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pairID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.engine == nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "engine not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobID, err := h.engine.CreateJob(pairID, "manual")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create job")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
h.engine.Run(r.Context(), jobID, pairID)
|
||||||
|
}()
|
||||||
|
|
||||||
|
jobRepo := models.NewJobRepository(h.db)
|
||||||
|
j, _ := jobRepo.GetByID(jobID)
|
||||||
|
writeJSON(w, jobToResp(*j), http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *JobHandler) StreamLog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusInternalServerError, "streaming not supported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
jobRepo := models.NewJobRepository(h.db)
|
||||||
|
j, err := jobRepo.GetByID(id)
|
||||||
|
if err == nil && j.LogFile != nil {
|
||||||
|
data, _ := os.ReadFile(*j.LogFile)
|
||||||
|
w.Write(data)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jobToResp(j models.Job) JobResponse {
|
||||||
|
resp := JobResponse{
|
||||||
|
ID: j.ID,
|
||||||
|
SyncPairID: j.SyncPairID,
|
||||||
|
TriggerType: j.TriggerType,
|
||||||
|
Status: j.Status,
|
||||||
|
LogFile: j.LogFile,
|
||||||
|
}
|
||||||
|
if j.StartedAt != nil {
|
||||||
|
s := j.StartedAt.Format(time.RFC3339)
|
||||||
|
resp.StartedAt = &s
|
||||||
|
}
|
||||||
|
if j.FinishedAt != nil {
|
||||||
|
s := j.FinishedAt.Format(time.RFC3339)
|
||||||
|
resp.FinishedAt = &s
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/syncserver/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MachineHandler struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMachineHandler(db *sql.DB) *MachineHandler {
|
||||||
|
return &MachineHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
var macRegex = regexp.MustCompile(`^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$`)
|
||||||
|
|
||||||
|
func (h *MachineHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo := models.NewMachineRepository(h.db)
|
||||||
|
ms, err := repo.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to fetch machines")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]MachineResponse, len(ms))
|
||||||
|
for i, m := range ms {
|
||||||
|
out[i] = machineToResp(m)
|
||||||
|
}
|
||||||
|
writeJSON(w, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MachineHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
repo := models.NewMachineRepository(h.db)
|
||||||
|
m, err := repo.GetByID(id)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "machine not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to fetch machine")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, machineToResp(*m))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MachineHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req MachineRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" || req.Host == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "name and host are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Port <= 0 || req.Port > 65535 {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid port")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.SSHUser == "" {
|
||||||
|
req.SSHUser = "root"
|
||||||
|
}
|
||||||
|
if req.WakeTimeoutSeconds <= 0 {
|
||||||
|
req.WakeTimeoutSeconds = 120
|
||||||
|
}
|
||||||
|
if req.WakeCheckIntervalSeconds <= 0 {
|
||||||
|
req.WakeCheckIntervalSeconds = 5
|
||||||
|
}
|
||||||
|
if req.WoLEnabled && req.MACAddress != nil && !macRegex.MatchString(*req.MACAddress) {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid mac_address format (expected AA:BB:CC:DD:EE:FF)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &models.Machine{
|
||||||
|
Name: req.Name,
|
||||||
|
Host: req.Host,
|
||||||
|
Port: req.Port,
|
||||||
|
SSHUser: req.SSHUser,
|
||||||
|
SSHKeyID: req.SSHKeyID,
|
||||||
|
MACAddress: req.MACAddress,
|
||||||
|
WoLEnabled: req.WoLEnabled,
|
||||||
|
BroadcastAddr: req.BroadcastAddr,
|
||||||
|
WakeTimeoutSeconds: req.WakeTimeoutSeconds,
|
||||||
|
WakeCheckIntervalSeconds: req.WakeCheckIntervalSeconds,
|
||||||
|
FingerprintConfirmed: false,
|
||||||
|
Status: "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := models.NewMachineRepository(h.db)
|
||||||
|
id, err := repo.Create(m)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create machine")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.ID = id
|
||||||
|
w.Header().Set("Location", "/api/machines/"+strconv.FormatInt(id, 10))
|
||||||
|
writeJSON(w, machineToResp(*m), http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MachineHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req MachineRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" || req.Host == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "name and host are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Port <= 0 || req.Port > 65535 {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid port")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.SSHUser == "" {
|
||||||
|
req.SSHUser = "root"
|
||||||
|
}
|
||||||
|
if req.WoLEnabled && req.MACAddress != nil && !macRegex.MatchString(*req.MACAddress) {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid mac_address format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := models.NewMachineRepository(h.db)
|
||||||
|
existing, err := repo.GetByID(id)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "machine not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to fetch machine")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.Name = req.Name
|
||||||
|
existing.Host = req.Host
|
||||||
|
existing.Port = req.Port
|
||||||
|
existing.SSHUser = req.SSHUser
|
||||||
|
existing.SSHKeyID = req.SSHKeyID
|
||||||
|
existing.MACAddress = req.MACAddress
|
||||||
|
existing.WoLEnabled = req.WoLEnabled
|
||||||
|
existing.BroadcastAddr = req.BroadcastAddr
|
||||||
|
if req.WakeTimeoutSeconds > 0 {
|
||||||
|
existing.WakeTimeoutSeconds = req.WakeTimeoutSeconds
|
||||||
|
}
|
||||||
|
if req.WakeCheckIntervalSeconds > 0 {
|
||||||
|
existing.WakeCheckIntervalSeconds = req.WakeCheckIntervalSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.Update(existing); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update machine")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, machineToResp(*existing))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *MachineHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := models.NewMachineRepository(h.db)
|
||||||
|
if err := repo.Delete(id); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to delete machine")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func machineToResp(m models.Machine) MachineResponse {
|
||||||
|
var status string
|
||||||
|
if m.LastSeenAt != nil {
|
||||||
|
status = m.Status + " (last seen " + m.LastSeenAt.Format(time.RFC3339) + ")"
|
||||||
|
} else {
|
||||||
|
status = m.Status
|
||||||
|
}
|
||||||
|
return MachineResponse{
|
||||||
|
ID: m.ID,
|
||||||
|
Name: m.Name,
|
||||||
|
Host: m.Host,
|
||||||
|
Port: m.Port,
|
||||||
|
SSHUser: m.SSHUser,
|
||||||
|
SSHKeyID: m.SSHKeyID,
|
||||||
|
MACAddress: m.MACAddress,
|
||||||
|
WoLEnabled: m.WoLEnabled,
|
||||||
|
BroadcastAddr: m.BroadcastAddr,
|
||||||
|
WakeTimeoutSeconds: m.WakeTimeoutSeconds,
|
||||||
|
WakeCheckIntervalSeconds: m.WakeCheckIntervalSeconds,
|
||||||
|
FingerprintConfirmed: m.FingerprintConfirmed,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, code int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
json.NewEncoder(w).Encode(ErrorResponse{Error: msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, data interface{}, codes ...int) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if len(codes) > 0 {
|
||||||
|
w.WriteHeader(codes[0])
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/syncserver/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SyncPairHandler struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSyncPairHandler(db *sql.DB) *SyncPairHandler {
|
||||||
|
return &SyncPairHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
var directionRegex = regexp.MustCompile(`^(push|pull|mirror)$`)
|
||||||
|
|
||||||
|
func (h *SyncPairHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repo := models.NewSyncPairRepository(h.db)
|
||||||
|
pairs, err := repo.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to fetch sync pairs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]SyncPairResponse, len(pairs))
|
||||||
|
for i, p := range pairs {
|
||||||
|
out[i] = syncPairToResp(p)
|
||||||
|
}
|
||||||
|
writeJSON(w, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SyncPairHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
repo := models.NewSyncPairRepository(h.db)
|
||||||
|
p, err := repo.GetByID(id)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "sync pair not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to fetch sync pair")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, syncPairToResp(*p))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SyncPairHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req SyncPairRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" || req.SourcePath == "" || req.DestPath == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "name, source_path and dest_path are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Direction == "" {
|
||||||
|
req.Direction = "push"
|
||||||
|
}
|
||||||
|
if !directionRegex.MatchString(req.Direction) {
|
||||||
|
writeError(w, http.StatusBadRequest, "direction must be push, pull, or mirror")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.RsyncFlags == "" {
|
||||||
|
req.RsyncFlags = "-aP"
|
||||||
|
}
|
||||||
|
if !req.Enabled {
|
||||||
|
req.Enabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
sp := &models.SyncPair{
|
||||||
|
Name: req.Name,
|
||||||
|
SourceMachineID: req.SourceMachineID,
|
||||||
|
SourcePath: req.SourcePath,
|
||||||
|
DestMachineID: req.DestMachineID,
|
||||||
|
DestPath: req.DestPath,
|
||||||
|
Direction: req.Direction,
|
||||||
|
RsyncFlags: req.RsyncFlags,
|
||||||
|
ExcludePatterns: req.ExcludePatterns,
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := models.NewSyncPairRepository(h.db)
|
||||||
|
id, err := repo.Create(sp)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create sync pair")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sp.ID = id
|
||||||
|
w.Header().Set("Location", "/api/sync-pairs/"+strconv.FormatInt(id, 10))
|
||||||
|
writeJSON(w, syncPairToResp(*sp), http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SyncPairHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req SyncPairRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" || req.SourcePath == "" || req.DestPath == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "name, source_path and dest_path are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !directionRegex.MatchString(req.Direction) {
|
||||||
|
writeError(w, http.StatusBadRequest, "direction must be push, pull, or mirror")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := models.NewSyncPairRepository(h.db)
|
||||||
|
existing, err := repo.GetByID(id)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
writeError(w, http.StatusNotFound, "sync pair not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to fetch sync pair")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.Name = req.Name
|
||||||
|
existing.SourceMachineID = req.SourceMachineID
|
||||||
|
existing.SourcePath = req.SourcePath
|
||||||
|
existing.DestMachineID = req.DestMachineID
|
||||||
|
existing.DestPath = req.DestPath
|
||||||
|
existing.Direction = req.Direction
|
||||||
|
existing.RsyncFlags = req.RsyncFlags
|
||||||
|
existing.ExcludePatterns = req.ExcludePatterns
|
||||||
|
existing.Enabled = req.Enabled
|
||||||
|
|
||||||
|
if err := repo.Update(existing); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update sync pair")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, syncPairToResp(*existing))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SyncPairHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repo := models.NewSyncPairRepository(h.db)
|
||||||
|
if err := repo.Delete(id); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to delete sync pair")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncPairToResp(p models.SyncPair) SyncPairResponse {
|
||||||
|
return SyncPairResponse{
|
||||||
|
ID: p.ID,
|
||||||
|
Name: p.Name,
|
||||||
|
SourceMachineID: p.SourceMachineID,
|
||||||
|
SourcePath: p.SourcePath,
|
||||||
|
DestMachineID: p.DestMachineID,
|
||||||
|
DestPath: p.DestPath,
|
||||||
|
Direction: p.Direction,
|
||||||
|
RsyncFlags: p.RsyncFlags,
|
||||||
|
ExcludePatterns: p.ExcludePatterns,
|
||||||
|
Enabled: p.Enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/syncserver/internal/syncengine"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SSEHandler struct {
|
||||||
|
engine *syncengine.Engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSSEHandler(engine *syncengine.Engine) *SSEHandler {
|
||||||
|
return &SSEHandler{engine: engine}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SSEHandler) Stream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
jobIDStr := r.URL.Query().Get("job_id")
|
||||||
|
var filterJobID int64
|
||||||
|
if jobIDStr != "" {
|
||||||
|
filterJobID, _ = strconv.ParseInt(jobIDStr, 10, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "SSE not supported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
if h.engine == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
events := h.engine.Events()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case evt := <-events:
|
||||||
|
if filterJobID != 0 && evt.JobID != filterJobID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(evt)
|
||||||
|
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", evt.Type, data)
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
fmt.Fprintf(w, ": keepalive\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/syncserver/internal/auth"
|
||||||
|
"github.com/syncserver/internal/config"
|
||||||
|
"github.com/syncserver/internal/sshmanager"
|
||||||
|
"github.com/syncserver/internal/syncengine"
|
||||||
|
"github.com/syncserver/internal/webui"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
router *chi.Mux
|
||||||
|
cfg *config.Config
|
||||||
|
engine *syncengine.Engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(cfg *config.Config, db *sql.DB, engine *syncengine.Engine) *Server {
|
||||||
|
auth.InitJWTManager(cfg.Auth.JWTSecret, cfg.Auth.JWTExpiryH)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(middleware.Logger)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
|
s := &Server{router: r, cfg: cfg, engine: engine}
|
||||||
|
|
||||||
|
authHandler := NewAuthHandler(db)
|
||||||
|
machineHandler := NewMachineHandler(db)
|
||||||
|
syncPairHandler := NewSyncPairHandler(db)
|
||||||
|
jobHandler := NewJobHandler(db, engine)
|
||||||
|
sseHandler := NewSSEHandler(engine)
|
||||||
|
|
||||||
|
r.Route("/api", func(r chi.Router) {
|
||||||
|
r.Route("/auth", func(r chi.Router) {
|
||||||
|
r.Post("/login", authHandler.Login)
|
||||||
|
r.Post("/logout", authHandler.Logout)
|
||||||
|
r.With(auth.RequireAuth).Get("/me", authHandler.Me)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.With(auth.RequireAuth).Route("/machines", func(r chi.Router) {
|
||||||
|
r.Get("/", machineHandler.List)
|
||||||
|
r.Post("/", machineHandler.Create)
|
||||||
|
r.Get("/{id}", machineHandler.Get)
|
||||||
|
r.Put("/{id}", machineHandler.Update)
|
||||||
|
r.Delete("/{id}", machineHandler.Delete)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.With(auth.RequireAuth).Route("/sync-pairs", func(r chi.Router) {
|
||||||
|
r.Get("/", syncPairHandler.List)
|
||||||
|
r.Post("/", syncPairHandler.Create)
|
||||||
|
r.Get("/{id}", syncPairHandler.Get)
|
||||||
|
r.Put("/{id}", syncPairHandler.Update)
|
||||||
|
r.Delete("/{id}", syncPairHandler.Delete)
|
||||||
|
r.Post("/{id}/run", jobHandler.TriggerRun)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.With(auth.RequireAuth).Route("/jobs", func(r chi.Router) {
|
||||||
|
r.Get("/", jobHandler.List)
|
||||||
|
r.Get("/{id}", jobHandler.Get)
|
||||||
|
r.Post("/{id}/cancel", jobHandler.Cancel)
|
||||||
|
r.Get("/{id}/log", jobHandler.StreamLog)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.With(auth.RequireAuth).Get("/jobs/stream", sseHandler.Stream)
|
||||||
|
|
||||||
|
r.With(auth.RequireAuth).Get("/settings/pubkey", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _, pubKey, _ := sshmanager.EnsureServerKey(cfg.SSHDir())
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
|
w.Write([]byte(pubKey))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/health", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := webui.DistFS.Open("dist" + r.URL.Path); ok == nil {
|
||||||
|
http.FileServer(http.FS(webui.DistFS)).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := webui.DistFS.ReadFile("dist/index.html")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Write(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.router.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const CookieName = "ss_token"
|
||||||
|
|
||||||
|
func SetAuthCookie(w http.ResponseWriter, token string, expiresAt time.Time) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: CookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
Expires: expiresAt,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: false,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClearAuthCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: CookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: false,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTokenFromRequest(r *http.Request) string {
|
||||||
|
cookie, err := r.Cookie(CookieName)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return cookie.Value
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidToken = errors.New("invalid token")
|
||||||
|
ErrExpiredToken = errors.New("token expired")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type JWTManager struct {
|
||||||
|
secret []byte
|
||||||
|
expiryH int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJWTManager(secret string, expiryH int) *JWTManager {
|
||||||
|
return &JWTManager{
|
||||||
|
secret: []byte(secret),
|
||||||
|
expiryH: expiryH,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JWTManager) Generate(userID int64, username, role string) (string, time.Time, error) {
|
||||||
|
expiresAt := time.Now().Add(time.Duration(m.expiryH) * time.Hour)
|
||||||
|
claims := &Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
Role: role,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
signed, err := token.SignedString(m.secret)
|
||||||
|
return signed, expiresAt, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JWTManager) Validate(tokenStr string) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return m.secret, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||||
|
return nil, ErrExpiredToken
|
||||||
|
}
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
claims, ok := token.Claims.(*Claims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ctxKey string
|
||||||
|
|
||||||
|
const ClaimsCtxKey ctxKey = "claims"
|
||||||
|
|
||||||
|
type contextKey struct{}
|
||||||
|
|
||||||
|
func RequireAuth(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := GetTokenFromRequest(r)
|
||||||
|
if token == "" {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := GlobalJWTManager.Validate(token)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
ctx = context.WithValue(ctx, ClaimsCtxKey, claims)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequireAdmin(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := GetClaims(r.Context())
|
||||||
|
if claims == nil || claims.Role != "admin" {
|
||||||
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetClaims(ctx context.Context) *Claims {
|
||||||
|
v := ctx.Value(ClaimsCtxKey)
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v.(*Claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
var GlobalJWTManager *JWTManager
|
||||||
|
|
||||||
|
func InitJWTManager(secret string, expiryH int) {
|
||||||
|
GlobalJWTManager = NewJWTManager(secret, expiryH)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetJWTManager() *JWTManager {
|
||||||
|
return GlobalJWTManager
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var bcryptCost = bcrypt.DefaultCost
|
||||||
|
|
||||||
|
func HashPassword(plain string) ([]byte, error) {
|
||||||
|
return bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
func VerifyPassword(hash []byte, plain string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword(hash, []byte(plain))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SeedAdmin(db *sql.DB, username, password string) error {
|
||||||
|
if username == "" || password == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE username = ?)", username).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(
|
||||||
|
"INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)",
|
||||||
|
username, string(hash), "admin",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("admin user created", "username", username)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Version string `yaml:"-" json:"-"`
|
||||||
|
DataDir string `yaml:"data_dir" env:"SYNCSERVER_DATA_DIR" default:"./data"`
|
||||||
|
ConfigDir string `yaml:"config_dir" env:"SYNCSERVER_CONFIG_DIR" default:"./etc/syncserver"`
|
||||||
|
Addr string `yaml:"addr" env:"SYNCSERVER_ADDR" default:":8080"`
|
||||||
|
|
||||||
|
Auth AuthConfig `yaml:"auth"`
|
||||||
|
|
||||||
|
Scheduler SchedulerConfig `yaml:"scheduler"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthConfig struct {
|
||||||
|
JWTSecret string `yaml:"jwt_secret" env:"SYNCSERVER_JWT_SECRET"`
|
||||||
|
JWTExpiryH int `yaml:"jwt_expiry_hours" env:"SYNCSERVER_JWT_EXPIRY_HOURS" default:"24"`
|
||||||
|
AdminUser string `yaml:"-" env:"SYNCSERVER_ADMIN_USER"`
|
||||||
|
AdminPass string `yaml:"-" env:"SYNCSERVER_ADMIN_PASSWORD"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SchedulerConfig struct {
|
||||||
|
Timezone string `yaml:"timezone" env:"SYNCSERVER_SCHEDULER_TZ" default:"UTC"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalCfg *Config
|
||||||
|
|
||||||
|
func Load(configPath, dataDir, addr string) (*Config, error) {
|
||||||
|
cfg := &Config{
|
||||||
|
DataDir: dataDir,
|
||||||
|
ConfigDir: "./etc/syncserver",
|
||||||
|
Addr: addr,
|
||||||
|
Auth: AuthConfig{
|
||||||
|
JWTExpiryH: 24,
|
||||||
|
},
|
||||||
|
Scheduler: SchedulerConfig{
|
||||||
|
Timezone: "UTC",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if configPath != "" {
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("reading config: %w", err)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := "SYNCSERVER_"
|
||||||
|
envs := []struct {
|
||||||
|
key *string
|
||||||
|
envName string
|
||||||
|
required bool
|
||||||
|
}{
|
||||||
|
{&cfg.Auth.JWTSecret, prefix + "JWT_SECRET", false},
|
||||||
|
{&cfg.Auth.AdminUser, prefix + "ADMIN_USER", false},
|
||||||
|
{&cfg.Auth.AdminPass, prefix + "ADMIN_PASSWORD", false},
|
||||||
|
{&cfg.DataDir, prefix + "DATA_DIR", false},
|
||||||
|
{&cfg.Addr, prefix + "ADDR", false},
|
||||||
|
{&cfg.Scheduler.Timezone, prefix + "SCHEDULER_TZ", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range envs {
|
||||||
|
if v := os.Getenv(e.envName); v != "" {
|
||||||
|
*e.key = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Auth.JWTSecret == "" {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
f, err := os.Open("/dev/urandom")
|
||||||
|
if err == nil {
|
||||||
|
defer f.Close()
|
||||||
|
n, _ := f.Read(b)
|
||||||
|
if n == 32 {
|
||||||
|
cfg.Auth.JWTSecret = fmt.Sprintf("%x", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.Auth.JWTSecret == "" {
|
||||||
|
cfg.Auth.JWTSecret = "insecure-dev-secret-change-in-production"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if dataDir := os.Getenv("SYNCSERVER_DATA_DIR"); dataDir != "" {
|
||||||
|
cfg.DataDir = dataDir
|
||||||
|
}
|
||||||
|
if addr := os.Getenv("SYNCSERVER_ADDR"); addr != "" {
|
||||||
|
cfg.Addr = addr
|
||||||
|
}
|
||||||
|
|
||||||
|
absDataDir, err := filepath.Abs(cfg.DataDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.DataDir = absDataDir
|
||||||
|
|
||||||
|
globalCfg = cfg
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Get() *Config {
|
||||||
|
return globalCfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) DBPath() string {
|
||||||
|
return filepath.Join(c.DataDir, "app.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) SSHDir() string {
|
||||||
|
return filepath.Join(c.DataDir, "ssh")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) LogsDir() string {
|
||||||
|
return filepath.Join(c.DataDir, "logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) EnsureDirs() error {
|
||||||
|
dirs := []string{c.DataDir, c.SSHDir(), c.LogsDir()}
|
||||||
|
for _, d := range dirs {
|
||||||
|
if err := os.MkdirAll(d, 0700); err != nil {
|
||||||
|
return fmt.Errorf("creating dir %s: %w", d, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) LogPath() string {
|
||||||
|
return filepath.Join(c.LogsDir(), "app.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeAddr(addr string) string {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
if !strings.Contains(addr, ":") {
|
||||||
|
addr = ":" + addr
|
||||||
|
}
|
||||||
|
return addr
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DB struct {
|
||||||
|
*sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DB) SQLDB() *sql.DB {
|
||||||
|
return d.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(dbPath string) (*DB, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_foreign_keys=ON&_busy_timeout=5000")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("opening db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("enabling foreign_keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DB{db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) Close() error {
|
||||||
|
return db.DB.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) RunMigrations() error {
|
||||||
|
return db.runMigrationsInternal(migrationsFS, "migrations")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) runMigrationsInternal(mfs embedFS, migrationsRoot string) error {
|
||||||
|
entries, err := mfs.ReadDir(migrationsRoot)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading migrations dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var names []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version TEXT PRIMARY KEY,
|
||||||
|
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`); err != nil {
|
||||||
|
return fmt.Errorf("creating schema_migrations table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
var applied bool
|
||||||
|
row := db.QueryRow("SELECT 1 FROM schema_migrations WHERE version = ?", name)
|
||||||
|
if err := row.Scan(&applied); err == nil {
|
||||||
|
applied = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if applied {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := mfs.ReadFile(filepath.Join(migrationsRoot, name))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.Exec(string(data)); err != nil {
|
||||||
|
return fmt.Errorf("applying migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", name); err != nil {
|
||||||
|
return fmt.Errorf("recording migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type embedFS interface {
|
||||||
|
ReadDir(name string) ([]os.DirEntry, error)
|
||||||
|
ReadFile(name string) ([]byte, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
// migrationsFS is the embedded filesystem containing SQL migration files.
|
||||||
|
//go:embed migrations
|
||||||
|
var migrationsFS embed.FS
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
-- 0001_init.sql
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ssh_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
private_key_path TEXT NOT NULL,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS machines (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
host TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL DEFAULT 22,
|
||||||
|
ssh_user TEXT NOT NULL DEFAULT 'root',
|
||||||
|
ssh_key_id INTEGER REFERENCES ssh_keys(id),
|
||||||
|
mac_address TEXT,
|
||||||
|
wol_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
broadcast_addr TEXT,
|
||||||
|
wake_timeout_seconds INTEGER NOT NULL DEFAULT 120,
|
||||||
|
wake_check_interval_seconds INTEGER NOT NULL DEFAULT 5,
|
||||||
|
fingerprint_confirmed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
last_seen_at DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_pairs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
source_machine_id INTEGER REFERENCES machines(id),
|
||||||
|
source_path TEXT NOT NULL,
|
||||||
|
dest_machine_id INTEGER REFERENCES machines(id),
|
||||||
|
dest_path TEXT NOT NULL,
|
||||||
|
direction TEXT NOT NULL DEFAULT 'push',
|
||||||
|
rsync_flags TEXT NOT NULL DEFAULT '-aP',
|
||||||
|
exclude_patterns TEXT NOT NULL DEFAULT '',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS schedules (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sync_pair_id INTEGER NOT NULL REFERENCES sync_pairs(id) ON DELETE CASCADE,
|
||||||
|
cron_expr TEXT NOT NULL,
|
||||||
|
next_run_at DATETIME,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sync_pair_id INTEGER NOT NULL REFERENCES sync_pairs(id),
|
||||||
|
trigger_type TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
started_at DATETIME,
|
||||||
|
finished_at DATETIME,
|
||||||
|
log_file TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS job_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||||
|
stream TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
revoked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Job struct {
|
||||||
|
ID int64 `db:"id" json:"id"`
|
||||||
|
SyncPairID int64 `db:"sync_pair_id" json:"sync_pair_id"`
|
||||||
|
TriggerType string `db:"trigger_type" json:"trigger_type"`
|
||||||
|
Status string `db:"status" json:"status"`
|
||||||
|
StartedAt *time.Time `db:"started_at" json:"started_at"`
|
||||||
|
FinishedAt *time.Time `db:"finished_at" json:"finished_at"`
|
||||||
|
LogFile *string `db:"log_file" json:"log_file"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJobRepository(db *sql.DB) *JobRepository {
|
||||||
|
return &JobRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepository) Create(syncPairID int64, triggerType, status string) (int64, error) {
|
||||||
|
res, err := r.db.Exec(`
|
||||||
|
INSERT INTO jobs (sync_pair_id, trigger_type, status) VALUES (?, ?, ?)`,
|
||||||
|
syncPairID, triggerType, status,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepository) GetByID(id int64) (*Job, error) {
|
||||||
|
var j Job
|
||||||
|
var started, finished sql.NullTime
|
||||||
|
var logFile sql.NullString
|
||||||
|
err := r.db.QueryRow(`
|
||||||
|
SELECT id, sync_pair_id, trigger_type, status, started_at, finished_at,
|
||||||
|
log_file, created_at FROM jobs WHERE id = ?`, id).Scan(
|
||||||
|
&j.ID, &j.SyncPairID, &j.TriggerType, &j.Status, &started, &finished,
|
||||||
|
&logFile, &j.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if started.Valid {
|
||||||
|
j.StartedAt = &started.Time
|
||||||
|
}
|
||||||
|
if finished.Valid {
|
||||||
|
j.FinishedAt = &finished.Time
|
||||||
|
}
|
||||||
|
if logFile.Valid {
|
||||||
|
j.LogFile = &logFile.String
|
||||||
|
}
|
||||||
|
return &j, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepository) GetAll(limit, offset int) ([]Job, error) {
|
||||||
|
rows, err := r.db.Query(`
|
||||||
|
SELECT id, sync_pair_id, trigger_type, status, started_at, finished_at,
|
||||||
|
log_file, created_at FROM jobs ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
||||||
|
limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var jobs []Job
|
||||||
|
for rows.Next() {
|
||||||
|
var j Job
|
||||||
|
var started, finished sql.NullTime
|
||||||
|
var logFile sql.NullString
|
||||||
|
if err := rows.Scan(&j.ID, &j.SyncPairID, &j.TriggerType, &j.Status,
|
||||||
|
&started, &finished, &logFile, &j.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if started.Valid {
|
||||||
|
j.StartedAt = &started.Time
|
||||||
|
}
|
||||||
|
if finished.Valid {
|
||||||
|
j.FinishedAt = &finished.Time
|
||||||
|
}
|
||||||
|
if logFile.Valid {
|
||||||
|
j.LogFile = &logFile.String
|
||||||
|
}
|
||||||
|
jobs = append(jobs, j)
|
||||||
|
}
|
||||||
|
return jobs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepository) UpdateStatus(id int64, status string) error {
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
switch status {
|
||||||
|
case "running", "waking_up":
|
||||||
|
query = "UPDATE jobs SET status = ?, started_at = COALESCE(started_at, CURRENT_TIMESTAMP) WHERE id = ?"
|
||||||
|
args = []interface{}{status, id}
|
||||||
|
case "success", "failed", "cancelled":
|
||||||
|
query = "UPDATE jobs SET status = ?, finished_at = CURRENT_TIMESTAMP WHERE id = ?"
|
||||||
|
args = []interface{}{status, id}
|
||||||
|
default:
|
||||||
|
query = "UPDATE jobs SET status = ? WHERE id = ?"
|
||||||
|
args = []interface{}{status, id}
|
||||||
|
}
|
||||||
|
_, err := r.db.Exec(query, args...)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepository) SetLogFile(id int64, path string) error {
|
||||||
|
_, err := r.db.Exec("UPDATE jobs SET log_file = ? WHERE id = ?", path, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepository) GetRunningBySyncPair(syncPairID int64) (*Job, error) {
|
||||||
|
var j Job
|
||||||
|
var started sql.NullTime
|
||||||
|
var logFile sql.NullString
|
||||||
|
err := r.db.QueryRow(`
|
||||||
|
SELECT id, sync_pair_id, trigger_type, status, started_at, finished_at,
|
||||||
|
log_file, created_at FROM jobs
|
||||||
|
WHERE sync_pair_id = ? AND status IN ('queued','waking_up','running')
|
||||||
|
ORDER BY created_at DESC LIMIT 1`, syncPairID).Scan(
|
||||||
|
&j.ID, &j.SyncPairID, &j.TriggerType, &j.Status, &started,
|
||||||
|
&j.FinishedAt, &logFile, &j.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if started.Valid {
|
||||||
|
j.StartedAt = &started.Time
|
||||||
|
}
|
||||||
|
if logFile.Valid {
|
||||||
|
j.LogFile = &logFile.String
|
||||||
|
}
|
||||||
|
return &j, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepository) Count() (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := r.db.QueryRow("SELECT COUNT(*) FROM jobs").Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Machine struct {
|
||||||
|
ID int64 `db:"id" json:"id"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
Host string `db:"host" json:"host"`
|
||||||
|
Port int `db:"port" json:"port"`
|
||||||
|
SSHUser string `db:"ssh_user" json:"ssh_user"`
|
||||||
|
SSHKeyID *int64 `db:"ssh_key_id" json:"ssh_key_id"`
|
||||||
|
MACAddress *string `db:"mac_address" json:"mac_address"`
|
||||||
|
WoLEnabled bool `db:"wol_enabled" json:"wol_enabled"`
|
||||||
|
BroadcastAddr *string `db:"broadcast_addr" json:"broadcast_addr"`
|
||||||
|
WakeTimeoutSeconds int `db:"wake_timeout_seconds" json:"wake_timeout_seconds"`
|
||||||
|
WakeCheckIntervalSeconds int `db:"wake_check_interval_seconds" json:"wake_check_interval_seconds"`
|
||||||
|
FingerprintConfirmed bool `db:"fingerprint_confirmed" json:"fingerprint_confirmed"`
|
||||||
|
Status string `db:"status" json:"status"`
|
||||||
|
LastSeenAt *time.Time `db:"last_seen_at" json:"last_seen_at"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MachineRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMachineRepository(db *sql.DB) *MachineRepository {
|
||||||
|
return &MachineRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MachineRepository) Create(m *Machine) (int64, error) {
|
||||||
|
res, err := r.db.Exec(`
|
||||||
|
INSERT INTO machines (name, host, port, ssh_user, ssh_key_id, mac_address,
|
||||||
|
wol_enabled, broadcast_addr, wake_timeout_seconds, wake_check_interval_seconds,
|
||||||
|
fingerprint_confirmed, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
m.Name, m.Host, m.Port, m.SSHUser, m.SSHKeyID, m.MACAddress,
|
||||||
|
boolToInt(m.WoLEnabled), m.BroadcastAddr, m.WakeTimeoutSeconds,
|
||||||
|
m.WakeCheckIntervalSeconds, boolToInt(m.FingerprintConfirmed), m.Status,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MachineRepository) GetAll() ([]Machine, error) {
|
||||||
|
rows, err := r.db.Query(`
|
||||||
|
SELECT id, name, host, port, ssh_user, ssh_key_id, mac_address,
|
||||||
|
wol_enabled, broadcast_addr, wake_timeout_seconds, wake_check_interval_seconds,
|
||||||
|
fingerprint_confirmed, status, last_seen_at, created_at
|
||||||
|
FROM machines ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var ms []Machine
|
||||||
|
for rows.Next() {
|
||||||
|
var m Machine
|
||||||
|
var mac, bcast sql.NullString
|
||||||
|
var keyID sql.NullInt64
|
||||||
|
var lastSeen sql.NullTime
|
||||||
|
err := rows.Scan(&m.ID, &m.Name, &m.Host, &m.Port, &m.SSHUser, &keyID,
|
||||||
|
&mac, &m.WoLEnabled, &bcast, &m.WakeTimeoutSeconds,
|
||||||
|
&m.WakeCheckIntervalSeconds, &m.FingerprintConfirmed,
|
||||||
|
&m.Status, &lastSeen, &m.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if keyID.Valid {
|
||||||
|
v := keyID.Int64
|
||||||
|
m.SSHKeyID = &v
|
||||||
|
}
|
||||||
|
if mac.Valid {
|
||||||
|
m.MACAddress = &mac.String
|
||||||
|
}
|
||||||
|
if bcast.Valid {
|
||||||
|
m.BroadcastAddr = &bcast.String
|
||||||
|
}
|
||||||
|
if lastSeen.Valid {
|
||||||
|
m.LastSeenAt = &lastSeen.Time
|
||||||
|
}
|
||||||
|
ms = append(ms, m)
|
||||||
|
}
|
||||||
|
return ms, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MachineRepository) GetByID(id int64) (*Machine, error) {
|
||||||
|
var m Machine
|
||||||
|
var mac, bcast sql.NullString
|
||||||
|
var keyID sql.NullInt64
|
||||||
|
var lastSeen sql.NullTime
|
||||||
|
err := r.db.QueryRow(`
|
||||||
|
SELECT id, name, host, port, ssh_user, ssh_key_id, mac_address,
|
||||||
|
wol_enabled, broadcast_addr, wake_timeout_seconds, wake_check_interval_seconds,
|
||||||
|
fingerprint_confirmed, status, last_seen_at, created_at
|
||||||
|
FROM machines WHERE id = ?`, id).Scan(
|
||||||
|
&m.ID, &m.Name, &m.Host, &m.Port, &m.SSHUser, &keyID,
|
||||||
|
&mac, &m.WoLEnabled, &bcast, &m.WakeTimeoutSeconds,
|
||||||
|
&m.WakeCheckIntervalSeconds, &m.FingerprintConfirmed,
|
||||||
|
&m.Status, &lastSeen, &m.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if keyID.Valid {
|
||||||
|
v := keyID.Int64
|
||||||
|
m.SSHKeyID = &v
|
||||||
|
}
|
||||||
|
if mac.Valid {
|
||||||
|
m.MACAddress = &mac.String
|
||||||
|
}
|
||||||
|
if bcast.Valid {
|
||||||
|
m.BroadcastAddr = &bcast.String
|
||||||
|
}
|
||||||
|
if lastSeen.Valid {
|
||||||
|
m.LastSeenAt = &lastSeen.Time
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MachineRepository) Update(m *Machine) error {
|
||||||
|
_, err := r.db.Exec(`
|
||||||
|
UPDATE machines SET name=?, host=?, port=?, ssh_user=?, ssh_key_id=?,
|
||||||
|
mac_address=?, wol_enabled=?, broadcast_addr=?, wake_timeout_seconds=?,
|
||||||
|
wake_check_interval_seconds=?, fingerprint_confirmed=?, status=?, last_seen_at=?
|
||||||
|
WHERE id=?`,
|
||||||
|
m.Name, m.Host, m.Port, m.SSHUser, m.SSHKeyID, m.MACAddress,
|
||||||
|
boolToInt(m.WoLEnabled), m.BroadcastAddr, m.WakeTimeoutSeconds,
|
||||||
|
m.WakeCheckIntervalSeconds, boolToInt(m.FingerprintConfirmed),
|
||||||
|
m.Status, m.LastSeenAt, m.ID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MachineRepository) Delete(id int64) error {
|
||||||
|
_, err := r.db.Exec("DELETE FROM machines WHERE id = ?", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MachineRepository) UpdateStatus(id int64, status string) error {
|
||||||
|
_, err := r.db.Exec(
|
||||||
|
"UPDATE machines SET status = ?, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
status, id,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func intToBool(i int) bool {
|
||||||
|
return i != 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Schedule struct {
|
||||||
|
ID int64 `db:"id" json:"id"`
|
||||||
|
SyncPairID int64 `db:"sync_pair_id" json:"sync_pair_id"`
|
||||||
|
CronExpr string `db:"cron_expr" json:"cron_expr"`
|
||||||
|
NextRunAt *time.Time `db:"next_run_at" json:"next_run_at"`
|
||||||
|
Enabled bool `db:"enabled" json:"enabled"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScheduleRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScheduleRepository(db *sql.DB) *ScheduleRepository {
|
||||||
|
return &ScheduleRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScheduleRepository) Create(s *Schedule) (int64, error) {
|
||||||
|
res, err := r.db.Exec(`
|
||||||
|
INSERT INTO schedules (sync_pair_id, cron_expr, next_run_at, enabled)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
s.SyncPairID, s.CronExpr, s.NextRunAt, boolToInt(s.Enabled),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScheduleRepository) GetAll() ([]Schedule, error) {
|
||||||
|
rows, err := r.db.Query(`
|
||||||
|
SELECT id, sync_pair_id, cron_expr, next_run_at, enabled, created_at
|
||||||
|
FROM schedules ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var schedules []Schedule
|
||||||
|
for rows.Next() {
|
||||||
|
var s Schedule
|
||||||
|
var nextRun sql.NullTime
|
||||||
|
if err := rows.Scan(&s.ID, &s.SyncPairID, &s.CronExpr, &nextRun,
|
||||||
|
&s.Enabled, &s.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if nextRun.Valid {
|
||||||
|
s.NextRunAt = &nextRun.Time
|
||||||
|
}
|
||||||
|
s.Enabled = intToBool(intToInt(s.Enabled))
|
||||||
|
schedules = append(schedules, s)
|
||||||
|
}
|
||||||
|
return schedules, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScheduleRepository) GetByID(id int64) (*Schedule, error) {
|
||||||
|
var s Schedule
|
||||||
|
var nextRun sql.NullTime
|
||||||
|
err := r.db.QueryRow(`
|
||||||
|
SELECT id, sync_pair_id, cron_expr, next_run_at, enabled, created_at
|
||||||
|
FROM schedules WHERE id = ?`, id).Scan(
|
||||||
|
&s.ID, &s.SyncPairID, &s.CronExpr, &nextRun, &s.Enabled, &s.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if nextRun.Valid {
|
||||||
|
s.NextRunAt = &nextRun.Time
|
||||||
|
}
|
||||||
|
s.Enabled = intToBool(intToInt(s.Enabled))
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScheduleRepository) Update(s *Schedule) error {
|
||||||
|
_, err := r.db.Exec(`
|
||||||
|
UPDATE schedules SET sync_pair_id=?, cron_expr=?, next_run_at=?, enabled=?
|
||||||
|
WHERE id=?`,
|
||||||
|
s.SyncPairID, s.CronExpr, s.NextRunAt, boolToInt(s.Enabled), s.ID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScheduleRepository) Delete(id int64) error {
|
||||||
|
_, err := r.db.Exec("DELETE FROM schedules WHERE id = ?", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScheduleRepository) GetEnabledDue(before time.Time) ([]Schedule, error) {
|
||||||
|
rows, err := r.db.Query(`
|
||||||
|
SELECT id, sync_pair_id, cron_expr, next_run_at, enabled, created_at
|
||||||
|
FROM schedules WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ?
|
||||||
|
ORDER BY next_run_at`, before)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var schedules []Schedule
|
||||||
|
for rows.Next() {
|
||||||
|
var s Schedule
|
||||||
|
var nextRun sql.NullTime
|
||||||
|
if err := rows.Scan(&s.ID, &s.SyncPairID, &s.CronExpr, &nextRun,
|
||||||
|
&s.Enabled, &s.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if nextRun.Valid {
|
||||||
|
s.NextRunAt = &nextRun.Time
|
||||||
|
}
|
||||||
|
s.Enabled = true
|
||||||
|
schedules = append(schedules, s)
|
||||||
|
}
|
||||||
|
return schedules, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScheduleRepository) UpdateNextRun(id int64, nextRun time.Time) error {
|
||||||
|
_, err := r.db.Exec("UPDATE schedules SET next_run_at = ? WHERE id = ?", nextRun, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SSHKey struct {
|
||||||
|
ID int64 `db:"id" json:"id"`
|
||||||
|
Label string `db:"label" json:"label"`
|
||||||
|
PrivateKeyPath string `db:"private_key_path" json:"-"`
|
||||||
|
PublicKey string `db:"public_key" json:"public_key"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SSHKeyRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSSHKeyRepository(db *sql.DB) *SSHKeyRepository {
|
||||||
|
return &SSHKeyRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SSHKeyRepository) Create(label, privPath, pubKey string) (int64, error) {
|
||||||
|
res, err := r.db.Exec(
|
||||||
|
"INSERT INTO ssh_keys (label, private_key_path, public_key) VALUES (?, ?, ?)",
|
||||||
|
label, privPath, pubKey,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SSHKeyRepository) GetByID(id int64) (*SSHKey, error) {
|
||||||
|
var k SSHKey
|
||||||
|
err := r.db.QueryRow(
|
||||||
|
"SELECT id, label, private_key_path, public_key, created_at FROM ssh_keys WHERE id = ?",
|
||||||
|
id,
|
||||||
|
).Scan(&k.ID, &k.Label, &k.PrivateKeyPath, &k.PublicKey, &k.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SSHKeyRepository) GetAll() ([]SSHKey, error) {
|
||||||
|
rows, err := r.db.Query(
|
||||||
|
"SELECT id, label, private_key_path, public_key, created_at FROM ssh_keys ORDER BY label")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var keys []SSHKey
|
||||||
|
for rows.Next() {
|
||||||
|
var k SSHKey
|
||||||
|
if err := rows.Scan(&k.ID, &k.Label, &k.PrivateKeyPath, &k.PublicKey, &k.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
return keys, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SSHKeyRepository) Delete(id int64) error {
|
||||||
|
_, err := r.db.Exec("DELETE FROM ssh_keys WHERE id = ?", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SSHKeyRepository) GetServerKey() (*SSHKey, error) {
|
||||||
|
var k SSHKey
|
||||||
|
err := r.db.QueryRow(
|
||||||
|
"SELECT id, label, private_key_path, public_key, created_at FROM ssh_keys WHERE label = 'server' LIMIT 1",
|
||||||
|
).Scan(&k.ID, &k.Label, &k.PrivateKeyPath, &k.PublicKey, &k.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &k, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SyncPair struct {
|
||||||
|
ID int64 `db:"id" json:"id"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
SourceMachineID *int64 `db:"source_machine_id" json:"source_machine_id"`
|
||||||
|
SourcePath string `db:"source_path" json:"source_path"`
|
||||||
|
DestMachineID *int64 `db:"dest_machine_id" json:"dest_machine_id"`
|
||||||
|
DestPath string `db:"dest_path" json:"dest_path"`
|
||||||
|
Direction string `db:"direction" json:"direction"`
|
||||||
|
RsyncFlags string `db:"rsync_flags" json:"rsync_flags"`
|
||||||
|
ExcludePatterns string `db:"exclude_patterns" json:"exclude_patterns"`
|
||||||
|
Enabled bool `db:"enabled" json:"enabled"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncPairRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSyncPairRepository(db *sql.DB) *SyncPairRepository {
|
||||||
|
return &SyncPairRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SyncPairRepository) Create(sp *SyncPair) (int64, error) {
|
||||||
|
res, err := r.db.Exec(`
|
||||||
|
INSERT INTO sync_pairs (name, source_machine_id, source_path, dest_machine_id,
|
||||||
|
dest_path, direction, rsync_flags, exclude_patterns, enabled)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
sp.Name, sp.SourceMachineID, sp.SourcePath, sp.DestMachineID,
|
||||||
|
sp.DestPath, sp.Direction, sp.RsyncFlags, sp.ExcludePatterns, boolToInt(sp.Enabled),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SyncPairRepository) GetAll() ([]SyncPair, error) {
|
||||||
|
rows, err := r.db.Query(`
|
||||||
|
SELECT id, name, source_machine_id, source_path, dest_machine_id, dest_path,
|
||||||
|
direction, rsync_flags, exclude_patterns, enabled, created_at
|
||||||
|
FROM sync_pairs ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var pairs []SyncPair
|
||||||
|
for rows.Next() {
|
||||||
|
var p SyncPair
|
||||||
|
var srcID, dstID sql.NullInt64
|
||||||
|
if err := rows.Scan(&p.ID, &p.Name, &srcID, &p.SourcePath, &dstID,
|
||||||
|
&p.DestPath, &p.Direction, &p.RsyncFlags, &p.ExcludePatterns,
|
||||||
|
&p.Enabled, &p.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if srcID.Valid {
|
||||||
|
v := srcID.Int64
|
||||||
|
p.SourceMachineID = &v
|
||||||
|
}
|
||||||
|
if dstID.Valid {
|
||||||
|
v := dstID.Int64
|
||||||
|
p.DestMachineID = &v
|
||||||
|
}
|
||||||
|
p.Enabled = intToBool(intToInt(p.Enabled))
|
||||||
|
pairs = append(pairs, p)
|
||||||
|
}
|
||||||
|
return pairs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func intToInt(v interface{}) int {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case int:
|
||||||
|
return x
|
||||||
|
case int64:
|
||||||
|
return int(x)
|
||||||
|
case bool:
|
||||||
|
if x {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SyncPairRepository) GetByID(id int64) (*SyncPair, error) {
|
||||||
|
var p SyncPair
|
||||||
|
var srcID, dstID sql.NullInt64
|
||||||
|
err := r.db.QueryRow(`
|
||||||
|
SELECT id, name, source_machine_id, source_path, dest_machine_id, dest_path,
|
||||||
|
direction, rsync_flags, exclude_patterns, enabled, created_at
|
||||||
|
FROM sync_pairs WHERE id = ?`, id).Scan(
|
||||||
|
&p.ID, &p.Name, &srcID, &p.SourcePath, &dstID,
|
||||||
|
&p.DestPath, &p.Direction, &p.RsyncFlags, &p.ExcludePatterns,
|
||||||
|
&p.Enabled, &p.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if srcID.Valid {
|
||||||
|
v := srcID.Int64
|
||||||
|
p.SourceMachineID = &v
|
||||||
|
}
|
||||||
|
if dstID.Valid {
|
||||||
|
v := dstID.Int64
|
||||||
|
p.DestMachineID = &v
|
||||||
|
}
|
||||||
|
p.Enabled = intToBool(intToInt(p.Enabled))
|
||||||
|
return &p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SyncPairRepository) Update(sp *SyncPair) error {
|
||||||
|
_, err := r.db.Exec(`
|
||||||
|
UPDATE sync_pairs SET name=?, source_machine_id=?, source_path=?, dest_machine_id=?,
|
||||||
|
dest_path=?, direction=?, rsync_flags=?, exclude_patterns=?, enabled=?
|
||||||
|
WHERE id=?`,
|
||||||
|
sp.Name, sp.SourceMachineID, sp.SourcePath, sp.DestMachineID,
|
||||||
|
sp.DestPath, sp.Direction, sp.RsyncFlags, sp.ExcludePatterns,
|
||||||
|
boolToInt(sp.Enabled), sp.ID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SyncPairRepository) Delete(id int64) error {
|
||||||
|
_, err := r.db.Exec("DELETE FROM sync_pairs WHERE id = ?", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SyncPairRepository) GetEnabled() ([]SyncPair, error) {
|
||||||
|
rows, err := r.db.Query(`
|
||||||
|
SELECT id, name, source_machine_id, source_path, dest_machine_id, dest_path,
|
||||||
|
direction, rsync_flags, exclude_patterns, enabled, created_at
|
||||||
|
FROM sync_pairs WHERE enabled = 1`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var pairs []SyncPair
|
||||||
|
for rows.Next() {
|
||||||
|
var p SyncPair
|
||||||
|
var srcID, dstID sql.NullInt64
|
||||||
|
if err := rows.Scan(&p.ID, &p.Name, &srcID, &p.SourcePath, &dstID,
|
||||||
|
&p.DestPath, &p.Direction, &p.RsyncFlags, &p.ExcludePatterns,
|
||||||
|
&p.Enabled, &p.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if srcID.Valid {
|
||||||
|
v := srcID.Int64
|
||||||
|
p.SourceMachineID = &v
|
||||||
|
}
|
||||||
|
if dstID.Valid {
|
||||||
|
v := dstID.Int64
|
||||||
|
p.DestMachineID = &v
|
||||||
|
}
|
||||||
|
p.Enabled = true
|
||||||
|
pairs = append(pairs, p)
|
||||||
|
}
|
||||||
|
return pairs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sp *SyncPair) ExcludePatternsList() []string {
|
||||||
|
if sp.ExcludePatterns == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var patterns []string
|
||||||
|
for _, p := range strings.Split(sp.ExcludePatterns, "\n") {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
patterns = append(patterns, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return patterns
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int64 `db:"id" json:"id"`
|
||||||
|
Username string `db:"username" json:"username"`
|
||||||
|
PasswordHash string `db:"password_hash" json:"-"`
|
||||||
|
Role string `db:"role" json:"role"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserRepository(db *sql.DB) *UserRepository {
|
||||||
|
return &UserRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Create(username, passwordHash, role string) (int64, error) {
|
||||||
|
res, err := r.db.Exec(
|
||||||
|
"INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)",
|
||||||
|
username, passwordHash, role,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) GetByUsername(username string) (*User, error) {
|
||||||
|
var u User
|
||||||
|
err := r.db.QueryRow(
|
||||||
|
"SELECT id, username, password_hash, role, created_at FROM users WHERE username = ?",
|
||||||
|
username,
|
||||||
|
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) Exists() (bool, error) {
|
||||||
|
var n int
|
||||||
|
err := r.db.QueryRow("SELECT COUNT(*) FROM users").Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) GetByID(id int64) (*User, error) {
|
||||||
|
var u User
|
||||||
|
err := r.db.QueryRow(
|
||||||
|
"SELECT id, username, password_hash, role, created_at FROM users WHERE id = ?",
|
||||||
|
id,
|
||||||
|
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CronExpr struct {
|
||||||
|
Minute []int
|
||||||
|
Hour []int
|
||||||
|
DayOfMonth []int
|
||||||
|
Month []int
|
||||||
|
DayOfWeek []int
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseCron(expr string) (*CronExpr, error) {
|
||||||
|
parts := strings.Fields(expr)
|
||||||
|
if len(parts) != 5 {
|
||||||
|
return nil, fmt.Errorf("expected 5 fields, got %d", len(parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
minute, err := parseField(parts[0], 0, 59)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("minute: %w", err)
|
||||||
|
}
|
||||||
|
hour, err := parseField(parts[1], 0, 23)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("hour: %w", err)
|
||||||
|
}
|
||||||
|
dom, err := parseField(parts[2], 1, 31)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("day of month: %w", err)
|
||||||
|
}
|
||||||
|
month, err := parseField(parts[3], 1, 12)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("month: %w", err)
|
||||||
|
}
|
||||||
|
dow, err := parseField(parts[4], 0, 6)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("day of week: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CronExpr{
|
||||||
|
Minute: minute,
|
||||||
|
Hour: hour,
|
||||||
|
DayOfMonth: dom,
|
||||||
|
Month: month,
|
||||||
|
DayOfWeek: dow,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseField(field string, min, max int) ([]int, error) {
|
||||||
|
if field == "*" {
|
||||||
|
var vals []int
|
||||||
|
for i := min; i <= max; i++ {
|
||||||
|
vals = append(vals, i)
|
||||||
|
}
|
||||||
|
return vals, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []int
|
||||||
|
parts := strings.Split(field, ",")
|
||||||
|
for _, part := range parts {
|
||||||
|
if strings.Contains(part, "/") {
|
||||||
|
stepParts := strings.Split(part, "/")
|
||||||
|
if len(stepParts) != 2 {
|
||||||
|
return nil, fmt.Errorf("invalid step: %s", part)
|
||||||
|
}
|
||||||
|
rangePart := stepParts[0]
|
||||||
|
step, err := strconv.Atoi(stepParts[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid step value: %s", stepParts[1])
|
||||||
|
}
|
||||||
|
var start, end int
|
||||||
|
if rangePart == "*" {
|
||||||
|
start, end = min, max
|
||||||
|
} else if strings.Contains(rangePart, "-") {
|
||||||
|
rp := strings.Split(rangePart, "-")
|
||||||
|
if len(rp) != 2 {
|
||||||
|
return nil, fmt.Errorf("invalid range: %s", rangePart)
|
||||||
|
}
|
||||||
|
start, _ = strconv.Atoi(rp[0])
|
||||||
|
end, _ = strconv.Atoi(rp[1])
|
||||||
|
} else {
|
||||||
|
v, _ := strconv.Atoi(rangePart)
|
||||||
|
start, end = v, v
|
||||||
|
}
|
||||||
|
for i := start; i <= end; i += step {
|
||||||
|
result = append(result, i)
|
||||||
|
}
|
||||||
|
} else if strings.Contains(part, "-") {
|
||||||
|
rp := strings.Split(part, "-")
|
||||||
|
if len(rp) != 2 {
|
||||||
|
return nil, fmt.Errorf("invalid range: %s", part)
|
||||||
|
}
|
||||||
|
start, _ := strconv.Atoi(rp[0])
|
||||||
|
end, _ := strconv.Atoi(rp[1])
|
||||||
|
for i := start; i <= end; i++ {
|
||||||
|
result = append(result, i)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
v, err := strconv.Atoi(part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid value: %s", part)
|
||||||
|
}
|
||||||
|
if v < min || v > max {
|
||||||
|
return nil, fmt.Errorf("value %d out of range [%d,%d]", v, min, max)
|
||||||
|
}
|
||||||
|
result = append(result, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CronExpr) Matches(t time.Time) bool {
|
||||||
|
if !contains(c.Minute, t.Minute()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !contains(c.Hour, t.Hour()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !contains(c.DayOfMonth, t.Day()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !contains(c.Month, int(t.Month())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !contains(c.DayOfWeek, int(t.Weekday())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(slice []int, val int) bool {
|
||||||
|
for _, v := range slice {
|
||||||
|
if v == val {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func NextRun(expr *CronExpr, from time.Time) time.Time {
|
||||||
|
for i := 1; i <= 525600; i++ {
|
||||||
|
t := from.Add(time.Duration(i) * time.Minute)
|
||||||
|
if expr.Matches(t) {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return from.AddDate(0, 0, 1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCron(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
expr string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"* * * * *", false},
|
||||||
|
{"0 * * * *", false},
|
||||||
|
{"*/5 * * * *", false},
|
||||||
|
{"0,30 * * * *", false},
|
||||||
|
{"0-30 * * * *", false},
|
||||||
|
{"*/15 9-17 * * *", false},
|
||||||
|
{"0 0 1 * *", false},
|
||||||
|
{"0 0 * * 0", false},
|
||||||
|
{"0 0 1,15 * *", false},
|
||||||
|
{"invalid", true},
|
||||||
|
{"* * * *", true},
|
||||||
|
{"60 * * * *", true},
|
||||||
|
{"* 24 * * *", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
_, err := ParseCron(tt.expr)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseCron(%q) error = %v, wantErr %v", tt.expr, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronMatches(t *testing.T) {
|
||||||
|
expr, err := ParseCron("*/5 * * * *")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
minute int
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{0, true},
|
||||||
|
{5, true},
|
||||||
|
{10, true},
|
||||||
|
{15, true},
|
||||||
|
{1, false},
|
||||||
|
{2, false},
|
||||||
|
{7, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
for _, tt := range tests {
|
||||||
|
tm := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), tt.minute, 0, 0, time.UTC)
|
||||||
|
if got := expr.Matches(tm); got != tt.want {
|
||||||
|
t.Errorf("Matches(minute=%d) = %v, want %v", tt.minute, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronMatchesSpecific(t *testing.T) {
|
||||||
|
expr, err := ParseCron("30 9 15 * *")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := time.Date(2024, 6, 15, 9, 30, 0, 0, time.UTC)
|
||||||
|
if !expr.Matches(matches) {
|
||||||
|
t.Error("should match 9:30 on 15th of month")
|
||||||
|
}
|
||||||
|
|
||||||
|
notMatch := time.Date(2024, 6, 16, 9, 30, 0, 0, time.UTC)
|
||||||
|
if expr.Matches(notMatch) {
|
||||||
|
t.Error("should not match 9:30 on 16th of month")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextRun(t *testing.T) {
|
||||||
|
expr, err := ParseCron("*/5 * * * *")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
from := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
next := NextRun(expr, from)
|
||||||
|
|
||||||
|
if next.Minute() != 5 || next.Hour() != 12 {
|
||||||
|
t.Errorf("NextRun = %v, want 12:05", next)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !next.After(from) {
|
||||||
|
t.Error("NextRun should be after from time")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/syncserver/internal/config"
|
||||||
|
"github.com/syncserver/internal/models"
|
||||||
|
"github.com/syncserver/internal/syncengine"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Scheduler struct {
|
||||||
|
db *sql.DB
|
||||||
|
engine *syncengine.Engine
|
||||||
|
cfg *config.Config
|
||||||
|
stopCh chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(database interface{ SQLDB() *sql.DB }, engine *syncengine.Engine, cfg *config.Config) *Scheduler {
|
||||||
|
return &Scheduler{
|
||||||
|
db: database.SQLDB(),
|
||||||
|
engine: engine,
|
||||||
|
cfg: cfg,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) Start() {
|
||||||
|
s.wg.Add(1)
|
||||||
|
go s.run()
|
||||||
|
slog.Info("scheduler started")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) Stop() {
|
||||||
|
close(s.stopCh)
|
||||||
|
s.wg.Wait()
|
||||||
|
slog.Info("scheduler stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) run() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.tick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) tick() {
|
||||||
|
scheduleRepo := models.NewScheduleRepository(s.db)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
schedules, err := scheduleRepo.GetEnabledDue(now)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("scheduler: failed to get due schedules", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sch := range schedules {
|
||||||
|
pairRepo := models.NewSyncPairRepository(s.db)
|
||||||
|
pair, err := pairRepo.GetByID(sch.SyncPairID)
|
||||||
|
if err != nil || !pair.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
jobID, err := s.engine.CreateJob(sch.SyncPairID, "scheduled")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("scheduler: failed to create job", "schedule_id", sch.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
go func(jobID int64, pairID int64, schID int64) {
|
||||||
|
if err := s.engine.Run(ctx, jobID, pairID); err != nil {
|
||||||
|
slog.Warn("scheduler: job failed", "job_id", jobID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expr, _ := ParseCron(sch.CronExpr)
|
||||||
|
if expr != nil {
|
||||||
|
next := NextRun(expr, time.Now().UTC())
|
||||||
|
scheduleRepo.UpdateNextRun(schID, next)
|
||||||
|
}
|
||||||
|
}(jobID, sch.SyncPairID, sch.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package sshmanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ServerKeyLabel = "server"
|
||||||
|
|
||||||
|
func EnsureServerKey(sshDir string) (privPath, pubPath string, pubKey string, err error) {
|
||||||
|
if err := os.MkdirAll(sshDir, 0700); err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("creating ssh dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privPath = filepath.Join(sshDir, "id_ed25519")
|
||||||
|
pubPath = filepath.Join(sshDir, "id_ed25519.pub")
|
||||||
|
|
||||||
|
if _, err := os.Stat(privPath); os.IsNotExist(err) {
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("generating ed25519 key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privFile, err := os.OpenFile(privPath, os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("creating private key file: %w", err)
|
||||||
|
}
|
||||||
|
defer privFile.Close()
|
||||||
|
|
||||||
|
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("marshaling private key: %w", err)
|
||||||
|
}
|
||||||
|
pem.Encode(privFile, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
|
||||||
|
|
||||||
|
pubKey = fmt.Sprintf("%s %s", strings.TrimSpace(string(pub)), "syncserver")
|
||||||
|
if err := os.WriteFile(pubPath, []byte(pubKey), 0644); err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("writing public key: %w", err)
|
||||||
|
}
|
||||||
|
return privPath, pubPath, pubKey, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("checking private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(pubPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("reading public key: %w", err)
|
||||||
|
}
|
||||||
|
return privPath, pubPath, strings.TrimSpace(string(data)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadPrivateKey(path string) ([]byte, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("no PEM block found")
|
||||||
|
}
|
||||||
|
return block.Bytes, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package sshmanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type KnownHost struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
KeyType string
|
||||||
|
Fingerprint string
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnsureKnownHosts(sshDir string) (string, error) {
|
||||||
|
path := filepath.Join(sshDir, "known_hosts")
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddKnownHost(sshDir, host string, port int, keyData []byte) error {
|
||||||
|
path := filepath.Join(sshDir, "known_hosts")
|
||||||
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
addr := host
|
||||||
|
if port != 22 {
|
||||||
|
addr = fmt.Sprintf("[%s]:%d", host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := fmt.Sprintf("%s %s\n", addr, strings.TrimSpace(string(keyData)))
|
||||||
|
if _, err := f.WriteString(line); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHostPort(entry string) (string, int) {
|
||||||
|
if strings.HasPrefix(entry, "[") {
|
||||||
|
var h string
|
||||||
|
var p int
|
||||||
|
if n, _ := fmt.Sscanf(entry, "[%[^]]]:%d", &h, &p); n == 2 {
|
||||||
|
return h, p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts := strings.Split(entry, ":")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return parts[0], 22
|
||||||
|
}
|
||||||
|
return entry, 22
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetKnownHost(sshDir, host string, port int) (*KnownHost, error) {
|
||||||
|
path := filepath.Join(sshDir, "known_hosts")
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var targetAddr string
|
||||||
|
if port != 22 {
|
||||||
|
targetAddr = fmt.Sprintf("[%s]:%d", host, port)
|
||||||
|
} else {
|
||||||
|
targetAddr = host
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h, p := parseHostPort(parts[0])
|
||||||
|
if (h == host || parts[0] == targetAddr) && p == port {
|
||||||
|
return &KnownHost{
|
||||||
|
Host: h,
|
||||||
|
Port: p,
|
||||||
|
KeyType: parts[1],
|
||||||
|
Fingerprint: parts[1] + " " + parts[2],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HasKnownHost(sshDir, host string, port int) (bool, error) {
|
||||||
|
kh, err := GetKnownHost(sshDir, host, port)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return kh != nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveKnownHost(sshDir, host string, port int) error {
|
||||||
|
path := filepath.Join(sshDir, "known_hosts")
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
targetAddr := host
|
||||||
|
if port != 22 {
|
||||||
|
targetAddr = fmt.Sprintf("[%s]:%d", host, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
h, p := parseHostPort(line)
|
||||||
|
if h == host && p == port {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line == targetAddr {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
wf, err := os.Create(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, l := range lines {
|
||||||
|
wf.WriteString(l + "\n")
|
||||||
|
}
|
||||||
|
wf.Close()
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package sshmanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ConnResult struct {
|
||||||
|
Success bool
|
||||||
|
Output string
|
||||||
|
Error string
|
||||||
|
Fingerprint string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSHConnection(ctx context.Context, host string, port int, user, privKeyPath, knownHostsPath string, strictHostKeyChecking bool) (*ConnResult, error) {
|
||||||
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
|
|
||||||
|
auths := []ssh.AuthMethod{}
|
||||||
|
if privKeyPath != "" {
|
||||||
|
key, err := os.ReadFile(privKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading private key: %w", err)
|
||||||
|
}
|
||||||
|
signer, err := ssh.ParsePrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing private key: %w", err)
|
||||||
|
}
|
||||||
|
auths = append(auths, ssh.PublicKeys(signer))
|
||||||
|
}
|
||||||
|
|
||||||
|
hostKeyPolicy := ssh.InsecureIgnoreHostKey()
|
||||||
|
if strictHostKeyChecking && knownHostsPath != "" {
|
||||||
|
hostKeyCallback, err := getHostKeyCallback(knownHostsPath, host, port)
|
||||||
|
if err != nil {
|
||||||
|
return &ConnResult{Success: false, Error: fmt.Sprintf("known_hosts: %v", err)}, nil
|
||||||
|
}
|
||||||
|
hostKeyPolicy = hostKeyCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &ssh.ClientConfig{
|
||||||
|
User: user,
|
||||||
|
Auth: auths,
|
||||||
|
HostKeyCallback: hostKeyPolicy,
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
conn, err := ssh.Dial("tcp", addr, cfg)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "known_hosts") || strings.Contains(err.Error(), "host key") {
|
||||||
|
return &ConnResult{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("host key verification failed: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return &ConnResult{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("connection failed: %v", err),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
session, err := conn.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return &ConnResult{Success: false, Error: fmt.Sprintf("session: %v", err)}, nil
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
session.Stdout = &stdout
|
||||||
|
session.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := session.Run("echo ok && uname -a"); err != nil {
|
||||||
|
return &ConnResult{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("exec: %v, stderr: %s", err, stderr.String()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ConnResult{
|
||||||
|
Success: true,
|
||||||
|
Output: stdout.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHostKeyCallback(knownHostsPath, host string, port int) (ssh.HostKeyCallback, error) {
|
||||||
|
return ssh.HostKeyCallback(func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||||
|
kh, err := GetKnownHost(filepath.Dir(knownHostsPath), host, port)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("checking known_hosts: %w", err)
|
||||||
|
}
|
||||||
|
if kh == nil {
|
||||||
|
return fmt.Errorf("host key not found in known_hosts: %s", hostname)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package syncengine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/syncserver/internal/config"
|
||||||
|
"github.com/syncserver/internal/models"
|
||||||
|
"github.com/syncserver/internal/wol"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Engine struct {
|
||||||
|
db *sql.DB
|
||||||
|
cfg *config.Config
|
||||||
|
queue *Queue
|
||||||
|
eventBus chan Event
|
||||||
|
mu sync.RWMutex
|
||||||
|
stopped bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
Type string
|
||||||
|
JobID int64
|
||||||
|
Status string
|
||||||
|
Line string
|
||||||
|
Stream string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(database interface{ SQLDB() *sql.DB }, cfg *config.Config) *Engine {
|
||||||
|
e := &Engine{
|
||||||
|
db: database.SQLDB(),
|
||||||
|
cfg: cfg,
|
||||||
|
queue: NewQueue(),
|
||||||
|
eventBus: make(chan Event, 100),
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Start() {}
|
||||||
|
func (e *Engine) Stop() {}
|
||||||
|
|
||||||
|
func (e *Engine) Events() <-chan Event {
|
||||||
|
return e.eventBus
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Run(ctx context.Context, jobID int64, pairID int64) error {
|
||||||
|
if e.queue.IsRunning(pairID) {
|
||||||
|
existingJobID, _ := e.queue.GetJobID(pairID)
|
||||||
|
return fmt.Errorf("%w: job %d is already running", ErrAlreadyRunning, existingJobID)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobCtx, cancel := context.WithCancel(ctx)
|
||||||
|
enqueueErr := e.queue.Enqueue(pairID, jobID, cancel)
|
||||||
|
if enqueueErr != nil {
|
||||||
|
return enqueueErr
|
||||||
|
}
|
||||||
|
defer e.queue.Dequeue(pairID)
|
||||||
|
|
||||||
|
pairRepo := models.NewSyncPairRepository(e.db)
|
||||||
|
pair, err := pairRepo.GetByID(pairID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetching sync pair: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
machineRepo := models.NewMachineRepository(e.db)
|
||||||
|
var srcMachine, dstMachine *models.Machine
|
||||||
|
|
||||||
|
if pair.SourceMachineID != nil {
|
||||||
|
m, _ := machineRepo.GetByID(*pair.SourceMachineID)
|
||||||
|
srcMachine = m
|
||||||
|
}
|
||||||
|
if pair.DestMachineID != nil {
|
||||||
|
m, _ := machineRepo.GetByID(*pair.DestMachineID)
|
||||||
|
dstMachine = m
|
||||||
|
}
|
||||||
|
|
||||||
|
src := buildPath(pair.SourcePath, srcMachine)
|
||||||
|
dst := buildPath(pair.DestPath, dstMachine)
|
||||||
|
|
||||||
|
cfg := &SyncPairConfig{
|
||||||
|
ID: pair.ID,
|
||||||
|
Name: pair.Name,
|
||||||
|
SourceMachineID: pair.SourceMachineID,
|
||||||
|
SourcePath: pair.SourcePath,
|
||||||
|
DestMachineID: pair.DestMachineID,
|
||||||
|
DestPath: pair.DestPath,
|
||||||
|
Direction: pair.Direction,
|
||||||
|
RsyncFlags: pair.RsyncFlags,
|
||||||
|
ExcludePatterns: pair.ExcludePatternsList(),
|
||||||
|
}
|
||||||
|
cfg.Source = src
|
||||||
|
cfg.Dest = dst
|
||||||
|
|
||||||
|
logPath := filepath.Join(e.cfg.LogsDir(), fmt.Sprintf("%d.log", jobID))
|
||||||
|
f, err := os.Create(logPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("failed to create log file", "error", err)
|
||||||
|
} else {
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
e.setJobStatus(jobID, "waking_up")
|
||||||
|
e.emit(Event{Type: "status", JobID: jobID, Status: "waking_up"})
|
||||||
|
|
||||||
|
var targetMachine *models.Machine
|
||||||
|
var remotePort int
|
||||||
|
if pair.Direction == "pull" && srcMachine != nil {
|
||||||
|
targetMachine = srcMachine
|
||||||
|
remotePort = srcMachine.Port
|
||||||
|
} else if dstMachine != nil {
|
||||||
|
targetMachine = dstMachine
|
||||||
|
remotePort = dstMachine.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetMachine != nil && targetMachine.WoLEnabled && targetMachine.MACAddress != nil {
|
||||||
|
mac, err := wol.ParseMAC(*targetMachine.MACAddress)
|
||||||
|
if err == nil {
|
||||||
|
bcast := ""
|
||||||
|
if targetMachine.BroadcastAddr != nil {
|
||||||
|
bcast = *targetMachine.BroadcastAddr
|
||||||
|
}
|
||||||
|
if err := wol.Send(targetMachine.Host, mac, bcast); err != nil {
|
||||||
|
slog.Warn("WoL failed", "host", targetMachine.Host, "error", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("WoL magic packet sent", "host", targetMachine.Host, "mac", *targetMachine.MACAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := time.Duration(targetMachine.WakeTimeoutSeconds) * time.Second
|
||||||
|
interval := time.Duration(targetMachine.WakeCheckIntervalSeconds) * time.Second
|
||||||
|
if err := wol.WaitUntilReady(jobCtx, targetMachine.Host, remotePort, timeout, interval, false); err != nil {
|
||||||
|
e.setJobStatus(jobID, "failed")
|
||||||
|
e.emit(Event{Type: "status", JobID: jobID, Status: "failed", Line: err.Error()})
|
||||||
|
return fmt.Errorf("machine not ready: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.setJobStatus(jobID, "running")
|
||||||
|
e.setJobLogFile(jobID, logPath)
|
||||||
|
e.emit(Event{Type: "status", JobID: jobID, Status: "running"})
|
||||||
|
slog.Info("job started", "job_id", jobID, "pair", pair.Name)
|
||||||
|
|
||||||
|
var privKey string
|
||||||
|
if targetMachine != nil && targetMachine.SSHKeyID != nil {
|
||||||
|
privKey = filepath.Join(e.cfg.SSHDir(), "id_ed25519")
|
||||||
|
}
|
||||||
|
|
||||||
|
runner := NewRsyncRunner(e.cfg.SSHDir(), privKey)
|
||||||
|
onLine := func(stream, line string) {
|
||||||
|
e.emit(Event{Type: "log", JobID: jobID, Stream: stream, Line: line})
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := runner.Run(jobCtx, cfg, onLine)
|
||||||
|
if err != nil {
|
||||||
|
if jobCtx.Err() != nil {
|
||||||
|
e.setJobStatus(jobID, "cancelled")
|
||||||
|
e.emit(Event{Type: "status", JobID: jobID, Status: "cancelled"})
|
||||||
|
return jobCtx.Err()
|
||||||
|
}
|
||||||
|
e.setJobStatus(jobID, "failed")
|
||||||
|
e.emit(Event{Type: "status", JobID: jobID, Status: "failed", Line: err.Error()})
|
||||||
|
return fmt.Errorf("rsync error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
e.setJobStatus(jobID, "failed")
|
||||||
|
e.emit(Event{Type: "status", JobID: jobID, Status: "failed", Line: result.Stderr})
|
||||||
|
return fmt.Errorf("rsync exited with code %d: %s", result.ExitCode, result.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.setJobStatus(jobID, "success")
|
||||||
|
e.emit(Event{Type: "status", JobID: jobID, Status: "success"})
|
||||||
|
slog.Info("job completed", "job_id", jobID, "pair", pair.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Cancel(jobID int64, syncPairID int64) bool {
|
||||||
|
if e.queue.IsRunning(syncPairID) {
|
||||||
|
e.queue.Cancel(syncPairID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) setJobStatus(jobID int64, status string) {
|
||||||
|
jobRepo := models.NewJobRepository(e.db)
|
||||||
|
jobRepo.UpdateStatus(jobID, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) setJobLogFile(jobID int64, path string) {
|
||||||
|
jobRepo := models.NewJobRepository(e.db)
|
||||||
|
jobRepo.SetLogFile(jobID, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) emit(evt Event) {
|
||||||
|
select {
|
||||||
|
case e.eventBus <- evt:
|
||||||
|
default:
|
||||||
|
slog.Warn("event bus full, dropping event", "type", evt.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPath(path string, machine *models.Machine) string {
|
||||||
|
if machine == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s@%s:%s", machine.SSHUser, machine.Host, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) CreateJob(syncPairID int64, triggerType string) (int64, error) {
|
||||||
|
jobRepo := models.NewJobRepository(e.db)
|
||||||
|
id, err := jobRepo.Create(syncPairID, triggerType, "queued")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package syncengine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RsyncStats struct {
|
||||||
|
SentBytes int64
|
||||||
|
ReceivedBytes int64
|
||||||
|
TotalSize int64
|
||||||
|
Speedup float64
|
||||||
|
FilesSent int
|
||||||
|
FilesTotal int
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProgressLine struct {
|
||||||
|
Phase string
|
||||||
|
Percent float64
|
||||||
|
Files int
|
||||||
|
Total int
|
||||||
|
SentBytes int64
|
||||||
|
XferedBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
progressRegex = regexp.MustCompile(`\s*([\d,]+)\s+([\d,]+)\s+([\d%]+)\s*`)
|
||||||
|
sentRegex = regexp.MustCompile(`sent\s+([\d,]+)\s+bytes`)
|
||||||
|
recvRegex = regexp.MustCompile(`received\s+([\d,]+)\s+bytes`)
|
||||||
|
totalRegex = regexp.MustCompile(`total size is\s+([\d,]+)`)
|
||||||
|
filesRegex = regexp.MustCompile(`Number of files: ([\d,]+)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func ParseProgressLine(line string) *ProgressLine {
|
||||||
|
if strings.Contains(line, "files to consider") || strings.Contains(line, "files...") {
|
||||||
|
return &ProgressLine{Phase: "scanning"}
|
||||||
|
}
|
||||||
|
if strings.Contains(line, "building file list") {
|
||||||
|
return &ProgressLine{Phase: "listing"}
|
||||||
|
}
|
||||||
|
if strings.Contains(line, "sent") && strings.Contains(line, "bytes") {
|
||||||
|
return &ProgressLine{Phase: "stats"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseStatsLine(line string) (int64, bool) {
|
||||||
|
m := sentRegex.FindStringSubmatch(line)
|
||||||
|
if len(m) >= 2 {
|
||||||
|
n, _ := strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64)
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseFinalStats(output string) *RsyncStats {
|
||||||
|
stats := &RsyncStats{}
|
||||||
|
lines := strings.Split(output, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if m := sentRegex.FindStringSubmatch(line); len(m) >= 2 {
|
||||||
|
stats.SentBytes, _ = strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64)
|
||||||
|
}
|
||||||
|
if m := recvRegex.FindStringSubmatch(line); len(m) >= 2 {
|
||||||
|
stats.ReceivedBytes, _ = strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64)
|
||||||
|
}
|
||||||
|
if m := totalRegex.FindStringSubmatch(line); len(m) >= 2 {
|
||||||
|
stats.TotalSize, _ = strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64)
|
||||||
|
}
|
||||||
|
if m := filesRegex.FindStringSubmatch(line); len(m) >= 2 {
|
||||||
|
stats.FilesTotal, _ = strconv.Atoi(strings.ReplaceAll(m[1], ",", ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package syncengine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrAlreadyRunning = errors.New("job already running for this sync pair")
|
||||||
|
|
||||||
|
type Queue struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
runs map[int64]*RunInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunInfo struct {
|
||||||
|
JobID int64
|
||||||
|
Cancel func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQueue() *Queue {
|
||||||
|
return &Queue{runs: make(map[int64]*RunInfo)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) Enqueue(syncPairID, jobID int64, cancel func()) error {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
if _, exists := q.runs[syncPairID]; exists {
|
||||||
|
return ErrAlreadyRunning
|
||||||
|
}
|
||||||
|
q.runs[syncPairID] = &RunInfo{JobID: jobID, Cancel: cancel}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) Dequeue(syncPairID int64) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
delete(q.runs, syncPairID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) IsRunning(syncPairID int64) bool {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
_, exists := q.runs[syncPairID]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) GetJobID(syncPairID int64) (int64, bool) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
info, exists := q.runs[syncPairID]
|
||||||
|
if !exists {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return info.JobID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) Cancel(syncPairID int64) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
if info, exists := q.runs[syncPairID]; exists && info.Cancel != nil {
|
||||||
|
info.Cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package syncengine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQueue(t *testing.T) {
|
||||||
|
q := NewQueue()
|
||||||
|
|
||||||
|
if q.IsRunning(1) {
|
||||||
|
t.Error("queue should be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelCalled := false
|
||||||
|
cancel := func() { cancelCalled = true }
|
||||||
|
|
||||||
|
err := q.Enqueue(1, 100, cancel)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Enqueue(1) unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !q.IsRunning(1) {
|
||||||
|
t.Error("queue should contain syncPair 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
jobID, ok := q.GetJobID(1)
|
||||||
|
if !ok || jobID != 100 {
|
||||||
|
t.Errorf("GetJobID(1) = %d, %v, want 100, true", jobID, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = q.Enqueue(1, 200, nil)
|
||||||
|
if err != ErrAlreadyRunning {
|
||||||
|
t.Errorf("Enqueue(1) again = %v, want ErrAlreadyRunning", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
q.Cancel(1)
|
||||||
|
if !cancelCalled {
|
||||||
|
t.Error("Cancel should have called the cancel func")
|
||||||
|
}
|
||||||
|
|
||||||
|
q.Dequeue(1)
|
||||||
|
if q.IsRunning(1) {
|
||||||
|
t.Error("queue should be empty after Dequeue")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package syncengine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RsyncResult struct {
|
||||||
|
ExitCode int
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
Stats *RsyncStats
|
||||||
|
}
|
||||||
|
|
||||||
|
type RsyncRunner struct {
|
||||||
|
sshDir string
|
||||||
|
privKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SyncPairConfig struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
SourceMachineID *int64
|
||||||
|
SourcePath string
|
||||||
|
DestMachineID *int64
|
||||||
|
DestPath string
|
||||||
|
Direction string
|
||||||
|
RsyncFlags string
|
||||||
|
ExcludePatterns []string
|
||||||
|
Source string
|
||||||
|
Dest string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRsyncRunner(sshDir, privKey string) *RsyncRunner {
|
||||||
|
return &RsyncRunner{sshDir: sshDir, privKey: privKey}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RsyncRunner) Run(ctx context.Context, pair *SyncPairConfig, onLine func(stream string, line string)) (*RsyncResult, error) {
|
||||||
|
args := r.buildArgs(pair)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "rsync", args...)
|
||||||
|
if r.privKey != "" {
|
||||||
|
sshCmd := fmt.Sprintf("ssh -i %s -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=%s",
|
||||||
|
r.privKey, strings.TrimRight(r.sshDir, "/")+"/known_hosts")
|
||||||
|
cmd.Args = append([]string{"rsync", "-e", sshCmd}, args[1:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stderr pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, fmt.Errorf("starting rsync: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var outLines, errLines []string
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
br := io.Reader(stdout)
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := br.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
line := strings.TrimRight(string(buf[:n]), "\r\n")
|
||||||
|
if line != "" {
|
||||||
|
outLines = append(outLines, line)
|
||||||
|
if onLine != nil {
|
||||||
|
onLine("stdout", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
default:
|
||||||
|
close(done)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
br := io.Reader(stderr)
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := br.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
line := strings.TrimRight(string(buf[:n]), "\r\n")
|
||||||
|
if line != "" {
|
||||||
|
errLines = append(errLines, line)
|
||||||
|
if onLine != nil {
|
||||||
|
onLine("stderr", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
default:
|
||||||
|
close(done)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
err = cmd.Wait()
|
||||||
|
<-done
|
||||||
|
|
||||||
|
result := &RsyncResult{
|
||||||
|
ExitCode: 0,
|
||||||
|
Stdout: strings.Join(outLines, "\n"),
|
||||||
|
Stderr: strings.Join(errLines, "\n"),
|
||||||
|
Stats: ParseFinalStats(strings.Join(outLines, "\n")),
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
result.ExitCode = exitErr.ExitCode()
|
||||||
|
} else {
|
||||||
|
result.ExitCode = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RsyncRunner) buildArgs(pair *SyncPairConfig) []string {
|
||||||
|
var args []string
|
||||||
|
|
||||||
|
flags := strings.Fields(pair.RsyncFlags)
|
||||||
|
args = append(args, flags...)
|
||||||
|
|
||||||
|
for _, pattern := range pair.ExcludePatterns {
|
||||||
|
args = append(args, "--exclude="+pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pair.Direction == "mirror" {
|
||||||
|
args = append(args, "--delete")
|
||||||
|
}
|
||||||
|
|
||||||
|
if pair.Direction == "pull" {
|
||||||
|
args = append(args, pair.Dest, pair.Source)
|
||||||
|
} else {
|
||||||
|
args = append(args, pair.Source, pair.Dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed dist
|
||||||
|
var DistFS embed.FS
|
||||||
|
|
||||||
|
var Dist fs.FS = DistFS
|
||||||
|
|
||||||
|
func ServeHTTP() http.Handler {
|
||||||
|
return http.FileServer(http.FS(DistFS))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ServeSPA() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
if path == "/" || !isStaticAsset(path) {
|
||||||
|
data, err := DistFS.ReadFile("dist/index.html")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Write(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.FileServer(http.FS(DistFS)).ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func isStaticAsset(path string) bool {
|
||||||
|
exts := []string{".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".map"}
|
||||||
|
for _, ext := range exts {
|
||||||
|
if len(path) > len(ext) && path[len(path)-len(ext):] == ext {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package wol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrTimeout = fmt.Errorf("timeout waiting for machine to respond")
|
||||||
|
|
||||||
|
func WaitUntilReady(ctx context.Context, host string, sshPort int, maxWait, checkInterval time.Duration, usePing bool) error {
|
||||||
|
deadline := time.Now().Add(maxWait)
|
||||||
|
interval := checkInterval
|
||||||
|
|
||||||
|
ticker := time.NewTicker(checkInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", host, sshPort)
|
||||||
|
dialer := net.Dialer{Timeout: 3 * time.Second}
|
||||||
|
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if usePing {
|
||||||
|
cmd := exec.CommandContext(ctx, "ping", "-c", "1", "-W", "1", host)
|
||||||
|
if err := cmd.Run(); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return ErrTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(time.Now().Add(-maxWait))
|
||||||
|
if elapsed > maxWait/2 && interval < 10*time.Second {
|
||||||
|
interval = interval * 3 / 2
|
||||||
|
if interval > 10*time.Second {
|
||||||
|
interval = 10 * time.Second
|
||||||
|
}
|
||||||
|
ticker.Reset(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsHostReachable(host string, port int, timeout time.Duration) bool {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
|
dialer := net.Dialer{Timeout: timeout}
|
||||||
|
conn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||||
|
if err == nil {
|
||||||
|
conn.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package wol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var macRegex = regexp.MustCompile(`^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$`)
|
||||||
|
|
||||||
|
func ParseMAC(s string) ([6]byte, error) {
|
||||||
|
s = strings.ReplaceAll(s, "-", ":")
|
||||||
|
s = strings.ToLower(s)
|
||||||
|
if !macRegex.MatchString(s) {
|
||||||
|
return [6]byte{}, fmt.Errorf("invalid MAC address: %s", s)
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ":")
|
||||||
|
var mac [6]byte
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
var b int
|
||||||
|
if _, err := fmt.Sscanf(parts[i], "%x", &b); err != nil {
|
||||||
|
return [6]byte{}, fmt.Errorf("invalid MAC address: %s", s)
|
||||||
|
}
|
||||||
|
mac[i] = byte(b)
|
||||||
|
}
|
||||||
|
return mac, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatMAC(mac [6]byte) string {
|
||||||
|
return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x",
|
||||||
|
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildMagicPacket(mac [6]byte) []byte {
|
||||||
|
packet := make([]byte, 6+16*6)
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
packet[i] = 0xFF
|
||||||
|
}
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
offset := 6 + i*6
|
||||||
|
copy(packet[offset:offset+6], mac[:])
|
||||||
|
}
|
||||||
|
return packet
|
||||||
|
}
|
||||||
|
|
||||||
|
func Send(addr string, mac [6]byte, broadcastAddr string) error {
|
||||||
|
packet := BuildMagicPacket(mac)
|
||||||
|
|
||||||
|
udpAddr := &net.UDPAddr{
|
||||||
|
IP: net.IPv4bcast,
|
||||||
|
Port: 9,
|
||||||
|
}
|
||||||
|
if broadcastAddr != "" {
|
||||||
|
udpAddr.IP = net.ParseIP(broadcastAddr)
|
||||||
|
if udpAddr.IP == nil {
|
||||||
|
return fmt.Errorf("invalid broadcast address: %s", broadcastAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.DialUDP("udp4", nil, udpAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating UDP connection: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if err := conn.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||||
|
return fmt.Errorf("setting broadcast mode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := conn.Write(packet)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sending magic packet: %w", err)
|
||||||
|
}
|
||||||
|
if n != len(packet) {
|
||||||
|
return fmt.Errorf("incomplete write: sent %d/%d bytes", n, len(packet))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package wol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseMAC(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
wantOK bool
|
||||||
|
wantBytes [6]byte
|
||||||
|
}{
|
||||||
|
{"AA:BB:CC:DD:EE:FF", true, [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}},
|
||||||
|
{"aa:bb:cc:dd:ee:ff", true, [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}},
|
||||||
|
{"AA-BB-CC-DD-EE-FF", true, [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}},
|
||||||
|
{"11:22:33:44:55:66", true, [6]byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66}},
|
||||||
|
{"not:a:mac:addre", false, [6]byte{}},
|
||||||
|
{"GG:HH:II:JJ:KK:LL", false, [6]byte{}},
|
||||||
|
{"aa:bb:cc:dd", false, [6]byte{}},
|
||||||
|
{"", false, [6]byte{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
mac, err := ParseMAC(tt.input)
|
||||||
|
if tt.wantOK {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseMAC(%q) unexpected error: %v", tt.input, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mac != tt.wantBytes {
|
||||||
|
t.Errorf("ParseMAC(%q) = %v, want %v", tt.input, mac, tt.wantBytes)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParseMAC(%q) expected error, got nil", tt.input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatMAC(t *testing.T) {
|
||||||
|
mac := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
||||||
|
got := FormatMAC(mac)
|
||||||
|
want := "aa:bb:cc:dd:ee:ff"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("FormatMAC() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMagicPacket(t *testing.T) {
|
||||||
|
mac := [6]byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}
|
||||||
|
packet := BuildMagicPacket(mac)
|
||||||
|
|
||||||
|
if len(packet) != 102 {
|
||||||
|
t.Errorf("BuildMagicPacket length = %d, want 102", len(packet))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
if packet[i] != 0xFF {
|
||||||
|
t.Errorf("packet[%d] = %02x, want FF", i, packet[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
offset := 6 + i*6
|
||||||
|
for j := 0; j < 6; j++ {
|
||||||
|
if packet[offset+j] != mac[j] {
|
||||||
|
t.Errorf("packet[%d] = %02x, want %02x (rep %d)", offset+j, packet[offset+j], mac[j], i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/etc/syncserver/config.yaml
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
Package: syncserver
|
||||||
|
Version: 1a66ac5
|
||||||
|
Architecture: amd64
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Depends: rsync, openssh-client, ca-certificates
|
||||||
|
Maintainer: SyncServer Team <syncserver@example.com>
|
||||||
|
Description: Self-hosted file sync orchestrator with Wake-on-LAN and web UI
|
||||||
|
SyncServer is a Go-based server that provides a web interface to manage
|
||||||
|
and execute file synchronizations between multiple machines using rsync
|
||||||
|
over SSH, with optional Wake-on-LAN support to power on remote machines
|
||||||
|
before syncing.
|
||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
configure)
|
||||||
|
if ! id syncserver > /dev/null 2>&1; then
|
||||||
|
useradd --system --no-create-home --shell /usr/sbin/nologin syncserver || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -d -o syncserver -g syncserver -m 0750 /var/lib/syncserver/data || true
|
||||||
|
install -d -o syncserver -g syncserver -m 0750 /var/lib/syncserver/ssh || true
|
||||||
|
install -d -o syncserver -g syncserver -m 0750 /var/lib/syncserver/logs || true
|
||||||
|
install -d -o root -g root -m 0755 /etc/syncserver || true
|
||||||
|
|
||||||
|
if [ ! -f /etc/syncserver/config.yaml ]; then
|
||||||
|
cat > /etc/syncserver/config.yaml << 'EOF'
|
||||||
|
# SyncServer configuration
|
||||||
|
# Copy this file and edit as needed.
|
||||||
|
# Environment variables SYNCSERVER_* take precedence over this file.
|
||||||
|
|
||||||
|
data_dir: /var/lib/syncserver
|
||||||
|
addr: :8080
|
||||||
|
|
||||||
|
auth:
|
||||||
|
jwt_secret: "" # Leave empty to generate a random one on startup
|
||||||
|
jwt_expiry_hours: 24
|
||||||
|
|
||||||
|
scheduler:
|
||||||
|
timezone: UTC
|
||||||
|
EOF
|
||||||
|
chown root:root /etc/syncserver/config.yaml
|
||||||
|
chmod 0644 /etc/syncserver/config.yaml
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v systemctl > /dev/null 2>&1; then
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
systemctl enable syncserver.service 2>/dev/null || true
|
||||||
|
systemctl start syncserver.service 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
abort-upgrade|abort-remove|abort-configure)
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
purge|remove)
|
||||||
|
if [ "$1" = "purge" ]; then
|
||||||
|
rm -rf /var/lib/syncserver || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
upgrade)
|
||||||
|
;;
|
||||||
|
failed-upgrade)
|
||||||
|
;;
|
||||||
|
abort-install|abort-upgrade)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
remove|purge|deconfigure)
|
||||||
|
if command -v systemctl > /dev/null 2>&1; then
|
||||||
|
systemctl stop syncserver.service 2>/dev/null || true
|
||||||
|
systemctl disable syncserver.service 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
upgrade)
|
||||||
|
;;
|
||||||
|
failed-upgrade)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=SyncServer - File sync orchestrator with Wake-on-LAN and web UI
|
||||||
|
Documentation=https://github.com/syncserver/syncserver
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=syncserver
|
||||||
|
Group=syncserver
|
||||||
|
ExecStart=/usr/bin/syncserver --config /etc/syncserver/config.yaml --data-dir /var/lib/syncserver
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=syncserver
|
||||||
|
|
||||||
|
# Hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/var/lib/syncserver /run
|
||||||
|
UMask=0077
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}"
|
||||||
|
GOARCH="${GOARCH:-amd64}"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
echo "=== Building SyncServer v${VERSION} for ${GOARCH} ==="
|
||||||
|
|
||||||
|
echo "=== Building frontend ==="
|
||||||
|
cd "$PROJECT_DIR/web"
|
||||||
|
npm ci --silent 2>/dev/null || npm install --silent
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "=== Copying frontend dist to Go embed dir ==="
|
||||||
|
rm -rf "$PROJECT_DIR/internal/webui/dist"
|
||||||
|
cp -R "$PROJECT_DIR/web/dist" "$PROJECT_DIR/internal/webui/dist"
|
||||||
|
|
||||||
|
echo "=== Building Go binary ==="
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH="${GOARCH}" \
|
||||||
|
go build -trimpath \
|
||||||
|
-ldflags="-s -w -X main.version=${VERSION}" \
|
||||||
|
-o "packaging/debian/usr/bin/syncserver" \
|
||||||
|
./cmd/server
|
||||||
|
|
||||||
|
echo "=== Binary size ==="
|
||||||
|
ls -lh "packaging/debian/usr/bin/syncserver"
|
||||||
|
|
||||||
|
echo "=== Done ==="
|
||||||
|
echo "Run: ./scripts/package-deb.sh to create the .deb package"
|
||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}"
|
||||||
|
ARCH="${ARCH:-amd64}"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
OUTPUT_DIR="$PROJECT_DIR/dist"
|
||||||
|
DEB_DIR="$PROJECT_DIR/packaging/debian"
|
||||||
|
|
||||||
|
echo "=== Packaging SyncServer v${VERSION} for ${ARCH} ==="
|
||||||
|
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
DEB_PATH="$OUTPUT_DIR/syncserver_${VERSION}_${ARCH}.deb"
|
||||||
|
|
||||||
|
sed "s/@@VERSION@@/${VERSION}/" "$DEB_DIR/DEBIAN/control" > "$DEB_DIR/DEBIAN/control.tmp"
|
||||||
|
mv "$DEB_DIR/DEBIAN/control.tmp" "$DEB_DIR/DEBIAN/control"
|
||||||
|
|
||||||
|
chmod 0755 "$DEB_DIR/DEBIAN/postinst"
|
||||||
|
chmod 0755 "$DEB_DIR/DEBIAN/prerm"
|
||||||
|
chmod 0755 "$DEB_DIR/DEBIAN/postrm"
|
||||||
|
chmod 0644 "$DEB_DIR/DEBIAN/conffiles"
|
||||||
|
chmod 0644 "$DEB_DIR/etc/syncserver/config.yaml"
|
||||||
|
chmod 0644 "$DEB_DIR/lib/systemd/system/syncserver.service"
|
||||||
|
|
||||||
|
echo "=== Building .deb ==="
|
||||||
|
dpkg-deb --build --root-owner-group "$DEB_DIR" "$DEB_PATH"
|
||||||
|
|
||||||
|
echo "=== Package info ==="
|
||||||
|
dpkg-deb -I "$DEB_PATH"
|
||||||
|
echo ""
|
||||||
|
echo "=== Package contents (first 30) ==="
|
||||||
|
dpkg-deb -c "$DEB_PATH" | head -30
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Output: $DEB_PATH ==="
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SyncServer</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2633
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "syncserver-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.28.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.15",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^6.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import Machines from './pages/Machines';
|
||||||
|
import SyncPairs from './pages/SyncPairs';
|
||||||
|
import JobHistory from './pages/JobHistory';
|
||||||
|
import Settings from './pages/Settings';
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }: { children: JSX.Element }) {
|
||||||
|
const [authed, setAuthed] = useState<boolean | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/auth/me', { credentials: 'include' })
|
||||||
|
.then(r => setAuthed(r.ok))
|
||||||
|
.catch(() => setAuthed(false));
|
||||||
|
}, []);
|
||||||
|
if (authed === null) return <div className="p-4">Loading...</div>;
|
||||||
|
return authed ? children : <Navigate to="/login" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||||
|
<Route path="/machines" element={<ProtectedRoute><Machines /></ProtectedRoute>} />
|
||||||
|
<Route path="/sync-pairs" element={<ProtectedRoute><SyncPairs /></ProtectedRoute>} />
|
||||||
|
<Route path="/jobs" element={<ProtectedRoute><JobHistory /></ProtectedRoute>} />
|
||||||
|
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||||
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
const BASE = '';
|
||||||
|
|
||||||
|
interface ApiOptions {
|
||||||
|
method?: string;
|
||||||
|
body?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api<T>(path: string, opts: ApiOptions = {}): Promise<T> {
|
||||||
|
const { method = 'GET', body } = opts;
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw new Error((err as { error?: string }).error || 'Request failed');
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Machine {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
ssh_user: string;
|
||||||
|
ssh_key_id: number | null;
|
||||||
|
mac_address: string | null;
|
||||||
|
wol_enabled: boolean;
|
||||||
|
broadcast_addr: string | null;
|
||||||
|
wake_timeout_seconds: number;
|
||||||
|
wake_check_interval_seconds: number;
|
||||||
|
fingerprint_confirmed: boolean;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncPair {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
source_machine_id: number | null;
|
||||||
|
source_path: string;
|
||||||
|
dest_machine_id: number | null;
|
||||||
|
dest_path: string;
|
||||||
|
direction: string;
|
||||||
|
rsync_flags: string;
|
||||||
|
exclude_patterns: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Job {
|
||||||
|
id: number;
|
||||||
|
sync_pair_id: number;
|
||||||
|
trigger_type: string;
|
||||||
|
status: string;
|
||||||
|
started_at: string | null;
|
||||||
|
finished_at: string | null;
|
||||||
|
log_file: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api, Machine, Job } from '../api/client';
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [machines, setMachines] = useState<Machine[]>([]);
|
||||||
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
api<Machine[]>('/api/machines'),
|
||||||
|
api<Job[]>('/api/jobs?limit=5'),
|
||||||
|
]).then(([m, j]) => {
|
||||||
|
setMachines(m);
|
||||||
|
setJobs(j);
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const online = machines.filter(m => m.status.startsWith('online')).length;
|
||||||
|
const todayJobs = jobs.filter(j => {
|
||||||
|
if (!j.started_at) return false;
|
||||||
|
return j.started_at.startsWith(new Date().toISOString().split('T')[0]);
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<div className="text-gray-400 text-sm">Machines</div>
|
||||||
|
<div className="text-3xl font-bold">{machines.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<div className="text-gray-400 text-sm">Online</div>
|
||||||
|
<div className="text-3xl font-bold text-green-500">{online}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<div className="text-gray-400 text-sm">Jobs Today</div>
|
||||||
|
<div className="text-3xl font-bold text-blue-500">{todayJobs}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Recent Jobs</h2>
|
||||||
|
{jobs.length === 0 ? <p className="text-gray-500">No jobs yet</p> : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-400 border-b border-gray-700">
|
||||||
|
<th className="pb-2">ID</th>
|
||||||
|
<th className="pb-2">Sync Pair</th>
|
||||||
|
<th className="pb-2">Status</th>
|
||||||
|
<th className="pb-2">Started</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{jobs.map(j => (
|
||||||
|
<tr key={j.id} className="border-b border-gray-700/50">
|
||||||
|
<td className="py-2">{j.id}</td>
|
||||||
|
<td className="py-2">{j.sync_pair_id}</td>
|
||||||
|
<td className="py-2">
|
||||||
|
<StatusBadge status={j.status} />
|
||||||
|
</td>
|
||||||
|
<td className="py-2">{j.started_at ? new Date(j.started_at).toLocaleString() : '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex gap-4">
|
||||||
|
<Link to="/machines" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">Machines</Link>
|
||||||
|
<Link to="/sync-pairs" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">Sync Pairs</Link>
|
||||||
|
<Link to="/jobs" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">All Jobs</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
queued: 'bg-gray-600',
|
||||||
|
waking_up: 'bg-yellow-600',
|
||||||
|
running: 'bg-blue-600',
|
||||||
|
success: 'bg-green-600',
|
||||||
|
failed: 'bg-red-600',
|
||||||
|
cancelled: 'bg-gray-600',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`${colors[status] || 'bg-gray-600'} text-white text-xs px-2 py-0.5 rounded`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { api, Job, SyncPair } from '../api/client';
|
||||||
|
|
||||||
|
interface SSEEvent {
|
||||||
|
type: string;
|
||||||
|
job_id: number;
|
||||||
|
status?: string;
|
||||||
|
line?: string;
|
||||||
|
stream?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JobHistory() {
|
||||||
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
|
const [pairs, setPairs] = useState<SyncPair[]>([]);
|
||||||
|
const esRef = useRef<EventSource | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
const es = new EventSource('/api/jobs/stream');
|
||||||
|
esRef.current = es;
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
const evt: SSEEvent = JSON.parse(e.data);
|
||||||
|
if (evt.type === 'status') {
|
||||||
|
setJobs(prev => prev.map(j => j.id === evt.job_id ? { ...j, status: evt.status! } : j));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return () => es.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [j, p] = await Promise.all([
|
||||||
|
api<Job[]>('/api/jobs?limit=100'),
|
||||||
|
api<SyncPair[]>('/api/sync-pairs'),
|
||||||
|
]);
|
||||||
|
setJobs(j);
|
||||||
|
setPairs(p);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancel(id: number) {
|
||||||
|
try {
|
||||||
|
await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
|
||||||
|
load();
|
||||||
|
} catch { alert('Cancel failed'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pairName(id: number) {
|
||||||
|
const p = pairs.find(p => p.id === id);
|
||||||
|
return p ? p.name : `Pair ${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(s: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
queued: 'bg-gray-600', waking_up: 'bg-yellow-600', running: 'bg-blue-600',
|
||||||
|
success: 'bg-green-600', failed: 'bg-red-600', cancelled: 'bg-gray-600',
|
||||||
|
};
|
||||||
|
return map[s] || 'bg-gray-600';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Job History</h1>
|
||||||
|
<table className="w-full text-sm bg-gray-800 rounded-lg overflow-hidden">
|
||||||
|
<thead className="bg-gray-700">
|
||||||
|
<tr className="text-left text-gray-400">
|
||||||
|
<th className="p-3">ID</th>
|
||||||
|
<th className="p-3">Sync Pair</th>
|
||||||
|
<th className="p-3">Trigger</th>
|
||||||
|
<th className="p-3">Status</th>
|
||||||
|
<th className="p-3">Started</th>
|
||||||
|
<th className="p-3">Finished</th>
|
||||||
|
<th className="p-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{jobs.map(j => (
|
||||||
|
<tr key={j.id} className="border-t border-gray-700">
|
||||||
|
<td className="p-3">{j.id}</td>
|
||||||
|
<td className="p-3">{pairName(j.sync_pair_id)}</td>
|
||||||
|
<td className="p-3">{j.trigger_type}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<span className={`${statusColor(j.status)} text-white text-xs px-2 py-0.5 rounded`}>
|
||||||
|
{j.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">{j.started_at ? new Date(j.started_at).toLocaleString() : '-'}</td>
|
||||||
|
<td className="p-3">{j.finished_at ? new Date(j.finished_at).toLocaleString() : '-'}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{['queued', 'waking_up', 'running'].includes(j.status) && (
|
||||||
|
<button onClick={() => cancel(j.id)} className="text-red-400 hover:text-red-300">Cancel</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{jobs.length === 0 && <tr><td colSpan={7} className="p-4 text-center text-gray-500">No jobs</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
navigate('/');
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data.error || 'Login failed');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Network error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-950">
|
||||||
|
<form onSubmit={handleSubmit} className="bg-gray-900 p-8 rounded-lg w-80 shadow-xl">
|
||||||
|
<h1 className="text-2xl font-bold mb-6 text-white">SyncServer</h1>
|
||||||
|
{error && <div className="bg-red-900 text-red-200 p-2 rounded mb-4 text-sm">{error}</div>}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-gray-400 text-sm mb-1">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 text-white rounded px-3 py-2 border border-gray-700 focus:border-blue-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-gray-400 text-sm mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 text-white rounded px-3 py-2 border border-gray-700 focus:border-blue-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="w-full bg-blue-600 hover:bg-blue-700 text-white rounded py-2 font-medium">
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api, Machine } from '../api/client';
|
||||||
|
|
||||||
|
export default function Machines() {
|
||||||
|
const [machines, setMachines] = useState<Machine[]>([]);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
id: undefined as number | undefined, name: '', host: '', port: 22, ssh_user: 'root',
|
||||||
|
mac_address: '', wol_enabled: false,
|
||||||
|
wake_timeout_seconds: 120, wake_check_interval_seconds: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try { setMachines(await api<Machine[]>('/api/machines')); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
id: form.id || null, name: form.name, host: form.host, port: Number(form.port),
|
||||||
|
ssh_user: form.ssh_user, mac_address: form.mac_address || null,
|
||||||
|
wol_enabled: Boolean(form.wol_enabled),
|
||||||
|
wake_timeout_seconds: Number(form.wake_timeout_seconds),
|
||||||
|
wake_check_interval_seconds: Number(form.wake_check_interval_seconds),
|
||||||
|
};
|
||||||
|
if (form.mac_address && !/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/.test(form.mac_address)) {
|
||||||
|
alert('Invalid MAC address format');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api(form.id ? `/api/machines/${form.id}` : '/api/machines', {
|
||||||
|
method: form.id ? 'PUT' : 'POST',
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
setShowForm(false);
|
||||||
|
setForm({ id: undefined, name: '', host: '', port: 22, ssh_user: 'root', mac_address: '', wol_enabled: false as boolean, wake_timeout_seconds: 120, wake_check_interval_seconds: 5 });
|
||||||
|
load();
|
||||||
|
} catch (e: unknown) { alert((e as Error).message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit(m: Machine) {
|
||||||
|
setForm({
|
||||||
|
id: m.id, name: m.name, host: m.host, port: m.port,
|
||||||
|
ssh_user: m.ssh_user, mac_address: m.mac_address || '',
|
||||||
|
wol_enabled: m.wol_enabled,
|
||||||
|
wake_timeout_seconds: m.wake_timeout_seconds,
|
||||||
|
wake_check_interval_seconds: m.wake_check_interval_seconds,
|
||||||
|
});
|
||||||
|
setShowForm(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number) {
|
||||||
|
if (!confirm('Delete machine?')) return;
|
||||||
|
try { await api(`/api/machines/${id}`, { method: 'DELETE' }); load(); } catch { alert('Delete failed'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Machines</h1>
|
||||||
|
<button onClick={() => setShowForm(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">
|
||||||
|
Add Machine
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<form onSubmit={handleSubmit} className="bg-gray-800 p-6 rounded-lg w-96 space-y-3">
|
||||||
|
<h2 className="text-lg font-bold">Machine</h2>
|
||||||
|
<input placeholder="Name" value={form.name} onChange={e => setForm({...form, name: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
|
||||||
|
<input placeholder="Host / IP" value={form.host} onChange={e => setForm({...form, host: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
|
||||||
|
<input placeholder="SSH Port" type="number" value={form.port} onChange={e => setForm({...form, port: +e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
|
||||||
|
<input placeholder="SSH User" value={form.ssh_user} onChange={e => setForm({...form, ssh_user: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
|
||||||
|
<input placeholder="MAC Address (AA:BB:CC:DD:EE:FF)" value={form.mac_address} onChange={e => setForm({...form, mac_address: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
|
||||||
|
<label className="flex items-center gap-2 text-gray-300">
|
||||||
|
<input type="checkbox" checked={form.wol_enabled} onChange={e => setForm({...form, wol_enabled: e.target.checked})} />
|
||||||
|
Enable Wake-on-LAN
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded flex-1">Save</button>
|
||||||
|
<button type="button" onClick={() => setShowForm(false)} className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<table className="w-full text-sm bg-gray-800 rounded-lg overflow-hidden">
|
||||||
|
<thead className="bg-gray-700">
|
||||||
|
<tr className="text-left text-gray-400">
|
||||||
|
<th className="p-3">Name</th>
|
||||||
|
<th className="p-3">Host</th>
|
||||||
|
<th className="p-3">WoL</th>
|
||||||
|
<th className="p-3">Status</th>
|
||||||
|
<th className="p-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{machines.map(m => (
|
||||||
|
<tr key={m.id} className="border-t border-gray-700">
|
||||||
|
<td className="p-3 font-medium">{m.name}</td>
|
||||||
|
<td className="p-3">{m.host}:{m.port}</td>
|
||||||
|
<td className="p-3">{m.wol_enabled ? 'Yes' : 'No'}</td>
|
||||||
|
<td className="p-3 text-gray-400">{m.status}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<button onClick={() => edit(m)} className="text-blue-400 hover:text-blue-300 mr-3">Edit</button>
|
||||||
|
<button onClick={() => remove(m.id)} className="text-red-400 hover:text-red-300">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{machines.length === 0 && <tr><td colSpan={5} className="p-4 text-center text-gray-500">No machines</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const [pubKey, setPubKey] = useState('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/settings/pubkey', { credentials: 'include' })
|
||||||
|
.then(r => r.ok ? r.text() : '')
|
||||||
|
.then(t => setPubKey(t))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function copyKey() {
|
||||||
|
navigator.clipboard.writeText(pubKey).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-2xl">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Settings</h1>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Server SSH Public Key</h2>
|
||||||
|
<p className="text-gray-400 text-sm mb-3">
|
||||||
|
Add this key to the <code className="bg-gray-700 px-1 rounded">~/.ssh/authorized_keys</code> file on your remote machines to allow SyncServer to connect.
|
||||||
|
</p>
|
||||||
|
<div className="bg-gray-900 p-3 rounded font-mono text-xs text-green-400 break-all mb-3">
|
||||||
|
{pubKey || 'Loading...'}
|
||||||
|
</div>
|
||||||
|
<button onClick={copyKey} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm">
|
||||||
|
{copied ? 'Copied!' : 'Copy to clipboard'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Quick Reference</h2>
|
||||||
|
<div className="text-gray-400 text-sm space-y-2">
|
||||||
|
<p><strong className="text-white">ssh-copy-id:</strong> Copy the public key above to a remote machine:</p>
|
||||||
|
<code className="block bg-gray-900 p-2 rounded text-xs">
|
||||||
|
cat ~/.ssh/id_ed25519.pub | ssh user@host 'cat >> ~/.ssh/authorized_keys'
|
||||||
|
</code>
|
||||||
|
<p className="mt-4"><strong className="text-white">Wake-on-LAN:</strong> Make sure your target machine BIOS/UEFI has WoL enabled and is connected to the same network layer (L2) as this server.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api, SyncPair, Machine } from '../api/client';
|
||||||
|
|
||||||
|
export default function SyncPairs() {
|
||||||
|
const [pairs, setPairs] = useState<SyncPair[]>([]);
|
||||||
|
const [machines, setMachines] = useState<Machine[]>([]);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
id: undefined as number | undefined, name: '', source_machine_id: null as number | null, source_path: '',
|
||||||
|
dest_machine_id: null as number | null, dest_path: '',
|
||||||
|
direction: 'push', rsync_flags: '-aP', exclude_patterns: '', enabled: true,
|
||||||
|
});
|
||||||
|
const [running, setRunning] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [p, m] = await Promise.all([
|
||||||
|
api<SyncPair[]>('/api/sync-pairs'),
|
||||||
|
api<Machine[]>('/api/machines'),
|
||||||
|
]);
|
||||||
|
setPairs(p);
|
||||||
|
setMachines(m);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.name, source_machine_id: form.source_machine_id, source_path: form.source_path,
|
||||||
|
dest_machine_id: form.dest_machine_id, dest_path: form.dest_path,
|
||||||
|
direction: form.direction, rsync_flags: form.rsync_flags,
|
||||||
|
exclude_patterns: form.exclude_patterns, enabled: form.enabled,
|
||||||
|
};
|
||||||
|
await api(form.id ? `/api/sync-pairs/${form.id}` : '/api/sync-pairs', {
|
||||||
|
method: form.id ? 'PUT' : 'POST',
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
setShowForm(false);
|
||||||
|
resetForm();
|
||||||
|
load();
|
||||||
|
} catch (e: unknown) { alert((e as Error).message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trigger(pairId: number) {
|
||||||
|
setRunning(r => ({ ...r, [pairId]: true }));
|
||||||
|
try {
|
||||||
|
await api(`/api/sync-pairs/${pairId}/run`, { method: 'POST' });
|
||||||
|
load();
|
||||||
|
} catch (e: unknown) { alert((e as Error).message); }
|
||||||
|
setRunning(r => ({ ...r, [pairId]: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number) {
|
||||||
|
if (!confirm('Delete sync pair?')) return;
|
||||||
|
try { await api(`/api/sync-pairs/${id}`, { method: 'DELETE' }); load(); } catch { alert('Delete failed'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
setForm({ id: undefined, name: '', source_machine_id: null, source_path: '', dest_machine_id: null, dest_path: '', direction: 'push', rsync_flags: '-aP', exclude_patterns: '', enabled: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function machineName(id: number | null) {
|
||||||
|
if (!id) return 'Local server';
|
||||||
|
const m = machines.find(m => m.id === id);
|
||||||
|
return m ? m.name : `Machine ${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Sync Pairs</h1>
|
||||||
|
<button onClick={() => setShowForm(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">
|
||||||
|
Add Sync Pair
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<form onSubmit={handleSubmit} className="bg-gray-800 p-6 rounded-lg w-[500px] space-y-3 max-h-[90vh] overflow-y-auto">
|
||||||
|
<h2 className="text-lg font-bold">Sync Pair</h2>
|
||||||
|
<input placeholder="Name" value={form.name} onChange={e => setForm({...form, name: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-gray-400 text-xs">Source Machine</label>
|
||||||
|
<select value={form.source_machine_id ?? ''} onChange={e => setForm({...form, source_machine_id: e.target.value ? +e.target.value : null })} className="w-full bg-gray-700 rounded px-3 py-2 text-white">
|
||||||
|
<option value="">Local server</option>
|
||||||
|
{machines.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-gray-400 text-xs">Dest Machine</label>
|
||||||
|
<select value={form.dest_machine_id ?? ''} onChange={e => setForm({...form, dest_machine_id: e.target.value ? +e.target.value : null })} className="w-full bg-gray-700 rounded px-3 py-2 text-white">
|
||||||
|
<option value="">Local server</option>
|
||||||
|
{machines.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<input placeholder="Source Path" value={form.source_path} onChange={e => setForm({...form, source_path: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
|
||||||
|
<input placeholder="Dest Path" value={form.dest_path} onChange={e => setForm({...form, dest_path: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<select value={form.direction} onChange={e => setForm({...form, direction: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white">
|
||||||
|
<option value="push">Push</option>
|
||||||
|
<option value="pull">Pull</option>
|
||||||
|
<option value="mirror">Mirror</option>
|
||||||
|
</select>
|
||||||
|
<input placeholder="Rsync Flags" value={form.rsync_flags} onChange={e => setForm({...form, rsync_flags: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
|
||||||
|
</div>
|
||||||
|
<textarea placeholder="Exclude Patterns (one per line)" value={form.exclude_patterns} onChange={e => setForm({...form, exclude_patterns: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white font-mono text-sm" rows={3} />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded flex-1">Save</button>
|
||||||
|
<button type="button" onClick={() => { setShowForm(false); resetForm(); }} className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<table className="w-full text-sm bg-gray-800 rounded-lg overflow-hidden">
|
||||||
|
<thead className="bg-gray-700">
|
||||||
|
<tr className="text-left text-gray-400">
|
||||||
|
<th className="p-3">Name</th>
|
||||||
|
<th className="p-3">Source</th>
|
||||||
|
<th className="p-3">Dest</th>
|
||||||
|
<th className="p-3">Direction</th>
|
||||||
|
<th className="p-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pairs.map(p => (
|
||||||
|
<tr key={p.id} className="border-t border-gray-700">
|
||||||
|
<td className="p-3 font-medium">{p.name}</td>
|
||||||
|
<td className="p-3 font-mono text-xs">{machineName(p.source_machine_id)}:{p.source_path}</td>
|
||||||
|
<td className="p-3 font-mono text-xs">{machineName(p.dest_machine_id)}:{p.dest_path}</td>
|
||||||
|
<td className="p-3">{p.direction}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<button onClick={() => trigger(p.id)} disabled={running[p.id]} className="text-green-400 hover:text-green-300 mr-3 disabled:opacity-50">
|
||||||
|
{running[p.id] ? 'Running...' : 'Run'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => remove(p.id)} className="text-red-400 hover:text-red-300">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{pairs.length === 0 && <tr><td colSpan={5} className="p-4 text-center text-gray-500">No sync pairs</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user