├── .git-blame-ignore-revs ├── .git_hooks_pre-commit ├── .github └── workflows │ ├── main.yml │ └── stale.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs └── assets │ └── cf_token_example.PNG ├── octodns_cloudflare ├── __init__.py └── processor │ ├── __init__.py │ ├── proxycname.py │ └── ttl.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── script ├── bootstrap ├── cibuild ├── cibuild-setup-py ├── coverage ├── format ├── lint ├── release ├── test └── update-requirements ├── setup.py └── tests ├── config └── unit.tests.yaml ├── fixtures ├── cloudflare-dns_records-page-1.json ├── cloudflare-dns_records-page-2.json ├── cloudflare-dns_records-page-3.json ├── cloudflare-pagerules.json ├── cloudflare-zones-page-1.json ├── cloudflare-zones-page-2.json └── cloudflare-zones-page-3.json ├── test_octodns_provider_cloudflare.py ├── test_octodns_provider_cloudflare_processor_proxycname.py └── test_octodns_provider_cloudflare_processor_ttl.py /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Commit that added in black formatting support 2 | ee2a7fc0f095019cbfdaf4507686663e333dbaf5 3 | # Commit for isort formatting changes 4 | f72160eccc3863857557c98a44ec366ef73c9dc3 5 | # black 24.x 6 | 1a8265881cb39515bbf9f099aa8f24d3ec2de91b 7 | -------------------------------------------------------------------------------- /.git_hooks_pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | HOOKS=$(dirname "$0") 6 | GIT=$(dirname "$HOOKS") 7 | ROOT=$(dirname "$GIT") 8 | 9 | . "$ROOT/env/bin/activate" 10 | "$ROOT/script/lint" 11 | "$ROOT/script/format" --check --quiet || (echo "Formatting check failed, run ./script/format" && exit 1) 12 | "$ROOT/script/coverage" 13 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: octoDNS CloudflareProvider 2 | on: 3 | pull_request: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | config: 8 | runs-on: ubuntu-latest 9 | outputs: 10 | json: ${{ steps.load.outputs.json }} 11 | steps: 12 | - id: load 13 | run: | 14 | { 15 | echo 'json<> $GITHUB_OUTPUT 19 | ci: 20 | needs: config 21 | runs-on: ubuntu-latest 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | python-version: ${{ fromJson(needs.config.outputs.json).python_versions_active }} 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Setup python 29 | uses: actions/setup-python@v4 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | architecture: x64 33 | - name: CI Build 34 | run: | 35 | ./script/cibuild 36 | setup-py: 37 | needs: config 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v4 41 | - name: Setup python 42 | uses: actions/setup-python@v4 43 | with: 44 | python-version: ${{ fromJson(needs.config.outputs.json).python_version_current }} 45 | architecture: x64 46 | - name: CI setup.py 47 | run: | 48 | ./script/cibuild-setup-py 49 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '42 4 * * *' 5 | workflow_dispatch: 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/stale@v8 12 | with: 13 | stale-issue-message: 'This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 7 days.' 14 | days-before-stale: 90 15 | days-before-close: 7 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .coverage 3 | .eggs/ 4 | .env 5 | /build/ 6 | /config/ 7 | coverage.xml 8 | dist/ 9 | env/ 10 | htmlcov/ 11 | nosetests.xml 12 | octodns_*.egg-info/ 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v1.0.0 - 2025-05-03 - Long overdue 1.0 2 | 3 | Noteworthy Changes: 4 | 5 | * Complete removal of SPF record support, records should be transitioned to TXT 6 | values before updating to this version. 7 | 8 | Changes: 9 | 10 | * Address pending octoDNS 2.x deprecations, require minimum of 1.5.x 11 | * Correctly quote and chunk TXT records to match Cloudflare's internal behavior 12 | 13 | ## v0.0.9 - 2025-02-06 - Unknown nameservers are a thing 14 | 15 | * Handle cases where Cloudflare doesn't return a zones name servers. 16 | 17 | ## v0.0.8 - 2025-02-06 - More options 18 | 19 | * Add support for optionally retrying requests that hit 403 errors 20 | * Add a zone_id lookup fallback when deleting records 21 | * Add support for setting Cloudflare plan type for zones 22 | 23 | ## v0.0.7 - 2024-08-20 - DS always come second 24 | 25 | * Create DS records after their sibling NS records to appease Cloudflare's 26 | validations 27 | * Throw an error when trying to create a DS without a coresponding NS, 28 | `strict_supports: false` will omit the DS instead 29 | * Add support for SVCB and HTTPS record types 30 | 31 | ## v0.0.6 - 2024-05-22 - Deal with unknowns and make more knowns 32 | 33 | * Fix handling of unsupported record types during apply 34 | * DS record type support 35 | 36 | ## v0.0.5 - 2024-04-15 - Comment on your proxying and ttls 37 | 38 | * TtlToProxy processor added to enable the proxied flag based on a sentinel 39 | ttl value. Useful when the source is not YamlProvider 40 | * Add support for comments & tags via octodns.cloudflare.comment|tags 41 | * ProxyCNAME processor added to aid in supporting Cloudflare prixed values 42 | with non-Cloudflare DNS providers by directing them to the relevant 43 | .cdn.cloudflare.net. CNAME / ALIAS value. 44 | 45 | ## v0.0.4 - 2024-02-08 - Know your zones 46 | 47 | * Support for Provider.list_zones to enable dynamic zone config when operating 48 | as a source 49 | * Support for auto-ttl without proxied as records can be configured that way, 50 | see auto-ttl in README.md for more info 51 | * Fix bug in handling of empty strings/content on TXT records 52 | * Make the minumum supported TTL configurable. 53 | 54 | ## v0.0.3 - 2023-09-20 - All the commits fit to release 55 | 56 | * SPF records can no longer be created, 57 | https://github.com/octodns/octodns-cloudflare/issues/28 58 | * NAPTR and SSHFP support added 59 | * All HTTP requests include a meaningful user-agent. 60 | * AccountID filter support 61 | * API token auth method/doc 62 | 63 | ## v0.0.2 - 2022-12-25 - Holiday Edition 64 | 65 | * Added support for TLSA record type 66 | * Switched to pytest and updated everything to latest template setup 67 | 68 | ## v0.0.1 - 2022-01-05 - Moving 69 | 70 | #### Nothworthy Changes 71 | 72 | * Initial extraction of CloudflareProvider from octoDNS core 73 | 74 | #### Stuff 75 | 76 | Nothing 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ross McFarland & the octoDNS Maintainers 4 | Copyright (c) 2017 GitHub, Inc. 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the "Software"), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Cloudflare provider for octoDNS 2 | 3 | An [octoDNS](https://github.com/octodns/octodns/) provider that targets [Cloudflare](https://www.cloudflare.com/dns/). 4 | 5 | ### Installation 6 | 7 | #### Command line 8 | 9 | ``` 10 | pip install octodns-cloudflare 11 | ``` 12 | 13 | #### requirements.txt/setup.py 14 | 15 | Pinning specific versions or SHAs is recommended to avoid unplanned upgrades. 16 | 17 | ##### Versions 18 | 19 | ``` 20 | # Start with the latest versions and don't just copy what's here 21 | octodns==0.9.14 22 | octodns-cloudflare==0.0.1 23 | ``` 24 | 25 | ##### SHAs 26 | 27 | ``` 28 | # Start with the latest/specific versions and don't just copy what's here 29 | -e git+https://git@github.com/octodns/octodns.git@9da19749e28f68407a1c246dfdf65663cdc1c422#egg=octodns 30 | -e git+https://git@github.com/octodns/octodns-cloudflare.git@ec9661f8b335241ae4746eea467a8509205e6a30#egg=octodns_cloudflare 31 | ``` 32 | 33 | ### Configuration 34 | 35 | ```yaml 36 | providers: 37 | cloudflare: 38 | class: octodns_cloudflare.CloudflareProvider 39 | # Your Cloudflare account email address (not needed if using token) 40 | # setting email along with an API Token will raise an error. 41 | email: env/CLOUDFLARE_EMAIL 42 | # The API Token or API Key. 43 | # Required permissions for API Tokens are Zone:Read, DNS:Read and DNS:Edit. 44 | # Page Rules:Edit is required for managing Page Rules (URLFWD) records. 45 | token: env/CLOUDFLARE_TOKEN 46 | # Optional. Filter by account ID in environments where a token has access 47 | # across more than the permitted number of accounts allowed by Cloudflare. 48 | account_id: env/CLOUDFLARE_ACCOUNT_ID 49 | # Import CDN enabled records as CNAME to {}.cdn.cloudflare.net. Records 50 | # ending at .cdn.cloudflare.net. will be ignored when this provider is 51 | # not used as the source and the cdn option is enabled. 52 | # 53 | # See: https://support.cloudflare.com/hc/en-us/articles/115000830351 54 | #cdn: false 55 | # Manage Page Rules (URLFWD) records 56 | # pagerules: true 57 | # Optional. Define Cloudflare plan type for the zones. Default: free, 58 | # options: free, pro, business, enterprise 59 | #plan_type: free 60 | # Optional. Default: 4. Number of times to retry if a 429 response 61 | # is received. 62 | #retry_count: 4 63 | # Optional. Default: 0. Number of times to retry if a 403 response 64 | # is received. 65 | #auth_error_retry_count: 0 66 | # Optional. Default: 300. Number of seconds to wait before retrying. 67 | #retry_period: 300 68 | # Optional. Default: 50. Number of zones per page. 69 | #zones_per_page: 50 70 | # Optional. Default: 100. Number of dns records per page. 71 | #records_per_page: 100 72 | # Optional. Default: 120. Lowest TTL allowed to be set. 73 | # A different limit for (non-)enterprise zone applies. 74 | # See: https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl 75 | #min_ttl: 120 76 | ``` 77 | 78 | Note: The "proxied" flag of "A", "AAAA" and "CNAME" records can be managed via the YAML provider like so: 79 | 80 | ```yaml 81 | name: 82 | octodns: 83 | cloudflare: 84 | proxied: true 85 | # auto-ttl true is implied by proxied true, but can be explicitly 86 | # configured to be more complete 87 | #auto-ttl: true 88 | # with proxied=true, the TTL here will be ignored by CloudflareProvider 89 | ttl: 120 90 | type: A 91 | value: 1.2.3.4 92 | ``` 93 | 94 | Note: All record types support "auto" ttl, which is effectively equivalent to 300s. 95 | 96 | ```yaml 97 | name: 98 | octodns: 99 | cloudflare: 100 | auto-ttl: true 101 | # with proxied=true, the TTL here will be ignored by CloudflareProvider 102 | ttl: 120 103 | type: A 104 | value: 1.2.3.4 105 | ``` 106 | 107 | ### Support Information 108 | 109 | #### Records 110 | 111 | CloudflareProvider supports A, AAAA, ALIAS, CAA, CNAME, DS, LOC, MX, NAPTR, NS, PTR, SPF, SRV, SSHFP, TXT, and URLFWD. There are restrictions on CAA tag support. 112 | 113 | #### Root NS Records 114 | 115 | CloudflareProvider does not supports root NS record management. They can partially be managed in the API, errors are thrown if you include the Cloudflare name servers in the values, but the system completely ignores the values set and serves up its own regardless. 116 | 117 | #### Dynamic 118 | 119 | CloudflareProvider does not support dynamic records. 120 | 121 | #### Required API Token Permissions 122 | 123 | Required Permissions for API Token are Zone:Read, DNS:Read, and DNS:Edit. 124 | Page Rules:Edit is also required for managing Page Rules (URLFWD) records, otherwise an authentication error will be raised. 125 | 126 | **Important Note:** When using a CloudFlare token you should **NOT** provide an email address or you will receive an error. 127 | 128 | An example when using Page Rules (URLFWD) records - 129 | 130 | ![Cloudflare API token config example screenshot](./docs/assets/cf_token_example.PNG) 131 | 132 | #### TTL 133 | 134 | Cloudflare has a different minimum TTL for enterprise and non-enterprise zones. See the [documentation](https://developers.cloudflare.com/dns/manage-dns-records/reference/ttl) for more information. 135 | In the past the CloudflareProvider had a fixed minimum TTL set to 120 seconds and for backwards compatibility this is the current default. 136 | 137 | ### Processors 138 | 139 | | Processor | Description | 140 | |--|--| 141 | | [ProxyCNAME](/octodns_cloudflare/processor/proxycname.py) | Allows Cloudflare proxied records to be used on other providers without exposing the proxied record value. Points other providers to the relevant `.cdn.cloudflare.net` subdomain. Useful to allow split authority with a secondary provider while still retaining Cloudflare benefits for certain records. | 142 | | [TtlToProxy ](/octodns_cloudflare/processor/ttl.py) | Ensure Cloudflare's proxy status is setup depending on the TTL set for the record. This can be helpful for `octodns_bind.ZoneFileSource` or the like. | 143 | 144 | ### Developement 145 | 146 | See the [/script/](/script/) directory for some tools to help with the development process. They generally follow the [Script to rule them all](https://github.com/github/scripts-to-rule-them-all) pattern. Most useful is `./script/bootstrap` which will create a venv and install both the runtime and development related requirements. It will also hook up a pre-commit hook that covers most of what's run by CI. 147 | -------------------------------------------------------------------------------- /docs/assets/cf_token_example.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/octodns/octodns-cloudflare/e958c7812eebc2984efe787fd79fafa69c8c4ee8/docs/assets/cf_token_example.PNG -------------------------------------------------------------------------------- /octodns_cloudflare/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # 3 | # 4 | 5 | from collections import defaultdict 6 | from io import StringIO 7 | from logging import getLogger 8 | from time import sleep 9 | from urllib.parse import urlsplit 10 | 11 | from requests import Session 12 | 13 | from octodns import __VERSION__ as octodns_version 14 | from octodns.idna import IdnaDict 15 | from octodns.provider import ProviderException, SupportsException 16 | from octodns.provider.base import BaseProvider 17 | from octodns.record import Create, Record, Update 18 | 19 | try: # pragma: no cover 20 | from octodns.record.https import HttpsValue 21 | from octodns.record.svcb import SvcbValue 22 | 23 | SUPPORTS_SVCB = True 24 | except ImportError: # pragma: no cover 25 | SUPPORTS_SVCB = False 26 | 27 | # TODO: remove __VERSION__ with the next major version release 28 | __version__ = __VERSION__ = '1.0.0' 29 | 30 | 31 | class CloudflareError(ProviderException): 32 | 33 | def __init__(self, data): 34 | try: 35 | message = data['errors'][0]['message'] 36 | except (IndexError, KeyError, TypeError): 37 | message = 'Cloudflare error' 38 | super().__init__(message) 39 | 40 | 41 | class CloudflareAuthenticationError(CloudflareError): 42 | 43 | def __init__(self, data): 44 | CloudflareError.__init__(self, data) 45 | 46 | 47 | class CloudflareRateLimitError(CloudflareError): 48 | 49 | def __init__(self, data): 50 | CloudflareError.__init__(self, data) 51 | 52 | 53 | class Cloudflare5xxError(CloudflareError): 54 | 55 | def __init__(self, data): 56 | CloudflareError.__init__(self, data) 57 | 58 | 59 | _PROXIABLE_RECORD_TYPES = {'A', 'AAAA', 'ALIAS', 'CNAME'} 60 | 61 | 62 | class CloudflareProvider(BaseProvider): 63 | SUPPORTS_GEO = False 64 | SUPPORTS_DYNAMIC = False 65 | SUPPORTS = set( 66 | ( 67 | 'ALIAS', 68 | 'A', 69 | 'AAAA', 70 | 'CAA', 71 | 'CNAME', 72 | 'DS', 73 | 'LOC', 74 | 'MX', 75 | 'NAPTR', 76 | 'NS', 77 | 'PTR', 78 | 'SSHFP', 79 | 'SRV', 80 | 'TLSA', 81 | 'TXT', 82 | ) 83 | ) 84 | 85 | # These are only supported if we have a new enough octoDNS core 86 | if SUPPORTS_SVCB: # pragma: no cover 87 | SUPPORTS.add('HTTPS') 88 | SUPPORTS.add('SVCB') 89 | 90 | TIMEOUT = 15 91 | 92 | def __init__( 93 | self, 94 | id, 95 | email=None, 96 | token=None, 97 | account_id=None, 98 | cdn=False, 99 | pagerules=True, 100 | plan_type=None, 101 | retry_count=4, 102 | retry_period=300, 103 | auth_error_retry_count=0, 104 | zones_per_page=50, 105 | records_per_page=100, 106 | min_ttl=120, 107 | *args, 108 | **kwargs, 109 | ): 110 | self.log = getLogger(f'CloudflareProvider[{id}]') 111 | self.log.debug( 112 | '__init__: id=%s, email=%s, token=***, account_id=%s, cdn=%s, plan=%s', 113 | id, 114 | email, 115 | account_id, 116 | cdn, 117 | plan_type, 118 | ) 119 | super().__init__(id, *args, **kwargs) 120 | 121 | sess = Session() 122 | if email and token: 123 | sess.headers.update({'X-Auth-Email': email, 'X-Auth-Key': token}) 124 | else: 125 | # https://api.cloudflare.com/#getting-started-requests 126 | # https://tools.ietf.org/html/rfc6750#section-2.1 127 | sess.headers.update({'Authorization': f'Bearer {token}'}) 128 | sess.headers.update( 129 | { 130 | 'User-Agent': f'octodns/{octodns_version} octodns-cloudflare/{__VERSION__}' 131 | } 132 | ) 133 | self.account_id = account_id 134 | self.cdn = cdn 135 | self.pagerules = pagerules 136 | self.plan_type = plan_type 137 | self.retry_count = retry_count 138 | self.retry_period = retry_period 139 | self.auth_error_retry_count = auth_error_retry_count 140 | self.zones_per_page = zones_per_page 141 | self.records_per_page = records_per_page 142 | self.min_ttl = min_ttl 143 | self._sess = sess 144 | 145 | self._zones = None 146 | self._zone_records = {} 147 | if self.pagerules: 148 | # copy the class static/ever present list of supported types into 149 | # an instance property so that when we modify it we won't change 150 | # the shared version 151 | self.SUPPORTS = set(self.SUPPORTS) 152 | self.SUPPORTS.add('URLFWD') 153 | 154 | def _try_request(self, *args, **kwargs): 155 | tries = self.retry_count 156 | auth_tries = self.auth_error_retry_count 157 | while True: # We'll raise to break after our tries expire 158 | try: 159 | return self._request(*args, **kwargs) 160 | except CloudflareRateLimitError: 161 | if tries <= 1: 162 | raise 163 | tries -= 1 164 | self.log.warning( 165 | 'rate limit encountered, pausing ' 166 | 'for %ds and trying again, %d remaining', 167 | self.retry_period, 168 | tries, 169 | ) 170 | sleep(self.retry_period) 171 | except CloudflareAuthenticationError: 172 | if auth_tries <= 0: 173 | raise 174 | auth_tries -= 1 175 | self.log.warning( 176 | 'authentication error encountered, pausing ' 177 | 'for %ds and trying again, %d remaining', 178 | self.retry_period, 179 | auth_tries, 180 | ) 181 | sleep(self.retry_period) 182 | except Cloudflare5xxError: 183 | if tries <= 0: 184 | raise 185 | tries -= 1 186 | self.log.warning( 187 | 'http 502 error encountered, pausing ' 188 | 'for %ds and trying again, %d remaining', 189 | self.retry_period, 190 | tries, 191 | ) 192 | sleep(self.retry_period) 193 | 194 | def _request(self, method, path, params=None, data=None): 195 | self.log.debug('_request: method=%s, path=%s', method, path) 196 | 197 | url = f'https://api.cloudflare.com/client/v4{path}' 198 | resp = self._sess.request( 199 | method, url, params=params, json=data, timeout=self.TIMEOUT 200 | ) 201 | self.log.debug('_request: status=%d', resp.status_code) 202 | if resp.status_code == 400: 203 | self.log.debug('_request: data=%s', data) 204 | raise CloudflareError(resp.json()) 205 | if resp.status_code == 403: 206 | raise CloudflareAuthenticationError(resp.json()) 207 | if resp.status_code == 429: 208 | raise CloudflareRateLimitError(resp.json()) 209 | if resp.status_code in [502, 503]: 210 | raise Cloudflare5xxError("http 5xx") 211 | resp.raise_for_status() 212 | return resp.json() 213 | 214 | def _change_keyer(self, change): 215 | _type = change.record._type 216 | if _type == 'DS' and isinstance(change, Create): 217 | # when creating records in CF the NS for a node must come before the 218 | # DS so we need to flip their order. when deleting they'll already 219 | # be in the required order 220 | _type = 'ZDS' 221 | return (change.CLASS_ORDERING, change.record.name, _type) 222 | 223 | @property 224 | def zones(self): 225 | if self._zones is None: 226 | page = 1 227 | zones = [] 228 | while page: 229 | params = {'page': page, 'per_page': self.zones_per_page} 230 | if self.account_id is not None: 231 | params['account.id'] = self.account_id 232 | resp = self._try_request('GET', '/zones', params=params) 233 | zones += resp['result'] 234 | info = resp['result_info'] 235 | if info['count'] > 0 and info['count'] == info['per_page']: 236 | page += 1 237 | else: 238 | page = None 239 | 240 | self._zones = IdnaDict( 241 | { 242 | f'{z["name"]}.': { 243 | 'id': z['id'], 244 | 'cloudflare_plan': z.get('plan', {}).get( 245 | 'legacy_id', None 246 | ), 247 | 'name_servers': z.get('name_servers', []), 248 | } 249 | for z in zones 250 | } 251 | ) 252 | 253 | return self._zones 254 | 255 | def _ttl_data(self, ttl): 256 | return 300 if ttl == 1 else ttl 257 | 258 | def _data_for_cdn(self, name, _type, records): 259 | self.log.info('CDN rewrite for %s', records[0]['name']) 260 | _type = "CNAME" 261 | if name == "": 262 | _type = "ALIAS" 263 | 264 | return { 265 | 'ttl': self._ttl_data(records[0]['ttl']), 266 | 'type': _type, 267 | 'value': f'{records[0]["name"]}.cdn.cloudflare.net.', 268 | } 269 | 270 | def _data_for_multiple(self, _type, records): 271 | return { 272 | 'ttl': self._ttl_data(records[0]['ttl']), 273 | 'type': _type, 274 | 'values': [r['content'] for r in records], 275 | } 276 | 277 | _data_for_A = _data_for_multiple 278 | _data_for_AAAA = _data_for_multiple 279 | _data_for_SPF = _data_for_multiple 280 | 281 | def _data_for_TXT(self, _type, records): 282 | return { 283 | 'ttl': self._ttl_data(records[0]['ttl']), 284 | 'type': _type, 285 | 'values': [ 286 | r.get('content', '').replace(';', '\\;') for r in records 287 | ], 288 | } 289 | 290 | def _data_for_CAA(self, _type, records): 291 | values = [] 292 | for r in records: 293 | data = r['data'] 294 | values.append(data) 295 | return { 296 | 'ttl': self._ttl_data(records[0]['ttl']), 297 | 'type': _type, 298 | 'values': values, 299 | } 300 | 301 | def _data_for_CNAME(self, _type, records): 302 | only = records[0] 303 | return { 304 | 'ttl': self._ttl_data(only['ttl']), 305 | 'type': _type, 306 | 'value': f'{only["content"]}.' if only['content'] != '.' else '.', 307 | } 308 | 309 | _data_for_ALIAS = _data_for_CNAME 310 | _data_for_PTR = _data_for_CNAME 311 | 312 | def _data_for_DS(self, _type, records): 313 | values = [] 314 | for record in records: 315 | key_tag, algorithm, digest_type, digest = record['content'].split( 316 | ' ', 3 317 | ) 318 | values.append( 319 | { 320 | 'algorithm': int(algorithm), 321 | 'digest': digest, 322 | 'digest_type': digest_type, 323 | 'key_tag': int(key_tag), 324 | } 325 | ) 326 | return { 327 | 'type': _type, 328 | 'values': values, 329 | 'ttl': self._ttl_data(records[0]['ttl']), 330 | } 331 | 332 | def _data_for_LOC(self, _type, records): 333 | values = [] 334 | for record in records: 335 | r = record['data'] 336 | values.append( 337 | { 338 | 'lat_degrees': int(r['lat_degrees']), 339 | 'lat_minutes': int(r['lat_minutes']), 340 | 'lat_seconds': float(r['lat_seconds']), 341 | 'lat_direction': r['lat_direction'], 342 | 'long_degrees': int(r['long_degrees']), 343 | 'long_minutes': int(r['long_minutes']), 344 | 'long_seconds': float(r['long_seconds']), 345 | 'long_direction': r['long_direction'], 346 | 'altitude': float(r['altitude']), 347 | 'size': float(r['size']), 348 | 'precision_horz': float(r['precision_horz']), 349 | 'precision_vert': float(r['precision_vert']), 350 | } 351 | ) 352 | return { 353 | 'ttl': self._ttl_data(records[0]['ttl']), 354 | 'type': _type, 355 | 'values': values, 356 | } 357 | 358 | def _data_for_MX(self, _type, records): 359 | values = [] 360 | for r in records: 361 | values.append( 362 | { 363 | 'preference': r['priority'], 364 | 'exchange': ( 365 | f'{r["content"]}.' if r['content'] != '.' else '.' 366 | ), 367 | } 368 | ) 369 | return { 370 | 'ttl': self._ttl_data(records[0]['ttl']), 371 | 'type': _type, 372 | 'values': values, 373 | } 374 | 375 | def _data_for_NAPTR(self, _type, records): 376 | values = [] 377 | for r in records: 378 | data = r['data'] 379 | values.append( 380 | { 381 | 'flags': data['flags'], 382 | 'order': data['order'], 383 | 'preference': data['preference'], 384 | 'regexp': data['regex'], 385 | 'replacement': data['replacement'], 386 | 'service': data['service'], 387 | } 388 | ) 389 | return { 390 | 'ttl': self._ttl_data(records[0]['ttl']), 391 | 'type': _type, 392 | 'values': values, 393 | } 394 | 395 | def _data_for_NS(self, _type, records): 396 | return { 397 | 'ttl': self._ttl_data(records[0]['ttl']), 398 | 'type': _type, 399 | 'values': [f'{r["content"]}.' for r in records], 400 | } 401 | 402 | def _data_for_SRV(self, _type, records): 403 | values = [] 404 | for r in records: 405 | target = ( 406 | f'{r["data"]["target"]}.' if r['data']['target'] != "." else "." 407 | ) 408 | values.append( 409 | { 410 | 'priority': r['data']['priority'], 411 | 'weight': r['data']['weight'], 412 | 'port': r['data']['port'], 413 | 'target': target, 414 | } 415 | ) 416 | return { 417 | 'type': _type, 418 | 'ttl': self._ttl_data(records[0]['ttl']), 419 | 'values': values, 420 | } 421 | 422 | def _data_for_SVCB(self, _type, records): 423 | values = [] 424 | for r in records: 425 | # it's cleaner/easier to parse the rdata version than CF's broken up 426 | # `data` which is really only half parsed 427 | value = SvcbValue.parse_rdata_text(r['content']) 428 | values.append(value) 429 | return { 430 | 'type': _type, 431 | 'ttl': self._ttl_data(records[0]['ttl']), 432 | 'values': values, 433 | } 434 | 435 | def _data_for_HTTPS(self, _type, records): 436 | values = [] 437 | for r in records: 438 | # it's cleaner/easier to parse the rdata version than CF's broken up 439 | # `data` which is really only half parsed 440 | value = HttpsValue.parse_rdata_text(r['content']) 441 | values.append(value) 442 | return { 443 | 'type': _type, 444 | 'ttl': self._ttl_data(records[0]['ttl']), 445 | 'values': values, 446 | } 447 | 448 | def _data_for_TLSA(self, _type, records): 449 | values = [] 450 | for r in records: 451 | data = r['data'] 452 | values.append( 453 | { 454 | 'certificate_usage': data['usage'], 455 | 'selector': data['selector'], 456 | 'matching_type': data['matching_type'], 457 | 'certificate_association_data': data['certificate'], 458 | } 459 | ) 460 | return { 461 | 'ttl': self._ttl_data(records[0]['ttl']), 462 | 'type': _type, 463 | 'values': values, 464 | } 465 | 466 | def _data_for_URLFWD(self, _type, records): 467 | values = [] 468 | for r in records: 469 | values.append( 470 | { 471 | 'path': r['path'], 472 | 'target': r['url'], 473 | 'code': r['status_code'], 474 | 'masking': 2, 475 | 'query': 0, 476 | } 477 | ) 478 | return { 479 | 'type': _type, 480 | 'ttl': 300, # ttl does not exist for this type, forcing a setting 481 | 'values': values, 482 | } 483 | 484 | def _data_for_SSHFP(self, _type, records): 485 | values = [] 486 | for record in records: 487 | algorithm, fingerprint_type, fingerprint = record['content'].split( 488 | ' ', 2 489 | ) 490 | values.append( 491 | { 492 | 'algorithm': int(algorithm), 493 | 'fingerprint_type': int(fingerprint_type), 494 | 'fingerprint': fingerprint, 495 | } 496 | ) 497 | return { 498 | 'type': _type, 499 | 'values': values, 500 | 'ttl': self._ttl_data(records[0]['ttl']), 501 | } 502 | 503 | def zone_records(self, zone): 504 | if zone.name not in self._zone_records: 505 | zone_id = self.zones.get(zone.name, {}).get('id', False) 506 | if not zone_id: 507 | return [] 508 | 509 | records = [] 510 | path = f'/zones/{zone_id}/dns_records' 511 | page = 1 512 | while page: 513 | resp = self._try_request( 514 | 'GET', 515 | path, 516 | params={'page': page, 'per_page': self.records_per_page}, 517 | ) 518 | # populate DNS records, ensure only supported types are considered 519 | records += [ 520 | record 521 | for record in resp['result'] 522 | if record['type'] in self.SUPPORTS 523 | ] 524 | info = resp['result_info'] 525 | if info['count'] > 0 and info['count'] == info['per_page']: 526 | page += 1 527 | else: 528 | page = None 529 | if self.pagerules: 530 | path = f'/zones/{zone_id}/pagerules' 531 | resp = self._try_request( 532 | 'GET', path, params={'status': 'active'} 533 | ) 534 | for r in resp['result']: 535 | # assumption, base on API guide, will only contain 1 action 536 | if r['actions'][0]['id'] == 'forwarding_url': 537 | records += [r] 538 | 539 | self._zone_records[zone.name] = records 540 | 541 | return self._zone_records[zone.name] 542 | 543 | def _record_for(self, zone, name, _type, records, lenient): 544 | # rewrite Cloudflare proxied records 545 | proxied = records[0].get('proxied', False) 546 | if self.cdn and proxied: 547 | data = self._data_for_cdn(name, _type, records) 548 | else: 549 | # Cloudflare supports ALIAS semantics with root CNAMEs 550 | if _type == 'CNAME' and name == '': 551 | _type = 'ALIAS' 552 | 553 | data_for = getattr(self, f'_data_for_{_type}') 554 | data = data_for(_type, records) 555 | 556 | record = Record.new(zone, name, data, source=self, lenient=lenient) 557 | 558 | proxied = proxied and _type in _PROXIABLE_RECORD_TYPES 559 | auto_ttl = records[0]['ttl'] == 1 560 | if proxied: 561 | self.log.debug('_record_for: proxied=True, auto-ttl=True') 562 | record.octodns['cloudflare'] = {'proxied': True, 'auto-ttl': True} 563 | elif auto_ttl: 564 | # auto-ttl can still be set on any record type, signaled by a ttl=1, 565 | # even if proxied is false. 566 | self.log.debug('_record_for: auto-ttl=True') 567 | record.octodns['cloudflare'] = {'auto-ttl': True} 568 | 569 | # update record comment 570 | if records[0].get('comment'): 571 | try: 572 | record.octodns['cloudflare']['comment'] = records[0]['comment'] 573 | except KeyError: 574 | record.octodns['cloudflare'] = { 575 | 'comment': records[0]['comment'] 576 | } 577 | 578 | # update record tags 579 | if records[0].get('tags'): 580 | try: 581 | record.octodns['cloudflare']['tags'] = records[0]['tags'] 582 | except KeyError: 583 | record.octodns['cloudflare'] = {'tags': records[0]['tags']} 584 | 585 | return record 586 | 587 | def list_zones(self): 588 | return sorted(self.zones.keys()) 589 | 590 | def populate(self, zone, target=False, lenient=False): 591 | self.log.debug( 592 | 'populate: name=%s, target=%s, lenient=%s', 593 | zone.name, 594 | target, 595 | lenient, 596 | ) 597 | 598 | exists = False 599 | before = len(zone.records) 600 | records = self.zone_records(zone) 601 | if records: 602 | exists = True 603 | values = defaultdict(lambda: defaultdict(list)) 604 | for record in records: 605 | if 'targets' in record: 606 | # We shouldn't get in here when pagerules are disabled as 607 | # we won't make the call to fetch the details/them 608 | # 609 | # assumption, targets will always contain 1 target 610 | # API documentation only indicates 'url' as the only target 611 | # if record['targets'][0]['target'] == 'url': 612 | uri = record['targets'][0]['constraint']['value'] 613 | uri = '//' + uri if not uri.startswith('http') else uri 614 | parsed_uri = urlsplit(uri) 615 | name = zone.hostname_from_fqdn(parsed_uri.netloc) 616 | path = parsed_uri.path 617 | _type = 'URLFWD' 618 | # assumption, actions will always contain 1 action 619 | _values = record['actions'][0]['value'] 620 | _values['path'] = path 621 | # no ttl set by pagerule, creating one 622 | _values['ttl'] = 300 623 | values[name][_type].append(_values) 624 | # the dns_records branch 625 | # elif 'name' in record: 626 | else: 627 | name = zone.hostname_from_fqdn(record['name']) 628 | _type = record['type'] 629 | values[name][record['type']].append(record) 630 | 631 | for name, types in values.items(): 632 | for _type, records in types.items(): 633 | record = self._record_for( 634 | zone, name, _type, records, lenient 635 | ) 636 | 637 | # only one rewrite is needed for names where the proxy is 638 | # enabled at multiple records with a different type but 639 | # the same name 640 | if ( 641 | self.cdn 642 | and records[0]['proxied'] 643 | and record in zone._records[name] 644 | ): 645 | self.log.info('CDN rewrite %s already in zone', name) 646 | continue 647 | 648 | zone.add_record(record, lenient=lenient) 649 | 650 | self.log.info( 651 | 'populate: found %s records, exists=%s', 652 | len(zone.records) - before, 653 | exists, 654 | ) 655 | return exists 656 | 657 | def _include_change(self, change): 658 | 659 | if isinstance(change, Update): 660 | new = change.new 661 | new_is_proxied = self._record_is_proxied(new) 662 | new_is_just_auto_ttl = self._record_is_just_auto_ttl(new) 663 | new_is_urlfwd = new._type == 'URLFWD' 664 | new = new.data 665 | 666 | existing = change.existing 667 | existing_is_proxied = self._record_is_proxied(existing) 668 | existing_is_just_auto_ttl = self._record_is_just_auto_ttl(existing) 669 | existing_is_urlfwd = existing._type == 'URLFWD' 670 | existing = existing.data 671 | 672 | if ( 673 | (new_is_proxied != existing_is_proxied) 674 | or (new_is_just_auto_ttl != existing_is_just_auto_ttl) 675 | or (new_is_urlfwd != existing_is_urlfwd) 676 | ): 677 | # changes in special flags, definitely need this change 678 | return True 679 | 680 | # at this point we know that all the special flags match in new and 681 | # existing so we can focus on the actual record details, so we can 682 | # ignore octodns.cloudflare 683 | new.get('octodns', {}).pop('cloudflare', None) 684 | existing.get('octodns', {}).pop('cloudflare', None) 685 | 686 | # TTLs are ignored for these, best way to do that is to just copy 687 | # it over so they'll match 688 | if new_is_proxied or new_is_just_auto_ttl or new_is_urlfwd: 689 | new['ttl'] = existing['ttl'] 690 | 691 | # Cloudflare has a minimum TTL, we need to clamp the TTL values so 692 | # that we ignore a desired state (new) where we can't support the 693 | # TTL 694 | new['ttl'] = max(self.min_ttl, new['ttl']) 695 | existing['ttl'] = max(self.min_ttl, existing['ttl']) 696 | 697 | if new == existing: 698 | return False 699 | 700 | # If this is a record to enable Cloudflare CDN don't update as 701 | # we don't know the original values. 702 | if change.record._type in ( 703 | 'ALIAS', 704 | 'CNAME', 705 | ) and change.record.value.endswith('.cdn.cloudflare.net.'): 706 | return False 707 | 708 | return True 709 | 710 | def _process_desired_zone(self, desired): 711 | dses = {} 712 | nses = set() 713 | for record in desired.records: 714 | if record._type == 'DS': 715 | dses[record.name] = record 716 | elif record._type == 'NS': 717 | nses.add(record.name) 718 | 719 | for name, record in dses.items(): 720 | if name not in nses: 721 | msg = f'DS record {record.fqdn} does not have coresponding NS record and Cloudflare requires it' 722 | fallback = 'omitting the record' 723 | self.supports_warn_or_except(msg, fallback) 724 | desired.remove_record(record) 725 | 726 | return super()._process_desired_zone(desired) 727 | 728 | def _contents_for_multiple(self, record): 729 | for value in record.values: 730 | yield {'content': value} 731 | 732 | _contents_for_A = _contents_for_multiple 733 | _contents_for_AAAA = _contents_for_multiple 734 | _contents_for_NS = _contents_for_multiple 735 | _contents_for_SPF = _contents_for_multiple 736 | 737 | def _contents_for_CAA(self, record): 738 | for value in record.values: 739 | yield { 740 | 'data': { 741 | 'flags': value.flags, 742 | 'tag': value.tag, 743 | 'value': value.value, 744 | } 745 | } 746 | 747 | def _contents_for_DS(self, record): 748 | for value in record.values: 749 | yield { 750 | 'data': { 751 | 'key_tag': value.key_tag, 752 | 'algorithm': value.algorithm, 753 | 'digest_type': value.digest_type, 754 | 'digest': value.digest, 755 | } 756 | } 757 | 758 | def _contents_for_TXT(self, record): 759 | for chunked in record.chunked_values: 760 | yield {'content': chunked.replace('\\;', ';')} 761 | 762 | def _contents_for_CNAME(self, record): 763 | yield {'content': record.value} 764 | 765 | _contents_for_PTR = _contents_for_CNAME 766 | 767 | def _contents_for_LOC(self, record): 768 | for value in record.values: 769 | yield { 770 | 'data': { 771 | 'lat_degrees': value.lat_degrees, 772 | 'lat_minutes': value.lat_minutes, 773 | 'lat_seconds': value.lat_seconds, 774 | 'lat_direction': value.lat_direction, 775 | 'long_degrees': value.long_degrees, 776 | 'long_minutes': value.long_minutes, 777 | 'long_seconds': value.long_seconds, 778 | 'long_direction': value.long_direction, 779 | 'altitude': value.altitude, 780 | 'size': value.size, 781 | 'precision_horz': value.precision_horz, 782 | 'precision_vert': value.precision_vert, 783 | } 784 | } 785 | 786 | def _contents_for_MX(self, record): 787 | for value in record.values: 788 | yield {'priority': value.preference, 'content': value.exchange} 789 | 790 | def _contents_for_NAPTR(self, record): 791 | for value in record.values: 792 | yield { 793 | 'data': { 794 | 'flags': value.flags, 795 | 'order': value.order, 796 | 'preference': value.preference, 797 | 'regex': value.regexp, 798 | 'replacement': value.replacement, 799 | 'service': value.service, 800 | } 801 | } 802 | 803 | def _contents_for_SSHFP(self, record): 804 | for value in record.values: 805 | yield { 806 | 'data': { 807 | 'algorithm': value.algorithm, 808 | 'type': value.fingerprint_type, 809 | 'fingerprint': value.fingerprint, 810 | } 811 | } 812 | 813 | def _contents_for_SRV(self, record): 814 | try: 815 | service, proto, subdomain = record.name.split('.', 2) 816 | # We have a SRV in a sub-zone 817 | except ValueError: 818 | # We have a SRV in the zone 819 | service, proto = record.name.split('.', 1) 820 | subdomain = None 821 | 822 | name = record.zone.name 823 | if subdomain: 824 | name = subdomain 825 | 826 | for value in record.values: 827 | target = value.target[:-1] if value.target != "." else "." 828 | 829 | yield { 830 | 'data': { 831 | 'service': service, 832 | 'proto': proto, 833 | 'name': name, 834 | 'priority': value.priority, 835 | 'weight': value.weight, 836 | 'port': value.port, 837 | 'target': target, 838 | } 839 | } 840 | 841 | def _contents_for_SVCB(self, record): 842 | for value in record.values: 843 | params = StringIO() 844 | for k, v in value.svcparams.items(): 845 | params.write(' ') 846 | params.write(k) 847 | if v is not None: 848 | params.write('="') 849 | if isinstance(v, list): 850 | params.write(','.join(v)) 851 | else: 852 | params.write(v) 853 | params.write('"') 854 | yield { 855 | 'data': { 856 | 'priority': value.svcpriority, 857 | 'target': value.targetname, 858 | 'value': params.getvalue(), 859 | } 860 | } 861 | 862 | _contents_for_HTTPS = _contents_for_SVCB 863 | 864 | def _contents_for_TLSA(self, record): 865 | for value in record.values: 866 | yield { 867 | 'data': { 868 | 'usage': value.certificate_usage, 869 | 'selector': value.selector, 870 | 'matching_type': value.matching_type, 871 | 'certificate': value.certificate_association_data, 872 | } 873 | } 874 | 875 | def _contents_for_URLFWD(self, record): 876 | name = record.fqdn[:-1] 877 | for value in record.values: 878 | yield { 879 | 'targets': [ 880 | { 881 | 'target': 'url', 882 | 'constraint': { 883 | 'operator': 'matches', 884 | 'value': name + value.path, 885 | }, 886 | } 887 | ], 888 | 'actions': [ 889 | { 890 | 'id': 'forwarding_url', 891 | 'value': { 892 | 'url': value.target, 893 | 'status_code': value.code, 894 | }, 895 | } 896 | ], 897 | 'status': 'active', 898 | } 899 | 900 | def _record_is_proxied(self, record): 901 | return not self.cdn and record.octodns.get('cloudflare', {}).get( 902 | 'proxied', False 903 | ) 904 | 905 | def _record_is_just_auto_ttl(self, record): 906 | 'This tests if it is strictly auto-ttl and not proxied' 907 | return ( 908 | not self._record_is_proxied(record) 909 | and not self.cdn 910 | and record.octodns.get('cloudflare', {}).get('auto-ttl', False) 911 | ) 912 | 913 | def _record_comment(self, record): 914 | 'Returns record comment' 915 | return record.octodns.get('cloudflare', {}).get('comment', '') 916 | 917 | def _record_tags(self, record): 918 | 'Returns nonduplicate record tags' 919 | return set(record.octodns.get('cloudflare', {}).get('tags', [])) 920 | 921 | def _gen_data(self, record): 922 | name = record.fqdn[:-1] 923 | _type = record._type 924 | proxied = self._record_is_proxied(record) 925 | if proxied or self._record_is_just_auto_ttl(record): 926 | # proxied implies auto-ttl, and auto-ttl can be enabled on its own, 927 | # when either is the case we tell Cloudflare with ttl=1 928 | ttl = 1 929 | else: 930 | ttl = max(self.min_ttl, record.ttl) 931 | 932 | # Cloudflare supports ALIAS semantics with a root CNAME 933 | if _type == 'ALIAS': 934 | _type = 'CNAME' 935 | 936 | if _type == 'URLFWD': 937 | contents_for = getattr(self, f'_contents_for_{_type}') 938 | for content in contents_for(record): 939 | yield content 940 | else: 941 | contents_for = getattr(self, f'_contents_for_{_type}') 942 | for content in contents_for(record): 943 | content.update({'name': name, 'type': _type, 'ttl': ttl}) 944 | 945 | if _type in _PROXIABLE_RECORD_TYPES: 946 | content.update({'proxied': self._record_is_proxied(record)}) 947 | 948 | if self._record_comment(record): 949 | content.update({'comment': self._record_comment(record)}) 950 | 951 | if self._record_tags(record): 952 | content.update({'tags': list(self._record_tags(record))}) 953 | 954 | yield content 955 | 956 | def _gen_key(self, data): 957 | # Note that most CF record data has a `content` field the value of 958 | # which is a unique/hashable string for the record's. It includes all 959 | # the "value" bits, but not the secondary stuff like TTL's. E.g. for 960 | # an A it'll include the value, for a CAA it'll include the flags, tag, 961 | # and value, ... We'll take advantage of this to try and match up old & 962 | # new records cleanly. In general when there are multiple records for a 963 | # name & type each will have a distinct/consistent `content` that can 964 | # serve as a unique identifier. 965 | # BUT... there are exceptions. MX, CAA, LOC and SRV don't have a simple 966 | # content as things are currently implemented so we need to handle 967 | # those explicitly and create unique/hashable strings for them. 968 | # AND... for URLFWD/Redirects additional adventures are created. 969 | _type = data.get('type', 'URLFWD') 970 | if _type == 'MX': 971 | priority = data['priority'] 972 | content = data['content'] 973 | return f'{priority} {content}' 974 | elif _type == 'CAA': 975 | data = data['data'] 976 | flags = data['flags'] 977 | tag = data['tag'] 978 | value = data['value'] 979 | return f'{flags} {tag} {value}' 980 | elif _type == 'SRV': 981 | data = data['data'] 982 | port = data['port'] 983 | priority = data['priority'] 984 | target = data['target'] 985 | weight = data['weight'] 986 | return f'{port} {priority} {target} {weight}' 987 | elif _type == 'LOC': 988 | data = data['data'] 989 | lat_degrees = data['lat_degrees'] 990 | lat_minutes = data['lat_minutes'] 991 | lat_seconds = data['lat_seconds'] 992 | lat_direction = data['lat_direction'] 993 | long_degrees = data['long_degrees'] 994 | long_minutes = data['long_minutes'] 995 | long_seconds = data['long_seconds'] 996 | long_direction = data['long_direction'] 997 | altitude = data['altitude'] 998 | size = data['size'] 999 | precision_horz = data['precision_horz'] 1000 | precision_vert = data['precision_vert'] 1001 | return ( 1002 | f'{lat_degrees} {lat_minutes} {lat_seconds} ' 1003 | f'{lat_direction} {long_degrees} {long_minutes} ' 1004 | f'{long_seconds} {long_direction} {altitude} {size} ' 1005 | f'{precision_horz} {precision_vert}' 1006 | ) 1007 | elif _type == 'NAPTR': 1008 | data = data['data'] 1009 | flags = data['flags'] 1010 | order = data['order'] 1011 | preference = data['preference'] 1012 | regex = data['regex'] 1013 | replacement = data['replacement'] 1014 | service = data['service'] 1015 | return f'{order} {preference} "{flags}" "{service}" "{regex}" {replacement}' 1016 | elif _type == 'SSHFP': 1017 | data = data['data'] 1018 | algorithm = data['algorithm'] 1019 | fingerprint_type = data['type'] 1020 | fingerprint = data['fingerprint'] 1021 | return f'{algorithm} {fingerprint_type} {fingerprint}' 1022 | elif _type in ('HTTPS', 'SVCB'): 1023 | data = data['data'] 1024 | priority = data['priority'] 1025 | target = data['target'] 1026 | value = data['value'] 1027 | return f'{priority} {target} {value}' 1028 | elif _type == 'TLSA': 1029 | data = data['data'] 1030 | usage = data['usage'] 1031 | selector = data['selector'] 1032 | matching_type = data['matching_type'] 1033 | certificate = data['certificate'] 1034 | return f'{usage} {selector} {matching_type} {certificate}' 1035 | elif _type == 'URLFWD': 1036 | uri = data['targets'][0]['constraint']['value'] 1037 | uri = '//' + uri if not uri.startswith('http') else uri 1038 | parsed_uri = urlsplit(uri) 1039 | url = data['actions'][0]['value']['url'] 1040 | status_code = data['actions'][0]['value']['status_code'] 1041 | return ( 1042 | f'{parsed_uri.netloc} {parsed_uri.path} {url} ' 1043 | + f'{status_code}' 1044 | ) 1045 | 1046 | return data['content'] 1047 | 1048 | def _apply_Create(self, change): 1049 | new = change.new 1050 | zone_id = self.zones[new.zone.name]['id'] 1051 | if new._type == 'URLFWD': 1052 | path = f'/zones/{zone_id}/pagerules' 1053 | else: 1054 | path = f'/zones/{zone_id}/dns_records' 1055 | for content in self._gen_data(new): 1056 | self._try_request('POST', path, data=content) 1057 | 1058 | def _apply_Update(self, change): 1059 | zone = change.new.zone 1060 | zone_id = self.zones[zone.name]['id'] 1061 | hostname = zone.hostname_from_fqdn(change.new.fqdn[:-1]) 1062 | _type = change.new._type 1063 | 1064 | existing = {} 1065 | # Find all of the existing CF records for this name & type 1066 | for record in self.zone_records(zone): 1067 | if 'targets' in record: 1068 | uri = record['targets'][0]['constraint']['value'] 1069 | uri = '//' + uri if not uri.startswith('http') else uri 1070 | parsed_uri = urlsplit(uri) 1071 | name = zone.hostname_from_fqdn(parsed_uri.netloc) 1072 | path = parsed_uri.path 1073 | # assumption, actions will always contain 1 action 1074 | _values = record['actions'][0]['value'] 1075 | _values['path'] = path 1076 | _values['ttl'] = 300 1077 | _values['type'] = 'URLFWD' 1078 | record.update(_values) 1079 | else: 1080 | name = zone.hostname_from_fqdn(record['name']) 1081 | # Use the _record_for so that we include all of standard 1082 | # conversion logic 1083 | r = self._record_for(zone, name, record['type'], [record], True) 1084 | if hostname == r.name and _type == r._type: 1085 | # Round trip the single value through a record to contents 1086 | # flow to get a consistent _gen_data result that matches 1087 | # what went in to new_contents 1088 | data = next(self._gen_data(r)) 1089 | 1090 | # Record the record_id and data for this existing record 1091 | key = self._gen_key(data) 1092 | existing[key] = {'record_id': record['id'], 'data': data} 1093 | 1094 | # Build up a list of new CF records for this Update 1095 | new = {self._gen_key(d): d for d in self._gen_data(change.new)} 1096 | 1097 | # OK we now have a picture of the old & new CF records, our next step 1098 | # is to figure out which records need to be deleted 1099 | deletes = {} 1100 | for key, info in existing.items(): 1101 | if key not in new: 1102 | deletes[key] = info 1103 | # Now we need to figure out which records will need to be created 1104 | creates = {} 1105 | # And which will be updated 1106 | updates = {} 1107 | for key, data in new.items(): 1108 | if key in existing: 1109 | # To update we need to combine the new data and existing's 1110 | # record_id. old_data is just for debugging/logging purposes 1111 | old_info = existing[key] 1112 | updates[key] = { 1113 | 'record_id': old_info['record_id'], 1114 | 'data': data, 1115 | 'old_data': old_info['data'], 1116 | } 1117 | else: 1118 | creates[key] = data 1119 | 1120 | # To do this as safely as possible we'll add new things first, update 1121 | # existing things, and then remove old things. This should (try) and 1122 | # ensure that we have as many value CF records in their system as 1123 | # possible at any given time. Ideally we'd have a "batch" API that 1124 | # would allow create, delete, and upsert style stuff so operations 1125 | # could be done atomically, but that's not available so we made the 1126 | # best of it... 1127 | 1128 | # However, there are record types like CNAME that can only have a 1129 | # single value. B/c of that our create and then delete approach isn't 1130 | # actually viable. To address this we'll convert as many creates & 1131 | # deletes as we can to updates. This will have a minor upside of 1132 | # resulting in fewer ops and in the case of things like CNAME where 1133 | # there's a single create and delete result in a single update instead. 1134 | create_keys = sorted(creates.keys()) 1135 | delete_keys = sorted(deletes.keys()) 1136 | for i in range(0, min(len(create_keys), len(delete_keys))): 1137 | create_key = create_keys[i] 1138 | create_data = creates.pop(create_key) 1139 | delete_info = deletes.pop(delete_keys[i]) 1140 | updates[create_key] = { 1141 | 'record_id': delete_info['record_id'], 1142 | 'data': create_data, 1143 | 'old_data': delete_info['data'], 1144 | } 1145 | 1146 | # The sorts ensure a consistent order of operations, they're not 1147 | # otherwise required, just makes things deterministic 1148 | 1149 | # Creates 1150 | if _type == 'URLFWD': 1151 | path = f'/zones/{zone_id}/pagerules' 1152 | else: 1153 | path = f'/zones/{zone_id}/dns_records' 1154 | for _, data in sorted(creates.items()): 1155 | self.log.debug('_apply_Update: creating %s', data) 1156 | self._try_request('POST', path, data=data) 1157 | 1158 | # Updates 1159 | for _, info in sorted(updates.items()): 1160 | record_id = info['record_id'] 1161 | data = info['data'] 1162 | old_data = info['old_data'] 1163 | if _type == 'URLFWD': 1164 | path = f'/zones/{zone_id}/pagerules/{record_id}' 1165 | else: 1166 | path = f'/zones/{zone_id}/dns_records/{record_id}' 1167 | self.log.debug( 1168 | '_apply_Update: updating %s, %s -> %s', 1169 | record_id, 1170 | data, 1171 | old_data, 1172 | ) 1173 | self._try_request('PUT', path, data=data) 1174 | 1175 | # Deletes 1176 | for _, info in sorted(deletes.items()): 1177 | record_id = info['record_id'] 1178 | old_data = info['data'] 1179 | if _type == 'URLFWD': 1180 | path = f'/zones/{zone_id}/pagerules/{record_id}' 1181 | else: 1182 | path = f'/zones/{zone_id}/dns_records/{record_id}' 1183 | self.log.debug( 1184 | '_apply_Update: removing %s, %s', record_id, old_data 1185 | ) 1186 | self._try_request('DELETE', path) 1187 | 1188 | def _apply_Delete(self, change): 1189 | existing = change.existing 1190 | existing_name = existing.fqdn[:-1] 1191 | # Make sure to map ALIAS to CNAME when looking for the target to delete 1192 | existing_type = 'CNAME' if existing._type == 'ALIAS' else existing._type 1193 | zone_id = self.zones[existing.zone.name]['id'] 1194 | for record in self.zone_records(existing.zone): 1195 | if 'targets' in record and self.pagerules: 1196 | uri = record['targets'][0]['constraint']['value'] 1197 | uri = '//' + uri if not uri.startswith('http') else uri 1198 | parsed_uri = urlsplit(uri) 1199 | record_name = parsed_uri.netloc 1200 | record_type = 'URLFWD' 1201 | if ( 1202 | existing_name == record_name 1203 | and existing_type == record_type 1204 | ): 1205 | path = f'/zones/{zone_id}/pagerules/{record["id"]}' 1206 | self._try_request('DELETE', path) 1207 | else: 1208 | if ( 1209 | existing_name == record['name'] 1210 | and existing_type == record['type'] 1211 | ): 1212 | record_zone_id = record.get('zone_id') 1213 | if record_zone_id is None: 1214 | self.log.warning( 1215 | '_apply_Delete: record "%s", %s is missing "zone_id", falling back to lookup', 1216 | record['name'], 1217 | record['type'], 1218 | ) 1219 | record_zone_id = zone_id 1220 | path = ( 1221 | f'/zones/{record_zone_id}/dns_records/' 1222 | f'{record["id"]}' 1223 | ) 1224 | self._try_request('DELETE', path) 1225 | 1226 | def _available_plans(self, zone_name): 1227 | zone_id = self.zones.get(zone_name, {}).get('id', None) 1228 | if not zone_id: 1229 | msg = f'{self.id}: zone {zone_name} not found' 1230 | raise SupportsException(msg) 1231 | path = f'/zones/{zone_id}/available_plans' 1232 | resp = self._try_request('GET', path) 1233 | result = resp['result'] 1234 | if not isinstance(result, list): 1235 | msg = f'{self.id}: unable to determine supported plans, do you have an Enterprise account?' 1236 | raise SupportsException(msg) 1237 | return { 1238 | plan['legacy_id']: plan['id'] 1239 | for plan in result 1240 | if plan['legacy_id'] is not None 1241 | } 1242 | 1243 | def _resolve_plan_legacy_id(self, zone_name, legacy_id): 1244 | # Get the plan id for the given legacy_id, Cloudflare only supports setting the plan by id 1245 | plan_id = self._available_plans(zone_name).get(legacy_id, None) 1246 | if not plan_id: 1247 | msg = f'{self.id}: {legacy_id} is not supported for {zone_name}' 1248 | raise SupportsException(msg) 1249 | return plan_id 1250 | 1251 | def _update_plan(self, zone_name, legacy_id): 1252 | plan_id = self._resolve_plan_legacy_id(zone_name, legacy_id) 1253 | zone_id = self.zones[zone_name]['id'] 1254 | data = {'plan': {'id': plan_id}} 1255 | resp = self._try_request('PATCH', f'/zones/{zone_id}', data=data) 1256 | # Update the cached plan information 1257 | self.zones[zone_name]['cloudflare_plan'] = resp['result']['plan'][ 1258 | 'legacy_id' 1259 | ] 1260 | 1261 | def _plan_meta(self, existing, desired, changes): 1262 | desired_plan = self.plan_type 1263 | if desired_plan is None: 1264 | # No plan type configured leave things unmanaged 1265 | return 1266 | zone_name = desired.name 1267 | current_plan = self.zones.get(zone_name, {}).get( 1268 | 'cloudflare_plan', None 1269 | ) 1270 | if current_plan == desired_plan: 1271 | return 1272 | return { 1273 | 'cloudflare_plan': { 1274 | 'current': current_plan, 1275 | 'desired': desired_plan, 1276 | } 1277 | } 1278 | 1279 | def _apply(self, plan): 1280 | desired = plan.desired 1281 | changes = plan.changes 1282 | zone_name = desired.name 1283 | 1284 | self.log.debug( 1285 | '_apply: zone=%s, len(changes)=%d', zone_name, len(changes) 1286 | ) 1287 | 1288 | if zone_name not in self.zones: 1289 | self.log.debug('_apply: no matching zone, creating') 1290 | data = {'name': zone_name[:-1], 'jump_start': False} 1291 | if self.account_id is not None: 1292 | data['account'] = {'id': self.account_id} 1293 | resp = self._try_request('POST', '/zones', data=data) 1294 | zone = resp['result'] 1295 | self.zones[zone_name] = { 1296 | 'id': zone['id'], 1297 | 'cloudflare_plan': zone.get('plan', {}).get('legacy_id', None), 1298 | 'name_servers': zone.get('name_servers', []), 1299 | } 1300 | self._zone_records[zone_name] = {} 1301 | 1302 | # Handle plan changes if needed 1303 | if self.plan_type is not None: 1304 | if hasattr(plan, 'meta'): 1305 | meta = plan.meta 1306 | if meta: 1307 | desired_plan = meta.get('cloudflare_plan', {}).get( 1308 | 'desired', None 1309 | ) 1310 | if desired_plan: 1311 | self._update_plan(zone_name, desired_plan) 1312 | else: 1313 | # Older versions of octodns don't have meta support. 1314 | self.log.warning( 1315 | 'plan_type is set but meta is not supported by octodns %s, plan changes will not be applied', 1316 | octodns_version, 1317 | ) 1318 | 1319 | self.log.info( 1320 | 'zone %s (id %s) name servers: %s', 1321 | zone_name, 1322 | self.zones[zone_name]['id'], 1323 | self.zones[zone_name]['name_servers'], 1324 | ) 1325 | 1326 | # Force the operation order to be Delete() -> Create() -> Update() 1327 | # This will help avoid problems in updating a CNAME record into an 1328 | # A record and vice-versa 1329 | changes.sort(key=self._change_keyer) 1330 | 1331 | for change in changes: 1332 | class_name = change.__class__.__name__ 1333 | getattr(self, f'_apply_{class_name}')(change) 1334 | 1335 | # clear the cache 1336 | self._zone_records.pop(zone_name, None) 1337 | 1338 | def _extra_changes(self, existing, desired, changes): 1339 | extra_changes = [] 1340 | 1341 | existing_records = {r: r for r in existing.records} 1342 | changed_records = {c.record for c in changes} 1343 | 1344 | for desired_record in desired.records: 1345 | existing_record = existing_records.get(desired_record, None) 1346 | if not existing_record: # Will be created 1347 | continue 1348 | elif desired_record in changed_records: # Already being updated 1349 | continue 1350 | 1351 | if ( 1352 | self._record_is_proxied(existing_record) 1353 | != self._record_is_proxied(desired_record) 1354 | ) or ( 1355 | self._record_is_just_auto_ttl(existing_record) 1356 | != self._record_is_just_auto_ttl(desired_record) 1357 | ): 1358 | extra_changes.append(Update(existing_record, desired_record)) 1359 | 1360 | if self._record_comment(existing_record) != self._record_comment( 1361 | desired_record 1362 | ): 1363 | extra_changes.append(Update(existing_record, desired_record)) 1364 | 1365 | if self._record_tags(existing_record) != self._record_tags( 1366 | desired_record 1367 | ): 1368 | extra_changes.append(Update(existing_record, desired_record)) 1369 | 1370 | return extra_changes 1371 | -------------------------------------------------------------------------------- /octodns_cloudflare/processor/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # 3 | # 4 | -------------------------------------------------------------------------------- /octodns_cloudflare/processor/proxycname.py: -------------------------------------------------------------------------------- 1 | # 2 | # 3 | # 4 | 5 | from octodns.processor.base import BaseProcessor, ProcessorException 6 | 7 | from octodns_cloudflare import CloudflareProvider 8 | 9 | 10 | class ProxyCNAMEException(ProcessorException): 11 | pass 12 | 13 | 14 | class ProxyCNAME(BaseProcessor): 15 | ''' 16 | Replace Cloudflare proxied values on non Cloudflare providers with the relevant .cdn.cloudflare.net. CNAME / ALIAS value. 17 | 18 | Example usage: 19 | 20 | processors: 21 | proxy-cname: 22 | class: octodns_cloudflare.processor.proxycname.ProxyCNAME 23 | 24 | ... 25 | 26 | zones: 27 | example.com.: 28 | sources: 29 | - zones 30 | processors: 31 | - proxy-cname 32 | targets: 33 | - cloudflare 34 | - ns1 35 | ''' 36 | 37 | def process_source_and_target_zones(self, desired, existing, target): 38 | 39 | # Check if zone is destined for Cloudflare 40 | if isinstance(target, CloudflareProvider): 41 | # If it is then dont bother with any processing just return now 42 | return desired, existing 43 | 44 | for record in desired.records: 45 | # Check the record is NOT Cloudflare proxied OR is a non Cloudflare proxyable record type 46 | # https://developers.cloudflare.com/dns/manage-dns-records/reference/proxied-dns-records/#record-types 47 | # NOTE: Inclusion of ALIAS as this is generally a CNAME equivalent that can be used at the root 48 | if not record.octodns.get('cloudflare', {}).get( 49 | 'proxied', False 50 | ) or record._type not in ['ALIAS', 'A', 'AAAA', 'CNAME']: 51 | # Not interested in this record. 52 | continue 53 | 54 | # Remove record 55 | desired.remove_record(record) 56 | 57 | # Root 58 | if record.name == "": 59 | # Replace with ALIAS 60 | type = "ALIAS" 61 | 62 | # NOT Root 63 | else: 64 | # Replace with CNAME 65 | type = "CNAME" 66 | 67 | # Create new record 68 | # NOTE: New record created instead of doing a .copy() and update as record requires change of type 69 | new = record.new( 70 | desired, 71 | record.name, 72 | { 73 | 'type': type, 74 | 'ttl': record.ttl, 75 | 'value': (f"{record.fqdn}cdn.cloudflare.net."), 76 | }, # Set the value to Cloudflare CDN value e.g www.example.com.cdn.cloudflare.net. 77 | ) 78 | 79 | # Replace the record 80 | # NOTE: lenient=True is required here even though coexisting CNAMEs should not exist 81 | desired.add_record(new, replace=True, lenient=True) 82 | 83 | return desired, existing 84 | -------------------------------------------------------------------------------- /octodns_cloudflare/processor/ttl.py: -------------------------------------------------------------------------------- 1 | # 2 | # 3 | # 4 | 5 | from octodns.processor.base import BaseProcessor 6 | 7 | from octodns_cloudflare import _PROXIABLE_RECORD_TYPES 8 | 9 | 10 | class TtlToProxy(BaseProcessor): 11 | ''' 12 | Ensure Cloudflare's proxy status is setup depending on the TTL set for the record. This 13 | can be helpful for `octodns_bind.ZoneFileSource` or the like. 14 | 15 | Example usage: 16 | 17 | processors: 18 | ttl-to-proxy: 19 | class: octodns_cloudflare.processor.ttl.TtlToProxy 20 | ttl: 0 21 | 22 | zones: 23 | exxampled.com.: 24 | sources: 25 | - config 26 | processors: 27 | - ttl-to-proxy 28 | targets: 29 | - cloudflare 30 | ''' 31 | 32 | def __init__(self, name, ttl=0): 33 | super().__init__(name) 34 | self.ttl = ttl 35 | 36 | def process_source_zone(self, zone, *args, **kwargs): 37 | for record in zone.records: 38 | if record.ttl == self.ttl: 39 | attr = {'auto-ttl': True} 40 | if record._type in _PROXIABLE_RECORD_TYPES: 41 | attr['proxied'] = True 42 | 43 | record = record.copy() 44 | record.octodns['cloudflare'] = attr 45 | record.ttl = 1 46 | # Ensure we set to valid TTL. 47 | zone.add_record(record, replace=True, lenient=True) 48 | 49 | return zone 50 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length=80 3 | skip-string-normalization=true 4 | skip-magic-trailing-comma=true 5 | 6 | [tool.isort] 7 | profile = "black" 8 | known_first_party="octodns_cloudflare" 9 | known_octodns="octodns" 10 | line_length=80 11 | sections="FUTURE,STDLIB,THIRDPARTY,OCTODNS,FIRSTPARTY,LOCALFOLDER" 12 | 13 | [tool.pytest.ini_options] 14 | filterwarnings = [ 15 | 'error', 16 | ] 17 | pythonpath = "." 18 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE DIRECTLY - use ./script/update-requirements to update 2 | Pygments==2.19.1 3 | black==24.10.0 4 | build==1.2.2.post1 5 | cffi==1.17.1 6 | click==8.1.8 7 | cmarkgfm==2024.11.20 8 | coverage==7.6.10 9 | docutils==0.21.2 10 | id==1.5.0 11 | iniconfig==2.0.0 12 | isort==6.0.0 13 | jaraco.classes==3.4.0 14 | jaraco.context==6.0.1 15 | jaraco.functools==4.1.0 16 | keyring==25.6.0 17 | markdown-it-py==3.0.0 18 | mdurl==0.1.2 19 | more-itertools==10.6.0 20 | mypy-extensions==1.0.0 21 | nh3==0.2.20 22 | packaging==24.2 23 | pathspec==0.12.1 24 | platformdirs==4.3.6 25 | pluggy==1.5.0 26 | pycparser==2.22 27 | pyflakes==3.2.0 28 | pyproject_hooks==1.2.0 29 | pytest-cov==6.0.0 30 | pytest==8.3.4 31 | pytest_network==0.0.1 32 | readme_renderer==44.0 33 | requests-mock==1.12.1 34 | requests-toolbelt==1.0.0 35 | rfc3986==2.0.0 36 | rich==13.9.4 37 | twine==6.1.0 38 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # DO NOT EDIT THIS FILE DIRECTLY - use ./script/update-requirements to update 2 | PyYAML==6.0.2 3 | certifi==2025.1.31 4 | charset-normalizer==3.4.1 5 | dnspython==2.7.0 6 | fqdn==1.5.1 7 | idna==3.10 8 | natsort==8.4.0 9 | octodns==1.11.0 10 | python-dateutil==2.9.0.post0 11 | requests==2.32.3 12 | six==1.17.0 13 | urllib3==2.3.0 14 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Usage: script/bootstrap 3 | # Ensures all dependencies are installed locally. 4 | 5 | set -e 6 | 7 | cd "$(dirname "$0")"/.. 8 | ROOT=$(pwd) 9 | 10 | if [ -z "$VENV_NAME" ]; then 11 | VENV_NAME="env" 12 | fi 13 | 14 | if [ ! -d "$VENV_NAME" ]; then 15 | if [ -z "$VENV_PYTHON" ]; then 16 | VENV_PYTHON=$(command -v python3) 17 | fi 18 | "$VENV_PYTHON" -m venv "$VENV_NAME" 19 | fi 20 | . "$VENV_NAME/bin/activate" 21 | 22 | # We're in the venv now, so use the first Python in $PATH. In particular, don't 23 | # use $VENV_PYTHON - that's the Python that *created* the venv, not the python 24 | # *inside* the venv 25 | python -m pip install -U 'pip>=10.0.1' 26 | python -m pip install -r requirements.txt 27 | 28 | if [ "$ENV" != "production" ]; then 29 | python -m pip install -r requirements-dev.txt 30 | fi 31 | 32 | if [ -d ".git" ]; then 33 | if [ -f ".git-blame-ignore-revs" ]; then 34 | echo "" 35 | echo "Setting blame.ignoreRevsFile to .git-blame-ingore-revs" 36 | git config --local blame.ignoreRevsFile .git-blame-ignore-revs 37 | fi 38 | if [ ! -L ".git/hooks/pre-commit" ]; then 39 | echo "" 40 | echo "Installing pre-commit hook" 41 | ln -s "$ROOT/.git_hooks_pre-commit" ".git/hooks/pre-commit" 42 | fi 43 | fi 44 | 45 | echo "" 46 | echo "Run source env/bin/activate to get your shell in to the virtualenv" 47 | echo "See README.md for more information." 48 | echo "" 49 | -------------------------------------------------------------------------------- /script/cibuild: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | cd "$(dirname "$0")/.." 5 | 6 | echo "## bootstrap ###################################################################" 7 | script/bootstrap 8 | 9 | if [ -z "$VENV_NAME" ]; then 10 | VENV_NAME="env" 11 | fi 12 | 13 | . "$VENV_NAME/bin/activate" 14 | 15 | echo "## environment & versions ######################################################" 16 | python --version 17 | pip --version 18 | 19 | echo "## clean up ####################################################################" 20 | find octodns_cloudflare tests* -name "*.pyc" -exec rm {} \; 21 | rm -f *.pyc 22 | echo "## begin #######################################################################" 23 | # For now it's just lint... 24 | echo "## lint ########################################################################" 25 | script/lint 26 | echo "## formatting ##################################################################" 27 | script/format --check || (echo "Formatting check failed, run ./script/format" && exit 1) 28 | echo "## tests/coverage ##############################################################" 29 | script/coverage 30 | echo "## complete ####################################################################" 31 | -------------------------------------------------------------------------------- /script/cibuild-setup-py: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | cd "$(dirname "$0")/.." 5 | 6 | VERSION="$(grep "^__version__" "./octodns_cloudflare/__init__.py" | sed -e "s/.* = '//" -e "s/'$//")" 7 | 8 | echo "## create test venv ############################################################" 9 | TMP_DIR=$(mktemp -d -t ci-XXXXXXXXXX) 10 | python3 -m venv $TMP_DIR 11 | . "$TMP_DIR/bin/activate" 12 | pip install build setuptools 13 | echo "## environment & versions ######################################################" 14 | python --version 15 | pip --version 16 | echo "## validate setup.py build #####################################################" 17 | python -m build --sdist --wheel 18 | echo "## validate wheel install ###################################################" 19 | pip install dist/*$VERSION*.whl 20 | echo "## validate tests can run against installed code ###############################" 21 | pip install pytest pytest-network requests_mock 22 | pytest --disable-network 23 | echo "## complete ####################################################################" 24 | -------------------------------------------------------------------------------- /script/coverage: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | cd "$(dirname "$0")/.." 5 | 6 | if [ -z "$VENV_NAME" ]; then 7 | VENV_NAME="env" 8 | fi 9 | 10 | ACTIVATE="$VENV_NAME/bin/activate" 11 | if [ ! -f "$ACTIVATE" ]; then 12 | echo "$ACTIVATE does not exist, run ./script/bootstrap" >&2 13 | exit 1 14 | fi 15 | . "$ACTIVATE" 16 | 17 | SOURCE_DIR="octodns_cloudflare/" 18 | 19 | # Don't allow disabling coverage 20 | grep -r -I --line-number "# pragma: +no.*cover" $SOURCE_DIR && { 21 | echo "Code coverage should not be disabled" 22 | exit 1 23 | } 24 | 25 | export CLOUDFLARE_ACCOUNT_ID= 26 | export CLOUDFLARE_EMAIL= 27 | export CLOUDFLARE_TOKEN= 28 | 29 | pytest \ 30 | --disable-network \ 31 | --cov-reset \ 32 | --cov=$SOURCE_DIR \ 33 | --cov-fail-under=100 \ 34 | --cov-report=html \ 35 | --cov-report=xml \ 36 | --cov-report=term \ 37 | --cov-branch \ 38 | "$@" 39 | -------------------------------------------------------------------------------- /script/format: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | SOURCES=$(find *.py octodns_* tests -name "*.py") 6 | 7 | . env/bin/activate 8 | 9 | isort "$@" $SOURCES 10 | black "$@" $SOURCES 11 | -------------------------------------------------------------------------------- /script/lint: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | cd "$(dirname "$0")/.." 5 | ROOT=$(pwd) 6 | 7 | if [ -z "$VENV_NAME" ]; then 8 | VENV_NAME="env" 9 | fi 10 | 11 | ACTIVATE="$VENV_NAME/bin/activate" 12 | if [ ! -f "$ACTIVATE" ]; then 13 | echo "$ACTIVATE does not exist, run ./script/bootstrap" >&2 14 | exit 1 15 | fi 16 | . "$ACTIVATE" 17 | 18 | SOURCES="octodns_cloudflare/*.py setup.py tests/*.py" 19 | 20 | pyflakes $SOURCES 21 | -------------------------------------------------------------------------------- /script/release: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -o pipefail 5 | 6 | cd "$(dirname "$0")"/.. 7 | ROOT=$(pwd) 8 | 9 | if [ -z "$VENV_NAME" ]; then 10 | VENV_NAME="env" 11 | fi 12 | 13 | PYPYRC="$HOME/.pypirc" 14 | if [ ! -e "$PYPYRC" ]; then 15 | cat << EndOfMessage >&2 16 | $PYPYRC does not exist, please create it with the following contents 17 | 18 | [pypi] 19 | username = __token__ 20 | password = [secret-token-goes-here] 21 | 22 | EndOfMessage 23 | exit 1 24 | fi 25 | 26 | ACTIVATE="$VENV_NAME/bin/activate" 27 | if [ ! -f "$ACTIVATE" ]; then 28 | echo "$ACTIVATE does not exist, run ./script/bootstrap" >&2 29 | exit 1 30 | fi 31 | . "$ACTIVATE" 32 | 33 | # Set so that setup.py will create a public release style version number 34 | export OCTODNS_RELEASE=1 35 | 36 | VERSION="$(grep "^__version__" "$ROOT/octodns_cloudflare/__init__.py" | sed -e "s/.* = '//" -e "s/'$//")" 37 | 38 | git tag -s "v$VERSION" -m "Release $VERSION" 39 | git push origin "v$VERSION" 40 | echo "Tagged and pushed v$VERSION" 41 | python -m build --sdist --wheel 42 | twine check dist/*$VERSION.tar.gz dist/*$VERSION*.whl 43 | twine upload dist/*$VERSION.tar.gz dist/*$VERSION*.whl 44 | echo "Uploaded $VERSION" 45 | -------------------------------------------------------------------------------- /script/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | cd "$(dirname "$0")/.." 5 | 6 | if [ -z "$VENV_NAME" ]; then 7 | VENV_NAME="env" 8 | fi 9 | 10 | ACTIVATE="$VENV_NAME/bin/activate" 11 | if [ ! -f "$ACTIVATE" ]; then 12 | echo "$ACTIVATE does not exist, run ./script/bootstrap" >&2 13 | exit 1 14 | fi 15 | . "$ACTIVATE" 16 | 17 | export CLOUDFLARE_ACCOUNT_ID= 18 | export CLOUDFLARE_EMAIL= 19 | export CLOUDFLARE_TOKEN= 20 | 21 | pytest --disable-network "$@" 22 | -------------------------------------------------------------------------------- /script/update-requirements: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from os.path import join 4 | from subprocess import check_call, check_output 5 | from sys import argv 6 | from tempfile import TemporaryDirectory 7 | import re 8 | 9 | 10 | def print_packages(packages, heading): 11 | print(f'{heading}:') 12 | print(' ', end='') 13 | print('\n '.join(packages)) 14 | 15 | 16 | # would be nice if there was a cleaner way to get this, but I've not found a 17 | # more reliable one. 18 | with open('setup.py') as fh: 19 | match = re.search(r"name='(?P[\w-]+)',", fh.read()) 20 | if not match: 21 | raise Exception('failed to determine our package name') 22 | our_package_name = match.group('pkg') 23 | print(f'our_package_name: {our_package_name}') 24 | 25 | with TemporaryDirectory() as tmpdir: 26 | check_call(['python3', '-m', 'venv', tmpdir]) 27 | 28 | # base needs 29 | check_call([join(tmpdir, 'bin', 'pip'), 'install', '.']) 30 | frozen = check_output([join(tmpdir, 'bin', 'pip'), 'freeze']) 31 | frozen = set(frozen.decode('utf-8').strip().split('\n')) 32 | 33 | # dev additions 34 | check_call([join(tmpdir, 'bin', 'pip'), 'install', '.[dev]']) 35 | dev_frozen = check_output([join(tmpdir, 'bin', 'pip'), 'freeze']) 36 | dev_frozen = set(dev_frozen.decode('utf-8').strip().split('\n')) - frozen 37 | 38 | # pip installs the module itself along with deps so we need to get that out of 39 | # our list by finding the thing that was file installed during dev 40 | frozen = sorted([p for p in frozen if not p.startswith(our_package_name)]) 41 | dev_frozen = sorted([p for p in dev_frozen 42 | if not p.startswith(our_package_name)]) 43 | 44 | print_packages(frozen, 'frozen') 45 | print_packages(dev_frozen, 'dev_frozen') 46 | 47 | script = argv[0] 48 | 49 | with open('requirements.txt', 'w') as fh: 50 | fh.write(f'# DO NOT EDIT THIS FILE DIRECTLY - use {script} to update\n') 51 | fh.write('\n'.join(frozen)) 52 | fh.write('\n') 53 | 54 | with open('requirements-dev.txt', 'w') as fh: 55 | fh.write(f'# DO NOT EDIT THIS FILE DIRECTLY - use {script} to update\n') 56 | fh.write('\n'.join(dev_frozen)) 57 | fh.write('\n') 58 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import find_packages, setup 4 | 5 | 6 | def descriptions(): 7 | with open('README.md') as fh: 8 | ret = fh.read() 9 | first = ret.split('\n', 1)[0].replace('#', '') 10 | return first, ret 11 | 12 | 13 | def version(): 14 | with open('octodns_cloudflare/__init__.py') as fh: 15 | for line in fh: 16 | if line.startswith('__version__'): 17 | return line.split("'")[1] 18 | return 'unknown' 19 | 20 | 21 | description, long_description = descriptions() 22 | 23 | tests_require = ('pytest', 'pytest-cov', 'pytest-network', 'requests_mock') 24 | 25 | setup( 26 | author='Ross McFarland', 27 | author_email='rwmcfa1@gmail.com', 28 | description=description, 29 | extras_require={ 30 | 'dev': tests_require 31 | + ( 32 | # we need to manually/explicitely bump major versions as they're 33 | # likely to result in formatting changes that should happen in their 34 | # own PR. This will basically happen yearly 35 | # https://black.readthedocs.io/en/stable/the_black_code_style/index.html#stability-policy 36 | 'black>=24.3.0,<25.0.0', 37 | 'build>=0.7.0', 38 | 'isort>=5.11.5', 39 | 'pyflakes>=2.2.0', 40 | 'readme_renderer[md]>=26.0', 41 | 'twine>=3.4.2', 42 | ), 43 | 'test': tests_require, 44 | }, 45 | install_requires=('octodns>=1.5.0', 'requests>=2.27.0'), 46 | license='MIT', 47 | long_description=long_description, 48 | long_description_content_type='text/markdown', 49 | name='octodns-cloudflare', 50 | packages=find_packages(), 51 | python_requires='>=3.9', 52 | tests_require=tests_require, 53 | url='https://github.com/octodns/octodns-cloudflare', 54 | version=version(), 55 | ) 56 | -------------------------------------------------------------------------------- /tests/config/unit.tests.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | ? '' 3 | : - ttl: 300 4 | type: A 5 | values: 6 | - 1.2.3.4 7 | - 1.2.3.5 8 | - ttl: 3600 9 | type: SSHFP 10 | values: 11 | - algorithm: 1 12 | fingerprint: bf6b6825d2977c511a475bbefb88aad54a92ac73 13 | fingerprint_type: 1 14 | - algorithm: 1 15 | fingerprint: 7491973e5f8b39d5327cd4e08bc81b05f7710b49 16 | fingerprint_type: 1 17 | - type: CAA 18 | values: 19 | - flags: 0 20 | tag: issue 21 | value: ca.unit.tests 22 | _imap._tcp: 23 | ttl: 600 24 | type: SRV 25 | values: 26 | - port: 0 27 | priority: 0 28 | target: . 29 | weight: 0 30 | _pop3._tcp: 31 | ttl: 600 32 | type: SRV 33 | values: 34 | - port: 0 35 | priority: 0 36 | target: . 37 | weight: 0 38 | _srv._tcp: 39 | ttl: 600 40 | type: SRV 41 | values: 42 | - port: 30 43 | priority: 12 44 | target: foo-2.unit.tests. 45 | weight: 20 46 | - port: 30 47 | priority: 10 48 | target: foo-1.unit.tests. 49 | weight: 20 50 | aaaa: 51 | ttl: 600 52 | type: AAAA 53 | value: 2601:644:500:e210:62f8:1dff:feb8:947a 54 | cname: 55 | ttl: 300 56 | type: CNAME 57 | value: unit.tests. 58 | ds: 59 | - ttl: 300 60 | type: DS 61 | value: 62 | algorithm: 13 63 | digest: "B5BB9D8014A0F9B1D61E21E796D78DCCDF1352F23CD32812F4850B878AE4944C" 64 | digest_type: 2 65 | key_tag: 1 66 | - ttl: 301 67 | type: NS 68 | value: ns1.unit.tests. 69 | excluded: 70 | octodns: 71 | excluded: 72 | - test 73 | type: CNAME 74 | value: unit.tests. 75 | https: 76 | ttl: 304 77 | type: HTTPS 78 | value: 79 | svcparams: 80 | alpn: 81 | - h3 82 | - h2 83 | ipv4hint: 84 | - 127.0.0.2 85 | svcpriority: 1 86 | targetname: www.unit.tests. 87 | ignored: 88 | octodns: 89 | ignored: true 90 | type: A 91 | value: 9.9.9.9 92 | included: 93 | octodns: 94 | included: 95 | - test 96 | type: CNAME 97 | value: unit.tests. 98 | loc: 99 | ttl: 300 100 | type: LOC 101 | values: 102 | - altitude: 20 103 | lat_degrees: 31 104 | lat_direction: S 105 | lat_minutes: 58 106 | lat_seconds: 52.1 107 | long_degrees: 115 108 | long_direction: E 109 | long_minutes: 49 110 | long_seconds: 11.7 111 | precision_horz: 10 112 | precision_vert: 2 113 | size: 10 114 | - altitude: 20 115 | lat_degrees: 53 116 | lat_direction: N 117 | lat_minutes: 13 118 | lat_seconds: 10 119 | long_degrees: 2 120 | long_direction: W 121 | long_minutes: 18 122 | long_seconds: 26 123 | precision_horz: 1000 124 | precision_vert: 2 125 | size: 10 126 | mx: 127 | ttl: 300 128 | type: MX 129 | values: 130 | - exchange: smtp-1.unit.tests. 131 | preference: 40 132 | - exchange: smtp-2.unit.tests. 133 | preference: 20 134 | - exchange: smtp-3.unit.tests. 135 | preference: 30 136 | - priority: 10 137 | value: smtp-4.unit.tests. 138 | naptr: 139 | ttl: 600 140 | type: NAPTR 141 | values: 142 | - flags: U 143 | order: 100 144 | preference: 100 145 | regexp: '!^.*$!sip:info@bar.example.com!' 146 | replacement: . 147 | service: SIP+D2U 148 | - flags: S 149 | order: 10 150 | preference: 100 151 | regexp: '!^.*$!sip:info@bar.example.com!' 152 | replacement: . 153 | service: SIP+D2U 154 | ptr: 155 | ttl: 300 156 | type: PTR 157 | values: [foo.bar.com.] 158 | sub: 159 | type: 'NS' 160 | values: 161 | - 6.2.3.4. 162 | - 7.2.3.4. 163 | svcb: 164 | ttl: 303 165 | type: SVCB 166 | value: 167 | svcparams: 168 | alpn: 169 | - h3 170 | - h2 171 | svcpriority: 1 172 | targetname: www.unit.tests. 173 | txt: 174 | ttl: 600 175 | type: TXT 176 | values: 177 | - Bah bah black sheep 178 | - have you any wool. 179 | - 'v=DKIM1\;k=rsa\;s=email\;h=sha256\;p=A/kinda+of/long/string+with+numb3rs' 180 | urlfwd: 181 | ttl: 300 182 | type: URLFWD 183 | values: 184 | - code: 302 185 | masking: 2 186 | path: '/' 187 | query: 0 188 | target: 'http://www.unit.tests' 189 | - code: 301 190 | masking: 2 191 | path: '/target' 192 | query: 0 193 | target: 'http://target.unit.tests' 194 | www: 195 | ttl: 300 196 | type: A 197 | value: 2.2.3.6 198 | www.sub: 199 | ttl: 300 200 | type: A 201 | value: 2.2.3.6 202 | -------------------------------------------------------------------------------- /tests/fixtures/cloudflare-dns_records-page-1.json: -------------------------------------------------------------------------------- 1 | { 2 | "result": [ 3 | { 4 | "id": "fc12ab34cd5611334422ab3322997650", 5 | "type": "A", 6 | "name": "unit.tests", 7 | "content": "1.2.3.4", 8 | "proxiable": true, 9 | "proxied": false, 10 | "ttl": 300, 11 | "locked": false, 12 | "zone_id": "ff12ab34cd5611334422ab3322997650", 13 | "zone_name": "unit.tests", 14 | "modified_on": "2017-03-11T18:01:43.054409Z", 15 | "created_on": "2017-03-11T18:01:43.054409Z", 16 | "meta": { 17 | "auto_added": false 18 | } 19 | }, 20 | { 21 | "id": "fc12ab34cd5611334422ab3322997651", 22 | "type": "A", 23 | "name": "unit.tests", 24 | "content": "1.2.3.5", 25 | "proxiable": true, 26 | "proxied": false, 27 | "ttl": 300, 28 | "locked": false, 29 | "zone_id": "ff12ab34cd5611334422ab3322997650", 30 | "zone_name": "unit.tests", 31 | "modified_on": "2017-03-11T18:01:43.160148Z", 32 | "created_on": "2017-03-11T18:01:43.160148Z", 33 | "meta": { 34 | "auto_added": false 35 | } 36 | }, 37 | { 38 | "id": "fc12ab34cd5611334422ab3322997653", 39 | "type": "A", 40 | "name": "www.unit.tests", 41 | "content": "2.2.3.6", 42 | "proxiable": true, 43 | "proxied": false, 44 | "ttl": 300, 45 | "locked": false, 46 | "zone_id": "ff12ab34cd5611334422ab3322997650", 47 | "zone_name": "unit.tests", 48 | "modified_on": "2017-03-11T18:01:43.420689Z", 49 | "created_on": "2017-03-11T18:01:43.420689Z", 50 | "meta": { 51 | "auto_added": false 52 | } 53 | }, 54 | { 55 | "id": "fc12ab34cd5611334422ab3322997654", 56 | "type": "A", 57 | "name": "www.sub.unit.tests", 58 | "content": "2.2.3.6", 59 | "proxiable": true, 60 | "proxied": false, 61 | "ttl": 300, 62 | "locked": false, 63 | "zone_id": "ff12ab34cd5611334422ab3322997650", 64 | "zone_name": "unit.tests", 65 | "modified_on": "2017-03-11T18:01:44.030044Z", 66 | "created_on": "2017-03-11T18:01:44.030044Z", 67 | "meta": { 68 | "auto_added": false 69 | } 70 | }, 71 | { 72 | "id": "fc12ab34cd5611334422ab3322997655", 73 | "type": "AAAA", 74 | "name": "aaaa.unit.tests", 75 | "content": "2601:644:500:e210:62f8:1dff:feb8:947a", 76 | "proxiable": true, 77 | "proxied": false, 78 | "ttl": 600, 79 | "locked": false, 80 | "zone_id": "ff12ab34cd5611334422ab3322997650", 81 | "zone_name": "unit.tests", 82 | "modified_on": "2017-03-11T18:01:43.843594Z", 83 | "created_on": "2017-03-11T18:01:43.843594Z", 84 | "meta": { 85 | "auto_added": false 86 | } 87 | }, 88 | { 89 | "id": "fc12ab34cd5611334422ab3322997656", 90 | "type": "CNAME", 91 | "name": "cname.unit.tests", 92 | "content": "unit.tests", 93 | "proxiable": true, 94 | "proxied": false, 95 | "ttl": 300, 96 | "locked": false, 97 | "zone_id": "ff12ab34cd5611334422ab3322997650", 98 | "zone_name": "unit.tests", 99 | "modified_on": "2017-03-11T18:01:43.940682Z", 100 | "created_on": "2017-03-11T18:01:43.940682Z", 101 | "meta": { 102 | "auto_added": false 103 | } 104 | }, 105 | { 106 | "id": "fc12ab34cd5611334422ab3322997657", 107 | "type": "MX", 108 | "name": "mx.unit.tests", 109 | "content": "smtp-1.unit.tests", 110 | "proxiable": false, 111 | "proxied": false, 112 | "ttl": 300, 113 | "priority": 40, 114 | "locked": false, 115 | "zone_id": "ff12ab34cd5611334422ab3322997650", 116 | "zone_name": "unit.tests", 117 | "modified_on": "2017-03-11T18:01:43.764273Z", 118 | "created_on": "2017-03-11T18:01:43.764273Z", 119 | "meta": { 120 | "auto_added": false 121 | } 122 | }, 123 | { 124 | "id": "fc12ab34cd5611334422ab3322997658", 125 | "type": "MX", 126 | "name": "mx.unit.tests", 127 | "content": "smtp-2.unit.tests", 128 | "proxiable": false, 129 | "proxied": false, 130 | "ttl": 300, 131 | "priority": 20, 132 | "locked": false, 133 | "zone_id": "ff12ab34cd5611334422ab3322997650", 134 | "zone_name": "unit.tests", 135 | "modified_on": "2017-03-11T18:01:43.586007Z", 136 | "created_on": "2017-03-11T18:01:43.586007Z", 137 | "meta": { 138 | "auto_added": false 139 | } 140 | }, 141 | { 142 | "id": "fc12ab34cd5611334422ab3322997659", 143 | "type": "MX", 144 | "name": "mx.unit.tests", 145 | "content": "smtp-3.unit.tests", 146 | "proxiable": false, 147 | "proxied": false, 148 | "ttl": 300, 149 | "priority": 30, 150 | "locked": false, 151 | "zone_id": "ff12ab34cd5611334422ab3322997650", 152 | "zone_name": "unit.tests", 153 | "modified_on": "2017-03-11T18:01:43.670592Z", 154 | "created_on": "2017-03-11T18:01:43.670592Z", 155 | "meta": { 156 | "auto_added": false 157 | } 158 | }, 159 | { 160 | "id": "fc12ab34cd5611334422ab3322997660", 161 | "type": "MX", 162 | "name": "mx.unit.tests", 163 | "content": "smtp-4.unit.tests", 164 | "proxiable": false, 165 | "proxied": false, 166 | "ttl": 300, 167 | "priority": 10, 168 | "locked": false, 169 | "zone_id": "ff12ab34cd5611334422ab3322997650", 170 | "zone_name": "unit.tests", 171 | "modified_on": "2017-03-11T18:01:43.505671Z", 172 | "created_on": "2017-03-11T18:01:43.505671Z", 173 | "meta": { 174 | "auto_added": false 175 | } 176 | } 177 | ], 178 | "result_info": { 179 | "page": 1, 180 | "per_page": 10, 181 | "total_pages": 3, 182 | "count": 10, 183 | "total_count": 27 184 | }, 185 | "success": true, 186 | "errors": [], 187 | "messages": [] 188 | } 189 | -------------------------------------------------------------------------------- /tests/fixtures/cloudflare-dns_records-page-2.json: -------------------------------------------------------------------------------- 1 | { 2 | "result": [ 3 | { 4 | "id": "fc12ab34cd5611334422ab3322997661", 5 | "type": "NS", 6 | "name": "under.unit.tests", 7 | "content": "ns1.unit.tests", 8 | "proxiable": false, 9 | "proxied": false, 10 | "ttl": 3600, 11 | "locked": false, 12 | "zone_id": "ff12ab34cd5611334422ab3322997650", 13 | "zone_name": "unit.tests", 14 | "modified_on": "2017-03-11T18:01:42.599878Z", 15 | "created_on": "2017-03-11T18:01:42.599878Z", 16 | "meta": { 17 | "auto_added": false 18 | } 19 | }, 20 | { 21 | "id": "fc12ab34cd5611334422ab3322997662", 22 | "type": "NS", 23 | "name": "under.unit.tests", 24 | "content": "ns2.unit.tests", 25 | "proxiable": false, 26 | "proxied": false, 27 | "ttl": 3600, 28 | "locked": false, 29 | "zone_id": "ff12ab34cd5611334422ab3322997650", 30 | "zone_name": "unit.tests", 31 | "modified_on": "2017-03-11T18:01:42.727011Z", 32 | "created_on": "2017-03-11T18:01:42.727011Z", 33 | "meta": { 34 | "auto_added": false 35 | } 36 | }, 37 | { 38 | "id": "fc12ab34cd5611334422ab3322997663", 39 | "type": "SPF", 40 | "name": "spf.unit.tests", 41 | "content": "v=spf1 ip4:192.168.0.1/16-all", 42 | "proxiable": false, 43 | "proxied": false, 44 | "ttl": 600, 45 | "locked": false, 46 | "zone_id": "ff12ab34cd5611334422ab3322997650", 47 | "zone_name": "unit.tests", 48 | "modified_on": "2017-03-11T18:01:44.112568Z", 49 | "created_on": "2017-03-11T18:01:44.112568Z", 50 | "meta": { 51 | "auto_added": false 52 | } 53 | }, 54 | { 55 | "id": "fc12ab34cd5611334422ab3322997664", 56 | "type": "TXT", 57 | "name": "txt.unit.tests", 58 | "content": "\"Bah bah black sheep\"", 59 | "proxiable": false, 60 | "proxied": false, 61 | "ttl": 600, 62 | "locked": false, 63 | "zone_id": "ff12ab34cd5611334422ab3322997650", 64 | "zone_name": "unit.tests", 65 | "modified_on": "2017-03-11T18:01:42.837282Z", 66 | "created_on": "2017-03-11T18:01:42.837282Z", 67 | "meta": { 68 | "auto_added": false 69 | } 70 | }, 71 | { 72 | "id": "fc12ab34cd5611334422ab3322997665", 73 | "type": "TXT", 74 | "name": "txt.unit.tests", 75 | "content": "\"have you any wool.\"", 76 | "proxiable": false, 77 | "proxied": false, 78 | "ttl": 600, 79 | "locked": false, 80 | "zone_id": "ff12ab34cd5611334422ab3322997650", 81 | "zone_name": "unit.tests", 82 | "modified_on": "2017-03-11T18:01:42.961566Z", 83 | "created_on": "2017-03-11T18:01:42.961566Z", 84 | "meta": { 85 | "auto_added": false 86 | } 87 | }, 88 | { 89 | "id": "fc12ab34cd5611334422ab3322997666", 90 | "type": "SOA", 91 | "name": "unit.tests", 92 | "content": "ignored", 93 | "proxiable": false, 94 | "proxied": false, 95 | "ttl": 600, 96 | "locked": false, 97 | "zone_id": "ff12ab34cd5611334422ab3322997650", 98 | "zone_name": "unit.tests", 99 | "modified_on": "2017-03-11T18:01:42.961566Z", 100 | "created_on": "2017-03-11T18:01:42.961566Z", 101 | "meta": { 102 | "auto_added": false 103 | } 104 | }, 105 | { 106 | "id": "fc12ab34cd5611334422ab3322997667", 107 | "type": "TXT", 108 | "name": "txt.unit.tests", 109 | "content": "\"v=DKIM1;k=rsa;s=email;h=sha256;p=A\/kinda+of\/long\/string+with+numb3rs\"", 110 | "proxiable": false, 111 | "proxied": false, 112 | "ttl": 600, 113 | "locked": false, 114 | "zone_id": "ff12ab34cd5611334422ab3322997650", 115 | "zone_name": "unit.tests", 116 | "modified_on": "2017-03-11T18:01:42.961566Z", 117 | "created_on": "2017-03-11T18:01:42.961566Z", 118 | "meta": { 119 | "auto_added": false 120 | } 121 | }, 122 | { 123 | "id": "fc223b34cd5611334422ab3322997667", 124 | "type": "CAA", 125 | "name": "unit.tests", 126 | "data": { 127 | "flags": 0, 128 | "tag": "issue", 129 | "value": "ca.unit.tests" 130 | }, 131 | "proxiable": false, 132 | "proxied": false, 133 | "ttl": 3600, 134 | "locked": false, 135 | "zone_id": "ff12ab34cd5611334422ab3322997650", 136 | "zone_name": "unit.tests", 137 | "modified_on": "2017-03-11T18:01:42.961566Z", 138 | "created_on": "2017-03-11T18:01:42.961566Z", 139 | "meta": { 140 | "auto_added": false 141 | } 142 | }, 143 | { 144 | "id": "fc12ab34cd5611334422ab3322997656", 145 | "type": "CNAME", 146 | "name": "included.unit.tests", 147 | "content": "unit.tests", 148 | "proxiable": true, 149 | "proxied": false, 150 | "ttl": 3600, 151 | "locked": false, 152 | "zone_id": "ff12ab34cd5611334422ab3322997650", 153 | "zone_name": "unit.tests", 154 | "modified_on": "2017-03-11T18:01:43.940682Z", 155 | "created_on": "2017-03-11T18:01:43.940682Z", 156 | "meta": { 157 | "auto_added": false 158 | } 159 | }, 160 | { 161 | "id": "fc12ab34cd5611334422ab3322997677", 162 | "type": "PTR", 163 | "name": "ptr.unit.tests", 164 | "content": "foo.bar.com", 165 | "proxiable": true, 166 | "proxied": false, 167 | "ttl": 300, 168 | "locked": false, 169 | "zone_id": "ff12ab34cd5611334422ab3322997650", 170 | "zone_name": "unit.tests", 171 | "modified_on": "2017-03-11T18:01:43.940682Z", 172 | "created_on": "2017-03-11T18:01:43.940682Z", 173 | "meta": { 174 | "auto_added": false 175 | } 176 | }, 177 | { 178 | "id": "fc12ab34cd5611334422ab3322997656", 179 | "type": "SRV", 180 | "name": "_imap._tcp.unit.tests", 181 | "data": { 182 | "service": "_imap", 183 | "proto": "_tcp", 184 | "name": "unit.tests", 185 | "priority": 0, 186 | "weight": 0, 187 | "port": 0, 188 | "target": "." 189 | }, 190 | "proxiable": true, 191 | "proxied": false, 192 | "ttl": 600, 193 | "locked": false, 194 | "zone_id": "ff12ab34cd5611334422ab3322997650", 195 | "zone_name": "unit.tests", 196 | "modified_on": "2017-03-11T18:01:43.940682Z", 197 | "created_on": "2017-03-11T18:01:43.940682Z", 198 | "meta": { 199 | "auto_added": false 200 | } 201 | }, 202 | { 203 | "id": "fc12ab34cd5611334422ab3322997656", 204 | "type": "SRV", 205 | "name": "_pop3._tcp.unit.tests", 206 | "data": { 207 | "service": "_imap", 208 | "proto": "_pop3", 209 | "name": "unit.tests", 210 | "priority": 0, 211 | "weight": 0, 212 | "port": 0, 213 | "target": "." 214 | }, 215 | "proxiable": true, 216 | "proxied": false, 217 | "ttl": 600, 218 | "locked": false, 219 | "zone_id": "ff12ab34cd5611334422ab3322997650", 220 | "zone_name": "unit.tests", 221 | "modified_on": "2017-03-11T18:01:43.940682Z", 222 | "created_on": "2017-03-11T18:01:43.940682Z", 223 | "meta": { 224 | "auto_added": false 225 | } 226 | } 227 | ], 228 | "result_info": { 229 | "page": 2, 230 | "per_page": 10, 231 | "total_pages": 3, 232 | "count": 10, 233 | "total_count": 27 234 | }, 235 | "success": true, 236 | "errors": [], 237 | "messages": [] 238 | } 239 | -------------------------------------------------------------------------------- /tests/fixtures/cloudflare-dns_records-page-3.json: -------------------------------------------------------------------------------- 1 | { 2 | "result": [ 3 | { 4 | "id": "fc12ab34cd5611334422ab3322997656", 5 | "type": "SRV", 6 | "name": "_srv._tcp.unit.tests", 7 | "data": { 8 | "service": "_srv", 9 | "proto": "_tcp", 10 | "name": "unit.tests", 11 | "priority": 12, 12 | "weight": 20, 13 | "port": 30, 14 | "target": "foo-2.unit.tests" 15 | }, 16 | "proxiable": true, 17 | "proxied": false, 18 | "ttl": 600, 19 | "locked": false, 20 | "zone_id": "ff12ab34cd5611334422ab3322997650", 21 | "zone_name": "unit.tests", 22 | "modified_on": "2017-03-11T18:01:43.940682Z", 23 | "created_on": "2017-03-11T18:01:43.940682Z", 24 | "meta": { 25 | "auto_added": false 26 | } 27 | }, 28 | { 29 | "id": "fc12ab34cd5611334422ab3322997656", 30 | "type": "SRV", 31 | "name": "_srv._tcp.unit.tests", 32 | "data": { 33 | "service": "_srv", 34 | "proto": "_tcp", 35 | "name": "unit.tests", 36 | "priority": 10, 37 | "weight": 20, 38 | "port": 30, 39 | "target": "foo-1.unit.tests" 40 | }, 41 | "proxiable": true, 42 | "proxied": false, 43 | "ttl": 600, 44 | "locked": false, 45 | "zone_id": "ff12ab34cd5611334422ab3322997650", 46 | "zone_name": "unit.tests", 47 | "modified_on": "2017-03-11T18:01:43.940682Z", 48 | "created_on": "2017-03-11T18:01:43.940682Z", 49 | "meta": { 50 | "auto_added": false 51 | } 52 | }, 53 | { 54 | "id": "372e67954025e0ba6aaa6d586b9e0b59", 55 | "type": "LOC", 56 | "name": "loc.unit.tests", 57 | "content": "IN LOC 31 58 52.1 S 115 49 11.7 E 20m 10m 10m 2m", 58 | "proxiable": true, 59 | "proxied": false, 60 | "ttl": 300, 61 | "locked": false, 62 | "zone_id": "ff12ab34cd5611334422ab3322997650", 63 | "zone_name": "unit.tests", 64 | "created_on": "2020-01-28T05:20:00.12345Z", 65 | "modified_on": "2020-01-28T05:20:00.12345Z", 66 | "data": { 67 | "lat_degrees": 31, 68 | "lat_minutes": 58, 69 | "lat_seconds": 52.1, 70 | "lat_direction": "S", 71 | "long_degrees": 115, 72 | "long_minutes": 49, 73 | "long_seconds": 11.7, 74 | "long_direction": "E", 75 | "altitude": 20, 76 | "size": 10, 77 | "precision_horz": 10, 78 | "precision_vert": 2 79 | }, 80 | "meta": { 81 | "auto_added": true, 82 | "source": "primary" 83 | } 84 | }, 85 | { 86 | "id": "372e67954025e0ba6aaa6d586b9e0b59", 87 | "type": "LOC", 88 | "name": "loc.unit.tests", 89 | "content": "IN LOC 53 14 10 N 2 18 26 W 20m 10m 1000m 2m", 90 | "proxiable": true, 91 | "proxied": false, 92 | "ttl": 300, 93 | "locked": false, 94 | "zone_id": "ff12ab34cd5611334422ab3322997650", 95 | "zone_name": "unit.tests", 96 | "created_on": "2020-01-28T05:20:00.12345Z", 97 | "modified_on": "2020-01-28T05:20:00.12345Z", 98 | "data": { 99 | "lat_degrees": 53, 100 | "lat_minutes": 13, 101 | "lat_seconds": 10, 102 | "lat_direction": "N", 103 | "long_degrees": 2, 104 | "long_minutes": 18, 105 | "long_seconds": 26, 106 | "long_direction": "W", 107 | "altitude": 20, 108 | "size": 10, 109 | "precision_horz": 1000, 110 | "precision_vert": 2 111 | }, 112 | "meta": { 113 | "auto_added": true, 114 | "source": "primary" 115 | } 116 | }, 117 | { 118 | "id": "8b60c8518d09465ffc7741b7a4a431f99", 119 | "type": "NAPTR", 120 | "name": "unit.tests", 121 | "content": "30 100 \"S\" \"SIP+D2T\" \"\" _sip._tls.unit.tests.", 122 | "proxiable": false, 123 | "proxied": false, 124 | "ttl": 1, 125 | "locked": false, 126 | "zone_id": "ff12ab34cd5611334422ab3322997650", 127 | "zone_name": "unit.tests", 128 | "created_on": "2023-01-08T01:02:34.567985Z", 129 | "modified_on": "2023-01-08T01:02:34.567985Z", 130 | "data": { 131 | "flags": "S", 132 | "order": 30, 133 | "preference": 100, 134 | "regex": "", 135 | "replacement": "_sip._tls.unit.tests.", 136 | "service": "SIP+D2T" 137 | }, 138 | "meta": { 139 | "auto_added": false, 140 | "managed_by_apps": false, 141 | "managed_by_argo_tunnel": false, 142 | "source": "primary" 143 | } 144 | }, 145 | { 146 | "id": "ggozrtnzb11nrr9qs4ko6y3j19qkehux9", 147 | "type": "SSHFP", 148 | "name": "unit.tests", 149 | "content": "1 2 859be6ed04643db411f067b6c1da1d75fe08b672", 150 | "proxiable": false, 151 | "proxied": false, 152 | "ttl": 300, 153 | "locked": false, 154 | "zone_id": "ff12ab34cd5611334422ab3322997650", 155 | "zone_name": "unit.tests", 156 | "created_on": "2023-03-02T01:02:44.567985Z", 157 | "modified_on": "2023-03-02T01:02:44.567985Z", 158 | "data": { 159 | "algorithm": 1, 160 | "type": 2, 161 | "fingerprint": "859be6ed04643db411f067b6c1da1d75fe08b672" 162 | }, 163 | "meta": { 164 | "auto_added": false, 165 | "managed_by_apps": false, 166 | "managed_by_argo_tunnel": false, 167 | "source": "primary" 168 | } 169 | }, 170 | { 171 | "id": "fc12ab34cd5612345422ab3322997664", 172 | "type": "TXT", 173 | "name": "txt.gíthub.com", 174 | "content": "\"IDNA test data\"", 175 | "proxiable": false, 176 | "proxied": false, 177 | "ttl": 600, 178 | "locked": false, 179 | "zone_id": "234234243423aaabb334342bbb343433", 180 | "zone_name": "gíthub.com", 181 | "modified_on": "2017-03-11T18:01:42.837282Z", 182 | "created_on": "2017-03-11T18:01:42.837282Z", 183 | "meta": { 184 | "auto_added": false 185 | } 186 | }, 187 | { 188 | "content": "1 13 2 B5BB9D8014A0F9B1D61E21E796D78DCCDF1352F23CD32812F4850B878AE4944C", 189 | "created_on": "2024-05-20T19:27:04.707266Z", 190 | "data": { 191 | "algorithm": 13, 192 | "digest": "B5BB9D8014A0F9B1D61E21E796D78DCCDF1352F23CD32812F4850B878AE4944C", 193 | "digest_type": 2, 194 | "key_tag": 1 195 | }, 196 | "id": "b7ac1830a4f79eb06d17fd4ca035cafd", 197 | "locked": false, 198 | "meta": { 199 | "auto_added": false, 200 | "managed_by_apps": false, 201 | "managed_by_argo_tunnel": false 202 | }, 203 | "modified_on": "2024-05-20T19:27:04.707266Z", 204 | "name": "ds.unit.tests", 205 | "proxiable": false, 206 | "proxied": false, 207 | "tags": [], 208 | "ttl": 300, 209 | "type": "DS", 210 | "zone_id": "0896a14115d694fe7ad10dd1ed282578", 211 | "zone_name": "unit.tests" 212 | }, 213 | { 214 | "content": "1 www.unit.tests. alpn=\"h3,h2\"", 215 | "created_on": "2024-05-20T19:27:04.707266Z", 216 | "data": { 217 | "priority": 1, 218 | "target": "www.unit.tests.", 219 | "value": "alpn=\"h3,h2\"" 220 | }, 221 | "id": "e7ac1830a4f79eb06d17fd4ca043cafd", 222 | "locked": false, 223 | "meta": { 224 | "auto_added": false, 225 | "managed_by_apps": false, 226 | "managed_by_argo_tunnel": false 227 | }, 228 | "modified_on": "2024-05-20T19:27:04.707266Z", 229 | "name": "svcb.unit.tests", 230 | "proxiable": false, 231 | "proxied": false, 232 | "tags": [], 233 | "ttl": 303, 234 | "type": "SVCB", 235 | "zone_id": "0896a14115d694fe7ad10dd1ed282578", 236 | "zone_name": "unit.tests" 237 | }, 238 | { 239 | "content": "1 www.unit.tests. alpn=\"h3,h2\" ipv4hint=\"127.0.0.2\"", 240 | "created_on": "2024-05-20T19:27:04.707266Z", 241 | "data": { 242 | "priority": 1, 243 | "target": "www.unit.tests.", 244 | "value": "alpn=\"h3,h2\" ipv4hint=\"127.0.0.2\"" 245 | }, 246 | "id": "f8ac1830a4f79eb06d17fd4ca043cafd", 247 | "locked": false, 248 | "meta": { 249 | "auto_added": false, 250 | "managed_by_apps": false, 251 | "managed_by_argo_tunnel": false 252 | }, 253 | "modified_on": "2024-05-20T19:27:04.707266Z", 254 | "name": "https.unit.tests", 255 | "proxiable": false, 256 | "proxied": false, 257 | "tags": [], 258 | "ttl": 304, 259 | "type": "HTTPS", 260 | "zone_id": "0896a14115d694fe7ad10dd1ed282578", 261 | "zone_name": "unit.tests" 262 | } 263 | ], 264 | "result_info": { 265 | "page": 3, 266 | "per_page": 10, 267 | "total_pages": 3, 268 | "count": 8, 269 | "total_count": 27 270 | }, 271 | "success": true, 272 | "errors": [], 273 | "messages": [] 274 | } 275 | -------------------------------------------------------------------------------- /tests/fixtures/cloudflare-pagerules.json: -------------------------------------------------------------------------------- 1 | { 2 | "result": [ 3 | { 4 | "id": "2b1ec1793185213139f22059a165376e", 5 | "targets": [ 6 | { 7 | "target": "url", 8 | "constraint": { 9 | "operator": "matches", 10 | "value": "urlfwd0.unit.tests/" 11 | } 12 | } 13 | ], 14 | "actions": [ 15 | { 16 | "id": "always_use_https" 17 | } 18 | ], 19 | "priority": 4, 20 | "status": "active", 21 | "created_on": "2021-06-29T17:14:28.000000Z", 22 | "modified_on": "2021-06-29T17:15:33.000000Z" 23 | }, 24 | { 25 | "id": "2b1ec1793185213139f22059a165376f", 26 | "targets": [ 27 | { 28 | "target": "url", 29 | "constraint": { 30 | "operator": "matches", 31 | "value": "urlfwd0.unit.tests/*" 32 | } 33 | } 34 | ], 35 | "actions": [ 36 | { 37 | "id": "forwarding_url", 38 | "value": { 39 | "url": "https://www.unit.tests/", 40 | "status_code": 301 41 | } 42 | } 43 | ], 44 | "priority": 3, 45 | "status": "active", 46 | "created_on": "2021-06-29T17:07:12.000000Z", 47 | "modified_on": "2021-06-29T17:15:12.000000Z" 48 | }, 49 | { 50 | "id": "2b1ec1793185213139f22059a165377e", 51 | "targets": [ 52 | { 53 | "target": "url", 54 | "constraint": { 55 | "operator": "matches", 56 | "value": "urlfwd1.unit.tests/*" 57 | } 58 | } 59 | ], 60 | "actions": [ 61 | { 62 | "id": "forwarding_url", 63 | "value": { 64 | "url": "https://www.unit.tests/", 65 | "status_code": 302 66 | } 67 | } 68 | ], 69 | "priority": 2, 70 | "status": "active", 71 | "created_on": "2021-06-28T22:42:27.000000Z", 72 | "modified_on": "2021-06-28T22:43:13.000000Z" 73 | }, 74 | { 75 | "id": "2a9140b17ffb0e6aed826049eec970b8", 76 | "targets": [ 77 | { 78 | "target": "url", 79 | "constraint": { 80 | "operator": "matches", 81 | "value": "urlfwd2.unit.tests/*" 82 | } 83 | } 84 | ], 85 | "actions": [ 86 | { 87 | "id": "forwarding_url", 88 | "value": { 89 | "url": "https://www.unit.tests/", 90 | "status_code": 301 91 | } 92 | } 93 | ], 94 | "priority": 1, 95 | "status": "active", 96 | "created_on": "2021-06-25T20:10:50.000000Z", 97 | "modified_on": "2021-06-28T22:38:10.000000Z" 98 | } 99 | ], 100 | "success": true, 101 | "errors": [], 102 | "messages": [] 103 | } 104 | -------------------------------------------------------------------------------- /tests/fixtures/cloudflare-zones-page-1.json: -------------------------------------------------------------------------------- 1 | { 2 | "result": [ 3 | { 4 | "id": "234234243423aaabb334342aaa343433", 5 | "name": "github.com", 6 | "status": "pending", 7 | "paused": false, 8 | "type": "full", 9 | "development_mode": 0, 10 | "name_servers": [ 11 | "alice.ns.cloudflare.com", 12 | "tom.ns.cloudflare.com" 13 | ], 14 | "original_name_servers": [], 15 | "original_registrar": null, 16 | "original_dnshost": null, 17 | "modified_on": "2017-02-20T03:57:03.753292Z", 18 | "created_on": "2017-02-20T03:53:59.274170Z", 19 | "meta": { 20 | "step": 4, 21 | "wildcard_proxiable": false, 22 | "custom_certificate_quota": 0, 23 | "page_rule_quota": 3, 24 | "phishing_detected": false, 25 | "multiple_railguns_allowed": false 26 | }, 27 | "owner": { 28 | "type": "user", 29 | "id": "334234243423aaabb334342aaa343433", 30 | "email": "noreply@github.com" 31 | }, 32 | "permissions": [ 33 | "#analytics:read", 34 | "#billing:edit", 35 | "#billing:read", 36 | "#cache_purge:edit", 37 | "#dns_records:edit", 38 | "#dns_records:read", 39 | "#lb:edit", 40 | "#lb:read", 41 | "#logs:read", 42 | "#organization:edit", 43 | "#organization:read", 44 | "#ssl:edit", 45 | "#ssl:read", 46 | "#waf:edit", 47 | "#waf:read", 48 | "#zone:edit", 49 | "#zone:read", 50 | "#zone_settings:edit", 51 | "#zone_settings:read" 52 | ], 53 | "plan": { 54 | "id": "0feeeeeeeeeeeeeeeeeeeeeeeeeeeeee", 55 | "name": "Free Website", 56 | "price": 0, 57 | "currency": "USD", 58 | "frequency": "", 59 | "is_subscribed": true, 60 | "can_subscribe": false, 61 | "legacy_id": "free", 62 | "legacy_discount": false, 63 | "externally_managed": false 64 | } 65 | }, 66 | { 67 | "id": "234234243423aaabb334342aaa343434", 68 | "name": "github.io", 69 | "status": "pending", 70 | "paused": false, 71 | "type": "full", 72 | "development_mode": 0, 73 | "name_servers": [ 74 | "alice.ns.cloudflare.com", 75 | "tom.ns.cloudflare.com" 76 | ], 77 | "original_name_servers": [], 78 | "original_registrar": null, 79 | "original_dnshost": null, 80 | "modified_on": "2017-02-20T04:12:00.732827Z", 81 | "created_on": "2017-02-20T04:11:58.250696Z", 82 | "meta": { 83 | "step": 4, 84 | "wildcard_proxiable": false, 85 | "custom_certificate_quota": 0, 86 | "page_rule_quota": 3, 87 | "phishing_detected": false, 88 | "multiple_railguns_allowed": false 89 | }, 90 | "owner": { 91 | "type": "user", 92 | "id": "334234243423aaabb334342aaa343433", 93 | "email": "noreply@github.com" 94 | }, 95 | "permissions": [ 96 | "#analytics:read", 97 | "#billing:edit", 98 | "#billing:read", 99 | "#cache_purge:edit", 100 | "#dns_records:edit", 101 | "#dns_records:read", 102 | "#lb:edit", 103 | "#lb:read", 104 | "#logs:read", 105 | "#organization:edit", 106 | "#organization:read", 107 | "#ssl:edit", 108 | "#ssl:read", 109 | "#waf:edit", 110 | "#waf:read", 111 | "#zone:edit", 112 | "#zone:read", 113 | "#zone_settings:edit", 114 | "#zone_settings:read" 115 | ], 116 | "plan": { 117 | "id": "0feeeeeeeeeeeeeeeeeeeeeeeeeeeeee", 118 | "name": "Free Website", 119 | "price": 0, 120 | "currency": "USD", 121 | "frequency": "", 122 | "is_subscribed": true, 123 | "can_subscribe": false, 124 | "legacy_id": "free", 125 | "legacy_discount": false, 126 | "externally_managed": false 127 | } 128 | } 129 | ], 130 | "result_info": { 131 | "page": 1, 132 | "per_page": 2, 133 | "total_pages": 3, 134 | "count": 2, 135 | "total_count": 5 136 | }, 137 | "success": true, 138 | "errors": [], 139 | "messages": [] 140 | } 141 | -------------------------------------------------------------------------------- /tests/fixtures/cloudflare-zones-page-2.json: -------------------------------------------------------------------------------- 1 | { 2 | "result": [ 3 | { 4 | "id": "234234243423aaabb334342aaa343434", 5 | "name": "githubusercontent.com", 6 | "status": "pending", 7 | "paused": false, 8 | "type": "full", 9 | "development_mode": 0, 10 | "original_name_servers": [], 11 | "original_registrar": null, 12 | "original_dnshost": null, 13 | "modified_on": "2017-02-20T04:06:46.019706Z", 14 | "created_on": "2017-02-20T04:05:51.683040Z", 15 | "meta": { 16 | "step": 4, 17 | "wildcard_proxiable": false, 18 | "custom_certificate_quota": 0, 19 | "page_rule_quota": 3, 20 | "phishing_detected": false, 21 | "multiple_railguns_allowed": false 22 | }, 23 | "owner": { 24 | "type": "user", 25 | "id": "334234243423aaabb334342aaa343433", 26 | "email": "noreply@github.com" 27 | }, 28 | "permissions": [ 29 | "#analytics:read", 30 | "#billing:edit", 31 | "#billing:read", 32 | "#cache_purge:edit", 33 | "#dns_records:edit", 34 | "#dns_records:read", 35 | "#lb:edit", 36 | "#lb:read", 37 | "#logs:read", 38 | "#organization:edit", 39 | "#organization:read", 40 | "#ssl:edit", 41 | "#ssl:read", 42 | "#waf:edit", 43 | "#waf:read", 44 | "#zone:edit", 45 | "#zone:read", 46 | "#zone_settings:edit", 47 | "#zone_settings:read" 48 | ], 49 | "plan": { 50 | "id": "0feeeeeeeeeeeeeeeeeeeeeeeeeeeeee", 51 | "name": "Free Website", 52 | "price": 0, 53 | "currency": "USD", 54 | "frequency": "", 55 | "is_subscribed": true, 56 | "can_subscribe": false, 57 | "legacy_id": "free", 58 | "legacy_discount": false, 59 | "externally_managed": false 60 | } 61 | }, 62 | { 63 | "id": "234234243423aaabb334342aaa343435", 64 | "name": "unit.tests", 65 | "status": "pending", 66 | "paused": false, 67 | "type": "full", 68 | "development_mode": 0, 69 | "name_servers": [ 70 | "alice.ns.cloudflare.com", 71 | "tom.ns.cloudflare.com" 72 | ], 73 | "original_name_servers": [], 74 | "original_registrar": null, 75 | "original_dnshost": null, 76 | "modified_on": "2017-02-20T04:10:23.687329Z", 77 | "created_on": "2017-02-20T04:10:18.294562Z", 78 | "meta": { 79 | "step": 4, 80 | "wildcard_proxiable": false, 81 | "custom_certificate_quota": 0, 82 | "page_rule_quota": 3, 83 | "phishing_detected": false, 84 | "multiple_railguns_allowed": false 85 | }, 86 | "owner": { 87 | "type": "user", 88 | "id": "334234243423aaabb334342aaa343433", 89 | "email": "noreply@github.com" 90 | }, 91 | "permissions": [ 92 | "#analytics:read", 93 | "#billing:edit", 94 | "#billing:read", 95 | "#cache_purge:edit", 96 | "#dns_records:edit", 97 | "#dns_records:read", 98 | "#lb:edit", 99 | "#lb:read", 100 | "#logs:read", 101 | "#organization:edit", 102 | "#organization:read", 103 | "#ssl:edit", 104 | "#ssl:read", 105 | "#waf:edit", 106 | "#waf:read", 107 | "#zone:edit", 108 | "#zone:read", 109 | "#zone_settings:edit", 110 | "#zone_settings:read" 111 | ], 112 | "plan": { 113 | "id": "0feeeeeeeeeeeeeeeeeeeeeeeeeeeeee", 114 | "name": "Free Website", 115 | "price": 0, 116 | "currency": "USD", 117 | "frequency": "", 118 | "is_subscribed": true, 119 | "can_subscribe": false, 120 | "legacy_id": "free", 121 | "legacy_discount": false, 122 | "externally_managed": false 123 | } 124 | } 125 | ], 126 | "result_info": { 127 | "page": 2, 128 | "per_page": 2, 129 | "total_pages": 3, 130 | "count": 2, 131 | "total_count": 5 132 | }, 133 | "success": true, 134 | "errors": [], 135 | "messages": [] 136 | } 137 | -------------------------------------------------------------------------------- /tests/fixtures/cloudflare-zones-page-3.json: -------------------------------------------------------------------------------- 1 | { 2 | "result": [ 3 | { 4 | "id": "234234243423aaabb334342bbb343433", 5 | "name": "gíthub.com", 6 | "status": "pending", 7 | "paused": false, 8 | "type": "full", 9 | "development_mode": 0, 10 | "name_servers": [ 11 | "alice.ns.cloudflare.com", 12 | "tom.ns.cloudflare.com" 13 | ], 14 | "original_name_servers": [], 15 | "original_registrar": null, 16 | "original_dnshost": null, 17 | "modified_on": "2017-02-20T03:57:03.753292Z", 18 | "created_on": "2017-02-20T03:53:59.274170Z", 19 | "meta": { 20 | "step": 4, 21 | "wildcard_proxiable": false, 22 | "custom_certificate_quota": 0, 23 | "page_rule_quota": 3, 24 | "phishing_detected": false, 25 | "multiple_railguns_allowed": false 26 | }, 27 | "owner": { 28 | "type": "user", 29 | "id": "334234243423aaabb334342aaa343433", 30 | "email": "noreply@github.com" 31 | }, 32 | "permissions": [ 33 | "#analytics:read", 34 | "#billing:edit", 35 | "#billing:read", 36 | "#cache_purge:edit", 37 | "#dns_records:edit", 38 | "#dns_records:read", 39 | "#lb:edit", 40 | "#lb:read", 41 | "#logs:read", 42 | "#organization:edit", 43 | "#organization:read", 44 | "#ssl:edit", 45 | "#ssl:read", 46 | "#waf:edit", 47 | "#waf:read", 48 | "#zone:edit", 49 | "#zone:read", 50 | "#zone_settings:edit", 51 | "#zone_settings:read" 52 | ], 53 | "plan": { 54 | "id": "0feeeeeeeeeeeeeeeeeeeeeeeeeeeeee", 55 | "name": "Free Website", 56 | "price": 0, 57 | "currency": "USD", 58 | "frequency": "", 59 | "is_subscribed": true, 60 | "can_subscribe": false, 61 | "legacy_id": "free", 62 | "legacy_discount": false, 63 | "externally_managed": false 64 | } 65 | } 66 | ], 67 | "result_info": { 68 | "page": 3, 69 | "per_page": 2, 70 | "total_pages": 3, 71 | "count": 1, 72 | "total_count": 5 73 | }, 74 | "success": true, 75 | "errors": [], 76 | "messages": [] 77 | } 78 | -------------------------------------------------------------------------------- /tests/test_octodns_provider_cloudflare_processor_proxycname.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from octodns.provider.yaml import YamlProvider 4 | from octodns.record import Record 5 | from octodns.zone import Zone 6 | 7 | from octodns_cloudflare import CloudflareProvider 8 | from octodns_cloudflare.processor.proxycname import ProxyCNAME 9 | 10 | 11 | class TestProxyCNAME(TestCase): 12 | def test_proxy_cname(self): 13 | processor = ProxyCNAME('test') 14 | 15 | zone = Zone('unit.tests.', []) # Simulate what we want 16 | zone_empty = Zone( 17 | 'unit.tests.', [] 18 | ) # Simulate there being nothing existing 19 | zone_expected_cf = Zone( 20 | 'unit.tests.', [] 21 | ) # What the processor should produce based off what we want for Cloudflare provider 22 | zone_expected_other = Zone( 23 | 'unit.tests.', [] 24 | ) # What the processor should produce based off what we want for other providers 25 | 26 | # Zone of what we want 27 | proxyable = Record.new( 28 | zone, 29 | 'good', 30 | { 31 | 'type': 'A', 32 | 'ttl': 300, 33 | 'value': '1.2.3.4', 34 | 'octodns': {'cloudflare': {'proxied': True}}, 35 | }, 36 | ) 37 | proxyable_root = Record.new( 38 | zone, 39 | '', 40 | { 41 | 'type': 'A', 42 | 'ttl': 300, 43 | 'value': '1.2.3.4', 44 | 'octodns': {'cloudflare': {'proxied': True}}, 45 | }, 46 | ) 47 | non_proxyable = Record.new( 48 | zone, 49 | 'bad', 50 | { 51 | 'type': 'TXT', 52 | 'ttl': 300, 53 | 'value': 'test', 54 | 'octodns': {'cloudflare': {'proxied': True}}, 55 | }, 56 | ) 57 | zone.add_record(proxyable) 58 | zone.add_record(proxyable_root) 59 | zone.add_record(non_proxyable) 60 | 61 | # Expected for both Cloudflare / other providers 62 | expected_non_proxyable = Record.new( 63 | zone, 64 | 'bad', 65 | { 66 | 'type': 'TXT', 67 | 'ttl': 300, 68 | 'value': 'test', 69 | '_octodns': {'cloudflare': {'proxied': True}}, 70 | }, 71 | ) 72 | 73 | # Expected result in Cloudflare provider 74 | expected_cf_proxyable = Record.new( 75 | zone, 76 | 'good', 77 | { 78 | 'type': 'A', 79 | 'ttl': 300, 80 | 'value': "1.2.3.4", 81 | '_octodns': {'cloudflare': {'proxied': True}}, 82 | }, 83 | ) 84 | expected_cf_proxyable_root = Record.new( 85 | zone, 86 | '', 87 | { 88 | 'type': 'A', 89 | 'ttl': 300, 90 | 'value': "1.2.3.4", 91 | '_octodns': {'cloudflare': {'proxied': True}}, 92 | }, 93 | ) 94 | zone_expected_cf.add_record(expected_cf_proxyable) 95 | zone_expected_cf.add_record(expected_cf_proxyable_root) 96 | zone_expected_cf.add_record(expected_non_proxyable) 97 | 98 | # Expected result in other providers 99 | expected_other_proxyable = Record.new( 100 | zone, 101 | 'good', 102 | { 103 | 'type': 'CNAME', 104 | 'ttl': 300, 105 | 'value': "good.unit.tests.cdn.cloudflare.net.", 106 | '_octodns': {'cloudflare': {'proxied': True}}, 107 | }, 108 | ) 109 | expected_other_proxyable_root = Record.new( 110 | zone, 111 | '', 112 | { 113 | 'type': 'ALIAS', 114 | 'ttl': 300, 115 | 'value': "unit.tests.cdn.cloudflare.net.", 116 | '_octodns': {'cloudflare': {'proxied': True}}, 117 | }, 118 | ) 119 | zone_expected_other.add_record(expected_other_proxyable) 120 | zone_expected_other.add_record(expected_other_proxyable_root) 121 | zone_expected_other.add_record(expected_non_proxyable) 122 | 123 | # Process / check Cloudflare provider destined records 124 | cloudflare_provider = CloudflareProvider( 125 | 'test', 'email', 'token', retry_period=0 126 | ) 127 | processed_cf_desired, processed_cf_existing = ( 128 | processor.process_source_and_target_zones( 129 | zone, zone_empty, cloudflare_provider 130 | ) 131 | ) 132 | self.assertEqual(zone_expected_cf.records, processed_cf_desired.records) 133 | 134 | # Process / check other provider destined records 135 | yaml_provider = YamlProvider('test', 'test') 136 | processed_other_desired, processed_other_existing = ( 137 | processor.process_source_and_target_zones( 138 | zone, zone_empty, yaml_provider 139 | ) 140 | ) 141 | self.assertEqual( 142 | zone_expected_other.records, processed_other_desired.records 143 | ) 144 | -------------------------------------------------------------------------------- /tests/test_octodns_provider_cloudflare_processor_ttl.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from octodns.record import Record 4 | from octodns.zone import Zone 5 | 6 | from octodns_cloudflare.processor.ttl import TtlToProxy 7 | 8 | 9 | class TestTtlToProxy(TestCase): 10 | def test_ttl_to_proxy(self): 11 | processor = TtlToProxy('test', ttl=0) 12 | 13 | zone = Zone('unit.tests.', []) 14 | zone_expected = Zone('unit.tests.', []) 15 | 16 | with_ttl = Record.new( 17 | zone, 'good', {'type': 'A', 'ttl': 0, 'value': '1.2.3.4'} 18 | ) 19 | with_ttl_type_other = Record.new( 20 | zone, 'ttl-only', {'type': 'TXT', 'ttl': 0, 'value': 'acme'} 21 | ) 22 | without_ttl = Record.new( 23 | zone, 'bad', {'type': 'A', 'ttl': 10, 'value': '1.2.3.4'} 24 | ) 25 | zone.add_record(with_ttl) 26 | zone.add_record(with_ttl_type_other) 27 | zone.add_record(without_ttl) 28 | 29 | expected_with = Record.new( 30 | zone, 31 | 'good', 32 | { 33 | 'type': 'A', 34 | 'ttl': 0, 35 | 'value': '1.2.3.4', 36 | '_octodns': {'cloudflare': {'proxied': True, 'auto-ttl': True}}, 37 | }, 38 | ) 39 | expected_with_ttl_only = Record.new( 40 | zone, 41 | 'ttl-only', 42 | { 43 | 'type': 'TXT', 44 | 'ttl': 0, 45 | 'value': '1.2.3.4', 46 | '_octodns': {'cloudflare': {'auto-ttl': True}}, 47 | }, 48 | ) 49 | expected_without = Record.new( 50 | zone, 'bad', {'type': 'A', 'ttl': 10, 'value': '1.2.3.4'} 51 | ) 52 | zone_expected.add_record(expected_with) 53 | zone_expected.add_record(expected_with_ttl_only) 54 | zone_expected.add_record(expected_without) 55 | 56 | added_proxy = processor.process_source_zone(zone) 57 | self.assertEqual(zone_expected.records, added_proxy.records) 58 | good = next(r for r in added_proxy.records if r.name == 'good') 59 | self.assertEqual(1, good.ttl) 60 | self.assertEqual( 61 | {'cloudflare': {'proxied': True, 'auto-ttl': True}}, good.octodns 62 | ) 63 | ttl_only = next(r for r in added_proxy.records if r.name == 'ttl-only') 64 | self.assertEqual(1, good.ttl) 65 | self.assertEqual({'cloudflare': {'auto-ttl': True}}, ttl_only.octodns) 66 | bad = next(r for r in added_proxy.records if r.name == 'bad') 67 | self.assertEqual(10, bad.ttl) 68 | self.assertFalse('cloudflare' in bad.octodns) 69 | --------------------------------------------------------------------------------