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

More details

Full run details