Initial commit.
authorMichael Tremer <michael.tremer@ipfire.org>
Thu, 19 Jul 2012 18:52:08 +0000 (18:52 +0000)
committerMichael Tremer <michael.tremer@ipfire.org>
Thu, 19 Jul 2012 18:52:08 +0000 (18:52 +0000)
12 files changed:
.gitignore [new file with mode: 0644]
.tx/config [new file with mode: 0644]
Makefile [new file with mode: 0644]
ddns.conf [new file with mode: 0644]
ddns.py [new file with mode: 0755]
ddns/__init__.py [new file with mode: 0644]
ddns/errors.py [new file with mode: 0644]
ddns/i18n.py [new file with mode: 0644]
ddns/providers.py [new file with mode: 0644]
ddns/system.py [new file with mode: 0644]
po/ddns.pot [new file with mode: 0644]
po/de.po [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..35ef121
--- /dev/null
@@ -0,0 +1,3 @@
+*.mo
+*.py[co]
+ddns/__version__.py
diff --git a/.tx/config b/.tx/config
new file mode 100644 (file)
index 0000000..8d2469e
--- /dev/null
@@ -0,0 +1,7 @@
+[main]
+host = https://www.transifex.net
+
+[ipfire.dynamic-dns-client]
+file_filter = po/<lang>.po
+source_file = po/ddns.pot
+source_lang = en
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..6197de6
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,74 @@
+
+PACKAGE_NAME    = ddns
+PACKAGE_VERSION = 0.01
+
+DESTDIR    =
+PREFIX     = /usr
+SYSCONFDIR = /etc
+BINDIR     = $(PREFIX)/bin
+LIBDIR     = $(PREFIX)/lib
+LOCALEDIR  = $(PREFIX)/share/locale
+
+# Get the version and configuration of the python interpreter.
+PYTHON_VERSION = $(shell python -c "import platform; print '.'.join(platform.python_version_tuple()[:2])")
+ifeq "$(PYTHON_VERSION)" ""
+  $(error Could not determine the version of the python interpreter.)
+endif
+PYTHON_DIR = $(LIBDIR)/python$(PYTHON_VERSION)/site-packages/ddns
+
+VERSION_FILE = ddns/__version__.py
+
+###
+# Translation stuff
+###
+# A list of all files that need translation
+TRANSLATION_FILES = $(wildcard ddns/*.py) ddns.py
+
+POT_FILE = po/$(PACKAGE_NAME).pot
+PO_FILES = $(wildcard po/*.po)
+MO_FILES = $(patsubst %.po,%.mo,$(PO_FILES))
+
+################################################################################
+
+all: $(POT_FILE) $(MO_FILES)
+       @: # Do nothing else.
+
+$(VERSION_FILE): Makefile
+       echo "# this file is autogenerated by the build system" > $(VERSION_FILE)
+       echo "CLIENT_VERSION = \"$(PACKAGE_VERSION)\"" >> $(VERSION_FILE)
+
+install: $(VERSION_FILE) $(MO_FILES)
+       # Install the main command.
+       -mkdir -pv $(DESTDIR)$(BINDIR)
+       install -v -m 755 ddns.py $(DESTDIR)$(BINDIR)/ddns
+
+       # Install python module.
+       -mkdir -pv $(DESTDIR)$(PYTHON_DIR)
+       install -v -m 644 ddns/*.py $(DESTDIR)$(PYTHON_DIR)
+
+       # Install the example configuration file.
+       -mkdir -pv $(DESTDIR)$(SYSCONFDIR)
+       install -v -m 640 ddns.conf $(DESTDIR)$(SYSCONFDIR)/ddns.conf
+
+       # Install translation files.
+       -mkdir -pv $(DESTDIR)$(LOCALEDIR)
+       for file in $(MO_FILES); do \
+               lang=$${file/.mo/}; \
+               mkdir -pv $(DESTDIR)$(LOCALEDIR)/$${lang}/LC_MESSAGES; \
+               install -v -m 644 $${file} \
+                       $(DESTDIR)$(LOCALEDIR)/$${lang}/LC_MESSAGES/$(PACKAGE_NAME).mo; \
+       done
+
+# Cleanup temporary files.
+clean:
+       rm -f $(VERSION_FILE)
+       rm -f $(MO_FILES)
+
+# Translation stuff.
+$(POT_FILE): $(TRANSLATION_FILES) Makefile
+       xgettext --language python -d $(PACKAGE_NAME) -k_ -kN_ \
+                -o $@ --add-comments --from-code=UTF-8 $^
+
+# Compile gettext dictionaries from translation files.
+%.mo: %.po $(POT_FILE)
+       msgfmt -o $@ $<
diff --git a/ddns.conf b/ddns.conf
new file mode 100644 (file)
index 0000000..a40f567
--- /dev/null
+++ b/ddns.conf
@@ -0,0 +1,26 @@
+#
+# This is a sample configuration file for the
+# IPFire dynamic DNS update client.
+#
+
+[config]
+
+# Upstream proxy.
+# proxy = http://192.168.180.1:800
+
+# Guess the external IP address with help
+# of an external server.
+# guess_external_ip = true
+
+# Accounts are its own sections.
+# These are some examples.
+
+# [test.no-ip.org]
+# provider = no-ip.com
+# username = user
+# password = pass
+
+# [test.selfhost.de]
+# provider = selfhost.de
+# username = user
+# password = pass
diff --git a/ddns.py b/ddns.py
new file mode 100755 (executable)
index 0000000..426ddcb
--- /dev/null
+++ b/ddns.py
@@ -0,0 +1,8 @@
+#!/usr/bin/python
+
+import ddns
+
+d = ddns.DDNSCore(debug=1)
+d.load_configuration("/etc/ddns.conf")
+
+d.updateall()
diff --git a/ddns/__init__.py b/ddns/__init__.py
new file mode 100644 (file)
index 0000000..0815790
--- /dev/null
@@ -0,0 +1,139 @@
+#!/usr/bin/python
+
+import logging
+import logging.handlers
+import ConfigParser
+
+from i18n import _
+
+logger = logging.getLogger("ddns.core")
+logger.propagate = 1
+
+from .providers import *
+from .system import DDNSSystem
+
+# Setup the logger.
+def setup_logging():
+       rootlogger = logging.getLogger("ddns")
+       rootlogger.setLevel(logging.DEBUG)
+
+       # Setup a logger that logs to syslog.
+       #handler = logging.handlers.SysLogHandler(address="/dev/log")
+
+       handler = logging.StreamHandler()
+       rootlogger.addHandler(handler)
+
+setup_logging()
+
+class DDNSCore(object):
+       def __init__(self, debug=False):
+               # In debug mode, enable debug logging.
+               if debug:
+                       logger.setLevel(logging.DEBUG)
+
+               # Initialize the settings array.
+               self.settings = {}
+
+               # Dict with all providers, that are supported.
+               self.providers = {}
+               self.register_all_providers()
+
+               # List of configuration entries.
+               self.entries = []
+
+               # Add the system class.
+               self.system = DDNSSystem(self)
+
+       def register_provider(self, provider):
+               """
+                       Registers a new provider.
+               """
+               assert issubclass(provider, DDNSProvider)
+
+               provider_handle = provider.INFO.get("handle")
+               assert provider_handle
+
+               assert not self.providers.has_key(provider_handle), \
+                       "Provider '%s' has already been registered" % provider_handle
+
+               provider_name = provider.INFO.get("name")
+               assert provider_name
+
+               logger.debug("Registered new provider: %s (%s)" % (provider_name, provider_handle))
+               self.providers[provider_handle] = provider
+
+       def register_all_providers(self):
+               """
+                       Simply registers all providers.
+               """
+               for provider in (
+                       DDNSProviderNOIP,
+                       DDNSProviderSelfhost,
+               ):
+                       self.register_provider(provider)
+
+       def load_configuration(self, filename):
+               configs = ConfigParser.SafeConfigParser()
+               configs.read([filename,])
+
+               # First apply all global configuration settings.
+               for k, v in configs.items("config"):
+                       self.settings[k] = v
+
+               for entry in configs.sections():
+                       # Skip the special config section.
+                       if entry == "config":
+                               continue
+
+                       settings = {}
+                       for k, v in configs.items(entry):
+                               settings[k] = v
+                       settings["hostname"] = entry
+
+                       # Get the name of the provider.
+                       provider = settings.get("provider", None)
+                       if not provider:
+                               logger.warning("Entry '%s' lacks a provider setting. Skipping." % entry)
+                               continue
+
+                       # Try to find the provider with the wanted name.
+                       try:
+                               provider = self.providers[provider]
+                       except KeyError:
+                               logger.warning("Could not find provider '%s' for entry '%s'." % (provider, entry))
+                               continue
+
+                       # Create an instance of the provider object with settings from the
+                       # configuration file.
+                       entry = provider(self, **settings)
+
+                       # Add new entry to list (if not already exists).
+                       if not entry in self.entries:
+                               self.entries.append(entry)
+
+       def updateall(self):
+               # If there are no entries, there is nothing to do.
+               if not self.entries:
+                       logger.debug(_("Found no entries in the configuration file. Exiting."))
+                       return
+
+               # Update them all.
+               for entry in self.entries:
+                       self.update(entry)
+
+       def update(self, entry):
+               try:
+                       entry()
+
+               except DDNSUpdateError, e:
+                       logger.error(_("Dynamic DNS update for %(hostname)s (%(provider)s) failed:") % \
+                               { "hostname" : entry.hostname, "provider" : entry.name })
+                       logger.error("  %s" % e)
+
+               except Exception, e:
+                       logger.error(_("Dynamic DNS update for %(hostname)s (%(provider)s) throwed an unhandled exception:") % \
+                               { "hostname" : entry.hostname, "provider" : entry.name })
+                       logger.error("  %s" % e)
+
+               logger.info(_("Dynamic DNS update for %(hostname)s (%(provider)s) successful") % \
+                       { "hostname" : entry.hostname, "provider" : entry.name })
diff --git a/ddns/errors.py b/ddns/errors.py
new file mode 100644 (file)
index 0000000..93a55ed
--- /dev/null
@@ -0,0 +1,44 @@
+#!/usr/bin/python
+
+class DDNSError(Exception):
+       pass
+
+
+class DDNSAbuseError(DDNSError):
+       """
+               Thrown when the server reports
+               abuse for this account.
+       """
+       pass
+
+
+class DDNSAuthenticationError(DDNSError):
+       """
+               Thrown when the server did not
+               accept the user credentials.
+       """
+       pass
+
+
+class DDNSInternalServerError(DDNSError):
+       """
+               Thrown when the remote server reported
+               an error on the provider site.
+       """
+       pass
+
+
+class DDNSRequestError(DDNSError):
+       """
+               Thrown when a request could
+               not be properly performed.
+       """
+       pass
+
+
+class DDNSUpdateError(DDNSError):
+       """
+               Thrown when an update could not be
+               properly performed.
+       """
+       pass
diff --git a/ddns/i18n.py b/ddns/i18n.py
new file mode 100644 (file)
index 0000000..4443657
--- /dev/null
@@ -0,0 +1,20 @@
+#!/usr/bin/python
+
+import gettext
+
+TEXTDOMAIN = "ddns"
+
+N_ = lambda x: x
+
+def _(singular, plural=None, n=None):
+       """
+               A function that returnes the translation of a string if available.
+
+               The language is taken from the system environment.
+        """
+       if not plural is None:
+               assert n is not None
+               return gettext.dngettext(TEXTDOMAIN, singular, plural, n)
+
+       return gettext.dgettext(TEXTDOMAIN, singular)
+
diff --git a/ddns/providers.py b/ddns/providers.py
new file mode 100644 (file)
index 0000000..f3230fc
--- /dev/null
@@ -0,0 +1,166 @@
+#!/usr/bin/python
+
+# Import all possible exception types.
+from .errors import *
+
+class DDNSProvider(object):
+       INFO = {
+               # A short string that uniquely identifies
+               # this provider.
+               "handle"    : None,
+
+               # The full name of the provider.
+               "name"      : None,
+
+               # A weburl to the homepage of the provider.
+               # (Where to register a new account?)
+               "website"   : None,
+
+               # A list of supported protocols.
+               "protocols" : ["ipv6", "ipv4"],
+       }
+
+       DEFAULT_SETTINGS = {}
+
+       def __init__(self, core, **settings):
+               self.core = core
+
+               # Copy a set of default settings and
+               # update them by those from the configuration file.
+               self.settings = self.DEFAULT_SETTINGS.copy()
+               self.settings.update(settings)
+
+       def __repr__(self):
+               return "<DDNS Provider %s (%s)>" % (self.name, self.handle)
+
+       def __cmp__(self, other):
+               return cmp(self.hostname, other.hostname)
+
+       @property
+       def name(self):
+               """
+                       Returns the name of the provider.
+               """
+               return self.INFO.get("name")
+
+       @property
+       def website(self):
+               """
+                       Returns the website URL of the provider
+                       or None if that is not available.
+               """
+               return self.INFO.get("website", None)
+
+       @property
+       def handle(self):
+               """
+                       Returns the handle of this provider.
+               """
+               return self.INFO.get("handle")
+
+       def get(self, key, default=None):
+               """
+                       Get a setting from the settings dictionary.
+               """
+               return self.settings.get(key, default)
+
+       @property
+       def hostname(self):
+               """
+                       Fast access to the hostname.
+               """
+               return self.get("hostname")
+
+       @property
+       def username(self):
+               """
+                       Fast access to the username.
+               """
+               return self.get("username")
+
+       @property
+       def password(self):
+               """
+                       Fast access to the password.
+               """
+               return self.get("password")
+
+       def __call__(self):
+               raise NotImplementedError
+
+       def send_request(self, *args, **kwargs):
+               """
+                       Proxy connection to the send request
+                       method.
+               """
+               return self.core.system.send_request(*args, **kwargs)
+
+       def get_address(self, proto):
+               """
+                       Proxy method to get the current IP address.
+               """
+               return self.core.system.get_address(proto)
+
+
+class DDNSProviderNOIP(DDNSProvider):
+       INFO = {
+               "handle"    : "no-ip.com",
+               "name"      : "No-IP",
+               "website"   : "http://www.no-ip.com/",
+               "protocols" : ["ipv4",]
+       }
+
+       # Information about the format of the HTTP request is to be found
+       # here: http://www.no-ip.com/integrate/request and
+       # here: http://www.no-ip.com/integrate/response
+
+       url = "http://%(username)s:%(password)s@dynupdate.no-ip.com/nic/update?hostname=%(hostname)s&myip=%(address)s"
+
+       def __call__(self):
+               url = self.url % {
+                       "hostname" : self.hostname,
+                       "username" : self.username,
+                       "password" : self.password,
+                       "address"  : self.get_address("ipv4"),
+               }
+
+               # Send update to the server.
+               response = self.send_request(url)
+
+               # Get the full response message.
+               output = response.read()
+
+               # Handle success messages.
+               if output.startswith("good") or output.startswith("nochg"):
+                       return
+
+               # Handle error codes.
+               if output == "badauth":
+                       raise DDNSAuthenticationError
+               elif output == "aduse":
+                       raise DDNSAbuseError
+               elif output == "911":
+                       raise DDNSInternalServerError
+
+               # If we got here, some other update error happened.
+               raise DDNSUpdateError
+
+
+class DDNSProviderSelfhost(DDNSProvider):
+       INFO = {
+               "handle"    : "selfhost.de",
+               "name"      : "Selfhost.de",
+               "website"   : "http://www.selfhost.de/",
+               "protocols" : ["ipv4",],
+       }
+
+       url = "https://carol.selfhost.de/update?username=%(username)s&password=%(password)s&textmodi=1"
+
+       def __call__(self):
+               url = self.url % { "username" : self.username, "password" : self.password }
+
+               response = self.send_request(url)
+
+               match = re.search("status=20(0|4)", response.read())
+               if not match:
+                       raise DDNSUpdateError
diff --git a/ddns/system.py b/ddns/system.py
new file mode 100644 (file)
index 0000000..6840b7d
--- /dev/null
@@ -0,0 +1,105 @@
+#!/usr/bin/python
+
+import re
+import urllib2
+
+from __version__ import CLIENT_VERSION
+from i18n import _
+
+# Initialize the logger.
+import logging
+logger = logging.getLogger("ddns.system")
+logger.propagate = 1
+
+class DDNSSystem(object):
+       """
+               The DDNSSystem class adds a layer of abstraction
+               between the ddns software and the system.
+       """
+
+       # The default useragent.
+       USER_AGENT = "IPFireDDNSUpdater/%s" % CLIENT_VERSION
+
+       def __init__(self, core):
+               # Connection to the core of the program.
+               self.core = core
+
+       @property
+       def proxy(self):
+               proxy = self.core.settings.get("proxy")
+
+               # Strip http:// at the beginning.
+               if proxy.startswith("http://"):
+                       proxy = proxy[7:]
+
+               return proxy
+
+       def guess_external_ipv4_address(self):
+               """
+                       Sends a request to the internet to determine
+                       the public IP address.
+
+                       XXX does not work for IPv6.
+               """
+               response = self.send_request("http://checkip.dyndns.org/")
+
+               if response.code == 200:
+                       match = re.search(r"Current IP Address: (\d+.\d+.\d+.\d+)", response.read())
+                       if match is None:
+                               return
+
+                       return match.group(1)
+
+       def send_request(self, url, data=None, timeout=30):
+               logger.debug("Sending request: %s" % url)
+               if data:
+                       logger.debug("  data: %s" % data)
+
+               req = urllib2.Request(url, data=data)
+
+               # Set the user agent.
+               req.add_header("User-Agent", self.USER_AGENT)
+
+               # All requests should not be cached anywhere.
+               req.add_header("Pragma", "no-cache")
+
+               # Set the upstream proxy if needed.
+               if self.proxy:
+                       logger.debug("Using proxy: %s" % self.proxy)
+
+                       # Configure the proxy for this request.
+                       req.set_proxy(self.proxy, "http")
+
+               logger.debug(_("Request header:"))
+               for k, v in req.headers.items():
+                       logger.debug("  %s: %s" % (k, v))
+
+               try:
+                       resp = urllib2.urlopen(req)
+
+                       # Log response header.
+                       logger.debug(_("Response header:"))
+                       for k, v in resp.info().items():
+                               logger.debug("  %s: %s" % (k, v))
+
+                       # Return the entire response object.
+                       return resp
+
+               except urllib2.URLError, e:
+                       raise
+
+       def get_address(self, proto):
+               assert proto in ("ipv6", "ipv4")
+
+               if proto == "ipv4":
+                       # Check if the external IP address should be guessed from
+                       # a remote server.
+                       guess_ip = self.core.settings.get("guess_external_ip", "")
+
+                       # If the external IP address should be used, we just do
+                       # that.
+                       if guess_ip in ("true", "yes", "1"):
+                               return self.guess_external_ipv4_address()
+
+               # XXX TODO
+               assert False
diff --git a/po/ddns.pot b/po/ddns.pot
new file mode 100644 (file)
index 0000000..15d01bc
--- /dev/null
@@ -0,0 +1,48 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2012-07-19 18:48+0000\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=CHARSET\n"
+"Content-Transfer-Encoding: 8bit\n"
+
+#: ddns/__init__.py:117
+msgid "Found no entries in the configuration file. Exiting."
+msgstr ""
+
+#: ddns/__init__.py:129
+#, python-format
+msgid "Dynamic DNS update for %(hostname)s (%(provider)s) failed:"
+msgstr ""
+
+#: ddns/__init__.py:134
+#, python-format
+msgid ""
+"Dynamic DNS update for %(hostname)s (%(provider)s) throwed an unhandled "
+"exception:"
+msgstr ""
+
+#: ddns/__init__.py:138
+#, python-format
+msgid "Dynamic DNS update for %(hostname)s (%(provider)s) successful"
+msgstr ""
+
+#: ddns/system.py:73
+msgid "Request header:"
+msgstr ""
+
+#. Log response header.
+#: ddns/system.py:81
+msgid "Response header:"
+msgstr ""
diff --git a/po/de.po b/po/de.po
new file mode 100644 (file)
index 0000000..47a5363
--- /dev/null
+++ b/po/de.po
@@ -0,0 +1,50 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# 
+# Translators:
+# Michael Tremer <michael.tremer@ipfire.org>, 2012.
+msgid ""
+msgstr ""
+"Project-Id-Version: The IPFire Project\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2012-07-19 17:03+0000\n"
+"PO-Revision-Date: 2012-07-19 17:11+0000\n"
+"Last-Translator: Michael Tremer <michael.tremer@ipfire.org>\n"
+"Language-Team: German (http://www.transifex.com/projects/p/ipfire/language/de/)\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: de\n"
+"Plural-Forms: nplurals=2; plural=(n != 1)\n"
+
+#: ddns/__init__.py:117
+msgid "Found no entries in the configuration file. Exiting."
+msgstr "Keine Einträge in der Konfigurationsdatei gefunden. Ende."
+
+#: ddns/__init__.py:126
+#, python-format
+msgid "Dynamic DNS update for %(hostname)s (%(provider)s) failed:"
+msgstr "Dynamic DNS-Update für %(hostname)s (%(provider)s) fehlgeschlagen"
+
+#: ddns/__init__.py:131
+#, python-format
+msgid ""
+"Dynamic DNS update for %(hostname)s (%(provider)s) throwed an unhandled "
+"exception:"
+msgstr "Dynamic DNS-Update für %(hostname)s (%(provider)s) erzeugte einen unerwarteten Fehler:"
+
+#. XXX DEBUG
+#: ddns/__init__.py:136
+#, python-format
+msgid "Dynamic DNS update for %(hostname)s (%(provider)s) successful"
+msgstr "Dynamic DNS-Update für %(hostname)s (%(provider)s) erfolgreich"
+
+#: ddns/system.py:73
+msgid "Request header:"
+msgstr "Request-Header:"
+
+#. Log response header.
+#: ddns/system.py:81
+msgid "Response header:"
+msgstr "Response-Header:"