Skip to content

Writing a new DNS API module

Todd Knarr edited this page Jun 21, 2016 · 2 revisions

The first thing you should do when writing a new DNS service API module is to fork the main repository (if you haven't already forked it) and create a new branch to work on. That'll let you keep your changes separate from the mainline code and make it easy to see what changes you may've made to files outside your new module source file. That in turn makes it easy for me to review the pull request and makes it more likely I'll accept it quickly. If you plan on contributing your module back or contributing a Wiki page, ask about being set up as a collaborator on the project so you can have the needed access.

The module's filename must be dnsapi_xxxxx.py, where xxxxx is the name used for the protocol in dnsapi.ini. It should be a single alphanumeric word, no spaces or underscores or other symbols, and in all lowercase.

The initial portion of the file is Python's UTF-8 encoding header followed by license text and notes about the data the module requires, URLs it uses, how parameters are filled in for the DNS service and so on. Taking the Linode DNS Manager module as an example:

# -*- coding: utf-8 -*-

The following two lines should be edited to reflect the name of the DNS service and your own name and email address. Update the copyright year to the current year.

#    OpenDKIM genkeys tool, Linode DNS Manager API
#    Copyright (C) 2016 Todd Knarr <tknarr@silverglass.org>

The licensing text follows. All of the original package code is under the GPL v3. You only need to worry about this if you're going to distribute your module to others. If you or your organization is using the package you aren't under any licensing obligations. Likewise if you distribute your module on it's own without any part of the original package. If you distribute the package and include your module in the distribution, or if you wish to contribute your module back to the mainline package, the module has to be licensed under either the GPL v3 or a license compatible with it.

#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.

#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.

#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <http://www.gnu.org/licenses/>.

Note any dependencies your module requires that aren't part of a standard Python installation.

# Uses the 'requests' package.

Describe what fields from dnsapi.ini, domains.ini and the key data dictionary created by genkeys.py that your module will use and what the value means.

# Requires:
# dnsapi_data[0]        : API key
# dnsapi_domain_data[0] : Domain ID
# key_data['plain']     : TXT record value in plain unquoted format

Note the URL or URLs the module will use to update the DNS data, including any fields that have to be filled in based on passed-in data.

# POST URL: https://api.linode.com/

Describe the fields used in the DNS update call.

# Parameters:
# api_key            : dnsapi_data[0]
# api_action         : "domain.resource.create"
# DomainID           : dnsapi_domain_data[0]
# Type               : "TXT"
# Name               : selector + "._domainkey"
# Target             : key_data['plain']

Imports and the update() function declaration.

import logging
import requests

def update( dnsapi_data, dnsapi_domain_data, key_data, debugging = False ):

The first thing done in the update function should be to check the input lists to make sure all required information is present and extract that information into variables that can conveniently be used later on. If information is missing, log an error and return False to indicate a failure.

    if len(dnsapi_data) < 1:
        logging.error( "DNS API linode: API key not configured" )
        return False;
    api_key = dnsapi_data[0]
    if len(dnsapi_domain_data) < 1:
        logging.error( "DNS API linode: domain data does not contain domain ID" )
        return False
    domain_id = dnsapi_domain_data[0]

When extracting information from the key data, wrap it in a try statement to catch attempts to retrieve a key that doesn't exist. The exception that generates should cause an error to be logged and False to be returned.

    try:
        selector = key_data['selector']
        data = key_data['plain']
    except KeyError as e:
        logging.error( "DNS API linode: required information not present: %s", str(e) )
        return False

The above two blocks of code are the first parts you'll need to alter or write for your own module. You'll need to be familiar with genkeys.py and understand how the parameters from dnsapi.ini and domains.ini are passed and how the key data dictionary is filled in and with what keys. In general the dnsapi_data list contains the parameters from dnsapi.ini starting from the first one after the API name. If there aren't any parameters, as for the null API module, the list will be empty. dnsapi_domains_data contains the parameters from domains.ini starting after the DNS module name (starting with the 4th whitespace-separated field). If you want to make some parameters optional, place them at the end of your list to simplify parsing. It's advisable to avoid complex parameter parsing.

The key data dictionary contains two keys, plain and chunked. plain contains the key data as a single string without any double-quote marks. chunked contains the key data broken up into the chunks the OpenDKIM utilities created it in with each chunk surrounded by double-quotes and separated by a single space. The two forms are there because UDP DNS responses are limited to 255 bytes and many DKIM records are larger than that and so need to be broken up into chunks of 255 characters or less. Some DNS services like Linode's DNS Manager handle that automatically, so the plain form can be used. Other services like FreeDNS require that you break the record up into chunks yourself, so that module uses the chunked form of the key data. The other keys in the dictionary are:

  • domain: the domain name being processed, without any initial dots.
  • selector: the selector specified or automatically generated, used to create the name of the DKIM record.
  • dnsapi: the name of the DNS API module from the two .ini files, available if perchance two or more DNS services share the same API save for credentials and/or endpoint URLs and so can share a single module that checks the name it was invoked under to decide how to handle the API call.

If the user has enabled the debugging flag, the code should stop here and return True to indicate success rather than continuing to try to update the DNS service. I would've liked to go further and simulate the service update call, but I couldn't see a way to do that cleanly without doing an actual update or adding code that'd bypass the code further down that's most in need of testing/debugging.

    if debugging:
        return True

The call to the Linode DNS Manager's API endpoint uses a standard POST request with the parameters passed as standard form data. The HTTP status gets logged as an informational message that'll be seen if the user's requested verbose logging via -v. This code will need to be rewritten to reflect the mechanics of the API call for the service you're implementing. Most work via HTTP, so a familiarity with the requests package will be required. Services that use XML or JSON to encode parameters will require familiarity with Python's built-in JSON and XML modules.

    result = False
    resp = requests.post( "https://api.linode.com/",
                          data = { 'api_key': api_key,
                                   'api_action': 'domain.resource.create',
                                   'DomainID': domain_id,
                                   'Type': 'TXT',
                                   'Name': selector + "._domainkey",
                                   'Target': data
                                   } )
    logging.info( "HTTP status: %d", resp.status_code )

Then comes one of the more tricky parts, checking for errors. HTTP errors are simple, you can just check the response status code. Linode's DNS Manager for instance returns an HTTP error if anything's wrong with the request so if the result wasn't a 200 OK the request failed. It's a good idea to log the response body in that case so the user can see if there's any useful information in it. Services that use JSON or XML, such as AWS Route 53 or CloudFlare's API, often return a valid body even when an HTTP error occurs, and in those cases you'll want to parse the body for error information just as if the request succeeded. Look at the code for Route 53 and CloudFlare for examples.

If the request succeeds, you're not out of the woods yet. The service may return error information in a 200 OK response. Linode's DNS Manager returns JSON-encoded data, and will fill in an ERRORARRAY sequence with dictionaries containing error codes and messages. On an HTTP-successful request the module has to parse the response body and check whether ERRORARRAY is empty. The json() method on the response object converts the body into a dictionary representing the JSON data which can then be checked like any other dictionary. If the error array has anything in it the module prints out the error codes and messages in a nicely-formatted way and sets the return value to False indicating a failure. If the error array was empty, the return value gets set to True to indicate a successful update.

    if resp.status_code == requests.codes.ok:
        error_array = resp.json()['ERRORARRAY']
        if len(error_array) > 0:
            result = False
            for error in error_array:
                logging.error( "DNS API linode: error %d: %s", error['ERRORCODE'], error['ERRORMESSAGE'] )
        else:
            result = True
    else:
        result = False
        logging.error( "DNS API linode: HTTP error %d", resp.status_code )
        logging.error( "DNS API linode: error response body:\n%s", resp.text )

    return result

DNS service modules aren't hard to write, exactly, but they take familiarity with accessing the DNS service's API from Python code and with debugging web-service clients in general. If the service provides a Python SDK it's perfectly acceptable to use it, just note the dependency in the code. I don't use the Route 53 SDK, for an example, because the service call isn't complicated and using the SDK would involve pulling in a lot of dependencies that wouldn't actually be used when the code runs. Use your own judgment when deciding.

If you're planning on contributing your module back, you should also write up a Wiki entry describing how to set up your module in dnsapi.ini and domains.ini. Information on how to get any identifiers or keys needed is important, especially if they aren't clearly and obviously presented in the service's Web UI. An additional script like the cloudflare_list_zone_ids.py script to simplify getting the ID information is something to consider. For Cloudflare it was almost impossible to find the zone IDs required in the Web UI, but trivial to query the API for a list of zones and list out ID/name pairs once I had the module code to access the API written that I could crib from.