From 77b96e3870b5f6cd3eb35b51b105dab4bfba9d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1zaro=20Blanc?= Date: Thu, 13 May 2021 16:46:45 +0200 Subject: [PATCH] init --- LICENSE | 201 ++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 86 +++++++++++++++++++++ ionos_dyndns.py | 180 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 467 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 ionos_dyndns.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2fd6814 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2021 Lázaro Blanc + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e46e014 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# ionos_dyndns.py + +Create and update DNS records for a host using IONOS' API for use as a DynDNS (for example via a cronjob). +The script will create A and/or AAAA records if none already exists for the host and update existing ones to reflect the new public IP. + +The public IP-Address is determined in two ways: + +- **IPv4**: uses the **ipify.org** API +- **IPv6**: uses the `ip address show` command + +By default the name of the A/AAAA record that will be created/updated is one that matches the output from the command `hostname -f`.
+Update your `/etc/hosts` file with your FQDN if it's not already there.
+Alternatively you can override this default value by using the `-H` or `--fqdn` parameter. + +## Requirements + +- Linux +- Python 3 +- IONOS API key + +You can create an API key here: https://developer.hosting.ionos.de/keys
+The API is still in Beta and not enabled by default. In my case I had to call the customer support hotline and request to be enabled for the API. + +## Usage + +### Cronjob + +This example shows how to update the AAAA record every 5 minutes and save the script output to a file: + +1. Download the script and make sure it is executable +```sh +wget https://raw.githubusercontent.com/lazaroblanc/IONOS-DynDNS/main/ionos_dyndns.py +chmod +x ionos_dyndns.py +``` +2. Open your crontab file with `crontab -e` +3. Paste this line: +```sh +*/5 * * * * ./ionos_dyndns.py --AAAA --api-prefix $publicprefix --api-secret $secret >> ionos_dyndns.log +``` + +### General +``` +usage: ionos_dyndns.py [-h] [-4] [-6] [-i] [-H] --api-prefix --api-secret + +Create and update DNS records for this host using IONOS' API to use as a sort of DynDNS (for example via a cronjob). + +optional arguments: + -h, --help show this help message and exit + -4, --A Create/Update A record + -6, --AAAA Create/Update AAAA record + -i , --interface Interface name for determining the public IPv6 address (Default: eth0) + -H , --fqdn Host's FQDN (Default: hostname -f) + --api-prefix API key publicprefix + --api-secret API key secret +``` + +## Ideas / To-do + +- [ ] improve log messages (add a timestamp) +- [ ] refactor duplicate code (~ line 94) + +
+
+ + + + + + + + +
+

🐛 Bug reports & Feature requests 🆕

+If you've found a bug or want to request a new feature please open a new Issue +

+
+

🤝 Contributing

+✅ Pull requests are welcome! +

+
+

📃 License

+Published under the Apache License 2.0
+Please see the License for details +

+
+
diff --git a/ionos_dyndns.py b/ionos_dyndns.py new file mode 100644 index 0000000..6b483fb --- /dev/null +++ b/ionos_dyndns.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 + +# Author: Lázaro Blanc +# GitHub: https://github.com/lazaroblanc +# Usage example: ./ionos_dyndns.py --A --AAAA --api-prefix --api-secret + +import subprocess # Getting IPv6 address from systools instead of via web API +import re # Just regex stuff +import requests # Talk to the API +import json # Work with the API responses +from argparse import ArgumentParser, RawDescriptionHelpFormatter # Commandline argument parsing + + +def parse_cmdline_args(): + argparser = ArgumentParser( + description="Create and update DNS records for this host using IONOS' API to use as a sort of DynDNS (for example via a cronjob).", + epilog="Author: Lázaro Blanc\nGitHub: https://github.com/lazaroblanc", + formatter_class=RawDescriptionHelpFormatter + ) + argparser.add_argument( + "-4", "--A", + default=False, + action="store_const", + const=True, + help="Create/Update A record" + ) + argparser.add_argument( + "-6", "--AAAA", + default=False, + action="store_const", + const=True, + help="Create/Update AAAA record" + ) + argparser.add_argument( + "-i", "--interface", + default="eth0", + action="store", + metavar="", + help="Interface name for determining the public IPv6 address (Default: eth0)" + ) + argparser.add_argument( + "-H", "--fqdn", + default=subprocess.getoutput(f"hostname -f"), + action="store", + metavar="", + help="Host's FQDN (Default: hostname -f)" + ) + argparser.add_argument( + "--api-prefix", + required=True, + action="store", + metavar="", + help="API key publicprefix" + ) + argparser.add_argument( + "--api-secret", + required=True, + action="store", + metavar="", + help="API key secret" + ) + + args = argparser.parse_args() + + if not args.A and not args.AAAA: + argparser.print_help() + exit() + + return args + + +# Map args to global variables +args = parse_cmdline_args() +fqdn = args.fqdn +api_url = "https://api.hosting.ionos.com/dns/v1/zones" +api_key_publicprefix = args.api_prefix +api_key_secret = args.api_secret +api_headers = { + "accept": "application/json", + "X-API-Key": f"{api_key_publicprefix}.{api_key_secret}" +} +interface = args.interface + + +def main(): + + domain = get_domain_from_fqdn(fqdn) + zone = get_zone(domain) + all_records = get_all_records_for_fqdn(zone["id"], fqdn) + records_to_create = [] + records_to_update = [] + + # Code duplication, could use some refactoring but I don't have time for this atm + if args.A: + ipv4_address = get_ipv4_address() + print("Public IPv4: " + ipv4_address) + if filter_records_by_type(all_records, "A"): + if filter_records_by_type(all_records, "A")[0]["content"] == ipv4_address: + print("A record is up-to-date") + else: + print("A record is outdated") + records_to_update.append( + new_record(fqdn, "A", ipv4_address, 60)) + else: + print("No A record found") + records_to_create.append(new_record(fqdn, "A", ipv4_address, 60)) + + if args.AAAA: + ipv6_address = get_ipv6_address(interface) + print("Public IPv6: " + ipv6_address) + if filter_records_by_type(all_records, "AAAA"): + if filter_records_by_type(all_records, "AAAA")[0]["content"] == ipv6_address: + print("AAAA record is up-to-date") + else: + print("AAAA record is outdated") + records_to_update.append(new_record( + fqdn, "AAAA", ipv6_address, 60)) + else: + print("No AAAA record found") + records_to_create.append(new_record( + fqdn, "AAAA", ipv6_address, 60)) + + if (len(records_to_create) > 0): + post_new_records(zone["id"], records_to_create) + + if (len(records_to_update) > 0): + patch_records(zone["id"], records_to_update) + + +def get_domain_from_fqdn(fqdn): + regex = r"(?:(?:\w|-)+\.)+(\w+\.\w+)$" + return re.search(regex, fqdn, re.IGNORECASE).group(1) + + +def get_ipv4_address(): + return requests.request("GET", "https://api4.ipify.org").text + + +def get_ipv6_address(interface_name): + ip_output = subprocess.getoutput(f"ip -6 -o address show dev {interface_name} scope global | grep --invert temporary | grep --invert mngtmpaddr") + ip_output_regex = r"(?:inet6)(?:\s+)(.+)(?:\/\d{1,3})" + return re.search(ip_output_regex, ip_output, re.IGNORECASE).group(1) + + +def get_zone(domain): + response = json.loads(requests.request( + "GET", api_url, headers=api_headers).text) + return list(filter(lambda zone: zone['name'] == domain, response))[0] + + +def get_all_records_for_fqdn(zone_id, host): + url = f"{api_url}/{zone_id}" + records = json.loads(requests.request("GET", url, headers=api_headers).text)['records'] + return list(filter(lambda record: record['name'] == host, records)) + + +def filter_records_by_type(records, type): + return list(filter(lambda record: record['type'] == type, records)) + + +def new_record(name, record_type, ip_address, ttl): + return { + "name": name, + "type": record_type, + "content": ip_address, + "ttl": ttl + } + + +def post_new_records(zone_id, records): + url = f"{api_url}/{zone_id}/records" + return requests.request("POST", url, headers=api_headers, json=records) + + +def patch_records(zone_id, records): + url = f"{api_url}/{zone_id}" + return requests.request("PATCH", url, headers=api_headers, json=records) + + +main()