├── .gitignore ├── README.md ├── black_dnsync ├── __init__.py ├── config.py ├── dns_client.py ├── main.py └── record.py ├── requirements-dev.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore/8f35779cefbabdca21cfc4cda55abb1290d59fd9/Global/OSX.gitignore 2 | 3 | *.DS_Store 4 | .AppleDouble 5 | .LSOverride 6 | 7 | # Icon must end with two \r 8 | Icon 9 | 10 | # Thumbnails 11 | ._* 12 | 13 | # Files that might appear in the root of a volume 14 | .DocumentRevisions-V100 15 | .fseventsd 16 | .Spotlight-V100 17 | .TemporaryItems 18 | .Trashes 19 | .VolumeIcon.icns 20 | .com.apple.timemachine.donotpresent 21 | 22 | # Directories potentially created on remote AFP share 23 | .AppleDB 24 | .AppleDesktop 25 | Network Trash Folder 26 | Temporary Items 27 | .apdisk 28 | 29 | 30 | ### https://raw.github.com/github/gitignore/8f35779cefbabdca21cfc4cda55abb1290d59fd9/Python.gitignore 31 | 32 | # Byte-compiled / optimized / DLL files 33 | __pycache__/ 34 | *.py[cod] 35 | *$py.class 36 | 37 | # C extensions 38 | *.so 39 | 40 | # Distribution / packaging 41 | .Python 42 | env/ 43 | build/ 44 | develop-eggs/ 45 | dist/ 46 | downloads/ 47 | eggs/ 48 | .eggs/ 49 | lib/ 50 | lib64/ 51 | parts/ 52 | sdist/ 53 | var/ 54 | *.egg-info/ 55 | .installed.cfg 56 | *.egg 57 | 58 | # PyInstaller 59 | # Usually these files are written by a python script from a template 60 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 61 | *.manifest 62 | *.spec 63 | 64 | # Installer logs 65 | pip-log.txt 66 | pip-delete-this-directory.txt 67 | 68 | # Unit test / coverage reports 69 | htmlcov/ 70 | .tox/ 71 | .coverage 72 | .coverage.* 73 | .cache 74 | nosetests.xml 75 | coverage.xml 76 | *,cover 77 | .hypothesis/ 78 | 79 | # Translations 80 | *.mo 81 | *.pot 82 | 83 | # Django stuff: 84 | *.log 85 | local_settings.py 86 | 87 | # Flask stuff: 88 | instance/ 89 | .webassets-cache 90 | 91 | # Scrapy stuff: 92 | .scrapy 93 | 94 | # Sphinx documentation 95 | docs/_build/ 96 | 97 | # PyBuilder 98 | target/ 99 | 100 | # IPython Notebook 101 | .ipynb_checkpoints 102 | 103 | # pyenv 104 | .python-version 105 | 106 | # celery beat schedule file 107 | celerybeat-schedule 108 | 109 | # dotenv 110 | .env 111 | 112 | # virtualenv 113 | venv/ 114 | ENV/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | 123 | ### https://raw.github.com/github/gitignore/8f35779cefbabdca21cfc4cda55abb1290d59fd9/Global/SublimeText.gitignore 124 | 125 | # cache files for sublime text 126 | *.tmlanguage.cache 127 | *.tmPreferences.cache 128 | *.stTheme.cache 129 | 130 | # workspace files are user-specific 131 | *.sublime-workspace 132 | 133 | # project files should be checked into the repository, unless a significant 134 | # proportion of contributors will probably not be using SublimeText 135 | # *.sublime-project 136 | 137 | # sftp configuration file 138 | sftp-config.json 139 | 140 | # Package control specific files 141 | Package Control.last-run 142 | Package Control.ca-list 143 | Package Control.ca-bundle 144 | Package Control.system-ca-bundle 145 | Package Control.cache/ 146 | Package Control.ca-certs/ 147 | bh_unicode_properties.cache 148 | 149 | # Sublime-github package stores a github token in this file 150 | # https://packagecontrol.io/packages/sublime-github 151 | GitHub.sublime-settings 152 | 153 | 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Black DNSync 2 | 3 | A interactive cli tool for managing DNS records with conf file 4 | 5 | ## Supported DNS Service provider 6 | 7 | - Aliyun DNS (Both China and Intl version) 8 | - DNSPod 9 | - DNSimple 10 | - CloudXNS 11 | 12 | ## How to use 13 | 14 | ``` 15 | # sync existing remote records to local 16 | blackdnsync --export --domain domain --provider dnspod --app-id id --app-key key 17 | 18 | # sync local config to remote 19 | blackdnsync --sync -c path_to_config.conf [--quite] [--dry-run] 20 | ``` 21 | 22 | available providers are dnspod/aliyun/cloudxns/dnsimple 23 | 24 | ### preview 25 | ![preview](https://user-images.githubusercontent.com/300016/31016688-592a30f0-a4eb-11e7-9b59-502e95e63184.png) 26 | 27 | 28 | ## Example conf 29 | 30 | ``` 31 | [General] 32 | domain = cat.sb 33 | default-ttl = 600 34 | provider = aliyun 35 | app-id = 1as0P9F4Ivk0uI8x 36 | app-key = xssCB4e4BdCxsMcm33iRNyvqyCzy 37 | 38 | [Records] 39 | # mailgun 40 | @ MX mxa.mailgun.org priority=10 41 | @ MX mxb.mailgun.org priority=10 42 | @ TXT "v=spf1 include:mailgun.ors ~all" 43 | 44 | @ A 1.1.1.1 45 | www A hk-ali01 line=ct ttl=120 46 | ``` 47 | A record value support not only IP but also ssh hostname 48 | 49 | ### Line support 50 | 51 | - DNSPod: ct/cu/cm/edu/oversea 52 | - Aliyun DNS: ct/cu/cm/edu/oversea 53 | - CloudXNS: ct/cu/cm/edu/oversea 54 | - DNSimple: no 55 | 56 | ## TODO (Maybe) 57 | 58 | - HE DNS 59 | - Cloudflare DNS 60 | - Namecheap DNS 61 | - ns1 62 | - Dyn 63 | - Google Cloud DNS 64 | -------------------------------------------------------------------------------- /black_dnsync/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/humiaozuzu/black-dnsync/33946e7b102d07c0181bc2132c4948d322be417b/black_dnsync/__init__.py -------------------------------------------------------------------------------- /black_dnsync/config.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | import shlex 4 | 5 | import configparser 6 | 7 | from record import Record 8 | 9 | 10 | def cfg_to_record(record, default_ttl, ssh_config): 11 | items = shlex.split(record) 12 | name, type, value = items[:3] 13 | other = items[3:] 14 | 15 | hostname = None 16 | if type == 'A': 17 | real_ip = ssh_config.lookup(value)['hostname'] 18 | if real_ip != value: 19 | hostname = value 20 | value = real_ip 21 | 22 | ttl = default_ttl 23 | line = 'default' 24 | priority = None 25 | for rec in other: 26 | k, v = rec.split('=') 27 | if k == 'priority': 28 | priority = int(v) 29 | elif k == 'ttl': 30 | ttl = int(v) 31 | elif k == 'line': 32 | line = v 33 | 34 | return Record(name, type, value, ttl, line, priority=priority, hostname=hostname) 35 | 36 | 37 | def read_config(config_path, ssh_config): 38 | cp = configparser.ConfigParser(delimiters=('='), inline_comment_prefixes=('#'), allow_no_value=True, strict=False) 39 | cp.optionxform = str 40 | cp.read(config_path) 41 | 42 | general_cfg = {k: v for (k, v) in cp.items('General')} 43 | general_cfg['default-ttl'] = int(general_cfg['default-ttl']) 44 | 45 | cp = configparser.ConfigParser(delimiters=(u'蛤'), inline_comment_prefixes=('#'), allow_no_value=True, strict=False) 46 | cp.optionxform = str 47 | cp.read(config_path) 48 | records = [] 49 | for record_str in cp.items('Records'): 50 | record = cfg_to_record(record_str[0], general_cfg['default-ttl'], ssh_config) 51 | records.append(record) 52 | return general_cfg, records 53 | -------------------------------------------------------------------------------- /black_dnsync/dns_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import base64 4 | import uuid 5 | import time 6 | import sys 7 | import urllib 8 | import hmac 9 | from hashlib import sha1 10 | from hashlib import md5 11 | import requests 12 | import json 13 | from urllib import urlencode 14 | 15 | from record import Record 16 | 17 | def get_dns_client(client_name): 18 | if client_name == 'dnspod': 19 | return DnspodClient 20 | elif client_name == 'dnsimple': 21 | return DnsimpleClient 22 | elif client_name == 'aliyun': 23 | return AliyunClient 24 | elif client_name == 'cloudxns': 25 | return CloudxnsClient 26 | 27 | 28 | class APIError(Exception): 29 | def __init__(self, msg): 30 | self.msg = msg 31 | def __str__(self): 32 | return repr(self.msg) 33 | 34 | 35 | class UnsupportedLineError(Exception): 36 | def __init__(self, msg): 37 | self.msg = msg 38 | def __str__(self): 39 | return repr(self.msg) 40 | 41 | 42 | class BaseClient(object): 43 | 44 | def __init__(self, api_key, api_secret): 45 | self.api_key = api_key 46 | self.api_secret = api_secret 47 | 48 | def list_domains(self): 49 | pass 50 | 51 | def list_records(self): 52 | pass 53 | 54 | def add_record(self): 55 | pass 56 | 57 | def remove_record(self): 58 | pass 59 | 60 | def update_record(self): 61 | pass 62 | 63 | 64 | DNSPOD_LINE_MAP = { 65 | 'default': '0', 66 | 'ct': '10=0', 67 | 'cu': '10=1', 68 | 'cm': '10=3', 69 | 'edu': '10=2', 70 | 'oversea': '3=0', 71 | } 72 | 73 | DNSPOD_LINE_MAP_REV = {v: k for k, v in DNSPOD_LINE_MAP.items()} 74 | 75 | 76 | class DnspodClient(BaseClient): 77 | def __init__(self, api_key, api_secret): 78 | super(DnspodClient, self).__init__(api_key, api_secret) 79 | self.base_url = 'https://dnsapi.cn/' 80 | 81 | def __request(self, uri, **kw): 82 | url = self.base_url + uri 83 | payload = { 84 | 'format': 'json', 85 | 'login_token': '%s,%s' % (self.api_key, self.api_secret), 86 | } 87 | payload.update(kw) 88 | r = requests.post(url, data=payload) 89 | r_json = r.json() 90 | 91 | if int(r_json['status']['code']) != 1: 92 | raise APIError(r_json) 93 | return r_json 94 | 95 | def __to_record(self, record): 96 | if record['enabled'] == '0': 97 | raise ValueError('Do not support DNSPod record disabled status!') 98 | 99 | try: 100 | line = DNSPOD_LINE_MAP_REV[record['line_id']] 101 | except KeyError: 102 | raise UnsupportedLineError(record) 103 | 104 | priority = None 105 | if record['type'] == 'MX': 106 | priority = int(record['mx']) 107 | if record['type'] in ('CNAME', 'MX'): 108 | record['value'] = record['value'].rstrip('.') 109 | 110 | return Record(record['name'], record['type'], record['value'], int(record['ttl']), 111 | line, priority, record['id']) 112 | 113 | def list_records(self, domain): 114 | resp = self.__request('Record.List', domain=domain) 115 | records = [] 116 | for r in resp['records']: 117 | record = self.__to_record(r) 118 | if record.type == 'NS': 119 | continue 120 | records.append(record) 121 | return records 122 | 123 | def add_record(self, domain, record): 124 | data = { 125 | 'domain': domain, 126 | 'sub_domain': record.name, 127 | 'record_type': record.type, 128 | 'value': record.value, 129 | 'ttl': record.ttl, 130 | } 131 | data['record_line_id'] = DNSPOD_LINE_MAP[record.line] 132 | if type == 'MX': 133 | data['mx'] = record.priority 134 | return self.__request('Record.Create', **data) 135 | 136 | def remove_record(self, domain, record_id): 137 | return self.__request('Record.Remove', domain=domain, record_id=record_id) 138 | 139 | def update_record(self, domain, record_id, record): 140 | # Only DNSPod Enterprise can add record weight 141 | data = { 142 | 'domain': domain, 143 | 'record_id': record_id, 144 | 'sub_domain': record.name, 145 | 'record_type': record.type, 146 | 'value': record.value, 147 | 'ttl': record.ttl, 148 | } 149 | data['record_line_id'] = DNSPOD_LINE_MAP[record.line] 150 | if record.type == 'MX': 151 | data['mx'] = record.priority 152 | return self.__request('Record.Modify', **data) 153 | 154 | 155 | CLOUDXNS_LINE_MAP = { 156 | 'default': 1, 157 | 'ct': 2, 158 | 'cu': 3, 159 | 'cm': 144, 160 | 'edu': 6, 161 | 'oversea': 9, 162 | } 163 | 164 | CLOUDXNS_LINE_MAP_REV = {v: k for k, v in CLOUDXNS_LINE_MAP.items()} 165 | 166 | 167 | class CloudxnsClient(BaseClient): 168 | def __init__(self, api_key, api_secret): 169 | super(CloudxnsClient, self).__init__(api_key, api_secret) 170 | self.base_url = 'https://www.cloudxns.net/api2/' 171 | self.domain_id_map = {} 172 | 173 | def __request(self, method, uri, data=None): 174 | url = self.base_url + uri 175 | if data: 176 | data = json.dumps(data) 177 | else: 178 | data = '' 179 | date = time.strftime('%a %b %d %H:%M:%S %Y', time.localtime()) 180 | md5_ins = md5() 181 | md5_ins.update(self.api_key + url + data + date + self.api_secret) 182 | headers = { 183 | 'Content-Type': 'application/json', 184 | 'API-FORMAT': 'json', 185 | 'API-KEY': self.api_key, 186 | 'API-REQUEST-DATE': date, 187 | 'API-HMAC': md5_ins.hexdigest(), 188 | } 189 | r = requests.request(method, url, headers=headers, data=data) 190 | r_json = r.json() 191 | 192 | if int(r_json['code']) != 1: 193 | raise APIError(r_json) 194 | return r_json 195 | 196 | def __to_record(self, record): 197 | if record['status'] == 'userstop': 198 | raise ValueError('Do not support CloudXNS record disabled status!') 199 | try: 200 | line = CLOUDXNS_LINE_MAP_REV[int(record['line_id'])] 201 | except KeyError: 202 | raise UnsupportedLineError(record) 203 | 204 | priority = None 205 | if record['type'] == 'MX': 206 | priority = int(record['mx']) 207 | if record['type'] in ('CNAME', 'MX'): 208 | record['value'] = record['value'].rstrip('.') 209 | if record['type'] == 'TXT': 210 | record['value'] = record['value'].strip('"') 211 | 212 | return Record(record['host'], record['type'], record['value'], int(record['ttl']), 213 | line, priority, record['record_id']) 214 | 215 | def __get_domain_id(self, domain): 216 | domain_id = self.domain_id_map.get(domain) 217 | if domain_id: 218 | return domain_id 219 | 220 | data = self.__request('GET', 'domain') 221 | for domain_info in data['data']: 222 | self.domain_id_map[domain_info['domain'].rstrip('.')] = int(domain_info['id']) 223 | return self.domain_id_map.get(domain) 224 | 225 | def list_records(self, domain): 226 | uri = 'record/%s' % self.__get_domain_id(domain) 227 | params = { 228 | 'host_id': 0, 229 | 'offset': 0, 230 | 'row_num': 2000, 231 | } 232 | uri = uri + '?' + urlencode(params) 233 | resp = self.__request('GET', uri) 234 | records = [] 235 | for r in resp['data']: 236 | record = self.__to_record(r) 237 | if record.type == 'NS': 238 | continue 239 | records.append(record) 240 | return records 241 | 242 | def add_record(self, domain, record): 243 | domain_id = self.__get_domain_id(domain) 244 | data = { 245 | 'domain_id': domain_id, 246 | 'host': record.name, 247 | 'value': record.value, 248 | 'type': record.type, 249 | 'ttl': record.ttl, 250 | } 251 | data['line_id'] = CLOUDXNS_LINE_MAP[record.line] 252 | if type == 'MX': 253 | data['mx'] = record.priority 254 | return self.__request('POST', 'record', data) 255 | 256 | def remove_record(self, domain, record_id): 257 | uri = 'record/%s/%s' % (record_id, self.__get_domain_id(domain)) 258 | return self.__request('DELETE', uri) 259 | 260 | def update_record(self, domain, record_id, record): 261 | uri = 'record/%s' % record_id 262 | domain_id = self.__get_domain_id(domain) 263 | data = { 264 | 'domain_id': domain_id, 265 | 'host': record.name, 266 | 'value': record.value, 267 | 'type': record.type, 268 | 'ttl': record.ttl, 269 | } 270 | data['line_id'] = DNSPOD_LINE_MAP[record.line] 271 | if record.type == 'MX': 272 | data['mx'] = record.priority 273 | return self.__request('PUT', uri, data) 274 | 275 | 276 | ALIDNS_LINE_MAP = { 277 | 'default': 'default', 278 | 'ct': 'telecom', 279 | 'cu': 'unicom', 280 | 'cm': 'mobile', 281 | 'edu': 'edu', 282 | 'oversea': 'oversea', 283 | } 284 | 285 | ALIDNS_LINE_MAP_REV = {v: k for k, v in ALIDNS_LINE_MAP.items()} 286 | 287 | 288 | class AliyunClient(BaseClient): 289 | def __init__(self, api_key, api_secret): 290 | super(AliyunClient, self).__init__(api_key, api_secret) 291 | self.base_url = 'https://dns.aliyuncs.com' 292 | 293 | # Aliyun Signature. 294 | def __sign(self, params): 295 | sorted_parameters = sorted(params.items(), 296 | key=lambda params: params[0]) 297 | canonicalized_query_string = '' 298 | for (k, v) in sorted_parameters: 299 | canonicalized_query_string += '&' + self.char_encode(k) + '=' + self.char_encode(v) 300 | 301 | string_to_sign = 'GET&%2F&' + self.char_encode(canonicalized_query_string[1:]) 302 | h = hmac.new(str(self.api_secret) + '&', string_to_sign, sha1) 303 | signature = base64.encodestring(h.digest()).strip() 304 | return signature 305 | 306 | # Encode URL chars. 307 | def char_encode(self, encodeStr): 308 | encodeStr = str(encodeStr) 309 | res = urllib.quote(encodeStr.decode(sys.stdin.encoding).encode('utf8'), '') 310 | res = res.replace('+', '%20') 311 | res = res.replace('*', '%2A') 312 | res = res.replace('%7E', '~') 313 | return res 314 | 315 | def __request(self, data): 316 | timestamp = time.strftime('%Y-%m-%dT%H:%M:%Sz', time.gmtime()) 317 | params = { 318 | 'Format': 'JSON', 319 | 'Version': '2015-01-09', 320 | 'AccessKeyId': self.api_key, 321 | 'SignatureVersion': '1.0', 322 | 'SignatureMethod': 'HMAC-SHA1', 323 | 'SignatureNonce': str(uuid.uuid1()), 324 | 'Timestamp': timestamp 325 | } 326 | params.update(data) 327 | 328 | # get sign 329 | signature = self.__sign(params) 330 | params['Signature'] = signature 331 | 332 | r = requests.get(self.base_url, params=params) 333 | r_json = r.json() 334 | 335 | if 'Code' in r_json: 336 | raise APIError(r_json) 337 | return r_json 338 | 339 | 340 | def __to_record(self, record): 341 | if record['Status'] == 'Disable': 342 | raise ValueError('Do not support Aliyun DNS record disabled status!') 343 | 344 | try: 345 | line = ALIDNS_LINE_MAP_REV[record['Line']] 346 | except KeyError: 347 | raise UnsupportedLineError(record) 348 | 349 | priority = None 350 | if record['Type'] == 'MX': 351 | priority = int(record['Priority']) 352 | if record['Type'] in ('CNAME', 'MX'): 353 | record['Value'] = record['Value'].rstrip('.') 354 | 355 | return Record(record['RR'], record['Type'], record['Value'], int(record['TTL']), 356 | line, priority, record['RecordId']) 357 | 358 | def list_records(self, domain): 359 | data = { 360 | 'Action': 'DescribeDomainRecords', 361 | 'DomainName': domain, 362 | 'PageSize': 500, 363 | } 364 | resp = self.__request(data) 365 | 366 | records = [] 367 | for r in resp['DomainRecords']['Record']: 368 | record = self.__to_record(r) 369 | if record.type == 'NS': 370 | continue 371 | records.append(record) 372 | return records 373 | 374 | 375 | def add_record(self, domain, record): 376 | data = { 377 | 'Action': 'AddDomainRecord', 378 | 'DomainName': domain, 379 | 'RR': record.name, 380 | 'Type': record.type, 381 | 'Value': record.value, 382 | 'TTL': record.ttl, 383 | } 384 | data['Line'] = ALIDNS_LINE_MAP[record.line] 385 | if record.type == 'MX': 386 | data['Priority'] = record.priority 387 | return self.__request(data) 388 | 389 | def remove_record(self, domain, record_id): 390 | data = { 391 | 'Action': 'DeleteDomainRecord', 392 | 'RecordId': record_id, 393 | } 394 | return self.__request(data) 395 | 396 | def update_record(self, domain, record_id, record): 397 | data = { 398 | 'Action': 'UpdateDomainRecord', 399 | 'RecordId': record_id, 400 | 'RR': record.name, 401 | 'Type': record.type, 402 | 'Value': record.value, 403 | 'TTL': record.ttl, 404 | } 405 | data['Line'] = ALIDNS_LINE_MAP[record.line] 406 | if record.type == 'MX': 407 | data['Priority'] = record.priority 408 | return self.__request(data) 409 | 410 | 411 | class DnsimpleClient(BaseClient): 412 | def __init__(self, api_key, api_secret): 413 | super(DnsimpleClient, self).__init__(api_key, api_secret) 414 | self.base_url = 'https://api.dnsimple.com/v2/' 415 | 416 | def __request(self, method, uri, data=None): 417 | url = self.base_url + uri 418 | headers = { 419 | 'Content-Type': 'application/json', 420 | 'Authorization': 'Basic %s' % base64.b64encode(self.api_secret), 421 | } 422 | r = requests.request(method, url, headers=headers, json=data) 423 | if method == 'delete': 424 | if r.status_code != 204: 425 | r_json = r.json() 426 | raise APIError(r_json) 427 | else: 428 | r_json = r.json() 429 | if 'data' not in r_json: 430 | raise APIError(r_json) 431 | return r_json 432 | 433 | def __to_record(self, record): 434 | line = 'default' 435 | if not record['name']: 436 | record['name'] = '@' 437 | return Record(record['name'], record['type'], record['content'], int(record['ttl']), 438 | line, record['priority'], record['id']) 439 | 440 | def list_records(self, domain): 441 | uri = '%s/zones/%s/records?per_page=100' % (self.api_key, domain) 442 | resp = self.__request('GET', uri) 443 | records = [] 444 | for r in resp['data']: 445 | if r['system_record']: 446 | continue 447 | record = self.__to_record(r) 448 | if record.type == 'NS': 449 | continue 450 | records.append(record) 451 | return records 452 | 453 | def add_record(self, domain, record): 454 | uri = '%s/zones/%s/records' % (self.api_key, domain) 455 | json = { 456 | 'name': record.name, 457 | 'type': record.type, 458 | 'content': record.value, 459 | 'priority': record.priority, 460 | 'ttl': record.ttl, 461 | } 462 | return self.__request('post', uri, json) 463 | 464 | def remove_record(self, domain, record_id): 465 | uri = '%s/zones/%s/records/%s' % (self.api_key, domain, record_id) 466 | return self.__request('delete', uri) 467 | 468 | def update_record(self, domain, record_id, record): 469 | uri = '%s/zones/%s/records/%s' % (self.api_key, domain, record_id) 470 | json = { 471 | 'name': record.name, 472 | 'content': record.value, 473 | 'priority': record.priority, 474 | 'ttl': record.ttl, 475 | } 476 | return self.__request('patch', uri, json) 477 | -------------------------------------------------------------------------------- /black_dnsync/main.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | import getopt 4 | import sys 5 | import os 6 | 7 | import paramiko 8 | from termcolor import colored, cprint 9 | 10 | from record import Record 11 | from record import diff_records 12 | from record import diff_record_update 13 | from dns_client import get_dns_client 14 | from config import read_config 15 | 16 | def main(): 17 | shortopts = 'hc:' 18 | longopts = ['export', 'sync', 'quite', 'dry-run', 'domain=', 'provider=', 'app-id=', 'app-key='] 19 | optlist, args = getopt.getopt(sys.argv[1:], shortopts, longopts) 20 | 21 | run_sync = False 22 | run_export = False 23 | config_path = None 24 | dry_run = False 25 | quite = False 26 | domain = None 27 | provider = None 28 | app_id = None 29 | app_key = None 30 | for o, a in optlist: 31 | if o in ('-h', '--help'): 32 | print 'Usage: blackdnsync --sync -c path_to_config.conf [--quite] [--dry-run]' 33 | print ' blackdnsync --export --domain domain --provider dnspod --app-id id --app-key key' 34 | sys.exit(0) 35 | elif o in ('--sync'): 36 | run_sync = True 37 | elif o in ('--export'): 38 | run_export = True 39 | elif o in ('-c', '--config'): 40 | config_path = a 41 | elif o in ('--quite'): 42 | quite = True 43 | elif o in ('--dry-run'): 44 | dry_run = True 45 | elif o in ('--domain'): 46 | domain = a 47 | elif o in ('--provider'): 48 | provider = a 49 | elif o in ('--app-id'): 50 | app_id = a 51 | elif o in ('--app-key'): 52 | app_key = a 53 | 54 | if run_export: 55 | dns_client = get_dns_client(provider) 56 | client = dns_client(app_id, app_key) 57 | records = client.list_records(domain) 58 | for record in records: 59 | print record.to_cfg() 60 | elif run_sync: 61 | # load .ssh/config for later host lookup 62 | client = paramiko.SSHClient() 63 | ssh_config = paramiko.SSHConfig() 64 | user_config_file = os.path.expanduser("~/.ssh/config") 65 | if os.path.exists(user_config_file): 66 | with open(user_config_file) as f: 67 | ssh_config.parse(f) 68 | 69 | # get local config and records 70 | cfg, local_records = read_config(config_path, ssh_config) 71 | 72 | # pull latest records 73 | dns_client = get_dns_client(cfg['provider']) 74 | client = dns_client(cfg['app-id'], cfg['app-key']) 75 | remote_records = client.list_records(cfg['domain']) 76 | 77 | add, update, remove = diff_records(remote_records, local_records) 78 | 79 | # preview changes 80 | print 'Records to Update:' 81 | for old_record, new_record in update: 82 | print '%s => %s' % diff_record_update(old_record, new_record) 83 | print '\nRecords to Remove:' 84 | for record in remove: 85 | cprint(record, 'red') 86 | print '\nRecords to Add:' 87 | for record in add: 88 | cprint(record, 'green') 89 | 90 | if all(l == [] for l in (add, update, remove)): 91 | print 'Nothing to change. Bye~' 92 | exit(0) 93 | # prompt to run or not 94 | choice = raw_input('\nSubmit changes? [Y/N]').lower() 95 | if choice != 'y': 96 | print 'Nothing to change. Bye~' 97 | exit(0) 98 | 99 | # commit changes 100 | for r_record, l_record in update: 101 | print 'Updating... %s => %s' % (r_record, l_record) 102 | client.update_record(cfg['domain'], r_record.record_id, l_record) 103 | print 'Done' 104 | for record in remove: 105 | print record 106 | print 'Removing... %s' % record 107 | client.remove_record(cfg['domain'], record.record_id) 108 | print 'Done' 109 | for record in add: 110 | print 'Adding... %s' % record 111 | client.add_record(cfg['domain'], record) 112 | print 'Done' 113 | 114 | 115 | if __name__ == '__main__': 116 | main() 117 | -------------------------------------------------------------------------------- /black_dnsync/record.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import difflib 4 | 5 | from termcolor import colored 6 | 7 | 8 | class Record(object): 9 | def __init__(self, name, type, value, ttl, line=None, priority=None, record_id=None, hostname=None): 10 | assert isinstance(ttl, int), 'ttl should be an interger type!' 11 | assert isinstance(priority, int) or (priority == None) 12 | self.name = name 13 | self.type = type 14 | self.value = value 15 | self.ttl = ttl 16 | self.line = line 17 | self.priority = priority # only avaliable for MX records 18 | self.record_id = record_id # only available for record init from cloud 19 | self.hostname = hostname 20 | 21 | def __str__(self): 22 | return unicode(self).encode('utf-8') 23 | 24 | def __unicode__(self): 25 | record_items = self.to_list(show_hostname=True) 26 | return ' '.join(record_items) 27 | 28 | def __eq__(self, other): 29 | return all([ 30 | self.name == other.name, 31 | self.type == other.type, 32 | self.value == other.value, 33 | self.ttl == other.ttl, 34 | self.line == other.line, 35 | self.priority == other.priority, 36 | ]) 37 | 38 | def __ne__(self, other): 39 | return not self.__eq__(other) 40 | 41 | def delta(self, other): 42 | cnt = 0 43 | if self.value != other.value: 44 | cnt += 1 45 | if self.ttl != other.ttl: 46 | cnt += 1 47 | if self.line != other.line: 48 | cnt += 1 49 | if self.priority != other.priority: 50 | cnt += 1 51 | return cnt 52 | 53 | def get_close_matches(self, records): 54 | for record in records: 55 | delta = self.delta(record) 56 | if delta == 1: 57 | return record 58 | 59 | def to_list(self, show_hostname=False): 60 | items = [self.name, self.type, self.value, 'ttl=%s' % self.ttl] 61 | 62 | if self.type == 'TXT': 63 | items[2] = wrap_txt(items[2]) 64 | if self.line and self.line != 'default': 65 | items.append('line=%s' % self.line) 66 | if self.type == 'MX': 67 | items.append('priority=%s' % self.priority) 68 | if show_hostname and self.hostname: 69 | items[2] = items[2] + '(%s)' % self.hostname 70 | return items 71 | 72 | def to_cfg(self): 73 | """api, MX, 11.11.11.11, ttl=600, priority=20, line=edu""" 74 | return ' '.join(self.to_list()) 75 | 76 | 77 | def wrap_txt(value): 78 | return '"%s"' % value 79 | 80 | 81 | def unwrap_txt(value): 82 | return value[1:-1] 83 | 84 | 85 | def build_records_map(records): 86 | records_map = {} 87 | for record in records: 88 | key = (record.type, record.name) 89 | records_map.setdefault(key, []) 90 | records_map[key].append(record) 91 | return records_map 92 | 93 | 94 | def diff_record_update(old, new): 95 | d = difflib.Differ() 96 | result = d.compare(old.to_list(show_hostname=True), new.to_list(show_hostname=True)) 97 | old_list = [] 98 | new_list = [] 99 | for line in result: 100 | if line.startswith(' '): 101 | old_list.append(colored(line[2:], 'red')) 102 | new_list.append(colored(line[2:], 'green')) 103 | elif line.startswith('- '): 104 | old_list.append(colored(line[2:], 'red', attrs=['reverse'])) 105 | elif line.startswith('+ '): 106 | new_list.append(colored(line[2:], 'green', attrs=['reverse'])) 107 | return ' '.join(old_list), ' '.join(new_list) 108 | 109 | 110 | def diff_records(old, new): 111 | # remove union records 112 | for old_record in old[:]: 113 | if old_record in new: 114 | old.remove(old_record) 115 | new.remove(old_record) 116 | 117 | add = [] 118 | update = [] 119 | remove = [] 120 | 121 | old_map = build_records_map(old) 122 | new_map = build_records_map(new) 123 | 124 | for key, new_content_list in new_map.iteritems(): 125 | if key not in old_map: 126 | add.extend(new_content_list) 127 | else: 128 | old_content_list = old_map[key] 129 | # find bi-least_delta matches 130 | for old_record in old_content_list: 131 | matched_update = old_record.get_close_matches(new_content_list) 132 | if matched_update is None: 133 | remove.append(old_record) 134 | else: 135 | update.append((old_record, matched_update)) 136 | new_content_list.remove(matched_update) 137 | # left update records must be add 138 | for new_record in new_content_list: 139 | add.append(new_record) 140 | 141 | for key, old_content_list in old_map.iteritems(): 142 | if key not in new_map: 143 | remove.extend(old_content_list) 144 | 145 | return add, update, remove 146 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | cffi==1.8.3 2 | configparser==3.5.0 3 | cryptography==1.5.2 4 | enum34==1.1.6 5 | idna==2.1 6 | ipaddress==1.0.17 7 | paramiko==2.0.2 8 | pyasn1==0.1.9 9 | pycparser==2.14 10 | requests==2.11.1 11 | six==1.10.0 12 | termcolor==1.1.0 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from setuptools import setup 4 | 5 | 6 | setup( 7 | name='black-dnsync', 8 | version='0.1.0', 9 | license='GPL', 10 | description='dns syncer', 11 | zip_safe=False, 12 | include_package_data=True, 13 | author='Maple', 14 | author_email='maplevalley8@gmail.com', 15 | platforms='any', 16 | packages=['black_dnsync'], 17 | entry_points=""" 18 | [console_scripts] 19 | blackdnsync = black_dnsync.main:main 20 | """, 21 | install_requires=[ 22 | 'configparser', 23 | 'paramiko', 24 | 'requests', 25 | 'termcolor', 26 | ], 27 | classifiers=[ 28 | 'Intended Audience :: Developers', 29 | 'Operating System :: OS Independent', 30 | 'Programming Language :: Python', 31 | "Programming Language :: Python :: 2", 32 | 'Programming Language :: Python :: 2.7', 33 | ] 34 | ) 35 | --------------------------------------------------------------------------------