pax_global_header00006660000000000000000000000064141722745020014516gustar00rootroot0000000000000052 comment=8bd61f5fc992dce9a537e39d39bbcc5cd732cbe0 ddupdate-0.6.6/000077500000000000000000000000001417227450200133215ustar00rootroot00000000000000ddupdate-0.6.6/.gitignore000066400000000000000000000001251417227450200153070ustar00rootroot00000000000000__pycache__ *.pyc *.swp build dist *.egg-info __pycache__ fedora testing.txt install ddupdate-0.6.6/CONFIGURATION.md000066400000000000000000000106061417227450200156550ustar00rootroot00000000000000Complete, 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. The address plugin to use is normally 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 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. Using the .netrc info in the *ddupdate help *, create an entry in the *~/.netrc* file like:: machine api.dynu.com login password Note that this file must be protected for other users (otherwise no tools will accept it). Do:: $ chmod 600 ~/.netrc 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 After which it should be possible to just invoke *ddupdate* without any options. When done, proceed to Configuring systemd in README.md ddupdate-0.6.6/CONTRIBUTE.md000066400000000000000000000173031417227450200153250ustar00rootroot00000000000000This 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``` - Most services uses a http GET request to set the data. See ```freedns_io.py``` for a http POST example. - Reply decoding: - Most sites just returns some text, simple enough - json: example in ```system_ns.py``` - html: example in ```duiadns.py``` 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.6.6:: $ git clone -b fedora https://github.com/leamas/ddupdate.git $ cd ddupdate/fedora $ sudo dnf builddep ddupdate.spec $ ./make-tarball 0.6.6 $ 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.6.6 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.6.6 -us -uc $ sudo dpkg -i ../ddupdate_0.6.6*_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. - Commit and tag the release: git tag 0.6.6 - Create fedora package: git checkout fedora cd fedora ./make-tarball 0.6.6 rpmdev-bumpspec *.spec , and edit it. rm -rf rpmbuild rpmbuild-here -ba *.spec - Copy tarball and repo to debian and commit it on pristine-tar git fetch upstream debian:debian git fetch upstream pristine-tar:pristine-tar scp fedora/ddupdate-0.6.6.tar.gz sid: cd ..; ssh sid rm -rf ddupdate.git scp -rq ddupdate sid:ddupdate.git ssh sid cd ddupdate; rm -rf * mv ../ddupdate-0.6.6.tar.gz ddupdate_0.6.6.orig.tar.gz git clone -o upstream -b debian ../ddupdate.git ddupdate cd ddupdate git remote add github git@github.com:leamas/ddupdate.git git fetch upstream pristine-tar:pristine-tar pristine-tar commit ../ddupdate_0.6.6.orig.tar.gz 0.6.6 - Upload to pypi: $ python setup.py sdist $ twine upload dist/* - Create debian test huild 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.6.6 $ dch -v 0.6.6-1 $ git commit -am "debian: 0.6.6-1" $ Check systemd/ddupdate.service $ dpkg-source --commit $ git commit -a --amend $ git clean -fd $ gbp buildpackage --git-upstream-tag=0.6.6 -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.6.6.orig.tar.gz $ mv ddupdate_0.6.6.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.6.6_all.deb # Build and distribute source package (upstream only) $ debuild -S $ dput ppa:leamas-alec/ddupdate ../*source.changes ddupdate-0.6.6/LICENSE.txt000066400000000000000000000020331417227450200151420ustar00rootroot00000000000000Copyright 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.6.6/Makefile000066400000000000000000000016211417227450200147610ustar00rootroot00000000000000# # Standard Makefile supports targets build, install and clean + static # code checking. make install respects DESTDIR, build and install respects # V=0 and V=1 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 python3-pylint \ --rcfile=pylint.conf \ --msg-template='$(pylint_template)' \ $? pydocstyle: $(PYTHON_SRC) pydocstyle $? pycodestyle: $(PYTHON_SRC) pycodestyle $? .phony: ddupdate-0.6.6/NEWS000066400000000000000000000100401417227450200140130ustar00rootroot000000000000000.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.6.6/README.md000066400000000000000000000077741417227450200146170ustar00rootroot00000000000000ddupdate - 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 (tested on 3.6 and 3.4) - 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/local 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/sid 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.6.6/bash_completion.d/000077500000000000000000000000001417227450200167115ustar00rootroot00000000000000ddupdate-0.6.6/bash_completion.d/ddupdate000066400000000000000000000024571417227450200204360ustar00rootroot00000000000000_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" 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 ;; *) ;; esac if [[ ${cur} == -* ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) return 0 fi } complete -F _ddupdate ddupdate ddupdate-0.6.6/ddupdate000077500000000000000000000005331417227450200150420ustar00rootroot00000000000000#!/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: import lib.ddupdate.main as main except ImportError: import ddupdate.main as main main.main() ddupdate-0.6.6/ddupdate-config000077500000000000000000000006171417227450200163100ustar00rootroot00000000000000#!/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: import lib.ddupdate.config as config except ImportError: import ddupdate.config as config config.main() ddupdate-0.6.6/ddupdate-config.8000066400000000000000000000012641417227450200164520ustar00rootroot00000000000000.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 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.6.6/ddupdate.8000066400000000000000000000155741417227450200152200ustar00rootroot00000000000000.TH DDUPDATE "8" "Last change: Jun 2019" "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. \fBddupdate\fR caches the address, and only attempts the update if the address actually is changed. .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, the netrc(5) file 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-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-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-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/etc/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 Used to store username and password for logging in to service providers to update, firewalls to get the IP address etc. 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. .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 netrc(5) Authentication tokens file, originally used by ftp(1). .TP 4 .B ddupdate-config(8) Configuration tool .TP 4 .B https://github.com/leamas/ddupdate Project homesite and README ddupdate-0.6.6/ddupdate.conf000066400000000000000000000005511417227450200157630ustar00rootroot00000000000000# # 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.6.6/ddupdate.conf.5000066400000000000000000000016271417227450200161330ustar00rootroot00000000000000.TH DDUPDATE.CONF "5" "Last change: Jan 2018" "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 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 "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.6.6/dispatcher.d/000077500000000000000000000000001417227450200156715ustar00rootroot00000000000000ddupdate-0.6.6/dispatcher.d/50-ddupdate000077500000000000000000000015051417227450200176340ustar00rootroot00000000000000#!/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.6.6/lib/000077500000000000000000000000001417227450200140675ustar00rootroot00000000000000ddupdate-0.6.6/lib/ddupdate/000077500000000000000000000000001417227450200156615ustar00rootroot00000000000000ddupdate-0.6.6/lib/ddupdate/__init__.py000066400000000000000000000000311417227450200177640ustar00rootroot00000000000000"""Empty placeholder.""" ddupdate-0.6.6/lib/ddupdate/config.py000077500000000000000000000271071417227450200175120ustar00rootroot00000000000000 """Simple, CLI configuration script for ddupdate.""" import configparser import logging import os import os.path import re import shutil import stat import subprocess import sys import tempfile import time from ddupdate.main import \ setup, build_load_path, envvar_default, load_plugin_dir from ddupdate.ddplugin import ServicePlugin, AddressPlugin _CONFIG_TRAILER = """ # # Check for plugin options using ddupdate --help # # service-options = foo bar # address-options = foo bar """ _UPDATE_CONFIG = """ #!/bin/sh if test -e {netrc_path}; then sed -E -i '/machine[ ]+{machine}/d' {netrc_path} fi if test "{netrc_line}" != "machine dummy"; then echo {netrc_line} >> {netrc_path} fi chmod 600 {netrc_path} cp {config_src} {config_dest} """ 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.expanduser('~/.netrc'), 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)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) 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 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) if ix not in range(1, len(services_by_ix) + 1): raise _GoodbyeError("Illegal selection\n", 2) return services_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] else: raise _GoodbyeError("Illegal value", 1) 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) path = os.path.join(user_dir, "ddupdate.service") if not os.path.exists(path): shutil.copy("/usr/share/ddupdate/systemd/ddupdate.service", path) path = os.path.join(user_dir, "ddupdate.timer") if not os.path.exists(path): shutil.copy("/usr/share/ddupdate/systemd/ddupdate.timer", path) here = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) 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(); with open(os.path.join(user_dir, 'ddupdate.service'), 'w') as f: for l in lines: if l.startswith('ExecStart'): f.write("ExecStart=" + bindir + "/ddupdate\n") else: f.write(l + "\n") 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 merge_configs(netrc_line, netrc_path, config_src, config_dest, cmd): """ Merge netrc and config file options into current configuration. Parameters: - netrc_line: String, new netrc authentication line. - netrc_path: String, path of netrc file. - config_src: String, path of updated, temporary config file. - config_dest: String, path of existing config file actually used. - cmd: function(path) returning command executing path in a shell, a list of strings. Returns nothing. """ netrc_line = netrc_line if netrc_line else "machine dummy" script = _UPDATE_CONFIG.format( netrc_line=netrc_line, machine=netrc_line.split()[1], netrc_path=netrc_path, config_src=config_src, config_dest=config_dest ) with tempfile.NamedTemporaryFile(delete=False) as f: f.write(script.encode()) os.chmod(f.name, stat.S_IRUSR | stat.S_IXUSR) subprocess.run(cmd(f.name)) os.unlink(f.name) print("Patched .netrc: " + netrc_path) print("Patched config: " + config_dest) 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, netrc_line): """ Merge user config data into user config files. Parameters: - config: dict with new configuration options. - netrc_line: Authentication line to merge into existing .netrc file. Updates: ~/.config/ddupdate.conf and ~/.netrc, respecting XDG_CONFIG_HOME. """ confdir = \ envvar_default('XDG_CONFIG_HOME', os.path.expanduser('~/.config')) if not os.path.exists(confdir): os.makedirs(confdir) tmp_conf = update_config(config, os.path.join(confdir, "ddupdate.conf")) merge_configs(netrc_line, os.path.expanduser('~/.netrc'), tmp_conf, os.path.join(confdir, "ddupdate.conf"), lambda p: ["/bin/sh", p]) os.unlink(tmp_conf) 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) 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") subprocess.run(['sh', '-c', cmd]) else: cmd = 'systemctl --user stop ddupdate.timer' cmd += 'systemctl --user disable ddupdate.timer' print("Stopping ddupdate.timer") subprocess.run(['sh', '-c', cmd]) 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 = setup(logging.WARNING)[0] 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) 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 } write_config_files(conf, netrc) 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.6.6/lib/ddupdate/ddplugin.py000066400000000000000000000211301417227450200200360ustar00rootroot00000000000000""" 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 os.path import urllib.request from urllib.parse import urlencode, urlparse from socket import timeout as timeoutError from netrc import netrc URL_TIMEOUT = 120 # Default timeout in get_response() 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. 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: with urllib.request.urlopen(url, data, timeout=to) as response: code = response.getcode() html = response.read().decode('ascii') except timeoutError: raise ServiceError("Timeout reading %s" % url) except (urllib.error.HTTPError, urllib.error.URLError) as err: raise ServiceError("Error reading %s :%s" % (url, 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 ~/-netrc or /etc/netrc. Parameters: - machine: key while searching in netrc file. Returns: - A (user, password) tuple. User might be None. Raises: - ServiceError if .netrc or password is not found. See: - netrc(5) """ if os.path.exists(os.path.expanduser('~/.netrc')): path = os.path.expanduser('~/.netrc') elif os.path.exists('/etc/netrc'): path = '/etc/netrc' else: raise ServiceError("Cannot locate the netrc file (see manpage).") auth = netrc(path).authenticators(machine) if not auth: raise ServiceError("No .netrc data found for " + machine) if not auth[2]: raise ServiceError("No password found for " + machine) return auth[0], auth[2] class IpAddr(object): """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.""" pass class AbstractPlugin(object): """Abstract base for all plugins.""" _name = None _oneliner = 'No info found' __version__ = '0.6.6' 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()") ddupdate-0.6.6/lib/ddupdate/main.py000077500000000000000000000366161417227450200171760ustar00rootroot00000000000000"""Update DNS data for dynamic ip addresses.""" import argparse import configparser import glob import importlib import inspect import logging import math import os import os.path import stat import sys import time import ast from ddupdate.ddplugin import AddressPlugin, AddressError from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import IpAddr # pylint: disable=ungrouped-imports if sys.version_info >= (3, 5): import importlib.util else: from importlib.machinery import SourceFileLoader 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', '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 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 + '.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 in range(len(sys.argv)): arg = sys.argv[i] 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 '%s' for read", path) return None return path def parse_config(path, log): """Parse config file, return fully populated dict of key-values.""" results = {} config = configparser.ConfigParser() config.read(path) if 'update' in config: items = config['update'] else: log.warning( 'No [update] section found in %s, file ignored', path) items = {} for key in DEFAULTS: if key in items: results[key] = items[key] else: results[key] = DEFAULTS[key] return results 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", "--config-file", metavar="path", help='Config file with default values for all options' + ' [' + envvar_default('XDG_CONFIG_HOME', ' ~/.config/ddupdate.conf') + ':/etc/dupdate.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( "-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.6.6" 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_options(log, args): """Print some info on seledted options.""" log.info("Loglevel: " + logging.getLevelName(args.loglevel)) log.info("Using hostname: " + args.hostname) log.info("Using ip address plugin: " + args.address_plugin) log.info("Using service plugin: " + args.service_plugin) log.info("Service options: " + (' '.join(args.service_options) if args.service_options else '')) log.info("Address options: " + (' '.join(args.address_options) if args.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', '') if sys.version_info >= (3, 5): spec = importlib.util.spec_from_file_location(name, path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) else: module = SourceFileLoader(name, path).load_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) getters_by_name = {plug.name(): plug for plug in getters} setters_by_name = {plug.name(): plug for plug in setters} log.debug("Loaded %d address and %d service plugins from %s", len(getters), len(setters), path) return 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(ip_plugins, service_plugins, plugid): """Print full help for given plugin.""" if plugid in ip_plugins: plugin = ip_plugins[plugid] elif plugid in service_plugins: plugin = service_plugins[plugid] else: raise _GoodbyeError("No help found (nu such plugin?): " + plugid, 1) print("Name: " + plugin.name()) print("Source file: " + plugin.module.__file__ + "\n") print(plugin.info()) 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 setup(loglevel=None): """Return a standard log, arg_parser tuple.""" log = log_setup() conffile_path = parse_conffile(log) conf = parse_config(conffile_path, log) if conffile_path else DEFAULTS opts = parse_options(conf) log.handlers[0].setLevel(loglevel if loglevel else opts.loglevel) log.debug('Using config file: %s', conffile_path) log_options(log, opts) return log, opts def get_plugins(log, opts): """ Handles plugin listing, plugin help or load plugins. return: (ip plugin, service plugin) tuple. """ ip_plugins = {} service_plugins = {} for path in build_load_path(log): 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) if opts.list_services: list_plugins(service_plugins) raise _GoodbyeError() if opts.list_addressers: list_plugins(ip_plugins) raise _GoodbyeError() if opts.help and opts.help != '-': plugin_help(ip_plugins, service_plugins, opts.help) raise _GoodbyeError() if opts.ip_plugin: raise _GoodbyeError( "--ip-plugin has been replaced by --address-plugin.") elif opts.address_plugin not in ip_plugins: raise _GoodbyeError('No such ip plugin: ' + opts.address_plugin, 2) elif 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] return ip_plugin, service_plugin def main(): """Indeed: main function.""" try: log, opts = setup() ip_plugin, service_plugin = get_plugins(log, opts) try: ip = ip_plugin.get_ip(log, opts.address_options) except AddressError as err: raise _GoodbyeError("Cannot obtain ip address: " + str(err), 3) 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) 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 _GoodbyeError() except _GoodbyeError as err: if err.exitcode != 0: log.error(err.msg) sys.stderr.write("Fatal error: " + str(err) + "\n") sys.exit(err.exitcode) try: service_plugin.register(log, opts.hostname, ip, opts.service_options) except ServiceError as err: log.error("Cannot update DNS data: %s", err) sys.exit(4) else: ip_cache_set(opts, ip) log.info("Update OK") if __name__ == '__main__': main() # vim: set expandtab ts=4 sw=4: ddupdate-0.6.6/plugins/000077500000000000000000000000001417227450200150025ustar00rootroot00000000000000ddupdate-0.6.6/plugins/changeip.py000066400000000000000000000023621417227450200171350ustar00rootroot00000000000000""" 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 not 'uccessful' in html: raise ServiceError("Bad update reply: " + html) ddupdate-0.6.6/plugins/cloudflare.py000066400000000000000000000171511417227450200175010ustar00rootroot00000000000000""" 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. """ 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) from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import get_netrc_auth, dict_of_opts 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)) 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. """ 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.6.6/plugins/default_if.py000066400000000000000000000025461417227450200174650ustar00rootroot00000000000000""" ddupdate plugin to obtain ip address. See: ddupdate(8) """ import subprocess from ddupdate.ddplugin import AddressPlugin, AddressError, IpAddr 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 find_device(self, 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 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_ = self.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.6.6/plugins/default_web.py000066400000000000000000000035501417227450200176400ustar00rootroot00000000000000""" 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: if response.getcode() != 200: log.debug("Bad response at %s (ignored)" % url) return None html = response.read().decode('ascii') except (urllib.error.HTTPError, urllib.error.URLError) as err: raise AddressError("Error reading %s :%s" % (url, err)) 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.6.6/plugins/default_web6.py000066400000000000000000000037361417227450200177340ustar00rootroot00000000000000""" 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.6.6/plugins/dnsdynamic_org.py000066400000000000000000000021551417227450200203570ustar00rootroot00000000000000""" ddupdate plugin updating data on dnsdynamic.org. See: ddupdate(8) See: https://www.dnsdynamic.org/api.php """ from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import http_basic_auth_setup, get_response class DynamicDnsPlugin(ServicePlugin): """ Update a dns entry on dnsdynamic.org. Despite documentation, does not support setting arbitrary ip address. The ip-disabled plugin should be used, and the address set is as seen from dns-dynamic.org. ipv6 is not supported. netrc: Use a line like machine www.dnsdynamic.org login password Options: none """ _name = 'dnsdynamic.org' _oneliner = 'Updates on http://dnsdynamic.org/' _url = 'https://www.dnsdynamic.org/api?hostname={0}' 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.6.6/plugins/dnsexit.py000066400000000000000000000040641417227450200170360ustar00rootroot00000000000000""" 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' _update_host = 'http://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._update_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.6.6/plugins/dnspark.py000066400000000000000000000026261417227450200170240ustar00rootroot00000000000000""" 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.6.6/plugins/dry_run.py000066400000000000000000000012711417227450200170370ustar00rootroot00000000000000""" 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.6.6/plugins/dtdns.py000066400000000000000000000025341417227450200164740ustar00rootroot00000000000000""" ddupdate plugin updating data on dtdns.com. See: ddupdate(8) See: https://www.dtdns.com/dtsite/updatespec """ from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import get_response, get_netrc_auth class DtdnsPlugin(ServicePlugin): """ Update a dns entry on dtdns.com. Supports most address plugins including default-web-ip, default-if and ip-disabled. ipv6 is not supporterted. The number of hosts are limited for free accounts, see website. .netrc: Use a line like: machine www.dtdns.com login password Options: none """ _name = 'dtdns.com' _oneliner = 'Updates on https://www.dtdns.com' _url = "https://www.dtdns.com/api/autodns.cfm?id={0}&pw={1}" # pylint: disable=unused-variable def register(self, log, hostname, ip, options): """Implement ServicePlugin.register().""" user, password = get_netrc_auth('www.dtdns.com') url = self._url.format(hostname, password) if ip: url += "&ip=" + ip.v4 try: html = get_response(log, url) except TimeoutError: # one more try... html = get_response(log, url) if 'points to' not in html: raise ServiceError("Bad update reply: " + html) log.info("Update completed: " + html) ddupdate-0.6.6/plugins/duckdns.py000066400000000000000000000025251417227450200170130ustar00rootroot00000000000000""" 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.6.6/plugins/duiadns.py000066400000000000000000000050731417227450200170100ustar00rootroot00000000000000""" ddupdate plugin updating data on duiadns.com. See: ddupdate(8) See: https://www.duiadns.net/duiadns-url-update """ from html.parser import HTMLParser from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import get_response, get_netrc_auth class DuiadnsParser(HTMLParser): """Dig out ip address and hostname in server HTML reply.""" def error(self, message): """Implement HTMLParser.error().""" raise ServiceError("HTML parser error: " + message) 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. 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 html = get_response(log, url) 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.6.6/plugins/dyfi.py000066400000000000000000000020451417227450200163100ustar00rootroot00000000000000""" 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.6.6/plugins/dynu.py000066400000000000000000000022031417227450200163300ustar00rootroot00000000000000""" ddupdate plugin updating data on dynu.com. See: ddupdate(8) See: https://www.dynu.com/Resources/API/Documentation """ import hashlib from ddupdate.ddplugin import ServicePlugin, 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 """ _name = 'dynu.com' _oneliner = 'Updates on https://www.dynu.com/en-US/DynamicDNS' _url = "http://api.dynu.com" \ + "/nic/update?hostname={0}&username={1}&password={2}" def register(self, log, hostname, ip, options): """Implement ServicePlugin.register().""" user, password = get_netrc_auth('api.dynu.com') pw_hash = hashlib.md5(password.encode()).hexdigest() url = self._url.format(hostname, user, pw_hash) if ip and ip.v4: url += "&myip=" + ip.v4 if ip and ip.v6: url += "&myipv6=" + ip.v6 get_response(log, url) ddupdate-0.6.6/plugins/dynv6_com.py000066400000000000000000000025341417227450200172640ustar00rootroot00000000000000""" 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.6.6/plugins/freedns.py000066400000000000000000000043001417227450200167770ustar00rootroot00000000000000""" ddupdate plugin updating data on freedns.afraid.org. See: ddupdate(8) See: https://linuxaria.com/howto/dynamic-dns-with-bash-afraid-org """ import hashlib 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://freedns.afraid.org/api/?action=getdyndns&sha={0}' def register(self, log, hostname, ip, options): """ Based on http://freedns.afraid.org/api/, needs _url below to update. The sha parameter is sha1sum of login|password. This returns a list of host|currentIP|updateURL lines. Pick the line that matches myhost, and fetch the URL. word 'Updated' for success, 'fail' for failure. """ def build_shasum(): """Compute sha1sum('user|password') used in url.""" user, password = get_netrc_auth('freedns.afraid.org') token = "{0}|{1}".format(user, password) return hashlib.sha1(token.encode()).hexdigest() shasum = build_shasum() url = self._url.format(shasum) if ip and ip.v6: url += "&address=" + str(ip.v6) elif ip and ip.v4: url += "&address=" + str(ip.v4) html = get_response(log, url) update_url = None for line in html.split("\n"): log.debug("Got line: " + line) tokens = line.split("|") if tokens[0] == hostname: update_url = tokens[2] break if not update_url: raise ServiceError( "Cannot see %s being set up at this account" % hostname) log.debug("Contacting freedns for update on %s", update_url) get_response(log, update_url) ddupdate-0.6.6/plugins/freedns_io.py000066400000000000000000000025251417227450200174750ustar00rootroot00000000000000""" 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.6.6/plugins/googledomains.py000066400000000000000000000023701417227450200202050ustar00rootroot00000000000000""" 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, \ get_response, http_basic_auth_setup 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) 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.6.6/plugins/hardcoded_if.py000066400000000000000000000015011417227450200177440ustar00rootroot00000000000000""" ddupdate plugin providing an ip address from an interface option. See: ddupdate(8) """ import subprocess import sys from ddupdate.ddplugin import AddressPlugin, 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.6.6/plugins/hardcoded_ip.py000066400000000000000000000015541417227450200177660ustar00rootroot00000000000000""" ddupdate plugin providing an ip address to use a from an interface option. See: ddupdate(8) """ import sys from ddupdate.ddplugin import AddressPlugin, 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.6.6/plugins/hurricane_electric.py000066400000000000000000000025771417227450200212210ustar00rootroot00000000000000""" 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.6.6/plugins/ip_disabled.py000066400000000000000000000010361417227450200176130ustar00rootroot00000000000000""" 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.6.6/plugins/ip_from_cmd.py000066400000000000000000000032331417227450200176330ustar00rootroot00000000000000""" 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.6.6/plugins/myonlineportal_net.py000066400000000000000000000024421417227450200213000ustar00rootroot00000000000000""" ddupdate plugin updating data on myonlineportal.net. See: ddupdate(8) See: http://myonlineportal.net/ddns_api """ from ddupdate.ddplugin import ServicePlugin, ServiceError, \ get_response, http_basic_auth_setup 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 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) if ip and ip.v4: url += "&ip=" + ip.v4 if ip and ip.v6: url += "&ip6=" + ip.v6 http_basic_auth_setup(url) html = get_response(log, url) key = html.split()[0] if key not in ['OK', 'good', 'nochg']: raise ServiceError("Bad server reply: " + html) log.info("Server reply: " + html) ddupdate-0.6.6/plugins/no_ip.py000066400000000000000000000017001417227450200164560ustar00rootroot00000000000000""" 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.6.6/plugins/now_dns.py000066400000000000000000000025251417227450200170270ustar00rootroot00000000000000""" ddupdate plugin updating data on now-dns.com. See: ddupdate(8) See: https://now-dns.com/?p=clients """ import base64 import urllib.request import urllib.error from ddupdate.ddplugin import ServicePlugin, ServiceError, \ http_basic_auth_setup, get_response 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 user 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 http_basic_auth_setup(url) html = get_response(log, request) if html not in ['good', 'nochg']: raise ServiceError('Bad server reply: ' + html) ddupdate-0.6.6/plugins/nsupdate.py000066400000000000000000000033041417227450200171770ustar00rootroot00000000000000""" ddupdate plugin using nsupdate. See: ddupdate(8) See: nsupdate(1) """ from ddupdate.ddplugin import ServicePlugin, ServiceError, dict_of_opts from subprocess import Popen,PIPE import sys 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')) p = Popen(args,stdout=PIPE,stdin=PIPE,stderr=PIPE) 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') stdout,err = p.communicate() if len(err) > 0: raise ServiceError("Bad update reply: " + err.decode('ascii')) ddupdate-0.6.6/plugins/system_ns.py000066400000000000000000000027021417227450200174010ustar00rootroot00000000000000""" ddupdate plugin updating data on system-ns.com. See: ddupdate(8) See: https://system-ns.com/services/dynamic """ import json from ddupdate.ddplugin import ServicePlugin, ServiceError from ddupdate.ddplugin import get_response, get_netrc_auth class SystemNsPlugin(ServicePlugin): """ Update a dns entry on system-ns.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 not supported. Access to the service requires an API token. This is available in the website account. netrc: Use a line like machine system-ns.com password Options: None """ _name = 'system-ns.com' _oneliner = 'Updates on https://system-ns.com' _apihost = 'https://system-ns.com/api' _url = '{0}?type=dynamic&domain={1}&command=set&token={2}' def register(self, log, hostname, ip, options): """Implement ServicePlugin.register().""" password = get_netrc_auth('system-ns.com')[1] url = self._url.format(self._apihost, hostname, password) if ip: url += "&ip=" + ip.v4 html = get_response(log, url) reply = json.loads(html) if reply['code'] > 2: raise ServiceError('Bad reply code {0}, message: {1}'.format( reply['code'], reply['msg'])) log.info("Server reply: " + reply['msg']) ddupdate-0.6.6/pylint.conf000066400000000000000000000172551417227450200155210ustar00rootroot00000000000000[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 #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.6.6/setup.cfg000066400000000000000000000004411417227450200151410ustar00rootroot00000000000000[pydocstyle] add_ignore = D105,D200,D402,D401 [pycodestyle] ignore=E262,E266,E402,W503 ; 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. ddupdate-0.6.6/setup.py000066400000000000000000000061051417227450200150350ustar00rootroot00000000000000"""ddupdate install data.""" import shutil import os import subprocess from distutils.command.clean import clean from distutils.command.install import install from glob import glob from setuptools import setup # pylint: disable=bad-continuation ROOT = os.path.dirname(__file__) ROOT = ROOT if ROOT else '.' def systemd_unitdir(): """Return the official systemd user unit dir path without leading /.""" cmd = 'pkg-config systemd --variable=systemduserunitdir'.split() try: return subprocess.check_output(cmd).decode().strip()[1:] except (OSError, subprocess.CalledProcessError): return "usr/lib/systemd/user" DATA = [ (systemd_unitdir(), glob('systemd/*')), ('share/bash-completion/completions/', ['bash_completion.d/ddupdate']), ('share/man/man8', ['ddupdate.8', 'ddupdate-config.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): 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 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.6.6', 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'], data_files=DATA, cmdclass={'clean': _ProjectClean, 'install': _ProjectInstall} ) ddupdate-0.6.6/systemd/000077500000000000000000000000001417227450200150115ustar00rootroot00000000000000ddupdate-0.6.6/systemd/ddupdate.service000066400000000000000000000006031417227450200201640ustar00rootroot00000000000000[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.6.6/systemd/ddupdate.timer000066400000000000000000000002771417227450200176530ustar00rootroot00000000000000[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