From f22ab0851f894136823df166d03d80cf7f4646b7 Mon Sep 17 00:00:00 2001 From: Michael Tremer Date: Thu, 19 Jul 2012 18:52:08 +0000 Subject: [PATCH] Initial commit. --- .gitignore | 3 + .tx/config | 7 ++ Makefile | 74 +++++++++++++++++++++ ddns.conf | 26 ++++++++ ddns.py | 8 +++ ddns/__init__.py | 139 ++++++++++++++++++++++++++++++++++++++ ddns/errors.py | 44 ++++++++++++ ddns/i18n.py | 20 ++++++ ddns/providers.py | 166 ++++++++++++++++++++++++++++++++++++++++++++++ ddns/system.py | 105 +++++++++++++++++++++++++++++ po/ddns.pot | 48 ++++++++++++++ po/de.po | 50 ++++++++++++++ 12 files changed, 690 insertions(+) create mode 100644 .gitignore create mode 100644 .tx/config create mode 100644 Makefile create mode 100644 ddns.conf create mode 100755 ddns.py create mode 100644 ddns/__init__.py create mode 100644 ddns/errors.py create mode 100644 ddns/i18n.py create mode 100644 ddns/providers.py create mode 100644 ddns/system.py create mode 100644 po/ddns.pot create mode 100644 po/de.po diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35ef121 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.mo +*.py[co] +ddns/__version__.py diff --git a/.tx/config b/.tx/config new file mode 100644 index 0000000..8d2469e --- /dev/null +++ b/.tx/config @@ -0,0 +1,7 @@ +[main] +host = https://www.transifex.net + +[ipfire.dynamic-dns-client] +file_filter = po/.po +source_file = po/ddns.pot +source_lang = en diff --git a/Makefile b/Makefile new file mode 100644 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 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 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 index 0000000..0815790 --- /dev/null +++ b/ddns/__init__.py @@ -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 index 0000000..93a55ed --- /dev/null +++ b/ddns/errors.py @@ -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 index 0000000..4443657 --- /dev/null +++ b/ddns/i18n.py @@ -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 index 0000000..f3230fc --- /dev/null +++ b/ddns/providers.py @@ -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 "" % (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 index 0000000..6840b7d --- /dev/null +++ b/ddns/system.py @@ -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 index 0000000..15d01bc --- /dev/null +++ b/po/ddns.pot @@ -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 , 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 \n" +"Language-Team: LANGUAGE \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 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 , 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 \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:" -- 2.39.2