diff --git a/V3/README.md b/V3/README.md index 911859d..5144a8a 100644 --- a/V3/README.md +++ b/V3/README.md @@ -15,8 +15,40 @@ Cross-compile examples: GOOS=linux GOARCH=amd64 go build -o ha-backup-linux-amd64 . GOOS=linux GOARCH=arm64 go build -o ha-backup-linux-arm64 . # Raspberry Pi / ARM NAS + +## Logging + +ha-backup writes a log to `/var/log/ha-backup.log` by default. Each line is timestamped, and the file is appended to on every run. The same output is still printed to the console. + +### Create the log file + +Writing to `/var/log` requires elevated permissions, so create the file once and give ownership to the user that runs the backup. Replace `backupuser` with that user (use `root` if you run it as root): + + +If you'd rather do it in steps: + +``` +sudo touch /var/log/ha-backup.log +sudo chown backupuser:backupuser /var/log/ha-backup.log +sudo chmod 640 /var/log/ha-backup.log +``` + +If the log file can't be opened, ha-backup prints a warning and continues the backup without file logging. + +### Change or disable the log location + +Set `LOG_FILE` in `home_assistant_backup.conf`: + +```bash +LOG_FILE="/var/log/ha-backup.log" # default +LOG_FILE="/home/backupuser/ha-backup.log" # custom path +LOG_FILE="none" # disable file logging +``` + + ## Run - ./ha-backup # looks for home_assistant_backup.conf next to the binary, then in cwd + ./ha-backup # looks for home_assistant_backup.conf next to the binary, then in cwd ./ha-backup -config /path/to.conf + Requires `ssh` and `scp` on the machine running it. `jq` and `yq` are no longer needed. diff --git a/V3/home_assistant_backup.conf b/V3/home_assistant_backup.conf index 3cf8050..e851d8d 100644 --- a/V3/home_assistant_backup.conf +++ b/V3/home_assistant_backup.conf @@ -30,3 +30,9 @@ NTFY_TOPIC="NTFY_TOPIC" # Replace with your ntfy topic DISCORD_ENABLED=false # Set to 'true' to enable Discord notifications DISCORD_WEBHOOK_URL="DISCORD_WEBHOOK_URL" # Replace with your Discord webhook URL + +## Logging +# Set the log file location (Default /var/log/ha-backup.log) +# The file must be writable by the user running ha-backup, see the README +# Set to "none" to disable logging to a file (console output is unaffected) +LOG_FILE="/var/log/ha-backup.log" diff --git a/V3/main.go b/V3/main.go index b8adb9b..bed1aca 100644 --- a/V3/main.go +++ b/V3/main.go @@ -1,6 +1,6 @@ -// ha-backup v3.1.1: creates a Home Assistant backup over SSH, copies it locally, -// prunes old backups (remote and local) and sends optional ntfy/Discord -// notifications. Go port of home_assistant_backup.sh. +// ha-backup v3.2.0: creates a Home Assistant backup over SSH, copies it locally, +// prunes old backups (remote and local), logs to /var/log and sends optional +// ntfy/Discord notifications. Go port of home_assistant_backup.sh. // // Only the standard library is used. The system `ssh` and `scp` binaries are // still required (same as the original script), but `jq` and `yq` are not. @@ -12,6 +12,7 @@ import ( "encoding/json" "flag" "fmt" + "io" "io/fs" "net/http" "os" @@ -20,6 +21,7 @@ import ( "regexp" "strconv" "strings" + "sync" "time" ) @@ -29,10 +31,13 @@ import ( const defaultConfigName = "home_assistant_backup.conf" +// defaultLogFile is where the log is written unless LOG_FILE says otherwise. +const defaultLogFile = "/var/log/ha-backup.log" + // version is the release version. It can be overridden at build time with: // -// go build -ldflags "-X main.version=3.1.2" -var version = "3.1.1" +// go build -ldflags "-X main.version=3.2.0" +var version = "3.2.0" type Config struct { SSHKey string @@ -46,6 +51,10 @@ type Config struct { LocalCleanupDays int RemoteCleanupDays int + // LogFile is the path of the log file. Set LOG_FILE="none" to disable + // file logging. + LogFile string + NtfyEnabled bool NtfyServer string NtfyTopic string @@ -120,6 +129,7 @@ func loadConfig(path string) (*Config, error) { SSHPort: get("HA_SSH_PORT", ""), BackupDest: get("BACKUP_DEST", ""), BackupFileLocation: strings.TrimRight(get("BACKUP_FILE_LOCATION", "/backup"), "/"), + LogFile: get("LOG_FILE", defaultLogFile), NtfyEnabled: get("NTFY_ENABLED", "false") == "true", NtfyServer: get("NTFY_SERVER", ""), NtfyTopic: get("NTFY_TOPIC", ""), @@ -170,6 +180,56 @@ func (c *Config) validate() error { return nil } +// --------------------------------------------------------------------------- +// Logging +// --------------------------------------------------------------------------- + +var ( + logMu sync.Mutex + logFile *os.File // nil when file logging is disabled or could not be opened +) + +// openLog opens (or creates) the log file in append mode. An empty path or +// "none" disables file logging. Console output is never affected. +func openLog(path string) error { + if path == "" || strings.EqualFold(path, "none") { + return nil + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640) + if err != nil { + return err + } + logMu.Lock() + logFile = f + logMu.Unlock() + return nil +} + +// logf prints a timestamped line to the console and, when enabled, appends a +// line with a full date and time to the log file. +func logf(format string, a ...any) { + msg := fmt.Sprintf(format, a...) + now := time.Now() + + logMu.Lock() + defer logMu.Unlock() + fmt.Printf("[%s] %s\n", now.Format("15:04:05"), msg) + if logFile != nil { + fmt.Fprintf(logFile, "%s %s\n", now.Format("2006-01-02 15:04:05"), msg) + } +} + +// stderrWriter returns a writer for subprocess stderr that goes to the +// terminal and, when enabled, to the log file as well. +func stderrWriter() io.Writer { + logMu.Lock() + defer logMu.Unlock() + if logFile != nil { + return io.MultiWriter(os.Stderr, logFile) + } + return os.Stderr +} + // --------------------------------------------------------------------------- // Notifications // --------------------------------------------------------------------------- @@ -186,12 +246,12 @@ func NewNotifier(cfg *Config) *Notifier { func (n *Notifier) post(url, contentType string, body []byte) { resp, err := n.client.Post(url, contentType, bytes.NewReader(body)) if err != nil { - fmt.Fprintf(os.Stderr, "notification failed (%s): %v\n", url, err) + logf("notification failed (%s): %v", url, err) return } resp.Body.Close() if resp.StatusCode >= 300 { - fmt.Fprintf(os.Stderr, "notification failed (%s): HTTP %d\n", url, resp.StatusCode) + logf("notification failed (%s): HTTP %d", url, resp.StatusCode) } } @@ -216,12 +276,12 @@ type Remote struct{ cfg *Config } func (r Remote) target() string { return r.cfg.SSHUser + "@" + r.cfg.SSHHost } // run executes a command on the Home Assistant host and returns its stdout. -// stderr is passed through to the terminal. +// stderr is passed through to the terminal (and the log file). func (r Remote) run(remoteCmd string) (string, error) { cmd := exec.Command("ssh", "-i", r.cfg.SSHKey, "-p", r.cfg.SSHPort, r.target(), remoteCmd) var out bytes.Buffer cmd.Stdout = &out - cmd.Stderr = os.Stderr + cmd.Stderr = stderrWriter() err := cmd.Run() return out.String(), err } @@ -230,7 +290,7 @@ func (r Remote) copyFrom(remotePath, localPath string) error { cmd := exec.Command("scp", "-i", r.cfg.SSHKey, "-P", r.cfg.SSHPort, r.target()+":"+remotePath, localPath) cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + cmd.Stderr = stderrWriter() return cmd.Run() } @@ -281,11 +341,11 @@ func (r Remote) listBackups() ([]backupEntry, error) { } func removeOldRemoteBackups(r Remote, n *Notifier, days int) { - fmt.Printf("Removing backups older than %d days on Home Assistant server...\n", days) + logf("Removing backups older than %d days on Home Assistant server...", days) backups, err := r.listBackups() if err != nil { - fmt.Printf("Error: %v\n", err) + logf("Error: %v", err) return } @@ -294,27 +354,27 @@ func removeOldRemoteBackups(r Remote, n *Notifier, days int) { for _, b := range backups { t, err := time.Parse(time.RFC3339Nano, b.Date) if err != nil { - fmt.Printf("Skipping backup %s: cannot parse date %q\n", b.Slug, b.Date) + logf("Skipping backup %s: cannot parse date %q", b.Slug, b.Date) continue } if !t.Before(cutoff) { continue } if !slugRe.MatchString(b.Slug) { - fmt.Printf("Skipping backup with unexpected slug %q\n", b.Slug) + logf("Skipping backup with unexpected slug %q", b.Slug) continue } - fmt.Printf("Removing backup with slug: %s\n", b.Slug) + logf("Removing backup with slug: %s", b.Slug) if _, err := r.run("ha backups remove " + b.Slug); err != nil { - fmt.Printf("Failed to remove backup with slug: %s\n", b.Slug) + logf("Failed to remove backup with slug: %s", b.Slug) continue } removed++ - fmt.Printf("Successfully removed backup with slug: %s\n", b.Slug) + logf("Successfully removed backup with slug: %s", b.Slug) n.Send("Old backup removed: " + b.Slug) } if removed == 0 { - fmt.Println("No old backups found to remove.") + logf("No old backups found to remove.") } } @@ -371,24 +431,17 @@ func removeOldLocalBackups(dir string, days int) error { return err } if now.Sub(info.ModTime()) >= minAge { + logf("Removing old local backup: %s", path) return os.Remove(path) } return nil }) } -// --------------------------------------------------------------------------- -// main -// --------------------------------------------------------------------------- - // --------------------------------------------------------------------------- // Progress reporting // --------------------------------------------------------------------------- -func logf(format string, a ...any) { - fmt.Printf("[%s] %s\n", time.Now().Format("15:04:05"), fmt.Sprintf(format, a...)) -} - func humanBytes(n int64) string { const unit = 1024 if n < unit { @@ -444,8 +497,8 @@ func (r Remote) remoteSize(path string) int64 { } // copyWithProgress runs scp while reporting how much has arrived locally. -// On a terminal it updates a single line; otherwise (cron, logs) it prints a -// line every 10 seconds. +// On a terminal it updates a single line (console only, never written to the +// log file); otherwise (cron, logs) it logs a line every 10 seconds. func (r Remote) copyWithProgress(remotePath, localPath string) error { total := r.remoteSize(remotePath) if total > 0 { @@ -505,6 +558,10 @@ func (r Remote) copyWithProgress(remotePath, localPath string) error { return err } +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + func findConfig(flagPath string) string { if flagPath != "" { return flagPath @@ -534,8 +591,15 @@ func main() { fmt.Printf("Error: cannot load config file %s: %v\n", confPath, err) os.Exit(1) } + + // Open the log file as early as possible so validation errors are logged + // too. A failure here (e.g. not running as root) is not fatal. + if err := openLog(cfg.LogFile); err != nil { + fmt.Fprintf(os.Stderr, "Warning: cannot open log file %s: %v (continuing without file logging)\n", cfg.LogFile, err) + } + if err := cfg.validate(); err != nil { - fmt.Printf("Error: %v\n", err) + logf("Error: %v", err) os.Exit(1) } @@ -553,6 +617,9 @@ func main() { start := time.Now() logf("ha-backup v%s - backup process started (%s)", version, timestamp) + if logFile != nil { + logf("Logging to %s", cfg.LogFile) + } notify.Send("Backup process started at " + timestamp) // Free-space check on the Home Assistant host.