├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Pipfile ├── Pipfile.lock ├── README.rst ├── cloudflare_ddns ├── __init__.py ├── __main__.py ├── cloudflare.py └── exceptions.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Shawn Lin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | requests = ">2.0.0,<3.0.0" 10 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "641e0b6d0593b0b72d368cebfa96c7cc264a4bceafae327eb97711d7cd18207b" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": {}, 8 | "sources": [ 9 | { 10 | "name": "pypi", 11 | "url": "https://pypi.org/simple", 12 | "verify_ssl": true 13 | } 14 | ] 15 | }, 16 | "default": { 17 | "certifi": { 18 | "hashes": [ 19 | "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082", 20 | "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9" 21 | ], 22 | "index": "pypi", 23 | "markers": "python_version >= '3.6'", 24 | "version": "==2023.7.22" 25 | }, 26 | "chardet": { 27 | "hashes": [ 28 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 29 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 30 | ], 31 | "version": "==3.0.4" 32 | }, 33 | "idna": { 34 | "hashes": [ 35 | "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", 36 | "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" 37 | ], 38 | "version": "==2.8" 39 | }, 40 | "requests": { 41 | "hashes": [ 42 | "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", 43 | "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" 44 | ], 45 | "index": "pypi", 46 | "version": "==2.22.0" 47 | }, 48 | "urllib3": { 49 | "hashes": [ 50 | "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", 51 | "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" 52 | ], 53 | "version": "==1.25.8" 54 | } 55 | }, 56 | "develop": {} 57 | } 58 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | cloudflare-ddns 3 | =============== 4 | .. image:: https://img.shields.io/pypi/v/cloudflare-ddns.svg 5 | :target: https://pypi.python.org/pypi/cloudflare-ddns 6 | 7 | .. image:: https://img.shields.io/pypi/l/cloudflare-ddns.svg 8 | :target: https://pypi.python.org/pypi/cloudflare-ddns 9 | 10 | .. image:: https://img.shields.io/pypi/wheel/cloudflare-ddns.svg 11 | :target: https://pypi.python.org/pypi/cloudflare-ddns 12 | 13 | .. image:: https://img.shields.io/pypi/pyversions/cloudflare-ddns.svg 14 | :target: https://pypi.python.org/pypi/cloudflare-ddns 15 | 16 | The Python DDNS(Dynamic DNS) script for CloudFlare. It can sync your public IP address to DNS records on CloudFlare. It also provide the RESTful API to operate CloudFlare API v4. 17 | 18 | Installation 19 | ------------ 20 | 21 | .. code:: shell 22 | 23 | pip install cloudflare-ddns 24 | 25 | Examples 26 | -------- 27 | 28 | #. Sync your public ip address to dns record on CloudFlare 29 | 30 | - Use command in command line 31 | 32 | .. code:: shell 33 | 34 | cloudflare-ddns email api_key domain 35 | 36 | - Print command line help 37 | 38 | .. code:: shell 39 | 40 | cloudflare-ddns --help 41 | 42 | - Execute python package in command line 43 | 44 | .. code:: shell 45 | 46 | python -m cloudflare_ddns email api_key domain --proxied 47 | 48 | 49 | - Python code 50 | 51 | .. code:: python 52 | 53 | from cloudflare_ddns import CloudFlare 54 | cf = CloudFlare(email, api_key, domain) 55 | cf.sync_dns_from_my_ip() # Successfully updated IP address from xx.xx.xx.xx to xx.xx.xx.xx 56 | 57 | #. RESTful dns record operation 58 | 59 | .. code:: python 60 | 61 | cf.get_record('A', 'example.com') 62 | 63 | .. code:: python 64 | 65 | cf.create_record('A', 'sub.example.com', '202.202.202.202') 66 | 67 | .. code:: python 68 | 69 | cf.update_record('A', 'another.example.com', '202.202.202.202') 70 | 71 | .. code:: python 72 | 73 | cf.delete_record('A', 'another.example.com') 74 | 75 | *Please note: The class will cache dns records information it gets from CloudFlare. To refresh cache, call 'refresh' method:* 76 | 77 | .. code:: python 78 | 79 | cf.refresh() 80 | -------------------------------------------------------------------------------- /cloudflare_ddns/__init__.py: -------------------------------------------------------------------------------- 1 | from .cloudflare import CloudFlare 2 | -------------------------------------------------------------------------------- /cloudflare_ddns/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudflare_ddns.__main__: executed when package directory is called as script. 3 | """ 4 | from .cloudflare import main 5 | main() 6 | -------------------------------------------------------------------------------- /cloudflare_ddns/cloudflare.py: -------------------------------------------------------------------------------- 1 | """ 2 | CloudFlare dns tools. 3 | """ 4 | import sys 5 | import argparse 6 | import socket 7 | import urllib.parse 8 | import requests 9 | from .exceptions import ZoneNotFound, RecordNotFound 10 | 11 | 12 | def main(): 13 | parser = argparse.ArgumentParser() 14 | parser.add_argument('email') 15 | parser.add_argument('api_key') 16 | parser.add_argument('domain') 17 | parser.add_argument('--proxied', '-p', dest='proxied', action='store_true', default=False, 18 | help='Enable the Cloudflare proxy for the record') 19 | args = parser.parse_args() 20 | cf = CloudFlare(**vars(args)) 21 | cf.sync_dns_from_my_ip() 22 | 23 | 24 | class CloudFlare: 25 | """ 26 | CloudFlare dns tools class 27 | """ 28 | api_url = 'https://api.cloudflare.com/client/v4/zones/' 29 | 30 | email = '' 31 | 32 | api_key = '' 33 | 34 | proxied = False 35 | 36 | headers = None 37 | 38 | domain = None 39 | 40 | zone = None 41 | 42 | dns_records = None 43 | 44 | public_ip_finder = ( 45 | 'https://api.ipify.org/', 46 | 'https://jsonip.com/', 47 | 'https://ifconfig.co/json' 48 | ) 49 | 50 | def __init__(self, email: str, api_key: str, domain: str, proxied: bool = False): 51 | """ 52 | Initialization. It will set the zone information of the domain for operation. 53 | It will also get dns records of the current zone. 54 | :param email: 55 | :param api_key: 56 | :param domain: 57 | :param proxied: 58 | """ 59 | self.email = email 60 | self.api_key = api_key 61 | self.domain = domain 62 | self.proxied = proxied 63 | self.headers = { 64 | 'X-Auth-Key': api_key, 65 | 'X-Auth-Email': email 66 | } 67 | self.setup_zone() 68 | 69 | def request(self, url, method, data=None): 70 | """ 71 | The requester shortcut to submit a http request to CloutFlare 72 | :param url: 73 | :param method: 74 | :param data: 75 | :return: 76 | """ 77 | method = getattr(requests, method) 78 | response = method( 79 | url, 80 | headers=self.headers, 81 | json=data 82 | ) 83 | content = response.json() 84 | if response.status_code != 200: 85 | print(content) 86 | raise requests.HTTPError(content['message']) 87 | return content 88 | 89 | def setup_zone(self): 90 | """ 91 | Setup zone for current domain. 92 | It will also setup the dns records of the zone 93 | :return: 94 | """ 95 | # Initialize current zone 96 | zones_content = self.request(self.api_url, 'get') 97 | domain_segments = self.domain.split(".") 98 | 99 | # Join the last two segments of the domain name. 100 | domain = domain_segments[-2] + "." + domain_segments[-1] 101 | 102 | try: 103 | zone = [zone for zone in zones_content['result'] if zone['name'] == domain][0] 104 | except IndexError: 105 | # if that's not on the list, try with three segments instead 106 | domain = domain_segments[-3] + "." + domain 107 | try: 108 | zone = [zone for zone in zones_content['result'] if zone['name'] == domain][0] 109 | except IndexError: 110 | raise ZoneNotFound('Cannot find zone information for the domain {domain}.' 111 | .format(domain=self.domain)) 112 | self.zone = zone 113 | 114 | # Initialize dns_records of current zone 115 | dns_content = self.request(self.api_url + zone['id'] + '/dns_records', 'get') 116 | self.dns_records = dns_content['result'] 117 | 118 | def refresh(self): 119 | """ 120 | Shortcut for setup zone 121 | :return: 122 | """ 123 | self.setup_zone() 124 | 125 | def get_record(self, dns_type, name): 126 | """ 127 | Get a dns record 128 | :param dns_type: 129 | :param name: 130 | :return: 131 | """ 132 | try: 133 | record = [record for record in self.dns_records 134 | if record['type'] == dns_type and record['name'] == name][0] 135 | except IndexError: 136 | raise RecordNotFound( 137 | 'Cannot find the specified dns record in domain {domain}' 138 | .format(domain=name)) 139 | return record 140 | 141 | def create_record(self, dns_type, name, content, **kwargs): 142 | """ 143 | Create a dns record 144 | :param dns_type: 145 | :param name: 146 | :param content: 147 | :param kwargs: 148 | :return: 149 | """ 150 | data = { 151 | 'type': dns_type, 152 | 'name': name, 153 | 'content': content 154 | } 155 | if kwargs.get('ttl') and kwargs['ttl'] != 1: 156 | data['ttl'] = kwargs['ttl'] 157 | if kwargs.get('proxied') is True: 158 | data['proxied'] = True 159 | else: 160 | data['proxied'] = False 161 | content = self.request( 162 | self.api_url + self.zone['id'] + '/dns_records', 163 | 'post', 164 | data=data 165 | ) 166 | self.dns_records.append(content['result']) 167 | print('DNS record successfully created') 168 | return content['result'] 169 | 170 | def update_record(self, dns_type, name, content, **kwargs): 171 | """ 172 | Update dns record 173 | :param dns_type: 174 | :param name: 175 | :param content: 176 | :param kwargs: 177 | :return: 178 | """ 179 | record = self.get_record(dns_type, name) 180 | data = { 181 | 'type': dns_type, 182 | 'name': name, 183 | 'content': content 184 | } 185 | if kwargs.get('ttl') and kwargs['ttl'] != 1: 186 | data['ttl'] = kwargs['ttl'] 187 | if kwargs.get('proxied') is True: 188 | data['proxied'] = True 189 | else: 190 | data['proxied'] = False 191 | content = self.request( 192 | urllib.parse.urljoin(self.api_url, self.zone['id'] + '/dns_records/' + record['id']), 193 | 'put', 194 | data=data 195 | ) 196 | record.update(content['result']) 197 | print('DNS record successfully updated') 198 | return content['result'] 199 | 200 | def create_or_update_record(self, dns_type, name, content, **kwargs): 201 | """ 202 | Create a dns record. Update it if the record already exists. 203 | :param dns_type: 204 | :param name: 205 | :param content: 206 | :param kwargs: 207 | :return: 208 | """ 209 | try: 210 | return self.update_record(dns_type, name, content, **kwargs) 211 | except RecordNotFound: 212 | return self.create_record(dns_type, name, content, **kwargs) 213 | 214 | def delete_record(self, dns_type, name): 215 | """ 216 | Delete a dns record 217 | :param dns_type: 218 | :param name: 219 | :return: 220 | """ 221 | record = self.get_record(dns_type, name) 222 | content = self.request( 223 | urllib.parse.urljoin(self.api_url, self.zone['id'] + '/dns_records/' + record['id']), 224 | 'delete' 225 | ) 226 | cached_record_id = [i for i, rec in enumerate(self.dns_records) if rec['id'] == content['result']['id']][0] 227 | del self.dns_records[cached_record_id] 228 | return content['result']['id'] 229 | 230 | def sync_dns_from_my_ip(self, dns_type='A'): 231 | """ 232 | Sync dns from my public ip address. 233 | It will not do update if ip address in dns record is already same as 234 | current public ip address. 235 | :param dns_type: 236 | :return: 237 | """ 238 | ip_address = '' 239 | for finder in self.public_ip_finder: 240 | try: 241 | result = requests.get(finder) 242 | except requests.RequestException: 243 | continue 244 | if result.status_code == 200: 245 | try: 246 | socket.inet_aton(result.text) 247 | ip_address = result.text 248 | break 249 | except socket.error: 250 | try: 251 | socket.inet_aton(result.json().get('ip')) 252 | ip_address = result.json()['ip'] 253 | break 254 | except socket.error: 255 | continue 256 | 257 | if ip_address == '': 258 | print('None of public ip finder is working. Please try later') 259 | sys.exit(1) 260 | 261 | try: 262 | record = self.get_record(dns_type, self.domain) \ 263 | if len(self.domain.split('.')) == 3 \ 264 | else self.get_record(dns_type, self.domain) 265 | except RecordNotFound: 266 | self.create_record(dns_type, self.domain, ip_address, proxied=self.proxied) 267 | print('Successfully created new record with IP address {new_ip}' 268 | .format(new_ip=ip_address)) 269 | else: 270 | if record['content'] != ip_address: 271 | old_ip = record['content'] 272 | self.update_record(dns_type, self.domain, ip_address, proxied=record['proxied']) 273 | print('Successfully updated IP address from {old_ip} to {new_ip}' 274 | .format(old_ip=old_ip, new_ip=ip_address)) 275 | else: 276 | print('IP address on CloudFlare is same as your current address') 277 | -------------------------------------------------------------------------------- /cloudflare_ddns/exceptions.py: -------------------------------------------------------------------------------- 1 | class CloudFlareError(Exception): 2 | """ 3 | Base exception for CloudFlare module 4 | """ 5 | pass 6 | 7 | 8 | class ZoneNotFound(CloudFlareError): 9 | """ 10 | Raised when a specified zone is not found from CloudFlare 11 | """ 12 | pass 13 | 14 | 15 | class RecordNotFound(CloudFlareError): 16 | """ 17 | Raised when a specified record is not found for a zone from CloudFlare 18 | """ 19 | pass 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import find_packages, setup 3 | 4 | base = os.path.abspath(os.path.dirname(__file__)) 5 | 6 | with open(os.path.join(base, 'README.rst'), encoding='utf-8') as readme: 7 | README = readme.read() 8 | 9 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 10 | 11 | setup( 12 | name='cloudflare-ddns', 13 | version='1.4.0', 14 | description='DDNS script to sync public IP address to CloudFlare dns records', 15 | long_description=README, 16 | url='https://github.com/ailionx/cloudflare-ddns', 17 | author='Shawn Lin', 18 | author_email='ailionxy@gmail.com', 19 | packages=find_packages(exclude=['contrib', 'docs', 'tests*']), 20 | include_package_data=True, 21 | license='MIT', 22 | keywords='cloudflare ddns', 23 | classifiers=[ 24 | 'Development Status :: 5 - Production/Stable', 25 | 'Intended Audience :: Developers', 26 | 'License :: OSI Approved :: MIT License', 27 | 'Topic :: Software Development :: Libraries :: Python Modules', 28 | 'Programming Language :: Python :: 3', 29 | ], 30 | install_requires=[ 31 | 'requests' 32 | ], 33 | entry_points={ 34 | 'console_scripts': [ 35 | 'cloudflare-ddns=cloudflare_ddns.cloudflare:main' 36 | ], 37 | } 38 | ) 39 | --------------------------------------------------------------------------------