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:
@@ -0,0 +1,223 @@
|
||||
package syncengine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/syncserver/internal/config"
|
||||
"github.com/syncserver/internal/models"
|
||||
"github.com/syncserver/internal/wol"
|
||||
)
|
||||
|
||||
type Engine struct {
|
||||
db *sql.DB
|
||||
cfg *config.Config
|
||||
queue *Queue
|
||||
eventBus chan Event
|
||||
mu sync.RWMutex
|
||||
stopped bool
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
Type string
|
||||
JobID int64
|
||||
Status string
|
||||
Line string
|
||||
Stream string
|
||||
}
|
||||
|
||||
func New(database interface{ SQLDB() *sql.DB }, cfg *config.Config) *Engine {
|
||||
e := &Engine{
|
||||
db: database.SQLDB(),
|
||||
cfg: cfg,
|
||||
queue: NewQueue(),
|
||||
eventBus: make(chan Event, 100),
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *Engine) Start() {}
|
||||
func (e *Engine) Stop() {}
|
||||
|
||||
func (e *Engine) Events() <-chan Event {
|
||||
return e.eventBus
|
||||
}
|
||||
|
||||
func (e *Engine) Run(ctx context.Context, jobID int64, pairID int64) error {
|
||||
if e.queue.IsRunning(pairID) {
|
||||
existingJobID, _ := e.queue.GetJobID(pairID)
|
||||
return fmt.Errorf("%w: job %d is already running", ErrAlreadyRunning, existingJobID)
|
||||
}
|
||||
|
||||
jobCtx, cancel := context.WithCancel(ctx)
|
||||
enqueueErr := e.queue.Enqueue(pairID, jobID, cancel)
|
||||
if enqueueErr != nil {
|
||||
return enqueueErr
|
||||
}
|
||||
defer e.queue.Dequeue(pairID)
|
||||
|
||||
pairRepo := models.NewSyncPairRepository(e.db)
|
||||
pair, err := pairRepo.GetByID(pairID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching sync pair: %w", err)
|
||||
}
|
||||
|
||||
machineRepo := models.NewMachineRepository(e.db)
|
||||
var srcMachine, dstMachine *models.Machine
|
||||
|
||||
if pair.SourceMachineID != nil {
|
||||
m, _ := machineRepo.GetByID(*pair.SourceMachineID)
|
||||
srcMachine = m
|
||||
}
|
||||
if pair.DestMachineID != nil {
|
||||
m, _ := machineRepo.GetByID(*pair.DestMachineID)
|
||||
dstMachine = m
|
||||
}
|
||||
|
||||
src := buildPath(pair.SourcePath, srcMachine)
|
||||
dst := buildPath(pair.DestPath, dstMachine)
|
||||
|
||||
cfg := &SyncPairConfig{
|
||||
ID: pair.ID,
|
||||
Name: pair.Name,
|
||||
SourceMachineID: pair.SourceMachineID,
|
||||
SourcePath: pair.SourcePath,
|
||||
DestMachineID: pair.DestMachineID,
|
||||
DestPath: pair.DestPath,
|
||||
Direction: pair.Direction,
|
||||
RsyncFlags: pair.RsyncFlags,
|
||||
ExcludePatterns: pair.ExcludePatternsList(),
|
||||
}
|
||||
cfg.Source = src
|
||||
cfg.Dest = dst
|
||||
|
||||
logPath := filepath.Join(e.cfg.LogsDir(), fmt.Sprintf("%d.log", jobID))
|
||||
f, err := os.Create(logPath)
|
||||
if err != nil {
|
||||
slog.Warn("failed to create log file", "error", err)
|
||||
} else {
|
||||
f.Close()
|
||||
}
|
||||
|
||||
e.setJobStatus(jobID, "waking_up")
|
||||
e.emit(Event{Type: "status", JobID: jobID, Status: "waking_up"})
|
||||
|
||||
var targetMachine *models.Machine
|
||||
var remotePort int
|
||||
if pair.Direction == "pull" && srcMachine != nil {
|
||||
targetMachine = srcMachine
|
||||
remotePort = srcMachine.Port
|
||||
} else if dstMachine != nil {
|
||||
targetMachine = dstMachine
|
||||
remotePort = dstMachine.Port
|
||||
}
|
||||
|
||||
if targetMachine != nil && targetMachine.WoLEnabled && targetMachine.MACAddress != nil {
|
||||
mac, err := wol.ParseMAC(*targetMachine.MACAddress)
|
||||
if err == nil {
|
||||
bcast := ""
|
||||
if targetMachine.BroadcastAddr != nil {
|
||||
bcast = *targetMachine.BroadcastAddr
|
||||
}
|
||||
if err := wol.Send(targetMachine.Host, mac, bcast); err != nil {
|
||||
slog.Warn("WoL failed", "host", targetMachine.Host, "error", err)
|
||||
} else {
|
||||
slog.Info("WoL magic packet sent", "host", targetMachine.Host, "mac", *targetMachine.MACAddress)
|
||||
}
|
||||
|
||||
timeout := time.Duration(targetMachine.WakeTimeoutSeconds) * time.Second
|
||||
interval := time.Duration(targetMachine.WakeCheckIntervalSeconds) * time.Second
|
||||
if err := wol.WaitUntilReady(jobCtx, targetMachine.Host, remotePort, timeout, interval, false); err != nil {
|
||||
e.setJobStatus(jobID, "failed")
|
||||
e.emit(Event{Type: "status", JobID: jobID, Status: "failed", Line: err.Error()})
|
||||
return fmt.Errorf("machine not ready: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
e.setJobStatus(jobID, "running")
|
||||
e.setJobLogFile(jobID, logPath)
|
||||
e.emit(Event{Type: "status", JobID: jobID, Status: "running"})
|
||||
slog.Info("job started", "job_id", jobID, "pair", pair.Name)
|
||||
|
||||
var privKey string
|
||||
if targetMachine != nil && targetMachine.SSHKeyID != nil {
|
||||
privKey = filepath.Join(e.cfg.SSHDir(), "id_ed25519")
|
||||
}
|
||||
|
||||
runner := NewRsyncRunner(e.cfg.SSHDir(), privKey)
|
||||
onLine := func(stream, line string) {
|
||||
e.emit(Event{Type: "log", JobID: jobID, Stream: stream, Line: line})
|
||||
}
|
||||
|
||||
result, err := runner.Run(jobCtx, cfg, onLine)
|
||||
if err != nil {
|
||||
if jobCtx.Err() != nil {
|
||||
e.setJobStatus(jobID, "cancelled")
|
||||
e.emit(Event{Type: "status", JobID: jobID, Status: "cancelled"})
|
||||
return jobCtx.Err()
|
||||
}
|
||||
e.setJobStatus(jobID, "failed")
|
||||
e.emit(Event{Type: "status", JobID: jobID, Status: "failed", Line: err.Error()})
|
||||
return fmt.Errorf("rsync error: %w", err)
|
||||
}
|
||||
|
||||
if result.ExitCode != 0 {
|
||||
e.setJobStatus(jobID, "failed")
|
||||
e.emit(Event{Type: "status", JobID: jobID, Status: "failed", Line: result.Stderr})
|
||||
return fmt.Errorf("rsync exited with code %d: %s", result.ExitCode, result.Stderr)
|
||||
}
|
||||
|
||||
e.setJobStatus(jobID, "success")
|
||||
e.emit(Event{Type: "status", JobID: jobID, Status: "success"})
|
||||
slog.Info("job completed", "job_id", jobID, "pair", pair.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) Cancel(jobID int64, syncPairID int64) bool {
|
||||
if e.queue.IsRunning(syncPairID) {
|
||||
e.queue.Cancel(syncPairID)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *Engine) setJobStatus(jobID int64, status string) {
|
||||
jobRepo := models.NewJobRepository(e.db)
|
||||
jobRepo.UpdateStatus(jobID, status)
|
||||
}
|
||||
|
||||
func (e *Engine) setJobLogFile(jobID int64, path string) {
|
||||
jobRepo := models.NewJobRepository(e.db)
|
||||
jobRepo.SetLogFile(jobID, path)
|
||||
}
|
||||
|
||||
func (e *Engine) emit(evt Event) {
|
||||
select {
|
||||
case e.eventBus <- evt:
|
||||
default:
|
||||
slog.Warn("event bus full, dropping event", "type", evt.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func buildPath(path string, machine *models.Machine) string {
|
||||
if machine == nil {
|
||||
return path
|
||||
}
|
||||
return fmt.Sprintf("%s@%s:%s", machine.SSHUser, machine.Host, path)
|
||||
}
|
||||
|
||||
func (e *Engine) CreateJob(syncPairID int64, triggerType string) (int64, error) {
|
||||
jobRepo := models.NewJobRepository(e.db)
|
||||
id, err := jobRepo.Create(syncPairID, triggerType, "queued")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package syncengine
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RsyncStats struct {
|
||||
SentBytes int64
|
||||
ReceivedBytes int64
|
||||
TotalSize int64
|
||||
Speedup float64
|
||||
FilesSent int
|
||||
FilesTotal int
|
||||
}
|
||||
|
||||
type ProgressLine struct {
|
||||
Phase string
|
||||
Percent float64
|
||||
Files int
|
||||
Total int
|
||||
SentBytes int64
|
||||
XferedBytes int64
|
||||
}
|
||||
|
||||
var (
|
||||
progressRegex = regexp.MustCompile(`\s*([\d,]+)\s+([\d,]+)\s+([\d%]+)\s*`)
|
||||
sentRegex = regexp.MustCompile(`sent\s+([\d,]+)\s+bytes`)
|
||||
recvRegex = regexp.MustCompile(`received\s+([\d,]+)\s+bytes`)
|
||||
totalRegex = regexp.MustCompile(`total size is\s+([\d,]+)`)
|
||||
filesRegex = regexp.MustCompile(`Number of files: ([\d,]+)`)
|
||||
)
|
||||
|
||||
func ParseProgressLine(line string) *ProgressLine {
|
||||
if strings.Contains(line, "files to consider") || strings.Contains(line, "files...") {
|
||||
return &ProgressLine{Phase: "scanning"}
|
||||
}
|
||||
if strings.Contains(line, "building file list") {
|
||||
return &ProgressLine{Phase: "listing"}
|
||||
}
|
||||
if strings.Contains(line, "sent") && strings.Contains(line, "bytes") {
|
||||
return &ProgressLine{Phase: "stats"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseStatsLine(line string) (int64, bool) {
|
||||
m := sentRegex.FindStringSubmatch(line)
|
||||
if len(m) >= 2 {
|
||||
n, _ := strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64)
|
||||
return n, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func ParseFinalStats(output string) *RsyncStats {
|
||||
stats := &RsyncStats{}
|
||||
lines := strings.Split(output, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if m := sentRegex.FindStringSubmatch(line); len(m) >= 2 {
|
||||
stats.SentBytes, _ = strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64)
|
||||
}
|
||||
if m := recvRegex.FindStringSubmatch(line); len(m) >= 2 {
|
||||
stats.ReceivedBytes, _ = strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64)
|
||||
}
|
||||
if m := totalRegex.FindStringSubmatch(line); len(m) >= 2 {
|
||||
stats.TotalSize, _ = strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64)
|
||||
}
|
||||
if m := filesRegex.FindStringSubmatch(line); len(m) >= 2 {
|
||||
stats.FilesTotal, _ = strconv.Atoi(strings.ReplaceAll(m[1], ",", ""))
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package syncengine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var ErrAlreadyRunning = errors.New("job already running for this sync pair")
|
||||
|
||||
type Queue struct {
|
||||
mu sync.Mutex
|
||||
runs map[int64]*RunInfo
|
||||
}
|
||||
|
||||
type RunInfo struct {
|
||||
JobID int64
|
||||
Cancel func()
|
||||
}
|
||||
|
||||
func NewQueue() *Queue {
|
||||
return &Queue{runs: make(map[int64]*RunInfo)}
|
||||
}
|
||||
|
||||
func (q *Queue) Enqueue(syncPairID, jobID int64, cancel func()) error {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if _, exists := q.runs[syncPairID]; exists {
|
||||
return ErrAlreadyRunning
|
||||
}
|
||||
q.runs[syncPairID] = &RunInfo{JobID: jobID, Cancel: cancel}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *Queue) Dequeue(syncPairID int64) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
delete(q.runs, syncPairID)
|
||||
}
|
||||
|
||||
func (q *Queue) IsRunning(syncPairID int64) bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
_, exists := q.runs[syncPairID]
|
||||
return exists
|
||||
}
|
||||
|
||||
func (q *Queue) GetJobID(syncPairID int64) (int64, bool) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
info, exists := q.runs[syncPairID]
|
||||
if !exists {
|
||||
return 0, false
|
||||
}
|
||||
return info.JobID, true
|
||||
}
|
||||
|
||||
func (q *Queue) Cancel(syncPairID int64) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if info, exists := q.runs[syncPairID]; exists && info.Cancel != nil {
|
||||
info.Cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package syncengine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQueue(t *testing.T) {
|
||||
q := NewQueue()
|
||||
|
||||
if q.IsRunning(1) {
|
||||
t.Error("queue should be empty")
|
||||
}
|
||||
|
||||
cancelCalled := false
|
||||
cancel := func() { cancelCalled = true }
|
||||
|
||||
err := q.Enqueue(1, 100, cancel)
|
||||
if err != nil {
|
||||
t.Errorf("Enqueue(1) unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !q.IsRunning(1) {
|
||||
t.Error("queue should contain syncPair 1")
|
||||
}
|
||||
|
||||
jobID, ok := q.GetJobID(1)
|
||||
if !ok || jobID != 100 {
|
||||
t.Errorf("GetJobID(1) = %d, %v, want 100, true", jobID, ok)
|
||||
}
|
||||
|
||||
err = q.Enqueue(1, 200, nil)
|
||||
if err != ErrAlreadyRunning {
|
||||
t.Errorf("Enqueue(1) again = %v, want ErrAlreadyRunning", err)
|
||||
}
|
||||
|
||||
q.Cancel(1)
|
||||
if !cancelCalled {
|
||||
t.Error("Cancel should have called the cancel func")
|
||||
}
|
||||
|
||||
q.Dequeue(1)
|
||||
if q.IsRunning(1) {
|
||||
t.Error("queue should be empty after Dequeue")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package syncengine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RsyncResult struct {
|
||||
ExitCode int
|
||||
Stdout string
|
||||
Stderr string
|
||||
Stats *RsyncStats
|
||||
}
|
||||
|
||||
type RsyncRunner struct {
|
||||
sshDir string
|
||||
privKey string
|
||||
}
|
||||
|
||||
type SyncPairConfig struct {
|
||||
ID int64
|
||||
Name string
|
||||
SourceMachineID *int64
|
||||
SourcePath string
|
||||
DestMachineID *int64
|
||||
DestPath string
|
||||
Direction string
|
||||
RsyncFlags string
|
||||
ExcludePatterns []string
|
||||
Source string
|
||||
Dest string
|
||||
}
|
||||
|
||||
func NewRsyncRunner(sshDir, privKey string) *RsyncRunner {
|
||||
return &RsyncRunner{sshDir: sshDir, privKey: privKey}
|
||||
}
|
||||
|
||||
func (r *RsyncRunner) Run(ctx context.Context, pair *SyncPairConfig, onLine func(stream string, line string)) (*RsyncResult, error) {
|
||||
args := r.buildArgs(pair)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "rsync", args...)
|
||||
if r.privKey != "" {
|
||||
sshCmd := fmt.Sprintf("ssh -i %s -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=%s",
|
||||
r.privKey, strings.TrimRight(r.sshDir, "/")+"/known_hosts")
|
||||
cmd.Args = append([]string{"rsync", "-e", sshCmd}, args[1:]...)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("starting rsync: %w", err)
|
||||
}
|
||||
|
||||
var outLines, errLines []string
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
br := io.Reader(stdout)
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := br.Read(buf)
|
||||
if n > 0 {
|
||||
line := strings.TrimRight(string(buf[:n]), "\r\n")
|
||||
if line != "" {
|
||||
outLines = append(outLines, line)
|
||||
if onLine != nil {
|
||||
onLine("stdout", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
default:
|
||||
close(done)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
br := io.Reader(stderr)
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := br.Read(buf)
|
||||
if n > 0 {
|
||||
line := strings.TrimRight(string(buf[:n]), "\r\n")
|
||||
if line != "" {
|
||||
errLines = append(errLines, line)
|
||||
if onLine != nil {
|
||||
onLine("stderr", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
default:
|
||||
close(done)
|
||||
}
|
||||
}()
|
||||
|
||||
err = cmd.Wait()
|
||||
<-done
|
||||
|
||||
result := &RsyncResult{
|
||||
ExitCode: 0,
|
||||
Stdout: strings.Join(outLines, "\n"),
|
||||
Stderr: strings.Join(errLines, "\n"),
|
||||
Stats: ParseFinalStats(strings.Join(outLines, "\n")),
|
||||
}
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
result.ExitCode = -1
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *RsyncRunner) buildArgs(pair *SyncPairConfig) []string {
|
||||
var args []string
|
||||
|
||||
flags := strings.Fields(pair.RsyncFlags)
|
||||
args = append(args, flags...)
|
||||
|
||||
for _, pattern := range pair.ExcludePatterns {
|
||||
args = append(args, "--exclude="+pattern)
|
||||
}
|
||||
|
||||
if pair.Direction == "mirror" {
|
||||
args = append(args, "--delete")
|
||||
}
|
||||
|
||||
if pair.Direction == "pull" {
|
||||
args = append(args, pair.Dest, pair.Source)
|
||||
} else {
|
||||
args = append(args, pair.Source, pair.Dest)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
Reference in New Issue
Block a user