84b185be39
Phase A - Stability: - Engine.Start(): recovers orphaned jobs (running/queued/waking_up) after crash - Engine.Stop(): graceful shutdown - cancels all in-flight jobs and waits - Queue keyed by jobID (not syncPairID): cancel now targets exact job - Local rsync uses jobCtx (context.Background() replaced) - Migrations wrapped in transactions; checksums stored Phase B - Security: - admin/admin default removed: SYNCSERVER_ADMIN_PASSWORD required on first run - Path validation: rejects .., leading -, null bytes in sync pair paths - Rsync flags allowlist: dangerous flags blocked (--rsync-path, -e, --files-from) - Shell concat in RunRemote replaced with proper sh -c escaping - knownhosts: replaced custom parser with golang.org/x/crypto/ssh/knownhosts - RequireAdmin wired: machine CRUD, SSH key ops, settings require admin role - deploy-keys: uses authorized_keys only (no private key upload) - Hardcoded /var/lib/syncserver/ssh paths replaced with cfg.SSHDir() Phase C - Operational: - /readyz health check: DB query + SSH dir accessibility - /metrics endpoint: Prometheus text format (jobs, queue, machines) - Event struct JSON tags: job_id, machine_id, type (snake_case) - EventBus broadcast: fanned out to all subscribers - SQLite VACUUM INTO backup: scheduled before cleanup if BackupDir set - Filesystem job log cleanup: removes .log files for purged jobs - Backup retention: old backups auto-purged Phase D - Frontend: - Schedules page: REST API + full CRUD UI for cron schedules - Dashboard: cancel button for running/queued jobs - JobDetail: server-side log download via API - Settings: displays data_dir from server - 404 page: proper NotFound component Phase E - Tests: - auth_test.go: JWT, bcrypt, middleware, seed (18 tests) - models_test.go: Job, Machine, SyncPair, Schedule repos (18 tests) - go test -race: no data races found
172 lines
4.3 KiB
Go
172 lines
4.3 KiB
Go
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
|
|
}
|
|
|
|
type ProgressFields struct {
|
|
FileBytes int64 `json:"file_bytes"`
|
|
Pct int `json:"pct"`
|
|
SpeedBps int64 `json:"speed_bps"`
|
|
EtaSeconds int `json:"eta_seconds"`
|
|
XfrDone int `json:"xfr_done"`
|
|
XfrTotal int `json:"xfr_total"`
|
|
}
|
|
|
|
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,]+)`)
|
|
)
|
|
|
|
var perFileProgressRegex = regexp.MustCompile(
|
|
`^\s*(\d{1,3}(?:,\d{3})+)\s+(\d+)%\s+(\d+\.\d+)([kMG])B/s\s+(\d+:\d{2}:\d{2})(.*)`,
|
|
)
|
|
|
|
var xfrRegex = regexp.MustCompile(`xfr#(\d+).*to-chk=(\d+)/(\d+)`)
|
|
|
|
func parseXfrSuffix(suffix string) (done, total int) {
|
|
m := xfrRegex.FindStringSubmatch(suffix)
|
|
if m == nil {
|
|
return 0, 0
|
|
}
|
|
done, _ = strconv.Atoi(m[1])
|
|
t, _ := strconv.Atoi(m[2])
|
|
_ = t
|
|
total, _ = strconv.Atoi(m[3])
|
|
return done, total
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func isProgressOnlyLine(line string) bool {
|
|
return perFileProgressRegex.MatchString(line)
|
|
}
|
|
|
|
func parseProgressFields(line string) *ProgressFields {
|
|
m := perFileProgressRegex.FindStringSubmatch(line)
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
bytes, _ := strconv.ParseInt(strings.ReplaceAll(m[1], ",", ""), 10, 64)
|
|
pct, _ := strconv.Atoi(m[2])
|
|
speed, _ := strconv.ParseFloat(m[3], 64)
|
|
unit := m[4]
|
|
eta := m[5]
|
|
suffix := m[6]
|
|
|
|
speedBps := int64(speed * 1e6)
|
|
switch unit {
|
|
case "k", "K":
|
|
speedBps = int64(speed * 1e3)
|
|
case "m", "M":
|
|
speedBps = int64(speed * 1e6)
|
|
case "g", "G":
|
|
speedBps = int64(speed * 1e9)
|
|
}
|
|
|
|
etaSecs := 0
|
|
parts := strings.Split(eta, ":")
|
|
if len(parts) == 3 {
|
|
h, _ := strconv.Atoi(parts[0])
|
|
m, _ := strconv.Atoi(parts[1])
|
|
s, _ := strconv.Atoi(parts[2])
|
|
etaSecs = h*3600 + m*60 + s
|
|
}
|
|
|
|
pf := &ProgressFields{
|
|
FileBytes: bytes,
|
|
Pct: pct,
|
|
SpeedBps: speedBps,
|
|
EtaSeconds: etaSecs,
|
|
}
|
|
|
|
if suffix != "" {
|
|
done, total := parseXfrSuffix(suffix)
|
|
pf.XfrDone = done
|
|
pf.XfrTotal = total
|
|
}
|
|
|
|
return pf
|
|
}
|
|
|
|
func isFileNameLine(line string) bool {
|
|
if line == "" || strings.TrimSpace(line) == "" {
|
|
return false
|
|
}
|
|
if strings.Contains(line, "sending incremental file list") ||
|
|
strings.Contains(line, "building file list") ||
|
|
strings.Contains(line, "cannot open") ||
|
|
strings.Contains(line, "skipping non-regular") ||
|
|
strings.HasPrefix(line, "sent ") ||
|
|
strings.HasPrefix(line, "total ") ||
|
|
strings.HasPrefix(line, "Number of files:") ||
|
|
strings.Contains(line, "bytes received") {
|
|
return false
|
|
}
|
|
return !isProgressOnlyLine(line)
|
|
}
|