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
+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
}