Harden backup and restore operations

This commit is contained in:
Mikei386
2026-07-13 19:47:43 +02:00
parent 6cbf170d65
commit bbf063c157
26 changed files with 914 additions and 184 deletions
+3
View File
@@ -87,5 +87,8 @@ func main() {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel() defer shutdownCancel()
_ = server.Shutdown(shutdownCtx) _ = server.Shutdown(shutdownCtx)
if err := svc.Wait(shutdownCtx); err != nil {
log.Warn("queue cleanup did not finish before shutdown timeout", "error", err)
}
log.Info("urbm daemon stopped") log.Info("urbm daemon stopped")
} }
-1
View File
@@ -1 +0,0 @@
3e3f832d98656655d591c05ad4454d8480d5cfcdf60c03a2a498a6498e206beb urbm-2026.07.10.r007-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
d2d572254dc7dd9d8a7489cbd7908fc80869efb506d4ecc01845f513c4cfb08b urbm-2026.07.13.r001-x86_64-1.txz
+8 -2
View File
@@ -2,13 +2,19 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.07.10.r007"> <!ENTITY version "2026.07.13.r001">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg"> <!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz"> <!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "3e3f832d98656655d591c05ad4454d8480d5cfcdf60c03a2a498a6498e206beb"> <!ENTITY packageSHA256 "d2d572254dc7dd9d8a7489cbd7908fc80869efb506d4ecc01845f513c4cfb08b">
]> ]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png"> <PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
<CHANGES> <CHANGES>
### 2026.07.13.r001
- Harden restore task validation and make confirmed in-place restores target original paths safely.
- Serialize repository and managed-mount access, block unsafe force-unlock operations, and wait for task cleanup during daemon shutdown.
- Bound snapshot browser memory, stream Restic diffs, continuously drain command output, and reject symlink-overlapping Rsync paths.
- Activate persistent run logs and align package, WebGUI, and manifest versions during builds.
### 2026.07.10.r007 ### 2026.07.10.r007
- Serve snapshot browser folders as compact cached tree pages instead of sending the complete Restic file list to the browser. - Serve snapshot browser folders as compact cached tree pages instead of sending the complete Restic file list to the browser.
- Prevent large snapshots with hundreds of thousands of entries from failing with generic 500 responses during snapshot browsing. - Prevent large snapshots with hundreds of thousands of entries from failing with generic 500 responses during snapshot browsing.
+4
View File
@@ -301,6 +301,10 @@ func decode(r *http.Request, target any) error {
if err := decoder.Decode(target); err != nil { if err := decoder.Decode(target); err != nil {
return fmt.Errorf("validation: invalid JSON: %w", err) return fmt.Errorf("validation: invalid JSON: %w", err)
} }
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return errors.New("validation: JSON body must contain exactly one value and be at most 2 MiB")
}
return nil return nil
} }
+33 -32
View File
@@ -138,38 +138,39 @@ type NotificationTarget struct {
} }
type Run struct { type Run struct {
SchemaVersion int `json:"schemaVersion"` SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"` ID string `json:"id"`
JobID string `json:"jobId,omitempty"` JobID string `json:"jobId,omitempty"`
TaskType string `json:"taskType"` TaskType string `json:"taskType"`
Status string `json:"status"` Status string `json:"status"`
Priority int `json:"priority"` Priority int `json:"priority"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
StartedAt *time.Time `json:"startedAt,omitempty"` StartedAt *time.Time `json:"startedAt,omitempty"`
FinishedAt *time.Time `json:"finishedAt,omitempty"` FinishedAt *time.Time `json:"finishedAt,omitempty"`
SnapshotID string `json:"snapshotId,omitempty"` SnapshotID string `json:"snapshotId,omitempty"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
ErrorCode string `json:"errorCode,omitempty"` ErrorCode string `json:"errorCode,omitempty"`
BytesAdded int64 `json:"bytesAdded,omitempty"` BytesAdded int64 `json:"bytesAdded,omitempty"`
FilesNew int64 `json:"filesNew,omitempty"` FilesNew int64 `json:"filesNew,omitempty"`
FilesChanged int64 `json:"filesChanged,omitempty"` FilesChanged int64 `json:"filesChanged,omitempty"`
BytesProcessed int64 `json:"bytesProcessed,omitempty"` BytesProcessed int64 `json:"bytesProcessed,omitempty"`
FilesProcessed int64 `json:"filesProcessed,omitempty"` FilesProcessed int64 `json:"filesProcessed,omitempty"`
RetentionBefore int `json:"retentionBefore,omitempty"` RetentionBefore int `json:"retentionBefore,omitempty"`
RetentionAfter int `json:"retentionAfter,omitempty"` RetentionAfter int `json:"retentionAfter,omitempty"`
RetentionRemoved int `json:"retentionRemoved,omitempty"` RetentionRemoved int `json:"retentionRemoved,omitempty"`
RepositoryBefore int64 `json:"repositoryBefore,omitempty"` RepositoryBefore int64 `json:"repositoryBefore,omitempty"`
RepositoryAfter int64 `json:"repositoryAfter,omitempty"` RepositoryAfter int64 `json:"repositoryAfter,omitempty"`
RepositoryFreed int64 `json:"repositoryFreed,omitempty"` RepositoryFreed int64 `json:"repositoryFreed,omitempty"`
ProgressPercent float64 `json:"progressPercent,omitempty"` ProgressPercent float64 `json:"progressPercent,omitempty"`
ProgressBytes int64 `json:"progressBytes,omitempty"` ProgressBytes int64 `json:"progressBytes,omitempty"`
ProgressTotal int64 `json:"progressTotal,omitempty"` ProgressTotal int64 `json:"progressTotal,omitempty"`
ProgressFiles int64 `json:"progressFiles,omitempty"` ProgressFiles int64 `json:"progressFiles,omitempty"`
ProgressFileTotal int64 `json:"progressFileTotal,omitempty"` ProgressFileTotal int64 `json:"progressFileTotal,omitempty"`
BytesPerSecond float64 `json:"bytesPerSecond,omitempty"` BytesPerSecond float64 `json:"bytesPerSecond,omitempty"`
SecondsRemaining int64 `json:"secondsRemaining,omitempty"` SecondsRemaining int64 `json:"secondsRemaining,omitempty"`
CurrentFile string `json:"currentFile,omitempty"` CurrentFile string `json:"currentFile,omitempty"`
LiveLog []string `json:"liveLog,omitempty"` LiveLog []string `json:"liveLog,omitempty"`
Restore *RestoreTask `json:"restore,omitempty"`
} }
type RestoreTask struct { type RestoreTask struct {
+70 -4
View File
@@ -10,12 +10,15 @@ import (
) )
var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`) var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
var snapshotPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}$`)
var safePathPattern = regexp.MustCompile(`^/[a-zA-Z0-9_./-]+$`)
func ValidateConfig(c Config) error { func ValidateConfig(c Config) error {
if c.SchemaVersion != SchemaVersion { if c.SchemaVersion != SchemaVersion {
return fmt.Errorf("unsupported schemaVersion %d", c.SchemaVersion) return fmt.Errorf("unsupported schemaVersion %d", c.SchemaVersion)
} }
repos := make(map[string]Repository, len(c.Repositories)) repos := make(map[string]Repository, len(c.Repositories))
mountPoints := make(map[string]string)
for _, repo := range c.Repositories { for _, repo := range c.Repositories {
if err := ValidateRepository(repo); err != nil { if err := ValidateRepository(repo); err != nil {
return fmt.Errorf("repository %q: %w", repo.ID, err) return fmt.Errorf("repository %q: %w", repo.ID, err)
@@ -24,6 +27,13 @@ func ValidateConfig(c Config) error {
return fmt.Errorf("duplicate repository id %q", repo.ID) return fmt.Errorf("duplicate repository id %q", repo.ID)
} }
repos[repo.ID] = repo repos[repo.ID] = repo
if repo.Mount != nil && repo.Mount.Managed {
point := filepath.Clean(repo.Mount.MountPoint)
if previous, exists := mountPoints[point]; exists {
return fmt.Errorf("repositories %q and %q use the same managed mountPoint %q", previous, repo.ID, point)
}
mountPoints[point] = repo.ID
}
} }
jobs := make(map[string]struct{}, len(c.Jobs)) jobs := make(map[string]struct{}, len(c.Jobs))
for _, job := range c.Jobs { for _, job := range c.Jobs {
@@ -50,8 +60,52 @@ func ValidateConfig(c Config) error {
} }
notifications[target.ID] = struct{}{} notifications[target.ID] = struct{}{}
} }
if !filepath.IsAbs(c.Settings.RestoreRoot) { restoreRoot := filepath.Clean(c.Settings.RestoreRoot)
return errors.New("restoreRoot must be absolute") if !filepath.IsAbs(restoreRoot) || !belowStorageRoot(restoreRoot) {
return errors.New("restoreRoot must be below /mnt/user, /mnt/disks, or /mnt/remotes")
}
if strings.TrimSpace(c.Settings.ResticPath) == "" || !filepath.IsAbs(c.Settings.ResticPath) {
return errors.New("resticPath must be an absolute path")
}
if strings.TrimSpace(c.Settings.RsyncPath) == "" || !filepath.IsAbs(c.Settings.RsyncPath) {
return errors.New("rsyncPath must be an absolute path")
}
if c.Settings.CatchUpWindowHrs < 0 || c.Settings.CatchUpWindowHrs > 24*31 {
return errors.New("catchUpWindowHours must be between 0 and 744")
}
if c.Settings.PersistentLogDir != "" {
logDir := filepath.Clean(c.Settings.PersistentLogDir)
if !filepath.IsAbs(logDir) || (!pathWithin(logDir, "/mnt/user") && !pathWithin(logDir, "/mnt/disks") && !pathWithin(logDir, "/mnt/remotes")) {
return errors.New("persistentLogDir must be below /mnt/user, /mnt/disks, or /mnt/remotes")
}
}
return nil
}
func ValidateRestoreTask(task RestoreTask) error {
if !idPattern.MatchString(task.RepositoryID) {
return errors.New("restore repositoryId is invalid")
}
if err := ValidateSnapshotID(task.SnapshotID); err != nil {
return err
}
if strings.ContainsRune(task.Target, '\x00') {
return errors.New("restore target contains a forbidden control character")
}
if task.InPlace && len(task.Includes) == 0 {
return errors.New("in-place restore requires at least one included path")
}
for _, include := range task.Includes {
if strings.ContainsAny(include, "\x00\r\n") || !filepath.IsAbs(include) || filepath.Clean(include) == "/" {
return fmt.Errorf("invalid restore include %q", include)
}
}
return nil
}
func ValidateSnapshotID(value string) error {
if !snapshotPattern.MatchString(value) {
return errors.New("restore snapshotId is invalid")
} }
return nil return nil
} }
@@ -178,6 +232,15 @@ func pathWithin(path, root string) bool {
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
} }
func belowStorageRoot(path string) bool {
for _, root := range []string{"/mnt/user", "/mnt/disks", "/mnt/remotes"} {
if filepath.Clean(path) != root && pathWithin(path, root) {
return true
}
}
return false
}
func ValidateRepository(r Repository) error { func ValidateRepository(r Repository) error {
if r.SchemaVersion != SchemaVersion || !idPattern.MatchString(r.ID) { if r.SchemaVersion != SchemaVersion || !idPattern.MatchString(r.ID) {
return errors.New("invalid schemaVersion or id") return errors.New("invalid schemaVersion or id")
@@ -200,11 +263,14 @@ func ValidateRepository(r Repository) error {
if r.Mount.Remote == "" || !filepath.IsAbs(r.Mount.MountPoint) { if r.Mount.Remote == "" || !filepath.IsAbs(r.Mount.MountPoint) {
return errors.New("managed mount requires remote and absolute mountPoint") return errors.New("managed mount requires remote and absolute mountPoint")
} }
if strings.HasPrefix(r.Mount.Remote, "-") || strings.ContainsAny(r.Mount.Remote, "\x00\r\n") {
return errors.New("managed mount remote contains forbidden characters")
}
} }
if r.Type == RepositorySFTP && r.CredentialRef != "" { if r.Type == RepositorySFTP && r.CredentialRef != "" {
knownHosts := r.Options["knownHostsPath"] knownHosts := r.Options["knownHostsPath"]
if !filepath.IsAbs(knownHosts) || strings.ContainsAny(knownHosts, " \t\r\n") { if !safePathPattern.MatchString(knownHosts) || filepath.Clean(knownHosts) != knownHosts {
return errors.New("SFTP key authentication requires an absolute knownHostsPath without whitespace") return errors.New("SFTP key authentication requires a clean absolute knownHostsPath containing only letters, digits, dot, underscore, slash, and hyphen")
} }
} }
return nil return nil
+36
View File
@@ -118,3 +118,39 @@ func TestRsyncJobRejectsOverlappingPaths(t *testing.T) {
t.Fatal("rsync target inside source accepted") t.Fatal("rsync target inside source accepted")
} }
} }
func TestValidateRestoreTaskRejectsDelimiterInjection(t *testing.T) {
task := RestoreTask{RepositoryID: "repo", SnapshotID: "abc123\x00/", Target: "/mnt/user/restore", Includes: []string{"/mnt/user/data"}}
if err := ValidateRestoreTask(task); err == nil {
t.Fatal("NUL-delimited snapshot injection accepted")
}
task.SnapshotID = "abc123"
task.InPlace = true
task.Includes = nil
if err := ValidateRestoreTask(task); err == nil {
t.Fatal("in-place restore without an include accepted")
}
}
func TestValidateConfigRejectsDuplicateManagedMountPoint(t *testing.T) {
c := DefaultConfig()
c.Repositories = []Repository{
{SchemaVersion: 1, ID: "one", Name: "One", Type: RepositorySMB, Location: "/mnt/remotes/shared", PasswordRef: "one-password", Mount: &MountConfig{Managed: true, Remote: "//one/share", MountPoint: "/mnt/remotes/shared"}},
{SchemaVersion: 1, ID: "two", Name: "Two", Type: RepositoryNFS, Location: "/mnt/remotes/shared", PasswordRef: "two-password", Mount: &MountConfig{Managed: true, Remote: "two:/share", MountPoint: "/mnt/remotes/shared"}},
}
if err := ValidateConfig(c); err == nil {
t.Fatal("duplicate managed mount point accepted")
}
}
func TestValidateConfigRejectsUnsafeRestoreRoot(t *testing.T) {
c := DefaultConfig()
c.Settings.RestoreRoot = "/etc/urbm-restores"
if err := ValidateConfig(c); err == nil {
t.Fatal("restoreRoot outside Unraid storage roots accepted")
}
c.Settings.RestoreRoot = "/mnt/user/urbm-restores"
if err := ValidateConfig(c); err != nil {
t.Fatalf("safe restoreRoot rejected: %v", err)
}
}
+9
View File
@@ -8,6 +8,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"git.casaderoll.de/michael/urbm/internal/model" "git.casaderoll.de/michael/urbm/internal/model"
) )
@@ -66,13 +67,16 @@ func (m *MountManager) Prepare(ctx context.Context, repo model.Repository) (Moun
result.CredentialFile = f.Name() result.CredentialFile = f.Name()
if err := f.Chmod(0600); err != nil { if err := f.Chmod(0600); err != nil {
f.Close() f.Close()
os.Remove(result.CredentialFile)
return result, err return result, err
} }
if _, err := f.WriteString(credential); err != nil { if _, err := f.WriteString(credential); err != nil {
f.Close() f.Close()
os.Remove(result.CredentialFile)
return result, err return result, err
} }
if err := f.Close(); err != nil { if err := f.Close(); err != nil {
os.Remove(result.CredentialFile)
return result, err return result, err
} }
options = append(options, "credentials="+result.CredentialFile) options = append(options, "credentials="+result.CredentialFile)
@@ -97,5 +101,10 @@ func (m *MountManager) Cleanup(ctx context.Context, mounted MountedRepository) e
if !mounted.Mounted { if !mounted.Mounted {
return nil return nil
} }
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 5*time.Second)
defer cancel()
}
return exec.CommandContext(ctx, "umount", mounted.Repository.Location).Run() return exec.CommandContext(ctx, "umount", mounted.Repository.Location).Run()
} }
+42 -9
View File
@@ -5,9 +5,42 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"git.casaderoll.de/michael/urbm/internal/model"
) )
func ValidateRsyncPaths(job model.Job) error {
target, err := filepath.EvalSymlinks(job.Rsync.Target)
if err != nil {
return errors.New("validation: resolve rsync target: " + err.Error())
}
for _, source := range job.Sources {
resolved, err := filepath.EvalSymlinks(source.Path)
if err != nil {
return errors.New("validation: resolve rsync source: " + err.Error())
}
if pathsContainEachOther(resolved, target) {
return errors.New("validation: resolved rsync source and target must not contain each other")
}
}
return nil
}
func pathsContainEachOther(first, second string) bool {
within := func(path, root string) bool {
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
return within(first, second) || within(second, first)
}
func ValidateRestoreTarget(target, restoreRoot string, inPlace, confirmed bool) (string, error) { func ValidateRestoreTarget(target, restoreRoot string, inPlace, confirmed bool) (string, error) {
if inPlace {
if !confirmed {
return "", errors.New("validation: in-place restore requires explicit confirmation")
}
return string(os.PathSeparator), nil
}
if !filepath.IsAbs(target) { if !filepath.IsAbs(target) {
return "", errors.New("validation: restore target must be absolute") return "", errors.New("validation: restore target must be absolute")
} }
@@ -19,20 +52,13 @@ func ValidateRestoreTarget(target, restoreRoot string, inPlace, confirmed bool)
if err := validateStagingRestoreTarget(clean, restoreRoot); err != nil { if err := validateStagingRestoreTarget(clean, restoreRoot); err != nil {
return "", err return "", err
} }
} else if !confirmed {
return "", errors.New("validation: in-place restore requires explicit confirmation")
}
if inPlace {
anchor := string(os.PathSeparator) + strings.Split(strings.TrimPrefix(clean, string(os.PathSeparator)), string(os.PathSeparator))[0]
if err := rejectSymlinks(anchor, clean); err != nil {
return "", err
}
} }
return clean, nil return clean, nil
} }
func validateStagingRestoreTarget(target, restoreRoot string) error { func validateStagingRestoreTarget(target, restoreRoot string) error {
allowedRoots := []string{filepath.Clean(restoreRoot), "/mnt/user", "/mnt/disks", "/mnt/remotes"} _ = restoreRoot // Configuration validation keeps this below one of the storage roots.
allowedRoots := []string{"/mnt/user", "/mnt/disks", "/mnt/remotes"}
for _, root := range allowedRoots { for _, root := range allowedRoots {
if root == "." || root == "" { if root == "." || root == "" {
continue continue
@@ -45,6 +71,13 @@ func validateStagingRestoreTarget(target, restoreRoot string) error {
} }
func rejectSymlinks(root, target string) error { func rejectSymlinks(root, target string) error {
if info, err := os.Lstat(root); err == nil {
if info.Mode()&os.ModeSymlink != 0 {
return errors.New("validation: restore target traverses a symlink")
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
relative, err := filepath.Rel(root, target) relative, err := filepath.Rel(root, target)
if err != nil { if err != nil {
return err return err
+37 -4
View File
@@ -4,10 +4,12 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"git.casaderoll.de/michael/urbm/internal/model"
) )
func TestValidateRestoreTarget(t *testing.T) { func TestValidateRestoreTarget(t *testing.T) {
root := t.TempDir() root := "/mnt/user/urbm-restores"
target := filepath.Join(root, "task") target := filepath.Join(root, "task")
if got, err := ValidateRestoreTarget(target, root, false, false); err != nil || got != target { if got, err := ValidateRestoreTarget(target, root, false, false); err != nil || got != target {
t.Fatalf("staging target rejected: %q %v", got, err) t.Fatalf("staging target rejected: %q %v", got, err)
@@ -18,14 +20,30 @@ func TestValidateRestoreTarget(t *testing.T) {
if _, err := ValidateRestoreTarget("/home/root/restore", root, false, false); err == nil { if _, err := ValidateRestoreTarget("/home/root/restore", root, false, false); err == nil {
t.Fatal("staging target outside allowed restore roots accepted") t.Fatal("staging target outside allowed restore roots accepted")
} }
if _, err := ValidateRestoreTarget("/etc", root, true, true); err == nil { if got, err := ValidateRestoreTarget("/mnt/user/data", root, true, true); err != nil || got != "/" {
t.Fatal("protected target accepted") t.Fatalf("confirmed in-place target = %q, %v", got, err)
} }
if _, err := ValidateRestoreTarget("/mnt/user/data", root, true, false); err == nil { if _, err := ValidateRestoreTarget("/mnt/user/data", root, true, false); err == nil {
t.Fatal("unconfirmed in-place restore accepted") t.Fatal("unconfirmed in-place restore accepted")
} }
} }
func TestValidateRsyncPathsRejectsSymlinkOverlap(t *testing.T) {
root := t.TempDir()
source := filepath.Join(root, "source")
if err := os.Mkdir(source, 0755); err != nil {
t.Fatal(err)
}
target := filepath.Join(root, "target-link")
if err := os.Symlink(source, target); err != nil {
t.Fatal(err)
}
job := model.Job{Sources: []model.Source{{Path: source}}, Rsync: model.RsyncOptions{Target: target}}
if err := ValidateRsyncPaths(job); err == nil {
t.Fatal("symlinked rsync target overlapping the source was accepted")
}
}
func TestValidateRestoreTargetRejectsSymlink(t *testing.T) { func TestValidateRestoreTargetRejectsSymlink(t *testing.T) {
root := t.TempDir() root := t.TempDir()
real := filepath.Join(root, "real") real := filepath.Join(root, "real")
@@ -36,7 +54,22 @@ func TestValidateRestoreTargetRejectsSymlink(t *testing.T) {
if err := os.Symlink(real, link); err != nil { if err := os.Symlink(real, link); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := ValidateRestoreTarget(filepath.Join(link, "task"), root, false, false); err == nil { if err := rejectSymlinks(root, filepath.Join(link, "task")); err == nil {
t.Fatal("symlink traversal accepted") t.Fatal("symlink traversal accepted")
} }
} }
func TestRejectSymlinksChecksRootItself(t *testing.T) {
base := t.TempDir()
real := filepath.Join(base, "real")
link := filepath.Join(base, "root-link")
if err := os.Mkdir(real, 0755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(real, link); err != nil {
t.Fatal(err)
}
if err := rejectSymlinks(link, filepath.Join(link, "task")); err == nil {
t.Fatal("symlinked restore root accepted")
}
}
+5
View File
@@ -203,6 +203,11 @@ func (w *WorkloadManager) FlashDevice(ctx context.Context) (string, error) {
} }
func (w *WorkloadManager) Cleanup(ctx context.Context, prepared Prepared) error { func (w *WorkloadManager) Cleanup(ctx context.Context, prepared Prepared) error {
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 5*time.Second)
defer cancel()
}
var first error var first error
for i := len(prepared.Stopped) - 1; i >= 0; i-- { for i := len(prepared.Stopped) - 1; i >= 0; i-- {
source := prepared.Stopped[i] source := prepared.Stopped[i]
+13 -1
View File
@@ -25,10 +25,12 @@ type Queue struct {
stopping bool stopping bool
handler Handler handler Handler
onChange OnChange onChange OnChange
done chan struct{}
doneOnce sync.Once
} }
func New(handler Handler, onChange OnChange) *Queue { func New(handler Handler, onChange OnChange) *Queue {
q := &Queue{handler: handler, onChange: onChange} q := &Queue{handler: handler, onChange: onChange, done: make(chan struct{})}
q.cond = sync.NewCond(&q.mu) q.cond = sync.NewCond(&q.mu)
return q return q
} }
@@ -71,6 +73,7 @@ func (q *Queue) Enqueue(run model.Run) error {
} }
func (q *Queue) Run(ctx context.Context) { func (q *Queue) Run(ctx context.Context) {
defer q.doneOnce.Do(func() { close(q.done) })
for { for {
q.mu.Lock() q.mu.Lock()
for len(q.pending) == 0 && !q.stopping { for len(q.pending) == 0 && !q.stopping {
@@ -104,6 +107,15 @@ func (q *Queue) Run(ctx context.Context) {
} }
} }
func (q *Queue) Wait(ctx context.Context) error {
select {
case <-q.done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (q *Queue) Cancel(id string) bool { func (q *Queue) Cancel(id string) bool {
q.mu.Lock() q.mu.Lock()
defer q.mu.Unlock() defer q.mu.Unlock()
+30
View File
@@ -90,3 +90,33 @@ func TestPauseAndResumeActiveRun(t *testing.T) {
} }
close(release) close(release)
} }
func TestWaitDoesNotReturnBeforeCancelledHandlerCleanup(t *testing.T) {
started := make(chan struct{})
cleanup := make(chan struct{})
q := New(func(ctx context.Context, run model.Run) model.Run {
close(started)
<-ctx.Done()
close(cleanup)
run.Status = "cancelled"
return run
}, nil)
ctx, cancel := context.WithCancel(context.Background())
go q.Run(ctx)
if err := q.Enqueue(model.Run{ID: "run", JobID: "job"}); err != nil {
t.Fatal(err)
}
<-started
q.Stop()
waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Second)
defer waitCancel()
if err := q.Wait(waitCtx); err != nil {
t.Fatal(err)
}
select {
case <-cleanup:
default:
t.Fatal("queue wait returned before handler cleanup")
}
cancel()
}
+73 -31
View File
@@ -30,6 +30,18 @@ type Runner struct {
processes map[string]*os.Process processes map[string]*os.Process
} }
func (r *Runner) SetBinary(path string) {
r.mu.Lock()
r.Binary = path
r.mu.Unlock()
}
func (r *Runner) binary() string {
r.mu.Lock()
defer r.mu.Unlock()
return r.Binary
}
type runIDKey struct{} type runIDKey struct{}
func WithRunID(ctx context.Context, runID string) context.Context { func WithRunID(ctx context.Context, runID string) context.Context {
@@ -270,28 +282,32 @@ func decodeStats(output []byte) (Stats, error) {
} }
func (r *Runner) List(ctx context.Context, repo model.Repository, snapshot, path string) ([]map[string]any, error) { func (r *Runner) List(ctx context.Context, repo model.Repository, snapshot, path string) ([]map[string]any, error) {
items := []map[string]any{}
err := r.WalkList(ctx, repo, snapshot, path, func(item map[string]any) { items = append(items, item) })
return items, err
}
func (r *Runner) WalkList(ctx context.Context, repo model.Repository, snapshot, path string, onItem func(map[string]any)) error {
args := []string{"ls", snapshot, "--json"} args := []string{"ls", snapshot, "--json"}
if path != "" { if path != "" {
args = append(args, path) args = append(args, path)
} }
items := []map[string]any{} return r.run(ctx, repo, args, func(line []byte) {
if err := r.run(ctx, repo, args, func(line []byte) {
var item map[string]any var item map[string]any
if json.Unmarshal(line, &item) == nil && item["struct_type"] == "node" { if json.Unmarshal(line, &item) == nil && item["struct_type"] == "node" {
items = append(items, item) onItem(item)
} }
}, nil); err != nil { }, nil)
return nil, err
}
return items, nil
} }
func (r *Runner) Diff(ctx context.Context, repo model.Repository, before, after string, limit int) (DiffResult, error) { func (r *Runner) Diff(ctx context.Context, repo model.Repository, before, after string, limit int) (DiffResult, error) {
var output []byte result := DiffResult{}
if err := r.run(ctx, repo, []string{"diff", "--metadata", before, after}, nil, &output); err != nil { if err := r.run(ctx, repo, []string{"diff", "--metadata", before, after}, func(line []byte) {
appendDiffLine(&result, string(line), limit)
}, nil); err != nil {
return DiffResult{}, err return DiffResult{}, err
} }
return parseDiffOutput(output, limit), nil return result, nil
} }
func parseDiffOutput(output []byte, limit int) DiffResult { func parseDiffOutput(output []byte, limit int) DiffResult {
@@ -302,29 +318,35 @@ func parseDiffOutput(output []byte, limit int) DiffResult {
scanner := bufio.NewScanner(bytes.NewReader(output)) scanner := bufio.NewScanner(bytes.NewReader(output))
scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024) scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() appendDiffLine(&result, scanner.Text(), limit)
if len(line) < 2 {
continue
}
kind := diffKind(line[0])
if kind == "" {
continue
}
path := strings.TrimSpace(line[1:])
if path == "" || !strings.HasPrefix(path, "/") {
continue
}
result.Total++
if len(result.Entries) == limit {
copy(result.Entries, result.Entries[1:])
result.Entries[len(result.Entries)-1] = DiffEntry{Kind: kind, Path: path}
continue
}
result.Entries = append(result.Entries, DiffEntry{Kind: kind, Path: path})
} }
return result return result
} }
func appendDiffLine(result *DiffResult, line string, limit int) {
if limit <= 0 {
limit = 50
}
if len(line) < 2 {
return
}
kind := diffKind(line[0])
if kind == "" {
return
}
path := strings.TrimSpace(line[1:])
if path == "" || !strings.HasPrefix(path, "/") {
return
}
result.Total++
if len(result.Entries) == limit {
copy(result.Entries, result.Entries[1:])
result.Entries[len(result.Entries)-1] = DiffEntry{Kind: kind, Path: path}
return
}
result.Entries = append(result.Entries, DiffEntry{Kind: kind, Path: path})
}
func diffKind(marker byte) string { func diffKind(marker byte) string {
switch marker { switch marker {
case '+': case '+':
@@ -404,7 +426,7 @@ func (r *Runner) runWithInputEnvPassword(ctx context.Context, repo model.Reposit
globalArgs = append(globalArgs, "-o", "sftp.command="+command) globalArgs = append(globalArgs, "-o", "sftp.command="+command)
} }
fullArgs := append(globalArgs, args...) fullArgs := append(globalArgs, args...)
cmd := exec.CommandContext(ctx, r.Binary, fullArgs...) cmd := exec.CommandContext(ctx, r.binary(), fullArgs...)
cmd.Stdin = input cmd.Stdin = input
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error { cmd.Cancel = func() error {
@@ -451,9 +473,11 @@ func (r *Runner) runWithInputEnvPassword(ctx context.Context, repo model.Reposit
} }
close(done) close(done)
}() }()
errBytes, _ := io.ReadAll(io.LimitReader(stderr, 1024*1024)) stderrDone := make(chan []byte, 1)
go func() { stderrDone <- drainLimited(stderr, 1024*1024) }()
err = cmd.Wait() err = cmd.Wait()
<-done <-done
errBytes := <-stderrDone
if capture != nil { if capture != nil {
*capture = captured *capture = captured
} }
@@ -470,6 +494,24 @@ func (r *Runner) runWithInputEnvPassword(ctx context.Context, repo model.Reposit
return nil return nil
} }
func drainLimited(reader io.Reader, limit int) []byte {
result := make([]byte, 0, limit)
buffer := make([]byte, 32*1024)
for {
count, err := reader.Read(buffer)
if count > 0 && len(result) < limit {
keep := count
if keep > limit-len(result) {
keep = limit - len(result)
}
result = append(result, buffer[:keep]...)
}
if err != nil {
return result
}
}
}
func (r *Runner) writePasswordFile(pattern, password string) (string, func(), error) { func (r *Runner) writePasswordFile(pattern, password string) (string, func(), error) {
secretDir := filepath.Join(r.RuntimeDir, "secrets") secretDir := filepath.Join(r.RuntimeDir, "secrets")
if err := os.MkdirAll(secretDir, 0700); err != nil { if err := os.MkdirAll(secretDir, 0700); err != nil {
+38 -4
View File
@@ -23,6 +23,18 @@ type Runner struct {
processes map[string]*os.Process processes map[string]*os.Process
} }
func (r *Runner) SetBinary(path string) {
r.mu.Lock()
r.Binary = path
r.mu.Unlock()
}
func (r *Runner) binary() string {
r.mu.Lock()
defer r.mu.Unlock()
return r.Binary
}
type Progress struct { type Progress struct {
Percent float64 Percent float64
Bytes int64 Bytes int64
@@ -43,7 +55,7 @@ var (
func (r *Runner) Run(ctx context.Context, runID string, job model.Job, callback func(Progress)) (Summary, error) { func (r *Runner) Run(ctx context.Context, runID string, job model.Job, callback func(Progress)) (Summary, error) {
args := Arguments(job) args := Arguments(job)
cmd := exec.CommandContext(ctx, r.Binary, args...) cmd := exec.CommandContext(ctx, r.binary(), args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C") cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error { cmd.Cancel = func() error {
@@ -92,9 +104,11 @@ func (r *Runner) Run(ctx context.Context, runID string, job model.Job, callback
}) })
close(done) close(done)
}() }()
errBytes, _ := io.ReadAll(io.LimitReader(stderr, 1024*1024)) stderrDone := make(chan []byte, 1)
go func() { stderrDone <- drainLimited(stderr, 1024*1024) }()
err = cmd.Wait() err = cmd.Wait()
<-done <-done
errBytes := <-stderrDone
if err != nil { if err != nil {
if errors.Is(ctx.Err(), context.Canceled) { if errors.Is(ctx.Err(), context.Canceled) {
return Summary{}, fmt.Errorf("cancelled: %w", ctx.Err()) return Summary{}, fmt.Errorf("cancelled: %w", ctx.Err())
@@ -117,7 +131,7 @@ func (r *Runner) Estimate(ctx context.Context, runID string, job model.Job) (Sum
args[index] = "--out-format=" args[index] = "--out-format="
} }
} }
cmd := exec.CommandContext(ctx, r.Binary, args...) cmd := exec.CommandContext(ctx, r.binary(), args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C") cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error { cmd.Cancel = func() error {
@@ -158,9 +172,11 @@ func (r *Runner) Estimate(ctx context.Context, runID string, job model.Job) (Sum
}) })
close(done) close(done)
}() }()
errBytes, _ := io.ReadAll(io.LimitReader(stderr, 1024*1024)) stderrDone := make(chan []byte, 1)
go func() { stderrDone <- drainLimited(stderr, 1024*1024) }()
err = cmd.Wait() err = cmd.Wait()
<-done <-done
errBytes := <-stderrDone
if err != nil { if err != nil {
if errors.Is(ctx.Err(), context.Canceled) { if errors.Is(ctx.Err(), context.Canceled) {
return Summary{}, fmt.Errorf("cancelled: %w", ctx.Err()) return Summary{}, fmt.Errorf("cancelled: %w", ctx.Err())
@@ -258,6 +274,24 @@ func scanOutput(reader io.Reader, callback func(string)) {
} }
} }
func drainLimited(reader io.Reader, limit int) []byte {
result := make([]byte, 0, limit)
buffer := make([]byte, 32*1024)
for {
count, err := reader.Read(buffer)
if count > 0 && len(result) < limit {
keep := count
if keep > limit-len(result) {
keep = limit - len(result)
}
result = append(result, buffer[:keep]...)
}
if err != nil {
return result
}
}
}
func splitLinesAndCarriageReturns(data []byte, atEOF bool) (advance int, token []byte, err error) { func splitLinesAndCarriageReturns(data []byte, atEOF bool) (advance int, token []byte, err error) {
for i, value := range data { for i, value := range data {
if value == '\n' || value == '\r' { if value == '\n' || value == '\r' {
+14 -2
View File
@@ -7,7 +7,10 @@ import (
"time" "time"
) )
type Expression struct{ fields [5]field } type Expression struct {
fields [5]field
restricted [5]bool
}
type field map[int]bool type field map[int]bool
var bounds = [5][2]int{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 6}} var bounds = [5][2]int{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 6}}
@@ -24,6 +27,7 @@ func Parse(spec string) (Expression, error) {
return Expression{}, fmt.Errorf("cron field %d: %w", i+1, err) return Expression{}, fmt.Errorf("cron field %d: %w", i+1, err)
} }
expression.fields[i] = parsed expression.fields[i] = parsed
expression.restricted[i] = part != "*"
} }
return expression, nil return expression, nil
} }
@@ -76,11 +80,19 @@ func parseField(value string, min, max int) (field, error) {
func (e Expression) Matches(t time.Time) bool { func (e Expression) Matches(t time.Time) bool {
values := [5]int{t.Minute(), t.Hour(), t.Day(), int(t.Month()), int(t.Weekday())} values := [5]int{t.Minute(), t.Hour(), t.Day(), int(t.Month()), int(t.Weekday())}
for i, value := range values { for i, value := range values {
if i == 2 || i == 4 {
continue
}
if !e.fields[i][value] { if !e.fields[i][value] {
return false return false
} }
} }
return true dayOfMonth := e.fields[2][values[2]]
dayOfWeek := e.fields[4][values[4]]
if e.restricted[2] && e.restricted[4] {
return dayOfMonth || dayOfWeek
}
return dayOfMonth && dayOfWeek
} }
func (e Expression) Next(after time.Time) time.Time { func (e Expression) Next(after time.Time) time.Time {
+10
View File
@@ -43,3 +43,13 @@ func TestPreviousWithinCatchUpWindow(t *testing.T) {
t.Fatalf("unexpected catch-up match %v", got) t.Fatalf("unexpected catch-up match %v", got)
} }
} }
func TestCronUsesOrForRestrictedMonthDayAndWeekday(t *testing.T) {
expr, err := Parse("0 2 1 * 1")
if err != nil {
t.Fatal(err)
}
if !expr.Matches(time.Date(2026, 6, 8, 2, 0, 0, 0, time.UTC)) {
t.Fatal("restricted day-of-month and weekday were treated as AND")
}
}
+67
View File
@@ -0,0 +1,67 @@
package service
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"git.casaderoll.de/michael/urbm/internal/model"
)
func TestRepositoryLockHonorsContextCancellation(t *testing.T) {
s := &Service{repositoryLocks: map[string]chan struct{}{}}
repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo"}
unlock, err := s.lockRepository(context.Background(), repo)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if _, err := s.lockRepository(ctx, repo); err == nil {
t.Fatal("concurrent repository access ignored the request deadline")
}
unlock()
}
func TestPersistentRunLogsAreWrittenAndPruned(t *testing.T) {
dir := t.TempDir()
config := model.DefaultConfig()
config.Settings.PersistentLogDir = dir
s := &Service{config: config}
finished := time.Now()
run := model.Run{ID: "run-1", FinishedAt: &finished, LiveLog: []string{"first", "second"}}
if err := s.persistRunLogs([]model.Run{run}); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "urbm-run-1.log")
if contents, err := os.ReadFile(path); err != nil || string(contents) != "first\nsecond\n" {
t.Fatalf("persistent log = %q, %v", contents, err)
}
if err := s.persistRunLogs(nil); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("cleared run log still exists: %v", err)
}
}
func TestQueuePersistenceDoesNotBlockWhenWriterIsBusy(t *testing.T) {
s := &Service{persistRuns: make(chan []model.Run, 1)}
s.persistRuns <- []model.Run{{ID: "old"}}
done := make(chan struct{})
go func() {
s.queuePersistence([]model.Run{{ID: "latest"}})
close(done)
}()
select {
case <-done:
case <-time.After(100 * time.Millisecond):
t.Fatal("queue persistence blocked the queue callback")
}
runs := <-s.persistRuns
if len(runs) != 1 || runs[0].ID != "latest" {
t.Fatalf("queued persistence state = %#v", runs)
}
}
+362 -73
View File
@@ -33,19 +33,25 @@ type SecretStore interface {
} }
type Service struct { type Service struct {
store *store.Store store *store.Store
secrets SecretStore secrets SecretStore
restic *restic.Runner restic *restic.Runner
rsync *rsync.Runner rsync *rsync.Runner
mounts *platform.MountManager mounts *platform.MountManager
workloads *platform.WorkloadManager workloads *platform.WorkloadManager
notifier *notify.Sender notifier *notify.Sender
log *slog.Logger log *slog.Logger
queue *queue.Queue queue *queue.Queue
mu sync.RWMutex mu sync.RWMutex
config model.Config config model.Config
snapshotMu sync.Mutex snapshotMu sync.Mutex
snapshotTree map[string]snapshotTreeCache snapshotTree map[string]snapshotTreeCache
repositoryMu sync.Mutex
repositoryLocks map[string]chan struct{}
persistRuns chan []model.Run
persistStop chan struct{}
persistDone chan struct{}
persistStopOnce sync.Once
} }
type SnapshotBrowserNode struct { type SnapshotBrowserNode struct {
@@ -78,27 +84,46 @@ type snapshotTreeNode struct {
children map[string]*snapshotTreeNode children map[string]*snapshotTreeNode
} }
type snapshotTreeBuilder struct {
root *snapshotTreeNode
nodes map[string]*snapshotTreeNode
total int
}
func New(st *store.Store, secrets SecretStore, rr *restic.Runner, rs *rsync.Runner, mounts *platform.MountManager, workloads *platform.WorkloadManager, notifier *notify.Sender, log *slog.Logger) (*Service, error) { func New(st *store.Store, secrets SecretStore, rr *restic.Runner, rs *rsync.Runner, mounts *platform.MountManager, workloads *platform.WorkloadManager, notifier *notify.Sender, log *slog.Logger) (*Service, error) {
config, err := st.LoadConfig() config, err := st.LoadConfig()
if err != nil { if err != nil {
return nil, err return nil, err
} }
s := &Service{store: st, secrets: secrets, restic: rr, rsync: rs, mounts: mounts, workloads: workloads, notifier: notifier, log: log, config: config} s := &Service{
s.queue = queue.New(s.execute, func(runs []model.Run) { store: st, secrets: secrets, restic: rr, rsync: rs, mounts: mounts, workloads: workloads, notifier: notifier, log: log, config: config,
if err := st.SaveRuns(runs); err != nil { repositoryLocks: map[string]chan struct{}{}, persistRuns: make(chan []model.Run, 1), persistStop: make(chan struct{}), persistDone: make(chan struct{}),
log.Error("save run history", "error", err) }
} go s.runPersistence()
}) s.queue = queue.New(s.execute, s.queuePersistence)
if runs, err := st.LoadRuns(); err == nil { if runs, err := st.LoadRuns(); err == nil {
s.queue.RestoreHistory(runs) s.queue.RestoreHistory(runs)
} }
s.queuePersistence(s.queue.Snapshot())
return s, nil return s, nil
} }
func (s *Service) RunQueue(ctx context.Context) { s.queue.Run(ctx) } func (s *Service) RunQueue(ctx context.Context) { s.queue.Run(ctx) }
func (s *Service) Stop() { s.queue.Stop() } func (s *Service) Stop() { s.queue.Stop() }
func (s *Service) Config() model.Config { s.mu.RLock(); defer s.mu.RUnlock(); return s.config } func (s *Service) Wait(ctx context.Context) error {
func (s *Service) Runs() []model.Run { return s.queue.Snapshot() } if err := s.queue.Wait(ctx); err != nil {
return err
}
s.persistStopOnce.Do(func() { close(s.persistStop) })
select {
case <-s.persistDone:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) Config() model.Config { s.mu.RLock(); defer s.mu.RUnlock(); return s.config }
func (s *Service) Runs() []model.Run { return s.queue.Snapshot() }
func (s *Service) Logs() map[string]any { func (s *Service) Logs() map[string]any {
return map[string]any{ return map[string]any{
"daemon": tailFile("/var/log/urbm.log", 1024*1024, 1000), "daemon": tailFile("/var/log/urbm.log", 1024*1024, 1000),
@@ -117,6 +142,123 @@ func runsWithLogs(runs []model.Run, limit int) []model.Run {
return result return result
} }
func (s *Service) queuePersistence(runs []model.Run) {
select {
case s.persistRuns <- runs:
return
default:
}
select {
case <-s.persistRuns:
default:
}
select {
case s.persistRuns <- runs:
default:
}
}
func (s *Service) runPersistence() {
defer close(s.persistDone)
for {
select {
case runs := <-s.persistRuns:
s.saveRunState(runs)
case <-s.persistStop:
var latest []model.Run
for {
select {
case latest = <-s.persistRuns:
default:
if latest != nil {
s.saveRunState(latest)
}
return
}
}
}
}
}
func (s *Service) saveRunState(runs []model.Run) {
if err := s.store.SaveRuns(runs); err != nil {
s.log.Error("save run history", "error", err)
}
if err := s.persistRunLogs(runs); err != nil {
s.log.Error("save persistent run logs", "error", err)
}
}
func (s *Service) persistRunLogs(runs []model.Run) error {
dir := strings.TrimSpace(s.Config().Settings.PersistentLogDir)
if dir == "" {
return nil
}
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
wanted := make(map[string]struct{})
for _, run := range runs {
if run.FinishedAt == nil || len(run.LiveLog) == 0 {
continue
}
name := "urbm-" + safeLogName(run.ID) + ".log"
wanted[name] = struct{}{}
path := filepath.Join(dir, name)
if _, err := os.Stat(path); err == nil {
continue
}
if err := writeTextAtomic(path, strings.Join(run.LiveLog, "\n")+"\n"); err != nil {
return err
}
}
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "urbm-") || !strings.HasSuffix(entry.Name(), ".log") {
continue
}
if _, ok := wanted[entry.Name()]; !ok {
if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
}
return nil
}
func safeLogName(value string) string {
return strings.Map(func(char rune) rune {
if char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '-' || char == '_' {
return char
}
return '_'
}, value)
}
func writeTextAtomic(path, value string) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".urbm-log-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if err := tmp.Chmod(0600); err != nil {
tmp.Close()
return err
}
if _, err := tmp.WriteString(value); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpPath, path)
}
func tailFile(path string, maxBytes int64, maxLines int) map[string]any { func tailFile(path string, maxBytes int64, maxLines int) map[string]any {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -193,8 +335,8 @@ func (s *Service) SaveConfig(config model.Config) error {
s.mu.Lock() s.mu.Lock()
s.config = config s.config = config
s.mu.Unlock() s.mu.Unlock()
s.restic.Binary = config.Settings.ResticPath s.restic.SetBinary(config.Settings.ResticPath)
s.rsync.Binary = config.Settings.RsyncPath s.rsync.SetBinary(config.Settings.RsyncPath)
return nil return nil
} }
@@ -215,6 +357,11 @@ func (s *Service) ChangeRepositoryPassword(ctx context.Context, repoID, newPassw
if !ok { if !ok {
return errors.New("validation: unknown repository") return errors.New("validation: unknown repository")
} }
unlock, err := s.lockRepository(ctx, repo)
if err != nil {
return err
}
defer unlock()
oldPassword, err := s.secrets.Get(repo.PasswordRef) oldPassword, err := s.secrets.Get(repo.PasswordRef)
if err != nil { if err != nil {
return fmt.Errorf("authentication: load current repository password: %w", err) return fmt.Errorf("authentication: load current repository password: %w", err)
@@ -260,6 +407,11 @@ func (s *Service) AdoptRepositoryPassword(ctx context.Context, repoID, password
if !ok { if !ok {
return errors.New("validation: unknown repository") return errors.New("validation: unknown repository")
} }
unlock, err := s.lockRepository(ctx, repo)
if err != nil {
return err
}
defer unlock()
mounted, err := s.mounts.Prepare(ctx, repo) mounted, err := s.mounts.Prepare(ctx, repo)
if err != nil { if err != nil {
return err return err
@@ -313,18 +465,21 @@ func (s *Service) EnqueueMaintenance(repoID, action string) (model.Run, error) {
} }
func (s *Service) EnqueueRestore(task model.RestoreTask) (model.Run, error) { func (s *Service) EnqueueRestore(task model.RestoreTask) (model.Run, error) {
if err := model.ValidateRestoreTask(task); err != nil {
return model.Run{}, fmt.Errorf("validation: %w", err)
}
if _, ok := s.repository(task.RepositoryID); !ok {
return model.Run{}, errors.New("validation: unknown repository")
}
config := s.Config() config := s.Config()
target, err := platform.ValidateRestoreTarget(task.Target, config.Settings.RestoreRoot, task.InPlace, task.Confirmed) target, err := platform.ValidateRestoreTarget(task.Target, config.Settings.RestoreRoot, task.InPlace, task.Confirmed)
if err != nil { if err != nil {
return model.Run{}, err return model.Run{}, err
} }
task.Target = target task.Target = target
if task.ID == "" { task.ID = newID()
task.ID = newID()
}
task.SchemaVersion = model.SchemaVersion task.SchemaVersion = model.SchemaVersion
encoded := strings.Join(append([]string{task.RepositoryID, task.SnapshotID, task.Target, fmt.Sprint(task.InPlace)}, task.Includes...), "\x00") run := model.Run{SchemaVersion: model.SchemaVersion, ID: task.ID, JobID: task.RepositoryID, TaskType: "restore", Priority: 10, CreatedAt: time.Now().UTC(), Restore: &task}
run := model.Run{SchemaVersion: model.SchemaVersion, ID: task.ID, JobID: encoded, TaskType: "restore", Priority: 10, CreatedAt: time.Now().UTC()}
return run, s.queue.Enqueue(run) return run, s.queue.Enqueue(run)
} }
@@ -375,6 +530,11 @@ func (s *Service) Snapshots(ctx context.Context, repoID string) ([]model.Snapsho
if !ok { if !ok {
return nil, errors.New("validation: unknown repository") return nil, errors.New("validation: unknown repository")
} }
unlock, err := s.lockRepository(ctx, repo)
if err != nil {
return nil, err
}
defer unlock()
mounted, err := s.mounts.Prepare(ctx, repo) mounted, err := s.mounts.Prepare(ctx, repo)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -388,6 +548,12 @@ func (s *Service) RepositoryStats(ctx context.Context) []model.RepositoryStats {
result := make([]model.RepositoryStats, 0, len(config.Repositories)) result := make([]model.RepositoryStats, 0, len(config.Repositories))
for _, repo := range config.Repositories { for _, repo := range config.Repositories {
item := model.RepositoryStats{RepositoryID: repo.ID, RepositoryName: repo.Name} item := model.RepositoryStats{RepositoryID: repo.ID, RepositoryName: repo.Name}
unlock, err := s.lockRepository(ctx, repo)
if err != nil {
item.Error = err.Error()
result = append(result, item)
break
}
mounted, err := s.mounts.Prepare(ctx, repo) mounted, err := s.mounts.Prepare(ctx, repo)
if err == nil { if err == nil {
var stored, files restic.Stats var stored, files restic.Stats
@@ -406,6 +572,7 @@ func (s *Service) RepositoryStats(ctx context.Context) []model.RepositoryStats {
err = cleanupErr err = cleanupErr
} }
} }
unlock()
if err != nil { if err != nil {
item.Error = err.Error() item.Error = err.Error()
} }
@@ -420,6 +587,9 @@ func (s *Service) RepositoryStats(ctx context.Context) []model.RepositoryStats {
func (s *Service) SnapshotFiles(ctx context.Context, repoID, snapshot, path string) (SnapshotBrowserPage, error) { func (s *Service) SnapshotFiles(ctx context.Context, repoID, snapshot, path string) (SnapshotBrowserPage, error) {
started := time.Now() started := time.Now()
s.log.Info("snapshot file listing started", "repoID", repoID, "snapshot", snapshot, "path", path) s.log.Info("snapshot file listing started", "repoID", repoID, "snapshot", snapshot, "path", path)
if err := model.ValidateSnapshotID(snapshot); err != nil {
return SnapshotBrowserPage{}, fmt.Errorf("validation: %w", err)
}
repo, ok := s.repository(repoID) repo, ok := s.repository(repoID)
if !ok { if !ok {
return SnapshotBrowserPage{}, errors.New("validation: unknown repository") return SnapshotBrowserPage{}, errors.New("validation: unknown repository")
@@ -431,6 +601,14 @@ func (s *Service) SnapshotFiles(ctx context.Context, repoID, snapshot, path stri
s.log.Info("snapshot file listing loaded from cache", "repoID", repoID, "snapshot", snapshot, "path", normalizedPath, "items", len(page.Items), "total", page.Total, "durationMs", time.Since(started).Milliseconds()) s.log.Info("snapshot file listing loaded from cache", "repoID", repoID, "snapshot", snapshot, "path", normalizedPath, "items", len(page.Items), "total", page.Total, "durationMs", time.Since(started).Milliseconds())
return page, nil return page, nil
} }
unlock, err := s.lockRepository(ctx, repo)
if err != nil {
return SnapshotBrowserPage{}, err
}
defer unlock()
if page, ok := s.snapshotBrowserPage(key, normalizedPath); ok {
return page, nil
}
mounted, err := s.mounts.Prepare(ctx, repo) mounted, err := s.mounts.Prepare(ctx, repo)
if err != nil { if err != nil {
@@ -439,12 +617,24 @@ func (s *Service) SnapshotFiles(ctx context.Context, repoID, snapshot, path stri
} }
defer s.mounts.Cleanup(context.Background(), mounted) defer s.mounts.Cleanup(context.Background(), mounted)
items, err := s.restic.List(ctx, mounted.Repository, snapshot, "") const maxSnapshotEntries = 1_000_000
builder := newSnapshotTreeBuilder()
tooLarge := false
err = s.restic.WalkList(ctx, mounted.Repository, snapshot, "", func(item map[string]any) {
if builder.total >= maxSnapshotEntries {
tooLarge = true
return
}
builder.add(item)
})
if err != nil { if err != nil {
s.log.Warn("snapshot file listing failed", "repoID", repoID, "snapshot", snapshot, "path", normalizedPath, "durationMs", time.Since(started).Milliseconds(), "error", err) s.log.Warn("snapshot file listing failed", "repoID", repoID, "snapshot", snapshot, "path", normalizedPath, "durationMs", time.Since(started).Milliseconds(), "error", err)
return SnapshotBrowserPage{}, err return SnapshotBrowserPage{}, err
} }
cache := buildSnapshotTreeCache(items) if tooLarge {
return SnapshotBrowserPage{}, fmt.Errorf("environment: snapshot contains more than %d entries; narrow the backup or use restic directly", maxSnapshotEntries)
}
cache := builder.cache()
s.storeSnapshotTree(key, cache) s.storeSnapshotTree(key, cache)
page, _ := s.snapshotBrowserPage(key, normalizedPath) page, _ := s.snapshotBrowserPage(key, normalizedPath)
s.log.Info("snapshot file listing loaded", "repoID", repoID, "snapshot", snapshot, "path", normalizedPath, "items", len(page.Items), "total", cache.total, "durationMs", time.Since(started).Milliseconds()) s.log.Info("snapshot file listing loaded", "repoID", repoID, "snapshot", snapshot, "path", normalizedPath, "items", len(page.Items), "total", cache.total, "durationMs", time.Since(started).Milliseconds())
@@ -480,53 +670,67 @@ func (s *Service) storeSnapshotTree(key string, cache snapshotTreeCache) {
delete(s.snapshotTree, cacheKey) delete(s.snapshotTree, cacheKey)
} }
} }
for len(s.snapshotTree) >= 3 {
oldestKey := ""
var oldest time.Time
for cacheKey, value := range s.snapshotTree {
if oldestKey == "" || value.generated.Before(oldest) {
oldestKey, oldest = cacheKey, value.generated
}
}
delete(s.snapshotTree, oldestKey)
}
s.snapshotTree[key] = cache s.snapshotTree[key] = cache
} }
func buildSnapshotTreeCache(items []map[string]any) snapshotTreeCache { func buildSnapshotTreeCache(items []map[string]any) snapshotTreeCache {
root := &snapshotTreeNode{path: "/", name: "/", nodeType: "dir", children: map[string]*snapshotTreeNode{}} builder := newSnapshotTreeBuilder()
nodes := map[string]*snapshotTreeNode{"/": root} for _, item := range items {
ensure := func(path string, nodeType string, size int64) *snapshotTreeNode { builder.add(item)
return root
} }
ensure = func(path string, nodeType string, size int64) *snapshotTreeNode { return builder.cache()
path = normalizeSnapshotPath(path) }
if node, ok := nodes[path]; ok {
if nodeType != "" && nodeType != "dir" { func newSnapshotTreeBuilder() *snapshotTreeBuilder {
node.nodeType = nodeType root := &snapshotTreeNode{path: "/", name: "/", nodeType: "dir", children: map[string]*snapshotTreeNode{}}
node.size = size return &snapshotTreeBuilder{root: root, nodes: map[string]*snapshotTreeNode{"/": root}}
} }
return node
func (b *snapshotTreeBuilder) ensure(path string, nodeType string, size int64) *snapshotTreeNode {
path = normalizeSnapshotPath(path)
if node, ok := b.nodes[path]; ok {
if nodeType != "" && nodeType != "dir" {
node.nodeType, node.size = nodeType, size
} }
parentPath := "/"
if path != "/" {
parentPath = filepath.Dir(path)
if parentPath == "." {
parentPath = "/"
}
}
parent := ensure(parentPath, "dir", 0)
node := &snapshotTreeNode{path: path, name: filepath.Base(path), nodeType: nodeType, size: size, children: map[string]*snapshotTreeNode{}}
if path == "/" {
node.name = "/"
}
nodes[path] = node
parent.children[path] = node
return node return node
} }
for _, item := range items { parentPath := filepath.Dir(path)
path := snapshotItemPath(item) if parentPath == "." {
if path == "/" { parentPath = "/"
continue
}
nodeType := "file"
if value, _ := item["type"].(string); value == "dir" {
nodeType = "dir"
}
ensure(path, nodeType, snapshotItemSize(item))
} }
children := make(map[string][]SnapshotBrowserNode, len(nodes)) parent := b.ensure(parentPath, "dir", 0)
for _, node := range nodes { node := &snapshotTreeNode{path: path, name: filepath.Base(path), nodeType: nodeType, size: size, children: map[string]*snapshotTreeNode{}}
b.nodes[path] = node
parent.children[path] = node
return node
}
func (b *snapshotTreeBuilder) add(item map[string]any) {
path := snapshotItemPath(item)
if path == "/" {
return
}
nodeType := "file"
if value, _ := item["type"].(string); value == "dir" {
nodeType = "dir"
}
b.ensure(path, nodeType, snapshotItemSize(item))
b.total++
}
func (b *snapshotTreeBuilder) cache() snapshotTreeCache {
children := make(map[string][]SnapshotBrowserNode, len(b.nodes))
for _, node := range b.nodes {
if len(node.children) == 0 { if len(node.children) == 0 {
continue continue
} }
@@ -542,7 +746,7 @@ func buildSnapshotTreeCache(items []map[string]any) snapshotTreeCache {
}) })
children[node.path] = list children[node.path] = list
} }
return snapshotTreeCache{generated: time.Now().UTC(), total: len(items), children: children} return snapshotTreeCache{generated: time.Now().UTC(), total: b.total, children: children}
} }
func snapshotItemPath(item map[string]any) string { func snapshotItemPath(item map[string]any) string {
@@ -580,6 +784,11 @@ func (s *Service) TestRepository(ctx context.Context, repoID string, initialize
if !ok { if !ok {
return errors.New("validation: unknown repository") return errors.New("validation: unknown repository")
} }
unlock, err := s.lockRepository(ctx, repo)
if err != nil {
return err
}
defer unlock()
mounted, err := s.mounts.Prepare(ctx, repo) mounted, err := s.mounts.Prepare(ctx, repo)
if err != nil { if err != nil {
return err return err
@@ -630,6 +839,11 @@ func (s *Service) UnlockRepository(ctx context.Context, repoID string) error {
if !ok { if !ok {
return errors.New("validation: unknown repository") return errors.New("validation: unknown repository")
} }
unlock, err := s.lockRepository(ctx, repo)
if err != nil {
return err
}
defer unlock()
mounted, err := s.mounts.Prepare(ctx, repo) mounted, err := s.mounts.Prepare(ctx, repo)
if err != nil { if err != nil {
return err return err
@@ -643,6 +857,14 @@ func (s *Service) ForceUnlockRepository(ctx context.Context, repoID string) erro
if !ok { if !ok {
return errors.New("validation: unknown repository") return errors.New("validation: unknown repository")
} }
if s.repositoryBusy(repoID) {
return errors.New("validation: repository has a queued, running, or paused task")
}
unlock, err := s.lockRepository(ctx, repo)
if err != nil {
return err
}
defer unlock()
mounted, err := s.mounts.Prepare(ctx, repo) mounted, err := s.mounts.Prepare(ctx, repo)
if err != nil { if err != nil {
return err return err
@@ -672,6 +894,11 @@ func (s *Service) execute(ctx context.Context, run model.Run) (result model.Run)
if !ok { if !ok {
return failed(run, "validation", "repository no longer exists") return failed(run, "validation", "repository no longer exists")
} }
unlock, err := s.lockRepository(ctx, repo)
if err != nil {
return failedError(run, err)
}
defer unlock()
maintenanceRepoName = repo.Name maintenanceRepoName = repo.Name
var pruneStatsBefore restic.Stats var pruneStatsBefore restic.Stats
pruneStatsBeforeOK := false pruneStatsBeforeOK := false
@@ -730,6 +957,9 @@ func (s *Service) executeRsync(ctx context.Context, run model.Run) (result model
return failed(run, "validation", "job no longer exists") return failed(run, "validation", "job no longer exists")
} }
defer func() { result = s.finishJob(run, job, result) }() defer func() { result = s.finishJob(run, job, result) }()
if err := platform.ValidateRsyncPaths(job); err != nil {
return failedError(run, err)
}
live := run live := run
appendLiveLog(&live, fmt.Sprintf("Rsync-Kopie wird vorbereitet: %d Quelle(n) nach %s", len(job.Sources), job.Rsync.Target)) appendLiveLog(&live, fmt.Sprintf("Rsync-Kopie wird vorbereitet: %d Quelle(n) nach %s", len(job.Sources), job.Rsync.Target))
s.updateActive(live) s.updateActive(live)
@@ -819,6 +1049,11 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode
if !ok { if !ok {
return failed(run, "validation", "repository no longer exists") return failed(run, "validation", "repository no longer exists")
} }
unlock, err := s.lockRepository(ctx, repo)
if err != nil {
return failedError(run, err)
}
defer unlock()
live := run live := run
appendLiveLog(&live, "Backup wird vorbereitet") appendLiveLog(&live, "Backup wird vorbereitet")
s.updateActive(live) s.updateActive(live)
@@ -1053,15 +1288,28 @@ func (s *Service) executeRestore(ctx context.Context, run model.Run) model.Run {
live := run live := run
appendLiveLog(&live, "Restore wird vorbereitet") appendLiveLog(&live, "Restore wird vorbereitet")
s.updateActive(live) s.updateActive(live)
parts := strings.Split(run.JobID, "\x00") if run.Restore == nil {
if len(parts) < 4 {
return failed(run, "internal", "invalid restore payload") return failed(run, "internal", "invalid restore payload")
} }
repo, ok := s.repository(parts[0]) task := *run.Restore
if err := model.ValidateRestoreTask(task); err != nil {
return failed(run, "validation", err.Error())
}
config := s.Config()
target, err := platform.ValidateRestoreTarget(task.Target, config.Settings.RestoreRoot, task.InPlace, task.Confirmed)
if err != nil {
return failedError(run, err)
}
task.Target = target
repo, ok := s.repository(task.RepositoryID)
if !ok { if !ok {
return failed(run, "validation", "repository no longer exists") return failed(run, "validation", "repository no longer exists")
} }
task := model.RestoreTask{SchemaVersion: model.SchemaVersion, ID: run.ID, RepositoryID: parts[0], SnapshotID: parts[1], Target: parts[2], InPlace: parts[3] == "true", Confirmed: true, Includes: parts[4:]} unlock, err := s.lockRepository(ctx, repo)
if err != nil {
return failedError(run, err)
}
defer unlock()
if err := os.MkdirAll(task.Target, 0750); err != nil { if err := os.MkdirAll(task.Target, 0750); err != nil {
failedRun := failedError(run, err) failedRun := failedError(run, err)
appendLiveLog(&live, "Restore-Ziel konnte nicht vorbereitet werden: "+err.Error()) appendLiveLog(&live, "Restore-Ziel konnte nicht vorbereitet werden: "+err.Error())
@@ -1302,6 +1550,47 @@ func (s *Service) repository(id string) (model.Repository, bool) {
return model.Repository{}, false return model.Repository{}, false
} }
func (s *Service) lockRepository(ctx context.Context, repo model.Repository) (func(), error) {
key := string(repo.Type) + ":" + filepath.Clean(repo.Location)
if repo.Mount != nil && repo.Mount.Managed {
key = "mount:" + filepath.Clean(repo.Mount.MountPoint)
}
s.repositoryMu.Lock()
lock := s.repositoryLocks[key]
if lock == nil {
lock = make(chan struct{}, 1)
s.repositoryLocks[key] = lock
}
s.repositoryMu.Unlock()
select {
case lock <- struct{}{}:
return func() { <-lock }, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (s *Service) repositoryBusy(repoID string) bool {
for _, run := range s.queue.Snapshot() {
if run.Status != "queued" && run.Status != "running" && run.Status != "paused" {
continue
}
if run.TaskType == "restore" && run.Restore != nil && run.Restore.RepositoryID == repoID {
return true
}
if run.TaskType == "check" || run.TaskType == "prune" {
if run.JobID == repoID {
return true
}
continue
}
if job, ok := s.job(run.JobID); ok && job.RepositoryID == repoID {
return true
}
}
return false
}
func failed(run model.Run, code, message string) model.Run { func failed(run model.Run, code, message string) model.Run {
run.Status, run.ErrorCode, run.Message = "failed", code, message run.Status, run.ErrorCode, run.Message = "failed", code, message
return run return run
+5 -3
View File
@@ -2,7 +2,9 @@
set -euo pipefail set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd) ROOT=$(cd "$(dirname "$0")/.." && pwd)
VERSION=${VERSION:-0.1.0} PLUGIN_VERSION=$(sed -n 's/^<!ENTITY version "\([^"]*\)">/\1/p' "$ROOT/plugin/urbm.plg")
VERSION=${VERSION:-$PLUGIN_VERSION}
[ -n "$VERSION" ] || { echo "Could not determine plugin version" >&2; exit 1; }
RESTIC_VERSION=0.19.0 RESTIC_VERSION=0.19.0
RESTIC_SHA256=13176fe6d89d4357947a2cd107218ab2873a5f9d8e1ac2d4cd1c8e07e6839c21 RESTIC_SHA256=13176fe6d89d4357947a2cd107218ab2873a5f9d8e1ac2d4cd1c8e07e6839c21
BUILD="$ROOT/build" BUILD="$ROOT/build"
@@ -28,7 +30,7 @@ fi
bzip2 -dc "$RESTIC_ARCHIVE" > "$STAGE/usr/local/libexec/urbm/restic" bzip2 -dc "$RESTIC_ARCHIVE" > "$STAGE/usr/local/libexec/urbm/restic"
chmod 0755 "$STAGE/usr/local/libexec/urbm/restic" "$STAGE/usr/local/sbin/urbmd" chmod 0755 "$STAGE/usr/local/libexec/urbm/restic" "$STAGE/usr/local/sbin/urbmd"
cp "$ROOT/webgui/URBM.page" "$STAGE/usr/local/emhttp/plugins/urbm/" sed "s/$PLUGIN_VERSION/$VERSION/g" "$ROOT/webgui/URBM.page" > "$STAGE/usr/local/emhttp/plugins/urbm/URBM.page"
cp "$ROOT/webgui/api.php" "$STAGE/usr/local/emhttp/plugins/urbm/" cp "$ROOT/webgui/api.php" "$STAGE/usr/local/emhttp/plugins/urbm/"
cp "$ROOT/webgui/assets/"* "$STAGE/usr/local/emhttp/plugins/urbm/assets/" cp "$ROOT/webgui/assets/"* "$STAGE/usr/local/emhttp/plugins/urbm/assets/"
cp "$ROOT/webgui/images/"* "$STAGE/usr/local/emhttp/plugins/urbm/images/" cp "$ROOT/webgui/images/"* "$STAGE/usr/local/emhttp/plugins/urbm/images/"
@@ -40,5 +42,5 @@ chmod 0755 "$STAGE/etc/rc.d/rc.urbm" "$STAGE/install/doinst.sh"
(cd "$STAGE" && tar --owner=0 --group=0 -cJf "$DIST/urbm-${VERSION}-x86_64-1.txz" .) (cd "$STAGE" && tar --owner=0 --group=0 -cJf "$DIST/urbm-${VERSION}-x86_64-1.txz" .)
(cd "$DIST" && sha256sum "urbm-${VERSION}-x86_64-1.txz" > "urbm-${VERSION}-x86_64-1.txz.sha256") (cd "$DIST" && sha256sum "urbm-${VERSION}-x86_64-1.txz" > "urbm-${VERSION}-x86_64-1.txz.sha256")
PACKAGE_SHA256=$(sha256sum "$DIST/urbm-${VERSION}-x86_64-1.txz" | awk '{print $1}') PACKAGE_SHA256=$(sha256sum "$DIST/urbm-${VERSION}-x86_64-1.txz" | awk '{print $1}')
sed "s/REPLACE_DURING_RELEASE/$PACKAGE_SHA256/" "$ROOT/plugin/urbm.plg" > "$DIST/urbm.plg" sed -e "s/<!ENTITY version \"$PLUGIN_VERSION\">/<!ENTITY version \"$VERSION\">/" -e "s/REPLACE_DURING_RELEASE/$PACKAGE_SHA256/" "$ROOT/plugin/urbm.plg" > "$DIST/urbm.plg"
echo "Built $DIST/urbm-${VERSION}-x86_64-1.txz" echo "Built $DIST/urbm-${VERSION}-x86_64-1.txz"
+24 -9
View File
@@ -4,30 +4,45 @@ DAEMON=/usr/local/sbin/urbmd
PIDFILE=/run/urbm/urbm.pid PIDFILE=/run/urbm/urbm.pid
LOGFILE=/var/log/urbm.log LOGFILE=/var/log/urbm.log
is_daemon_pid() {
PID=${1:-}
[ -n "$PID" ] && [ "$(readlink -f "/proc/$PID/exe" 2>/dev/null)" = "$DAEMON" ]
}
start() { start() {
mkdir -p /run/urbm /var/lib/urbm /boot/config/plugins/urbm mkdir -p /run/urbm /var/lib/urbm /boot/config/plugins/urbm
chmod 0750 /run/urbm /var/lib/urbm chmod 0750 /run/urbm /var/lib/urbm
chmod 0700 /boot/config/plugins/urbm chmod 0700 /boot/config/plugins/urbm
if [ -s "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then if [ -s "$PIDFILE" ] && is_daemon_pid "$(cat "$PIDFILE")"; then
return 0 return 0
fi fi
rm -f "$PIDFILE" /run/urbm/urbm.sock rm -f "$PIDFILE" /run/urbm/urbm.sock
nohup "$DAEMON" >>"$LOGFILE" 2>&1 & nohup "$DAEMON" >>"$LOGFILE" 2>&1 &
echo $! >"$PIDFILE" echo $! >"$PIDFILE"
for _ in $(seq 1 50); do
[ -S /run/urbm/urbm.sock ] && return 0
is_daemon_pid "$(cat "$PIDFILE" 2>/dev/null)" || break
sleep 0.1
done
rm -f "$PIDFILE"
echo "urbmd failed to create its API socket" >&2
return 1
} }
stop() { stop() {
STOPPED=0 STOPPED=0
if [ -s "$PIDFILE" ]; then if [ -s "$PIDFILE" ]; then
PID=$(cat "$PIDFILE") PID=$(cat "$PIDFILE")
kill "$PID" 2>/dev/null || true if is_daemon_pid "$PID"; then
for _ in $(seq 1 30); do kill "$PID" 2>/dev/null || true
kill -0 "$PID" 2>/dev/null || break for _ in $(seq 1 30); do
sleep 1 is_daemon_pid "$PID" || break
done sleep 1
kill -9 "$PID" 2>/dev/null || true done
is_daemon_pid "$PID" && kill -9 "$PID" 2>/dev/null || true
STOPPED=1
fi
rm -f "$PIDFILE" rm -f "$PIDFILE"
STOPPED=1
fi fi
if [ "$STOPPED" -eq 0 ]; then if [ "$STOPPED" -eq 0 ]; then
PIDS=$(pidof urbmd 2>/dev/null || true) PIDS=$(pidof urbmd 2>/dev/null || true)
@@ -50,7 +65,7 @@ case "${1:-}" in
stop) stop ;; stop) stop ;;
restart) stop; start ;; restart) stop; start ;;
status) status)
if [ -s "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then echo "urbmd is running"; else echo "urbmd is stopped"; exit 1; fi if [ -s "$PIDFILE" ] && is_daemon_pid "$(cat "$PIDFILE")"; then echo "urbmd is running"; else echo "urbmd is stopped"; exit 1; fi
;; ;;
*) echo "Usage: $0 {start|stop|restart|status}"; exit 2 ;; *) echo "Usage: $0 {start|stop|restart|status}"; exit 2 ;;
esac esac
+7 -1
View File
@@ -2,13 +2,19 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.07.10.r007"> <!ENTITY version "2026.07.13.r001">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg"> <!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz"> <!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE"> <!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
]> ]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png"> <PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
<CHANGES> <CHANGES>
### 2026.07.13.r001
- Harden restore task validation and make confirmed in-place restores target original paths safely.
- Serialize repository and managed-mount access, block unsafe force-unlock operations, and wait for task cleanup during daemon shutdown.
- Bound snapshot browser memory, stream Restic diffs, continuously drain command output, and reject symlink-overlapping Rsync paths.
- Activate persistent run logs and align package, WebGUI, and manifest versions during builds.
### 2026.07.10.r007 ### 2026.07.10.r007
- Serve snapshot browser folders as compact cached tree pages instead of sending the complete Restic file list to the browser. - Serve snapshot browser folders as compact cached tree pages instead of sending the complete Restic file list to the browser.
- Prevent large snapshots with hundreds of thousands of entries from failing with generic 500 responses during snapshot browsing. - Prevent large snapshots with hundreds of thousands of entries from failing with generic 500 responses during snapshot browsing.
+6 -4
View File
@@ -8,13 +8,15 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
--- ---
<?php <?php
$pluginRoot = '/plugins/urbm'; $pluginRoot = '/plugins/urbm';
$urbmVersion = '2026.07.13.r001';
$urbmAssetVersion = preg_replace('/[^A-Za-z0-9]/', '', $urbmVersion);
?> ?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260710r007"> <link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=<?= $urbmAssetVersion ?>">
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php"> <div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php">
<header class="bu-header"> <header class="bu-header">
<div class="bu-brand"> <div class="bu-brand">
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260710r007" alt="URBM-Logo"> <img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=<?= $urbmAssetVersion ?>" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version">2026.07.10.r007</span></h1><p>Restic Backup Manager für Unraid</p></div> <div><h1>URBM <span class="bu-version"><?= htmlspecialchars($urbmVersion, ENT_QUOTES) ?></span></h1><p>Restic Backup Manager für Unraid</p></div>
</div> </div>
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div> <div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div>
</header> </header>
@@ -33,4 +35,4 @@ $pluginRoot = '/plugins/urbm';
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div> <div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
</div> </div>
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script> <script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
<script src="<?= $pluginRoot ?>/assets/urbm-2026.07.10.r007.js"></script> <script src="<?= $pluginRoot ?>/assets/urbm-<?= rawurlencode($urbmVersion) ?>.js"></script>
+17 -4
View File
@@ -181,8 +181,12 @@
const runTarget = run => { const runTarget = run => {
if(run.taskType==='backup'||run.taskType==='rsync') return state.config.jobs.find(job=>job.id===run.jobId)?.name || 'Unbekannter Job'; if(run.taskType==='backup'||run.taskType==='rsync') return state.config.jobs.find(job=>job.id===run.jobId)?.name || 'Unbekannter Job';
if(['check','prune'].includes(run.taskType)) return state.config.repositories.find(repo=>repo.id===run.jobId)?.name || 'Unbekanntes Repository'; if(['check','prune'].includes(run.taskType)) return state.config.repositories.find(repo=>repo.id===run.jobId)?.name || 'Unbekanntes Repository';
if(run.taskType==='restore') { if(run.taskType==='restore') {
const parts=String(run.jobId||'').split('\u0000'); if(run.restore) {
const repo=state.config.repositories.find(candidate=>candidate.id===run.restore.repositoryId)?.name || 'Repository';
return `${repo} · ${String(run.restore.snapshotId||'').slice(0,12)}${run.restore.target||''}`;
}
const parts=String(run.jobId||'').split('\u0000');
if(parts.length>=4) { if(parts.length>=4) {
const repo=state.config.repositories.find(candidate=>candidate.id===parts[0])?.name || 'Repository'; const repo=state.config.repositories.find(candidate=>candidate.id===parts[0])?.name || 'Repository';
return `${repo} · ${parts[1].slice(0,12)}${parts[2]}`; return `${repo} · ${parts[1].slice(0,12)}${parts[2]}`;
@@ -915,10 +919,19 @@
const manual=e.target.closest('.bu-schedule')?.querySelector('.bu-schedule-manual'); const manual=e.target.closest('.bu-schedule')?.querySelector('.bu-schedule-manual');
if(manual) manual.hidden=e.target.value!=='manual'; if(manual) manual.hidden=e.target.value!=='manual';
} }
if (e.target.name === 'type' && e.target.closest('#bu-job-form')) { if (e.target.name === 'type' && e.target.closest('#bu-job-form')) {
state.backupSelections=[]; state.workloadSelections=[]; state.backupSelections=[]; state.workloadSelections=[];
renderJobSpecific(e.target.value); renderJobSpecific(e.target.value);
} }
if (e.target.name === 'inPlace' && e.target.closest('#bu-restore-form')) {
const target=e.target.closest('form').querySelector('[name="target"]');
const tree=e.target.closest('form').querySelector('#bu-dir-tree-restoreTarget');
if(target) {
if(e.target.checked) { target.dataset.stagingTarget=target.value; target.value='/'; target.readOnly=true; }
else { target.value=target.dataset.stagingTarget||state.config.settings.restoreRoot; target.readOnly=false; }
}
if(tree) tree.hidden=e.target.checked;
}
if (e.target.classList.contains('bu-source-select')) { if (e.target.classList.contains('bu-source-select')) {
const path=e.target.value; const prefix=path.replace(/\/$/,'')+'/'; const path=e.target.value; const prefix=path.replace(/\/$/,'')+'/';
if(e.target.checked) state.backupSelections=normalizeSourceSelections([...state.backupSelections.filter(selected=>!selected.startsWith(prefix)),path]); if(e.target.checked) state.backupSelections=normalizeSourceSelections([...state.backupSelections.filter(selected=>!selected.startsWith(prefix)),path]);