From fb8724b0eea2183450aad6c3f9f92308b426511b Mon Sep 17 00:00:00 2001 From: "Antonio J. Delgado" Date: Fri, 6 Oct 2023 14:50:19 +0300 Subject: [PATCH] Initial commit --- .gitignore | 142 +++++++++++++++++++++ LICENSE | 0 README.md | 23 ++++ odi6/__init__.py | 0 odi6/odi6.py | 315 +++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 25 ++++ requirements.txt | 5 + setup.cfg | 9 ++ setup.py | 23 ++++ 9 files changed, 542 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 odi6/__init__.py create mode 100755 odi6/odi6.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..297e1f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,142 @@ + Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# Configuration files +*.conf +*.ini diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c8c65e7 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# odi6 + +## Requirements + +## Installation + +### Linux + + ```bash +sudo python3 setup.py install +``` + +### Windows (from PowerShell) + + ```powershell +& $(where.exe python).split()[0] setup.py install +``` + +## Usage + + ```bash +odi6.py [--debug-level|-d CRITICAL|ERROR|WARNING|INFO|DEBUG|NOTSET] # Other parameters +``` diff --git a/odi6/__init__.py b/odi6/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/odi6/odi6.py b/odi6/odi6.py new file mode 100755 index 0000000..4fe79fe --- /dev/null +++ b/odi6/odi6.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +# -*- encoding: utf-8 -*- +# +# This script is licensed under GNU GPL version 2.0 or above +# (c) 2023 Antonio J. Delgado +"""Update Dynamic Host IPv6 in OVH""" + +import sys +import os +import logging +from logging.handlers import SysLogHandler +import re +import click +import click_config_file +import ovh +import netifaces +import requests + + +class odi6: + + def __init__(self, **kwargs): + self.config = kwargs + if 'log_file' not in kwargs or kwargs['log_file'] is None: + self.config['log_file'] = os.path.join( + os.environ.get( + 'HOME', + os.environ.get( + 'USERPROFILE', + os.getcwd() + ) + ), + 'log', + 'odi6.log' + ) + self._init_log() + self.ovh = ovh.Client( + endpoint=self.config['endpoint'], + application_key=self.config['application_key'], + application_secret=self.config['application_secret'], + consumer_key=self.config['consumer_key'], + ) + ipv4, ipv6 = self._determine_ip() + self._log.debug( + f"IPv4: {ipv4}. IPv6: {ipv6}" + ) + self._update_a_record(ipv4) + if ipv6: + self._update_aaaa_record(ipv6) + + def _update_a_record(self, ipv4): + a_records = self.ovh.get( + f"/domain/zone/{self.config['domain']}/record", + fieldType='A' + ) + record_exists = False + for a_record in a_records: + data = self.ovh.get( + f"/domain/zone/{self.config['domain']}/record/{a_record}", + ) + if data['subDomain'] == self.config['hostname']: + record_exists = data + self._log.debug( + f"""Found a record that match the 'subDomain' with +the hostname: {data}""" + ) + params = { + 'fieldType': "A", + 'subDomain': self.config['hostname'], + 'target': ipv4, + 'ttl': 3600 + } + if record_exists: + if record_exists['target'] != ipv4: + self._log.debug( + f"""An A record ({record_exists}) exists for this +hostname, updating it.""" + ) + result = self.ovh.put( + f"""/domain/zone/{self.config['domain']}/ +record/{record_exists['id']}""", + **params, + ) + else: + result = f"""A record exists {record_exists} with the same +target, doing nothing.""" + else: + self._log.debug( + "No A record exists for this hostname, creating it." + ) + result = self.ovh.post( + f"/domain/zone/{self.config['domain']}/record", + **params, + ) + self._log.debug(f"Result: {result}") + + def _update_aaaa_record(self, ipv6): + aaaa_records = self.ovh.get( + f"/domain/zone/{self.config['domain']}/record", + fieldType='AAAA' + ) + record_exists = False + for aaaa_record in aaaa_records: + data = self.ovh.get( + f"/domain/zone/{self.config['domain']}/record/{aaaa_record}", + ) + if data['subDomain'] == self.config['hostname']: + record_exists = data + self._log.debug( + f"""Found a record that match the 'subDomain' with +the hostname: {data}""" + ) + params = { + 'fieldType': "AAAA", + 'subDomain': self.config['hostname'], + 'target': ipv6, + 'ttl': 3600 + } + if record_exists: + if record_exists['target'] != ipv6: + self._log.debug( + f"""A AAAA record ({record_exists}) exists for this +hostname, updating it.""" + ) + result = self.ovh.put( + f"""/domain/zone/{self.config['domain']}/ +record/{record_exists['id']}""", + **params, + ) + else: + result = f"""A record exists {record_exists} with the +same target, doing nothing.""" + else: + self._log.debug( + "No AAAA record exists for this hostname, creating it." + ) + result = self.ovh.post( + f"/domain/zone/{self.config['domain']}/record", + **params, + ) + self._log.debug(f"Result: {result}") + + def _determine_ip(self): + ipv6 = None + if self.config['ip_method'] == 'value': + if self.config['ip']: + return self.config['ip'], self.config['ipv6'] + else: + self._log.error( + 'Selected "value" method but no IP (v4) address indicated.' + ) + elif self.config['ip_method'] == 'interface': + try: + iface = netifaces.ifaddresses(self.config['network_interface']) + if 10 in iface: + ipv6 = iface[10][0]['addr'] + ipv4 = iface[2][0]['addr'] + return ipv4, ipv6 + except ValueError as error: + self._log.error( + f"""'{self.config['network_interface']}' is NOT a +valid network interface. {error}""" + ) + elif self.config['ip_method'] == 'web': + s = requests.Session() + result = s.get(self.config['web_service_url']) + match = re.match( + br'[0-2]?[0-9]?[0-9]\.[0-2]?[0-9]?[0-9]\.[0-2]?[0-9]?[0-9]\.[0-2]?[0-9]?[0-9]', + result.content) + if match: + ipv4 = match.group(0).decode() + try: + result = s.get(self.config['web_service_url_ipv6']) + match = re.match( + br'[0-9a-f]{0,4}:?:[0-9a-f]{0,4}:?:[0-9a-f]{0,4}:?:[0-9a-f]{0,4}:?:[0-9a-f]{0,4}:?:[0-9a-f]{0,4}', + result.content) + if match: + ipv6 = match.group(0).decode() + except Exception as error: + self._log.debug(f"Exception reaching IPv6 URL. {error}") + return ipv4, ipv6 + else: + self._log.error( + f"""Unable to determine the IP using +'{self.config['web_service_url']}'. Returned data: {result.content}""" + ) + else: + self._log.error( + f"""Selected method '{self.config['ip_method']}' +has not been implemented.""" + ) + + def _init_log(self): + ''' Initialize log object ''' + self._log = logging.getLogger("odi6") + self._log.setLevel(logging.DEBUG) + + sysloghandler = SysLogHandler() + sysloghandler.setLevel(logging.DEBUG) + self._log.addHandler(sysloghandler) + + streamhandler = logging.StreamHandler(sys.stdout) + streamhandler.setLevel( + logging.getLevelName(self.config.get("debug_level", 'INFO')) + ) + self._log.addHandler(streamhandler) + + if 'log_file' in self.config: + log_file = self.config['log_file'] + else: + home_folder = os.environ.get( + 'HOME', os.environ.get('USERPROFILE', '') + ) + log_folder = os.path.join(home_folder, "log") + log_file = os.path.join(log_folder, "odi6.log") + + if not os.path.exists(os.path.dirname(log_file)): + os.mkdir(os.path.dirname(log_file)) + + filehandler = logging.handlers.RotatingFileHandler( + log_file, maxBytes=102400000 + ) + # create formatter + formatter = logging.Formatter( + '%(asctime)s %(name)-12s %(levelname)-8s %(message)s' + ) + filehandler.setFormatter(formatter) + filehandler.setLevel(logging.DEBUG) + self._log.addHandler(filehandler) + return True + + +@click.command() +@click.option( + "--debug-level", + "-d", + default="INFO", + type=click.Choice( + ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "NOTSET"], + case_sensitive=False, + ), + help='Set the debug level for the standard output.' +) +@click.option('--log-file', '-l', help="File to store all debug messages.") +# @click.option("--dummy","-n", is_flag=True, +# help="Don't do anything, just show what would be done.") +@click.option( + '--endpoint', '-e', default='ovh-eu', + help='OVH endpoint to use.' +) +@click.option( + '--application-key', '-k', + required=True, + help='OVH Application key.' +) +@click.option( + '--application-secret', '-s', + required=True, + help='OVH Application secret.' +) +@click.option( + '--consumer-key', '-c', + required=True, + help='OVH Consumer key.' +) +@click.option( + '--domain', '-D', + required=True, + help='Domain name for the hostname to update.' +) +@click.option( + '--hostname', '-h', + required=True, + help='Hostname to update.' +) +@click.option( + '--ip-method', '-i', + default='web', + type=click.Choice( + ["web", "interface", "value"], + case_sensitive=False, + ), + help='Method to find the public IP.' +) +@click.option( + '--web-service-url', '-w', + default='https://api.ipify.org', + help='Web service URL to determine the public IP.' +) +@click.option( + '--web-service-url-ipv6', '-W', + default='https://api6.ipify.org', + help='Web service URL to determine the public IPv6.' +) +@click.option( + '--network-interface', '-n', + help='Network interface name to determine the publick IP.' +) +@click.option( + '--ip', '-4', + help='''If the selected method is value, +then you have to enter the IP value with this option.''' +) +@click.option( + '--ipv6', '-6', + help='''If the selected method is value, +then you can optionally to enter the IPv6 value with this option.''' +) +@click_config_file.configuration_option() +def __main__(**kwargs): + return odi6(**kwargs) + + +if __name__ == "__main__": + __main__() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0fa5e11 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project.urls] +Homepage = "" + +[project] +name = "odi6" +version = "0.0.1" +description = "Update Dynamic Host IPv6 in OVH" +readme = "README.md" +authors = [{ name = "Antonio J. Delgado", email = "" }] +license = { file = "LICENSE" } +classifiers = [ + "License :: OSI Approved :: GPLv3 License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", +] +#keywords = ["vCard", "contacts", "duplicates"] +dependencies = [ + "click", + "click_config_file", +] +requires-python = ">=3" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1a922d1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +click +click_config_file +ovh +netifaces +requests diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..586c6e1 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,9 @@ +[metadata] +name = odi6 +version = 0.0.1 + +[options] +packages = odi6 +install_requires = + requests + importlib; python_version == "3.10" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2d7c716 --- /dev/null +++ b/setup.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Setup script""" + +import configparser +import setuptools + +config = configparser.ConfigParser() +config.read('setup.cfg') + +setuptools.setup( + scripts=['odi6/odi6.py'], + author="Antonio J. Delgado", + version=config['metadata']['version'], + name=config['metadata']['name'], + author_email="", + url="", + description="Update Dynamic Host IPv6 in OVH", + long_description="README.md", + long_description_content_type="text/markdown", + license="GPLv3", + # keywords=["my", "script", "does", "things"] +)