New Upstream Release - ddupdate
Ready changes
Summary
Merged new upstream version: 0.7.1 (was: 0.7.0).
Resulting package
Built on 2022-12-14T22:38 (took 18m40s)
The resulting binary packages can be installed (if you have the apt repository enabled) by running one of:
apt install -t fresh-releases ddupdate
Lintian Result
Diff
diff --git a/CONFIGURATION.md b/CONFIGURATION.md
index 4783293..e3d5ca3 100644
--- a/CONFIGURATION.md
+++ b/CONFIGURATION.md
@@ -9,7 +9,13 @@ 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*
+There is also a choice how to store the username/password credentials,
+either in the _~/.netrc_ file or in the keyring.
+
+Address Plugin
+--------------
+
+The address plugin to use is usually either *default-web-ip*
or *default-if*.
The *default-web-ip* plugin should be used when the address to register is
@@ -33,6 +39,8 @@ it using something like::
v6 address: None
hostname: host1.nowhere.net
+Service Plugin
+--------------
After selecting the address plugin, start the process of selecting a
service by listing all available services (your list might differ)::
@@ -78,11 +86,13 @@ Next, pick a service plugin and check the help info, here dynu::
If all looks good, register on dynu.com. This will end up in a hostname,
username and password. Add the host, username and password to the
-_~/.netrc_ file using
+_~/.netrc_ file using::
- $ ddupdate -C netrc -p api.dynu.com username password
+ $ ddupdate -C netrc -p <hostname> <username> <password>
-Test the service using the selected address plugins, something like::
+_hostname_ is available in the plugin's .netrc help text as 'machine',
+for example _api.dynu.com_ in help text above. Test the service using the
+selected address plugins, something like::
$ ./ddupdate --address-plugin default-web-ip --service-plugin dynu \
--hostname myhost.dynu.net -l info
@@ -112,10 +122,10 @@ Adding more hosts
It is possible to add more hosts to the configuration file. This means that
ddupdate will update two or more services when run. This is an experimental
-and purely manual procedure.
-
-The starting point could be a _~/.config/ddupdate.conf_ file like
+procedure.
+The starting point could be a _~/.config/ddupdate.conf_ file created by
+_ddupdate-config_ like::
[update]
address-plugin = default-web-ip
@@ -123,36 +133,40 @@ The starting point could be a _~/.config/ddupdate.conf_ file like
hostname = myhost.dynu.net
loglevel = info
-
-After adding a new host it might look like
+The first step is to replace '[update]' with a new name, for example
+'[dynu]'. After this, ddupdate-config can be run again creating::
[dynu]
address-plugin = default-web-ip
service-plugin = dynu
+ auth-plugin = keyring
hostname = myhost.dynu.net
loglevel = info
-
- [duckdns]
+ [update]
address-plugin = default-web-ip
service-plugin = duckdns.org
+ auth-plugin = keyring
hostname = myhost.duckdns.org
loglevel = info
-Note that the initial entry heading is changed from `[update]` to `[dynu]`
-to ease debugging.
+The process can be repeated to add more entries. New entries can also be
+added manually.
-It is also necessary to update ~/.netrc. From version 0.7, this can be
-done using the `-p' option using something like
+It is also necessary to update username/password credentials stored in
+~/.netrc or the keyring. If using `ddupdate-config` this is handled
+automatically. Otherwise this can be done using the `-p' option using
+something like::
- $ ddupdate -C netrc -p hostname username password
+ $ ddupdate -C netrc <hostname> <username> <password>
-The hostname is available in the plugin's \_url attribute. Services only
+_hostname_ is available in the plugin's .netrc help text as 'machine'.
+Use `-C keyring` when using the keyring credentials storage. Services only
using an API key should use "" as username and the API key as 'password'.
-The CLI support for multiple hosts:
+The CLI support for multiple hosts::
- - `-E` lists the available configurations sections.
+ - `-E` lists the available configuration sections.
- `-e <section>` can be used to only run a specific section when running
ddupdate manually on the command line.
@@ -167,11 +181,16 @@ keyring. The basic parts
to list available plugins.
- Set the _auth-plugin_ option in the config file to _keyring_ to activate
the keyring support.
- - To set passwords for services use the new -p option to `ddupdate`. For
+ - To set passwords for services use the new -p option to `ddupdate`. For
example `ddupdate -C keyring -p myhost username password`. For hosts
using an api key without username, use "" for username.
- The new script `ddupdate_netrc_to_keyring` migrates all entries in
- _~/.netrc_ to the keyring.
+ _~/.netrc_ to the keyring.
+ - To check passwords in keyring::
+
+ $ python3
+ > import keyring
+ > keyring.get_password('ddupdate', 'myhost.tld')
-There is yet not any support for the keyring in `ddupdate-config`, using it
-requires manual configuration.
+Note that the keyring needs to be unlocked before accessed making it less
+useful in servers.
diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md
index ac73d7a..4d01349 100644
--- a/CONTRIBUTE.md
+++ b/CONTRIBUTE.md
@@ -53,15 +53,24 @@ existing plugins and pick solutions from them. Some hints:
- Hashed passwords are used in e. g., ```dynu.py```
- API tokens are handled in e. g., ```duckdns.py```
- Some have broken basic authentication, see ```now_dns.py```
+ - Some uses a separate header with the API token, see ```desec.io```
- Most services uses a http GET request to set the data. See
```freedns_io.py``` for a http POST example.
+ - Handling expired server certificate: see duiadns.
+
- Reply decoding:
- Most sites just returns some text, simple enough
- json: example in ```system_ns.py```
- html: example in ```duiadns.py```
+ - Configuration:
+ The line 'netrc line' in the plugin method documentation is parsed
+ by ddupdate-config to determine what user should define for example
+ user, password, etc. This mechanism based on the netrc syntax is
+ used also in the keyring backend.
+
Packaging
---------
@@ -88,26 +97,26 @@ ddupdate has a multitude of packaging:
- **fedora** is packaged in the *fedora* branch. Pre-built packages are
at https://copr.fedorainfracloud.org/coprs/leamas/ddupdate/. Building
- requires the *git* and *rpm-build* packages. To build version 0.7.0::
+ requires the *git* and *rpm-build* packages. To build version 0.7.1::
$ git clone -b fedora https://github.com/leamas/ddupdate.git
$ cd ddupdate/fedora
$ sudo dnf builddep ddupdate.spec
- $ ./make-tarball 0.7.0
+ $ ./make-tarball 0.7.1
$ rpmbuild -D "_sourcedir $PWD" -ba ddupdate.spec
$ sudo rpm -U --force rpmbuild/RPMS/noarch/ddupdate*rpm
- The **debian** packaging is based on gbp and lives in the *debian* and
*pristine-tar* branches. The packages *git-buildpackage*, *devscripts*
- and *git* are required to build. To build current version 0.7.0 do::
+ and *git* are required to build. To build current version 0.7.1 do::
$ rm -rf ddupdate; mkdir ddupdate; cd ddupdate
$ git clone -o origin -b debian https://github.com/leamas/ddupdate.git
$ cd ddupdate
$ sudo mk-build-deps -i -r debian/control
$ git fetch origin pristine-tar:pristine-tar
- $ gbp buildpackage --git-upstream-tag=0.7.0 -us -uc
- $ sudo dpkg -i ../ddupdate_0.7.0*_all.deb
+ $ gbp buildpackage --git-upstream-tag=0.7.1 -us -uc
+ $ sudo dpkg -i ../ddupdate_0.7.1*_all.deb
$ git clean -fd # To be able to rebuild
- A simpler way to build **debian** packages is based on retreiving the
@@ -135,13 +144,13 @@ Creating a new version (maintainer work)
- Update NEWS file.
- - Commit and tag the release: git tag 0.7.0
+ - Commit and tag the release: git tag 0.7.1
- Create fedora package:
git checkout fedora
cd fedora
- ./make-tarball 0.7.0
+ ./make-tarball 0.7.1
rpmdev-bumpspec *.spec , and edit it.
rm -rf rpmbuild
rpmbuild-here -ba *.spec
@@ -150,17 +159,17 @@ Creating a new version (maintainer work)
git fetch upstream debian:debian
git fetch upstream pristine-tar:pristine-tar
- scp fedora/ddupdate-0.7.0.tar.gz sid:
+ scp fedora/ddupdate-0.7.1.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.7.0.tar.gz ddupdate_0.7.0.orig.tar.gz
+ mv ../ddupdate-0.7.1.tar.gz ddupdate_0.7.1.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.7.0.orig.tar.gz 0.7.0
+ pristine-tar commit ../ddupdate_0.7.1.orig.tar.gz 0.7.1
- Upload to pypi:
@@ -172,14 +181,14 @@ Creating a new version (maintainer work)
$ cd ddupdate/ddupdate
$ sudo mk-build-deps -i -r debian/control
$ git fetch upstream pristine-tar:pristine-tar
- $ git merge -X theirs 0.7.0
- $ dch -v 0.7.0-1
- $ git commit -am "debian: 0.7.0-1"
+ $ git merge -X theirs 0.7.1
+ $ dch -v 0.7.1-1
+ $ git commit -am "debian: 0.7.1-1"
$ Check systemd/ddupdate.service
$ dpkg-source --commit
$ git commit -a --amend
$ git clean -fd
- $ gbp buildpackage --git-upstream-tag=0.7.0 -us -uc
+ $ gbp buildpackage --git-upstream-tag=0.7.1 -us -uc
$ git clean -fd # To be able to rebuild
- Create fedora packages (above)
@@ -195,8 +204,8 @@ Creating a new version (maintainer work)
-o upstream -b debian https://github.com/leamas/ddupdate.git
$ cd ddupdate
$ git fetch upstream pristine-tar:pristine-tar
- $ pristine-tar checkout ddupdate_0.7.0.orig.tar.gz
- $ mv ddupdate_0.7.0.orig.tar.gz ..
+ $ pristine-tar checkout ddupdate_0.7.1.orig.tar.gz
+ $ mv ddupdate_0.7.1.orig.tar.gz ..
$ sudo mk-build-deps -i -r debian/control
# Patch ubuntu stuff:
@@ -204,7 +213,7 @@ Creating a new version (maintainer work)
# Build and install the binary package
$ debuild -us -uc
- $ sudo dpkg -i ../ddupdate_0.7.0_all.deb
+ $ sudo dpkg -i ../ddupdate_0.7.1_all.deb
# Build and distribute source package (upstream only)
$ debuild -S
diff --git a/Makefile b/Makefile
index 34ac139..bd2e129 100644
--- a/Makefile
+++ b/Makefile
@@ -2,7 +2,7 @@
# Standard Makefile supports targets build, install and clean + static
# code checking. make install respects DESTDIR, build and install respects
# V=0 and V=1
-
+PYLINT = pylint-3
ifeq ($(DESTDIR),)
DESTDIR = $(CURDIR)/install
@@ -31,7 +31,7 @@ clean: .phony
python3 setup.py clean
pylint: $(PYTHON_SRC)
- -PYTHONPATH=$(CURDIR)/lib python3-pylint \
+ -PYTHONPATH=$(CURDIR)/lib $(PYLINT) \
--rcfile=pylint.conf \
--msg-template='$(pylint_template)' \
$?
@@ -40,6 +40,6 @@ pydocstyle: $(PYTHON_SRC)
pydocstyle $?
pycodestyle: $(PYTHON_SRC)
- pycodestyle $?
+ -pycodestyle $?
.phony:
diff --git a/NEWS b/NEWS
index 9e0fe21..2d41bcf 100644
--- a/NEWS
+++ b/NEWS
@@ -1,3 +1,21 @@
+0.7.1
+* Drop the dnsdynamic plugin, service is discontinued.
+* Drop the system-ns plugin, service is discontinued.
+* Add new plugin for desec.io (#36).
+* Add new plugin for DNS-O-Matic (#57)
+* Handle expired certificate on duiadns server.
+* myonlineportal, dnsexit, dynu: Handle API updates.
+* Multiple bugfixes in new credentials backends.
+* Base64-encode passwords in auth_netrc to handle 'strange' characters (#49).
+* Add support for selecting auth plugin to ddupdate-config.
+* ddplugin: Add a new header kwarg argument to get_response(), used in
+ desec.io to allow token based header authentication.
+* Add pyproject.toml, first step in distutils being dropped in 3.12
+* Various documentation polish.
+* Rename all address plugins using an addr_ prefix.
+* Fix pylint and pycodestyle diagnostics.
+
+
0.7.0
* Support multiple configuration sections in config file (#25)
* Experimental support for keyring password storage (#23)
diff --git a/README.md b/README.md
index cf08fda..114e87e 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,8 @@ ddupdate is packaged in some distros:
- **Fedora** 27 and later.
- **EPEL7** addons for RHEL/CentOS
- - **Debian** Buster/sid
+ - **Debian** Buster and later
+ - **Ubuntu** Bionic and later
CONTRIBUTE.md describes how to create packages for **other Debian
distributions**
diff --git a/bash_completion.d/ddupdate b/bash_completion.d/ddupdate
index 013bfb8..1b2829f 100644
--- a/bash_completion.d/ddupdate
+++ b/bash_completion.d/ddupdate
@@ -7,6 +7,7 @@ _ddupdate()
opts="--help --hostname --service-plugin --address-plugin --config-file"
opts="$opts --loglevel --ip-version --service-option --address-option"
opts="$opts --list-addressers --list-services"
+ opts="$opts --auth-plugin --list-auth-plugins"
case "${prev}" in
--ip-version | -v)
COMPREPLY=( $(compgen -W "v4 v6 all" -- ${cur}) )
@@ -30,6 +31,11 @@ _ddupdate()
COMPREPLY=( $(compgen -W "$plugins" -- ${cur}) )
return 0
;;
+ --auth-plugin | -C)
+ plugins=$(ddupdate --list-auth-plugins -l error | awk '{print $1}')
+ COMPREPLY=( $(compgen -W "$plugins" -- ${cur}) )
+ return 0
+ ;;
--execute-section | -e)
sections=$(ddupdate --list-sections)
COMPREPLY=( $(compgen -W "$sections" -- ${cur}) )
diff --git a/ddupdate b/ddupdate
index 0b4e90e..71788bf 100755
--- a/ddupdate
+++ b/ddupdate
@@ -10,8 +10,8 @@ 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
+ from lib.ddupdate import main
except ImportError:
- import ddupdate.main as main
+ from ddupdate import main
main.main()
diff --git a/ddupdate-config b/ddupdate-config
index 5e83503..e0c2325 100755
--- a/ddupdate-config
+++ b/ddupdate-config
@@ -12,8 +12,8 @@ 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
+ from lib.ddupdate import config
+except (ImportError, ModuleNotFoundError):
+ from ddupdate import config
config.main()
diff --git a/ddupdate-netrc-to-keyring.8 b/ddupdate-netrc-to-keyring.8
new file mode 100644
index 0000000..b2a56b6
--- /dev/null
+++ b/ddupdate-netrc-to-keyring.8
@@ -0,0 +1,17 @@
+.TH DDUPDATE_NETRC_TO_KEYRING "8" "Last change: Jun 2019" "ddupdate-config" "System Administration Utilities"
+.SH NAME
+.P
+\fBddupdate_netrc_to_keyring\fR - ddupdate configuration migration tool.
+
+.SH SYNOPSIS
+\fBddupdate_netrc_to_keyring\fR
+
+.SH DESCRIPTION
+Simple script which reads all entries in \fI~/.netrc\fR and copies
+them to the system keyring in a format which can be used by the
+ddupdate keyring authentication plugin.
+
+.SH SEE ALSO
+.TP 4
+.B ddupdate(8)
+
diff --git a/ddupdate.8 b/ddupdate.8
index 7c9ef9c..7758339 100644
--- a/ddupdate.8
+++ b/ddupdate.8
@@ -54,6 +54,11 @@ which localizes the default interface using /usr/sbin/ip and uses it's
primary address. Use \fI\-\-list-addressers\fR to list available
plugins.
+.TP 4
+\fB-C, --auth-plugin <\fIplugin\fR>
+Plugin providing authentication credentials, either \fInetrc\fR or
+\fIkeyring\fR
+
.TP 4
\fB-v, --ip-version\fR <\fIv4\fR|\fIv6\fR|\fIall\fR>
The kind of ip addresses to register. The addresses obtained by the
@@ -113,6 +118,10 @@ List service provider plugins.
\fB-A, --list-addressers\fR
List plugins providing one or more ip addresses
+.TP 4
+\fB-P, --list-auth-plugins\fR
+List plugins for storing credentials like \fInetrc\fR and \fIkeyring\fR.
+
.TP 4
\fB-E, --list-sections\fR
List available sections in configuration file.
diff --git a/debian/changelog b/debian/changelog
index 6d15038..0eb39cb 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+ddupdate (0.7.1-1) UNRELEASED; urgency=low
+
+ * New upstream release.
+
+ -- Debian Janitor <janitor@jelmer.uk> Wed, 14 Dec 2022 22:21:33 -0000
+
ddupdate (0.7.0-1) unstable; urgency=medium
* New upstream release
diff --git a/debian/patches/0001-fix-systemd-service b/debian/patches/0001-fix-systemd-service
index bc0fc8e..3e3f535 100644
--- a/debian/patches/0001-fix-systemd-service
+++ b/debian/patches/0001-fix-systemd-service
@@ -11,10 +11,10 @@ ddupdate (0.6.1-1) sid; urgency=medium
systemd/ddupdate.service | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
-diff --git a/systemd/ddupdate.service b/systemd/ddupdate.service
-index 5fa70ac..62ec852 100644
---- a/systemd/ddupdate.service
-+++ b/systemd/ddupdate.service
+Index: ddupdate.git/systemd/ddupdate.service
+===================================================================
+--- ddupdate.git.orig/systemd/ddupdate.service
++++ ddupdate.git/systemd/ddupdate.service
@@ -5,7 +5,7 @@ After=network.target
[Service]
diff --git a/lib/ddupdate/config.py b/lib/ddupdate/config.py
index 53e83de..ad5d1d3 100755
--- a/lib/ddupdate/config.py
+++ b/lib/ddupdate/config.py
@@ -2,20 +2,18 @@
"""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
+ log_setup, build_load_path, envvar_default, load_plugin_dir
+from ddupdate.ddplugin import ServicePlugin, AddressPlugin, AuthPlugin
_CONFIG_TRAILER = """
@@ -26,19 +24,6 @@ _CONFIG_TRAILER = """
# 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()."""
@@ -53,20 +38,17 @@ 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 = [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:")
+ print("The following configuration file(s) already exists:")
for f in files:
print(" " + f)
reply = input("OK to overwrite (Yes/No) [No]: ")
if not reply or not reply.lower().startswith('y'):
print("Please save these file(s) and try again.")
- raise _GoodbyeError("", 0)
+ raise _GoodbyeError("", 0) from None
def _load_plugins(log, paths, plugin_class):
@@ -102,6 +84,11 @@ def _load_addressers(log, paths):
return _load_plugins(log, paths, AddressPlugin)
+def _load_auth_plugins(log, paths):
+ """Load auth plugins from paths into dict keyed by name."""
+ return _load_plugins(log, paths, AuthPlugin)
+
+
def get_service_plugin(service_plugins):
"""
Present a menu with all plugins to user, let her select.
@@ -124,12 +111,41 @@ def get_service_plugin(service_plugins):
try:
ix = int(text)
except ValueError:
- raise _GoodbyeError("Illegal number format", 1)
+ raise _GoodbyeError("Illegal number format", 1) from None
if ix not in range(1, len(services_by_ix) + 1):
- raise _GoodbyeError("Illegal selection\n", 2)
+ raise _GoodbyeError("Illegal selection\n", 2) from None
return services_by_ix[ix]
+def get_auth_plugin(plugins):
+ """
+ Present a menu with all auth plugins to user, let her select.
+
+ Parameters:
+ - plugins: Dict of loaded plugins keyed by plugin.name()
+
+ Return:
+ A loaded plugin as selected by user.
+
+ """
+ print("\nAvailable backends for storing passwords")
+ ix = 1
+ plugins_by_ix = {}
+ for id_ in sorted(plugins):
+ print("%2d %-18s %s" %
+ (ix, id_, plugins[id_].oneliner()))
+ plugins_by_ix[ix] = plugins[id_]
+ ix += 1
+ text = input("Select backend (use keyring if in doubt): ")
+ try:
+ ix = int(text)
+ except ValueError:
+ raise _GoodbyeError("Illegal number format", 1) from None
+ if ix not in range(1, len(plugins_by_ix) + 1):
+ raise _GoodbyeError("Illegal selection\n", 2) from None
+ return plugins_by_ix[ix]
+
+
def get_address_plugin(log, paths):
"""
Let user select address plugin.
@@ -158,8 +174,7 @@ def get_address_plugin(log, paths):
}
if ix in plugin_by_ix:
return plugin_by_ix[ix]
- else:
- raise _GoodbyeError("Illegal value", 1)
+ raise _GoodbyeError("Illegal value", 1) from None
def copy_systemd_units():
@@ -169,13 +184,23 @@ def copy_systemd_units():
user_dir = os.path.join(confdir, 'systemd/user')
if not os.path.exists(user_dir):
os.makedirs(user_dir)
+ here = os.path.abspath(os.path.dirname(os.path.realpath(__file__)))
+ srcdir = os.path.join(here, '..', '..', 'systemd')
+ if not os.path.exists(srcdir):
+ srcdir = "/usr/local/share/ddupdate/systemd"
+ if not os.path.exists(srcdir):
+ srcdir = "/usr/share/ddupdate/systemd"
+
path = os.path.join(user_dir, "ddupdate.service")
if not os.path.exists(path):
- shutil.copy("/usr/share/ddupdate/systemd/ddupdate.service", path)
+ shutil.copy(os.path.join(srcdir, "ddupdate.service"), path)
path = os.path.join(user_dir, "ddupdate.timer")
if not os.path.exists(path):
- shutil.copy("/usr/share/ddupdate/systemd/ddupdate.timer", path)
- here = os.path.abspath(os.path.dirname(os.path.realpath(__file__)))
+ shutil.copy(os.path.join(srcdir, "ddupdate.timer"), path)
+
+ # Ad-hoc logic: Use script in /usr/local/bin or /usr/bin if existing,
+ # else the one in current dir. This is practical although not quite
+ # consistent.
installconf_path = os.path.join(here, "install.conf")
parser = configparser.SafeConfigParser()
try:
@@ -187,18 +212,19 @@ def copy_systemd_units():
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")
+ lines = f.readlines()
+ output = []
+ for line in lines:
+ if line.startswith('ExecStart'):
+ output.append("ExecStart=" + bindir + "/ddupdate")
+ else:
+ output.append(line)
+ with open(os.path.join(user_dir, 'ddupdate.service'), 'w') as f:
+ f.write('\n'.join([elem.strip() for elem in output]))
def get_netrc(service):
- """
- Get .netrc line for service.
+ """Get .netrc line for service.
Looks into the service class documentation for a line starting
with 'machine' and returns it after substituting values in
@@ -225,38 +251,6 @@ def get_netrc(service):
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.
@@ -287,29 +281,46 @@ def update_config(config, path):
return f.name
-def write_config_files(config, netrc_line):
+def write_config_files(config):
"""
- Merge user config data into user config files.
+ Merge user config data into user config file.
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.
+ ~/.config/ddupdate.conf, respecting XDG_CONFIG_HOME.
"""
confdir = \
envvar_default('XDG_CONFIG_HOME', os.path.expanduser('~/.config'))
if not os.path.exists(confdir):
os.makedirs(confdir)
- 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])
+ dest = os.path.join(confdir, "ddupdate.conf")
+ tmp_conf = update_config(config, dest)
+ shutil.copyfile(tmp_conf, os.path.join(confdir, "ddupdate.conf"))
os.unlink(tmp_conf)
+ print("Patched config file: " + dest)
+
+
+def write_credentials(auth_plugin, hostname, netrc):
+ """Update credentials at auth_plugin with data from netrc."""
+ username = None
+ password = None
+ if not netrc:
+ print("NOTE: No credentials defined")
+ return
+ words = netrc.split(' ')
+ words = [word for word in words if word]
+ for i in range(0, len(words) - 1):
+ if words[i] == 'machine':
+ hostname = words[i + 1].lower()
+ if words[i] == 'login':
+ username = words[i + 1]
+ if words[i] == 'password':
+ password = words[i + 1]
+ auth_plugin.set_password(hostname, username, password)
+ print("Updated password for user %s at %s" % (username, hostname))
def try_start_service():
@@ -319,7 +330,7 @@ def try_start_service():
cmd += ';systemctl --user start ddupdate.service'
cmd += ';journalctl -l --user --since -60s -u ddupdate.service'
cmd = ['sh', '-c', cmd]
- subprocess.run(cmd)
+ subprocess.run(cmd, check=True)
print('Use "journalctl --user -u ddupdate.service" to display logs.')
@@ -331,12 +342,19 @@ def enable_service():
cmd = 'systemctl --user start ddupdate.timer'
cmd += ';systemctl --user enable ddupdate.timer'
print("\nStarting and enabling ddupdate.timer")
- subprocess.run(['sh', '-c', cmd])
+ try:
+ subprocess.run(['sh', '-c', cmd], check=True)
+ except subprocess.CalledProcessError as err:
+ raise _GoodbyeError(
+ "Cannot start ddupdate.timer: " + str(err), 2) from None
else:
cmd = 'systemctl --user stop ddupdate.timer'
cmd += 'systemctl --user disable ddupdate.timer'
print("Stopping ddupdate.timer")
- subprocess.run(['sh', '-c', cmd])
+ try:
+ subprocess.run(['sh', '-c', cmd], check=True)
+ except subprocess.CalledProcessError as err:
+ print("Cannot stop ddupdate.timer (already stopped?)")
msg = "systemctl --user start ddupdate.timer"
msg += "; systemctl --user enable ddupdate.timer"
print('\nStart ddupdate using "%s"' % msg)
@@ -347,21 +365,25 @@ def enable_service():
def main():
"""Indeed: main function."""
try:
- log = setup(logging.WARNING)[0]
+ log = log_setup()
check_existing_files()
copy_systemd_units()
load_paths = build_load_path(log)
service_plugins = _load_services(log, load_paths)
service = get_service_plugin(service_plugins)
netrc = get_netrc(service)
+ auth_plugins = _load_auth_plugins(log, load_paths)
+ auth_plugin = get_auth_plugin(auth_plugins)
hostname = input("[%s] hostname: " % service.name())
address_plugin = get_address_plugin(log, load_paths)
conf = {
'address-plugin': address_plugin,
'service-plugin': service.name(),
- 'hostname': hostname
+ 'hostname': hostname,
+ 'auth-plugin': auth_plugin.name()
}
- write_config_files(conf, netrc)
+ write_credentials(auth_plugin, hostname, netrc)
+ write_config_files(conf)
try_start_service()
enable_service()
except _GoodbyeError as err:
diff --git a/lib/ddupdate/ddplugin.py b/lib/ddupdate/ddplugin.py
index 458f7f1..5947c3b 100644
--- a/lib/ddupdate/ddplugin.py
+++ b/lib/ddupdate/ddplugin.py
@@ -19,13 +19,11 @@ 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()
@@ -34,15 +32,19 @@ auth_plugin = None
def set_auth_plugin(plugin):
- """ Define the actual AuthPlugin used. """
+ """Define the actual AuthPlugin used."""
+ # pylint: disable=global-statement
+ # See #63
global auth_plugin
auth_plugin = plugin
def get_auth_plugin():
- """ Return actual AuthPlugin used. """
+ """Return actual AuthPlugin used."""
return auth_plugin
+# pylint: disable=duplicate-code
+
def http_basic_auth_setup(url, host=None):
"""
@@ -95,6 +97,7 @@ def get_response(log, url, **kwargs):
- data: dict of post data. If data != None, get_response makes a
http POST request, otherwise a http GET.
- timeout: int, timeout in seconds. Defaults to 120.
+ - header: a (header, contents) tuple like ('api-key', 'xxxx')
Returns:
- Text read from url.
Raises:
@@ -107,13 +110,16 @@ def get_response(log, url, **kwargs):
if data:
log.debug("Posting data: " + data.decode('ascii'))
try:
- with urllib.request.urlopen(url, data, timeout=to) as response:
+ request = urllib.request.Request(url)
+ if 'header' in kwargs:
+ request.add_header(*kwargs['header'])
+ with urllib.request.urlopen(request, data, timeout=to) as response:
code = response.getcode()
html = response.read().decode('ascii')
except timeoutError:
- raise ServiceError("Timeout reading %s" % url)
+ raise ServiceError("Timeout reading %s" % url) from None
except (urllib.error.HTTPError, urllib.error.URLError) as err:
- raise ServiceError("Error reading %s :%s" % (url, err))
+ raise ServiceError("Error reading %s :%s" % (url, err)) from err
log.debug("Got response (%d) : %s", code, html)
if code != 200:
raise ServiceError("Cannot update, response code: %d" % code)
@@ -121,23 +127,21 @@ def get_response(log, url, **kwargs):
def get_netrc_auth(machine):
- """
- Retrieve data from ~/-netrc or /etc/netrc.
+ """Retrieve data from configured credentials source.
+
+ The function name is thus misleading but kept for legacy reasons.
Parameters:
- - machine: key while searching in netrc file.
+ - machine: key used to look up credentials.
Returns:
- A (user, password) tuple. User might be None.
Raises:
- - ServiceError if .netrc or password is not found.
- See:
- - netrc(5)
-
+ -AuthError if credentials cannot be retrieved.
"""
- return auth_plugin.get_auth(machine)
+ return auth_plugin.get_auth(machine.lower())
-class IpAddr(object):
+class IpAddr:
"""A (ipv4, ipv6) container."""
def __init__(self, ipv4=None, ipv6=None):
@@ -178,7 +182,7 @@ class IpAddr(object):
"""
for line in text.split('\n'):
- words = [ word for word in line.split(' ') if word != '' ]
+ words = [word for word in line.split(' ') if word != '']
if words[0] == 'inet':
# use existing logic
self.v4 = words[1].split('/')[0]
@@ -188,7 +192,7 @@ class IpAddr(object):
continue
addr = words[1].split('/')[0]
words = set(words[2:])
- if ('link' in words) or ('0x20<link>' in words) :
+ if ('link' in words) or ('0x20<link>' in words):
# don't use a link-local address
continue
if 'deprecated' in words:
@@ -223,20 +227,21 @@ class AddressError(Exception):
class ServiceError(AddressError):
"""General error in ServicePlugin."""
- pass
class AuthError(AddressError):
"""General error in AuthPlugin."""
- pass
-
-class AbstractPlugin(object):
+class AbstractPlugin:
"""Abstract base for all plugins."""
_name = None
_oneliner = 'No info found'
- __version__ = '0.7.0'
+ __version__ = '0.7.1'
+
+ def __str__(self): # pylint: disable=invalid-str-returned
+ """Standard implementation."""
+ return self.name()
def oneliner(self):
"""Return oneliner describing the plugin."""
@@ -316,11 +321,10 @@ class ServicePlugin(AbstractPlugin):
class AuthPlugin(AbstractPlugin):
- """ Abstract plugin for managing credentials for a hostname. """
+ """Abstract plugin for managing credentials for a hostname."""
def get_auth(self, machine):
- """
- Retrieve credentials for a machine
+ """Retrieve credentials for a machine.
Parameters:
- machine: Key while searching for credentials.
diff --git a/lib/ddupdate/main.py b/lib/ddupdate/main.py
index 8667905..9dcd3a8 100755
--- a/lib/ddupdate/main.py
+++ b/lib/ddupdate/main.py
@@ -4,6 +4,7 @@ import argparse
import configparser
import glob
import importlib
+import importlib.util
import inspect
import logging
import math
@@ -14,15 +15,11 @@ import sys
import time
import ast
+
from ddupdate.ddplugin import AddressPlugin, AddressError
from ddupdate.ddplugin import ServicePlugin, ServiceError, IpAddr
-from ddupdate.ddplugin import AuthPlugin, AuthError, set_auth_plugin, get_auth_plugin
-
-# pylint: disable=ungrouped-imports
-if sys.version_info >= (3, 5):
- import importlib.util
-else:
- from importlib.machinery import SourceFileLoader
+from ddupdate.ddplugin import AuthPlugin, AuthError
+from ddupdate.ddplugin import set_auth_plugin, get_auth_plugin
if 'XDG_CACHE_HOME' in os.environ:
@@ -54,8 +51,7 @@ class _GoodbyeError(Exception):
class _SectionFailError(Exception):
- """General error, terminates section processing"""
- pass
+ """General error, terminates section processing."""
def envvar_default(var, default=None):
@@ -122,8 +118,7 @@ def parse_conffile(log):
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]
+ for i, arg in enumerate(sys.argv):
if arg.startswith('-c') or arg.startswith('--conf'):
if arg.startswith('-c') and len(arg) > 2:
path = arg[2:]
@@ -140,17 +135,19 @@ def parse_conffile(log):
return path
-def parse_config(config, section, log):
- """Return dict with values from config backed by DEFAULTS"""
+def parse_config(config, section):
+ """Return dict with values from config backed by DEFAULTS."""
results = {}
- if not section in config:
+ if section not in config:
raise _GoodbyeError("No such section: " + section, 2)
items = config[section]
- for key in DEFAULTS:
- results[key] = items[key] if key in items else DEFAULTS[key]
+ for key, value in DEFAULTS.items():
+ results[key] = items[key] if key in items else value
return results
+
def get_config(log):
+ """Parse config file, return a (ConfigParser, list of sections) tuple."""
path = parse_conffile(log)
config = configparser.ConfigParser()
config.read(path)
@@ -232,12 +229,12 @@ def get_parser(conf):
help='List configuration file sections. ',
default=False, action='store_true')
others.add_argument(
- "-e", "--execute-section", metavar="section",
+ "-e", "--execute-section", metavar="section",
help='Update a given configuration file section [all sections]',
dest='execute_section', default='')
others.add_argument(
"-p", "--set_password", nargs=3, metavar=('host', 'user', 'pw'),
- help='Update username/password credentials for host. Use "" for empty username',
+ help='Update username/password for host. Use "" for empty username',
default="")
others.add_argument(
"-f", "--force",
@@ -264,7 +261,7 @@ def parse_options(conf):
'debug': logging.DEBUG,
}
parser = get_parser(conf)
- parser.version = "0.7.0"
+ parser.version = "0.7.1"
opts = parser.parse_args()
if opts.help == '-':
parser.print_help()
@@ -295,6 +292,7 @@ def log_setup():
def log_init(log, loglevel, opts):
+ """Initiate the global log."""
log.handlers[0].setLevel(loglevel if loglevel else opts.loglevel)
log.debug('Using config file: %s', parse_conffile(log))
log.info("Loglevel: " + logging.getLevelName(opts.loglevel))
@@ -312,12 +310,9 @@ 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()
+ spec = importlib.util.spec_from_file_location(name, path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
return module
@@ -372,25 +367,22 @@ def list_plugins(plugins):
print("%-20s %s" % (name, plugin.oneliner()))
-def plugin_help(auth_plugins, ip_plugins, service_plugins, plugid):
+def plugin_help(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]
- elif plugid in auth_plugins:
- plugin = auth_plugins[plugid]
+ if plugid in plugins:
+ plugin = plugins[plugid]
else:
- raise _GoodbyeError("No help found (nu such plugin?): " + plugid, 1)
- print("Name: " + plugin.name())
+ raise _GoodbyeError("No help found (no such plugin?): " + plugid, 1)
+ print("Name: " + str(plugin))
print("Source file: " + plugin.module.__file__ + "\n")
print(plugin.info())
def set_password(opts):
+ """Set password using selected auth plugin."""
auth_plugin = get_auth_plugin()
- args = opts.set_password
- auth_plugin.set_auth(args[0], args[1], args[2])
+ auth_plugin.set_password(*opts.set_password)
+
def filter_ip(ip_version, ip):
"""Filter the ip address to match the --ip-version option."""
@@ -424,6 +416,7 @@ def get_plugins(opts, log, sections):
Return: (auth_plugin, ip plugin, service plugin) tuple.
"""
+ # pylint: disable=too-many-branches,too-many-locals
ip_plugins = {}
service_plugins = {}
auth_plugins = {}
@@ -445,7 +438,8 @@ def get_plugins(opts, log, sections):
list_plugins(auth_plugins)
raise _GoodbyeError()
if opts.help and opts.help != '-':
- plugin_help(auth_plugins, ip_plugins, service_plugins, opts.help)
+ all_plugins = {**auth_plugins, **ip_plugins, **service_plugins}
+ plugin_help(all_plugins, opts.help)
raise _GoodbyeError()
if opts.list_sections:
print("\n".join(sections))
@@ -453,11 +447,11 @@ def get_plugins(opts, log, sections):
if opts.ip_plugin:
raise _GoodbyeError(
"--ip-plugin has been replaced by --address-plugin.")
- elif opts.address_plugin not in ip_plugins:
+ if opts.address_plugin not in ip_plugins:
raise _GoodbyeError('No such ip plugin: ' + opts.address_plugin, 2)
- elif opts.auth_plugin not in auth_plugins:
+ if opts.auth_plugin not in auth_plugins:
raise _GoodbyeError('No such auth plugin: ' + opts.auth_plugin, 2)
- elif opts.service_plugin not in service_plugins:
+ if opts.service_plugin not in service_plugins:
raise _GoodbyeError(
'No such service plugin: ' + opts.service_plugin, 2)
service_plugin = service_plugins[opts.service_plugin]
@@ -471,11 +465,12 @@ def get_plugins(opts, log, sections):
def get_ip(ip_plugin, opts, log):
- """ Try to get current ip address using the ip_plugin"""
+ """Try to get current ip address using the ip_plugin."""
try:
ip = ip_plugin.get_ip(log, opts.address_options)
except AddressError as err:
- raise _SectionFailError("Cannot obtain ip address: " + str(err))
+ raise _SectionFailError("Cannot obtain ip address: " + str(err)) \
+ from err
if not ip or ip.empty():
log.info("Using ip address provided by update service")
ip = None
@@ -486,7 +481,7 @@ def get_ip(ip_plugin, opts, log):
def check_ip_cache(ip, service_plugin, opts, log):
- """ Throw a _SectionFailError if ip is already in a fresh cache."""
+ """Throw a _SectionFailError if ip is already in a fresh cache."""
if opts.force:
ip_cache_clear(opts, log)
cached_ip, age = ip_cache_data(opts, log)
@@ -498,7 +493,6 @@ def check_ip_cache(ip, service_plugin, opts, log):
def main():
"""Indeed: main function."""
-
try:
log = log_setup()
config, sections = get_config(log)
@@ -508,26 +502,26 @@ def main():
sections = [opts.execute_section]
for section in sections:
try:
- conf = parse_config(config, section, log)
+ conf = parse_config(config, section)
opts = parse_options(conf)
log_init(log, None, opts)
- log.info("Processing configuration section: " + section)
+ log.info("Processing configuration section: %s", section)
auth_plugin, ip_plugin, service_plugin = get_plugins(
opts, log, sections)
set_auth_plugin(auth_plugin)
- log.debug("Using auth plugin: " + auth_plugin.name())
+ log.debug("Using auth plugin: %s", str(auth_plugin))
ip = get_ip(ip_plugin, opts, log)
check_ip_cache(ip, service_plugin, opts, log)
service_plugin.register(
- log, opts.hostname, ip, opts.service_options)
+ log, opts.hostname, ip, opts.service_options)
ip_cache_set(opts, ip)
log.info("Update OK")
except _SectionFailError:
- print("Skipping config section: " + section)
+ print("Skipping config section: %s" % section)
continue
except (ServiceError, AuthError) as err:
log.error("Cannot update DNS data: %s", err)
- log.info("Skipping config section: " + section)
+ log.info("Skipping config section: %s", section)
continue
except _GoodbyeError as err:
if err.exitcode != 0:
diff --git a/lib/ddupdate/netrc_to_keyring.py b/lib/ddupdate/netrc_to_keyring.py
index 8ec487c..4b6ac42 100755
--- a/lib/ddupdate/netrc_to_keyring.py
+++ b/lib/ddupdate/netrc_to_keyring.py
@@ -1,14 +1,20 @@
-import keyring
+"""Simple tools to migrate credentials from ~/.netrc to the keyring.
+"""
+
import netrc
-import sys
+
+import keyring
+
def main():
+ """Indeed: main function."""
_netrc = netrc.netrc()
for host in _netrc.hosts:
- login, account, password = _netrc.authenticators(host)
+ login, _, password = _netrc.authenticators(host)
print(host)
credentials = "{0}\t{1}".format((login or 'api_key'), password)
keyring.set_password('ddupdate', host, credentials)
+
if __name__ == '__main__':
main()
diff --git a/plugins/default_if.py b/plugins/addr_default_ip.py
similarity index 77%
rename from plugins/default_if.py
rename to plugins/addr_default_ip.py
index 6b8727c..cda9a24 100644
--- a/plugins/default_if.py
+++ b/plugins/addr_default_ip.py
@@ -9,6 +9,17 @@ import subprocess
from ddupdate.ddplugin import AddressPlugin, AddressError, IpAddr
+def find_device(words):
+ """Return first word following 'dev' or None."""
+ found = False
+ for word in words:
+ if word == "dev":
+ found = True
+ elif found:
+ return word
+ return None
+
+
class DefaultIfPLugin(AddressPlugin):
"""
Locates the default interface.
@@ -22,16 +33,6 @@ class DefaultIfPLugin(AddressPlugin):
_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.
@@ -40,7 +41,7 @@ class DefaultIfPLugin(AddressPlugin):
for line in subprocess.getoutput('ip route').split('\n'):
words = line.split()
if words[0] == 'default':
- if_ = self.find_device(words)
+ if_ = find_device(words)
break
if if_ is None:
raise AddressError("Cannot find default interface, giving up")
diff --git a/plugins/default_web.py b/plugins/addr_default_web.py
similarity index 99%
rename from plugins/default_web.py
rename to plugins/addr_default_web.py
index be221c9..61ec4f2 100644
--- a/plugins/default_web.py
+++ b/plugins/addr_default_web.py
@@ -38,7 +38,7 @@ class DefaultWebPlugin(AddressPlugin):
try:
with urllib.request.urlopen(url) as response:
html = response.read().decode('utf-8')
- except (urllib.error.HTTPError, urllib.error.URLError) as err:
+ except (urllib.error.HTTPError, urllib.error.URLError):
log.debug("Bad response at %s (ignored)" % url)
return None
log.debug("Got response: %s", html)
diff --git a/plugins/default_web6.py b/plugins/addr_default_web6.py
similarity index 100%
rename from plugins/default_web6.py
rename to plugins/addr_default_web6.py
diff --git a/plugins/dnshome_de_addr.py b/plugins/addr_dnshome_de.py
similarity index 84%
rename from plugins/dnshome_de_addr.py
rename to plugins/addr_dnshome_de.py
index 9802700..fc83ae7 100644
--- a/plugins/dnshome_de_addr.py
+++ b/plugins/addr_dnshome_de.py
@@ -12,22 +12,25 @@ from typing import AnyStr, Optional
from enum import Enum
from logging import Logger
-from ddupdate.ddplugin import http_basic_auth_setup, get_response
from ddupdate.ddplugin import AddressPlugin, IpAddr
TIMEOUT = 20
+
class DeDnshomeAddressURL(Enum):
"""Enumeration of the available *.dnshome.de ip-resolver urls."""
+
IP4 = 'https://ip4.dnshome.de'
IP6 = 'https://ip6.dnshome.de'
class DeDnshomeWebPlugin(AddressPlugin):
"""Get the external IPv4 and/or IPv6 address as seen from ip.dnshome.de.
- Depending on the type of your connection one or the other address may be `None`.
- Also the presence of an IPv4 address does not guarantee that inbound connections
- can be instantiated from external endpoints (see: DS-Lite and IPv6 tunneling).
+
+ Depending on the type of your connection one or the other address may
+ be `None`. Also the presence of an IPv4 address does not guarantee that
+ inbound connections can be instantiated from external endpoints (see:
+ DS-Lite and IPv6 tunneling).
Relies on [ip4|ip6].dnshome.de
@@ -36,12 +39,14 @@ class DeDnshomeWebPlugin(AddressPlugin):
"""
_name = 'ip.dnshome.de'
- _oneliner = 'Obtain external IPv4 and/or IPv6 address as seen by dnshome.de'
+ _oneliner = 'Obtain IPv4 and/or IPv6 address as seen by dnshome.de'
@staticmethod
def extract_ip(data: AnyStr) -> IpAddr:
- """Extracts the IPs from data
- Expects `data` to be an UTF-8 string holding either an single IPv4 or an IPv6 address.
+ """Extracts the IPs from data.
+
+ Expects `data` to be an UTF-8 string holding either an single
+ IPv4 or an IPv6 address.
Args:
data: Data to extract the IP from
@@ -49,7 +54,6 @@ class DeDnshomeWebPlugin(AddressPlugin):
Returns:
An `IpAddr` which may hold the IPv4 or IPv6 Address found.
"""
-
try:
ip = ipaddress.ip_address(data.strip())
@@ -57,13 +61,14 @@ class DeDnshomeWebPlugin(AddressPlugin):
return IpAddr(ip.exploded, None)
if isinstance(ip, ipaddress.IPv6Address):
return IpAddr(None, ip.exploded)
-
+ return IpAddr(None, None)
except ValueError:
return IpAddr(None, None)
@staticmethod
def load_ip(log: Logger, url: str) -> Optional[IpAddr]:
- """Loads the external IP from an remote Endpoint (url)
+ """Loads the external IP from an remote Endpoint (url).
+
Expects the Endpoint to respond with an UTF-8-String containing the IP.
Args:
@@ -73,7 +78,6 @@ class DeDnshomeWebPlugin(AddressPlugin):
Returns:
An `IPAddr` holding the IPv4 and/or IPv6 Address found or `None`.
"""
-
log.debug('loading ip from %s' % url)
try:
@@ -94,8 +98,7 @@ class DeDnshomeWebPlugin(AddressPlugin):
return result
def get_ip(self, log: Logger, options: [str]) -> Optional[IpAddr]:
- """Implements AddressPlugin.get_ip()"""
-
+ """Implements AddressPlugin.get_ip()."""
urls = [DeDnshomeAddressURL.IP4, DeDnshomeAddressURL.IP6]
ip = IpAddr(None, None)
diff --git a/plugins/hardcoded_if.py b/plugins/addr_hardcoded_if.py
similarity index 90%
rename from plugins/hardcoded_if.py
rename to plugins/addr_hardcoded_if.py
index 88b9dc3..b2d29e3 100644
--- a/plugins/hardcoded_if.py
+++ b/plugins/addr_hardcoded_if.py
@@ -5,9 +5,8 @@ See: ddupdate(8)
"""
import subprocess
-import sys
-from ddupdate.ddplugin import AddressPlugin, IpAddr, dict_of_opts
+from ddupdate.ddplugin import AddressPlugin, AddressError, IpAddr, dict_of_opts
class HardcodedIfPlugin(AddressPlugin):
diff --git a/plugins/hardcoded_ip.py b/plugins/addr_hardcoded_ip.py
similarity index 82%
rename from plugins/hardcoded_ip.py
rename to plugins/addr_hardcoded_ip.py
index 2cfb589..9b895e9 100644
--- a/plugins/hardcoded_ip.py
+++ b/plugins/addr_hardcoded_ip.py
@@ -4,9 +4,7 @@ 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
+from ddupdate.ddplugin import AddressPlugin, AddressError, IpAddr, dict_of_opts
class HardcodedIfPlugin(AddressPlugin):
@@ -27,7 +25,7 @@ class HardcodedIfPlugin(AddressPlugin):
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.')
+ 'Required option ip= or ip6= missing, giving up.')
if 'ip' in opts:
addr.v4 = opts['ip']
if 'ip6' in opts:
diff --git a/plugins/ip_disabled.py b/plugins/addr_ip_disabled.py
similarity index 100%
rename from plugins/ip_disabled.py
rename to plugins/addr_ip_disabled.py
diff --git a/plugins/ip_from_cmd.py b/plugins/addr_ip_from_cmd.py
similarity index 100%
rename from plugins/ip_from_cmd.py
rename to plugins/addr_ip_from_cmd.py
diff --git a/plugins/onhub.py b/plugins/addr_onhub.py
similarity index 91%
rename from plugins/onhub.py
rename to plugins/addr_onhub.py
index d1011ff..2ea3e3e 100644
--- a/plugins/onhub.py
+++ b/plugins/addr_onhub.py
@@ -28,7 +28,7 @@ class OnHubPlugin(AddressPlugin):
"""Implement AddressPlugin.get_ip()."""
# Documentation refers to testing on 3.4
# f-strings are from 3.6 and exception chaining from 3.9
- # pylint: disable=consider-using-f-string,raise-missing-from
+ # pylint: disable=raise-missing-from
log.debug("trying " + _URL)
try:
with urllib.request.urlopen(_URL) as response:
@@ -43,5 +43,4 @@ class OnHubPlugin(AddressPlugin):
log.debug("WAN online: %s", status["wan"]["online"])
- # TODO: Can we also get an external IPv6 address?
return IpAddr(status["wan"]["localIpAddress"])
diff --git a/plugins/auth_keyring.py b/plugins/auth_keyring.py
index b49e8e4..7b6ae61 100644
--- a/plugins/auth_keyring.py
+++ b/plugins/auth_keyring.py
@@ -1,5 +1,4 @@
-"""
-Implement credentials lookup using python3-keyring.
+"""Implement credentials lookup using python3-keyring.
The keyring just provides a basic username -> password lookup. However,
the get_auth() call should possibly return both username and password
@@ -10,41 +9,61 @@ For hosts using just an api key i. e., without a username the username
field is set to 'api-key'
"""
+
KEYRING_MISSING_MSG = """
python keyring module not found. Please install python3-keyring
using package manager or the keyring package using pip.
"""
-from ddupdate.ddplugin import AuthPlugin, AuthError
+# pylint: disable=wrong-import-position
+from ddupdate.ddplugin import AuthPlugin, AuthError
try:
import keyring
+ import keyring.errors
except (ModuleNotFoundError, ImportError):
- raise(AuthError(KEYRING_MISSING_MSG))
+ raise AuthError(KEYRING_MISSING_MSG) from None
+
class AuthKeyring(AuthPlugin):
- """ Implement credentials lookup using python3-keyring """
+ """Implement credentials lookup using python3-keyring.
+
+ This is a reasonably secure way to handle the passwords. Before actually
+ accessing the passwords the keyring must be unlocked. This makes this
+ backend less suited to servers but is no problem on for example a
+ notebook.
+
+ Prior to 0.7.1 all passwords was stored in the .netrc file. See the
+ ddupdate-netrc-to-keyring tool for migrating passwords from .netrc to
+ the keyring backend.
+ """
_name = 'keyring'
- _oneliner = 'Get credentials stored in the system keyring'
- __version__ = '0.7.0'
+ _oneliner = 'Store credentials in the system keyring'
+ __version__ = '0.7.1'
def get_auth(self, machine):
+ """Implement AuthPlugin::get_auth()."""
try:
- credentials = keyring.get_password('ddupdate', machine).split('\t')
- except KeyringError:
- raise AuthError("Cannot obtain credentials for: " + machine)
+ credentials = keyring.get_password('ddupdate', machine.lower())
+ if not credentials:
+ raise AuthError("Cannot get authentication for: " + machine)
+ credentials = credentials.split('\t')
+ except keyring.errors.KeyringError as err:
+ raise AuthError("Cannot obtain credentials for: " + machine) \
+ from err
if len(credentials) != 2:
raise AuthError("Cannot parse credentials for: " + machine)
if credentials[0] == 'api-key':
credentials[0] = None
return credentials[0], credentials[1]
- def set_auth(self, machine, username, password):
+ def set_password(self, machine, username, password):
+ """Implement AuthPlugin::set_password()."""
if not username:
username = 'api-key'
credentials = username + '\t' + password
try:
- keyring.set_password('ddupdate', machine, credentials)
- except KeyringError:
- raise AuthError("Cannot set credentials for: " + machine)
+ keyring.set_password('ddupdate', machine.lower(), credentials)
+ except keyring.errors.KeyringError as err:
+ raise AuthError("Cannot set credentials for: " + machine) from err
diff --git a/plugins/auth_netrc.py b/plugins/auth_netrc.py
index 77f7835..8584c05 100644
--- a/plugins/auth_netrc.py
+++ b/plugins/auth_netrc.py
@@ -1,21 +1,28 @@
"""
-Implement credentials lookup using the ~/.netrc(5) file
+Implement credentials lookup using the ~/.netrc(5) file.
"""
-
+import base64
+import binascii
from netrc import netrc
import os.path
from ddupdate.ddplugin import AuthPlugin, AuthError
+
class AuthNetrc(AuthPlugin):
- """ Get credentials stored in the .netrc(5) file """
- _name = 'netrc'
- _oneliner = 'Get credentials using .netrc'
- __version__ = '0.7.0'
+ """Get credentials stored in the .netrc(5) file.
+ This is the original storage used before 0.7.1. It is less secure
+ than for example the keyring but is convenient and, since it does
+ not require anything to be unlocked, a good candidate for servers.
+ """
- def get_auth(self, machine):
+ _name = 'netrc'
+ _oneliner = 'Store credentials in .netrc(5)'
+ __version__ = '0.7.1'
+ def get_auth(self, machine):
+ """Implement AuthPlugin::get_auth()."""
if os.path.exists(os.path.expanduser('~/.netrc')):
path = os.path.expanduser('~/.netrc')
elif os.path.exists('/etc/netrc'):
@@ -27,45 +34,40 @@ class AuthNetrc(AuthPlugin):
raise AuthError("No .netrc data found for " + machine)
if not auth[2]:
raise AuthError("No password found for " + machine)
- return auth[0], auth[2]
-
+ try:
+ pw = base64.b64decode(auth[2]).decode('ascii')
+ except (binascii.Error, UnicodeDecodeError):
+ pw = auth[2]
+ return auth[0], pw
- def set_auth(self, machine, username, password):
+ def set_password(self, machine, username, password):
+ """Implement AuthPlugin::set_password()."""
- def update(lines):
- """ Either update existing line matching machine or add a new """
- line_found = False
- new_lines = []
- for line in lines:
- words = line.split(' ')
- for i in range(0, len(words) - 1):
- if words[i] == 'machine' and words[i+1] == machine:
- line_found = True
- if not line_found:
- new_lines.append(line)
- continue
- for i in range(0, len(words) - 1):
- if words[i] == 'password':
- words[i+1] = password
- if words[i] == 'login' and username:
- words[i+1] = username
- new_lines.append(' '.join(words))
- if not line_found:
- line = 'machine ' + machine
- if username:
- line += ' login ' + username
- line += ' password ' + password
- new_lines.append(line)
- return new_lines
+ def is_matching_entry(line):
+ """Return True if line contains 'machine' machine'."""
+ words = line.split(' ')
+ for i in range(0, len(words) - 1):
+ if words[i] == 'machine' \
+ and words[i + 1].lower() == machine.lower():
+ return True
+ return False
- if os.path.exists(os.path.expanduser('~/.netrc')):
- path = os.path.expanduser('~/.netrc')
- elif os.path.exists('/etc/netrc'):
- path = '/etc/netrc'
- else:
- raise AuthError("Cannot locate the netrc file (see manpage).")
- with open(path, 'r') as f:
- lines = f.readlines()
- lines = update(lines)
+ def new_entry():
+ """Return new entry."""
+ pw = base64.b64encode(password.encode('utf-8')).decode('ascii')
+ line = 'machine ' + machine.lower()
+ if username:
+ line += ' login ' + username
+ line += ' password ' + pw
+ return line
+
+ path = os.path.expanduser('~/.netrc')
+ lines = []
+ if os.path.exists(path):
+ with open(path, 'r') as f:
+ lines = f.readlines()
+ lines = [line for line in lines if not is_matching_entry(line)]
+ lines.append(new_entry())
+ lines = [line.strip() + "\n" for line in lines]
with open(path, 'w') as f:
f.writelines(lines)
diff --git a/plugins/changeip.py b/plugins/changeip.py
index adc7528..bed4c2a 100644
--- a/plugins/changeip.py
+++ b/plugins/changeip.py
@@ -38,5 +38,5 @@ class ChangeAddressPlugin(ServicePlugin):
url += "&ip=" + ip.v4
http_basic_auth_setup(url)
html = get_response(log, url)
- if not 'uccessful' in html:
+ if 'uccessful' not in html:
raise ServiceError("Bad update reply: " + html)
diff --git a/plugins/cloudflare.py b/plugins/cloudflare.py
index 90de367..bed0735 100644
--- a/plugins/cloudflare.py
+++ b/plugins/cloudflare.py
@@ -12,17 +12,18 @@ The Cloudflare plugin uses the python3-requests package which cannot be found.
Please install python-requests or python3-requests. Giving up.
"""
+# pylint: disable=wrong-import-position
+from ddupdate.ddplugin import ServicePlugin, ServiceError
+from ddupdate.ddplugin import get_netrc_auth, dict_of_opts
+
try:
from requests import Request, Session
from requests.auth import AuthBase
except (ImportError, ModuleNotFoundError):
import sys
- print(REQUESTS_NOT_FOUND, file = sys.stderr)
+ 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."""
@@ -40,7 +41,7 @@ def _call(session, request):
return json['result']
except ValueError as err:
raise ServiceError("Error parsing response %s: %s" %
- (request.url, err))
+ (request.url, err)) from None
def _get_ipv4_from_dnsrecords(dnsrecords):
@@ -73,6 +74,7 @@ class CloudflareAuth(AuthBase):
Request object.
"""
+ # pylint: disable=too-few-public-methods
def __init__(self, email, auth_key):
"""Email and auth_key are required."""
self.email = email
diff --git a/plugins/desec_io.py b/plugins/desec_io.py
new file mode 100644
index 0000000..78b31c0
--- /dev/null
+++ b/plugins/desec_io.py
@@ -0,0 +1,44 @@
+"""
+ddupdate plugin updating data on desec.io.
+
+See: ddupdate(8)
+See: https://desec.readthedocs.io/en/latest/dyndns/update-api.html
+"""
+
+from ddupdate.ddplugin import ServicePlugin, ServiceError
+from ddupdate.ddplugin import get_response, get_netrc_auth
+
+
+class DesecPlugin(ServicePlugin):
+ """
+ Update a dns entry on https://desec.io.
+
+ Supports most address plugins including default-web-ip, default-if and
+ ip-disabled. ipv6 is supported.
+
+ The site also supports several ways to authenticate. This plugin only
+ supports using the API token which is created during registration.
+
+ netrc: Use a line like
+ machine update.dedyn.io password <API token>
+
+ Options:
+ none
+ """
+
+ _name = 'desec.io'
+ _oneliner = 'Updates on http://desec.io/'
+ _url = "https://update.dedyn.io/?hostname={0}"
+
+ def register(self, log, hostname, ip, options):
+ """Implement ServicePlugin.register()."""
+ url = self._url.format(hostname)
+ if ip.v4:
+ url += "&myipv4=" + ip.v4
+ if ip.v6:
+ url += "&myipv6=" + ip.v6
+ password = get_netrc_auth(hostname)[1]
+ hdr = ('Authorization', 'Token ' + password)
+ reply = get_response(log, url, header=hdr)
+ if not ('good' in reply or 'throttled' in reply):
+ raise ServiceError("Cannot update address: " + reply)
diff --git a/plugins/dns_o_matic.py b/plugins/dns_o_matic.py
new file mode 100644
index 0000000..2e33245
--- /dev/null
+++ b/plugins/dns_o_matic.py
@@ -0,0 +1,49 @@
+"""
+ddupdate plugin updating data on https://www.dnsomatic.com/.
+
+See: ddupdate(8)
+See: https://now-dns.com/?p=clients
+
+"""
+import base64
+from urllib.parse import urlparse
+
+from ddupdate.ddplugin import ServicePlugin, ServiceError, \
+ get_response, get_netrc_auth
+
+
+class DnsOMaticPlugin(ServicePlugin):
+ """
+ Update a dns entry on https://www.dnsomatic.com.
+
+ The hostname is the configured hostname in dns-o-matic. Common usecase
+ is to set hostname=all.dnsomatic.com which will update all hosts.
+
+ Only supports setting the address corresponding to default-web-ip or
+ ip-disabled, service does not allow setting address to anything else.
+ ipv6 is not supported.
+
+ netrc: Use a line like
+ machine updates.dnsomatic.com login <username> password <password>
+
+ Options:
+ None
+ """
+
+ _name = 'dns-o-matic.com'
+ _oneliner = 'Updates on http://dnsomatic.com'
+ _url = 'https://updates.dnsomatic.com/nic/update?hostname={0}'
+
+ def register(self, log, hostname, ip, options):
+ """Implement ServicePlugin.register()."""
+ url = self._url.format(hostname)
+ if ip:
+ url += '&myip=' + ip.v4
+ api_host = urlparse(url).hostname
+ username, password = get_netrc_auth(api_host)
+ user_pw = ('%s:%s' % (username, password))
+ credentials = base64.b64encode(user_pw.encode('ascii'))
+ auth_header = ('Authorization', 'Basic ' + credentials.decode("ascii"))
+ reply = get_response(log, url, header=auth_header)
+ if not ('good' in reply or 'nochg' in reply):
+ raise ServiceError('Bad server reply: ' + reply)
diff --git a/plugins/dnsdynamic_org.py b/plugins/dnsdynamic_org.py
deleted file mode 100644
index 897978b..0000000
--- a/plugins/dnsdynamic_org.py
+++ /dev/null
@@ -1,38 +0,0 @@
-"""
-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 <username> password <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)
diff --git a/plugins/dnsexit.py b/plugins/dnsexit.py
index d7587a1..ff2573e 100644
--- a/plugins/dnsexit.py
+++ b/plugins/dnsexit.py
@@ -34,7 +34,7 @@ class DnsexitPlugin(ServicePlugin):
_name = 'dnsexit.com'
_oneliner = 'Updates on https://www.dnsexit.com'
- _update_host = 'http://update.dnsexit.com'
+ _api_host = 'https://update.dnsexit.com'
_url = '{0}/RemoteUpdate.sv?login={1}&password={2}&host={3}'
_ip_warning = \
"service is not known to provide an address, use another ip plugin"
@@ -44,8 +44,7 @@ class DnsexitPlugin(ServicePlugin):
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)
+ url = self._url.format(self._api_host, user, password, hostname)
if ip:
url += "&myip=" + ip.v4
# if debugging:
diff --git a/plugins/dnshome_de_srvc.py b/plugins/dnshome_de_srvc.py
index 206af94..2081415 100644
--- a/plugins/dnshome_de_srvc.py
+++ b/plugins/dnshome_de_srvc.py
@@ -5,18 +5,18 @@ See: ddupdate(8)
See: https://www.dnshome.de/
"""
-from typing import AnyStr, Optional
+from typing import AnyStr
from logging import Logger
from ddupdate.ddplugin import ServicePlugin, ServiceError
-from ddupdate.ddplugin import http_basic_auth_setup, get_response
-from ddupdate.ddplugin import AddressPlugin, IpAddr
+from ddupdate.ddplugin import http_basic_auth_setup, get_response, IpAddr
class DeDnsHomeAddressPlugin(ServicePlugin):
"""Update a dns entry on dnshome.de.
- Supports using most address plugins including default-web-ip, default-if and ip-disabled.
+ Supports using most address plugins including default-web-ip, default-if
+ and ip-disabled.
You cannot set the host explicitly using a parameter like `hostname`.
Even though the hostname is included in the query, it simply gets ignored.
@@ -38,7 +38,7 @@ class DeDnsHomeAddressPlugin(ServicePlugin):
@staticmethod
def is_success(response: AnyStr) -> bool:
- """Checks if the action was successful using the response
+ """Checks if the action was successful using the response.
Args:
response: The response-body to analyze.
@@ -46,14 +46,16 @@ class DeDnsHomeAddressPlugin(ServicePlugin):
Returns:
true, if the response-body starts with
'good' - Update was successful
- 'nochg' - No change was performed, since records were already up to date.
+ 'nochg' - No change was performed, since records were
+ already up to date.
"""
-
return response.startswith('good') or response.startswith('nochg')
def register(self, log: Logger, hostname: str, ip: IpAddr, options):
"""Implement ServicePlugin.register.
- Expects the `ip` to be filtered already according to the _global_ `--ip-version` option.
+
+ Expects the `ip` to be filtered already according to the _global_
+ `--ip-version` option.
"""
url = self._url.format(hostname)
diff --git a/plugins/duiadns.py b/plugins/duiadns.py
index 9030ef8..861bd2d 100644
--- a/plugins/duiadns.py
+++ b/plugins/duiadns.py
@@ -5,19 +5,36 @@ See: ddupdate(8)
See: https://www.duiadns.net/duiadns-url-update
"""
+
+REQUESTS_NOT_FOUND = """
+The duiadns plugin uses the python3-requests package which cannot be found.
+Please install python-requests or python3-requests. Giving up.
+"""
+
+
+# pylint: disable=wrong-import-position
+
from html.parser import HTMLParser
from ddupdate.ddplugin import ServicePlugin, ServiceError
from ddupdate.ddplugin import get_response, get_netrc_auth
+try:
+ import requests
+except (ImportError, ModuleNotFoundError):
+ import sys
+ print(REQUESTS_NOT_FOUND, file=sys.stderr)
+ sys.exit(1)
+
+
+def error(message):
+ """Just a shorthand."""
+ raise ServiceError("HTML parser error: " + message)
+
class DuiadnsParser(HTMLParser):
"""Dig out ip address and hostname in server HTML reply."""
- def error(self, message):
- """Implement HTMLParser.error()."""
- raise ServiceError("HTML parser error: " + message)
-
def __init__(self):
"""Default constructor."""
HTMLParser.__init__(self)
@@ -38,6 +55,9 @@ class DuiadnsPlugin(ServicePlugin):
"""
Update a dns entry on duiadns.com.
+ At the time of writing the server has an expired certificate. Code
+ here works around this while rightfully issuing warnings.
+
As usual, any host updated must first be defined in the web UI. Although
the server supports auto-detection of addresses this plugin does not;
the ip-disabled plugin can not be used. ipv6 is supported
@@ -73,7 +93,14 @@ class DuiadnsPlugin(ServicePlugin):
url += "&ip4=" + ip.v4
if ip and ip.v6:
url += "&ip6=" + ip.v6
- html = get_response(log, url)
+ try:
+ html = get_response(log, url)
+ except ServiceError:
+ resp = requests.get(url, verify=False)
+ if resp.status_code != 200:
+ raise ServiceError("Cannot access update url: " + url) \
+ from None
+ html = resp.content.decode('ascii')
parser = DuiadnsParser()
parser.feed(html)
if 'error' in parser.data or 'Ipv4' not in parser.data:
diff --git a/plugins/dyfi.py b/plugins/dyfi.py
index b3f8780..67c3cf7 100644
--- a/plugins/dyfi.py
+++ b/plugins/dyfi.py
@@ -27,7 +27,7 @@ class DyFiPlugin(ServicePlugin):
_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
+ _ip_cache_ttl = 7200 # 5 days
def register(self, log, hostname, ip, options):
"""Implement ServicePlugin.register."""
diff --git a/plugins/dynu.py b/plugins/dynu.py
index e0ee247..cce0cb3 100644
--- a/plugins/dynu.py
+++ b/plugins/dynu.py
@@ -5,8 +5,11 @@ See: ddupdate(8)
See: https://www.dynu.com/Resources/API/Documentation
"""
-import hashlib
-from ddupdate.ddplugin import ServicePlugin, get_response, get_netrc_auth
+import base64
+from urllib.parse import urlparse
+
+from ddupdate.ddplugin import ServicePlugin, ServiceError
+from ddupdate.ddplugin import get_response, get_netrc_auth
class DynuPlugin(ServicePlugin):
@@ -21,20 +24,26 @@ class DynuPlugin(ServicePlugin):
Options:
none
+
+ See: https://www.dynu.com/en-US/DynamicDNS/IP-Update-Protocol
"""
_name = 'dynu.com'
_oneliner = 'Updates on https://www.dynu.com/en-US/DynamicDNS'
- _url = "https://api.dynu.com" \
- + "/nic/update?hostname={0}&username={1}&password={2}"
+ _url = "https://api.dynu.com/nic/update?host={0}"
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)
+ url = self._url.format(hostname)
+ api_host = urlparse(url).hostname
+ username, password = get_netrc_auth(api_host)
+ user_pw = ('%s:%s' % (username, password))
+ credentials = base64.b64encode(user_pw.encode('ascii'))
+ auth_header = ('Authorization', 'Basic ' + credentials.decode("ascii"))
if ip and ip.v4:
url += "&myip=" + ip.v4
if ip and ip.v6:
url += "&myipv6=" + ip.v6
- get_response(log, url)
+ reply = get_response(log, url, header=auth_header)
+ if not ('good' in reply or 'nochg' in reply):
+ raise ServiceError("Update error: " + reply)
diff --git a/plugins/freedns.py b/plugins/freedns.py
index f6fbbfb..165a1cd 100644
--- a/plugins/freedns.py
+++ b/plugins/freedns.py
@@ -5,8 +5,6 @@ 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
@@ -40,5 +38,5 @@ class FreednsPlugin(ServicePlugin):
elif ip and ip.v4:
url += "&ip=" + str(ip.v4)
html = get_response(log, url)
- if not 'Updated' in html:
- raise ServiceError("Error updating %s" % hostname)
+ if not ('Updated' in html or 'skipping' in html):
+ raise ServiceError("Error updating %s" % hostname)
diff --git a/plugins/googledomains.py b/plugins/googledomains.py
index 0b72143..1ba2b11 100644
--- a/plugins/googledomains.py
+++ b/plugins/googledomains.py
@@ -5,22 +5,25 @@ See: ddupdate(8)
See: https://support.google.com/domains/answer/6147083?hl=en
"""
-import netrc
-import os.path
import urllib.parse
import urllib.request
-from ddupdate.ddplugin import ServiceError, ServicePlugin, \
- get_response
+
+from ddupdate.ddplugin import ServiceError, ServicePlugin
+from ddupdate.ddplugin import AuthError, get_response, get_netrc_auth
# See https://github.com/leamas/ddupdate/pull/56
# and https://github.com/leamas/ddupdate/issues/52
# for why these functions are specialized here.
+
+# pylint: disable=duplicate-code
+# broken for now: https://github.com/PyCQA/pylint/issues/214
+
def http_basic_auth_setup(url, *, providerhost=None, targethost=None):
"""
Configure urllib to provide basic authentication.
- See get_netrc_auth for how providerhost and targethost
+ See get_auth for how providerhost and targethost
are resolved to credentials stored in netrc.
Parameters:
@@ -33,18 +36,17 @@ def http_basic_auth_setup(url, *, providerhost=None, targethost=None):
"""
if not providerhost:
providerhost = urllib.parse.urlparse(url).hostname
- user, password = get_netrc_auth(providerhost, targethost)
+ user, password = get_auth(providerhost, targethost)
pwmgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
pwmgr.add_password(None, url, user, password)
auth_handler = urllib.request.HTTPBasicAuthHandler(pwmgr)
opener = urllib.request.build_opener(auth_handler)
urllib.request.install_opener(opener)
-def get_netrc_auth(providerhost, targethost=None):
- """
- Retrieve data from ~/-netrc or /etc/netrc.
- Will look for matching identifiers in the netrc file.
+def get_auth(providerhost, targethost=None):
+ """
+ Retrieve credentials from configured source.
If a targethost is passed, the first machine name we look for
is targethost.providerhost.ddupdate, falling back to providerhost.
@@ -57,28 +59,18 @@ def get_netrc_auth(providerhost, targethost=None):
Returns:
- A (user, password) tuple. User might be None.
Raises:
- - ServiceError if .netrc or password is not found.
- See:
- - netrc(5)
+ - AuthError password is not found.
"""
- 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).")
- netrcdata = netrc.netrc(path)
if targethost is not None:
machine1 = "%s.%s.ddupdate" % (targethost, providerhost)
- auth = netrcdata.authenticators(machine1) or netrcdata.authenticators(providerhost)
+ try:
+ credentials = get_netrc_auth(machine1)
+ except AuthError:
+ credentials = get_netrc_auth(providerhost)
else:
- auth = netrcdata.authenticators(providerhost)
- if not auth:
- raise ServiceError("No .netrc data found for " + providerhost)
- if not auth[2]:
- raise ServiceError("No password found for " + providerhost)
- return auth[0], auth[2]
+ credentials = get_netrc_auth(providerhost)
+ return credentials
class GoogleDomainsPlugin(ServicePlugin):
@@ -106,7 +98,7 @@ class GoogleDomainsPlugin(ServicePlugin):
if ip:
query['myip'] = ip.v6 or ip.v4
- url="{}?{}".format(self._url, urllib.parse.urlencode(query))
+ url = "{}?{}".format(self._url, urllib.parse.urlencode(query))
http_basic_auth_setup(url, targethost=hostname)
request = urllib.request.Request(url=url, method='POST')
html = get_response(log, request)
diff --git a/plugins/myonlineportal_net.py b/plugins/myonlineportal_net.py
index aa5f23c..1ff2d02 100644
--- a/plugins/myonlineportal_net.py
+++ b/plugins/myonlineportal_net.py
@@ -4,9 +4,11 @@ ddupdate plugin updating data on myonlineportal.net.
See: ddupdate(8)
See: http://myonlineportal.net/ddns_api
"""
+import base64
+from urllib.parse import urlparse
from ddupdate.ddplugin import ServicePlugin, ServiceError, \
- get_response, http_basic_auth_setup
+ get_response, get_netrc_auth
class MyOnlinePortalPlugin(ServicePlugin):
@@ -20,6 +22,9 @@ class MyOnlinePortalPlugin(ServicePlugin):
netrc: Use a line like
machine myonlineportal.net login <username> password <password>
+ See:
+ https://myonlineportal.net/help#update_api
+
Options:
None
"""
@@ -31,12 +36,17 @@ class MyOnlinePortalPlugin(ServicePlugin):
def register(self, log, hostname, ip, options):
"""Implement ServicePlugin.register()."""
url = self._url.format(hostname)
+ api_host = urlparse(url).hostname
+ username, password = get_netrc_auth(api_host)
+ user_pw = ('%s:%s' % (username, password))
+ credentials = base64.b64encode(user_pw.encode('ascii'))
+ auth_header = ('Authorization', 'Basic ' + credentials.decode("ascii"))
+ url = self._url.format(hostname)
if ip and ip.v4:
url += "&ip=" + ip.v4
if ip and ip.v6:
url += "&ip6=" + ip.v6
- http_basic_auth_setup(url)
- html = get_response(log, url)
+ html = get_response(log, url, header=auth_header)
key = html.split()[0]
if key not in ['OK', 'good', 'nochg']:
raise ServiceError("Bad server reply: " + html)
diff --git a/plugins/now_dns.py b/plugins/now_dns.py
index 48d0842..e80d93e 100644
--- a/plugins/now_dns.py
+++ b/plugins/now_dns.py
@@ -6,11 +6,10 @@ See: https://now-dns.com/?p=clients
"""
import base64
-import urllib.request
-import urllib.error
+from urllib.parse import urlparse
from ddupdate.ddplugin import ServicePlugin, ServiceError, \
- http_basic_auth_setup, get_response
+ get_response, get_netrc_auth
class NowDnsPlugin(ServicePlugin):
@@ -27,7 +26,7 @@ class NowDnsPlugin(ServicePlugin):
mess.
netrc: Use a line like
- machine now-dns.com user <username> password <password>
+ machine now-dns.com login <username> password <password>
Options:
None
@@ -42,7 +41,11 @@ class NowDnsPlugin(ServicePlugin):
url = self._url.format(hostname)
if ip:
url += '&myip=' + ip.v4
- http_basic_auth_setup(url)
- html = get_response(log, request)
+ api_host = urlparse(url).hostname
+ username, password = get_netrc_auth(api_host)
+ user_pw = ('%s:%s' % (username, password))
+ credentials = base64.b64encode(user_pw.encode('ascii'))
+ auth_header = ('Authorization', 'Basic ' + credentials.decode("ascii"))
+ html = get_response(log, url, header=auth_header)
if html not in ['good', 'nochg']:
raise ServiceError('Bad server reply: ' + html)
diff --git a/plugins/nsupdate.py b/plugins/nsupdate.py
index 133de43..b70080f 100644
--- a/plugins/nsupdate.py
+++ b/plugins/nsupdate.py
@@ -5,12 +5,13 @@ See: ddupdate(8)
See: nsupdate(1)
"""
-from ddupdate.ddplugin import ServicePlugin, ServiceError, dict_of_opts
-from subprocess import Popen,PIPE
+from subprocess import Popen, PIPE
import sys
+from ddupdate.ddplugin import ServicePlugin, ServiceError, dict_of_opts
+
-class nsupdatePlugin(ServicePlugin):
+class NsupdatePlugin(ServicePlugin):
"""
Update a dns entry with nsupdate(1).
@@ -32,24 +33,26 @@ class nsupdatePlugin(ServicePlugin):
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()
+ args += ('-k', opts['key'].encode('ascii'))
+ with Popen(args, stdout=PIPE, stdin=PIPE, stderr=PIPE) as p:
+ p.stdin.write(b'server ' + opts['server'].encode('ascii') + b'\n')
+ try:
+ p.stdin.write(b'zone ' + opts['zone'].encode('ascii') + b'\n')
+ except KeyError:
+ pass
+ hostname = hostname.encode('ascii')
+ if ip:
+ if ip.v4:
+ addr = ip.v4.encode('ascii')
+ p.stdin.write(b'update delete ' + hostname + b' A\n')
+ p.stdin.write(
+ b'update add ' + hostname + b' 60 A ' + addr + b'\n')
+ if ip.v6:
+ addr = ip.v6.encode('ascii')
+ p.stdin.write(b'update delete ' + hostname + b' AAAA\n')
+ p.stdin.write(b'update add ' + hostname
+ + b' 60 AAAA ' + addr + b'\n')
+ p.stdin.write(b'send\n')
+ err = p.communicate()[1]
if len(err) > 0:
raise ServiceError("Bad update reply: " + err.decode('ascii'))
diff --git a/plugins/system_ns.py b/plugins/system_ns.py
deleted file mode 100644
index 55484d2..0000000
--- a/plugins/system_ns.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""
-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 <API token from website>
-
- 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'])
diff --git a/pylint.conf b/pylint.conf
index 876f38b..0e1172b 100644
--- a/pylint.conf
+++ b/pylint.conf
@@ -35,7 +35,9 @@ load-plugins=
# --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,invalid-name,duplicate-code
+# duplicate-code broken for now: https://github.com/PyCQA/pylint/issues/214
+
#disable=locally-disabled,too-few-public-methods,locally-enabled,bad-whitespace
# bad-whitespace: See https://github.com/PyCQA/pylint/issues/238
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..121a39f
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["setuptools >= 40.6.0", "wheel"]
+build-backend = "setuptools.build_meta"
diff --git a/setup.cfg b/setup.cfg
index 0b2dcdf..f7b0d98 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -2,7 +2,8 @@
add_ignore = D105,D200,D402,D401
[pycodestyle]
-ignore=E262,E266,E402,W503
+ignore=E262,E266,E402,W503,W504
; 262, 266: Comment formatting incompatible wiith doxygen
; 503: Line break before operator (not enforced by the actual pep8 rule)
; 402: import not at top... we have some code fixing with load paths.
+; W504 line break after binary operator -- this is actually nt bad.
diff --git a/setup.py b/setup.py
index 0a435ff..2e72f92 100644
--- a/setup.py
+++ b/setup.py
@@ -1,5 +1,8 @@
"""ddupdate install data."""
+# pylint: disable=bad-option-value, import-outside-toplevel
+# pylint: disable=consider-using-with
+
import shutil
import os
import subprocess
@@ -10,24 +13,24 @@ 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"""
+ """Return the official systemd user unit dir path."""
cmd = 'pkg-config systemd --variable=systemduserunitdir'.split()
try:
return subprocess.check_output(cmd).decode().strip()
except (OSError, subprocess.CalledProcessError):
- return "/usr/lib/systemd/user"
+ 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/man8', ['ddupdate.8', 'ddupdate-config.8',
+ 'ddupdate-netrc-to-keyring.8']),
('share/man/man5', ['ddupdate.conf.5']),
('share/ddupdate/plugins', glob('plugins/*.py')),
('share/ddupdate/dispatcher.d', ['dispatcher.d/50-ddupdate']),
@@ -45,14 +48,15 @@ class _ProjectClean(clean):
if os.path.exists(path):
shutil.rmtree(path)
+
class _ProjectInstall(install):
"""Log used installation paths."""
def run(self):
final_prefix = None
- if 'FINAL_PREFIX' in os.environ:
+ if 'FINAL_PREFIX' in os.environ:
final_prefix = os.environ['FINAL_PREFIX']
- if (final_prefix):
+ if final_prefix:
# Strip leading prefix in paths like /usr/lib/systemd,
# avoiding /usr/usr when applying the prefix
if DATA[0][0].startswith(self.prefix):
@@ -86,9 +90,10 @@ class _ProjectInstall(install):
f.write("[install]\n")
f.write(s)
+
setup(
name='ddupdate',
- version='0.7.0',
+ version='0.7.1',
description='Update dns data for dynamic ip addresses',
long_description=open(ROOT + '/README.md').read(),
include_package_data=True,
Debdiff
[The following lists of changes regard files as different if they have different names, permissions or owners.]
Files in second set of .debs but not in first
-rw-r--r-- root/root /usr/lib/python3/dist-packages/ddupdate-0.7.1.egg-info/PKG-INFO -rw-r--r-- root/root /usr/lib/python3/dist-packages/ddupdate-0.7.1.egg-info/dependency_links.txt -rw-r--r-- root/root /usr/lib/python3/dist-packages/ddupdate-0.7.1.egg-info/top_level.txt -rw-r--r-- root/root /usr/share/ddupdate/plugins/addr_default_ip.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/addr_default_web.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/addr_default_web6.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/addr_dnshome_de.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/addr_hardcoded_if.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/addr_hardcoded_ip.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/addr_ip_disabled.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/addr_ip_from_cmd.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/addr_onhub.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/desec_io.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/dns_o_matic.py -rw-r--r-- root/root /usr/share/doc/ddupdate/README.md.gz -rw-r--r-- root/root /usr/share/man/man8/ddupdate-netrc-to-keyring.8.gz
Files in first set of .debs but not in second
-rw-r--r-- root/root /usr/lib/python3/dist-packages/ddupdate-0.7.0.egg-info/PKG-INFO -rw-r--r-- root/root /usr/lib/python3/dist-packages/ddupdate-0.7.0.egg-info/dependency_links.txt -rw-r--r-- root/root /usr/lib/python3/dist-packages/ddupdate-0.7.0.egg-info/top_level.txt -rw-r--r-- root/root /usr/share/ddupdate/plugins/default_if.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/default_web.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/default_web6.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/dnsdynamic_org.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/dnshome_de_addr.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/hardcoded_if.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/hardcoded_ip.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/ip_disabled.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/ip_from_cmd.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/onhub.py -rw-r--r-- root/root /usr/share/ddupdate/plugins/system_ns.py -rw-r--r-- root/root /usr/share/doc/ddupdate/README.md
No differences were encountered in the control files