├── .gitignore ├── CONFIGURE_KEEPALIVED.md ├── LICENSE ├── README.md ├── config.json.sample ├── hcloud_failover.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/code,python 3 | # Edit at https://www.gitignore.io/?templates=code,python 4 | 5 | ### Code ### 6 | # Visual Studio Code - https://code.visualstudio.com/ 7 | .settings/ 8 | .vscode/ 9 | tsconfig.json 10 | jsconfig.json 11 | 12 | ### Python ### 13 | # Byte-compiled / optimized / DLL files 14 | __pycache__/ 15 | *.py[cod] 16 | *$py.class 17 | 18 | # C extensions 19 | *.so 20 | 21 | # Distribution / packaging 22 | .Python 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | wheels/ 35 | *.egg-info/ 36 | .installed.cfg 37 | *.egg 38 | MANIFEST 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .nox/ 54 | .coverage 55 | .coverage.* 56 | .cache 57 | nosetests.xml 58 | coverage.xml 59 | *.cover 60 | .hypothesis/ 61 | .pytest_cache/ 62 | 63 | # Translations 64 | *.mo 65 | *.pot 66 | 67 | # Django stuff: 68 | *.log 69 | local_settings.py 70 | db.sqlite3 71 | 72 | # Flask stuff: 73 | instance/ 74 | .webassets-cache 75 | 76 | # Scrapy stuff: 77 | .scrapy 78 | 79 | # Sphinx documentation 80 | docs/_build/ 81 | 82 | # PyBuilder 83 | target/ 84 | 85 | # Jupyter Notebook 86 | .ipynb_checkpoints 87 | 88 | # IPython 89 | profile_default/ 90 | ipython_config.py 91 | 92 | # pyenv 93 | .python-version 94 | 95 | # celery beat schedule file 96 | celerybeat-schedule 97 | 98 | # SageMath parsed files 99 | *.sage.py 100 | 101 | # Environments 102 | .env 103 | .venv 104 | env/ 105 | venv/ 106 | ENV/ 107 | env.bak/ 108 | venv.bak/ 109 | 110 | # Spyder project settings 111 | .spyderproject 112 | .spyproject 113 | 114 | # Rope project settings 115 | .ropeproject 116 | 117 | # mkdocs documentation 118 | /site 119 | 120 | # mypy 121 | .mypy_cache/ 122 | .dmypy.json 123 | dmypy.json 124 | 125 | # Pyre type checker 126 | .pyre/ 127 | 128 | ### Python Patch ### 129 | .venv/ 130 | 131 | ### Python.VirtualEnv Stack ### 132 | # Virtualenv 133 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 134 | [Bb]in 135 | [Ii]nclude 136 | [Ll]ib 137 | [Ll]ib64 138 | [Ll]ocal 139 | [Ss]cripts 140 | pyvenv.cfg 141 | pip-selfcheck.json 142 | 143 | # End of https://www.gitignore.io/api/code,python 144 | 145 | config.json -------------------------------------------------------------------------------- /CONFIGURE_KEEPALIVED.md: -------------------------------------------------------------------------------- 1 | # How to configure keepalived 2 | 3 | ## 1. Install keepalived 4 | ``` 5 | apt install keepalived 6 | ``` 7 | 8 | ## 2. Configure keepalived 9 | Copy the following config to `/etc/keepalived/keepalived.conf` and change it to fit your needs. 10 | 11 | ``` 12 | vrrp_instance LB_1 { 13 | state MASTER 14 | interface eth0 15 | virtual_router_id 1 16 | priority 100 17 | 18 | unicast_src_ip 1.1.1.1 19 | unicast_peer { 20 | 1.1.1.2 21 | } 22 | 23 | authentication { 24 | auth_type PASS 25 | auth_pass imasupersecurepassword 26 | } 27 | 28 | notify "/bin/sudo /opt/hcloud-failover/hcloud_failover.py" 29 | } 30 | ``` 31 | 32 | On the second (and all other servers) change: 33 | * the name of the vrrp instance (LB_1) 34 | * the state (to BACKUP) 35 | * the virtual_router_id to something unique 36 | * the priority to something that matches your needs 37 | * the unicast_src_ip to the ip of the local system 38 | * the unicast_peer array to the ip addresses of all other systems 39 | 40 | ## 3. Create a service user 41 | ``` 42 | adduser keepalived_script 43 | ``` 44 | 45 | ## 4. Install and configure sudo 46 | ``` 47 | apt install sudo 48 | vim /etc/sudoers.d/90-keepalived 49 | ``` 50 | 51 | ``` 52 | keepalived_script ALL=NOPASSWD: /bin/ip*, /opt/hcloud-failover/hcloud_failover.py* 53 | ``` 54 | 55 | ## 5. Make sure keepalived is enabled and started 56 | ``` 57 | systemctl enable keepalived --now 58 | ``` 59 | 60 | ## 6. Done 61 | Enjoy :) 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Alexander Holzapfel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hetzner Cloud - Floating IP and Private IP switchover - keepalived 2 | 3 | This is a little script for switching the assigned VM of Floating IPs. It's also possible to modify assigned Alias IPs to also use the private networks feature of Hetzner Cloud. 4 | 5 | I am using this script in combination with [keepalived](http://www.keepalived.org). It is tested on Debian based Systems. 6 | 7 | **Credits:** [r3vival](https://github.com/r3vival) | [lehuizi](https://github.com/lehuizi) 8 | **License:** MIT 9 | 10 | 11 | ## How to 12 | 13 | **1. Clone the repo** 14 | ``` 15 | apt install git 16 | git clone https://github.com/lehuizi/hcloud-failover-keepalived.git /opt/hcloud-failover 17 | ``` 18 | 19 | **2. Install requirements** 20 | ``` 21 | apt install python3 python3-pip keepalived 22 | pip3 install -r /opt/hcloud-failover/requirements.txt 23 | ``` 24 | 25 | **3. Copy config.json.sample to config.json** 26 | ``` 27 | cd /opt/hcloud-failover 28 | cp config.json.sample config.json 29 | ``` 30 | 31 | **4. Generate API Key in Hetzner Cloud Console** 32 | 1. Login to Hetzner Cloud Console 33 | 2. Click on your project, choose "Access" in the left menu bar and switch to "API-Tokens" 34 | 3. Click on "Create API Token" and give it a description (eg. Keepalived Failover) 35 | 4. Copy the key and insert it into the `/opt/hcloud-failover/config.json` file (on all servers) 36 | 37 | **5. Fill in the server id** 38 | It is important, that you insert the server id of the current system you are working on. You can find the id on the overview page of each server directly under the type of the server (eg. CX11). 39 | 40 | **6. Configure the floating ips** 41 | You can configure as much floating ips as you want. Just copy the floating ip id from the [hcloud](https://github.com/hetznercloud/cli/releases) command line utility (personally I don't know a way to fetch the ip from the webinterface). 42 | 43 | `hcloud floating-ip list` 44 | 45 | Now also add the ip address to the config file and remove the additional block from the sample config file if you don't need it. 46 | 47 | **7. Fill in the remaining parameters** 48 | 1. The wan interface is the interface name of the primary interface of the server you are currently working on (eg. `ens18` or `eth0`) 49 | 50 | **8. Configure private ip failover (optional)** 51 | 1. If you also want to failover private ip addresses, fill in the required private ips into the "private-ips" array and make sure the "use-private-ips" parameter is set to `true`. Initially you should configure the required ip addresses via webinterface on one of the servers. 52 | 2. Now you have to insert **all** server id's into the array called "server-ids". 53 | 3. Now you have to insert the id of your private network into "network-id". You can fetch this through the url of the webinterface (by clicking on the private network and copying the last id in the url or you copy the id from the hcloud command line interface `hcloud network list`. 54 | 4. Finally fill in the name of your private network interface (eg. `ens10`). 55 | 56 | --- 57 | 58 | Command: 59 | ``` 60 | python3 /path/to/hcloud_failover.py [type] [name] [endstate] 61 | ``` 62 | -------------------------------------------------------------------------------- /config.json.sample: -------------------------------------------------------------------------------- 1 | { 2 | "api-token": "", 3 | "server-id": 0, 4 | "ip-bin-path": "/bin/ip", 5 | "use-private-ips": true, 6 | "url-floating": "https://api.hetzner.cloud/v1/floating_ips/{}/actions/assign", 7 | "floating-ips": [ 8 | { 9 | "floating-ip-id": "", 10 | "floating-ip": "" 11 | }, 12 | { 13 | "floating-ip-id": "", 14 | "floating-ip": "" 15 | } 16 | ], 17 | "interface-wan": "", 18 | "url-alias": "https://api.hetzner.cloud/v1/servers/{}/actions/change_alias_ips", 19 | "private-ips": [ 20 | "127.0.0.1", 21 | "10.0.0.1" 22 | ], 23 | "server-ids": [ 24 | 123, 25 | 1337 26 | ], 27 | "network-id": 123, 28 | "interface-private": "" 29 | } -------------------------------------------------------------------------------- /hcloud_failover.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # (c) 2018 Maximilian Siegl 3 | 4 | import sys 5 | import json 6 | import os 7 | import requests 8 | from multiprocessing import Process 9 | 10 | CONFIG_PATH = os.path.join(os.path.abspath( 11 | os.path.dirname(__file__)), "config.json") 12 | 13 | 14 | def del_ip(ip_bin_path, ip, interface): 15 | os.system(ip_bin_path + " addr del " + ip + "/32 dev " + interface) 16 | 17 | 18 | def add_ip(ip_bin_path, ip, interface): 19 | os.system(ip_bin_path + " addr add " + ip + "/32 dev " + interface) 20 | 21 | 22 | def change_request(endstate, url, header, payload, ip_bin_path, floating_ip, interface): 23 | if endstate == "BACKUP": 24 | del_ip(ip_bin_path, floating_ip, interface) 25 | elif endstate == "FAULT": 26 | del_ip(ip_bin_path, floating_ip, interface) 27 | 28 | elif endstate == "MASTER": 29 | add_ip(ip_bin_path, floating_ip, interface) 30 | print("Post request to: " + url) 31 | print("Header: " + str(header)) 32 | print("Data: " + str(payload)) 33 | r = requests.post(url, data=payload, headers=header) 34 | print("Response:") 35 | print(r.status_code, r.reason) 36 | print(r.text) 37 | else: 38 | print("Error: Endstate not defined!") 39 | 40 | 41 | def change_aliases(url, header, network_id, alias_ips): 42 | payload_raw = { 43 | "network": network_id, 44 | "alias_ips": alias_ips 45 | } 46 | payload = json.dumps(payload_raw) 47 | 48 | print("Post request to: " + url) 49 | print("Header: " + str(header)) 50 | print("Data: " + str(payload)) 51 | r = requests.post(url, data=payload, headers=header) 52 | print("Response:") 53 | print(r.status_code, r.reason) 54 | print(r.text) 55 | 56 | 57 | def main(arg_type, arg_name, arg_endstate): 58 | with open(CONFIG_PATH, "r") as config_file: 59 | config = json.load(config_file) 60 | 61 | header = { 62 | "Content-Type": "application/json", 63 | "Authorization": "Bearer " + config["api-token"] 64 | } 65 | 66 | payload_floating_raw = { 67 | "server": config["server-id"] 68 | } 69 | payload_floating = json.dumps(payload_floating_raw) 70 | 71 | print("Perform action for transition to " + arg_endstate + " state") 72 | 73 | for ip in config["floating-ips"]: 74 | url = config["url-floating"].format(ip["floating-ip-id"]) 75 | Process(target=change_request, args=(arg_endstate, url, header, payload_floating, 76 | config["ip-bin-path"], ip["floating-ip"], config["interface-wan"])).start() 77 | 78 | if config["use-private-ips"] == True: 79 | if arg_endstate == "MASTER": 80 | for server_id in config["server-ids"]: 81 | url = config["url-alias"].format(server_id) 82 | Process(change_aliases( 83 | url, header, config["network-id"], [])) 84 | 85 | url = config["url-alias"].format(config["server-id"]) 86 | change_aliases( 87 | url, header, config["network-id"], config["private-ips"]) 88 | 89 | for private_ip in config["private-ips"]: 90 | add_ip(config["ip-bin-path"], private_ip, 91 | config["interface-private"]) 92 | 93 | else: 94 | for private_ip in config["private-ips"]: 95 | del_ip(config["ip-bin-path"], private_ip, 96 | config["interface-private"]) 97 | 98 | 99 | if __name__ == "__main__": 100 | main(arg_type=sys.argv[1], arg_name=sys.argv[2], arg_endstate=sys.argv[3]) 101 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.12.4 --------------------------------------------------------------------------------