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:
2026-07-07 15:03:22 -04:00
parent 1a66ac58cd
commit 8e08c73f60
69 changed files with 7949 additions and 152 deletions
+33 -151
View File
@@ -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.*
+76
View File
@@ -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.
+36
View File
@@ -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
+298 -1
View File
@@ -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
+124
View File
@@ -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")
}
+20
View File
@@ -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
)
+30
View File
@@ -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=
+69
View File
@@ -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"`
}
+107
View File
@@ -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})
}
+167
View File
@@ -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
}
+226
View File
@@ -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)
}
+183
View File
@@ -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,
}
}
+64
View File
@@ -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()
}
}
}
+102
View File
@@ -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)
}
+40
View File
@@ -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
}
+69
View File
@@ -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
}
+61
View File
@@ -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
}
+16
View File
@@ -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
}
+37
View File
@@ -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
}
+149
View File
@@ -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
}
+102
View File
@@ -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)
}
+7
View File
@@ -0,0 +1,7 @@
package db
import "embed"
// migrationsFS is the embedded filesystem containing SQL migration files.
//go:embed migrations
var migrationsFS embed.FS
+86
View File
@@ -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
);
+145
View File
@@ -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
}
+161
View File
@@ -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
}
+124
View File
@@ -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
}
+80
View File
@@ -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
}
+181
View File
@@ -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
}
+66
View File
@@ -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
}
+153
View File
@@ -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)
}
+97
View File
@@ -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")
}
}
+95
View File
@@ -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)
}
}
+68
View File
@@ -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
}
+143
View File
@@ -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)
}
+107
View File
@@ -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
}
+223
View File
@@ -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
}
+76
View File
@@ -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
}
+63
View File
@@ -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()
}
}
+45
View File
@@ -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")
}
}
+157
View File
@@ -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
}
+43
View File
@@ -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
}
+68
View File
@@ -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
}
+80
View File
@@ -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
}
+72
View File
@@ -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)
}
}
}
}
+1
View File
@@ -0,0 +1 @@
/etc/syncserver/config.yaml
+12
View File
@@ -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.
+47
View File
@@ -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
+21
View File
@@ -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
+20
View File
@@ -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
+32
View File
@@ -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"
+37
View File
@@ -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 ==="
+12
View File
@@ -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>
+2633
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+35
View File
@@ -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>
);
}
+67
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+10
View File
@@ -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>,
)
+92
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
+117
View File
@@ -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>
);
}
+50
View File
@@ -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 &gt;&gt; ~/.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>
);
}
+152
View File
@@ -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>
);
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
}
+20
View File
@@ -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"]
}
+11
View File
@@ -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,
},
})