3 Commits
Author SHA1 Message Date
phil 1af0a20429 ## 3.2.1
### Fixed
- Backup failures caused by low disk space on the Home Assistant host are now
  reported as such, e.g. "not enough disk space on Home Assistant server
  (1.2 GB free, threshold 2.0 GB)", instead of only "failed to extract backup slug".
- A failed `ha backup new` now includes Home Assistant's own error message in
  the notification and log.

### Changed
- Free space is re-checked after the remote cleanup runs. If it is still below
  `STORAGE_THRESHOLD_KB`, a warning is sent to ntfy/Discord before the backup
  is attempted.
- The remote cleanup now logs how many backups it found, and says when none are
  older than `REMOTE_BACKUP_CLEANUP` days.
2026-11-06 22:53:27 +00:00
phil 0444e0d45c Fixed README version 2026-11-06 22:41:17 +00:00
phil 6b9bd80fa8 ## 3.2.0
### Added
- File logging to `/var/log/ha-backup.log`. Each line has a full date and time,
  and the file is appended to on every run.
- New `LOG_FILE` setting in `home_assistant_backup.conf`. Set it to a custom
  path, or to `"none"` to disable file logging.
- `ssh`/`scp` error output is also written to the log file.
- Deletions of old local backups are now logged.

### Changed
- All console messages now go through the same logger, so remote cleanup,
  notification failures and config validation errors appear in the log too.
- If the log file can't be opened (for example, no write permission to
  `/var/log`), a warning is printed and the backup continues without file logging.
- The terminal progress bar stays on the console only and is not written to the log.
2026-11-06 22:40:15 +00:00
3 changed files with 187 additions and 34 deletions

No files matched your search

+34 -2
View File
@@ -1,4 +1,4 @@
# ha-backup v3.1.1
# ha-backup v3.2.0
Go port of `home_assistant_backup.sh`. Uses the same `home_assistant_backup.conf`.
@@ -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.
+6
View File
@@ -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"
+147 -32
View File
@@ -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.1: 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.1"
var version = "3.2.1"
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,54 +341,75 @@ 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
}
logf("Found %d backup(s) on Home Assistant server.", len(backups))
cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
removed := 0
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 (none are older than %d days).", days)
}
}
type newBackupResp struct {
Data struct {
Result string `json:"result"`
Message string `json:"message"`
Data struct {
Slug string `json:"slug"`
} `json:"data"`
Slug string `json:"slug"`
}
// describeOutput turns the output of an `ha` command into a short message for
// error reports: the "message" field of a JSON response if there is one,
// otherwise the raw text (collapsed to one line and truncated).
func describeOutput(out string) string {
var resp newBackupResp
if json.Unmarshal([]byte(out), &resp) == nil && resp.Message != "" {
return resp.Message
}
text := strings.Join(strings.Fields(out), " ")
if r := []rune(text); len(r) > 300 {
text = string(r[:300]) + "..."
}
return text
}
// createBackup creates a backup and returns its slug.
func (r Remote) createBackup(name string) (string, error) {
out, err := r.run("ha backup new --name=" + name + " --raw-json")
if err != nil {
if d := describeOutput(out); d != "" {
return "", fmt.Errorf("failed to create backup on Home Assistant server: %w (Home Assistant said: %s)", err, d)
}
return "", fmt.Errorf("failed to create backup on Home Assistant server: %w", err)
}
@@ -340,6 +421,9 @@ func (r Remote) createBackup(name string) (string, error) {
if resp.Slug != "" {
return strings.TrimSpace(resp.Slug), nil
}
if resp.Result == "error" && resp.Message != "" {
return "", fmt.Errorf("backup creation failed: Home Assistant said: %s", resp.Message)
}
}
// Fallback: plain-text "slug: xxxx" line (what the original script parsed).
@@ -351,6 +435,9 @@ func (r Remote) createBackup(name string) (string, error) {
}
}
}
if d := describeOutput(out); d != "" {
return "", fmt.Errorf("failed to extract backup slug (Home Assistant said: %s)", d)
}
return "", fmt.Errorf("failed to extract backup slug")
}
@@ -371,24 +458,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 +524,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 +585,10 @@ func (r Remote) copyWithProgress(remotePath, localPath string) error {
return err
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
func findConfig(flagPath string) string {
if flagPath != "" {
return flagPath
@@ -534,8 +618,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 +644,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.
@@ -562,9 +656,25 @@ func main() {
fail("failed to check disk space on Home Assistant server: " + err.Error())
}
logf("Free space: %s (threshold: %s)", humanBytes(avail*1024), humanBytes(cfg.StorageThresholdKB*1024))
if avail < cfg.StorageThresholdKB {
lowSpace := avail < cfg.StorageThresholdKB
if lowSpace {
logf("Disk space is below the threshold. Running remote backup cleanup...")
removeOldRemoteBackups(remote, notify, cfg.RemoteCleanupDays)
// Re-check: cleanup may not have freed anything.
if a, err := remote.availableKB(); err != nil {
logf("Could not re-check free space after cleanup: %v", err)
} else {
avail = a
lowSpace = avail < cfg.StorageThresholdKB
logf("Free space after cleanup: %s", humanBytes(avail*1024))
}
if lowSpace {
warn := fmt.Sprintf("Warning: low disk space on Home Assistant server (%s free, threshold %s) and cleanup did not free enough space.",
humanBytes(avail*1024), humanBytes(cfg.StorageThresholdKB*1024))
logf("%s", warn)
notify.Send(warn)
}
} else {
logf("Sufficient disk space available. Skipping remote backup cleanup.")
}
@@ -577,7 +687,12 @@ func main() {
return e
})
if err != nil {
fail(err.Error() + ".")
msg := err.Error()
if lowSpace {
msg = fmt.Sprintf("not enough disk space on Home Assistant server (%s free, threshold %s); %s",
humanBytes(avail*1024), humanBytes(cfg.StorageThresholdKB*1024), msg)
}
fail(msg + ".")
}
if !slugRe.MatchString(slug) {
fail(fmt.Sprintf("unexpected backup slug %q.", slug))