diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81af97e --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2004 Sam Hocevar + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..610c9ca --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ + +# Restictray + +A macOS system tray wrapper around [Restic](https://restic.net/), the Go-powered cmd-line backup tool. + +With Restictray, you can monitor and trigger backups from the system tray: + +![](img/restictray.jpg) + +Restictray is designed in such a way that it checks whether or not a backup is needed every hour by looking at the latest date in the `restic snapshot` output that's also part of the menu. If the backup command fails, the SFTP network goes down, or your laptop is offline, it will resume next time it's booted. + +It's also possible to trigger a backup manually. + +For convenience, browsing backups in Finder is done through `restic mount`, which means you will need to **install MacFUSE 4.x** through https://osxfuse.github.io/ for it to work! + +This was designed for my wife to access backups with a button press. + +## Configuration + +Restictray currently expects the following files in `~/.restic/`: + +1. `password.txt` as hardcoded `--password-file` argument +2. `excludes.txt` as hardcoded `--exclude-file` argument +3. `config.json` that configures the repository, the folder(s)/file(s) to backup, and the interval in hours: + +```json +{ + "repository": "sftp:user@server:/somewhere/resticdir", + "backup": "/Users/username", + "backupTimeInHours": 24 +} +``` + +Where `repository` is the restic `-r` argument and `backup` the folder(s)/file(s) fed into the `backup` command. + +**The repository should already be initialized!** You'll have to do this yourself using `restic -r [repo] init`. + +If `backupTimeInHours` is absent, it will default to **24**: backup once a day. + +For more information on how the restic arguments themselves work, please see the restic docs at https://restic.readthedocs.io/en/stable/. + +### Dev config + +If environment variable `RESTICTRAY_DEV` is set, Restictray configures Zerolog to use stdout and the prettyprint formatter instead of the external log, plus it relies on the `restic` command in `$PATH` instead of looking for it in the currently executing folder. + +## Deploying + +Restictray can be wrapped as a macOS `.app` folder that can be distributed. See `build.sh` on how to do this---I've used `fyne package`: see docs at https://developer.fyne.io/started/packaging. + +![](img/restictray-app.jpg) + +The app also wraps the `restic` binary so no local install is needed. + +Please note that the current supplied one in `build/` is an ARM64 macOS-specific binary for that very reason. + +## Troubleshooting + +Restictray uses Lumberjack and Zerolog to log info to `~/.restic/log.txt`. If a command fails, it should be logged there. \ No newline at end of file diff --git a/TODO.md b/TODO.md index 28dc127..014834a 100644 --- a/TODO.md +++ b/TODO.md @@ -7,8 +7,8 @@ - [ ] Create default files if not existing? - [ ] Create a config dialog in https://developer.fyne.io/ - [ ] Add additional resilience: - - [ ] What if SSH backup and network goes down? Do a `ping` before backup? Is there a timeout from the `restic` command itself? - - [ ] If something goes wrong, menu shows error and app becomes unusable. Perhaps not all errors (e.g. above one) have to be this way. + - [X] What if SSH backup and network goes down? Do a `ping` before backup? Is there a timeout from the `restic` command itself? + - [X] If something goes wrong, menu shows error and app becomes unusable. Perhaps not all errors (e.g. above one) have to be this way. - [X] Is backing up while mounted ok? => yes - [ ] `restic backup` can result in `Warning: at least one source file could not be read\n: exit status 3`. This returns an error, but the backup itself seems to be all right. - [X] exit code 3: https://restic.readthedocs.io/en/stable/040_backup.html likely to be a permission problem, perhaps ignoring instead of panicking as of now? diff --git a/cmdtest/timeout.go b/cmdtest/timeout.go new file mode 100644 index 0000000..e0e2793 --- /dev/null +++ b/cmdtest/timeout.go @@ -0,0 +1,60 @@ +package main + +import ( + "bufio" + "fmt" + "os/exec" + "time" +) + +func main() { + cmd := neverReturningThing() + //cmd := returningThingWithinThreeSecs() + //cmd := returningThingAfterThreeSecs() + stdout, _ := cmd.StdoutPipe() + + busy := make(chan bool, 1) + cmd.Start() + + scanner := bufio.NewScanner(stdout) + scanner.Split(bufio.ScanLines) + + go func() { + fmt.Println("starting scanner.scan (blocking)") + for scanner.Scan() { + fmt.Println("scanned") + m := scanner.Text() + fmt.Println(m) + if busy != nil { + busy <- true + } + } + }() + + fmt.Println("starting select chans") + var err error + select { + case <-busy: + fmt.Println("busy chan") + busy = nil + case <-time.After(3 * time.Second): + pkill := cmd.Process.Kill() + fmt.Println("timeout triggered? err: %w", pkill) + } + + fmt.Println("starting Wait()") + err = cmd.Wait() + fmt.Printf("done? here's an error: %w", err) +} + +func returningThingWithinThreeSecs() *exec.Cmd { + return exec.Command("echo", "sup") +} + +func returningThingAfterThreeSecs() *exec.Cmd { + return exec.Command("ping", "google.com") +} + +func neverReturningThing() *exec.Cmd { + return exec.Command("ssh", "user@unknown.local") +} diff --git a/go.mod b/go.mod index 9ade12b..75ec6d9 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,17 @@ go 1.19 require ( fyne.io/systray v1.10.0 github.com/rs/zerolog v1.29.0 + github.com/stretchr/testify v1.8.2 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/godbus/dbus/v5 v5.0.4 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.14 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tevino/abool v1.2.0 // indirect golang.org/x/sys v0.6.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 236f77f..acdac01 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,9 @@ fyne.io/systray v1.10.0 h1:Yr1D9Lxeiw3+vSuZWPlaHC8BMjIHZXJKkek706AfYQk= fyne.io/systray v1.10.0/go.mod h1:oM2AQqGJ1AMo4nNqZFYU8xYygSBZkW2hmdJ7n4yjedE= github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/godbus/dbus/v5 v5.0.4 h1:9349emZab16e7zQvpmsbtjc18ykshndd8y2PG3sgJbA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= @@ -8,9 +11,18 @@ github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w= github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/tevino/abool v1.2.0 h1:heAkClL8H6w+mK5md9dzsuohKeXHUpY7Vw0ZCKW+huA= github.com/tevino/abool v1.2.0/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -18,5 +30,10 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/img/restictray-app.jpg b/img/restictray-app.jpg new file mode 100644 index 0000000..74b5931 Binary files /dev/null and b/img/restictray-app.jpg differ diff --git a/img/restictray.jpg b/img/restictray.jpg new file mode 100644 index 0000000..32d5eb4 Binary files /dev/null and b/img/restictray.jpg differ diff --git a/main.go b/main.go index 4391d21..fe534ec 100644 --- a/main.go +++ b/main.go @@ -14,7 +14,7 @@ import ( // I'm ignoring go threading issues here; assume no clicks happen at the same time. var wrapper *restic.Wrapper -func addMenuQuit() { +func addMenuQuit() *systray.MenuItem { mQuit := systray.AddMenuItem("Quit", "Quit Restictray") go func() { <-mQuit.ClickedCh @@ -22,6 +22,7 @@ func addMenuQuit() { wrapper.Cleanup() systray.Quit() }() + return mQuit } func addMenuError(err error) { @@ -62,7 +63,7 @@ func main() { } // Updates snapshot information in restic wrapper and menu labels. This is a blocking call. -func updateSnapshots(cnf *restic.Config, mnuLatest *systray.MenuItem, mnuNext *systray.MenuItem, mnuBackupNow *systray.MenuItem) { +func updateSnapshots(cnf *restic.Config, mnu *resticmenu) { err := wrapper.UpdateLatestSnapshots(cnf) if err != nil { handleError(err) @@ -75,10 +76,9 @@ func updateSnapshots(cnf *restic.Config, mnuLatest *systray.MenuItem, mnuNext *s msg = "⚠️ Overdue - " + msg } - mnuLatest.SetTitle("Latest: " + snapshot.Id + " @ " + snapshot.ShortTime()) - mnuNext.SetTitle(msg) - mnuBackupNow.Enable() - mnuBackupNow.SetTitle("Backup now") + mnu.latestSnapshot.SetTitle("Latest: " + snapshot.Id + " @ " + snapshot.ShortTime()) + mnu.nextSnapshot.SetTitle(msg) + mnu.backupNowSucceeded() } func isBackupNeeded(cnf *restic.Config) bool { @@ -86,6 +86,26 @@ func isBackupNeeded(cnf *restic.Config) bool { return time.Now().After(nextTime) } +type resticmenu struct { + latestSnapshot *systray.MenuItem + nextSnapshot *systray.MenuItem + backupNow *systray.MenuItem + browse *systray.MenuItem + logs *systray.MenuItem + config *systray.MenuItem + quit *systray.MenuItem +} + +func (mnu *resticmenu) backupNowFailed(err error) { + mnu.backupNow.Enable() + mnu.backupNow.SetTitle("‼️ Backup now - latest failed!") +} + +func (mnu *resticmenu) backupNowSucceeded() { + mnu.backupNow.Enable() + mnu.backupNow.SetTitle("Backup now") +} + // See https://github.com/fyne-io/systray/tree/master/example for more examples func onSystrayReady() { systray.SetTemplateIcon(restic.TimeMachineIcon, restic.TimeMachineIcon) @@ -97,51 +117,80 @@ func onSystrayReady() { return } - mnuLatestSnapshot := systray.AddMenuItem("Latest: (Fetching...)", "Latest Restic snapshot") - mnuNextSnapshot := systray.AddMenuItem("Next @ (Unknown)", "Future Restic snapshot") - systray.AddSeparator() - mnuBackupNow := systray.AddMenuItem("Backup now", "Backup now") - mnuBackupNow.Disable() - mnuBrowse := systray.AddMenuItem("Browse backups in Finder...", "Mount and browse backups") - mnuLogs := systray.AddMenuItem("Open logfile...", "Open logging file") - mnuConfig := systray.AddMenuItem("Open config file...", "Open config file") - systray.AddSeparator() - addMenuQuit() + mnu := buildMenu() - go updateSnapshots(cnf, mnuLatestSnapshot, mnuNextSnapshot, mnuBackupNow) + go updateSnapshots(cnf, mnu) backupCheckTime := make(chan bool, 1) - backupCheckFn := func() { + hourlyBackupCheckFn := func() { time.Sleep(1 * time.Hour) backupCheckTime <- true } - go backupCheckFn() + go hourlyBackupCheckFn() for { select { - case <-mnuConfig.ClickedCh: - restic.OpenConfigFile() - case <-mnuLogs.ClickedCh: - restic.OpenLogs() + case <-mnu.config.ClickedCh: + onOpenConfig() + case <-mnu.logs.ClickedCh: + onOpenLogs() case <-backupCheckTime: - if !mnuBackupNow.Disabled() && isBackupNeeded(cnf) { - go backupNow(mnuBackupNow, cnf, func() { - updateSnapshots(cnf, mnuLatestSnapshot, mnuNextSnapshot, mnuBackupNow) - go backupCheckFn() - }) - } else { - log.Info().Msg("Backup not yet needed/in progress/impossible") - go backupCheckFn() - } - case <-mnuBackupNow.ClickedCh: - go backupNow(mnuBackupNow, cnf, func() { - updateSnapshots(cnf, mnuLatestSnapshot, mnuNextSnapshot, mnuBackupNow) - }) - case <-mnuBrowse.ClickedCh: - go mountBackups(cnf) + onBackupCheck(mnu, cnf, hourlyBackupCheckFn) + case <-mnu.backupNow.ClickedCh: + onBackupNow(mnu, cnf) + case <-mnu.browse.ClickedCh: + onBroseBackups(cnf) } } } +func onBroseBackups(cnf *restic.Config) { + go mountBackups(cnf) +} + +func onOpenLogs() error { + return restic.OpenLogs() +} + +func onOpenConfig() error { + return restic.OpenConfigFile() +} + +func onBackupNow(mnu *resticmenu, cnf *restic.Config) { + go backupNow(mnu, cnf, func() { + updateSnapshots(cnf, mnu) + }, mnu.backupNowFailed) +} + +func onBackupCheck(mnu *resticmenu, cnf *restic.Config, hourlyBackupCheckFn func()) { + if !mnu.backupNow.Disabled() && isBackupNeeded(cnf) { + go backupNow(mnu, cnf, func() { + updateSnapshots(cnf, mnu) + go hourlyBackupCheckFn() + }, func(err error) { + mnu.backupNowFailed(err) + go hourlyBackupCheckFn() + }) + } else { + log.Info().Msg("Backup not yet needed/in progress/impossible") + go hourlyBackupCheckFn() + } +} + +func buildMenu() *resticmenu { + mnu := &resticmenu{} + mnu.latestSnapshot = systray.AddMenuItem("Latest: (Fetching...)", "Latest Restic snapshot") + mnu.nextSnapshot = systray.AddMenuItem("Next @ (Unknown)", "Future Restic snapshot") + systray.AddSeparator() + mnu.backupNow = systray.AddMenuItem("Backup now", "Backup now") + mnu.backupNow.Disable() + mnu.browse = systray.AddMenuItem("Browse backups in Finder...", "Mount and browse backups") + mnu.logs = systray.AddMenuItem("Open logfile...", "Open logging file") + mnu.config = systray.AddMenuItem("Open config file...", "Open config file") + systray.AddSeparator() + mnu.quit = addMenuQuit() + return mnu +} + // Allows for browsing inside Restic backups by the "mount" command, using MacFuse internally. This is a NON-blocking call. // Restic allows backing up and consulting snapshots while mounted func mountBackups(cnf *restic.Config) { @@ -152,14 +201,17 @@ func mountBackups(cnf *restic.Config) { } // Disables the menu and triggers a restic backup. This is a blocking call. -func backupNow(mnu *systray.MenuItem, cnf *restic.Config, onDoneFn func()) { +func backupNow(mnu *resticmenu, cnf *restic.Config, onSuccessFn func(), onErrFn func(error)) { log.Debug().Msg("Backup triggered") - mnu.SetTitle("🔄 Backup in progress...") - mnu.Disable() - err := wrapper.Backup(cnf) + mnu.backupNow.SetTitle("🔄 Backup in progress...") + mnu.backupNow.Disable() + err := wrapper.Backup(cnf) if err != nil { - handleError(err) + log.Err(err).Msg("backup error") + onErrFn(err) + return } - onDoneFn() + + onSuccessFn() } diff --git a/restic/wrapper.go b/restic/wrapper.go index 36dcb8d..6fc3248 100644 --- a/restic/wrapper.go +++ b/restic/wrapper.go @@ -144,18 +144,33 @@ func openFolder(folder string) error { // Backup uses "restic backup" to create a new snapshot. This is a blocking call. func (w *Wrapper) Backup(c *Config) error { cmd := resticCmd("--password-file", PasswordFile(), "-r", c.Repository, "--exclude-file", ExcludeFile(), "backup", c.Backup, "--no-scan") - stdout, _ := cmd.StdoutPipe() + + busy := make(chan bool, 1) cmd.Start() scanner := bufio.NewScanner(stdout) scanner.Split(bufio.ScanLines) - for scanner.Scan() { - m := scanner.Text() - log.Info().Str("out", m).Msg("backup") - } - err := cmd.Wait() + go func() { + for scanner.Scan() { + m := scanner.Text() + log.Info().Str("out", m).Msg("backup") + if busy != nil { + busy <- true + } + } + }() + + select { + case <-busy: + busy = nil // It's alive! continue with cmd.Wait() + case <-time.After(3 * time.Second): + killErr := cmd.Process.Kill() + return fmt.Errorf("backup output timeout, repository server down?: %w", killErr) + } + + err := cmd.Wait() if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) && exitErr.ExitCode() == 3 {