Skip to content

Instantly share code, notes, and snippets.

@PhrozenByte
Last active May 14, 2022 21:50
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save PhrozenByte/9d72a89f271bbbb5f2c81add9e8af1b1 to your computer and use it in GitHub Desktop.
Save PhrozenByte/9d72a89f271bbbb5f2c81add9e8af1b1 to your computer and use it in GitHub Desktop.
Shell script to create a snapshot of a Hetzner CX line virtual server

hetzner-snapshot

Shell script to create a snapshot of a Hetzner CX line virtual server.

Install

# script
mv hetzner-snapshot.sh /usr/local/sbin/hetzner-snapshot
chmod +x /usr/local/sbin/hetzner-snapshot

# lib
mkdir /usr/local/lib/hetzner_api
mv lib.sh /usr/local/lib/hetzner_api/

# config file (don't forget to set strict permissions)
( umask 077 && touch /etc/hetzner_api )
cat hetzner_api.conf >> /etc/hetzner_api
#!/bin/bash
# Create Hetzner virtual server snapshots
# Version 1.2 (build 20190723)
#
# Copyright (C) 2017-2019 Daniel Rudolf <www.daniel-rudolf.de>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, version 3 of the License only.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# See <http://www.gnu.org/licenses/> to receive a full-text-copy of
# the GNU General Public License.
APP_NAME="$(basename "$0")"
set -e
VERSION="1.2"
BUILD="20190723"
if [ ! -e /etc/hetzner_api ]; then
echo "$APP_NAME: Unable to read Hetzner API credentials from '/etc/hetzner_api': No such file or directory" >&2
exit 1
elif [ ! -f /etc/hetzner_api ]; then
echo "$APP_NAME: Unable to read Hetzner API credentials from '/etc/hetzner_api': Not a regular file" >&2
exit 1
elif [ ! -r /etc/hetzner_api ]; then
echo "$APP_NAME: Unable to read Hetzner API credentials from '/etc/hetzner_api': Permission denied" >&2
exit 1
fi
. /etc/hetzner_api
. /usr/local/lib/hetzner_api/lib.sh
print_usage() {
echo "Usage:"
echo " $APP_NAME [--latest|--oldest] IP_ADDRESS"
}
# read parameters
SERVER_IP=""
SNAPSHOT_SELECT="latest"
while [ $# -gt 0 ]; do
if [ "$1" == "--oldest" ] || [ "$1" == "-o" ]; then
SNAPSHOT_SELECT="oldest"
elif [ "$1" == "--latest" ] || [ "$1" == "-l" ]; then
SNAPSHOT_SELECT="latest"
elif [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
print_usage
echo
echo "Application options:"
echo " -l, --latest replace the server's latest snapshot (default)"
echo " -o, --oldest replace the server's oldest snapshot"
echo
echo "Help options:"
echo " -h, --help display this help and exit"
echo " --version output version information and exit"
exit 0
elif [ "$1" == "--version" ]; then
echo "hetzner-snapshot.sh $VERSION ($BUILD)"
echo "Copyright (C) 2017-2019 Daniel Rudolf"
echo "License GPLv3: GNU GPL version 3 only <http://gnu.org/licenses/gpl.html>."
echo "This is free software: you are free to change and redistribute it."
echo "There is NO WARRANTY, to the extent permitted by law."
echo
echo "Written by Daniel Rudolf <http://www.daniel-rudolf.de/>"
exit 0
elif [ -z "$SERVER_IP" ]; then
if ! [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "$APP_NAME: Invalid IP address specified" >&2
exit 1
fi
SERVER_IP="$1"
else
print_usage >&2
exit 1
fi
shift
done
if [ -z "$SERVER_IP" ]; then
print_usage >&2
exit 1
fi
# function: returns the latest or oldest snapshot
get_snapshot() {
local SNAPSHOT=""
local SNAPSHOT_RESPOSNE=""
hetzner_api_call --output-var SNAPSHOT_RESPONSE --show-error "GET" "/snapshot/$SERVER_IP" >&2
SNAPSHOT="$(echo "$SNAPSHOT_RESPONSE" | php -r '
$json = json_decode(stream_get_contents(STDIN), true);
if (is_array($json)) {
if (empty($json)) {
exit(0);
}
$json = (isset($_SERVER["argv"]) && ($_SERVER["argv"] === "--latest")) ? array_reverse($json) : $json;
foreach ($json as $item) {
if (isset($item["snapshot"]) && ($item["snapshot"]["type"] === "snapshot")) {
echo $item["snapshot"]["id"];
exit(0);
}
}
}
exit(1);
' -- "--$SNAPSHOT_SELECT")"
if [ $? -ne 0 ]; then
echo "$APP_NAME: Hetzner API call 'GET /snapshot/$SERVER_IP' failed: Invalid JSON response: $SNAPSHOT_RESPONSE" >&2
exit 1
fi
if [ -n "$SNAPSHOT" ]; then
echo "$SNAPSHOT"
return 0
fi
return 1
}
# get list of existing snapshots
echo "Retrieving list of snapshots..."
SNAPSHOT="$(get_snapshot || true)"
# delete the latest snapshot
if [ -n "$SNAPSHOT" ]; then
echo -n "Deleting $SNAPSHOT_SELECT snapshot"
hetzner_api_call --show-error "DELETE" "/snapshot/$SERVER_IP/$SNAPSHOT"
# deleting a snapshot might take some time
for (( i=1 ; i < 100 ; i++ )); do
echo -n "."
if [ $(($i % 3)) -ne 0 ]; then
sleep 3
elif [ "$(get_snapshot || true)" != "$SNAPSHOT" ]; then
break
fi
done
echo
if [ $i -eq 100 ]; then
echo "$APP_NAME: Unable to delete latest snapshot" >&2
exit 1
fi
fi
# create a new snapshot
echo "Creating snapshot..."
hetzner_api_call --show-error "POST" "/snapshot/$SERVER_IP"
HETZNER_API="https://robot-ws.your-server.de"
HETZNER_LOGIN="#ws+XXXXXXXX"
HETZNER_PASSWORD="password"
# Hetzner API library
# Version 1.2 (build 20190723)
#
# Copyright (C) 2017-2019 Daniel Rudolf <www.daniel-rudolf.de>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, version 3 of the License only.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# See <http://www.gnu.org/licenses/> to receive a full-text-copy of
# the GNU General Public License.
if [ -z "$APP_NAME" ]; then
APP_NAME="$(basename "$0")"
fi
if [ -z "$HETZNER_API" ]; then
HETZNER_API="http://hetzner.invalid/"
fi
if [ -z "$HETZNER_LOGIN" ]; then
HETZNER_LOGIN="anonymous"
fi
if [ -z "$HETZNER_PASSWORD" ]; then
HETZNER_PASSWORD=""
fi
hetzner_api_call() {
local __METHOD=""
local __URL=""
local __SHOW_ERROR="no"
local __PRINT_STATUS="no"
local __RESULT_STATUS=""
local __PRINT_DOCUMENT="no"
local __RESULT_DOCUMENT=""
local __DUMP_HEADER="$(mktemp)"
local __DUMP_DOCUMENT="$(mktemp)"
local __CURL_ARGS=()
local __CURL_STATUS=0
while [ $# -gt 0 ]; do
if [ "$1" == "--show-error" ]; then
__SHOW_ERROR="yes"
elif [ "$1" == "--status" ]; then
__PRINT_STATUS="yes"
elif [ "$1" == "--status-var" ]; then
if ! [[ "$2" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
echo "$APP_NAME: Invalid Hetzner API status variable name '$2'" >&2
exit 1
fi
__RESULT_STATUS="$2"
shift
elif [ "$1" == "--output" ]; then
__PRINT_DOCUMENT="yes"
elif [ "$1" == "--output-var" ]; then
if ! [[ "$2" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
echo "$APP_NAME: Invalid Hetzner API document variable name '$2'" >&2
exit 1
fi
__RESULT_DOCUMENT="$2"
shift
elif [ -z "$__METHOD" ]; then
__METHOD="$1"
elif [ -z "$__URL" ]; then
__URL="${1#/}"
else
__CURL_ARGS+=( "$1" )
fi
shift
done
curl --silent --show-error \
--dump-header "$__DUMP_HEADER" \
--output "$__DUMP_DOCUMENT" \
--user "$HETZNER_LOGIN:$HETZNER_PASSWORD" \
"${__CURL_ARGS[@]}" \
--request "$__METHOD" "$HETZNER_API/$__URL" \
|| { __CURL_STATUS=$?; true; }
if [ $__CURL_STATUS -ne 0 ]; then
echo "$APP_NAME: Hetzner API call '$__METHOD /$__URL' failed: Execution of cURL failed" >&2
exit 1
fi
local __HTTP_STATUS="$(head -n1 "$__DUMP_HEADER")"
if ! [[ "$__HTTP_STATUS" =~ ^HTTP/[0-9]+(\.[0-9]+)*\ [0-9]{3}(\ |$) ]]; then
echo "$APP_NAME: Hetzner API call '$__METHOD /$__URL' failed: Invalid HTTP response" >&2
exit 1
fi
local __RESPONSE_CODE="$(echo "$__HTTP_STATUS" | cut -s -d " " -f 2)"
local __RESPONSE_CODE_INFO="$(echo "$__HTTP_STATUS" | cut -s -d " " -f 3-)"
if [ -n "$__RESULT_STATUS" ]; then
eval "$__RESULT_STATUS=\"$__RESPONSE_CODE $__RESPONSE_CODE_INFO\""
fi
if [ "$__PRINT_STATUS" == "yes" ]; then
echo "$__RESPONSE_CODE $__RESPONSE_CODE_INFO"
fi
if [ -n "$__RESULT_DOCUMENT" ]; then
eval "$__RESULT_DOCUMENT=\"\$(cat \"$__DUMP_DOCUMENT\")\""
fi
if [ "$__PRINT_DOCUMENT" == "yes" ]; then
cat "$__DUMP_DOCUMENT"
fi
rm "$__DUMP_HEADER"
rm "$__DUMP_DOCUMENT"
if [ $__RESPONSE_CODE -ge 200 ] && [ $__RESPONSE_CODE -lt 300 ]; then
return 0
elif [ "$__SHOW_ERROR" == "yes" ]; then
echo "$APP_NAME: Hetzner API call '$__METHOD /$__URL' failed: $__RESPONSE_CODE $__RESPONSE_CODE_INFO" >&2
return 1
else
return 1
fi
}
@Rapsoulis
Copy link

Hi,
I'm trying to use your script, but I get the "Invalid HTTP response" error. When I create a custom curl request for the snapshot, the response header is 200 which is the correct status reply.
I've created and moved the files according to your documentation. Is there anyway I can "debug" or have additional information on why I am getting a wrong header response?

@PhrozenByte
Copy link
Author

Hi @Rapsoulis, weird... This error should only occur when curl didn't receive a valid HTTP response (i.e. no HTTP response at all, not just a error status code). Did you configure /etc/hetzner_api properly? Try adding an echo in front of the curl call on line 78 of lib.sh to see what is being executed. If you want to paste it here remember to remove username and password 😉

@Rapsoulis
Copy link

Hi @PhrozenByte, thank you for your reply. Below I'm pasting the echoed command thats being executed. If you need any additional information please let me know. Thank you for your time and effort.

curl --silent --show-error --dump-header /tmp/tmp.CotZnMioDq --output /tmp/tmp.wrnHMrZHhV --user hetzner-api-username:hetzner-api-password --request GET https://robot-ws.your-server.de/snapshot/XX.XX.XXX.XXX

@Rapsoulis
Copy link

I believe the problem lies in the username and parameteres passed to the shell. If I run the curl command manually, without any quotes I get the following error:

curl: option --user: requires parameter
curl: try 'curl --help' or 'curl --manual' for more information

If I add double quotes to both the username and the password, the command runs fine and I get the desired results back. My guess it has to do with the # symbol in the new username scheme that Hetzner has applied.

@PhrozenByte
Copy link
Author

That's super weird... The parameters are quoted, the quotes are just not shown this way. As long as the username doesn't contain a colon (:), it should work just fine (my username has a # in it, too - works just fine...).

Remove the echo, remove the --silent --show-error parameters from curl and add the following lines after the curl call (i.e. line 85):

echo "rc: $?"
echo "header: $(cat "$__DUMP_HEADER")"
echo "document: $(cat "$__DUMP_DOCUMENT")"

@Rapsoulis
Copy link

Now this indeed provides more information - which you can find below:

hetzner-snapshot XX.XX.XXX.XXX
Retrieving list of snapshots...
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 598 100 598 0 0 177 0 0:00:03 0:00:03 --:--:-- 177
rc: 0
header: HTTP/2 200
date: Tue, 23 Jul 2019 12:34:29 GMT
server: Apache
content-length: 598
content-type: application/json; charset=utf-8

document: [{"snapshot":{"id":137556,"timestamp":"2019-03-23T01:35:40+01:00","name":"after upgrade, before autoremove","type":"snapshot","size":600}},{"snapshot":{"id":144992,"timestamp":"2019-05-27T20:00:36+02:00","name":"pre_disk_resize","type":"snapshot","size":600}},{"snapshot":{"id":145070,"timestamp":"2019-05-28T13:23:54+02:00","name":"pre_disk_resize2","type":"snapshot","size":600}},{"snapshot":{"id":150438,"timestamp":"2019-07-18T08:56:21+02:00","name":null,"type":"snapshot","size":600}},{"snapshot":{"id":150808,"timestamp":"2019-07-22T15:42:25+02:00","name":null,"type":"snapshot","size":600}}]
hetzner-snapshot: Hetzner API call 'GET /snapshot/XX.XX.XXX.XXX failed: Invalid HTTP response

I'm not sure why the HTTP response is perceived as wrong, since it seems that correct data are returned. Looking forward to your input.

@PhrozenByte
Copy link
Author

They updated to HTTP/2 😄 Weird that I'm not getting this error, too... I've updated the script accordingly. Please let me know whether it works now.

@Rapsoulis
Copy link

Rapsoulis commented Jul 23, 2019

It's an upgrade :P I already got a copy of the new script. Running it now and it takes a bit of time to delete the latest snapshot (I'll post if I have issues or any error messages). Additionally there is a small thing that needs correction in your new .md file:

mv hetzner-snapshot.sh /usr/local/**sbin**/hetzner-snapshot
chmod +x /usr/local/**bin**/hetzner-snapshot

Would it be possible to modify the script to delete the first snapshot and not the last? I believe it would be more useful to "rotate" the snapshots history, than just delete the last snapshot.

P.S. Deleting the latest snapshot failed:
Retrieving list of snapshots...
Deleting latest snapshot...................................................................................................
hetzner-snapshot: Unable to delete latest snapshot

Is it Hetzner's fault? Or would you like to check the response of the script?
Once again thank you for your time!

@PhrozenByte
Copy link
Author

PhrozenByte commented Jul 23, 2019

Sometimes deleting a snapshot is pretty fast, but sometimes it takes up to 5 minutes on Hetzner's side. Minimum execution time is around 30 seconds. Hetzner's API unfortunately isn't very reliable, I recommend trying it again after e.g. 30 minutes if the script fails (e.g. in a cronjob). Personally I'm running the script up to 3 times with 30 minutes timeout in-between if the script returns a non-zero exit code. However, the reason why it didn't work for you was rather a bug in the script. I took the opportunity to implement your feature request, too 😄 Just check out the script's new --latest (default) and --oldest options.

@Rapsoulis
Copy link

The fixed version deletes snapshots as it should - almost immediately . 👍 The oldest-latest feature works marvelously as well! 👍
A minor addition on handling the response from the successful creation of the snapshot would be nice touch as well - since the last message you get before the script exit is "Creating snapshot...".

The successful response contains these information:

    {
    "snapshot": {
        "id": 151000,
        "timestamp": "2019-07-24T10:25:27+02:00"
        }
    }

@PhrozenByte
Copy link
Author

That's how things are being done in the Linux world 😉 If something is done successfully, you don't output anything. You only output if something went wrong. Just check the script's exit code. If everything went fine it returns zero, otherwise a non-zero code. Not sure whether the POST request returns the information you want, if yes you can simply add the --output option to the final hetzner_api_call call on your own.

@Rapsoulis
Copy link

I've added it already, since I'm getting an e-mail from a cronjob.
Thank you very much for your assistance.

@fdellwing
Copy link

Hi @PhrozenByte,

i rewrote your option handling in the lines 48-91 for some easier reading :)

[ -z "$1" ] && print_usage >&2 && exit 1

options=$(getopt -l "latest,oldest,help,version" -o "lohv" -a -- "$@")

eval set -- "$options"

while true; do
	case $1 in
		-o|--oldest)
			SNAPSHOT_SELECT="oldest"
			;;
		-l|--latest)
			SNAPSHOT_SELECT="latest"
			;;
		-h|--help)
			print_usage
			echo "
			Application options:
			-l, -latest, --latest     replace the server's latest snapshot (default)
			-o, -oldest, --oldest     replace the server's oldest snapshot

			Help options:
			-h, -help, --help         display this help and exit
			-v, -version, --version   output version information and exit"
			exit 0
			;;
		-v|--version)
			echo "hetzner-snapshot.sh $VERSION ($BUILD)
			Copyright (C) 2017-2019 Daniel Rudolf
			License GPLv3: GNU GPL version 3 only <http://gnu.org/licenses/gpl.html>.
			This is free software: you are free to change and redistribute it.
			There is NO WARRANTY, to the extent permitted by law.

			Written by Daniel Rudolf <http://www.daniel-rudolf.de/>"
			exit 0
			;;
		--)
			shift
			break
			;;
	esac
	shift
done

[ -z "$1" ] && print_usage >&2 && exit 1

if ! [[ "$1" =~ ^(([0-9][0-9]?|[0-1][0-9][0-9]|[2][0-4][0-9]|[2][5][0-5])\.){3}([0-9][0-9]?|[0-1][0-9][0-9]|[2][0-4][0-9]|[2][5][0-5])$ ]]; then
	echo "$APP_NAME: Invalid IP address specified" >&2
	exit 1
fi

SERVER_IP="$1"

P.S. I also improved the regexp for the IP matching
P.S.S. getopts would be even nicer to read and use than this, but you opted for long-opts and I did not want to break backwards compatibility :)

@mikulabc
Copy link

mikulabc commented Jan 5, 2021

Hi @PhrozenByte

I just came across your shell script here because there is nothing like it out there and I hate all those external backup services, all very complicated and time-consuming, the snapshots is a goldmine people should be using more to do backups.

Your script seems to sweet and promising, here's the catch:

  • I am a basic user that has no idea where to start in order to use this
  • I know that i need to edit the config and upload this stuff to the server for it to run

But.. if anyone has a big heart and can write in the comments what a noob like me needs to do in order to get this running, i would appreciate it, I use aaPanel as a Web Server Panel for my cloud instance, i could upload files to /root/ with the uploader in the GUI if that helps?! And I can login to "root" and execute commands if it's not too complex..

You guys are all professionals, but think of me as that user that is between Noob and Pro, that simply needs some help with a step-by-step instruction, if you can update the description of "hetzner-snapshot.md" to include a walkthrough for amateurs like me i would be 1000x grateful :)

Or here in the comments would be helpful too, but I am thinking of all the other users landing here from Google too that were looking for automatic snapshots with hetzner cloud

Have a happy new year!

@PhrozenByte
Copy link
Author

@mikulabc This scrip was made for the discontinued Hetzner CX virtual server line and is not compatible with the new Hetzner Cloud infrastructure. The Hetzner Cloud uses a different API. Check out https://www.hetzner.com/cloud#api and Hetzner's official hcloud CLI tool.

@mikulabc
Copy link

mikulabc commented Jan 5, 2021

ok, i will have to continue my search unless you have this already set up too? :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment