-
-
Save jikamens/3d8f018c47e4c4f0dd87b8c31bc57076 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3 | |
""" | |
namecheap-dns.py - Export/import DNS records from/to Namecheap | |
This script can export DNS records from a domain in Namecheap to a YAML file or | |
import records from a YAML file in the same format into Namecheap. I use this | |
script to maintain my Namecheap DNS records in a source repository with change | |
history, i.e., "configuration as code" for my Namecheap DNS records. | |
Beware! The Namecheap API doesn't allow creating, modifying, or deleting | |
individual records. The only write operation supported by the API is replacing | |
all the records for the domain. Therefore, the expected way to use this script | |
with a domain that has records in it predating your use of the script is to | |
export all of the current records, modify the export file to reflect any | |
changes you want to make, and then import the modified file, possibly first | |
running the import with --dryrun to make sure it's going to do what you expect. | |
To use the script you need to enable the Namecheap API on your account and | |
whitelist the public IPv4 address of the host you're running the script on. See | |
https://www.namecheap.com/support/api/intro/ for details. | |
You need to have a config file, by default named namecheap-dns-config.yml in | |
the current directory though you can specify a different file on the command | |
line, whose contents look like this: | |
ApiUser: [Namecheap username] | |
UserName: [Namecheap username] | |
ApiKey: [Namecheap API keyi] | |
ClientIP: [public IPv4 address of the host you're running the script on] | |
The YAML file containing the records looks like this: | |
- Address: 127.0.0.1 | |
HostName: localhost | |
RecordType: A | |
TTL: '180' | |
- Address: 192.168.0.1 | |
HostName: router | |
RecordType: A | |
- Address: email.my.domain | |
MXPref: 10 | |
HostName: '@' | |
RecordType: MX | |
The order of records or of fields within individual records doesn't matter. | |
Of note: there's no way through this API to create records that show up on the | |
Namecheap Advanced DNS page as "dynamic DNS records," so if you have such a | |
record created through that page and then you export and import your records, | |
it will be converted into a regular DNS record. This doesn't seem to matter, | |
though, because the dynamic DNS update API will still work on it. :shrug: | |
Copyright 2023 Jonathan Kamens <[email protected]> | |
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 at | |
<https://www.gnu.org/licenses/> for more details. | |
""" | |
import argparse | |
from itertools import count | |
import json | |
from lxml import etree | |
import requests | |
import sys | |
import yaml | |
namecheap_api_url = 'https://api.namecheap.com/xml.response' | |
def parse_args(): | |
parser = argparse.ArgumentParser(description='Export or import Namecheap ' | |
'DNS records') | |
parser.add_argument('--config-file', action='store', | |
default='namecheap-dns-config.yml', | |
help='Config file (default namecheap-dns-config.yml)') | |
subparsers = parser.add_subparsers(title='subcommands', required=True) | |
import_parser = subparsers.add_parser( | |
'import', description='Import records into Namecheap') | |
import_parser.set_defaults(command=do_import) | |
import_parser.add_argument('--dryrun', action='store_true', default=False, | |
help='Say what would be done without doing it') | |
import_parser.add_argument('--input-file', type=argparse.FileType('r'), | |
default=sys.stdin, help='File to read records ' | |
'from (default stdin)') | |
import_parser.add_argument('domain') | |
export_parser = subparsers.add_parser( | |
'export', description='Export records from Namecheap') | |
export_parser.set_defaults(command=do_export) | |
export_parser.add_argument('--output-file', type=argparse.FileType('w'), | |
default=sys.stdout, help='File to write ' | |
'records to (default stdout)') | |
export_parser.add_argument('domain') | |
args = parser.parse_args() | |
return args | |
def make_namecheap_request(config, data): | |
request = data.copy() | |
request.update({ | |
'ApiUser': config['ApiUser'], | |
'UserName': config['UserName'], | |
'ApiKey': config['ApiKey'], | |
'ClientIP': config['ClientIP'], | |
}) | |
response = requests.post(namecheap_api_url, request) | |
response.raise_for_status() | |
response_xml = etree.XML(response.content) | |
if response_xml.get('Status') != 'OK': | |
raise Exception('Bad response: {}'.format(response.content)) | |
return response_xml | |
def get_records(config, sld, tld): | |
response = make_namecheap_request(config, { | |
'Command': 'namecheap.domains.dns.getHosts', | |
'SLD': sld, | |
'TLD': tld}) | |
host_elements = response.xpath( | |
'/x:ApiResponse/x:CommandResponse/x:DomainDNSGetHostsResult/x:host', | |
namespaces={'x': 'http://api.namecheap.com/xml.response'}) | |
records = [dict(h.attrib) for h in host_elements] | |
for record in records: | |
record.pop('AssociatedAppTitle', None) | |
record.pop('FriendlyName', None) | |
record.pop('HostId', None) | |
record['HostName'] = record.pop('Name') | |
record.pop('IsActive', None) | |
record.pop('IsDDNSEnabled', None) | |
if record['Type'] != 'MX': | |
record.pop('MXPref', None) | |
record['RecordType'] = record.pop('Type') | |
if record['TTL'] == '1800': | |
record.pop('TTL') | |
return records | |
def do_import(args, config): | |
current = {dict_hash(r): r | |
for r in get_records(config, args.sld, args.tld)} | |
new = {dict_hash(r): r | |
for r in yaml.safe_load(args.input_file)} | |
changed = False | |
for r in current.keys(): | |
if r not in new: | |
print(f'Removing {current[r]}') | |
changed = True | |
for r in new.keys(): | |
if r not in current: | |
print(f'Adding {new[r]}') | |
changed = True | |
if not changed: | |
return | |
data = { | |
'Command': 'namecheap.domains.dns.setHosts', | |
'SLD': args.sld, | |
'TLD': args.tld, | |
} | |
for num, record in zip(count(1), new.values()): | |
for key, value in record.items(): | |
data[f'{key}{num}'] = value | |
if not args.dryrun: | |
make_namecheap_request(config, data) | |
def do_export(args, config): | |
records = get_records(config, args.sld, args.tld) | |
yaml.dump(sorted(records, key=dict_hash), args.output_file) | |
def dict_hash(d): | |
d = d.copy() | |
name = d.pop('HostName') | |
type_ = d.pop('RecordType') | |
return (type_, name, json.dumps(d, sort_keys=True)) | |
def main(): | |
args = parse_args() | |
(args.sld, args.tld) = args.domain.split('.', 1) | |
config = yaml.safe_load(open(args.config_file)) | |
args.command(args, config) | |
if __name__ == '__main__': | |
main() |
instead of yaml file maybe a better option could be the bind format
Note, the instructions (line 23) says to a config file, by default named namecheap-dns-config.json
. However, the code itself it looking for a .yml
file, not a .json
file.
instead of yaml file maybe a better option could be the bind format
🤷 The BIND format is harder to generate and parse, and exporting to BIND format wasn't the point of writing the script, so I didn't bother.
Note, the instructions (line 23) says to a config file, by default named
namecheap-dns-config.json
. However, the code itself it looking for a.yml
file, not a.json
file.
Thanks, fixed.
Very nice script and very useful for backing up Namecheap DNS records.
I'd love to see this converted into a full Python module with programmatic access and standalone access via command line.
Thanks, fixed!