add readme/todo/license, resilience as timeout when e.g. sftp connection fails

This commit is contained in:
Wouter Groeneveld 2023-03-06 21:09:27 +01:00
parent bb4079fa99
commit c5e596a9d5
10 changed files with 272 additions and 52 deletions

14
LICENSE Normal file
View File

@ -0,0 +1,14 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
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.

58
README.md Normal file
View File

@ -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.

View File

@ -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?

60
cmdtest/timeout.go Normal file
View File

@ -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")
}

4
go.mod
View File

@ -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
)

17
go.sum
View File

@ -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=

BIN
img/restictray-app.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
img/restictray.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

136
main.go
View File

@ -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()
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 backupCheckFn()
}
case <-mnuBackupNow.ClickedCh:
go backupNow(mnuBackupNow, cnf, func() {
updateSnapshots(cnf, mnuLatestSnapshot, mnuNextSnapshot, mnuBackupNow)
})
case <-mnuBrowse.ClickedCh:
go mountBackups(cnf)
}
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()
}

View File

@ -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)
go func() {
for scanner.Scan() {
m := scanner.Text()
log.Info().Str("out", m).Msg("backup")
if busy != nil {
busy <- true
}
err := cmd.Wait()
}
}()
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 {