8 Commits
Author SHA1 Message Date
phil 4c5dcd779d Add SHA-256 verification of copied backups (v3.3.0)
After copying a backup from Home Assistant, compare its SHA-256 (via
sha256sum on the server) with a hash of the local file. On mismatch,
delete the local copy and send an error notification; the remote backup
is left in place so the run can be retried. If either checksum cannot
be computed, the run fails rather than reporting an unverified copy as
successful.

- Add VERIFY_CHECKSUM config option (default true; "false" skips the check)
- Make boolean config values case-insensitive (VERIFY_CHECKSUM, NTFY_ENABLED,
  DISCORD_ENABLED), so "True" now enables them. Previously only lowercase
  "true" did, so NTFY_ENABLED="True" silently left ntfy off
- Bump version to 3.3.0
2026-11-11 00:11:43 +00:00
phil 7f1a4186ea Updated README 2026-11-06 23:27:39 +00:00
phil d45c309110 Moved V1 and V2 of the scripts into their own Repos and archived them
V1 - https://git.ncltech.co.uk/phil/Home-Assistant-Backup-Script-V1/
V2 - https://git.ncltech.co.uk/phil/Home-Assistant-Backup-Script-V2

Moved V3 code into main dir
Updated and merged README file
2026-11-06 23:16:49 +00:00
phil caae2fb707 Updated main README file 2026-11-06 22:59:58 +00:00
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
phil 5c05a27ae2 Port Home Assistant backup script to Go (v3.1.1)
Rewrite home_assistant_backup.sh as a single Go binary that reads the
existing home_assistant_backup.conf unchanged.

- Create a backup over SSH, scp it locally, and prune old backups both
  on the HA host (when free space is low) and locally
- Send ntfy and Discord notifications at the same points as the script
- Drop the jq and yq dependencies: parse `ha backups --raw-json` and
  `ha backup new --raw-json` in Go, falling back to `slug:` line parsing
- Add timestamped progress output, a heartbeat while HA builds the
  backup, and live size/percent/speed reporting during the copy
- Build the Discord payload with json.Marshal and validate backup slugs
  before using them in remote commands
- Abort with a notification if the remote disk-space check fails
- Add -config and -version flags
- Build with CGO_ENABLED=0 for a static binary that runs on older glibc
2026-11-05 16:40:40 +00:00
10 changed files with 445 additions and 527 deletions

No files matched your search

+65 -36
View File
@@ -1,4 +1,4 @@
# Home Assistant Backup Automation Scripts # Home Assistant Backup Script
These scripts automate the backup process for **Home Assistant** servers, enabling you to create, transfer, and manage backups. The backups are securely transferred from the Home Assistant server to your local machine, with options for cleaning up old backups based on configurable retention periods. These scripts automate the backup process for **Home Assistant** servers, enabling you to create, transfer, and manage backups. The backups are securely transferred from the Home Assistant server to your local machine, with options for cleaning up old backups based on configurable retention periods.
@@ -11,50 +11,79 @@ These scripts automate the backup process for **Home Assistant** servers, enabli
- **Old Backup Cleanup**: Automatically cleans up old backups on both the Home Assistant server and local machine after a specified retention period. - **Old Backup Cleanup**: Automatically cleans up old backups on both the Home Assistant server and local machine after a specified retention period.
- **Notification Support** (Optional): Configure **ntfy** or **Discord** notifications to alert you about the backup status (available in the first script version). - **Notification Support** (Optional): Configure **ntfy** or **Discord** notifications to alert you about the backup status (available in the first script version).
### Two Versions of the Script: ## Requirements:
1. **Version 1: Simple Backup with Cleanup**
A simpler version that creates a backup, copies it to your local machine, and performs a basic cleanup of old backups locally. No notification system is included in this version.
2. **Version 2: Full Backup and Cleanup with Notifications**
This version includes backup creation, local and remote cleanup of old backups, and optional notifications via **ntfy** and **Discord**. It also checks for available disk space before cleaning up old backups.
## Requirements
Both scripts require the following to function correctly: Both scripts require the following to function correctly:
- **Bash shell** (on your local machine and Home Assistant server) - **Linux shell** (on your local machine)
- **SSH** access to the Home Assistant server - **`ssh`** access to the Home Assistant server
- **`scp`** and **`ssh`** tools - **`scp`** and **`ssh`** tools
- **Home Assistant** installed on the server with the `ha` CLI tool - **Home Assistant** installed on the server with the `ha` CLI tool
- **Optional**: Access to a notification service (for Version 2) - **Optional**: Access to a notification service
## Configuration
Before running either script, you need to set up a few configuration parameters. These include SSH credentials, backup destination paths, and notification settings. ## Build
- **HA_SSH_KEY**: Path to your private SSH key for accessing the Home Assistant server.
- **HA_SSH_USER**: The SSH username (default is `root`).
- **HA_SSH_HOST**: The IP address or hostname of your Home Assistant server.
- **BACKUP_DEST**: Path to the directory where backups will be saved locally.
- **Retention Settings**: You can configure how long old backups are kept before they are deleted (default is 30 days for local and remote backups).
- **Optional**: Set up **ntfy** or **Discord** notifications to receive alerts when backups are created or when errors occur.
## How to Use
### Step 1: Configure the Script
Open the script file in a text editor and set the following variables:
- **SSH credentials** (`HA_SSH_KEY`, `HA_SSH_USER`, `HA_SSH_HOST`)
- **Backup destination path** (`BACKUP_DEST`)
- **Notification settings** (optional)
### Step 2: Make the Script Executable
After configuring the script, make it executable by running:
``` ```
chmod +x home_assistant_backup.sh CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ha-backup .
``` ```
Check the version:
```
./ha-backup -version
```
## 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`:
```
LOG_FILE="/var/log/ha-backup.log" # default
LOG_FILE="/home/backupuser/ha-backup.log" # custom path
LOG_FILE="none" # disable file logging
```
## Run
##### Looks for home_assistant_backup.conf next to the binary, then in cwd
```
./ha-backup
```
##### Loads the home_assistant_backup.conf from a specified file
```
./ha-backup -config /path/to.conf
```
Requires `ssh` and `scp` on the machine running it. `jq` and `yq` are no longer needed.
## Three Versions of the Script:
This project used to contain three versions of this project. V1 and V2 have both been moved to their own repositorys and archived as they will no longer be developed and previous releases will be kept but marked as "Pre-Release"
1. **Version 1: See [https://git.ncltech.co.uk/phil/Home-Assistant-Backup-Script-V1](https://git.ncltech.co.uk/phil/Home-Assistant-Backup-Script-V1)**
2. **Version 2: See [https://git.ncltech.co.uk/phil/Home-Assistant-Backup-Script-V2](https://git.ncltech.co.uk/phil/Home-Assistant-Backup-Script-V2)**
## Disclaimer ## Disclaimer
**The software is provided "as-is" without any warranties or guarantees of any kind.** **The software is provided "as-is" without any warranties or guarantees of any kind.**
@@ -75,6 +104,6 @@ By using or modifying this script, you agree to indemnify and hold harmless the
--- ---
Use of this script is subject to the terms above. If you do not agree with this disclaimer, you should not use the script. Use of this project is subject to the terms above. If you do not agree with this disclaimer, you should not use it.
-70
View File
@@ -1,70 +0,0 @@
# Home Assistant Backup Script
This script automates the process of creating and copying backups from a Home Assistant server to a local machine. It includes functionality for:
- Creating a new backup on the Home Assistant server.
- Copying the backup file to a specified local destination.
- Cleaning up old backups based on a specified retention period.
## Requirements
- Bash shell
- SSH access to your Home Assistant server
- `scp` and `ssh` tools
- Access to a Home Assistant server with the `ha` command line tool installed
## Configuration
Before using the script, you need to configure the following variables:
### Global Settings
- **HA_SSH_KEY**: Path to your SSH private key for accessing the Home Assistant server.
- **HA_SSH_USER**: The SSH username to use for connecting to the Home Assistant server (usually `root`).
- **HA_SSH_HOST**: The IP address or hostname of your Home Assistant server.
- **BACKUP_DEST**: The destination directory on your local machine where the backup file will be stored.
### Backup Retention
- The script will automatically clean up backup files on the local machine that are older than 30 days. You can adjust this retention period by modifying the `-mtime +30` argument in the `find` command to a different number of days.
## Script Flow
1. **Backup Creation**: The script connects to the Home Assistant server via SSH and creates a new backup using the `ha backup new` command. It generates a timestamped name for the backup.
2. **Backup Transfer**: The backup file is copied from the Home Assistant server to your local machine using `scp`.
3. **Local Backup Cleanup**: The script will automatically remove local backups that are older than the configured retention period (default is 30 days).
4. **Completion Message**: A success message is displayed after the backup process is complete.
## Usage
1. **Clone or Download the Script**:
- Clone the repository or download the script file.
2. **Make the Script Executable**:
```
chmod +x home_assistant_backup.sh
```
## Disclaimer
**The software is provided "as-is" without any warranties or guarantees of any kind.**
By using this script, you acknowledge and agree that:
- **No Warranty**: The script is provided without any warranty of any kind, either express or implied, including but not limited to the implied warranties of merchantability, fitness for a particular purpose, or non-infringement. The entire risk arising out of the use or performance of the script remains with you.
- **Data Loss**: The author cannot be held responsible for any data loss, damage to your system, or any other kind of loss resulting from the use of this script. You are solely responsible for backing up your data and ensuring the integrity of your systems before using the script.
- **Use at Your Own Risk**: You are using this script at your own risk. The author shall not be liable for any damages, including but not limited to direct, indirect, incidental, special, consequential, or punitive damages, arising out of the use of or inability to use the script.
- **No Support**: The script is provided "as-is" with no formal support or maintenance. The author does not provide any guarantees of continued functionality, updates, or support.
- **Modifications and Redistribution**: If you modify or redistribute the script, you do so at your own risk. You are solely responsible for ensuring that any modifications or redistributed versions do not cause harm or violate any laws.
By using or modifying this script, you agree to indemnify and hold harmless the author from any and all claims, damages, liabilities, and expenses arising from your use or modification of the script.
---
Use of this script is subject to the terms above. If you do not agree with this disclaimer, you should not use the script.
-22
View File
@@ -1,22 +0,0 @@
#!/bin/bash
set -x
# Set your Home Assistant SSH credentials
HA_SSH_KEY="/home/user/.ssh/HomeAssistant-key"
HA_SSH_USER="root"
HA_SSH_HOST="HOME.ASSISTANT.IP.ADDRESS"
# Set the backup destination path
BACKUP_DEST="/BACKUP/DESTINATION"
# Create a backup on the Home Assistant server and extract the slug
BACKUP_OUTPUT=$(ssh -i "$HA_SSH_KEY" "$HA_SSH_USER@$HA_SSH_HOST" "ha backup new --name=auto_backup_$(date '+%Y-%m-%d_%H-%M-%S')")
BACKUP_SLUG=$(echo "$BACKUP_OUTPUT" | awk '/slug:/ {print $2}')
# Copy the backup file from Home Assistant to your local machine
scp -i "$HA_SSH_KEY" "$HA_SSH_USER@$HA_SSH_HOST:/backup/${BACKUP_SLUG}.tar" "$BACKUP_DEST/$(date '+%Y-%m-%d_%H-%M-%S')_${BACKUP_SLUG}.tar"
# Clean up old backups if needed (adjust the number of days to keep)
find "$BACKUP_DEST" -name '*.tar' -mtime +30 -exec rm {} \;
echo "Backup completed successfully: $BACKUP_DEST/$(date '+%Y-%m-%d_%H-%M-%S')_${BACKUP_SLUG}.tar"
-90
View File
@@ -1,90 +0,0 @@
# Home Assistant Backup Script
This script automates the process of creating, copying, and cleaning up backups for a Home Assistant server. It supports the ability to:
- Create a backup on the Home Assistant server.
- Copy the backup file to a local machine.
- Clean up old backups on both the local machine and Home Assistant server.
- Send notifications to `ntfy` and Discord.
## Requirements
- Bash shell
- SSH access to your Home Assistant server
- `scp` and `ssh`
- `yq` for parsing YAML (https://github.com/mikefarah/yq)
- `jq` for parsing JSON (https://github.com/jqlang/jq)
- `curl` for sending notifications
- Access to a Discord webhook URL (if using Discord notifications) (https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks)
- ntfy.sh server (if using ntfy.sh notifications) (https://ntfy.sh/)
## Configuration
Before using the script, you need to set up the following environment variables in the script:
### Global Settings
- **HA_SSH_KEY**: Path to your SSH private key for Home Assistant.
- **HA_SSH_USER**: Username for SSH login (usually `root`).
- **HA_SSH_HOST**: IP address or hostname of your Home Assistant server.
- **HA_SSH_PORT**: SSH port (default is 22).
- **BACKUP_DEST**: Destination directory for storing local backups.
- **BACKUP_FILE_LOCATION**: Location of the backups on the Home Assistant server (default is `/backup`).
- **LOCAL_BACKUP_CLEANUP**: Number of days to keep local backups (default is 90 days).
- **REMOTE_BACKUP_CLEANUP**: Number of days to keep backups on the Home Assistant server (default is 30 days).
- **NTFY_ENABLED**: Set to `true` to enable `ntfy` notifications.
- **NTFY_SERVER**: The FQDN of your `ntfy` server.
- **NTFY_TOPIC**: The topic for your `ntfy` notifications.
- **DISCORD_ENABLED**: Set to `true` to enable Discord notifications.
- **DISCORD_WEBHOOK_URL**: Your Discord webhook URL.
- **STORAGE_THRESHOLD_KB**: The disk space threshold in KB for triggering cleanup (default is 2GB).
### Notifications
- **ntfy**: Notifications are sent via HTTP POST to your `ntfy` server.
- **Discord**: Notifications are sent via a Discord webhook.
Set `NTFY_ENABLED` and `DISCORD_ENABLED` to `true` to enable notifications for respective services. Replace the relevant URLs and topics.
## Script Flow
1. **Backup Creation**: The script first creates a backup on the Home Assistant server.
2. **Disk Space Check**: It checks available disk space on the Home Assistant server. If it is less than the specified threshold, it will clean up old backups on the Home Assistant server.
3. **Backup Transfer**: The backup file is then copied from the Home Assistant server to the local backup destination.
4. **Local Cleanup**: Old backups are removed from the local machine if they exceed the specified retention period.
5. **Notification**: Notifications are sent at each major step (e.g., start of backup, successful backup, cleanup actions) via `ntfy` and Discord if enabled.
6. **Backup File Size and Script Runtime**: The script reports the backup file size and runtime after completing the backup process.
## Usage
1. **Clone or Download the Script**:
- Clone the repository or download the script file.
2. **Make the Script Executable**:
```bash
chmod +x home_assistant_backup.sh
## Disclaimer
**The software is provided "as-is" without any warranties or guarantees of any kind.**
By using this script, you acknowledge and agree that:
- **No Warranty**: The script is provided without any warranty of any kind, either express or implied, including but not limited to the implied warranties of merchantability, fitness for a particular purpose, or non-infringement. The entire risk arising out of the use or performance of the script remains with you.
- **Data Loss**: The author cannot be held responsible for any data loss, damage to your system, or any other kind of loss resulting from the use of this script. You are solely responsible for backing up your data and ensuring the integrity of your systems before using the script.
- **Use at Your Own Risk**: You are using this script at your own risk. The author shall not be liable for any damages, including but not limited to direct, indirect, incidental, special, consequential, or punitive damages, arising out of the use of or inability to use the script.
- **No Support**: The script is provided "as-is" with no formal support or maintenance. The author does not provide any guarantees of continued functionality, updates, or support.
- **Modifications and Redistribution**: If you modify or redistribute the script, you do so at your own risk. You are solely responsible for ensuring that any modifications or redistributed versions do not cause harm or violate any laws.
By using or modifying this script, you agree to indemnify and hold harmless the author from any and all claims, damages, liabilities, and expenses arising from your use or modification of the script.
---
Use of this script is subject to the terms above. If you do not agree with this disclaimer, you should not use the script.
-32
View File
@@ -1,32 +0,0 @@
### Global Settings ###
# Set your Home Assistant SSH credentials
HA_SSH_KEY="SSH_PRIVATE_KEY" # Replace with your SSH Private key for HomeAssistant
HA_SSH_USER="SSH_USER" # Replace with your HomeAssistant server username (Default is root)
HA_SSH_HOST="SSH_HOST_IP_OR_HOSTNAME" # Replace with your HomeAssistant server IP address
HA_SSH_PORT="SSH_PORT" # Replace with your HomeAssistant server port
## Storage
# Set the backup destination path
BACKUP_DEST="BACKUP_FILE_DESTINATION" # Replace with the location for the backup files
# Set the location of the Home Assistant backups (Set to default location /backup)
# If you have a custom install, using the likes of Supervised
# this location should normally be /usr/share/hassio/backup
BACKUP_FILE_LOCATION="/backup"
# Storage threshold (in KB) for triggering cleanup
STORAGE_THRESHOLD_KB=2097152 # 2GB = 2 * 1024 * 1024 KB
# Set clean up old backups (adjust the number of days to keep)
LOCAL_BACKUP_CLEANUP="90" # Set the number of days that local backup files will be kept for (Default 90 days)
REMOTE_BACKUP_CLEANUP="30" # Set the number of days that backups on the Home Assistant server will be kept for (Default 30 days)
## Notification Settings
# ntfy settings
NTFY_ENABLED=false # Set to 'true' to enable ntfy notifications
NTFY_SERVER="NTFY_SERVER_FQDN_ADDRESS" # Replace with your ntfy server address
NTFY_TOPIC="NTFY_TOPIC" # Replace with your ntfy topic
# Discord webhook settings
DISCORD_ENABLED=false # Set to 'true' to enable Discord notifications
DISCORD_WEBHOOK_URL="DISCORD_WEBHOOK_URL" # Replace with your Discord webhook URL
-222
View File
@@ -1,222 +0,0 @@
#!/bin/bash
#set -x
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/home_assistant_backup.conf"
if [ ! -f "$CONFIG_FILE" ]; then
echo "Error: Config file not found: $CONFIG_FILE"
exit 1
fi
# Load variables
# shellcheck disable=SC1090
source "$CONFIG_FILE"
### Script ###
## Sanity Check##
# Function to check if the necessary variables are set and if jq and yq are installed
sanity_check() {
# Check if jq and yq are installed
if ! command -v jq &> /dev/null; then
echo "Error: jq is not installed. Please install jq before running this script."
exit 1
fi
if ! command -v yq &> /dev/null; then
echo "Error: yq is not installed. Please install yq before running this script."
exit 1
fi
# Check for each variable
if [ -z "$HA_SSH_KEY" ] || [ "$HA_SSH_KEY" == "SSH_PRIVATE_KEY" ]; then
echo "Error: SSH_PRIVATE_KEY is not set."
exit 1
fi
if [ -z "$HA_SSH_USER" ] || [ "$HA_SSH_USER" == "SSH_USER" ]; then
echo "Error: SSH_USER is not set."
exit 1
fi
if [ -z "$HA_SSH_HOST" ] || [ "$HA_SSH_HOST" == "SSH_HOST_IP_OR_HOSTNAME" ]; then
echo "Error: SSH_HOST_IP_OR_HOSTNAME is not set."
exit 1
fi
if [ -z "$HA_SSH_PORT" ] || [ "$HA_SSH_PORT" == "SSH_PORT" ]; then
echo "Error: SSH_PORT is not set."
exit 1
fi
if [ -z "$BACKUP_DEST" ] || [ "$BACKUP_DEST" == "BACKUP_FILE_DESTINATION" ]; then
echo "Error: BACKUP_FILE_DESTINATION is not set."
exit 1
fi
if [ "$NTFY_ENABLED" == "true" ]; then
if [ -z "$NTFY_SERVER" ] || [ "$NTFY_SERVER" == "NTFY_SERVER_FQDN_ADDRESS" ]; then
echo "Error: NTFY_SERVER_FQDN_ADDRESS is not set."
exit 1
fi
if [ -z "$NTFY_TOPIC" ] || [ "$NTFY_TOPIC" == "NTFY_TOPIC" ]; then
echo "Error: NTFY_TOPIC is not set."
exit 1
fi
fi
if [ "$DISCORD_ENABLED" == "true" ]; then
if [ -z "$DISCORD_WEBHOOK_URL" ] || [ "$DISCORD_WEBHOOK_URL" == "DISCORD_WEBHOOK_URL" ]; then
echo "Error: DISCORD_WEBHOOK_URL is not set."
exit 1
fi
fi
}
# Call the function to check requirements and variables
sanity_check
# Function to remove old backups from Home Assistant server (older than $REMOTE_BACKUP_CLEANUP days)
remove_old_backups_ssh() {
echo "Removing backups older than $REMOTE_BACKUP_CLEANUP days on Home Assistant server..."
# Fetch the list of backups and parse the YAML output using yq
BACKUPS_OUTPUT=$(ssh -i "$HA_SSH_KEY" -p "$HA_SSH_PORT" "$HA_SSH_USER@$HA_SSH_HOST" "ha backups")
if ! echo "$BACKUPS_OUTPUT" | yq eval >/dev/null 2>&1; then
echo "Error: The output of 'ha backups' is not valid YAML."
return 1
fi
# Convert the YAML to JSON and extract old backups using jq
OLD_BACKUPS=$(echo "$BACKUPS_OUTPUT" | yq eval -o=json | jq --argjson days "$REMOTE_BACKUP_CLEANUP" -r '.backups[] | select((.date | sub("\\.[0-9]+\\+00:00$"; "Z") | fromdateiso8601) < (now - ($days * 86400))) | .slug')
# Remove each old backup by its slug
if [ -n "$OLD_BACKUPS" ]; then
for slug in $OLD_BACKUPS; do
echo "Removing backup with slug: $slug"
ssh -i "$HA_SSH_KEY" -p "$HA_SSH_PORT" "$HA_SSH_USER@$HA_SSH_HOST" "ha backups remove $slug"
if [ $? -eq 0 ]; then
echo "Successfully removed backup with slug: $slug"
send_ntfy_notification "Old backup removed: $slug"
send_discord_notification "Old backup removed: $slug"
else
echo "Failed to remove backup with slug: $slug"
fi
done
else
echo "No old backups found to remove."
fi
}
### Function for sending notifications ###
send_ntfy_notification() {
local message=$1
if [ "$NTFY_ENABLED" = true ]; then
curl -X POST -d "$message" $NTFY_URL
fi
}
send_discord_notification() {
local message=$1
if [ "$DISCORD_ENABLED" = true ]; then
curl -X POST -H "Content-Type: application/json" -d '{"content": "'"$message"'"}' $DISCORD_WEBHOOK_URL
fi
}
# ntfy URL setup
NTFY_URL="$NTFY_SERVER/$NTFY_TOPIC"
# Timestamp variable for consistent naming
TIMESTAMP=$(date '+%Y-%m-%d_%H-%M-%S')
# Start the timer
START_TIME=$(date +%s)
# Send notification that backup has started
send_ntfy_notification "Backup process started at $TIMESTAMP"
send_discord_notification "Backup process started at $TIMESTAMP"
# Check available disk space on Home Assistant server
AVAILABLE_SPACE=$(ssh -i "$HA_SSH_KEY" -p "$HA_SSH_PORT" "$HA_SSH_USER@$HA_SSH_HOST" "df -k / | tail -n 1 | tr -s ' ' | cut -d ' ' -f 4")
if [ "$AVAILABLE_SPACE" -lt "$STORAGE_THRESHOLD_KB" ]; then
echo "Disk space is less than 2GB on Home Assistant server. Proceeding with remote backup cleanup..."
# Remove old backups on Home Assistant server if less than 2GB available
remove_old_backups_ssh
else
echo "Sufficient disk space available on Home Assistant server. Skipping remote backup cleanup."
fi
# Create a backup on the Home Assistant server and extract the slug
BACKUP_OUTPUT=$(ssh -i "$HA_SSH_KEY" -p "$HA_SSH_PORT" "$HA_SSH_USER@$HA_SSH_HOST" "ha backup new --name=auto_backup_$TIMESTAMP")
if [ $? -ne 0 ]; then
# Send notification if backup creation fails
send_ntfy_notification "Error: Failed to create backup on Home Assistant server."
send_discord_notification "Error: Failed to create backup on Home Assistant server."
echo "Error: Failed to create backup on Home Assistant server."
exit 1
fi
# Extract the backup slug and strip any carriage return characters
BACKUP_SLUG=$(echo "$BACKUP_OUTPUT" | awk '/slug:/ {print $2}' | tr -d '\r')
if [ -z "$BACKUP_SLUG" ]; then
# Send notification if backup slug extraction fails
send_ntfy_notification "Error: Failed to extract backup slug."
send_discord_notification "Error: Failed to extract backup slug."
echo "Error: Failed to extract backup slug."
exit 1
fi
# Send notification that backup was created
send_ntfy_notification "Backup created successfully with slug: $BACKUP_SLUG"
send_discord_notification "Backup created successfully with slug: $BACKUP_SLUG"
# Create a filename for the backup
BACKUP_FILE="$BACKUP_DEST/${TIMESTAMP}_${BACKUP_SLUG}.tar"
# Copy the backup file from Home Assistant to your local machine
scp -i "$HA_SSH_KEY" -P "$HA_SSH_PORT" "$HA_SSH_USER@$HA_SSH_HOST:$BACKUP_FILE_LOCATION/${BACKUP_SLUG}.tar" "$BACKUP_FILE"
if [ $? -ne 0 ]; then
# Send notification if backup copy fails
send_ntfy_notification "Error: Failed to copy backup to local machine."
send_discord_notification "Error: Failed to copy backup to local machine."
echo "Error: Failed to copy backup to local machine."
exit 1
fi
# Get the size of the backup file
BACKUP_FILE_SIZE=$(stat -c %s "$BACKUP_FILE")
if [ $? -ne 0 ]; then
send_ntfy_notification "Error: Failed to get backup file size."
send_discord_notification "Error: Failed to get backup file size."
echo "Error: Failed to get backup file size."
exit 1
fi
# Clean up old backups locally (older than $LOCAL_BACKUP_CLEANUP days)
find "$BACKUP_DEST" -name '*.tar' -mtime +$LOCAL_BACKUP_CLEANUP -exec rm {} \;
if [ $? -ne 0 ]; then
# Send notification if cleanup fails
send_ntfy_notification "Error: Failed to clean up old backups locally."
send_discord_notification "Error: Failed to clean up old backups locally."
echo "Error: Failed to clean up old backups locally."
exit 1
fi
# Send notification that the cleanup was successful
send_ntfy_notification "Old backups cleaned up successfully."
send_discord_notification "Old backups cleaned up successfully."
# Success message
echo "Backup completed successfully: $BACKUP_FILE"
send_ntfy_notification "Backup completed successfully: $BACKUP_FILE"
send_discord_notification "Backup completed successfully: $BACKUP_FILE"
# Get the total time taken by the script
END_TIME=$(date +%s)
RUNTIME=$((END_TIME - START_TIME))
# Send notification with backup file size and script runtime
send_ntfy_notification "Backup file size: $BACKUP_FILE_SIZE bytes. Script runtime: $RUNTIME seconds."
send_discord_notification "Backup file size: $BACKUP_FILE_SIZE bytes. Script runtime: $RUNTIME seconds."
-26
View File
@@ -1,26 +0,0 @@
# ha-backup
Go port of `home_assistant_backup.sh`. Uses the same `home_assistant_backup.conf`.
## Build
```
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ha-backup .
```
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
```
## Run
#### looks for home_assistant_backup.conf next to the binary, then in cwd
```
./ha-backup
```
```
./ha-backup -config /path/to.conf
```
Requires `ssh` and `scp` on the machine running it. `jq` and `yq` are no longer needed.
View File
File renamed without changes.
@@ -30,3 +30,18 @@ NTFY_TOPIC="NTFY_TOPIC" # Replace with your ntfy topic
DISCORD_ENABLED=false # Set to 'true' to enable Discord notifications DISCORD_ENABLED=false # Set to 'true' to enable Discord notifications
DISCORD_WEBHOOK_URL="DISCORD_WEBHOOK_URL" # Replace with your Discord webhook URL 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"
## File Validation
# Verify the local copy against the backup on the Home Assistant host using
# SHA-256 (sha256sum on the server, compared with the hash of the local file).
# If the hashes differ, the local copy is deleted and an error notification is
# sent; the backup on the Home Assistant server is left untouched so you can retry.
# Verification reads the whole file twice (once on each side), so it adds time
# on large backups. Set to "false" to skip it. Accepts true/false in any case.
VERIFY_CHECKSUM="true" # "true" (default) or "false"
+365 -29
View File
@@ -1,6 +1,7 @@
// ha-backup: creates a Home Assistant backup over SSH, copies it locally, // ha-backup v3.3.0: creates a Home Assistant backup over SSH, copies it locally,
// prunes old backups (remote and local) and sends optional ntfy/Discord // verifies the copy with a SHA-256 checksum, prunes old backups (remote and
// notifications. Go port of home_assistant_backup.sh. // 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 // 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. // still required (same as the original script), but `jq` and `yq` are not.
@@ -9,9 +10,12 @@ package main
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"net/http" "net/http"
"os" "os"
@@ -20,6 +24,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
) )
@@ -29,6 +34,14 @@ import (
const defaultConfigName = "home_assistant_backup.conf" 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.3.0"
var version = "3.3.0"
type Config struct { type Config struct {
SSHKey string SSHKey string
SSHUser string SSHUser string
@@ -41,6 +54,15 @@ type Config struct {
LocalCleanupDays int LocalCleanupDays int
RemoteCleanupDays int RemoteCleanupDays int
// VerifyChecksum enables the SHA-256 comparison between the backup on the
// Home Assistant host and the local copy. Set VERIFY_CHECKSUM="false" to
// skip it.
VerifyChecksum bool
// LogFile is the path of the log file. Set LOG_FILE="none" to disable
// file logging.
LogFile string
NtfyEnabled bool NtfyEnabled bool
NtfyServer string NtfyServer string
NtfyTopic string NtfyTopic string
@@ -108,6 +130,11 @@ func loadConfig(path string) (*Config, error) {
return def return def
} }
// getBool treats "true" in any case (true, True, TRUE) as true.
getBool := func(key, def string) bool {
return strings.EqualFold(get(key, def), "true")
}
cfg := &Config{ cfg := &Config{
SSHKey: get("HA_SSH_KEY", ""), SSHKey: get("HA_SSH_KEY", ""),
SSHUser: get("HA_SSH_USER", ""), SSHUser: get("HA_SSH_USER", ""),
@@ -115,10 +142,12 @@ func loadConfig(path string) (*Config, error) {
SSHPort: get("HA_SSH_PORT", ""), SSHPort: get("HA_SSH_PORT", ""),
BackupDest: get("BACKUP_DEST", ""), BackupDest: get("BACKUP_DEST", ""),
BackupFileLocation: strings.TrimRight(get("BACKUP_FILE_LOCATION", "/backup"), "/"), BackupFileLocation: strings.TrimRight(get("BACKUP_FILE_LOCATION", "/backup"), "/"),
NtfyEnabled: get("NTFY_ENABLED", "false") == "true", VerifyChecksum: getBool("VERIFY_CHECKSUM", "true"),
LogFile: get("LOG_FILE", defaultLogFile),
NtfyEnabled: getBool("NTFY_ENABLED", "false"),
NtfyServer: get("NTFY_SERVER", ""), NtfyServer: get("NTFY_SERVER", ""),
NtfyTopic: get("NTFY_TOPIC", ""), NtfyTopic: get("NTFY_TOPIC", ""),
DiscordEnabled: get("DISCORD_ENABLED", "false") == "true", DiscordEnabled: getBool("DISCORD_ENABLED", "false"),
DiscordWebhookURL: get("DISCORD_WEBHOOK_URL", ""), DiscordWebhookURL: get("DISCORD_WEBHOOK_URL", ""),
} }
@@ -165,6 +194,56 @@ func (c *Config) validate() error {
return nil 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 // Notifications
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -181,12 +260,12 @@ func NewNotifier(cfg *Config) *Notifier {
func (n *Notifier) post(url, contentType string, body []byte) { func (n *Notifier) post(url, contentType string, body []byte) {
resp, err := n.client.Post(url, contentType, bytes.NewReader(body)) resp, err := n.client.Post(url, contentType, bytes.NewReader(body))
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "notification failed (%s): %v\n", url, err) logf("notification failed (%s): %v", url, err)
return return
} }
resp.Body.Close() resp.Body.Close()
if resp.StatusCode >= 300 { 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)
} }
} }
@@ -211,12 +290,12 @@ type Remote struct{ cfg *Config }
func (r Remote) target() string { return r.cfg.SSHUser + "@" + r.cfg.SSHHost } 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. // 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) { func (r Remote) run(remoteCmd string) (string, error) {
cmd := exec.Command("ssh", "-i", r.cfg.SSHKey, "-p", r.cfg.SSHPort, r.target(), remoteCmd) cmd := exec.Command("ssh", "-i", r.cfg.SSHKey, "-p", r.cfg.SSHPort, r.target(), remoteCmd)
var out bytes.Buffer var out bytes.Buffer
cmd.Stdout = &out cmd.Stdout = &out
cmd.Stderr = os.Stderr cmd.Stderr = stderrWriter()
err := cmd.Run() err := cmd.Run()
return out.String(), err return out.String(), err
} }
@@ -225,7 +304,7 @@ func (r Remote) copyFrom(remotePath, localPath string) error {
cmd := exec.Command("scp", "-i", r.cfg.SSHKey, "-P", r.cfg.SSHPort, cmd := exec.Command("scp", "-i", r.cfg.SSHKey, "-P", r.cfg.SSHPort,
r.target()+":"+remotePath, localPath) r.target()+":"+remotePath, localPath)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = stderrWriter()
return cmd.Run() return cmd.Run()
} }
@@ -276,54 +355,75 @@ func (r Remote) listBackups() ([]backupEntry, error) {
} }
func removeOldRemoteBackups(r Remote, n *Notifier, days int) { 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() backups, err := r.listBackups()
if err != nil { if err != nil {
fmt.Printf("Error: %v\n", err) logf("Error: %v", err)
return return
} }
logf("Found %d backup(s) on Home Assistant server.", len(backups))
cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour) cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
removed := 0 removed := 0
for _, b := range backups { for _, b := range backups {
t, err := time.Parse(time.RFC3339Nano, b.Date) t, err := time.Parse(time.RFC3339Nano, b.Date)
if err != nil { 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 continue
} }
if !t.Before(cutoff) { if !t.Before(cutoff) {
continue continue
} }
if !slugRe.MatchString(b.Slug) { 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 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 { 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 continue
} }
removed++ 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) n.Send("Old backup removed: " + b.Slug)
} }
if removed == 0 { 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 { type newBackupResp struct {
Data struct { Result string `json:"result"`
Message string `json:"message"`
Data struct {
Slug string `json:"slug"` Slug string `json:"slug"`
} `json:"data"` } `json:"data"`
Slug string `json:"slug"` 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. // createBackup creates a backup and returns its slug.
func (r Remote) createBackup(name string) (string, error) { func (r Remote) createBackup(name string) (string, error) {
out, err := r.run("ha backup new --name=" + name + " --raw-json") out, err := r.run("ha backup new --name=" + name + " --raw-json")
if err != nil { 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) return "", fmt.Errorf("failed to create backup on Home Assistant server: %w", err)
} }
@@ -335,6 +435,9 @@ func (r Remote) createBackup(name string) (string, error) {
if resp.Slug != "" { if resp.Slug != "" {
return strings.TrimSpace(resp.Slug), nil 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). // Fallback: plain-text "slug: xxxx" line (what the original script parsed).
@@ -346,6 +449,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") return "", fmt.Errorf("failed to extract backup slug")
} }
@@ -366,12 +472,160 @@ func removeOldLocalBackups(dir string, days int) error {
return err return err
} }
if now.Sub(info.ModTime()) >= minAge { if now.Sub(info.ModTime()) >= minAge {
logf("Removing old local backup: %s", path)
return os.Remove(path) return os.Remove(path)
} }
return nil return nil
}) })
} }
// ---------------------------------------------------------------------------
// Progress reporting
// ---------------------------------------------------------------------------
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp])
}
func stdoutIsTerminal() bool {
fi, err := os.Stdout.Stat()
return err == nil && fi.Mode()&os.ModeCharDevice != 0
}
// withHeartbeat runs fn and prints a "still running" line every interval so
// long operations don't look hung.
func withHeartbeat(label string, interval time.Duration, fn func() error) error {
start := time.Now()
done := make(chan struct{})
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-done:
return
case <-t.C:
logf("%s... still running (%s elapsed)", label, time.Since(start).Round(time.Second))
}
}
}()
err := fn()
close(done)
return err
}
// remoteSize returns the size in bytes of a file on the HA host (0 if unknown).
func (r Remote) remoteSize(path string) int64 {
out, err := r.run("wc -c < '" + path + "'")
if err != nil {
return 0
}
f := strings.Fields(out)
if len(f) == 0 {
return 0
}
n, _ := strconv.ParseInt(f[0], 10, 64)
return n
}
// remoteSHA256 returns the SHA-256 of a file on the HA host.
func (r Remote) remoteSHA256(path string) (string, error) {
out, err := r.run("sha256sum '" + path + "'")
if err != nil {
return "", err
}
f := strings.Fields(out)
if len(f) == 0 || len(f[0]) != 64 {
return "", fmt.Errorf("unexpected sha256sum output: %q", out)
}
return strings.ToLower(f[0]), nil
}
// localSHA256 streams a local file through SHA-256.
func localSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// copyWithProgress runs scp while reporting how much has arrived locally.
// 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 {
logf("Backup size on Home Assistant: %s", humanBytes(total))
}
tty := stdoutIsTerminal()
interval := 10 * time.Second
if tty {
interval = time.Second
}
stop := make(chan struct{})
stopped := make(chan struct{})
start := time.Now()
go func() {
defer close(stopped)
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-stop:
if tty {
fmt.Print("\r\033[K")
}
return
case <-t.C:
info, err := os.Stat(localPath)
if err != nil {
continue
}
cur := info.Size()
secs := time.Since(start).Seconds()
speed := int64(0)
if secs > 0 {
speed = int64(float64(cur) / secs)
}
var msg string
if total > 0 {
msg = fmt.Sprintf("Copying: %s / %s (%d%%) at %s/s",
humanBytes(cur), humanBytes(total), cur*100/total, humanBytes(speed))
} else {
msg = fmt.Sprintf("Copying: %s at %s/s", humanBytes(cur), humanBytes(speed))
}
if tty {
fmt.Printf("\r\033[K[%s] %s", time.Now().Format("15:04:05"), msg)
} else {
logf("%s", msg)
}
}
}
}()
err := r.copyFrom(remotePath, localPath)
close(stop)
<-stopped
return err
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// main // main
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -391,16 +645,29 @@ func findConfig(flagPath string) string {
func main() { func main() {
confFlag := flag.String("config", "", "path to config file (default: "+defaultConfigName+" next to the binary, then current directory)") confFlag := flag.String("config", "", "path to config file (default: "+defaultConfigName+" next to the binary, then current directory)")
versionFlag := flag.Bool("version", false, "print version and exit")
flag.Parse() flag.Parse()
if *versionFlag {
fmt.Printf("ha-backup v%s\n", version)
return
}
confPath := findConfig(*confFlag) confPath := findConfig(*confFlag)
cfg, err := loadConfig(confPath) cfg, err := loadConfig(confPath)
if err != nil { if err != nil {
fmt.Printf("Error: cannot load config file %s: %v\n", confPath, err) fmt.Printf("Error: cannot load config file %s: %v\n", confPath, err)
os.Exit(1) 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 { if err := cfg.validate(); err != nil {
fmt.Printf("Error: %v\n", err) logf("Error: %v", err)
os.Exit(1) os.Exit(1)
} }
@@ -410,41 +677,76 @@ func main() {
fail := func(msg string) { fail := func(msg string) {
full := "Error: " + msg full := "Error: " + msg
notify.Send(full) notify.Send(full)
fmt.Println(full) logf("%s", full)
os.Exit(1) os.Exit(1)
} }
timestamp := time.Now().Format("2006-01-02_15-04-05") timestamp := time.Now().Format("2006-01-02_15-04-05")
start := time.Now() 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) notify.Send("Backup process started at " + timestamp)
// Free-space check on the Home Assistant host. // Free-space check on the Home Assistant host.
logf("Checking free disk space on %s...", cfg.SSHHost)
avail, err := remote.availableKB() avail, err := remote.availableKB()
if err != nil { if err != nil {
fail("failed to check disk space on Home Assistant server: " + err.Error()) fail("failed to check disk space on Home Assistant server: " + err.Error())
} }
if avail < cfg.StorageThresholdKB { logf("Free space: %s (threshold: %s)", humanBytes(avail*1024), humanBytes(cfg.StorageThresholdKB*1024))
fmt.Printf("Disk space is below the threshold (%d KB available, %d KB required). Proceeding with remote backup cleanup...\n", lowSpace := avail < cfg.StorageThresholdKB
avail, cfg.StorageThresholdKB) if lowSpace {
logf("Disk space is below the threshold. Running remote backup cleanup...")
removeOldRemoteBackups(remote, notify, cfg.RemoteCleanupDays) 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 { } else {
fmt.Println("Sufficient disk space available on Home Assistant server. Skipping remote backup cleanup.") logf("Sufficient disk space available. Skipping remote backup cleanup.")
} }
// Create the backup. // Create the backup.
slug, err := remote.createBackup("auto_backup_" + timestamp) logf("Creating backup on Home Assistant (this can take a few minutes)...")
var slug string
err = withHeartbeat("Creating backup", 10*time.Second, func() (e error) {
slug, e = remote.createBackup("auto_backup_" + timestamp)
return e
})
if err != nil { 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) { if !slugRe.MatchString(slug) {
fail(fmt.Sprintf("unexpected backup slug %q.", slug)) fail(fmt.Sprintf("unexpected backup slug %q.", slug))
} }
logf("Backup created with slug: %s", slug)
notify.Send("Backup created successfully with slug: " + slug) notify.Send("Backup created successfully with slug: " + slug)
// Copy it locally. // Copy it locally.
remotePath := cfg.BackupFileLocation + "/" + slug + ".tar"
backupFile := filepath.Join(cfg.BackupDest, fmt.Sprintf("%s_%s.tar", timestamp, slug)) backupFile := filepath.Join(cfg.BackupDest, fmt.Sprintf("%s_%s.tar", timestamp, slug))
if err := remote.copyFrom(cfg.BackupFileLocation+"/"+slug+".tar", backupFile); err != nil { logf("Copying backup to %s ...", backupFile)
copyStart := time.Now()
if err := remote.copyWithProgress(remotePath, backupFile); err != nil {
fail("Failed to copy backup to local machine.") fail("Failed to copy backup to local machine.")
} }
@@ -452,16 +754,50 @@ func main() {
if err != nil { if err != nil {
fail("Failed to get backup file size.") fail("Failed to get backup file size.")
} }
logf("Copy complete: %s in %s", humanBytes(info.Size()), time.Since(copyStart).Round(time.Second))
// Verify the copy against the checksum on the Home Assistant host.
if cfg.VerifyChecksum {
logf("Verifying checksum (SHA-256)...")
var remoteSum, localSum string
err = withHeartbeat("Hashing remote file", 10*time.Second, func() (e error) {
remoteSum, e = remote.remoteSHA256(remotePath)
return e
})
if err != nil {
fail("failed to compute checksum on Home Assistant server: " + err.Error())
}
err = withHeartbeat("Hashing local file", 10*time.Second, func() (e error) {
localSum, e = localSHA256(backupFile)
return e
})
if err != nil {
fail("failed to compute local checksum: " + err.Error())
}
if localSum != remoteSum {
os.Remove(backupFile) // don't leave a corrupt file looking like a good backup
fail(fmt.Sprintf("checksum mismatch, local copy removed (remote %s, local %s).", remoteSum, localSum))
}
logf("Checksum OK: %s", localSum)
notify.Send("Backup checksum verified (SHA-256).")
} else {
logf("Checksum verification disabled (VERIFY_CHECKSUM=false).")
}
// Local retention. // Local retention.
logf("Cleaning up local backups older than %d days...", cfg.LocalCleanupDays)
if err := removeOldLocalBackups(cfg.BackupDest, cfg.LocalCleanupDays); err != nil { if err := removeOldLocalBackups(cfg.BackupDest, cfg.LocalCleanupDays); err != nil {
fail("Failed to clean up old backups locally: " + err.Error()) fail("Failed to clean up old backups locally: " + err.Error())
} }
notify.Send("Old backups cleaned up successfully.") notify.Send("Old backups cleaned up successfully.")
fmt.Println("Backup completed successfully: " + backupFile) logf("Backup completed successfully: %s", backupFile)
notify.Send("Backup completed successfully: " + backupFile) notify.Send("Backup completed successfully: " + backupFile)
runtime := int(time.Since(start).Seconds()) runtime := int(time.Since(start).Seconds())
logf("Total runtime: %d seconds", runtime)
notify.Send(fmt.Sprintf("Backup file size: %d bytes. Script runtime: %d seconds.", info.Size(), runtime)) notify.Send(fmt.Sprintf("Backup file size: %d bytes. Script runtime: %d seconds.", info.Size(), runtime))
} }