pax_global_header00006660000000000000000000000064143467064420014524gustar00rootroot0000000000000052 comment=643e8d8a15cd052ad38a76b920be1971ff16dce5 ddupdate-0.7.2/000077500000000000000000000000001434670644200133245ustar00rootroot00000000000000ddupdate-0.7.2/.gitignore000066400000000000000000000001251434670644200153120ustar00rootroot00000000000000__pycache__ *.pyc *.swp build dist *.egg-info __pycache__ fedora testing.txt install ddupdate-0.7.2/CONFIGURATION.md000066400000000000000000000161301434670644200156560ustar00rootroot00000000000000Complete, manual ddupdate configuration ======================================= Before configuration, the software must be installed. See README.md. Please also note that the the manual steps described here could be replaced by running *ddupdat-config* for most users. Configuration is basically about selecting a plugin for a specific ddns service and another plugin which provides the ip address to be registered. Some plugins needs specific plugin options. There is also a choice how to store the username/password credentials, either in the _~/.netrc_ file or in the keyring. Address Plugin -------------- The address plugin to use is usually either *default-web-ip* or *default-if*. The *default-web-ip* plugin should be used when the address to register is the external address visible on the internet - that is, if the registered host should be accessed from the internet. For most services *ip-disabled* could be used instead. Services will then use the external address as seen from the service. See the *ddupdate --help * info. The *default-if* plugin uses the first address found on the default interface. This typically means registering the address used on an internal network, and should be used if the registered host should be accessed from this internal network. Should these options not fit, several other address plugins are available using *ddupdate --list-addressers*. After selecting address plugin, test it using something like:: $ ./ddupdate --ip-plugin default-web-ip --service-plugin dry-run dry-run: Using v4 address: 83.255.182.111 v6 address: None hostname: host1.nowhere.net Service Plugin -------------- After selecting the address plugin, start the process of selecting a service by listing all available services (your list might differ):: $ ddupdate --list-services changeip.com Updates on http://changeip.com/ cloudflare.com Updates on https://cloudflare.com dnsdynamic.org Updates on http://dnsdynamic.org/ dnsexit.com Updates on https://www.dnsexit.com dnspark.com Updates on https://dnspark.com/ dry-run Debug dummy update plugin dtdns.com Updates on https://www.dtdns.com duckdns.org Updates on http://duckdns.org duiadns.net Updates on https://www.duiadns.net dy.fi Updates on https://www.dy.fi/ dynu.com Updates on https://www.dynu.com/en-US/DynamicDNS dynv6.com Updates on http://dynv6.com freedns.afraid.org Updates on https://freedns.afraid.org freedns.io Updates on https://freedns.io googledomains Updates DNS data on domains.google.com hurricane_electric Updates on https://he.com myonlineportal.net Updates on http://myonlineportal.net/ no-ip.com Updates on http://no-ip.com/ now-dns.com Updates on http://now-dns.com nsupdate Update address via nsupdate system-ns.com Updates on https://system-ns.com Next, pick a service plugin and check the help info, here dynu:: $ ddupdate --help dynu Name: dynu Source: /home/al/src/ddupdate/src/ddupdate/plugins/ddplugin.py Update a dns entry on dynu.com Supports ip address discovery and can thus work with the ip-disabled plugin. .netrc: Use a line like: machine api.dynu.com login password Options: none If all looks good, register on dynu.com. This will end up in a hostname, username and password. Add the host, username and password to the _~/.netrc_ file using:: $ ddupdate -C netrc -p _hostname_ is available in the plugin's .netrc help text as 'machine', for example _api.dynu.com_ in help text above. Test the service using the selected address plugins, something like:: $ ./ddupdate --address-plugin default-web-ip --service-plugin dynu \ --hostname myhost.dynu.net -l info INFO - Loglevel: INFO INFO - Using hostname: myhost.dynu.net INFO - Using ip address plugin: default-web-ip INFO - Using service plugin: dynu INFO - Plugin options: INFO - Using ip address: 90.3.08.212 INFO - Update OK When all is fine, update *~/.config/ddupdate.conf* or */etc/ddupdate.conf* to something like:: [update] address-plugin = web-default-ip service-plugin = dynu hostname = myhost.dynu.net loglevel = info auth-plugin = netrc After which it should be possible to just invoke *ddupdate* without any options. When done, proceed to Configuring systemd in README.md Adding more hosts ================= It is possible to add more hosts to the configuration file. This means that ddupdate will update two or more services when run. This is an experimental procedure. The starting point could be a _~/.config/ddupdate.conf_ file created by _ddupdate-config_ like:: [update] address-plugin = default-web-ip service-plugin = dynu hostname = myhost.dynu.net loglevel = info The first step is to replace '[update]' with a new name, for example '[dynu]'. After this, ddupdate-config can be run again creating:: [dynu] address-plugin = default-web-ip service-plugin = dynu auth-plugin = keyring hostname = myhost.dynu.net loglevel = info [update] address-plugin = default-web-ip service-plugin = duckdns.org auth-plugin = keyring hostname = myhost.duckdns.org loglevel = info The process can be repeated to add more entries. New entries can also be added manually. It is also necessary to update username/password credentials stored in ~/.netrc or the keyring. If using `ddupdate-config` this is handled automatically. Otherwise this can be done using the `-p' option using something like:: $ ddupdate -C netrc _hostname_ is available in the plugin's .netrc help text as 'machine'. Use `-C keyring` when using the keyring credentials storage. Services only using an API key should use "" as username and the API key as 'password'. The CLI support for multiple hosts:: - `-E` lists the available configuration sections. - `-e
` can be used to only run a specific section when running ddupdate manually on the command line. Using the keyring for passwords =============================== Version 7.0 contains experimental support for storing passwords in the system keyring. The basic parts - Credentials are managed by a new type of auth plugins. Use `ddupdate -P` to list available plugins. - Set the _auth-plugin_ option in the config file to _keyring_ to activate the keyring support. - To set passwords for services use the new -p option to `ddupdate`. For example `ddupdate -C keyring -p myhost username password`. For hosts using an api key without username, use "" for username. - The new script `ddupdate_netrc_to_keyring` migrates all entries in _~/.netrc_ to the keyring. - To check passwords in keyring:: $ python3 > import keyring > keyring.get_password('ddupdate', 'myhost.tld') Note that the keyring needs to be unlocked before accessed making it less useful in servers. ddupdate-0.7.2/CONTRIBUTE.md000066400000000000000000000173521434670644200153340ustar00rootroot00000000000000This document is both some upstream notes and an introduction to maintaining ddupdate code. Design goals: ------------- - ddupdate should be easy to configure. Most users should just need to run a simple configuration script (after registering with a DDNS service). - ddupdate should be secure, using standard tools to handle password secrets. No root access should be required to configure or run it. - ddupdate should be extensible, and thus being based on plugins. - ddupdate should be flexible for programmers, making it possible to write plugins for all sorts of sites including those ddclient cannot handle. - ddupdate should be linux-centric, using standard linux tools such as systemd and NetworkManager where it is appropriate. Writing plugins --------------- Writing plugins is not hard. Most plugins are about 10-20 lines of code + docs, most of which boilerplate stuff. The best way is to look at the existing plugins and pick solutions from them. Some hints: - Before writing the plugin, make tests with wget or curl to make sure how the api works. Essential step, this one. - The plugin API is defined in the ```ddplugin.py``` file. API docs can be generated using *python3 -m pydoc lib/ddupdate/ddplugin.py* or so. - Coding style: Use *make pylint*, *make pycodestyle* and *make pydocstyle*. The relevant tools python3-pylint, pycodestyle and pydocstyle needs to be in place. - Each plugin must live in a file with a unique name. It must contain a main class derived from AddressPlugin or ServicePlugin. The class docstring is the *help * documentation. - The class ```_name``` property is the official name of the plugin, must be unique. ```_oneliner``` is indeed the short summary displayed by for example *--list-services*. - To test, create the directory *~/.local/share/ddupdate/plugins* and drop the new plugin into it. - Authentication: - Some sites uses standard basic authentication. This is handled by *http_basic_auth_setup* in e. g., ```no_ip.py``` - Others uses username + password in the url e. g., ```dnsexit.py``` - Hashed passwords are used in e. g., ```dynu.py``` - API tokens are handled in e. g., ```duckdns.py``` - Some have broken basic authentication, see ```now_dns.py``` - Some uses a separate header with the API token, see ```desec.io``` - Most services uses a http GET request to set the data. See ```freedns_io.py``` for a http POST example. - Handling expired server certificate: see duiadns. - Reply decoding: - Most sites just returns some text, simple enough - json: example in ```system_ns.py``` - html: example in ```duiadns.py``` - Configuration: The line 'netrc line' in the plugin method documentation is parsed by ddupdate-config to determine what user should define for example user, password, etc. This mechanism based on the netrc syntax is used also in the keyring backend. Packaging --------- ddupdate has a multitude of packaging: - ddupdate is available as a **pypi package** from the master branch. It can be installed using pip:: $ sudo pip install ddupdate --prefix=/usr/local or from the cloned git directory: $ sudo python3 setup.py install --prefix=/usr/local It can be installed in an virtualenv root by a regular user. To use the plugins in the venv in favor of the system ones prepend the proper path to XDG\_DATA\_DIRS using something like:: $ export XDG_DATA_DIRS=$PWD/share:$XDG_DATA_DIRS Using a virtualenv, configuration files like *~/.config/ddupdate.conf* and *~/.netrc* are still used from their system-wide locations. - **fedora** is packaged in the *fedora* branch. Pre-built packages are at https://copr.fedorainfracloud.org/coprs/leamas/ddupdate/. Building requires the *git* and *rpm-build* packages. To build version 0.7.2:: $ git clone -b fedora https://github.com/leamas/ddupdate.git $ cd ddupdate/fedora $ sudo dnf builddep ddupdate.spec $ ./make-tarball 0.7.2 $ rpmbuild -D "_sourcedir $PWD" -ba ddupdate.spec $ sudo rpm -U --force rpmbuild/RPMS/noarch/ddupdate*rpm - The **debian** packaging is based on gbp and lives in the *debian* and *pristine-tar* branches. The packages *git-buildpackage*, *devscripts* and *git* are required to build. To build current version 0.7.2 do:: $ rm -rf ddupdate; mkdir ddupdate; cd ddupdate $ git clone -o origin -b debian https://github.com/leamas/ddupdate.git $ cd ddupdate $ sudo mk-build-deps -i -r debian/control $ git fetch origin pristine-tar:pristine-tar $ gbp buildpackage --git-upstream-tag=0.7.2 -us -uc $ sudo dpkg -i ../ddupdate_0.7.2*_all.deb $ git clean -fd # To be able to rebuild - A simpler way to build **debian** packages is based on retreiving the sources from the ubuntu ppa and rebuilding them:: # First-time setup $ sudo apt-get install devscripts build-essential $ ppa="http://ppa.launchpad.net/leamas-alec/ddupdate/ubuntu" $ echo "deb-src $ppa xenial main" | sudo tee -a /etc/apt/sources.list $ sudo apt-key \ adv --keyserver keyserver.ubuntu.com --recv-keys B0319103FF2D1390 # Rebuild package $ sudo apt-get update $ apt-get source --build ddupdate $ sudo dpkg -i ddupdate*.all.deb Creating a new version (maintainer work) ---------------------------------------- - Replace all occurrences of version string: sed -E -i 's/([^0-9])0\.[1-9]\.[0-9]/\10.5.1/g' $(git ls-files) - Update NEWS file. - Check Last Changed in all manpages. - Commit and tag the release: git tag 0.7.2 - Create fedora package: git checkout fedora cd fedora ./make-tarball 0.7.2 rpmdev-bumpspec *.spec , and edit it. rm -rf rpmbuild rpmbuild-here -ba *.spec - Possibly iterate. When done, merge to master and push with tags: $ git checkout master $ git merge devel $ git push --tags origin master:master $ git push origin devel:devel - Copy tarball and repo to debian and commit it on pristine-tar - Upload to pypi: $ python setup.py sdist $ twine upload dist/* - Create debian test build on sid:: $ cd ddupdate/ddupdate $ sudo mk-build-deps -i -r debian/control $ git fetch upstream pristine-tar:pristine-tar $ git merge -X theirs 0.7.2 $ dch -v 0.7.2-1 $ git commit -am "debian: 0.7.2-1" $ Check systemd/ddupdate.service $ git commit -a --amend $ git clean -fd $ gbp buildpackage --git-upstream-tag=0.7.2 -us -uc $ git clean -fd # To be able to rebuild - Create fedora packages (above) - Make a new COPR build - Create an Ubuntu package. Needs tar >= 1.29b, pristine-tar >= 1.42 (from zesty). Builds on xenial and trusty. $ sudo ntpdate se.pool.ntp.org # Setup build environment $ rm -rf ddupdate; mkdir ddupdate; cd ddupdate $ git clone \ -o upstream -b debian https://github.com/leamas/ddupdate.git $ cd ddupdate $ git fetch upstream pristine-tar:pristine-tar $ pristine-tar checkout ddupdate_0.7.2.orig.tar.gz $ mv ddupdate_0.7.2.orig.tar.gz .. $ sudo mk-build-deps -i -r debian/control # Patch ubuntu stuff: $ debian/deb2xenial # Build and install the binary package $ debuild -us -uc $ sudo dpkg -i ../ddupdate_0.7.2_all.deb # Build and distribute source package (upstream only) $ debuild -S $ dput ppa:leamas-alec/ddupdate ../*source.changes ddupdate-0.7.2/LICENSE.txt000066400000000000000000000020331434670644200151450ustar00rootroot00000000000000Copyright 2017 Alec Leamas Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ddupdate-0.7.2/Makefile000066400000000000000000000016471434670644200147740ustar00rootroot00000000000000# # Standard Makefile supports targets build, install and clean + static # code checking. make install respects DESTDIR, build and install respects # V=0 and V=1 PYLINT = pylint-3 ifeq ($(DESTDIR),) DESTDIR = $(CURDIR)/install endif VERBOSE = $(or $(V),0) ifeq ($(VERBOSE), 0) QUIET_OPT = --quiet endif PYTHON_SRC = plugins lib/ddupdate setup.py ddupdate ddupdate-config # vim-compatible error reporting: pylint_template = {path}:{line}: [{msg_id}({symbol}), {obj}] {msg} all: build build: python3 setup.py $(QUIET_OPT) build install: .phony python3 setup.py $(QUIET_OPT) install --root=$(DESTDIR) clean: .phony python3 setup.py clean pylint: $(PYTHON_SRC) -PYTHONPATH=$(CURDIR)/lib $(PYLINT) \ --rcfile=pylint.conf \ --msg-template='$(pylint_template)' \ $? pydocstyle: $(PYTHON_SRC) pydocstyle $? pycodestyle: $(PYTHON_SRC) -pycodestyle $? .phony: ddupdate-0.7.2/NEWS000066400000000000000000000130761434670644200140320ustar00rootroot000000000000000.7.2 * Remove dtdn plugin, service seems dead. * Add new plugin for namecheap.com * Add new plugin for alternative freedns.org scheme * Handle upcoming python 3.12 which will drop distutils. * Honor the NETRC environment variable * Documentation and user messages updates. 0.7.1 * Drop the dnsdynamic plugin, service is discontinued. * Drop the system-ns plugin, service is discontinued. * Add new plugin for desec.io (#36). * Add new plugin for DNS-O-Matic (#57) * Handle expired certificate on duiadns server. * myonlineportal, dnsexit, dynu: Handle API updates. * Multiple bugfixes in new credentials backends. * Base64-encode passwords in auth_netrc to handle 'strange' characters (#49). * Add support for selecting auth plugin to ddupdate-config. * ddplugin: Add a new header kwarg argument to get_response(), used in desec.io to allow token based header authentication. * Add pyproject.toml, first step in distutils being dropped in 3.12 * Various documentation polish. * Rename all address plugins using an addr_ prefix. * Fix pylint and pycodestyle diagnostics. 0.7.0 * Support multiple configuration sections in config file (#25) * Experimental support for keyring password storage (#23) * Add FINAL_PREFIX environment variable support to ease packaging * Support multiple credentials for Google domains (#52) * New address plugin for OnHub/Google WiFi/Nest WiFi (#58) * New dnshome.de address- and service plugins (#47) * Plugins: freedns: Update to simplified https API. Thanks to m-jung, Alexey Grevtsev, Marcin Mielniczuk, Gabriel de Perthuis 0.6.6 * Fix wrong path in systemd service file (#54). * Fix bad return value causing systemd problems (#53). * Graceful exist on missing python-requests (#48). Thanks to Teemo Ikonen 0.6.5 * Fix ip route parsing (#35) * Fix python 3.9 syntax warnings (#38, #37) * New plugin domains.google.com -- googledomains.py * Error logging bugfixes (#4411454). Thanks to aerusso, LoganK and Hroncok 0.6.4 * Fix address-options and service-options parsing regression (#24). * Add new nsupdate(1) plugin (#29). * Install systemd files in correct user locations (#22). * Enhance ipv6 address parsing in address plugins (#28). * Patch ddupdate.service script path on installation. * Thanks to aerusso for nsupdate plugin, bugfixes and enhancements. 0.6.3 * Fix #21, broken ipv6 configuration file parsing. * Documentation bugfixes. 0.6.2 * New cloudflare plugin, thanks to Mika Mannermaa * New dy.fi, restricted ti what looks like finnish addresses. Thanks to Teemu Ikonen * Fix bug causing frequent updates due to cache misses (#19). Kudos: Teemu Ikonen * Code and documentation cleanup. 0.6.1 * Fix crash when logging updates disabled by caching (#11). * afraid.org: Use now available https url (#12). * afraid.org: Support user-supplied addresses (#13). * Misc cleanup. 0.6.0 * New setup by ddupdate-config based on a user service. Nothing installed as root, service runs as invoking user. Existing setups based on a separate user running service continues to work, but must be maintained manually. * New service plugin dnspark.com * New Hurricane Electric service plugin. * New pkg-config build dependency. 9.5.3 * Remove the straight.plugins dependency. * config: Fix bug creating new config files like ~/.config/dduodate.conf * config: Add new address plugin option "Use address as of service". * build: Don't install documentation files (bad idea from beginning). * Miscellanaous cleanup and doc fixes. 0.5.2 * Multiple bugfixes in config script * Add missing commands and short options to completions * Move completions to proper /usr/share/bash-comletions/completions * Drop sudo usage, use plain su instead. 0.5.1 * Add bash completion support * Fix a bug in the user config file ~/.config/ddupdate.conf path. * Fix Respect XDG_CONFIG_HOME (#8). * Fix handle .netrc permissions correct (#7). * Clean up the Makefile build-install-clean targets. * Documentation re-organized and updated. 0.5.0 * Add ddupate-config configuration script. * Add missing ddupdate.conf.5 manpage * Static code checkers cleanup * Documentation updates * Fix bogus error message in --list-services/addressers. 0.4.1 * Documentation fixes 0.4.0: * Declared as beta state. * Revise API 0.3.0 * Split old --list command to --list-services and list-addressers * Split old --options to --address-options and --service-options. * Incompatible CLI changes * Documentation fixes. 0.2.1: * Fix start exception on python 3.4/jessie (#6). * Fix unhelpful error messages for bad .netrc (#5). * Documentation fixes. 0.2.0: * Revise and finish ipv6 support (#3) * New option --version, ipv4/ipv6/all addresses switch. * New ip plugin default-web-ip6, external ipv6 address. * Update and document proxy usage (#4) 0.1.0: * Multitude of minor bugfixes. * API: Finalizing current revision. Renaming silly named plugins_base -> ddplugin. * Adding pydocstyle checks and updating source * Review and bugfixes for package generation " Added COPR and Ubuntu PPA downstream repos. 0.0.6: * Fixed several bugs in plugin load paths. * Fixed a bug in config file path computation. * Removed the generated file ddupdate.8.html from distribution * Added timeout handling in plugin http handling. * New plugin hardcoded-ip * Minor bugfixes in both plugins and central code. * Added fast-track configuration to README.md, clean up. * Cleaned up and refactored code in main module * Fixed a bug when symlinking the ddupdate script. 0.0.5: * Added a NEWS file * Revised and cleaned up plugin interface. * Added ipv6 support. * New plugins myonlineportal.com and dynv6.com * Fedora and debian packaging verified. ddupdate-0.7.2/README.md000066400000000000000000000100301434670644200145750ustar00rootroot00000000000000ddupdate - Update dns data for dynamic ip addresses. ==================================================== General ------- ddupdate is a tool for automatically updating dns data for a system using for example DHCP. It makes it possible to access such a system with a fixed dns name like myhost.somewhere.net even if the IP address is changed. It is a linux-centric, user-friendly and secure alternative to the ubiquitous ddclient. Compared to ddclient, ddupdate is much easier to configure for users. It's also more flexible and provides support for some hosts which are known to be problematic using ddclient. Status ------ Beta. The plugin API will be kept stable up to 1.0.0, and there should be no incompatible CLI changes. At the time of writing 20 free services are supported. There is also 7 address plugins. Together this should cover most usecases based on freely available services. Still, this is beta and there is most likely bugs out there. Dependencies ------------ - python3 - python3-keyring - The /usr/sbin/ip command is used in some plugins. - python3-setuptools (build) - pkg-config (build) - The systemd package i. e., the systemd.pc file (build). Installation ------------ **ddupdate** can be run as a regular user straight off the cloned git directory. To make it possible to run from anywhere make a symlink:: $ ln -s $PWD/ddupdate $HOME/bin/ddupdate It is also possible to install as a pypi package using:: $ sudo pip3 install ddupdate --prefix=/usr See CONTRIBUTE.md for more info on using the pypi package. ddupdate is packaged in some distros: - **Fedora** 27 and later. - **EPEL7** addons for RHEL/CentOS - **Debian** Buster and later - **Ubuntu** Bionic and later CONTRIBUTE.md describes how to create packages for **other Debian distributions** **Ubuntu** users can install native .deb packages using the PPA at https://launchpad.net/~leamas-alec/+archive/ubuntu/ddupdate **Mageia** users can install native rpm packages from https://copr.fedorainfracloud.org/coprs/leamas/ddupdate/. This site also contains pre-release updates for Fedora and EPEL. Overall, using native packages is the preferred installation method on platforms supporting this. Configuration ------------- This is the fast track assuming that you are using a native package and mainstream address options. If running into troubles, see the manual steps described in CONFIGURATION.md. Start with running ```ddupdate --list-services```. Pick a supported service and check it using ```ddupdate --help ```. At this point you need to register with the relevant website. The usual steps are to first create an account and then, using the account, create a host. The process should end up with a hostname, a user and a secret password (some sites just uses an API key). Then start the configuration script ```ddupdate-config```. The script guides you through the configuration and updates several files, notably *~/.config/ddupdate.conf* and *~/.netrc*. After running the script it should be possible to run a plain ```ddupdate -l debug``` without error messages. When this works, systemd should be configured as described below. Configuring systemd ------------------- systemd is setup to run as a user service. Start by testing it:: $ systemctl --user daemon-reload $ systemctl --user start ddupdate.service $ journalctl --user -u ddupdate.service If all is fine make sure ddupdate is run hourly using:: $ systemctl --user start ddupdate.timer $ systemctl --user enable ddupdate.timer If you want the service to start as soon as the machine boots, and to continue even when you log out do: $ sudo loginctl enable-linger $USER If there is trouble or if you for example want to run ddupdate more often, use `systemctl --user edit ddupdate.service`or `systemctl --user edit ddupdate.timer` Configuring NetworkManager -------------------------- NetworkManager can be configured to start/stop ddupdate when interfaces goes up or down. An example script to drop in */etc/NetworkManager/dispatcher.d* is distributed in the package. ddupdate-0.7.2/bash_completion.d/000077500000000000000000000000001434670644200167145ustar00rootroot00000000000000ddupdate-0.7.2/bash_completion.d/ddupdate000066400000000000000000000033601434670644200204330ustar00rootroot00000000000000_ddupdate() { local cur prev opts plugins COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" opts="--help --hostname --service-plugin --address-plugin --config-file" opts="$opts --loglevel --ip-version --service-option --address-option" opts="$opts --list-addressers --list-services list-sections" opts="$opts --auth-plugin --list-auth-plugins" case "${prev}" in --ip-version | -v) COMPREPLY=( $(compgen -W "v4 v6 all" -- ${cur}) ) return 0 ;; --loglevel | -l) COMPREPLY=( $(compgen -W "error warning info debug" -- ${cur}) ) return 0 ;; --config-file | -c) COMPREPLY=( $(compgen -f -- ${cur}) ) return 0 ;; --address-plugin | -a) plugins=$(ddupdate --list-addressers -l error | awk '{print $1}') COMPREPLY=( $(compgen -W "$plugins" -- ${cur}) ) return 0 ;; --service-plugin | -s) plugins=$(ddupdate --list-services -l error | awk '{print $1}') COMPREPLY=( $(compgen -W "$plugins" -- ${cur}) ) return 0 ;; --auth-plugin | -C) plugins=$(ddupdate --list-auth-plugins -l error | awk '{print $1}') COMPREPLY=( $(compgen -W "$plugins" -- ${cur}) ) return 0 ;; --execute-section | -e) sections=$(ddupdate --list-sections) COMPREPLY=( $(compgen -W "$sections" -- ${cur}) ) return 0 ;; *) ;; esac if [[ ${cur} == -* ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) return 0 fi } complete -F _ddupdate ddupdate ddupdate-0.7.2/ddupdate000077500000000000000000000005251434670644200150460ustar00rootroot00000000000000#!/usr/bin/python3 ''' ddupdate main script, invokes ddupdate.main.main(). ''' import os.path import sys sys.path.pop(0) HOME = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) sys.path.insert(0, os.path.join(HOME, 'lib')) try: from lib.ddupdate import main except ImportError: from ddupdate import main main.main() ddupdate-0.7.2/ddupdate-config000077500000000000000000000006341434670644200163120ustar00rootroot00000000000000#!/usr/bin/python3 ''' ddupdate-config main script, invokes ddupdate.config.main(). ''' # pylint: disable=invalid-name import os.path import sys sys.path.pop(0) HERE = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) sys.path.insert(0, os.path.join(HERE, 'lib')) try: from lib.ddupdate import config except (ImportError, ModuleNotFoundError): from ddupdate import config config.main() ddupdate-0.7.2/ddupdate-config.8000066400000000000000000000015671434670644200164630ustar00rootroot00000000000000.TH DDUPDATE\-CONFIG "8" "Last change: Jun 2019" "ddupdate-config" "System Administration Utilities" .SH NAME .P \fBdddupdate-config\fR - ddupdate configuration file setup tool. .SH SYNOPSIS \fBdddupdate-config\fR .SH DESCRIPTION A simple configuration script for \fBddupdate\fR. Script provides a way to define address-plugin, service-plugin and hostname in an interactive way. .P The set of available plugins loaded depends on environment variables. See PLUGIN LOADING in \fBddupdate(8)\fR. .SH BUGS Passwords given to ddupdate must use printable characters which does not require escaping. This is partly a limitation in the script code, partly due to the python netrc module capabilities. .P Does not support configuration of more advanced features like ipv6 and non-standard address-plugins beyond \fIweb-default-ip\fR and \fIdefault-if\fR. .SH SEE ALSO .TP 4 .B ddupdate(8) ddupdate-0.7.2/ddupdate-netrc-to-keyring000077500000000000000000000005701434670644200202450ustar00rootroot00000000000000#!/usr/bin/python3 ''' Move entries from netrc file to keyring. See auth_keyring plugin ''' import os.path import sys sys.path.pop(0) HOME = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) sys.path.insert(0, os.path.join(HOME, 'lib')) try: import ddupdate.netrc_to_keyring as main except ImportError: import netrc_to_keyring.main as main main.main() ddupdate-0.7.2/ddupdate-netrc-to-keyring.8000066400000000000000000000007221434670644200204070ustar00rootroot00000000000000.TH DDUPDATE_NETRC_TO_KEYRING "8" "Last change: Apr 2022" "ddupdate-config" "System Administration Utilities" .SH NAME .P \fBddupdate_netrc_to_keyring\fR - ddupdate configuration migration tool. .SH SYNOPSIS \fBddupdate_netrc_to_keyring\fR .SH DESCRIPTION Simple script which reads all entries in \fI~/.netrc\fR and copies them to the system keyring in a format which can be used by the ddupdate keyring authentication plugin. .SH SEE ALSO .TP 4 .B ddupdate(8) ddupdate-0.7.2/ddupdate.8000066400000000000000000000167131434670644200152170ustar00rootroot00000000000000.TH DDUPDATE "8" "Last change: Apr 2022" "ddupdate" "System Administration Utilities" .SH NAME .P \fBddupdate\fR - Update DNS data for dynamic IP addresses .SH SYNOPSIS .B ddupdate [\fIoptions\fR] .SH DESCRIPTION A tool to update dynamic IP addresses typically obtained using DHCP with dynamic DNS service providers such as changeip.com, duckdns.org or no-ip.com. It makes it possible to access a machine with a fixed name like myhost.duckdns.org even if the ip address changes. .P The tool has a plugin structure with plugins for obtaining the actual address (typically hardware-dependent) and to update it (service dependent). .P The normal usecase is to specify all commandline options in the config file. However, all options in this file could be overridden by actual command line options e. g., while testing. .P Using service providers and possibly also firewalls requires use of username/password credentials. For these, either the netrc(5) or the system keyring is used. .P \fBddupdate\fR is distributed with systemd support to run at regular intervals, and with NetworkManager templates to run when interfaces goes up or down. It fully supports ipv6 addresses and also using proxies (see ENVIRONMENT). .PP .SH OPTIONS Options for normal operation, typically defined in config file: .TP 4 \fB-H, --hostname\fR <\fIhostname\fR> Hostname to update, typically fully qualified. Defaults to the not really usable host.nowhere.net .TP 4 \fB-s, --service-plugin\fR <\fIplugin\fR> Plugin used to update the dns data for the address obtained from the address-plugin. Defaults to dry-run, which just prints the address. Use \fI\-\-list-services\fR to list available plugins. .TP 4 \fB-a, --address-plugin\fR <\fIplugin\fR> Plugin used to obtain the actual ip address. Defaults to default-if, which localizes the default interface using /usr/sbin/ip and uses it's primary address. Use \fI\-\-list-addressers\fR to list available plugins. .TP 4 \fB-C, --auth-plugin <\fIplugin\fR> Plugin providing authentication credentials, either \fInetrc\fR or \fIkeyring\fR .TP 4 \fB-v, --ip-version\fR <\fIv4\fR|\fIv6\fR|\fIall\fR> The kind of ip addresses to register. The addresses obtained by the address-plugin could be either v6, v4 or both. However, the actual addresses sent to the service plugin is filtered using this option so for example an unused ipv6 address not becomes an official address to the host. Defaults to \fIv4\fr. .TP 4 \fB-L, --loglevel\fR [\fIlevel\fR] Determine the amount of logging information. \fIlevel\fR is a symbolic syslog level: \fIerror\fR,\fIwarning, \fIinfo\fR, or \fIdebug\fR. It defaults to \fIwarning\fR. .TP 4 \fB-o, --service-option\fR <\fIplugin option\fR> Option interpreted by service plugin, documented in \fI--help \fR. May be given multiple times as required. Any option on the command line will clear the list of options as of the config file. See PLUGIN OPTIONS. .TP 4 \fB-O, --address-option\fR <\fIplugin option\fR> Option interpreted by address-plugin. See \fI\-\-service-option\fR and PLUGIN OPTIONS. .P Other options: .TP 4 \fB-c, --config-file\fR <\fIpath\fR> File containing default values for all command line options. The path must be absolute. An example file is distributed with the sources. See [FILES] below. .TP 4 \fB-f, --force\fR Force \fBddupdate\fR to run even if the cached value is still valid. .TP 4 \fB-e, --execute-section
\fR Only run the given section in configuration file. Use \fI\-\-list-sections\fR to list available sections. .TP 4 \fB-h, --help [plugin] \fR Print help. If given a plugin argument, prints help for this plugin. .TP 4 \fB-S, --list-services\fR List service provider plugins. .TP 4 \fB-A, --list-addressers\fR List plugins providing one or more ip addresses .TP 4 \fB-P, --list-auth-plugins\fR List plugins for storing credentials like \fInetrc\fR and \fIkeyring\fR. .TP 4 \fB-E, --list-sections\fR List available sections in configuration file. .TP 4 \fB-V, --version\fR Print \fBddupdate\fR version. .SH PLUGIN OPTIONS The plugin options are generally just handed to the plugins without any further interpretation. An option is either a single keyword or a \fIkey=value\fR string. No whitespace is allowed in \fIkey\fR or \fIvalue\fR. .SH PLUGIN LOADING \fBddupdate\fR looks for a directory named \fIplugins\fR and tries to load plugins from all files in this directory. The search for \fIplugins\fR is done, in descending priority: .IP \(bu 4 The directory \fIplugins\fR in the same directory as the main.py module. This is the development case, and the highest priority. .IP \(bu 4 User plugins are searched in \fI~/.local/share/ddupdate/plugins\fR. Setting the XDG_DATA_HOME environment relocates this to \fI$XDG_DATA_HOME/ddupdate/plugins\fR .IP \(bu 4 The directories listed in the XDG_DATA_DIRS environment variable, by default \fI/usr/local/share:/usr/share\fR, are searched for \fIddupdate/plugins\fR. .SH EXAMPLES .P Please note that the command line options are normally stored in \fI~/.config/ddupdate.conf\fR, allowing an invocation without command line options. .P Update on dyndns.com using the external address as seen from the internet, displaying the address used: .nf ddupdate -a default-web-ip -s dtdns.com -H myhost.dyndns.org -l info .fi .P Make a debug run without actually updating, displaying the address on the local, default interface: .nf ddupdate -a default-if -s dry-run --loglevel info -H host.dyndns.org .fi .SH ENVIRONMENT \fBddupdate\fR respects the data paths defined by freedesktop.org. .TP 4 .B XDG_CACHE_HOME Locates the cached addresses files. See FILES. .TP 4 .B XDG_DATA_HOME Locates user plugins. See PLUGIN LOADING. .TP 4 .B XDG_DATA_DIRS Involved in system plugins, see PLUGIN LOADING. .TP 4 .B XDG_CONFIG_HOME User configuration file parent directory location, defaults to \fI~/.config\fR. .P \fBddupdate\fR also accepts the standard proxy environment: .TP 4 .B http_proxy, https_proxy URL to used proxies for http and https connections. The systemd service files distributed has provisions to define these as required. .SH FILES .TP 4 .B ~/.netrc When configured with the \fInetrc\fR authentication backend, this file is used to store username and password for logging in to service providers. See netrc(5) for the format used. The file must have restricted permissions like 600 to be accepted. .TP 4 .B /etc/netrc Fallback location for credentials when \fI~/.netrc\fR is not found. The use of this file is deprecated. .TP 4 .B ~/.config/ddupdate.conf Default config file location. If defined, the XDG_CONFIG_HOME variable relocates this to \fI$XDG_CONFIG_HOME/ddupdate.conf\fR. .TP 4 .B /etc/ddupdate.conf Fallback configuration file location. .TP 4 .B /usr/share/ddupdate/plugins Default directory for upstream plugins, see PLUGIN LOADING. .TP 4 .B /usr/local/share/ddupdate/plugins Default directory for site plugins, see PLUGIN LOADING. .TP 4 .B ~/.local/share/ddupdate/plugins Default directory for user plugins, see PLUGIN LOADING. .TP 4 .B ~/.cache/ddupdate/* Cached address from last update with an actual change, one for each update service. Setting the XDG_CACHE_HOME environment variable relocates these files to $XDG_CACHE_HOME/ddupdate/*. .SH "SEE ALSO" .TP 4 .B ddupdate.conf(5) Configuration file .TP 4 .B ddupdate-config(8) Configuration tool .TP 4 .B netrc(5) Authentication tokens file, originally used by ftp(1), used by the netrc authentication backend. .TP 4 .B https://pypi.org/project/keyring/ Interface for the keyring authentication backend .TP 4 .B https://github.com/leamas/ddupdate Project homesite and README ddupdate-0.7.2/ddupdate.conf000066400000000000000000000005511434670644200157660ustar00rootroot00000000000000# # ddupdate main configuration. Stanzas here reflect the commandline # options described in ddupdate(8). # [update] address-plugin = default-if service-plugin = dry-run hostname = host1.nowhere.net ip-version = v4 loglevel = warning # # Check for plugin options using ddupdate --help # # service-options = foo bar # address-options = foo bar ddupdate-0.7.2/ddupdate.conf.5000066400000000000000000000023121434670644200161260ustar00rootroot00000000000000.TH DDUPDATE.CONF "5" "Last change: Apr 2022" "ddupdate.comf" "File Formats Manual" .SH NAME \fBddupdate.conf\fR - ddupdate configuration file. .SH SYNOPSIS The \fBddupdate.conf\fR file holds default values for all \fBddupdate(8)\fR options. Since \fBddupdate\fR normally is invoked without command line parameters, this file then represents the used option values. .SH BASIC FILE FORMAT The file is formatted according to the rules use by the python3 configparser module. Basically, this is a leading \fI[update]\fR line followed by by \fI key = value\fR lines. The \fikey\fR represents the command line option, and \fIvalue\fR the value of said option. .P The actual options available are documented in \fBddupdate(8)\fR. .SH EXTENDED FORMAT FOR MULTIPLE HOSTS File has experimental support for updating multiple services. This is done using multiple \fI[hostname]\fR sections. The \fIhostname\fR is an arbitrary string without whitespace. Each section has the same syntax as the BASIC FILE FORMAT \fI[update]\fR section. .SH "SEE ALSO" .TP 4 .B ddupdate(8) .TP 4 .B https://github.com/leamas/ddupdate Project homesite and README .TP 4 .B https://docs.python.org/3/library/configparser.html More info on file format. ddupdate-0.7.2/dispatcher.d/000077500000000000000000000000001434670644200156745ustar00rootroot00000000000000ddupdate-0.7.2/dispatcher.d/50-ddupdate000077500000000000000000000015051434670644200176370ustar00rootroot00000000000000#!/bin/sh # # # Example file for activating ddupdate when default interface is up. # Install into /etc/NetworkManager/dispatcher.d to activate. # The user which runs ddupdate.service. DDUSER=foo export LC_ALL=C systemctl="/usr/bin/systemctl" logger="/usr/bin/logger" ip="/sbin/ip" if ! $systemctl is-enabled ddupdate.timer >/dev/null 2>&1; then exit 0 fi case "$2" in "down") if $ip route ls | grep -q '^default'; then exit 0 fi $logger "ddupdate: default interface down: stopping timer" || : sudo -u $DDUSER $systemctl --user stop ddupdate.timer || : ;; "up") if $ip -o route show dev "$1" | grep -q '^default' then $logger "ddupdate: default interface up: starting timer" || : sudo -u $DDUSER $systemctl --user start ddupdate.timer || : fi ;; *) ;; esac ddupdate-0.7.2/lib/000077500000000000000000000000001434670644200140725ustar00rootroot00000000000000ddupdate-0.7.2/lib/ddupdate/000077500000000000000000000000001434670644200156645ustar00rootroot00000000000000ddupdate-0.7.2/lib/ddupdate/__init__.py000066400000000000000000000000311434670644200177670ustar00rootroot00000000000000"""Empty placeholder.""" ddupdate-0.7.2/lib/ddupdate/config.py000077500000000000000000000312221434670644200175060ustar00rootroot00000000000000 """Simple, CLI configuration script for ddupdate.""" import configparser import os import os.path import re import shutil import subprocess import sys import tempfile import time from ddupdate.main import \ log_setup, build_load_path, envvar_default, load_plugin_dir from ddupdate.ddplugin import ServicePlugin, AddressPlugin, AuthPlugin _CONFIG_TRAILER = """ # # Check for plugin options using ddupdate --help # # service-options = foo bar # address-options = foo bar """ class _GoodbyeError(Exception): """General error, implies sys.exit().""" def __init__(self, msg="", exitcode=0): Exception.__init__(self, msg) self.exitcode = exitcode self.msg = msg def check_existing_files(): """Check existing files and let user save them.""" confdir = \ envvar_default('XDG_CONFIG_HOME', os.path.expanduser('~/.config')) files = [os.path.join(confdir, 'ddupdate.conf')] files = [f for f in files if os.path.exists(f)] if not files: return print("The following configuration file(s) already exists:") for f in files: print(" " + f) reply = input("OK to overwrite (Yes/No) [No]: ") if not reply or not reply.lower().startswith('y'): print("Please save these file(s) and try again.") raise _GoodbyeError("", 0) from None def _load_plugins(log, paths, plugin_class): """ Load plugins into dict keyed by name. Parameters: - log: Standard python log instance. - paths: List of strings, path candidates containing plugins. - plugin_class: Type, base class of plugins to load. Returns: dict of loaded plugins with plugin.name() as key. """ plugins = {} for path in paths: these = load_plugin_dir(os.path.join(path, 'plugins'), plugin_class) these_by_name = {plug.name(): plug for plug in these} for name, plugin in these_by_name.items(): plugins.setdefault(name, plugin) log.debug("Loaded %d plugins from %s", len(plugins), path) return plugins def _load_services(log, paths): """Load service plugins from paths into dict keyed by name.""" return _load_plugins(log, paths, ServicePlugin) def _load_addressers(log, paths): """Load address plugins from paths into dict keyed by name.""" return _load_plugins(log, paths, AddressPlugin) def _load_auth_plugins(log, paths): """Load auth plugins from paths into dict keyed by name.""" return _load_plugins(log, paths, AuthPlugin) def get_service_plugin(service_plugins): """ Present a menu with all plugins to user, let her select. Parameters: - service_plugins: Dict of loaded plugins keyed by plugin.name() Return: A loaded plugin as selected by user. """ ix = 1 services_by_ix = {} for id_ in sorted(service_plugins): print("%2d %-18s %s" % (ix, id_, service_plugins[id_].oneliner())) services_by_ix[ix] = service_plugins[id_] ix += 1 text = input("Select service to use: ") try: ix = int(text) except ValueError: raise _GoodbyeError("Illegal number format", 1) from None if ix not in range(1, len(services_by_ix) + 1): raise _GoodbyeError("Illegal selection\n", 2) from None return services_by_ix[ix] def get_auth_plugin(plugins): """ Present a menu with all auth plugins to user, let her select. Parameters: - plugins: Dict of loaded plugins keyed by plugin.name() Return: A loaded plugin as selected by user. """ print("\nAvailable backends for storing passwords") ix = 1 plugins_by_ix = {} for id_ in sorted(plugins): print("%2d %-18s %s" % (ix, id_, plugins[id_].oneliner())) plugins_by_ix[ix] = plugins[id_] ix += 1 text = input("Select backend (use keyring if in doubt): ") try: ix = int(text) except ValueError: raise _GoodbyeError("Illegal number format", 1) from None if ix not in range(1, len(plugins_by_ix) + 1): raise _GoodbyeError("Illegal selection\n", 2) from None return plugins_by_ix[ix] def get_address_plugin(log, paths): """ Let user select address plugin. Parameters: - log: Standard python log instance. - paths: List of strings, directory paths to load plugins from. Return: Name of selected address plugin. """ plugins = _load_addressers(log, paths) web_default_ip = plugins['default-web-ip'] default_if = plugins['default-if'] print("Probing for addresses, can take some time...") if_addr = default_if.get_ip(log, []) web_addr = web_default_ip.get_ip(log, []) print("1 Use address as seen from Internet [%s]" % web_addr.v4) print("2 Use address as seen on local network [%s]" % if_addr.v4) print("3 Use address as decided by service") ix = input("Select address to register (1, 2, 3) [1]: ").strip() ix = ix if ix else '1' plugin_by_ix = { '1': 'default-web-ip', '2': 'default-if', '3': 'ip-disabled' } if ix in plugin_by_ix: return plugin_by_ix[ix] raise _GoodbyeError("Illegal value", 1) from None def copy_systemd_units(): """Copy system-wide templates to ~/.config/systemd/user.""" confdir = \ envvar_default('XDG_CONFIG_HOME', os.path.expanduser('~/.config')) user_dir = os.path.join(confdir, 'systemd/user') if not os.path.exists(user_dir): os.makedirs(user_dir) here = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) srcdir = os.path.join(here, '..', '..', 'systemd') if not os.path.exists(srcdir): srcdir = "/usr/local/share/ddupdate/systemd" if not os.path.exists(srcdir): srcdir = "/usr/share/ddupdate/systemd" path = os.path.join(user_dir, "ddupdate.service") if not os.path.exists(path): shutil.copy(os.path.join(srcdir, "ddupdate.service"), path) path = os.path.join(user_dir, "ddupdate.timer") if not os.path.exists(path): shutil.copy(os.path.join(srcdir, "ddupdate.timer"), path) # Ad-hoc logic: Use script in /usr/local/bin or /usr/bin if existing, # else the one in current dir. This is practical although not quite # consistent. installconf_path = os.path.join(here, "install.conf") parser = configparser.SafeConfigParser() try: parser.read(installconf_path) except configparser.Error: parser.clear() if 'install' in parser: bindir = parser['install']['install_scripts'] else: bindir = os.path.abspath(os.path.join(here, '..', '..')) with open(os.path.join(user_dir, 'ddupdate.service')) as f: lines = f.readlines() output = [] for line in lines: if line.startswith('ExecStart'): output.append("ExecStart=" + bindir + "/ddupdate") else: output.append(line) with open(os.path.join(user_dir, 'ddupdate.service'), 'w') as f: f.write('\n'.join([elem.strip() for elem in output])) def get_netrc(service): """Get .netrc line for service. Looks into the service class documentation for a line starting with 'machine' and returns it after substituting values in angle brackets lke with values supllied by user. Parameters: - service: Loaded service plugin. Return: netrc line with , , etc., as substituted by user. """ lines = service.info().split('\n') line = '' for line in lines: if line.strip().startswith('machine'): break else: return None matches = re.findall('<([^>]+)>', line) for item in matches: value = input("[%s] %s :" % (service.name(), item)) line = line.replace('<' + item + '>', value) return line def update_config(config, path): """ Merge values from config dict and existing conf into tempfile. Parameters: - config: dict of new configuration options. - path: Path to existing config file. Return: Path to temporary config file with updated options. """ parser = configparser.SafeConfigParser() try: parser.read(path) except configparser.Error: parser.clear() parser.setdefault('update', {}) parser['update'].setdefault('ip-version', 'v4') parser['update'].setdefault('loglevel', 'info') parser['update'].update(config) with tempfile.NamedTemporaryFile(delete=False, mode='w') as f: f.write('# Created by ddupdate-config at %s\n' % time.asctime()) parser.write(f) if ('service-options' or 'address-options') not in parser: f.write(_CONFIG_TRAILER) f.flush() return f.name def write_config_files(config): """ Merge user config data into user config file. Parameters: - config: dict with new configuration options. Updates: ~/.config/ddupdate.conf, respecting XDG_CONFIG_HOME. """ confdir = \ envvar_default('XDG_CONFIG_HOME', os.path.expanduser('~/.config')) if not os.path.exists(confdir): os.makedirs(confdir) dest = os.path.join(confdir, "ddupdate.conf") tmp_conf = update_config(config, dest) shutil.copyfile(tmp_conf, os.path.join(confdir, "ddupdate.conf")) os.unlink(tmp_conf) print("Patched config file: " + dest) def write_credentials(auth_plugin, hostname, netrc): """Update credentials at auth_plugin with data from netrc.""" username = None password = None if not netrc: print("NOTE: No credentials defined") return words = netrc.split(' ') words = [word for word in words if word] for i in range(0, len(words) - 1): if words[i] == 'machine': hostname = words[i + 1].lower() if words[i] == 'login': username = words[i + 1] if words[i] == 'password': password = words[i + 1] auth_plugin.set_password(hostname, username, password) print("Updated password for user %s at %s" % (username, hostname)) def try_start_service(): """Start dduppdate systemd user service and display logs.""" print("Starting service and displaying logs") cmd = 'systemctl --user daemon-reload' cmd += ';systemctl --user start ddupdate.service' cmd += ';journalctl -l --user --since -60s -u ddupdate.service' cmd = ['sh', '-c', cmd] subprocess.run(cmd, check=True) print('Use "journalctl --user -u ddupdate.service" to display logs.') def enable_service(): """Enable/start service and timer as user determines.""" reply = input("Shall I run service regularly (Yes/No) [No]: ") do_start = reply and reply.lower().startswith('y') if do_start: cmd = 'systemctl --user start ddupdate.timer' cmd += ';systemctl --user enable ddupdate.timer' print("\nStarting and enabling ddupdate.timer") try: subprocess.run(['sh', '-c', cmd], check=True) except subprocess.CalledProcessError as err: raise _GoodbyeError( "Cannot start ddupdate.timer: " + str(err), 2) from None else: cmd = 'systemctl --user stop ddupdate.timer' cmd += 'systemctl --user disable ddupdate.timer' print("Stopping ddupdate.timer") try: subprocess.run(['sh', '-c', cmd], check=True) except subprocess.CalledProcessError as err: print("Cannot stop ddupdate.timer (already stopped?)") msg = "systemctl --user start ddupdate.timer" msg += "; systemctl --user enable ddupdate.timer" print('\nStart ddupdate using "%s"' % msg) print("To run service from boot and after logout do " + '"sudo loginctl enable-linger $USER"') def main(): """Indeed: main function.""" try: log = log_setup() check_existing_files() copy_systemd_units() load_paths = build_load_path(log) service_plugins = _load_services(log, load_paths) service = get_service_plugin(service_plugins) netrc = get_netrc(service) auth_plugins = _load_auth_plugins(log, load_paths) auth_plugin = get_auth_plugin(auth_plugins) hostname = input("[%s] hostname: " % service.name()) address_plugin = get_address_plugin(log, load_paths) conf = { 'address-plugin': address_plugin, 'service-plugin': service.name(), 'hostname': hostname, 'auth-plugin': auth_plugin.name() } write_credentials(auth_plugin, hostname, netrc) write_config_files(conf) try_start_service() enable_service() except _GoodbyeError as err: if err.exitcode != 0: sys.stderr.write("Fatal error: " + str(err) + "\n") sys.exit(err.exitcode) if __name__ == '__main__': main() # vim: set expandtab ts=4 sw=4: ddupdate-0.7.2/lib/ddupdate/ddplugin.py000066400000000000000000000235441434670644200200540ustar00rootroot00000000000000""" ddupdate plugin API. A plugin is either a service plugin or an address plugin. Service plugins register the ip address with a dynamic dns service provider. They implement the ServicePlugin abstract interface. Naming of these plugins is normally based on the website used to register since these by definition are unique Address plugins determines the ip address to register. They implement the abstract AddressPlugin interface. All plugins shares the AbstractPlugin interface. This handles general aspects like name and documentation. The module also provides some utility functions used in plugins. """ import inspect import urllib.request from urllib.parse import urlencode, urlparse from socket import timeout as timeoutError URL_TIMEOUT = 120 # Default timeout in get_response() # Pesky,transitional global for actual AuthPlugin auth_plugin = None def set_auth_plugin(plugin): """Define the actual AuthPlugin used.""" # pylint: disable=global-statement # See #63 global auth_plugin auth_plugin = plugin def get_auth_plugin(): """Return actual AuthPlugin used.""" return auth_plugin # pylint: disable=duplicate-code def http_basic_auth_setup(url, host=None): """ Configure urllib to provide basic authentication. Parameters: - url: string, the url to connect to. - host: string, hostname looked up in .netrc. Defaults to to hostname part of url. """ if not host: host = urlparse(url).hostname user, password = get_netrc_auth(host) pwmgr = urllib.request.HTTPPasswordMgrWithDefaultRealm() pwmgr.add_password(None, url, user, password) auth_handler = urllib.request.HTTPBasicAuthHandler(pwmgr) opener = urllib.request.build_opener(auth_handler) urllib.request.install_opener(opener) def dict_of_opts(options): """ Convert list of plugin options from the arg_parser to a dict. Single keyword options are inserted as dict[keyword] = True, key=val options are inserted as dict[key] = val. """ if not options: return {} result = {} for opt in options: if '=' in opt: key, value = opt.split('=') result[key] = value else: result[opt] = True return result def get_response(log, url, **kwargs): """ Get data from server at given url. Parameters: - log: Standard python log instance - url: The url to make a post/get request to. - kwargs: Keyword arguments. - data: dict of post data. If data != None, get_response makes a http POST request, otherwise a http GET. - timeout: int, timeout in seconds. Defaults to 120. - header: a (header, contents) tuple like ('api-key', 'xxxx') Returns: - Text read from url. Raises: - ServiceError if return code is != 200, httpError or timeout. """ log.debug("Trying url: %s", url) data = urlencode(kwargs['data']).encode() if 'data' in kwargs else None to = kwargs['timeout'] if 'timeout' in kwargs else URL_TIMEOUT if data: log.debug("Posting data: " + data.decode('ascii')) try: request = urllib.request.Request(url) if 'header' in kwargs: request.add_header(*kwargs['header']) with urllib.request.urlopen(request, data, timeout=to) as response: code = response.getcode() html = response.read().decode('ascii') except timeoutError: raise ServiceError("Timeout reading %s" % url) from None except (urllib.error.HTTPError, urllib.error.URLError) as err: raise ServiceError("Error reading %s :%s" % (url, err)) from err log.debug("Got response (%d) : %s", code, html) if code != 200: raise ServiceError("Cannot update, response code: %d" % code) return html def get_netrc_auth(machine): """Retrieve data from configured credentials source. The function name is thus misleading but kept for legacy reasons. Parameters: - machine: key used to look up credentials. Returns: - A (user, password) tuple. User might be None. Raises: -AuthError if credentials cannot be retrieved. """ return auth_plugin.get_auth(machine.lower()) class IpAddr: """A (ipv4, ipv6) container.""" def __init__(self, ipv4=None, ipv6=None): """ Construct a fresh object. Parameters: - ipv4: string, the ipv4 address in dotted notation. - ipv6: string, the ipv6 address in colon-hex notation. """ self.v4 = ipv4 self.v6 = ipv6 def __str__(self): return repr([self.v4, self.v6]) def __eq__(self, obj): if not isinstance(obj, IpAddr): return False return obj.v4 == self.v4 and obj.v6 == self.v6 def __hash__(self): return hash(self.v4, self.v6) def empty(self): """Check if any address is set.""" return self.v4 is None and self.v6 is None def parse_ifconfig_output(self, text): """ Update v4 and v6 attributes by parsing ifconfig(8) or ip(8) output. Parameters: - text: string, ifconfig or ip address show dev output. Raises: - AddressError if no address can be found in text """ for line in text.split('\n'): words = [word for word in line.split(' ') if word != ''] if words[0] == 'inet': # use existing logic self.v4 = words[1].split('/')[0] elif words[0] == 'inet6': if self.v6: # stop if we already have an address continue addr = words[1].split('/')[0] words = set(words[2:]) if ('link' in words) or ('0x20' in words): # don't use a link-local address continue if 'deprecated' in words: # don't use a "deprecated" address continue self.v6 = addr if self.empty(): raise AddressError("Cannot find address for %s, giving up" % text) class AddressError(Exception): """General error in AddressPlugin.""" def __init__(self, value, exitcode=1): """ Construct the error. Parameters: - value: string, error message - exitcode: int, aimed as sys.exit() argument. """ Exception.__init__(self, value) self.value = value self.exitcode = exitcode def __str__(self): """Represent the error.""" return repr(self.value) class ServiceError(AddressError): """General error in ServicePlugin.""" class AuthError(AddressError): """General error in AuthPlugin.""" class AbstractPlugin: """Abstract base for all plugins.""" _name = None _oneliner = 'No info found' __version__ = '0.7.2' def __str__(self): # pylint: disable=invalid-str-returned """Standard implementation.""" return self.name() def oneliner(self): """Return oneliner describing the plugin.""" return self._oneliner def info(self): """ Return full, formatted user info; in particular, options used. Default implementation returns class docstring. """ return inspect.getdoc(self) def name(self): """ Retrieve the plugin short, unique id (no spaces). Returning None implies not-a-plugin. Names must be unique. Also module name (i. e., filename) must be unique. """ return self._name def version(self): """Return plugin version.""" return self.__version__ class AddressPlugin(AbstractPlugin): """An abstract plugin obtaining the ip address.""" def get_ip(self, log, options): """ Return ip address to register. Parameters: - log: Standard python log instance. - options: List of --address-option options. Returns: - IpAddr or None Raises: AddressError. """ raise NotImplementedError("Attempt to invoke abstract get_ip()") class ServicePlugin(AbstractPlugin): """Abstract plugin doing the actual update work using a service.""" _ip_cache_ttl = 120 # 2 hours, address cache timeout def __init__(self): """Default, empty constructor.""" AbstractPlugin.__init__(self) def ip_cache_ttl(self): """Return time when ip cache expires, in minutes from creation.""" return self._ip_cache_ttl def register(self, log, hostname, ip, options): """ Do the actual update. Parameters: - log: Standard python log instance - hostname: string, the DNS name to register - ip: IpAddr, address to register - opts: List of --service-option values. Raises: - ServiceError on errors. """ raise NotImplementedError("Attempt to invoke abstract register()") class AuthPlugin(AbstractPlugin): """Abstract plugin for managing credentials for a hostname.""" def get_auth(self, machine): """Retrieve credentials for a machine. Parameters: - machine: Key while searching for credentials. Returns: - A (user, password) tuple. User might be None. Raises: - AuthError if credentials cannot be retrieved. """ raise NotImplementedError("Attempt to invoke abstract get_auth()") def set_password(self, machine, username, password): """ Set username/password credentials for a machine. Parameters: - machine: Key for stored credentials - username: Possibly empty reflecting machines using an API key. - password: string Raises: - AuthError if credentials cannot be stored. """ raise NotImplementedError("Attempt to invoke abstract get_auth()") ddupdate-0.7.2/lib/ddupdate/main.py000077500000000000000000000444441434670644200171770ustar00rootroot00000000000000"""Update DNS data for dynamic ip addresses.""" import argparse import ast import configparser import glob import importlib import importlib.util import inspect import logging import math import os import os.path import stat import sys import time from ddupdate.ddplugin import AddressPlugin, AddressError from ddupdate.ddplugin import ServicePlugin, ServiceError, IpAddr from ddupdate.ddplugin import AuthPlugin, AuthError from ddupdate.ddplugin import set_auth_plugin, get_auth_plugin if 'XDG_CACHE_HOME' in os.environ: CACHE_DIR = os.environ['XDG_CACHE_HOME'] else: CACHE_DIR = os.path.expanduser('~/.cache') DEFAULTS = { 'hostname': 'host.nowhere.net', 'address-plugin': 'default-if', 'service-plugin': 'dry-run', 'auth-plugin': 'netrc', 'loglevel': 'info', 'ip-version': 'v4', 'service-options': None, 'address-options': None, 'ip-cache': os.path.join(CACHE_DIR, 'ddupdate'), 'force': False } class _GoodbyeError(Exception): """General error, implies sys.exit().""" def __init__(self, msg="", exitcode=0): Exception.__init__(self, msg) self.exitcode = exitcode self.msg = msg class _SectionFailError(Exception): """General error, terminates section processing.""" def envvar_default(var, default=None): """Return var if found in environment, else default.""" return os.environ[var] if var in os.environ else default def ip_cache_setup(opts): """Ensure that our cache directory exists, return cache file path.""" if not os.path.exists(opts.ip_cache): os.makedirs(opts.ip_cache) return os.path.join(opts.ip_cache, opts.service_plugin + '_' + opts.hostname + '.ip') def ip_cache_clear(opts, log): """Remove the cache file for actual service plugin in opts.""" path = ip_cache_setup(opts) if not os.path.exists(path): return log.debug("Removing cache file: " + path) os.unlink(path) def ip_cache_data(opts, log, default=(IpAddr(ipv4="0.0.0.0"), 100000)): """ Return an (address, cache age in minute) tuple. If not existing, the default value is returned. """ path = ip_cache_setup(opts) if not os.path.exists(path): return default mtime = os.stat(path)[stat.ST_MTIME] now = time.time() delta = math.floor((now - mtime) / 60) with open(path) as f: astr = f.read().strip() try: ll = ast.literal_eval(astr) ip = IpAddr(ipv4=ll[0], ipv6=ll[1]) except SyntaxError: log.debug("SyntaxError while reading ip cache.") ip_cache_clear(opts, log) ip, delta = default return ip, delta def ip_cache_set(opts, ip): """Set the cached address to IpAddr ip.""" path = ip_cache_setup(opts) ip = ip if ip else IpAddr(ipv4="0.0.0.0") with open(path, "w") as f: f.write(str(ip)) def here(path): """Return path added to current dir for __file__.""" return os.path.join(os.path.dirname(os.path.abspath(__file__)), path) def parse_conffile(log): """Parse config file path, returns verified path or None.""" path = envvar_default('XDG_CONFIG_HOME', os.path.expanduser('~/.config')) path = os.path.join(path, 'ddupdate.conf') if not os.path.exists(path): path = '/etc/ddupdate.conf' for i, arg in enumerate(sys.argv): if arg.startswith('-c') or arg.startswith('--conf'): if arg.startswith('-c') and len(arg) > 2: path = arg[2:] elif '=' in arg: path = arg.split('=')[1] elif i < len(sys.argv) - 1: path = sys.argv[i + 1] else: # Trust that the regular parsing handles the error. return None if not os.access(path, os.R_OK): log.warning("Cannot open config file ~/.config/ddupdate.conf for read", path) return None return path def parse_config(config, section): """Return dict with values from config backed by DEFAULTS.""" results = {} if section not in config: raise _GoodbyeError("No such section: " + section, 2) items = config[section] for key, value in DEFAULTS.items(): results[key] = items[key] if key in items else value return results def get_config(log): """Parse config file, return a (ConfigParser, list of sections) tuple.""" path = parse_conffile(log) config = configparser.ConfigParser() config.read(path) sections = list(config.keys()) if 'DEFAULT' in sections: sections.remove('DEFAULT') return config, sections def get_parser(conf): """Construct the argparser.""" parser = argparse.ArgumentParser( prog='ddupdate', add_help=False, description="Tool to update DNS data for dynamic ip addresses") normals = parser.add_argument_group() normals.title = "Normal operation options" normals.add_argument( "-H", "--hostname", metavar="host", help='Hostname to update [host.nowhere.net]', default=conf['hostname']) normals.add_argument( "-s", "--service-plugin", metavar="plugin", help='Plugin updating a dns hostname address [%s]' % conf['service-plugin'], default=conf['service-plugin']) normals.add_argument( "-a", "--address-plugin", metavar="plugin", help='Plugin providing ip address to use [%s]' % conf['address-plugin'], default=conf['address-plugin']) normals.add_argument( "-C", "--auth-plugin", metavar="plugin", help='Plugin providing authentication credentials [%s]' % conf['auth-plugin'], default=conf['auth-plugin']) normals.add_argument( "-c", "--config-file", metavar="path", help='Config file with default values for all options' + ' [' + envvar_default('XDG_CONFIG_HOME', ' ~/.config/ddupdate.conf') + ':/etc/ddupdate.conf]', dest='config_file', default='/etc/ddupdate.conf') normals.add_argument( "-l", "--loglevel", metavar='level', choices=['error', 'warning', 'info', 'debug'], help='Amount of printed diagnostics [warning]', default=conf['loglevel']) normals.add_argument( "-v", "--ip-version", metavar='version', choices=['all', 'v6', 'v4'], help='Ip address version(s) to register (v6, v4, all) [v4]', default=conf['ip-version']) normals.add_argument( "-o", "--service-option", metavar="plugin option", help='Service plugin option (enter multiple times if required)', dest='service_options', action='append') normals.add_argument( "-O", "--address-option", metavar="plugin option", help='Address plugin option (enter multiple times if required)', dest='address_options', action='append') normals.add_argument( "-i", "--ip-plugin", help=argparse.SUPPRESS) others = parser.add_argument_group() others.title = "Other options" others.add_argument( "-S", "--list-services", help='List service provider plugins. ', default=False, action='store_true') others.add_argument( "-A", "--list-addressers", help='List plugins providing ip address. ', default=False, action='store_true') others.add_argument( "-P", "--list-auth-plugins", help='List plugins providing credentials. ', default=False, action='store_true') others.add_argument( "-E", "--list-sections", help='List configuration file sections. ', default=False, action='store_true') others.add_argument( "-e", "--execute-section", metavar="section", help='Update a given configuration file section [all sections]', dest='execute_section', default='') others.add_argument( "-p", "--set_password", nargs=3, metavar=('host', 'user', 'pw'), help='Update username/password for host. Use "" for empty username', default="") others.add_argument( "-f", "--force", help='Force run even if the cache is fresh', default=False, action='store_true') others.add_argument( "-h", "--help", metavar="plugin", help='Print overall help or help for given plugin', nargs='?', const='-') others.add_argument( "-V", "--version", help='Print ddupdate version and exit', action='version') return parser def parse_options(conf): """Parse command line using conf as defaults, return namespace.""" level_by_name = { 'error': logging.ERROR, 'warn': logging.WARNING, 'warning': logging.WARNING, 'info': logging.INFO, 'debug': logging.DEBUG, } parser = get_parser(conf) parser.version = "0.7.2" opts = parser.parse_args() if opts.help == '-': parser.print_help() raise _GoodbyeError() if not opts.address_options: opts.address_options = [] if conf['address-options']: opts.address_options = conf['address-options'].split() if not opts.service_options: opts.service_options = [] if conf['service-options']: opts.service_options = conf['service-options'].split() opts.loglevel = level_by_name[opts.loglevel] opts.ip_cache = conf['ip-cache'] return opts def log_setup(): """Initialize and return the module log.""" log = logging.getLogger('ddupdate') log.setLevel(logging.DEBUG) handler = logging.StreamHandler() handler.setLevel(logging.INFO) formatter = logging.Formatter("%(levelname)s - %(message)s") handler.setFormatter(formatter) log.addHandler(handler) return log def log_init(log, loglevel, opts): """Initiate the global log.""" log.handlers[0].setLevel(loglevel if loglevel else opts.loglevel) log.debug('Using config file: %s', parse_conffile(log)) log.info("Loglevel: " + logging.getLevelName(opts.loglevel)) log.info("Using hostname: " + opts.hostname) log.info("Using ip address plugin: " + opts.address_plugin) log.info("Using service plugin: " + opts.service_plugin) log.info("Service options: " + (' '.join(opts.service_options) if opts.service_options else '')) log.info("Address options: " + (' '.join(opts.address_options) if opts.address_options else '')) def load_module(path): """Return instantiated module loaded from given path.""" # pylint: disable=deprecated-method name = os.path.basename(path).replace('.py', '') spec = importlib.util.spec_from_file_location(name, path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def load_plugin_dir(dirpath, parent_class): """ Load all plugins in dirpath having a class derived from parent_class. Parameters: - dirpath: string, all path/*.py files are plugin candidates. - parent_class: class, objects being a subclass of parent are loaded. Returns: List of instantiated plugins, all derived from parent_class. """ found = [] for plugpath in glob.glob(os.path.join(dirpath, '*.py')): try: module = load_module(plugpath) except ImportError: continue for member_class in [m[1] for m in inspect.getmembers(module)]: # pylint: disable=undefined-loop-variable if not inspect.isclass(member_class): continue if not issubclass(member_class, parent_class): continue if member_class == parent_class: continue instance = member_class() instance.module = module found.append(instance) return found def load_plugins(path, log): """Load ip and service plugins into dicts keyed by name.""" setters = load_plugin_dir(os.path.join(path, 'plugins'), ServicePlugin) getters = load_plugin_dir(os.path.join(path, 'plugins'), AddressPlugin) auths = load_plugin_dir(os.path.join(path, 'plugins'), AuthPlugin) getters_by_name = {plug.name(): plug for plug in getters} setters_by_name = {plug.name(): plug for plug in setters} auths_by_name = {plug.name(): plug for plug in auths} log.debug("Loaded %d address, %d service and %d auth plugins from %s", len(getters), len(setters), len(auths), path) return auths_by_name, getters_by_name, setters_by_name def list_plugins(plugins): """List given plugins.""" for name, plugin in sorted(plugins.items()): print("%-20s %s" % (name, plugin.oneliner())) def plugin_help(plugins, plugid): """Print full help for given plugin.""" if plugid in plugins: plugin = plugins[plugid] else: raise _GoodbyeError("No help found (no such plugin?): " + plugid, 1) print("Name: " + str(plugin)) print("Source file: " + plugin.module.__file__ + "\n") print(plugin.info()) def set_password(opts): """Set password using selected auth plugin.""" auth_plugin = get_auth_plugin() auth_plugin.set_password(*opts.set_password) def filter_ip(ip_version, ip): """Filter the ip address to match the --ip-version option.""" if ip_version == 'v4': ip.v6 = None elif ip_version == 'v6': ip.v4 = None if ip.empty(): raise AddressError("No usable address") return ip def build_load_path(log): """Return list of paths to load plugins from.""" paths = [] paths.append(envvar_default('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))) syspaths = envvar_default('XDG_DATA_DIRS', '/usr/local/share:/usr/share') paths.extend(syspaths.split(':')) paths = [os.path.join(p, 'ddupdate') for p in paths] home = os.path.join( os.path.dirname(os.path.realpath(__file__)), '..', '..') paths.insert(0, os.path.abspath(home)) log.debug('paths :%s', ':'.join(paths)) return paths def get_plugins(opts, log, sections): """ Handles plugin listing, plugin help or load plugins. Return: (auth_plugin, ip plugin, service plugin) tuple. """ # pylint: disable=too-many-branches,too-many-locals ip_plugins = {} service_plugins = {} auth_plugins = {} for path in build_load_path(log): auths, getters, setters = load_plugins(path, log) for name, plugin in getters.items(): ip_plugins.setdefault(name, plugin) for name, plugin in setters.items(): service_plugins.setdefault(name, plugin) for name, plugin in auths.items(): auth_plugins.setdefault(name, plugin) if opts.list_services: list_plugins(service_plugins) raise _GoodbyeError() if opts.list_addressers: list_plugins(ip_plugins) raise _GoodbyeError() if opts.list_auth_plugins: list_plugins(auth_plugins) raise _GoodbyeError() if opts.help and opts.help != '-': all_plugins = {**auth_plugins, **ip_plugins, **service_plugins} plugin_help(all_plugins, opts.help) raise _GoodbyeError() if opts.list_sections: print("\n".join(sections)) raise _GoodbyeError() if opts.ip_plugin: raise _GoodbyeError( "--ip-plugin has been replaced by --address-plugin.") if opts.address_plugin not in ip_plugins: raise _GoodbyeError('No such ip plugin: ' + opts.address_plugin, 2) if opts.auth_plugin not in auth_plugins: raise _GoodbyeError('No such auth plugin: ' + opts.auth_plugin, 2) if opts.service_plugin not in service_plugins: raise _GoodbyeError( 'No such service plugin: ' + opts.service_plugin, 2) service_plugin = service_plugins[opts.service_plugin] ip_plugin = ip_plugins[opts.address_plugin] auth_plugin = auth_plugins[opts.auth_plugin] if opts.set_password: set_auth_plugin(auth_plugin) set_password(opts) raise _GoodbyeError() return auth_plugin, ip_plugin, service_plugin def get_ip(ip_plugin, opts, log): """Try to get current ip address using the ip_plugin.""" try: ip = ip_plugin.get_ip(log, opts.address_options) except AddressError as err: raise _SectionFailError("Cannot obtain ip address: " + str(err)) \ from err if not ip or ip.empty(): log.info("Using ip address provided by update service") ip = None else: ip = filter_ip(opts.ip_version, ip) log.info("Using ip address: %s", ip) return ip def check_ip_cache(ip, service_plugin, opts, log): """Throw a _SectionFailError if ip is already in a fresh cache.""" if opts.force: ip_cache_clear(opts, log) cached_ip, age = ip_cache_data(opts, log) if age < service_plugin.ip_cache_ttl() and (cached_ip == ip or not ip): log.info("Update inhibited, cache is fresh (%d/%d min)", age, service_plugin.ip_cache_ttl()) raise _SectionFailError() def main(): """Indeed: main function.""" try: log = log_setup() config, sections = get_config(log) opts = parse_options(DEFAULTS) get_plugins(opts, log, sections) if opts.execute_section: sections = [opts.execute_section] for section in sections: try: conf = parse_config(config, section) opts = parse_options(conf) log_init(log, None, opts) log.info("Processing configuration section: %s", section) auth_plugin, ip_plugin, service_plugin = get_plugins( opts, log, sections) set_auth_plugin(auth_plugin) log.debug("Using auth plugin: %s", str(auth_plugin)) ip = get_ip(ip_plugin, opts, log) check_ip_cache(ip, service_plugin, opts, log) service_plugin.register( log, opts.hostname, ip, opts.service_options) ip_cache_set(opts, ip) log.info("Update OK") except _SectionFailError: print("Skipping config section: %s" % section) continue except (ServiceError, AuthError) as err: log.error("Cannot update DNS data: %s", err) log.info("Skipping config section: %s", section) continue except _GoodbyeError as err: if err.exitcode != 0: log.error(err.msg) sys.stderr.write("Fatal error: " + str(err) + "\n") sys.exit(err.exitcode) if __name__ == '__main__': main() # vim: set expandtab ts=4 sw=4: ddupdate-0.7.2/lib/ddupdate/netrc_to_keyring.py000077500000000000000000000007141434670644200216100ustar00rootroot00000000000000"""Simple tools to migrate credentials from ~/.netrc to the keyring. """ import netrc import keyring def main(): """Indeed: main function.""" _netrc = netrc.netrc() for host in _netrc.hosts: login, _, password = _netrc.authenticators(host) print(host) credentials = "{0}\t{1}".format((login or 'api_key'), password) keyring.set_password('ddupdate', host, credentials) if __name__ == '__main__': main() ddupdate-0.7.2/manpage_date000077500000000000000000000005041434670644200156560ustar00rootroot00000000000000#!/usr/bin/bash # # Update a manpage "Last Change" field using the last git commit as date # # Usage: # manpage_date date=$(git log -1 --pretty="format:%ad" --date="format:%B %Y" $1) date=$(echo $date | awk '{print substr($1, 1, 3) " " $2}') sed -i "s/Last change:.*[0-9]/Last change: $date/" $1 ddupdate-0.7.2/plugins/000077500000000000000000000000001434670644200150055ustar00rootroot00000000000000ddupdate-0.7.2/plugins/addr_default_ip.py000066400000000000000000000024701434670644200204700ustar00rootroot00000000000000""" ddupdate plugin to obtain ip address. See: ddupdate(8) """ import subprocess from ddupdate.ddplugin import AddressPlugin, AddressError, IpAddr def find_device(words): """Return first word following 'dev' or None.""" found = False for word in words: if word == "dev": found = True elif found: return word return None class DefaultIfPLugin(AddressPlugin): """ Locates the default interface. Digs in the routing tables and returns it's address using linux-specific code based on the ip utility which must be in $PATH Options used: none """ _name = 'default-if' _oneliner = 'Get ip address from default interface (linux)' def get_ip(self, log, options): """ Get default interface using ip route and address using ifconfig. """ if_ = None for line in subprocess.getoutput('ip route').split('\n'): words = line.split() if words[0] == 'default': if_ = find_device(words) break if if_ is None: raise AddressError("Cannot find default interface, giving up") address = IpAddr() output = subprocess.getoutput('ip address show dev ' + if_) address.parse_ifconfig_output(output) return address ddupdate-0.7.2/plugins/addr_default_web.py000066400000000000000000000033271434670644200206370ustar00rootroot00000000000000""" ddupdate plugin to retrieve address as seen from internet. See: ddupdate(8) """ import urllib.request import re from ddupdate.ddplugin import AddressPlugin, AddressError, IpAddr _URLS = [ 'http://checkip.dyndns.org/', 'https://api.ipify.org?format=json', 'https://ifconfig.co' ] class DefaultWebPlugin(AddressPlugin): """ Get the external address as seen from the web. Relies on urls defined in _URLS, trying each in turn when running into trouble. Options used: none """ _name = 'default-web-ip' _oneliner = 'Obtain external address as seen from the net' def get_ip(self, log, options): """Implement AddressPlugin.get_ip().""" def check_url(url): """Get reply from host and decode.""" log.debug('trying ' + url) try: with urllib.request.urlopen(url) as response: html = response.read().decode('utf-8') except (urllib.error.HTTPError, urllib.error.URLError): log.debug("Bad response at %s (ignored)" % url) return None log.debug("Got response: %s", html) pat = re.compile(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}") match = pat.search(html) if match: return html[match.start(): match.end()] log.debug("Cannot parse address reply") return None for ix, url in enumerate(_URLS): ip = check_url(url) if ip: return IpAddr(ip) if ix + 1 < len(_URLS): log.info("Falling back to %s", _URLS[ix + 1]) raise AddressError( "Cannot obtain ip address (%s, %s and %s tried)" % tuple(_URLS)) ddupdate-0.7.2/plugins/addr_default_web6.py000066400000000000000000000037361434670644200207310ustar00rootroot00000000000000""" ddupdate plugin to retrieve ipv6 address as seen from internet. See: ddupdate(8) """ import urllib.request import urllib.error import re from ddupdate.ddplugin import AddressPlugin, AddressError, IpAddr TIMEOUT = 20 class DefaultWeb6Plugin(AddressPlugin): """ Get the external ipv6 address as seen from the web. Relies on now-dns.com, falling back to ipv6.whatismyip.akamai.com and ifcfg.me. Options used: none """ _name = 'default-web-ip6' _oneliner = 'Obtain external ipv6 address as seen from the net' def get_ip(self, log, options): """Implement AddressPlugin.get_ip().""" def check_url(url): """Get reply from host and decode.""" log.debug('trying ' + url) try: with urllib.request.urlopen(url, None, TIMEOUT) as response: if response.getcode() != 200: log.debug("Bad response at %s (ignored)" % url) return None html = response.read().decode() except urllib.error.URLError as err: log.debug("Got URLError: %s", err) return None log.debug("Got response: %s", html) pat = re.compile(r'[:0-9a-f]{12,}(\s|\Z)') match = pat.search(html) if match: return html[match.start(): match.end()] log.debug("Cannot parse ipv6 address reply") return None urls = [ 'https://now-dns.com/ip', 'http://ipv6.whatismyip.akamai.com', 'https://ifcfg.me/' ] for ix, url in enumerate(urls): log.info('Trying: %s', url) ip = check_url(url) if ip: return IpAddr(None, ip) if ix + 1 < len(urls): log.info("Falling back to %s", urls[ix + 1]) raise AddressError( "Cannot obtain ip6 address (%s, %s and %s tried)" % tuple(urls)) ddupdate-0.7.2/plugins/addr_dnshome_de.py000066400000000000000000000064001434670644200204560ustar00rootroot00000000000000""" ddupdate plugin updating data on dnshome.de. See: ddupdate(8) See: https://www.dnshome.de/ """ import urllib.request import urllib.error import ipaddress from typing import AnyStr, Optional from enum import Enum from logging import Logger from ddupdate.ddplugin import AddressPlugin, IpAddr TIMEOUT = 20 class DeDnshomeAddressURL(Enum): """Enumeration of the available *.dnshome.de ip-resolver urls.""" IP4 = 'https://ip4.dnshome.de' IP6 = 'https://ip6.dnshome.de' class DeDnshomeWebPlugin(AddressPlugin): """Get the external IPv4 and/or IPv6 address as seen from ip.dnshome.de. Depending on the type of your connection one or the other address may be `None`. Also the presence of an IPv4 address does not guarantee that inbound connections can be instantiated from external endpoints (see: DS-Lite and IPv6 tunneling). Relies on [ip4|ip6].dnshome.de Options used: none """ _name = 'ip.dnshome.de' _oneliner = 'Obtain IPv4 and/or IPv6 address as seen by dnshome.de' @staticmethod def extract_ip(data: AnyStr) -> IpAddr: """Extracts the IPs from data. Expects `data` to be an UTF-8 string holding either an single IPv4 or an IPv6 address. Args: data: Data to extract the IP from Returns: An `IpAddr` which may hold the IPv4 or IPv6 Address found. """ try: ip = ipaddress.ip_address(data.strip()) if isinstance(ip, ipaddress.IPv4Address): return IpAddr(ip.exploded, None) if isinstance(ip, ipaddress.IPv6Address): return IpAddr(None, ip.exploded) return IpAddr(None, None) except ValueError: return IpAddr(None, None) @staticmethod def load_ip(log: Logger, url: str) -> Optional[IpAddr]: """Loads the external IP from an remote Endpoint (url). Expects the Endpoint to respond with an UTF-8-String containing the IP. Args: log: Logger url: URL to Load the IP from Returns: An `IPAddr` holding the IPv4 and/or IPv6 Address found or `None`. """ log.debug('loading ip from %s' % url) try: with urllib.request.urlopen(url, None, TIMEOUT) as response: body = response.read().decode() except urllib.error.URLError as err: log.debug("Got URLError: %s", err) return None log.debug("Got response: %s", body) result = DeDnshomeWebPlugin.extract_ip(body) if result.empty(): log.debug("Cannot parse IPv4/IPv6 address from response") return None return result def get_ip(self, log: Logger, options: [str]) -> Optional[IpAddr]: """Implements AddressPlugin.get_ip().""" urls = [DeDnshomeAddressURL.IP4, DeDnshomeAddressURL.IP6] ip = IpAddr(None, None) for url in urls: result = DeDnshomeWebPlugin.load_ip(log, url.value) if result: if not ip.v4 and result.v4: ip.v4 = result.v4 if not ip.v6 and result.v6: ip.v6 = result.v6 log.debug("Returning ip: " + str(ip)) return ip if not ip.empty() else None ddupdate-0.7.2/plugins/addr_hardcoded_if.py000066400000000000000000000015041434670644200207440ustar00rootroot00000000000000""" ddupdate plugin providing an ip address from an interface option. See: ddupdate(8) """ import subprocess from ddupdate.ddplugin import AddressPlugin, AddressError, IpAddr, dict_of_opts class HardcodedIfPlugin(AddressPlugin): """ Use address on hardcoded interface. Options: if=interface """ _name = 'hardcoded-if' _oneliner = 'Get address from an configuration option interface' def get_ip(self, log, options): """Implement AddressPlugin.get_ip().""" opts = dict_of_opts(options) if 'if' not in opts: raise AddressError('Required option if= missing, giving up.') if_ = opts['if'] address = IpAddr() output = subprocess.getoutput('ip address show dev ' + if_) address.parse_ifconfig_output(output) return address ddupdate-0.7.2/plugins/addr_hardcoded_ip.py000066400000000000000000000015521434670644200207610ustar00rootroot00000000000000""" ddupdate plugin providing an ip address to use a from an interface option. See: ddupdate(8) """ from ddupdate.ddplugin import AddressPlugin, AddressError, IpAddr, dict_of_opts class HardcodedIfPlugin(AddressPlugin): """ Use address given in configuration options. Options: ip = ipv4 address ip6 = ipv6 address """ _name = 'hardcoded-ip' _oneliner = 'Get address from configuration options' def get_ip(self, log, options): """Implement AddressPlugin.get_ip().""" addr = IpAddr() opts = dict_of_opts(options) if 'ip' not in opts and 'ip6' not in opts: raise AddressError( 'Required option ip= or ip6= missing, giving up.') if 'ip' in opts: addr.v4 = opts['ip'] if 'ip6' in opts: addr.v6 = opts['ip6'] return addr ddupdate-0.7.2/plugins/addr_ip_disabled.py000066400000000000000000000010361434670644200206100ustar00rootroot00000000000000""" ddupdate plugin providing a null ip address. See: ddupdate(8) """ from ddupdate.ddplugin import AddressPlugin, IpAddr class IpDisabledPlugin(AddressPlugin): """ ddupdate plugin providing a null ip address. To be used when the update service determines the address Options: None netrc: None """ _name = 'ip-disabled' _oneliner = 'Force update service to provide ip address' def get_ip(self, log, options): """Implement AddressPlugin.get_ip().""" return IpAddr() ddupdate-0.7.2/plugins/addr_ip_from_cmd.py000066400000000000000000000032331434670644200206300ustar00rootroot00000000000000""" ddupdate plugin supporting getting ip address from a command. See: ddupdate(8) """ import re import subprocess from ddupdate.ddplugin import \ AddressPlugin, AddressError, IpAddr, dict_of_opts class IpFromCmdPlugin(AddressPlugin): """ Use ip4 address obtained from a command. The command is invoked without parameters, and should return one or two space-separated words on stdout. Each word must either a valid ipv4 or ipv6 address. Anything which is not parsed as addresses is treated as an error message. Note that when invoked in a systemd context, the environment for the command is more or less empty. The command invoked is specified in the cmd option Options: cmd=command netrc: Nothing """ _name = 'ip-from-command' _oneliner = 'Obtain address from a command' def get_ip(self, log, options): """Implement AddressPlugin.get_ip().""" opts = dict_of_opts(options) if 'cmd' not in opts: raise AddressError('Required option cmd= missing, giving up.') cmd = opts['cmd'] log.debug('Running: %s', cmd) addr = IpAddr() result = subprocess.getoutput(cmd).strip() log.debug('result: %s', result) pat = re.compile(r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}') pat6 = re.compile(r'[:0-9a-f]{12,}(\s|\Z)') for word in result.split(): if pat.fullmatch(word): addr.v4 = word elif pat6.fullmatch(word): addr.v6 = word else: raise AddressError( 'Cannot parse command output: ' + result) return addr ddupdate-0.7.2/plugins/addr_onhub.py000066400000000000000000000026731434670644200174740ustar00rootroot00000000000000""" ddupdate plugin to retrieve address from an OnHub / Google / Nest router. See: ddupdate(8) """ import json import urllib.request from ddupdate.ddplugin import AddressPlugin, AddressError, IpAddr # onhub.here should resolve correctly if you have this type of router _URL = "http://onhub.here/api/v1/status" class OnHubPlugin(AddressPlugin): """ Get the external address via the OnHub status API. Options used: none """ _name = "onhub" _oneliner = "Obtain external address from OnHub / Google / Nest router" def get_ip(self, log, options): """Implement AddressPlugin.get_ip().""" # Documentation refers to testing on 3.4 # f-strings are from 3.6 and exception chaining from 3.9 # pylint: disable=raise-missing-from log.debug("trying " + _URL) try: with urllib.request.urlopen(_URL) as response: if response.getcode() != 200: raise AddressError( "Bad response %s from %s" % (response.getcode(), _URL) ) status = json.loads(response.read().decode("utf-8")) except (urllib.error.HTTPError, urllib.error.URLError) as err: raise AddressError("Error reading %s :%s" % (_URL, err)) log.debug("Got response: %s", json.dumps(status)) log.debug("WAN online: %s", status["wan"]["online"]) return IpAddr(status["wan"]["localIpAddress"]) ddupdate-0.7.2/plugins/auth_keyring.py000066400000000000000000000047721434670644200200620ustar00rootroot00000000000000"""Implement credentials lookup using python3-keyring. The keyring just provides a basic username -> password lookup. However, the get_auth() call should possibly return both username and password for a given machine. To that end, the value stored for each hostname is 'usernamepassword' For hosts using just an api key i. e., without a username the username field is set to 'api-key' """ KEYRING_MISSING_MSG = """ python keyring module not found. Please install python3-keyring using package manager or the keyring package using pip. """ # pylint: disable=wrong-import-position from ddupdate.ddplugin import AuthPlugin, AuthError try: import keyring import keyring.errors except (ModuleNotFoundError, ImportError): import sys print(KEYRING_MISSING_MSG) sys.exit(1) class AuthKeyring(AuthPlugin): """Implement credentials lookup using python3-keyring. This is a reasonably secure way to handle the passwords. Before actually accessing the passwords the keyring must be unlocked. This makes this backend less suited to servers but is no problem on for example a notebook. Prior to 0.7.2 all passwords was stored in the .netrc file. See the ddupdate-netrc-to-keyring tool for migrating passwords from .netrc to the keyring backend. """ _name = 'keyring' _oneliner = 'Store credentials in the system keyring' __version__ = '0.7.2' def get_auth(self, machine): """Implement AuthPlugin::get_auth().""" try: credentials = keyring.get_password('ddupdate', machine.lower()) if not credentials: raise AuthError("Cannot get authentication for: " + machine) credentials = credentials.split('\t') except keyring.errors.KeyringError as err: raise AuthError("Cannot obtain credentials for: " + machine) \ from err if len(credentials) != 2: raise AuthError("Cannot parse credentials for: " + machine) if credentials[0] == 'api-key': credentials[0] = None return credentials[0], credentials[1] def set_password(self, machine, username, password): """Implement AuthPlugin::set_password().""" if not username: username = 'api-key' credentials = username + '\t' + password try: keyring.set_password('ddupdate', machine.lower(), credentials) except keyring.errors.KeyringError as err: raise AuthError("Cannot set credentials for: " + machine) from err ddupdate-0.7.2/plugins/auth_netrc.py000066400000000000000000000050671434670644200175230ustar00rootroot00000000000000""" Implement credentials lookup using the ~/.netrc(5) file. """ import base64 import binascii from netrc import netrc import os.path from ddupdate.ddplugin import AuthPlugin, AuthError class AuthNetrc(AuthPlugin): """Get credentials stored in the .netrc(5) file. This is the original storage used before 0.7.2. It is less secure than for example the keyring but is convenient and, since it does not require anything to be unlocked, a good candidate for servers. """ _name = 'netrc' _oneliner = 'Store credentials in .netrc(5)' __version__ = '0.7.2' def get_auth(self, machine): """Implement AuthPlugin::get_auth().""" path = os.environ.get('NETRC', '') if path: pass elif os.path.exists(os.path.expanduser('~/.netrc')): path = os.path.expanduser('~/.netrc') elif os.path.exists('/etc/netrc'): path = '/etc/netrc' else: raise AuthError("Cannot locate the netrc file (see manpage).") auth = netrc(path).authenticators(machine) if not auth: raise AuthError("No .netrc data found for " + machine) if not auth[2]: raise AuthError("No password found for " + machine) try: pw = base64.b64decode(auth[2]).decode('ascii') except (binascii.Error, UnicodeDecodeError): pw = auth[2] return auth[0], pw def set_password(self, machine, username, password): """Implement AuthPlugin::set_password().""" def is_matching_entry(line): """Return True if line contains 'machine' machine'.""" words = line.split(' ') for i in range(0, len(words) - 1): if words[i] == 'machine' \ and words[i + 1].lower() == machine.lower(): return True return False def new_entry(): """Return new entry.""" pw = base64.b64encode(password.encode('utf-8')).decode('ascii') line = 'machine ' + machine.lower() if username: line += ' login ' + username line += ' password ' + pw return line path = os.path.expanduser('~/.netrc') lines = [] if os.path.exists(path): with open(path, 'r') as f: lines = f.readlines() lines = [line for line in lines if not is_matching_entry(line)] lines.append(new_entry()) lines = [line.strip() + "\n" for line in lines] with open(path, 'w') as f: f.writelines(lines) ddupdate-0.7.2/plugins/changeip.py000066400000000000000000000023621434670644200171400ustar00rootroot00000000000000""" ddupdate plugin updating data on changeip.com. See: ddupdate(8) See: http://www.changeip.com/accounts/knowledgebase.php?action=displayarticle&id=34 """ from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import http_basic_auth_setup, get_response class ChangeAddressPlugin(ServicePlugin): """ Update a dns entry on changeip.com. Supports using most address plugins including default-web-ip, default-if and ip-disabled. ipv6 addresses are not supported. Free accounts has limitations both to number of hosts and that unused host are expired. See the website for more. netrc: Use a line like machine nic.ChangeIP.com login password Options: none """ _name = 'changeip.com' _oneliner = 'Updates on http://changeip.com/' _url = "https://nic.ChangeIP.com/nic/update?&hostname={0}" def register(self, log, hostname, ip, options): """Implement ServicePlugin.register.""" url = self._url.format(hostname) if ip: url += "&ip=" + ip.v4 http_basic_auth_setup(url) html = get_response(log, url) if 'uccessful' not in html: raise ServiceError("Bad update reply: " + html) ddupdate-0.7.2/plugins/cloudflare.py000066400000000000000000000173061434670644200175060ustar00rootroot00000000000000""" ddupdate plugin updating data on cloudflare.com. See: ddupdate(8) See: https://api.cloudflare.com """ REQUESTS_NOT_FOUND = """ The Cloudflare plugin uses the python3-requests package which cannot be found. Please install python-requests or python3-requests. Giving up. """ # pylint: disable=wrong-import-position from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import get_netrc_auth, dict_of_opts try: from requests import Request, Session from requests.auth import AuthBase except (ImportError, ModuleNotFoundError): import sys print(REQUESTS_NOT_FOUND, file=sys.stderr) sys.exit(1) def _call(session, request): """Call Cloudflare V4 API.""" try: prepped = session.prepare_request(request) res = session.send(prepped) if res.status_code / 100 != 2: raise ServiceError("Error retrieving %s: status %d" % (request.url, res.status_code)) json = res.json() if not json['success']: raise ServiceError("Error retrieving %s: errors %s" % (request.url, json['errors'])) return json['result'] except ValueError as err: raise ServiceError("Error parsing response %s: %s" % (request.url, err)) from None def _get_ipv4_from_dnsrecords(dnsrecords): """ Find the A record in dns records, and return tuple (id, address). """ for rec in dnsrecords: if 'type' in rec: if rec['type'] == 'A': return (rec['id'], rec['content']) return (None, None) def _get_ipv6_from_dnsrecords(dnsrecords): """ Find the AAAA record in dns records, and return tuple (id, address). """ for rec in dnsrecords: if 'type' in rec: if rec['type'] == 'AAAA': return (rec['id'], rec['content']) return (None, None) class CloudflareAuth(AuthBase): """ Cloudflare Custom Authentication. Attaches a Cloudflare X-Auth-Email/Key authentication scheme to the given Request object. """ # pylint: disable=too-few-public-methods def __init__(self, email, auth_key): """Email and auth_key are required.""" self.email = email self.auth_key = auth_key def __call__(self, r): """Implement AuthBase.""" r.headers['X-Auth-Email'] = self.email r.headers['X-Auth-Key'] = self.auth_key return r class CloudflarePlugin(ServicePlugin): """ Update a dns entry on cloudflare.com. Supports address plugins that define the IP, including default-web-ip and default-if. The ip-disabled plugin is not supported. ipv6 is supported Access to the service requires an API token and login email. This is available in the web interface. Also required is the name of the zone. netrc: Use a line like machine api.cloudflare.com login password Options: zone = Cloudflare Zone name (mandatory) """ _name = 'cloudflare.com' _oneliner = 'Updates on https://cloudflare.com' _url = "https://api.cloudflare.com/client/v4" _auth = None def _get_zoneid(self, session, opts): """Retrieve an identifier for a given zone name.""" zone = opts['zone'] params = { 'name': zone, 'per_page': 1 } request = Request( 'GET', self._url + "/zones", params=params, auth=self._auth) res = _call(session, request) if res and len(res) == 1 and 'id' in res[0] and res[0]['id']: return res[0]['id'] raise ServiceError("Zone %s not found" % zone) def _get_dnsrecords(self, session, hostname, opts): """Retrieve all dns records for a given hostname.""" zone_id = opts['zone_id'] params = { 'name': hostname, 'match': 'all', } request = Request( 'GET', self._url + "/zones/{0}/dns_records".format(zone_id), params=params, auth=self._auth) return _call(session, request) def _create_dnsrecord(self, session, record, opts): """Create a new dns record.""" zone_id = opts['zone_id'] request = Request( 'POST', self._url + "/zones/{0}/dns_records".format(zone_id), json=record, auth=self._auth) res = _call(session, request) return (res['id'], res['content']) def _update_dnsrecord(self, session, record_id, record, opts): """Update existing dns record.""" zone_id = opts['zone_id'] request = Request( 'PUT', self._url + "/zones/{0}/dns_records/{1}".format(zone_id, record_id), json=record, auth=self._auth) res = _call(session, request) return (res['id'], res['content']) def _init_auth(self): """Initialize Custom Authentication for Cloudflare v4 API.""" user, password = get_netrc_auth('api.cloudflare.com') self._auth = CloudflareAuth(user, password) def register(self, log, hostname, ip, options): """Implement ServicePlugin.register().""" if not ip: raise ServiceError("IP must be defined.") self._init_auth() opts = dict_of_opts(options) if 'zone' not in opts: raise ServiceError('Required option zone= missing, giving up.') session = Session() opts['zone_id'] = self._get_zoneid(session, opts) dnsrecords = self._get_dnsrecords(session, hostname, opts) ipv4_id, ipv4 = _get_ipv4_from_dnsrecords(dnsrecords) ipv6_id, ipv6 = _get_ipv6_from_dnsrecords(dnsrecords) log.debug("host=%s existing_ipv4=%s existing_ipv6=%s", hostname, ipv4, ipv6) if ip.v4: if ipv4 != ip.v4: record = { 'type': 'A', 'name': hostname, 'content': ip.v4 } if ipv4_id: log.debug( "method=update_A host=%s existing=%s expected=%s", hostname, ipv4, ip.v4) ipv4_id, ipv4 = \ self._update_dnsrecord(session, ipv4_id, record, opts) else: log.debug( "method=create_A host=%s existing=%s expected=%s", hostname, ipv4, ip.v4) ipv4_id, ipv4 = \ self._create_dnsrecord(session, record, opts) log.debug("ipv4_id=%s updated_ipv4=%s", ipv4_id, ipv4) else: log.info("Existing ipv4 record matches, skipping update") if ip.v6: if ipv6 != ip.v6: record = { 'type': 'AAAA', 'name': hostname, 'content': ip.v6 } if ipv6_id: log.debug( "method=update_AAAA host=%s existing=%s expected=%s", hostname, ipv6, ip.v6) ipv6_id, ipv6 = \ self._update_dnsrecord(session, ipv6_id, record, opts) else: log.debug( "method=create_AAAA host=%s existing=%s expected=%s", hostname, ipv6, ip.v6) ipv6_id, ipv6 = \ self._create_dnsrecord(session, record, opts) log.debug("ipv6_id=%s updated_ipv6=%s", ipv6_id, ipv6) else: log.info("Existing ipv6 record matches, skipping update") ddupdate-0.7.2/plugins/desec_io.py000066400000000000000000000025301434670644200171310ustar00rootroot00000000000000""" ddupdate plugin updating data on desec.io. See: ddupdate(8) See: https://desec.readthedocs.io/en/latest/dyndns/update-api.html """ from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import get_response, get_netrc_auth class DesecPlugin(ServicePlugin): """ Update a dns entry on https://desec.io. Supports most address plugins including default-web-ip, default-if and ip-disabled. ipv6 is supported. The site also supports several ways to authenticate. This plugin only supports using the API token which is created during registration. netrc: Use a line like machine update.dedyn.io password Options: none """ _name = 'desec.io' _oneliner = 'Updates on http://desec.io/' _url = "https://update.dedyn.io/?hostname={0}" def register(self, log, hostname, ip, options): """Implement ServicePlugin.register().""" url = self._url.format(hostname) if ip.v4: url += "&myipv4=" + ip.v4 if ip.v6: url += "&myipv6=" + ip.v6 password = get_netrc_auth(hostname)[1] hdr = ('Authorization', 'Token ' + password) reply = get_response(log, url, header=hdr) if not ('good' in reply or 'throttled' in reply): raise ServiceError("Cannot update address: " + reply) ddupdate-0.7.2/plugins/dns_o_matic.py000066400000000000000000000031441434670644200176400ustar00rootroot00000000000000""" ddupdate plugin updating data on https://www.dnsomatic.com/. See: ddupdate(8) See: https://now-dns.com/?p=clients """ import base64 from urllib.parse import urlparse from ddupdate.ddplugin import ServicePlugin, ServiceError, \ get_response, get_netrc_auth class DnsOMaticPlugin(ServicePlugin): """ Update a dns entry on https://www.dnsomatic.com. The hostname is the configured hostname in dns-o-matic. Common usecase is to set hostname=all.dnsomatic.com which will update all hosts. Only supports setting the address corresponding to default-web-ip or ip-disabled, service does not allow setting address to anything else. ipv6 is not supported. netrc: Use a line like machine updates.dnsomatic.com login password Options: None """ _name = 'dns-o-matic.com' _oneliner = 'Updates on http://dnsomatic.com' _url = 'https://updates.dnsomatic.com/nic/update?hostname={0}' def register(self, log, hostname, ip, options): """Implement ServicePlugin.register().""" url = self._url.format(hostname) if ip: url += '&myip=' + ip.v4 api_host = urlparse(url).hostname username, password = get_netrc_auth(api_host) user_pw = ('%s:%s' % (username, password)) credentials = base64.b64encode(user_pw.encode('ascii')) auth_header = ('Authorization', 'Basic ' + credentials.decode("ascii")) reply = get_response(log, url, header=auth_header) if not ('good' in reply or 'nochg' in reply): raise ServiceError('Bad server reply: ' + reply) ddupdate-0.7.2/plugins/dnsexit.py000066400000000000000000000040421434670644200170350ustar00rootroot00000000000000""" ddupdate plugin updating data on dnsexit.com. See: ddupdate(8) See: http://downloads.dnsexit.com/ipUpdateDev.doc """ from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import get_response, get_netrc_auth class DnsexitPlugin(ServicePlugin): """ Updates DNS data for host on dnsexit.com. The documentation is not clear whether dnsexit can update data without given an ip address. The safe route is to avoid the ip-disabled plugin for this service. The password used is transferred in the url. Contrary to the docs, dnsexit actually seems to support https. This plugin uses this, so it should be reasonably safe. The service recommends setting up a separate password to be used when updating. According to the docs the correct way is to interrogate dnsexit.com for update urls. However, we don't, we just assume the url is fixed. netrc: Use a line like machine update.dnsexit.com login password Options: None """ _name = 'dnsexit.com' _oneliner = 'Updates on https://www.dnsexit.com' _api_host = 'https://update.dnsexit.com' _url = '{0}/RemoteUpdate.sv?login={1}&password={2}&host={3}' _ip_warning = \ "service is not known to provide an address, use another ip plugin" def register(self, log, hostname, ip, options): """Implement AddressPlugin.get_ip().""" if not ip: log.warn(self._ip_warning) user, password = get_netrc_auth('update.dnsexit.com') url = self._url.format(self._api_host, user, password, hostname) if ip: url += "&myip=" + ip.v4 # if debugging: # url += "&force=Y" # override 8 minutes server limit html = get_response(log, url).split('\n') if '200' not in html[0]: raise ServiceError("Bad HTML response: " + html) code = html[1].split('=')[0] if int(code) > 1: raise ServiceError("Bad update response: " + html[1]) log.info("Response: " + html[1]) ddupdate-0.7.2/plugins/dnshome_de_srvc.py000066400000000000000000000043341434670644200205250ustar00rootroot00000000000000""" ddupdate plugin updating data on dnshome.de. See: ddupdate(8) See: https://www.dnshome.de/ """ from typing import AnyStr from logging import Logger from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import http_basic_auth_setup, get_response, IpAddr class DeDnsHomeAddressPlugin(ServicePlugin): """Update a dns entry on dnshome.de. Supports using most address plugins including default-web-ip, default-if and ip-disabled. You cannot set the host explicitly using a parameter like `hostname`. Even though the hostname is included in the query, it simply gets ignored. Set the host you want to update as username (like: subdomain.dnshome.de). Respects _global_ global `--ip-version` option. Be sure to configure ddupdate according to your connection type. netrc: Use a line like machine www.dnshome.de login password Options: none """ _name = 'dnshome.de' _oneliner = 'Updates on https://www.dnshome.de/' _url = "https://www.dnshome.de/dyndns.php?&hostname={0}" @staticmethod def is_success(response: AnyStr) -> bool: """Checks if the action was successful using the response. Args: response: The response-body to analyze. Returns: true, if the response-body starts with 'good' - Update was successful 'nochg' - No change was performed, since records were already up to date. """ return response.startswith('good') or response.startswith('nochg') def register(self, log: Logger, hostname: str, ip: IpAddr, options): """Implement ServicePlugin.register. Expects the `ip` to be filtered already according to the _global_ `--ip-version` option. """ url = self._url.format(hostname) if ip: if ip.v4: url += '&ip=' + ip.v4 if ip.v6: url += '&ip6=' + ip.v6 http_basic_auth_setup(url) body = get_response(log, url) # Get ASCII encoded body-content if not DeDnsHomeAddressPlugin.is_success(body): raise ServiceError("Bad update reply.\nMessage: " + body) ddupdate-0.7.2/plugins/dnspark.py000066400000000000000000000026261434670644200170270ustar00rootroot00000000000000""" ddupdate plugin updating data on dnspark.com. See: ddupdate(8) See: https://dnspark.zendesk.com/hc/en-us/articles/ 216322723-Dynamic-DNS-API-Documentation """ from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import http_basic_auth_setup, get_response class DnsparkPlugin(ServicePlugin): """ Update a dns entry on dnspark.com. Supports most address plugins including default-web-ip, default-if and ip-disabled. ipv6 is not supported. You need to own a domain and delegate it to dnspark to use the service, nothing like myhost.dnspark.net is supported. Note that the dynamic dns user and password is a separate set of credentials created in the web interface. netrc: Use a line like machine control.dnspark.com login password Options: none """ _name = 'dnspark.com' _oneliner = 'Updates on https://dnspark.com/' _url = "https://control.dnspark.com/api/dynamic/update.php?hostname={0}" def register(self, log, hostname, ip, options): """Implement ServicePlugin.register().""" url = self._url.format(hostname) if ip and ip.v4: url += "&ip=" + ip.v4 http_basic_auth_setup(url) reply = get_response(log, url).strip() if reply not in ['ok', 'nochange']: raise ServiceError("Unexpected update reply: " + reply) ddupdate-0.7.2/plugins/dry_run.py000066400000000000000000000012711434670644200170420ustar00rootroot00000000000000""" ddupdate dummy plugin making absolutely no update. See: ddupdate(8) """ from ddupdate.ddplugin import ServicePlugin class DryRunPlugin(ServicePlugin): """ Prints the ip address obtained and configured hostname to update. Does not invoke any action. Primarely a debug tool. Options used: none """ _name = 'dry-run' _oneliner = 'Debug dummy update plugin' _ip_cache_ttl = 1 # we do not cache these runs, right? def register(self, log, hostname, ip, options): """Run the actual module work.""" print("dry-run: Using") print(" v4 address: %s\n v6 address: %s\n hostname: %s" % (ip.v4, ip.v6, hostname)) ddupdate-0.7.2/plugins/duckdns.py000066400000000000000000000025251434670644200170160ustar00rootroot00000000000000""" ddupdate plugin updating data on duckdns.org. See: ddupdate(8) See: https://www.duckdns.org/spec.jsp """ from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import get_response, get_netrc_auth class DuckdnsPlugin(ServicePlugin): """ Update a dns entry on duckdns.org. As usual, any host updated must first be defined in the web UI. Supports most address plugins including default-web-ip, default-if and ip-disabled. ipv6 is supported Access to the service requires an API token. This is available in the website account. netrc: Use a line like machine www.duckdns.org password Options: None """ _name = 'duckdns.org' _oneliner = 'Updates on http://duckdns.org' _url = "https://www.duckdns.org/update?domains={0}&token={1}" def register(self, log, hostname, ip, options): """Implement ServicePlugin.register().""" password = get_netrc_auth('www.duckdns.org')[1] host = hostname.split('.duckdns.org')[0] url = self._url.format(host, password) if ip and ip.v4: url += "&ip=" + ip.v4 if ip and ip.v6: url += "&ipv6=" + ip.v6 html = get_response(log, url) if html.strip() != "OK": raise ServiceError("Update error, got: " + html) ddupdate-0.7.2/plugins/duiadns.py000066400000000000000000000064531434670644200170160ustar00rootroot00000000000000""" ddupdate plugin updating data on duiadns.com. See: ddupdate(8) See: https://www.duiadns.net/duiadns-url-update """ REQUESTS_NOT_FOUND = """ The duiadns plugin uses the python3-requests package which cannot be found. Please install python-requests or python3-requests. Giving up. """ # pylint: disable=wrong-import-position from html.parser import HTMLParser from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import get_response, get_netrc_auth try: import requests except (ImportError, ModuleNotFoundError): import sys print(REQUESTS_NOT_FOUND, file=sys.stderr) sys.exit(1) def error(message): """Just a shorthand.""" raise ServiceError("HTML parser error: " + message) class DuiadnsParser(HTMLParser): """Dig out ip address and hostname in server HTML reply.""" def __init__(self): """Default constructor.""" HTMLParser.__init__(self) self.data = {} def handle_data(self, data): """Implement HTMLParser.handle_data().""" if data.strip() == '': return words = data.split() if len(words) == 2: self.data[words[0]] = words[1] else: self.data['error'] = data class DuiadnsPlugin(ServicePlugin): """ Update a dns entry on duiadns.com. At the time of writing the server has an expired certificate. Code here works around this while rightfully issuing warnings. As usual, any host updated must first be defined in the web UI. Although the server supports auto-detection of addresses this plugin does not; the ip-disabled plugin can not be used. ipv6 is supported Access to the service requires an API token. This is available in the website account. The documentation is partially broken, but the code here seems to work. In particular, using the recommended @IP provokes a 500 error. Overall, the server seems fragile and small errors provokes 500 replies rather than expected error messages. Also, it returns a needlessly complicated HTML-formatted reply. netrc: Use a line like machine ip.duiadns.net password Options: None """ _name = 'duiadns.net' _oneliner = 'Updates on https://www.duiadns.net' _url = 'https://ip.duiadns.net/dynamic.duia?host={0}&password={1}' def register(self, log, hostname, ip, options): """Implement ServicePlugin.register().""" password = get_netrc_auth('ip.duiadns.net')[1] url = self._url.format(hostname, password) if not ip: log.warn("This plugin requires an ip address.") if ip and ip.v4: url += "&ip4=" + ip.v4 if ip and ip.v6: url += "&ip6=" + ip.v6 try: html = get_response(log, url) except ServiceError: resp = requests.get(url, verify=False) if resp.status_code != 200: raise ServiceError("Cannot access update url: " + url) \ from None html = resp.content.decode('ascii') parser = DuiadnsParser() parser.feed(html) if 'error' in parser.data or 'Ipv4' not in parser.data: raise ServiceError('Cannot parse server reply (see debug log)') log.info('New ip address: ' + parser.data['Ipv4']) ddupdate-0.7.2/plugins/dyfi.py000066400000000000000000000020461434670644200163140ustar00rootroot00000000000000""" ddupdate plugin updating data on dy.fi. See: ddupdate(8) See: https://www.dy.fi/page/specification """ from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import http_basic_auth_setup, get_response class DyFiPlugin(ServicePlugin): """ Update a dns entry on dy.fi. Does not support setting arbitrary ip address. The ip-disabled plugin should be used, and the address set is as seen from dy.fi. netrc: Use a line like machine www.dy.fi login password Options: none """ _name = 'dy.fi' _oneliner = 'Updates on https://www.dy.fi/' _url = 'https://www.dy.fi/nic/update?hostname={0}' _ip_cache_ttl = 7200 # 5 days def register(self, log, hostname, ip, options): """Implement ServicePlugin.register.""" url = self._url.format(hostname) http_basic_auth_setup(url) html = get_response(log, url) if html.split()[0] not in ['nochg', 'good']: raise ServiceError("Bad update reply: " + html) ddupdate-0.7.2/plugins/dynu.py000066400000000000000000000030211434670644200163320ustar00rootroot00000000000000""" ddupdate plugin updating data on dynu.com. See: ddupdate(8) See: https://www.dynu.com/Resources/API/Documentation """ import base64 from urllib.parse import urlparse from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import get_response, get_netrc_auth class DynuPlugin(ServicePlugin): """ Update a dns entry on dynu.com. Supports most address plugins including default-wep-ip, default-if and ip-disabled. ipv6 is supported. .netrc: Use a line like: machine api.dynu.com login password Options: none See: https://www.dynu.com/en-US/DynamicDNS/IP-Update-Protocol """ _name = 'dynu.com' _oneliner = 'Updates on https://www.dynu.com/en-US/DynamicDNS' _url = "https://api.dynu.com/nic/update?host={0}" def register(self, log, hostname, ip, options): """Implement ServicePlugin.register().""" url = self._url.format(hostname) api_host = urlparse(url).hostname username, password = get_netrc_auth(api_host) user_pw = ('%s:%s' % (username, password)) credentials = base64.b64encode(user_pw.encode('ascii')) auth_header = ('Authorization', 'Basic ' + credentials.decode("ascii")) if ip and ip.v4: url += "&myip=" + ip.v4 if ip and ip.v6: url += "&myipv6=" + ip.v6 reply = get_response(log, url, header=auth_header) if not ('good' in reply or 'nochg' in reply): raise ServiceError("Update error: " + reply) ddupdate-0.7.2/plugins/dynv6_com.py000066400000000000000000000025341434670644200172670ustar00rootroot00000000000000""" ddupdate plugin updating data on dynv6. See: ddupdate(8) See: https://dynv6.com/docs/apis """ import urllib.parse from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import get_response, get_netrc_auth class Dynv6Plugin(ServicePlugin): """ Update a dns entry on dynv6.com. As usual, any host updated must first be defined in the web UI. Supports most address plugins including default-web-ip, default-if and ip-disabled. dynv6 also supports ipv6 addresses. Access to the service requires an API token. This is available in the website account. netrc: Use a line like machine dynv6.com password Options: None """ _name = 'dynv6.com' _oneliner = 'Updates on http://dynv6.com' _url = "https://dynv6.com/api/update?" def register(self, log, hostname, ip, options): """Implement ServicePlugin.register().""" password = get_netrc_auth('dynv6.com')[1] query = {"hostname": hostname, "token": password} query['ipv4'] = ip.v4 if ip and ip.v4 else "auto" query['ipv6'] = ip.v6 if ip and ip.v6 else "auto" html = get_response(log, self._url + urllib.parse.urlencode(query)) if not ('updated' in html or 'unchanged' in html): raise ServiceError("Update error, got: " + html) ddupdate-0.7.2/plugins/freedns.py000066400000000000000000000025501434670644200170070ustar00rootroot00000000000000""" ddupdate plugin updating data on freedns.afraid.org. See: ddupdate(8) See: https://linuxaria.com/howto/dynamic-dns-with-bash-afraid-org """ from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import get_response, get_netrc_auth class FreednsPlugin(ServicePlugin): """ Updates DNS data for host on freedns.afraid.org. Freedns allows settings the IP address using an address plugin or just using the address as seen from the internet using the ip-disabled plugin. Ipv6 is supported. netrc: Use a line like machine freedns.afraid.org login password Options: None """ _name = 'freedns.afraid.org' _oneliner = 'Updates on https://freedns.afraid.org' _url = 'https://sync.afraid.org/u/?u={0}&p={1}&h={2}' def register(self, log, hostname, ip, options): """ Based on http://freedns.afraid.org/api/, needs _url below to update. """ user, password = get_netrc_auth('freedns.afraid.org') url = self._url.format(user, password, hostname) if ip and ip.v6: url += "&ip=" + str(ip.v6) elif ip and ip.v4: url += "&ip=" + str(ip.v4) html = get_response(log, url) if not ('Updated' in html or 'skipping' in html): raise ServiceError("Error updating %s" % hostname) ddupdate-0.7.2/plugins/freedns_io.py000066400000000000000000000025251434670644200175000ustar00rootroot00000000000000""" ddupdate plugin updating data on freedns.io. See: ddupdate(8) See: https://freedns.io/api FIXME: Add ipv6 support """ from ddupdate.ddplugin import ServicePlugin, get_netrc_auth, get_response class FreednsIoPlugin(ServicePlugin): """ Updates DNS data for host on freedns.io. As usual, any host updated must first be defined in the web UI. Supports most address plugins including default-web-ip, default-if and ip-disabled. The service offers more functionality not exposed here: ipv6 addresses, TXT and MX records, etc. See https://freedns.io/api It should be straight-forward to add options supporting this. netrc: Use a line like machine freedns.io login password Options: None """ _name = 'freedns.io' _oneliner = 'Updates on https://freedns.io' _url = 'https://freedns.io/request' def register(self, log, hostname, ip, options): """Implement ServicePlugin.register.""" user, password = get_netrc_auth('freedns.io') data = { 'username': user, 'password': password, 'host': hostname.split('.freedns.io')[0], 'record': 'A' } if ip: data['value'] = ip.v4 html = get_response(log, self._url, data=data) log.info("Server reply: " + html) ddupdate-0.7.2/plugins/freedns_v2.py000066400000000000000000000047411434670644200174220ustar00rootroot00000000000000""" ddupdate plugin updating data on freedns.afraid.org api v2 random token. See: ddupdate(8) See: https://freedns.afraid.org See: https://freedns.afraid.org/dynamic/v2/ (needs login) """ from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import get_response, get_netrc_auth class FreednsV2Plugin(ServicePlugin): """ Updates DNS data for host on freedns.afraid.org with API v2 token. Freedns allows settings the IP address using an address plugin or just using the address as seen from the internet using the ip-disabled plugin. Ipv6 is supported. V2 api uses a random token which is simpler and more secure, your credentials or domains are never exposed. Login to freedns.afraid.org, switch to v2 API and add your domain to be updated with v2, select 'randomized token' update style and take note of the update url for your domain, it should be like: https://sync.afraid.org/u/{API-v2-token}/ Copy the token part of the url and use it as password in .netrc file. Since ddupdate-config currently doesn't support services with multiple hostnames, you must edit .netrc file by hand in the meanwhile. Netrc: use lines with this *non-standard* format (no braces): machine {your.hostname}@sync.afraid.org password {API-v2-token} Options: None """ # descriptive name, i see no need to set as url because _onliner already tells _name = 'sync.afraid.org' _oneliner = 'Updates on https://sync.afraid.org/u/{API-v2-token}/' _url = 'https://{0}sync.afraid.org/u/{1}/' def register(self, log, hostname, ip, options): """ Based on https://freedns.afraid.org/dynamic/v2/, needs _url below to update. The {1} parameter is a randomized update token, generated specificly for each domain(s) when enabled for api v2. The server automatically detects public source IP, but optionally you can provide it. """ password = get_netrc_auth(hostname + '@sync.afraid.org')[1] url = '' if ip and ip.v6: url = self._url.format('v6.', password) + '?address=' + str(ip.v6) else: url = self._url.format('' , password) if ip and ip.v4: url += '?address=' + str(ip.v4) log.debug("Contacting freedns for update v2 on %s", url) html = get_response(log, url) if html.startswith("Couldn't "): raise ServiceError("Update error, got: " + html) ddupdate-0.7.2/plugins/googledomains.py000066400000000000000000000067071434670644200202200ustar00rootroot00000000000000""" ddupdate plugin updating data on domains.google.com. See: ddupdate(8) See: https://support.google.com/domains/answer/6147083?hl=en """ import urllib.parse import urllib.request from ddupdate.ddplugin import ServiceError, ServicePlugin from ddupdate.ddplugin import AuthError, get_response, get_netrc_auth # See https://github.com/leamas/ddupdate/pull/56 # and https://github.com/leamas/ddupdate/issues/52 # for why these functions are specialized here. # pylint: disable=duplicate-code # broken for now: https://github.com/PyCQA/pylint/issues/214 def http_basic_auth_setup(url, *, providerhost=None, targethost=None): """ Configure urllib to provide basic authentication. See get_auth for how providerhost and targethost are resolved to credentials stored in netrc. Parameters: - url: string, the url to connect to. - providerhost: string, a hostname representing the provider. Defaults to the hostname part of url. - targethost: string, the host being updated. Optional, used to discriminate hosts registered with different credentials at the same provider. """ if not providerhost: providerhost = urllib.parse.urlparse(url).hostname user, password = get_auth(providerhost, targethost) pwmgr = urllib.request.HTTPPasswordMgrWithDefaultRealm() pwmgr.add_password(None, url, user, password) auth_handler = urllib.request.HTTPBasicAuthHandler(pwmgr) opener = urllib.request.build_opener(auth_handler) urllib.request.install_opener(opener) def get_auth(providerhost, targethost=None): """ Retrieve credentials from configured source. If a targethost is passed, the first machine name we look for is targethost.providerhost.ddupdate, falling back to providerhost. If no targethost is passed, we only look for providerhost. Parameters: - providerhost: identifies the dns provider - targethost: optional. Allows selecting credentials for multiple host names registered at the same provider. Returns: - A (user, password) tuple. User might be None. Raises: - AuthError password is not found. """ if targethost is not None: machine1 = "%s.%s.ddupdate" % (targethost, providerhost) try: credentials = get_netrc_auth(machine1) except AuthError: credentials = get_netrc_auth(providerhost) else: credentials = get_netrc_auth(providerhost) return credentials class GoogleDomainsPlugin(ServicePlugin): """ Update a DNS entry on domains.google.com. .netrc: Use a line like: machine domains.google.com login password Options: none """ _name = "domains.google.com" _oneliner = "Updates on https://domains.google.com" _url = "https://domains.google.com/nic/update" def register(self, log, hostname, ip, options): """Implement ServicePlugin.register().""" query = { 'hostname': hostname, } # IP address is optional for IPv4 if ip: query['myip'] = ip.v6 or ip.v4 url = "{}?{}".format(self._url, urllib.parse.urlencode(query)) http_basic_auth_setup(url, targethost=hostname) request = urllib.request.Request(url=url, method='POST') html = get_response(log, request) code = html.split()[0] if code not in ['good', 'nochg']: raise ServiceError("Bad server reply: " + html) ddupdate-0.7.2/plugins/hurricane_electric.py000066400000000000000000000025771434670644200212240ustar00rootroot00000000000000""" ddupdate plugin updating data on Hurricane Electric a k a he.com. See: ddupdate(8) See: https://dns.he.net/docs.html """ from ddupdate.ddplugin import ServicePlugin, get_netrc_auth, get_response class FreednsIoPlugin(ServicePlugin): """ Updates DNS data for host on Hurricane Electric's site he.com. As usual, any host updated must first be defined in the web UI. Supports most address plugins including default-web-ip, default-if and ip-disabled. Ipv6 is supported. Hurricane uses a separate password/key for each host, generated in the web interface. Updating both ipv4 and ipv6 is supported by hurricane, but this plugin uses ipv6 if such an address is available, othwerwise it falls back to using ipv4. netrc: Use a line like machine password Options: None """ _name = 'hurricane_electric' _oneliner = 'Updates on https://he.com' _url = 'https://dyn.dns.he.net/nic/update' def register(self, log, hostname, ip, options): """Implement ServicePlugin.register.""" password = get_netrc_auth(hostname)[1] data = {'password': password, 'hostname': hostname} if ip and ip.v6: data['myip'] = ip.v6 elif ip and ip.v4: data['myip'] = ip.v4 html = get_response(log, self._url, data=data) log.info("Server reply: " + html) ddupdate-0.7.2/plugins/myonlineportal_net.py000066400000000000000000000033051434670644200213020ustar00rootroot00000000000000""" ddupdate plugin updating data on myonlineportal.net. See: ddupdate(8) See: http://myonlineportal.net/ddns_api """ import base64 from urllib.parse import urlparse from ddupdate.ddplugin import ServicePlugin, ServiceError, \ get_response, get_netrc_auth class MyOnlinePortalPlugin(ServicePlugin): """ Updates DNS data for host on myonlineportal.net . As usual, any host updated must first be defined in the web UI. Supports most address plugins including default-web-ip, default-if and ip-disabled. ipv6 is supported. netrc: Use a line like machine myonlineportal.net login password See: https://myonlineportal.net/help#update_api Options: None """ _name = 'myonlineportal.net' _oneliner = 'Updates on http://myonlineportal.net/' _url = 'https://myonlineportal.net/updateddns?hostname={0}' def register(self, log, hostname, ip, options): """Implement ServicePlugin.register().""" url = self._url.format(hostname) api_host = urlparse(url).hostname username, password = get_netrc_auth(api_host) user_pw = ('%s:%s' % (username, password)) credentials = base64.b64encode(user_pw.encode('ascii')) auth_header = ('Authorization', 'Basic ' + credentials.decode("ascii")) url = self._url.format(hostname) if ip and ip.v4: url += "&ip=" + ip.v4 if ip and ip.v6: url += "&ip6=" + ip.v6 html = get_response(log, url, header=auth_header) key = html.split()[0] if key not in ['OK', 'good', 'nochg']: raise ServiceError("Bad server reply: " + html) log.info("Server reply: " + html) ddupdate-0.7.2/plugins/namecheap.py000066400000000000000000000046171434670644200173100ustar00rootroot00000000000000""" ddupdate plugin updating data on namecheap.com See: ddupdate(8) See: https://www.namecheap.com/support/knowledgebase/article.aspx/29/11/how-to-dynamically-update-the-hosts-ip-with-an-http-request/ """ from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import get_response, get_netrc_auth import xml.etree.ElementTree as ET class NamecheapPlugin(ServicePlugin): """ Update a dns entry on namecheap.com As usual, any host updated must first be defined in the web UI. Supports most address plugins including default-web-ip, default-if and ip-disabled. ipv6 is supported Access to the service requires an API token. This is available in the website account. netrc: Use a line like machine namecheap.com login {domainname} password {namecheap hostname passwd} Options: None """ _name = 'namecheap.com' _oneliner = 'Updates on http://namecheap.com' _url = "https://dynamicdns.park-your-domain.com/update?host={0}&domain={1}&ip={2}&password={3}" def _etree_to_dict(self, t): """ https://stackoverflow.com/questions/7684333/converting-xml-to-dictionary-using-elementtree/68082847#68082847 """ if type(t) is ET.ElementTree: return self._etree_to_dict(t.getroot()) return { **t.attrib, 'text': t.text, **{e.tag: self._etree_to_dict(e) for e in t} } def register(self, log, hostname, ip, options): """Implement ServicePlugin.register().""" password = get_netrc_auth(self._name)[1] # domain is last two elements of 'host' domain = '.'.join(hostname.split('.')[-2:]) # hostname is everything above the last two elements of 'host' host = '.'.join(hostname.split('.')[:-2]) # handle the special case of co.uk # it is the only second-level domain namecheap support if domain == "co.uk": domain = '.'.join(hostname.split('.')[-3:]) host = '.'.join(hostname.split('.')[:-3]) # ip address is either an ipv4 or ipv6 ip = ip.v4 if ip.v4 else ip.v6 url = self._url.format(host, domain, ip, password) html = get_response(log, url) tree = ET.ElementTree(ET.fromstring(html)) resp = self._etree_to_dict(tree) if resp['errors']['text'] is not None: raise ServiceError("Update error, got: " + resp['errors']['text']) ddupdate-0.7.2/plugins/no_ip.py000066400000000000000000000017001434670644200164610ustar00rootroot00000000000000""" ddupdate plugin updating data on no-ip.com. See: ddupdate(8) See: https://www.noip.com/integrate/request """ from ddupdate.ddplugin import ServicePlugin from ddupdate.ddplugin import http_basic_auth_setup, get_response class NoAddressPlugin(ServicePlugin): """ Update a dns entry on no-ip.com. Supports most address plugins including default-web-ip, default-if and ip-disabled. ipv6 is not supported. netrc: Use a line like machine dynupdate.no-ip.com login password Options: none """ _name = 'no-ip.com' _oneliner = 'Updates on http://no-ip.com/' _url = "http://dynupdate.no-ip.com/nic/update?hostname={0}" def register(self, log, hostname, ip, options): """Implement ServicePlugin.register().""" url = self._url.format(hostname) if ip: url += "&myip=" + ip.v4 http_basic_auth_setup(url) get_response(log, url) ddupdate-0.7.2/plugins/now_dns.py000066400000000000000000000031271434670644200170310ustar00rootroot00000000000000""" ddupdate plugin updating data on now-dns.com. See: ddupdate(8) See: https://now-dns.com/?p=clients """ import base64 from urllib.parse import urlparse from ddupdate.ddplugin import ServicePlugin, ServiceError, \ get_response, get_netrc_auth class NowDnsPlugin(ServicePlugin): """ Update a dns entry on now-dns.com. As usual, any host updated must first be defined in the web UI. Supports most address plugins including default-web-ip, default-if and ip-disabled. ipv6 address are supported by the site, but not by this plugin. now-dns uses an odd authentication scheme without challenge. Using wget, the --auth-no-challenge is required. This code copes with this mess. netrc: Use a line like machine now-dns.com login password Options: None """ _name = 'now-dns.com' _oneliner = 'Updates on http://now-dns.com' _url = 'https://now-dns.com/update?hostname={0}' def register(self, log, hostname, ip, options): """Implement ServicePlugin.register().""" url = self._url.format(hostname) if ip: url += '&myip=' + ip.v4 api_host = urlparse(url).hostname username, password = get_netrc_auth(api_host) user_pw = ('%s:%s' % (username, password)) credentials = base64.b64encode(user_pw.encode('ascii')) auth_header = ('Authorization', 'Basic ' + credentials.decode("ascii")) html = get_response(log, url, header=auth_header) if html not in ['good', 'nochg']: raise ServiceError('Bad server reply: ' + html) ddupdate-0.7.2/plugins/nsupdate.py000066400000000000000000000035541434670644200172110ustar00rootroot00000000000000""" ddupdate plugin using nsupdate. See: ddupdate(8) See: nsupdate(1) """ from subprocess import Popen, PIPE import sys from ddupdate.ddplugin import ServicePlugin, ServiceError, dict_of_opts class NsupdatePlugin(ServicePlugin): """ Update a dns entry with nsupdate(1). Options (see manpage): server key zone """ _name = 'nsupdate' _oneliner = 'Update address via nsupdate' def register(self, log, hostname, ip, options): """Implement ServicePlugin.register.""" opts = dict_of_opts(options) log.debug(opts) if 'server' not in opts: log.error("Required server option missing, giving up") sys.exit(2) args = ('nsupdate',) if 'key' in opts: args += ('-k', opts['key'].encode('ascii')) with Popen(args, stdout=PIPE, stdin=PIPE, stderr=PIPE) as p: p.stdin.write(b'server ' + opts['server'].encode('ascii') + b'\n') try: p.stdin.write(b'zone ' + opts['zone'].encode('ascii') + b'\n') except KeyError: pass hostname = hostname.encode('ascii') if ip: if ip.v4: addr = ip.v4.encode('ascii') p.stdin.write(b'update delete ' + hostname + b' A\n') p.stdin.write( b'update add ' + hostname + b' 60 A ' + addr + b'\n') if ip.v6: addr = ip.v6.encode('ascii') p.stdin.write(b'update delete ' + hostname + b' AAAA\n') p.stdin.write(b'update add ' + hostname + b' 60 AAAA ' + addr + b'\n') p.stdin.write(b'send\n') err = p.communicate()[1] if len(err) > 0: raise ServiceError("Bad update reply: " + err.decode('ascii')) ddupdate-0.7.2/pylint.conf000066400000000000000000000174271434670644200155250ustar00rootroot00000000000000[MASTER] # Specify a configuration file. #rcfile= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). #init-hook= # Add files or directories to the blacklist. They should be base names, not # paths. ignore=CVS # Pickle collected data for later comparisons. persistent=yes # List of plugins (as comma separated values of python modules names) to load, # usually to register additional checkers. load-plugins= [MESSAGES CONTROL] # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time. See also the "--disable" option for examples. #enable= # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifiers separated by comma (,) or put this # option multiple times (only on the command line, not in the configuration # file where it should appear only once).You can also use "--disable=all" to # disable everything first and then reenable specific checks. For example, if # you want to run only the similarities checker, you can use "--disable=all # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" disable=locally-disabled,invalid-name,duplicate-code # duplicate-code broken for now: https://github.com/PyCQA/pylint/issues/214 #disable=locally-disabled,too-few-public-methods,locally-enabled,bad-whitespace # bad-whitespace: See https://github.com/PyCQA/pylint/issues/238 [REPORTS] # Set the output format. Available formats are text, parseable, colorized, msvs # (visual studio) and html. You can also give a reporter class, eg # mypackage.mymodule.MyReporterClass. output-format=text # Put messages in a separate file for each module / package specified on the # command line instead of printing them on stdout. Reports (if any) will be # written in a file name "pylint_global.[txt|html]". files-output=no # Tells whether to display a full report or only the messages reports=no # Python expression which should return a note less than 10 (10 is the highest # note). You have access to the variables errors warning, statement which # respectively contain the number of errors / warnings messages and the total # number of statements analyzed. This is used by the global evaluation report # (RP0004). evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) # Template used to display messages. This is a python new-style format string # used to format the massage information. See doc for all details #msg-template= [SIMILARITIES] # Minimum lines number of a similarity. min-similarity-lines=4 # Ignore comments when computing similarities. ignore-comments=yes # Ignore docstrings when computing similarities. ignore-docstrings=yes # Ignore imports when computing similarities. ignore-imports=no [MISCELLANEOUS] # List of note tags to take in consideration, separated by a comma. notes=FIXME,XXX,TODO [FORMAT] # Maximum number of characters on a single line. max-line-length=80 # Regexp for a line that is allowed to be longer than the limit. ignore-long-lines=^\s*(# )??$ # Maximum number of lines in a module max-module-lines=1000 # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 # tab). indent-string=' ' [BASIC] # List of builtins function names that should not be used, separated by a comma bad-functions=map,filter,apply,input # Regular expression which should only match correct module names module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ # Regular expression which should only match correct module level names const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ # Regular expression which should only match correct class names class-rgx=[A-Z_][a-zA-Z0-9]+$ # Regular expression which should only match correct function names function-rgx=[a-z_][a-z0-9_]{2,30}$ # Regular expression which should only match correct method names method-rgx=[a-z_][a-z0-9_]{2,30}$ # Regular expression which should only match correct instance attribute names attr-rgx=[a-z_][a-z0-9_]{2,30}$ # Regular expression which should only match correct argument names argument-rgx=[a-z_][a-z0-9_]{2,30}$ # Regular expression which should only match correct variable names variable-rgx=[a-z_][a-z0-9_]{2,30}$ # Regular expression which should only match correct attribute names in class # bodies class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ # Regular expression which should only match correct list comprehension / # generator expression variable names inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ # Good variable names which should always be accepted, separated by a comma good-names=b,cf,f,fd,d,i,j,k,ex,ix,c,ok,ip,OK,to,v4,v6,s1,s2 # Bad variable names which should always be refused, separated by a comma bad-names=foo,bar,baz,toto,tutu,tata # Regular expression which should only match function or class names that do # not require a docstring. no-docstring-rgx=__.*__ # Minimum line length for functions/classes that require docstrings, shorter # ones are exempt. docstring-min-length=-1 [TYPECHECK] # Tells whether missing members accessed in mixin class should be ignored. A # mixin class is detected if its name ends with "mixin" (case insensitive). ignore-mixin-members=yes # List of classes names for which member attributes should not be checked # (useful for classes with attributes dynamically set). ignored-classes=SQLObject # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E0201 when accessed. Python regular # expressions are accepted. generated-members=Gui, urllib.request [VARIABLES] # Tells whether we should check for unused import in __init__ files. init-import=no # A regular expression matching the beginning of the name of dummy variables # (i.e. not used). dummy-variables-rgx=_$|dummy # List of additional names supposed to be defined in builtins. Remember that # you should avoid to define new builtins when possible. additional-builtins= [CLASSES] # List of method names used to declare (i.e. assign) instance attributes. defining-attr-methods=__init__,__new__,setUp # List of valid names for the first argument in a class method. valid-classmethod-first-arg=cls # List of valid names for the first argument in a metaclass class method. valid-metaclass-classmethod-first-arg=mcs [IMPORTS] # Deprecated modules which should not be used, separated by a comma deprecated-modules=regsub,TERMIOS,Bastion,rexec # Create a graph of every (i.e. internal and external) dependencies in the # given file (report RP0402 must not be disabled) import-graph= # Create a graph of external dependencies in the given file (report RP0402 must # not be disabled) ext-import-graph= # Create a graph of internal dependencies in the given file (report RP0402 must # not be disabled) int-import-graph= [DESIGN] # Maximum number of arguments for function / method max-args=5 # Argument names that match this expression will be ignored. Default to name # with leading underscore ignored-argument-names=_.* # Maximum number of locals for function / method body max-locals=15 # Maximum number of return / yield for function / method body max-returns=6 # Maximum number of branch for function / method body max-branches=12 # Maximum number of statements in function / method body max-statements=50 # Maximum number of parents for a class (see R0901). max-parents=7 # Maximum number of attributes for a class (see R0902). max-attributes=8 # Minimum number of public methods for a class (see R0903). min-public-methods=2 # Maximum number of public methods for a class (see R0904). max-public-methods=20 [EXCEPTIONS] # Exceptions that will emit a warning when being caught. Defaults to # "Exception" overgeneral-exceptions=Exception ddupdate-0.7.2/pyproject.toml000066400000000000000000000001441434670644200162370ustar00rootroot00000000000000[build-system] requires = ["setuptools >= 40.6.0", "wheel"] build-backend = "setuptools.build_meta" ddupdate-0.7.2/setup.cfg000066400000000000000000000005521434670644200151470ustar00rootroot00000000000000[pydocstyle] add_ignore = D105,D200,D402,D401 [pycodestyle] ignore=E262,E266,E402,W503,W504 ; 262, 266: Comment formatting incompatible wiith doxygen ; 503: Line break before operator (not enforced by the actual pep8 rule) ; 402: import not at top... we have some code fixing with load paths. ; W504 line break after binary operator -- this is actually nt bad. ddupdate-0.7.2/setup.py000066400000000000000000000076121434670644200150440ustar00rootroot00000000000000"""ddupdate install data.""" # pylint: disable=bad-option-value, import-outside-toplevel # pylint: disable=consider-using-with import shutil import os import subprocess from glob import glob from setuptools import setup from distutils.command.clean import clean from distutils.command.install import install ROOT = os.path.dirname(__file__) ROOT = ROOT if ROOT else '.' def systemd_unitdir(): """Return the official systemd user unit dir path.""" cmd = 'pkg-config systemd --variable=systemduserunitdir'.split() try: return subprocess.check_output(cmd).decode().strip() except (OSError, subprocess.CalledProcessError): return "/usr/lib/systemd/user" DATA = [ ("lib/systemd/user", glob('systemd/*')), ('share/bash-completion/completions/', ['bash_completion.d/ddupdate']), ('share/man/man8', ['ddupdate.8', 'ddupdate-config.8', 'ddupdate-netrc-to-keyring.8']), ('share/man/man5', ['ddupdate.conf.5']), ('share/ddupdate/plugins', glob('plugins/*.py')), ('share/ddupdate/dispatcher.d', ['dispatcher.d/50-ddupdate']), ('share/ddupdate/systemd', glob('systemd/*')) ] class _ProjectClean(clean): """Actually clean up everything generated.""" def run(self): super().run() paths = ['build', 'install', 'dist', 'lib/ddupdate.egg-info'] for path in paths: if os.path.exists(path): shutil.rmtree(path) class _ProjectInstall(install): """Log used installation paths.""" def run(self): final_prefix = None if 'FINAL_PREFIX' in os.environ: final_prefix = os.environ['FINAL_PREFIX'] if final_prefix: # Strip leading prefix in paths like /usr/lib/systemd, # avoiding /usr/usr when applying the prefix if DATA[0][0].startswith(self.prefix): DATA[0] = (DATA[0][0][len(self.prefix) + 1:], DATA[0][1]) super().run() from distutils.fancy_getopt import longopt_xlate s = "" install_lib = "" for (option, _, _) in self.user_options: option = option.translate(longopt_xlate) if option[-1] == "=": option = option[:-1] try: value = getattr(self, option) except AttributeError: continue if option == "install_lib": install_lib = value if final_prefix and self.root: value = str(value).replace(self.root, final_prefix) elif final_prefix and self.prefix: value = str(value).replace(self.prefix, final_prefix) value = str(value).replace('//', '/') s += option + " = " + (str(value) if value else "") + "\n" if not install_lib: print("Warning: cannot create platform install paths file") return path = install_lib + "/ddupdate/install.conf" print("Creating install config file " + path) with open(path, "w") as f: f.write("[install]\n") f.write(s) setup( name='ddupdate', version='0.7.2', description='Update dns data for dynamic ip addresses', long_description=open(ROOT + '/README.md').read(), include_package_data=True, license='MIT', url='http://github.com/leamas/ddupdate', author='Alec Leamas', author_email='alec.leamas@nowhere.net', classifiers=[ 'Development Status :: 4 - Beta', 'Intended Audience :: End Users/Desktop', 'Topic :: System :: Networking', 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3.4', ], keywords=['dyndns', 'dhcp', 'dns'], package_dir={'': 'lib'}, packages=['ddupdate'], scripts=['ddupdate', 'ddupdate-config', 'ddupdate-netrc-to-keyring'], data_files=DATA, cmdclass={'clean': _ProjectClean, 'install': _ProjectInstall}, install_requires=["keyring"] ) ddupdate-0.7.2/systemd/000077500000000000000000000000001434670644200150145ustar00rootroot00000000000000ddupdate-0.7.2/systemd/ddupdate.service000066400000000000000000000006031434670644200201670ustar00rootroot00000000000000[Unit] Description=Update DNS data for this host Documentation=man:ddupdate.8 http://github.com/leamas/ddupdate After=network.target [Service] Type=oneshot ExecStart=/usr/local/bin/ddupdate Environment=PATH=/bin:/usr/bin:/sbin:/usr/sbin # User=ddupdate # Environment=http_proxy=my.proxy.domain:8888 # Environment=https_proxy=my.proxy.domain:8888 [Install] WantedBy=multi-user.target ddupdate-0.7.2/systemd/ddupdate.timer000066400000000000000000000002771434670644200176560ustar00rootroot00000000000000[Unit] Description=Run ddupdate hourly and on boot Documentation=man:ddupdate.8 http://github.com/leamas/ddupdate [Timer] OnBootSec=2min OnUnitActiveSec=1h [Install] WantedBy=timers.target