-
-
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 <jik@kamens.us> | |
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() |
Thanks for posting this script! Just in case this helps anyone in future -- I had to make a tiny tweak on line 187 to change it to
args.domain.split('.', 1)
to handle domains like.co.uk
which have 2.
's!
Thanks, fixed!
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.
Thanks for posting this script! Just in case this helps anyone in future -- I had to make a tiny tweak on line 187 to change it to
args.domain.split('.', 1)
to handle domains like.co.uk
which have 2.
's!