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,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