Samba Backup

This commit is contained in:
2023-07-23 09:37:27 -05:00
parent 0b8e7992e7
commit 6a6368e10f
23 changed files with 1549 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
#!/command/with-contenv bashio
# shellcheck shell=bash
# shellcheck disable=SC1091
source scripts/config.sh
source scripts/main.sh
source scripts/helper.sh
source scripts/precheck.sh
source scripts/sensor.sh
export __BASHIO_LOG_TIMESTAMP="%y-%m-%d %T"
function run-backup {
(
# synchronize the backup routine
flock -n -x 200 || { bashio::log.warning "Backup already running. Trigger ignored."; return 0; }
bashio::log.info "Backup running ..."
get-sensor
update-sensor "${SAMBA_STATUS[1]}"
# run entire backup steps
# shellcheck disable=SC2015
create-backup && copy-backup && cleanup-backups-local && cleanup-backups-remote \
&& update-sensor "${SAMBA_STATUS[2]}" "ALL" \
|| update-sensor "${SAMBA_STATUS[3]}" "ALL"
sleep 10
update-sensor "${SAMBA_STATUS[0]}"
bashio::log.info "Backup finished"
) 200>/tmp/samba_backup.lockfile
}
# init config and sensor
get-config
get-sensor
# run precheck and exit entire addon in case the check fails
if [ "$SKIP_PRECHECK" = true ]; then
update-sensor "${SAMBA_STATUS[0]}"
elif ! smb-precheck; then
update-sensor "${SAMBA_STATUS[3]}"
exit 1
else
update-sensor "${SAMBA_STATUS[0]}" "ALL"
fi
bashio::log.info "Samba Backup started successfully"
# check the time in the background
if [[ "$TRIGGER_TIME" != "manual" ]]; then
{
bashio::log.debug "Starting main loop ..."
while true; do
current_date=$(date +'%a %H:%M')
[[ "$TRIGGER_DAYS" =~ ${current_date:0:3} && "$current_date" =~ $TRIGGER_TIME ]] && run-backup
sleep 60
done
} &
fi
# start the stdin listener in foreground
bashio::log.debug "Starting stdin listener ..."
while true; do
read -r input
bashio::log.debug "Input received: ${input}"
input=$(echo "$input" | jq -r .)
if [ "$input" = "restore-sensor" ]; then
restore-sensor
elif [ "$input" = "reset-counter" ]; then
get-sensor && reset-counter
bashio::log.info "Counter variables reset successfully"
elif [ "$input" = "trigger" ]; then
run-backup
elif is-extended-trigger "$input"; then
bashio::log.info "Running backup with customized parameters"
overwrite-params "$input" && run-backup && restore-params
else
bashio::log.warning "Received unknown input: ${input}"
fi
done
+131
View File
@@ -0,0 +1,131 @@
#!/command/with-contenv bashio
# shellcheck shell=bash
# shellcheck disable=SC2034
# user input variables
declare TARGET_DIR
declare KEEP_LOCAL
declare KEEP_REMOTE
declare TRIGGER_TIME
declare TRIGGER_DAYS
declare EXCLUDE_ADDONS
declare EXCLUDE_FOLDERS
declare BACKUP_NAME
declare BACKUP_PWD
declare SKIP_PRECHECK
# smbclient command strings
declare SMB
declare ALL_SHARES
# ------------------------------------------------------------------------------
# Read and print config.
# ------------------------------------------------------------------------------
function get-config {
local host
local share
local username
local password
local workgroup
host=$(bashio::config 'host' | escape-input)
share=$(bashio::config 'share' | escape-input)
username=$(bashio::config 'username' | escape-input)
password=$(bashio::config 'password' | escape-input)
bashio::config.exists 'workgroup' && workgroup=$(bashio::config 'workgroup' | escape-input) || workgroup=""
TARGET_DIR=$(bashio::config 'target_dir')
KEEP_LOCAL=$(bashio::config 'keep_local')
KEEP_REMOTE=$(bashio::config 'keep_remote')
TRIGGER_TIME=$(bashio::config 'trigger_time')
TRIGGER_DAYS=$(bashio::config 'trigger_days')
EXCLUDE_ADDONS=$(bashio::config 'exclude_addons')
EXCLUDE_FOLDERS=$(bashio::config 'exclude_folders')
bashio::config.exists 'backup_name' && BACKUP_NAME=$(bashio::config 'backup_name') || BACKUP_NAME=""
bashio::config.exists 'backup_password' && BACKUP_PWD=$(bashio::config 'backup_password') || BACKUP_PWD=""
bashio::config.true 'skip_precheck' && SKIP_PRECHECK=true || SKIP_PRECHECK=false
if [[ -n "$username" && -n "$password" ]]; then
SMB="smbclient -U \"${username}\"%\"${password}\" \"//${host}/${share}\" 2>&1 -t 180"
ALL_SHARES="smbclient -U \"${username}\"%\"${password}\" -L \"//${host}\" 2>&1"
else
SMB="smbclient -N \"//${host}/${share}\" 2>&1 -t 180"
ALL_SHARES="smbclient -N -L \"//${host}\" 2>&1"
fi
# non-default workgroup?
[ -n "$workgroup" ] && SMB="${SMB} -W \"${workgroup}\""
[ -n "$workgroup" ] && ALL_SHARES="${ALL_SHARES} -W \"${workgroup}\""
# legacy SMB protocols allowed?
bashio::config.true 'compatibility_mode' && SMB="${SMB} --option=\"client min protocol\"=\"NT1\""
bashio::config.true 'compatibility_mode' && ALL_SHARES="${ALL_SHARES} --option=\"client min protocol\"=\"NT1\""
bashio::log.info "---------------------------------------------------"
bashio::log.info "Host/Share: ${host}/${share}"
bashio::log.info "Target directory: ${TARGET_DIR}"
bashio::log.info "Keep local/remote: ${KEEP_LOCAL}/${KEEP_REMOTE}"
bashio::log.info "Trigger time: ${TRIGGER_TIME}"
[[ "$TRIGGER_TIME" != "manual" ]] && bashio::log.info "Trigger days: $(echo "$TRIGGER_DAYS" | xargs)"
bashio::log.info "---------------------------------------------------"
return 0
}
# ------------------------------------------------------------------------------
# Escape input given by the user.
#
# Returns the escaped string on stdout
# ------------------------------------------------------------------------------
function escape-input {
local input
read -r input
# escape the evil dollar sign
input=${input//$/\\$}
echo "$input"
}
# ------------------------------------------------------------------------------
# Overwrite the backup parameters.
#
# Arguments
# $1 The json input string
# ------------------------------------------------------------------------------
function overwrite-params {
local input="$1"
local addons
local folders
local name
local password
addons=$(echo "$input" | jq '.exclude_addons[]' 2>/dev/null)
[[ "$addons" != null ]] && EXCLUDE_ADDONS="$addons"
folders=$(echo "$input" | jq '.exclude_folders[]' 2>/dev/null)
[[ "$folders" != null ]] && EXCLUDE_FOLDERS="$folders"
name=$(echo "$input" | jq -r '.backup_name')
[[ "$name" != null ]] && BACKUP_NAME="$name"
password=$(echo "$input" | jq -r '.backup_password')
[[ "$password" != null ]] && BACKUP_PWD="$password"
return 0
}
# ------------------------------------------------------------------------------
# Restore the original backup parameters.
# ------------------------------------------------------------------------------
function restore-params {
EXCLUDE_ADDONS=$(bashio::config 'exclude_addons')
EXCLUDE_FOLDERS=$(bashio::config 'exclude_folders')
bashio::config.exists 'backup_name' && BACKUP_NAME=$(bashio::config 'backup_name') || BACKUP_NAME=""
bashio::config.exists 'backup_password' && BACKUP_PWD=$(bashio::config 'backup_password') || BACKUP_PWD=""
return 0
}
+93
View File
@@ -0,0 +1,93 @@
#!/command/with-contenv bashio
# shellcheck shell=bash
# ------------------------------------------------------------------------------
# Create the backup name by replacing all name patterns.
#
# Returns the final name on stdout
# ------------------------------------------------------------------------------
function generate-backup-name {
local name
local theversion
local thetype
local thedate
if [ -n "$BACKUP_NAME" ]; then
# get all values
theversion=$(ha core info --raw-json | jq -r .data.version)
[[ -n "$EXCLUDE_ADDONS" || -n "$EXCLUDE_FOLDERS" ]] && thetype="Partial" || thetype="Full"
thedate=$(date +'%Y-%m-%d %H:%M')
# replace the string patterns with the real values
name="$BACKUP_NAME"
name=${name/\{version\}/$theversion}
name=${name/\{type\}/$thetype}
name=${name/\{date\}/$thedate}
else
name="Samba Backup $(date +'%Y-%m-%d %H:%M')"
fi
echo "$name"
}
# ------------------------------------------------------------------------------
# Create a valid filename by replacing all forbidden characters.
#
# Arguments
# $1 The original name
#
# Returns the final name on stdout
# ------------------------------------------------------------------------------
function generate-filename {
local input="${1}"
local prefix
declare -a forbidden=('\/' '\\' '\<' '\>' '\:' '\"' '\|' '\?' '\*' '\.' '\..' '\ ' '\-')
for fc in "${forbidden[@]}"; do
input=${input//$fc/_}
done
prefix=${input:0:13}
[ "$prefix" = "Samba_Backup_" ] && echo "${input}" || echo "Samba_Backup_${input}"
}
# ------------------------------------------------------------------------------
# Run a command and log its output (debug or warning).
#
# Arguments
# $1 The command to run
#
# Returns 1 in case the command failed
# ------------------------------------------------------------------------------
function run-and-log {
local cmd="$1"
local result
if result=$(eval "$cmd"); then
[ -n "$result" ] && bashio::log.debug "$result"
else
bashio::log.warning "$result"
return 1
fi
return 0
}
# ------------------------------------------------------------------------------
# Checks if input is an extended trigger.
#
# Arguments
# $1 The input to check
#
# Returns 0 (true) or 1 (false)
# ------------------------------------------------------------------------------
function is-extended-trigger {
local input=${1}
local cmd
if cmd=$(echo "$input" | jq -r '.command' 2>/dev/null); then
[ "$cmd" = "trigger" ] && return 0
fi
return 1
}
+110
View File
@@ -0,0 +1,110 @@
#!/command/with-contenv bashio
# shellcheck shell=bash
declare SLUG
declare SNAP_NAME
# ------------------------------------------------------------------------------
# Create a new backup (full or partial).
# ------------------------------------------------------------------------------
function create-backup {
local args
local addons
local folders
SNAP_NAME=$(generate-backup-name)
args=()
args+=("--name" "$SNAP_NAME")
[ -n "$BACKUP_PWD" ] && args+=("--password" "$BACKUP_PWD")
# do we need a partial backup?
if [[ -n "$EXCLUDE_ADDONS" || -n "$EXCLUDE_FOLDERS" ]]; then
# include all installed addons that are not listed to be excluded
addons=$(ha addons --raw-json | jq -rc '.data.addons[] | select (.installed != false) | .slug')
for ad in ${addons}; do [[ ! $EXCLUDE_ADDONS =~ $ad ]] && args+=("-a" "$ad"); done
# include all folders that are not listed to be excluded
folders=(homeassistant ssl share addons/local media)
for fol in "${folders[@]}"; do [[ ! $EXCLUDE_FOLDERS =~ $fol ]] && args+=("-f" "$fol"); done
fi
# run the command
bashio::log.info "Creating backup \"${SNAP_NAME}\""
SLUG="$(ha backups new "${args[@]}" --raw-json | jq -r .data.slug)"
}
# ------------------------------------------------------------------------------
# Copy the latest backup to the remote share.
# ------------------------------------------------------------------------------
function copy-backup {
local store_name
local input
local count
if [ "$SLUG" = "null" ]; then
bashio::log.error "Error occurred! Backup could not be created! Please try again"
return 1
fi
store_name=$(generate-filename "$SNAP_NAME")
# append number to filename if already existing
input="$(eval "${SMB} -c 'cd \"${TARGET_DIR}\"; ls'")"
count=$(echo "$input" | grep "\<$store_name.*\.tar\>" | wc -l)
(( "$count" > 0 )) && store_name="${store_name}${count}.tar" || store_name="${store_name}.tar"
bashio::log.info "Copying backup ${SLUG} (${store_name}) to share"
cd /backup || return 1
if ! run-and-log "${SMB} -c 'cd \"${TARGET_DIR}\"; put ${SLUG}.tar ${store_name}'"; then
bashio::log.warning "Could not copy backup ${SLUG} to share. Trying again ..."
sleep 5
run-and-log "${SMB} -c 'cd \"${TARGET_DIR}\"; put ${SLUG}.tar ${store_name}'"
fi
}
# ------------------------------------------------------------------------------
# Delete old local backups.
# ------------------------------------------------------------------------------
function cleanup-backups-local {
local snaps
local slug
local name
[ "$KEEP_LOCAL" == "all" ] && return 0
snaps=$(ha backups --raw-json | jq -c '.data.backups[] | {date,slug,name}' | sort -r)
bashio::log.debug "List of local backups:\n$snaps"
echo "$snaps" | tail -n +$((KEEP_LOCAL + 1)) | while read -r backup; do
slug=$(echo "$backup" | jq -r .slug)
name=$(echo "$backup" | jq -r .name)
bashio::log.info "Deleting ${slug} (${name}) local"
run-and-log "ha backups remove ${slug}"
done
}
# ------------------------------------------------------------------------------
# Delete old backups on the share.
# ------------------------------------------------------------------------------
function cleanup-backups-remote {
local input
local snaps
[ "$KEEP_REMOTE" == "all" ] && return 0
# read all tar files that match the backup name pattern and sort them
input="$(eval "${SMB} -c 'cd \"${TARGET_DIR}\"; ls'")"
snaps="$(echo "$input" | grep -E '\<([0-9a-f]{8}|Samba_Backup_.*)\.tar\>' | while read -r name _ _ _ a b c d; do
theDate=$(echo "$a $b $c $d" | xargs -i date +'%Y-%m-%d %H:%M' -d "{}")
echo "$theDate $name"
done | sort -r)"
bashio::log.debug "List of remote backups:\n$snaps"
echo "$snaps" | tail -n +$((KEEP_REMOTE + 1)) | while read -r _ _ name; do
bashio::log.info "Deleting ${name} on share"
run-and-log "${SMB} -c 'cd \"${TARGET_DIR}\"; rm ${name}'"
done
}
+71
View File
@@ -0,0 +1,71 @@
#!/command/with-contenv bashio
# shellcheck shell=bash
# ------------------------------------------------------------------------------
# Perform a pre-check if the Samba share is configured correctly.
#
# Returns 1 in case of a failure
# ------------------------------------------------------------------------------
function smb-precheck {
local result
local shares
# check if we can access the share at all
if ! result=$(eval "${SMB} -c 'exit'"); then
bashio::log.warning "$result"
# host not found
if [[ "$result" =~ "NT_STATUS_NOT_FOUND" ]]; then
bashio::log.fatal "The provided host cannot be found. If you've specified a DNS name, please try using an IP address instead."
# host unreachable
elif [[ "$result" =~ "NT_STATUS_HOST_UNREACHABLE" ]]; then
bashio::log.fatal "The provided host is unreachable. Please check your config and network."
# SMB1 problem
elif [[ "$result" =~ "NT_STATUS_CONNECTION_DISCONNECTED" ]]; then
bashio::log.fatal "Cannot access share. It seems that your share only supports insecure SMB protocols."
bashio::log.fatal "If you want me to connect, please check the \"compatibility_mode\" option. Use at your own risk."
# share does not exist
elif [[ "$result" =~ "NT_STATUS_BAD_NETWORK_NAME" ]]; then
bashio::log.fatal "Cannot access share. It seems that your configured share does not exist."
# try to find out which shares exist
if shares=$(eval "${ALL_SHARES}"); then
bashio::log.fatal "I found the following shares on your host. Did you mean one of those?"
bashio::log.fatal "$shares"
fi
# access denied
elif [[ "$result" =~ "NT_STATUS_ACCESS_DENIED" ]]; then
bashio::log.fatal "Cannot access share. Access denied. Please check your share permissions."
# login failed
elif [[ "$result" =~ "NT_STATUS_LOGON_FAILURE" ]]; then
bashio::log.fatal "Cannot access share. Login failed. Please check your credentials."
# unknown reason
else
bashio::log.fatal "Cannot access share. Unknown reason."
fi
return 1
fi
# check if the target directory exists
if ! run-and-log "${SMB} -c 'cd \"${TARGET_DIR}\"'"; then
bashio::log.fatal "Target directory does not exist. Please check your config."
return 1
fi
# check if we have write permissions
touch samba-tmp123
if ! run-and-log "${SMB} -c 'cd \"${TARGET_DIR}\"; put samba-tmp123; rm samba-tmp123'"; then
bashio::log.fatal "Missing write permissions on target folder. Please check your share settings."
return 1
fi
rm samba-tmp123
return 0
}
+190
View File
@@ -0,0 +1,190 @@
#!/command/with-contenv bashio
# shellcheck shell=bash
declare SAMBA_STATUS=(IDLE RUNNING SUCCEEDED FAILED)
declare SENSOR_NAME="sensor.samba_backup"
declare SENSOR_URL="/core/api/states/${SENSOR_NAME}"
declare STORAGE_FILE="/backup/.samba_backup.sensor"
declare CURRENT_STATUS
declare BACKUPS_LOCAL="0"
declare BACKUPS_REMOTE="0"
declare TOTAL_SUCCESS="0"
declare TOTAL_FAIL="0"
declare LAST_BACKUP="never"
# ------------------------------------------------------------------------------
# Get the current sensor values and store them in internal variables.
# ------------------------------------------------------------------------------
function get-sensor {
local storage
local result
if [ -f "$STORAGE_FILE" ]; then
storage=$(cat "$STORAGE_FILE")
if result=$(echo "$storage" | jq -r ".attributes.backups_local" 2>/dev/null); then
[[ "$result" != null ]] && BACKUPS_LOCAL="$result"
fi
if result=$(echo "$storage" | jq -r ".attributes.backups_remote" 2>/dev/null); then
[[ "$result" != null ]] && BACKUPS_REMOTE="$result"
fi
if result=$(echo "$storage" | jq -r ".attributes.total_backups_succeeded" 2>/dev/null); then
[[ "$result" != null ]] && TOTAL_SUCCESS="$result"
fi
if result=$(echo "$storage" | jq -r ".attributes.total_backups_failed" 2>/dev/null); then
[[ "$result" != null ]] && TOTAL_FAIL="$result"
fi
if result=$(echo "$storage" | jq -r ".attributes.last_backup" 2>/dev/null); then
[[ "$result" != null ]] && LAST_BACKUP="$result"
fi
fi
bashio::log.debug "Backups local/remote: ${BACKUPS_LOCAL}/${BACKUPS_REMOTE}"
bashio::log.debug "Total backups succeeded/failed: ${TOTAL_SUCCESS}/${TOTAL_FAIL}"
bashio::log.debug "Last backup: ${LAST_BACKUP}"
return 0
}
# ------------------------------------------------------------------------------
# Update the Home Assistant sensor.
#
# Arguments
# $1 The status
# $2 Whether to update all values
# ------------------------------------------------------------------------------
function update-sensor {
local status=${1}
local all=${2:-}
local data
local response
CURRENT_STATUS="$status"
if bashio::var.has_value "${all}"; then
if response=$(ha backups --raw-json | jq ".data.backups[].slug"); then
[ -n "$response" ] && BACKUPS_LOCAL=$(echo "$response" | wc -l) || BACKUPS_LOCAL="0"
fi
if response=$(eval "${SMB} -c 'cd \"${TARGET_DIR}\"; ls'"); then
# grep returns non-zero exit code if there are no matches
if response=$(echo "$response" | grep -E '\<([0-9a-f]{8}|Samba_Backup_.*)\.tar\>'); then
BACKUPS_REMOTE=$(echo "$response" | wc -l)
else
BACKUPS_REMOTE="0"
fi
fi
if [ "$CURRENT_STATUS" = "${SAMBA_STATUS[2]}" ]; then
TOTAL_SUCCESS=$((TOTAL_SUCCESS + 1))
LAST_BACKUP=$(date +'%Y-%m-%d %H:%M')
elif [ "$CURRENT_STATUS" = "${SAMBA_STATUS[3]}" ]; then
TOTAL_FAIL=$((TOTAL_FAIL + 1))
fi
fi
data=$(jq -n \
--arg s "$CURRENT_STATUS" \
--arg bl "$BACKUPS_LOCAL" \
--arg br "$BACKUPS_REMOTE" \
--arg ts "$TOTAL_SUCCESS" \
--arg tf "$TOTAL_FAIL" \
--arg lb "$LAST_BACKUP" \
'{
"state": $s,
"attributes": {
"friendly_name": "Samba Backup",
"backups_local": $bl,
"backups_remote": $br,
"total_backups_succeeded": $ts,
"total_backups_failed": $tf,
"last_backup": $lb
}
}')
if ! response=$(ha-post-sensor "$data"); then
bashio::log.error "Unable to update sensor ${SENSOR_NAME} in Home Assistant"
fi
echo "$data" > "$STORAGE_FILE"
return 0
}
# ------------------------------------------------------------------------------
# Restore the Home Assistant sensor with the last known values.
# ------------------------------------------------------------------------------
function restore-sensor {
local data
local response
if [ -f "$STORAGE_FILE" ]; then
data=$(cat "$STORAGE_FILE")
if ! response=$(ha-post-sensor "$data"); then
bashio::log.error "Unable to restore sensor ${SENSOR_NAME} in Home Assistant"
fi
fi
return 0
}
# ------------------------------------------------------------------------------
# Reset the counter variables of the Home Assistant sensor.
# ------------------------------------------------------------------------------
function reset-counter {
TOTAL_SUCCESS="0"
TOTAL_FAIL="0"
update-sensor "$CURRENT_STATUS"
return 0
}
# ------------------------------------------------------------------------------
# ----------------------- INTERNAL FUNCTION ------------------------------------
# ------------------------------------------------------------------------------
# Post a sensor via the REST API of Home Assistant.
#
# Arguments:
# $1 The JSON data to POST
# ------------------------------------------------------------------------------
function ha-post-sensor {
local data=${1}
local status
local response
bashio::log.debug "Posting sensor data to API at ${SENSOR_URL}"
if ! response=$(curl --silent --show-error \
--write-out '\n%{http_code}' --request "POST" \
-H "Authorization: Bearer ${__BASHIO_SUPERVISOR_TOKEN}" \
-H "Content-Type: application/json" \
-d "${data}" \
"${__BASHIO_SUPERVISOR_API}${SENSOR_URL}"
); then
bashio::log.debug "${response}"
bashio::log.error "Something went wrong contacting the API"
return 1
fi
status=${response##*$'\n'}
response=${response%$status}
bashio::log.debug "API Status: ${status}"
bashio::log.debug "API Response: ${response}"
if [[ "${status}" -eq 401 ]]; then
bashio::log.error "Unable to authenticate with the API, permission denied"
return 1
fi
echo "${response}"
return 0
}