Skip to content

Commit

Permalink
initial support for duckdns. fixes #42
Browse files Browse the repository at this point in the history
  • Loading branch information
infothrill committed Feb 16, 2015
1 parent cac8e10 commit 57ee12f
Show file tree
Hide file tree
Showing 6 changed files with 199 additions and 5 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.rst
Expand Up @@ -5,6 +5,7 @@ Release history

0.4.2 (February XX 2015)
++++++++++++++++++++++++
- added support for https://www.duckdns.org
- user configuration keys now override presets

0.4.1 (February 16th 2015)
Expand Down
5 changes: 5 additions & 0 deletions dyndnsc/resources/presets.ini
Expand Up @@ -75,3 +75,8 @@ updater = dyndns2
updater-url = https://members.dyndns.org/nic/update
detector = webcheck

[preset:duckdns.org]
updater = duckdns
updater-url = https://www.duckdns.org/update
detector = webcheck4

93 changes: 93 additions & 0 deletions dyndnsc/tests/updater/test_duckdns.py
@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-

import unittest
from time import sleep
from multiprocessing import Process

from bottle import Bottle, run, response, request


def nicupdate():
arg_hostname = request.query.domains
arg_token = request.query.token
arg_myip = request.query.ip
assert len(arg_hostname) > 0
assert len(arg_token) > 0
assert len(arg_myip) > 0
response.content_type = 'text/plain; charset=utf-8'
return str("good %s" % arg_myip)


class DuckdnsApp(Bottle):

"""
A minimal http server that resembles an actual duckdns service
"""

def __init__(self, host='localhost', port=8000):
super(DuckdnsApp, self).__init__()
self.host = host
self.port = port
self.process = None
self.route(path='/update', callback=nicupdate)

def run(self):
run(self, host=self.host, port=self.port, debug=False, quiet=True)

def start(self):
self.process = Process(target=self.run)
self.process.start()
# even though I have a super fast quad core cpu, this is not working
# consistently if we don't sleep here!
sleep(3.5)

def stop(self):
self.process.terminate()
self.process = None
# sleep(1)

@property
def url(self):
return 'http://%s:%s' % (self.host, str(self.port))


class TestDuckdns2BottleServer(unittest.TestCase):

def setUp(self):
"""
Start local server
"""
import random
portnumber = random.randint(8000, 8900)
self.server = DuckdnsApp('127.0.0.1', portnumber)
self.url = "http://127.0.0.1:%i/update" % portnumber
self.server.start()
unittest.TestCase.setUp(self)

def tearDown(self):
"""
Stop local server.
"""
self.server.stop()
self.server = None
unittest.TestCase.tearDown(self)

def test_dyndns2(self):
import dyndnsc.updater.duckdns as duckdns
NAME = "duckdns"
theip = "127.0.0.1"
options = {"hostname": "duckdns.example.com",
"token": "dummy",
"url": self.url
}
self.assertEqual(
NAME, duckdns.UpdateProtocolDuckdns.configuration_key())
updater = duckdns.UpdateProtocolDuckdns(**options)
self.assertEqual(str, type(updater.url()))
self.assertEqual(self.url, updater.url())
res = updater.update(theip)
self.assertEqual(theip, res)


if __name__ == '__main__':
DuckdnsApp('localhost', 8000).run()
16 changes: 11 additions & 5 deletions dyndnsc/updater/base.py
Expand Up @@ -9,15 +9,15 @@


class UpdateProtocol(Subject, DynamicCliMixin):
"""
base class for all update protocols that use the dyndns2 update protocol
"""

"""Base class for all update protocols that use a simple http GET protocol."""

_updateurl = None
theip = None
hostname = None # this holds the desired dns hostname

def __init__(self):
"""Initializer."""
self.updateurl = self._updateurl
super(UpdateProtocol, self).__init__()

Expand All @@ -33,11 +33,17 @@ def url(self):
@staticmethod
def configuration_key():
"""
This method must be implemented by all updater subclasses. Returns a
human readable string identifying the protocol.
Return a human readable string identifying the protocol.
Must be implemented by all updater subclasses.
"""
return "none_base_class"

@staticmethod
def configuration_key_prefix():
"""
Return a human readable string classifying this class as an updater.
Must be not be implemented or overwritten in updater subclasses.
"""
return "updater"
1 change: 1 addition & 0 deletions dyndnsc/updater/builtin.py
Expand Up @@ -11,6 +11,7 @@
_builtins = (
('dyndnsc.updater.afraid', 'UpdateProtocolAfraid'),
('dyndnsc.updater.dummy', 'UpdateProtocolDummy'),
('dyndnsc.updater.duckdns', 'UpdateProtocolDuckdns'),
('dyndnsc.updater.dyndns2', 'UpdateProtocolDyndns2'),
('dyndnsc.updater.dnsimple', 'UpdateProtocolDnsimple'),
)
Expand Down
88 changes: 88 additions & 0 deletions dyndnsc/updater/duckdns.py
@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-

"""Module containing the logic for updating DNS records using the duckdns protocol.
From the duckdns.org website:
https://{DOMAIN}/update?domains={DOMAINLIST}&token={TOKEN}&ip={IP}
where:
DOMAIN the service domain
DOMAINLIST is either a single domain or a comma separated list of domains
TOKEN is the API token for authentication/authorization
IP is either the IP or blank for auto-detection
"""

from logging import getLogger

from .base import UpdateProtocol
from ..common import constants

import requests

log = getLogger(__name__)


class UpdateProtocolDuckdns(UpdateProtocol):

"""Updater for services compatible with the duckdns protocol."""

def __init__(self, hostname, token, url, *args, **kwargs):
"""
Initializer.
:param hostname: the fully qualified hostname to be managed
:param token: the token for authentication
:param url: the API URL for updating the DNS entry
"""
self.hostname = hostname
self.token = token
self._updateurl = url

super(UpdateProtocolDuckdns, self).__init__()

@staticmethod
def configuration_key():
"""Human readable string identifying this update protocol."""
return "duckdns"

def update(self, ip):
self.theip = ip
return self.protocol()

def protocol(self):
timeout = 60
log.debug("Updating '%s' to '%s' at service '%s'", self.hostname, self.theip, self.url())
params = {'domains': self.hostname, 'token': self.token}
if self.theip is None:
params['ip'] = ""
else:
params['ip'] = self.theip
try:
r = requests.get(self.url(), params=params, headers=constants.REQUEST_HEADERS_DEFAULT,
timeout=timeout)
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc:
log.warning("an error occurred while updating IP at '%s'",
self.url(), exc_info=exc)
return False
else:
r.close()
log.debug("status %i, %s", r.status_code, r.text)
if r.status_code == 200:
if r.text.startswith("good "):
return self.theip
elif r.text.startswith('nochg'):
return self.theip
elif r.text == 'nohost':
return 'nohost'
elif r.text == 'abuse':
return 'abuse'
elif r.text == '911':
return '911'
elif r.text == 'notfqdn':
return 'notfqdn'
else:
return r.text
else:
return 'invalid http status code: %s' % r.status_code

0 comments on commit 57ee12f

Please sign in to comment.