├── .gitignore ├── LICENSE ├── README.md ├── auth.json.template ├── cf-dns.sh └── get-dns.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | add.sh 3 | auth.json 4 | delete.sh 5 | f.json 6 | nf.json 7 | origin-ip.sh 8 | README.new.md 9 | test.sh 10 | update.sh 11 | update-x.sh 12 | .vscode 13 | __pycache__ 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Steve Ward 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cloudflare-dns 2 | 3 | ## Purpose 4 | Add, update or delete your domain's DNS records from the command line using the [Cloudflare API](https://api.cloudflare.com/#dns-records-for-a-zone-properties). 5 | 6 | ## Background 7 | I initially wrote this script as a quick way of repeatedly adding or updating DNS records for several domains from the command line when I was building and testing my mail server. ***If you're thinking of using it, it's important you read the [Limitations](#limitations) section first*** and check your domain's DNS records on Cloudflare afterwards. 8 | 9 | The script is based upon examples of using the Cloudflare API at [Using the Cloudflare API to Manage DNS Records](https://www.tech-otaku.com/web-development/using-cloudflare-api-manage-dns-records/). 10 | 11 | To view an existing domain's current DNS records see [Get an Existing Domain's Current DNS Records](#get-an-existing-domains-current-dns-records) 12 | 13 | ## Usage 14 | #### Help 15 | `./cf-dns.sh -h` 16 | 17 | #### Add or Update 18 | `./cf-dns.sh -d DOMAIN -n NAME -t TYPE -c CONTENT [-p PRIORITY] [-x PROXIED] [-l TTL] [-C COMMENT] [-k] [-S] [-A]` 19 | 20 | #### Delete 21 | `./cf-dns.sh -d DOMAIN -n NAME -t TYPE -c CONTENT -Z [-a] [-k] [-S]` 22 | 23 | ## Options 24 | 25 | Use `./cf-dns.sh -h` to see an explanation of the options and their usage. 26 | 27 | In addition, please note the following: 28 | 29 | --- 30 | 31 | - The scipt always requires the options *domain* (`-d DOMAIN `), *type* (`-t TYPE`), *name* (`-n NAME`) and *content* (`-c CONTENT`). 32 | 33 | - For new MX records, *priority* (`-p PRIORITY`) is required, but will be defaulted to `10` by the script if omitted. 34 | 35 | - When updating existing records the script now uses the `PATCH` method of the Cloudflare API instead of `PUT` meaning that, in addition to the mandatory options, **only** the data being updated need be passed to the script. 36 | 37 | - *Proxy status* (`-x PROXIED`) is not required for `MX` or `TXT` records as these DNS record types can not be proxied through Cloudflare. When creating `A`, `AAAA` or `CNAME` records, the Cloudflare API defaults the *proxy status* to `false` if omitted. 38 | 39 | - *TTL* (`-l TTL`) can only be set to one of the following values: 40 | 41 | | `-l TTL` | TTL | 42 | |----------|------------| 43 | | `1` | Auto | 44 | | `60` | 1 minute | 45 | | `120` | 2 minutes | 46 | | `300` | 5 minutes | 47 | | `600` | 10 minutes | 48 | | `900` | 15 minutes | 49 | | `1800` | 30 minutes | 50 | | `3600` | 1 hour | 51 | | `7200` | 2 hours | 52 | | `18000` | 5 hours | 53 | | `43200` | 12 hours | 54 | | `86400` | 1 day | 55 | 56 | If a DNS record's **Proxy status** (`-x PROXIED`) is _Proxied_ (`true`), its TTL will be set to `1` automtically by the Cloudflare API regardless of the value passed to the script. This is due to Cloudflare only allowing TTL values other than `1` for DNS records that are not proxied. 57 | 58 | - The script checks if the domain name (`-d DOMAIN`) and DNS record name (`-n NAME`) are the same. If not, the domain name is appended to the DNS record name as per the table below (think `dig TXT example.com` and `dig TXT dkim._domainkey.example.com`). 59 | 60 | | TYPE | DOMAIN | NAME | REFERENCED AS | 61 | |:------|:------------|:--------------------|:--------------------------------| 62 | | A | example.com | **example.com** | example.com | 63 | | AAAA | example.com | **example.com** | example.com | 64 | | A | example.com | **demo** | **demo**.example.com | 65 | | CNAME | example.com | **www** | **www**.example.com | 66 | | MX | example.com | **example.com** | example.com | 67 | | TXT | example.com | **dkim._domainkey** | **dkim._domainkey**.example.com | 68 | | TXT | example.com | **_dmarc** | **_dmarc**.example.com | 69 | | TXT | example.com | **example.com** | example.com | 70 | 71 |
72 | 73 | - Strings containing spaces must be enclosed in single `'` or double `"` quotes. Strings containing a variable must only be enclosed in double `"` quotes to ensure the variable is expanded. For example: 74 | 75 | `MAILTO=postmaster@mail.example.net` 76 | 77 | `./cf-dns.sh -d example.com -t TXT -n _dmarc -c "v=DMARC1; p=none; pct=100; rua=mailto:$MAILTO; sp=none; aspf=r;" -l 1` 78 | 79 | - A double `"` quote *contained* in a Comment [`-C COMMENT`] is replaced with a single `'` quote by the script to avoid the error `{"code":9207,"message":"Request body is invalid."}`. 80 | 81 | - As Cloudflare only allows a maximum Comment [`-C COMMENT`] length of 100 characters, the script truncates them to 97 characters and appends `...` . 82 | 83 | ## Authentication 84 | 85 | Your Cloudflare credentials are read from a file. Rename `auth.json.template` as `auth.json` and enter your Cloudflare credentials: 86 | 87 | ``` 88 | { 89 | "cloudflare": { 90 | "email": "your-cloudflare-email", 91 | "key": "your-cloudflare-api-key", 92 | "token": "your-cloudflare-api-token" 93 | } 94 | } 95 | ``` 96 | 97 | `email` and `key` are required if you use a legacy [API key](https://developers.cloudflare.com/api/keys) to authenticate. `token` is required if you authenticate using the preferred [API token](https://developers.cloudflare.com/api/tokens). By default, the script uses your API token. If you want it to use your API key instead you must use the `-k` option. 98 | 99 | ## How the Script Works 100 | 101 | A domain or site on your Cloudflare account is known as a zone and is assigned a unique 32-character ID by Cloudflare when it's created. A zone contains various DNS records each of which is also assigned its own unique 32-character ID by Cloudflare. A DNS record consists of data identifying its type, name and content amongst other information. 102 | 103 | To add a new DNS record the domain's zone ID has to be passed to the Cloudflare API. To update or delete an existing DNS record both the domain's zone ID and the DNS record ID must be passed to the Cloudflare API. The zone ID can be found on the domain's *Overview* page on the Cloudflare dashboard or by using the Cloudflare API to list zones on your Cloudflare account. DNS record IDs can only be found by using the Cloudflare API to list a zone's DNS records. These API calls produce a lot of output and the (correct) IDs can be difficult to find. 104 | 105 | The script helps streamline this process by not needing to know the zone ID and DNS record ID beforehand. 106 | 107 | Consider the following DNS records for the `example.com` domain: 108 | 109 | | # | Type | Comment | Name | Content | Priority | Proxy | TTL | 110 | |---------|------------| ----------------------|-------------------|--------------------------------------|-----------|----------------|------------| 111 | | 1 | A | 'A' Record | example.com | 203.0.113.50 | N/A | DNS Only | Auto | 112 | | ***2*** | ***AAAA*** | ***'AAAA' Record*** | ***example.com*** | ***2001:db8:c010:46d6::1*** | ***N/A*** | ***Proxied*** | ***Auto*** | 113 | | 3 | CNAME | 'CNAME' Record | www | example.com | N/A | Proxied | Auto | 114 | | 4 | MX | 1st 'MX' Record | example.com | alt2.aspmx.l.google.com | 10 | DNS only | Auto | 115 | | 5 | MX | 2nd 'MX' Record | example.com | aspmx.l.google.com | 10 | DNS only | Auto | 116 | | ***6*** | ***MX*** | ***3rd 'MX' Record*** | ***example.com*** | ***mail.example.com*** | ***20*** | ***DNS only*** | ***1hr*** | 117 | | 7 | TXT | 'DKIM' Record | dkim._domainkey | v=DKIM1; p=MFswDQYJKoZIhvc... | N/A | DNS only | Auto | 118 | | 8 | TXT | 'DMARC' Record | _dmarc | v=DMARC1; p=none; pct=100; r... | N/A | DNS only | Auto | 119 | | 9 | TXT | 'SPF' Record | example.com | v=spf1 mx ~all | N/A | DNS only | Auto | 120 | 121 | To demonstrate how the script works, let's assume that neither the **AAAA** record (#2) nor the **MX** record pointing to **mail.example.com** (#6) exist. 122 | 123 | --- 124 | 125 | To attempt to add the **AAAA** record with the script I use: 126 | 127 | `./cf-dns.sh -d example.com -t AAAA -n example.com -c 2001:db8:c010:46d6::1 -x y -l 1 -C "'AAAA' Record"` 128 | 129 | The script executes these steps: 130 | 131 | 1. It attempts to get the zone ID of the domain (`-d DOMAIN`). 132 | 2. If successful, it then tries to find a single DNS record for that zone that matches a combination of type (`-t TYPE`), name (`-n NAME`) and content (`-c CONTENT`). 133 | 3. If no matching DNS record is found, it further looks for *all* DNS records for the zone using only type (`-t TYPE`) and name (`-n NAME`). 134 | 4. If still no matches are found, a new DNS record is created. 135 | 136 | ---- 137 | 138 | To attempt to add the **MX** record with the script I use: 139 | 140 | `./cf-dns.sh -d example.com -t MX -n example.com -c mail.example.com -p 20 -x N -l 3600 -C "3rd 'MX' Record"` 141 | 142 | As with the previous example, the script executes steps 1 to 2. However, on executing step 3 it finds two existing records (#4 and #5) that match type (`-t TYPE`) and name (`-n NAME`) and so displays the following interactive prompt: 143 | 144 | ``` 145 | Found 2 existing DNS record(s) whose type is 'MX' named 'example.com' 146 | [1] ID:s5stc17nr83o0szd9rsn9cx56tybwuo0, TYPE:MX, NAME:example.com, CONTENT:aspmx.l.google.com 147 | [2] ID:a38q5zlhnhpycw05xld4gvlpb8ucelfd, TYPE:MX, NAME:example.com, CONTENT:alt2.aspmx.l.google.com 148 | [A] Add New DNS Record 149 | [Q] Quit 150 | 151 | Type '1' or '2' to update an existing record, 'A' to add a new record or 'Q' to quit without changes and then press enter: 152 | ``` 153 | As I want to add a new DNS record, I type `A` and press enter and a new DNS record is created. 154 | 155 | --- 156 | 157 | Having created this new **MX** record, I decide to change the *Priority* from **20** to **15** using: 158 | 159 | `./cf-dns.sh -d example.com -t MX -n example.com -c mail.example.com -p 15` 160 | 161 | On this occasion, when executing step 2, the script finds the single existing record that matches type (`-t TYPE`), name (`-n NAME`) and content (`-c CONTENT`) and simply changes its priority to `15`. 162 | 163 | --- 164 | 165 | Later I realise I've made a mistake. This new **MX** record should point to **mail.example.net** and not **mail.example.com**. To change its *Content* I use: 166 | 167 | `./cf-dns.sh -d example.com -t MX -n example.com -c mail.example.net` 168 | 169 | On executing step 3 the script now finds three existing records (#4, #5 and #6) that match type (`-t TYPE`) and name (`-n NAME`) and so displays the following interactive prompt: 170 | 171 | ``` 172 | Found 3 existing DNS record(s) whose type is 'MX' named 'example.com' 173 | [1] ID:s5stc17nr83o0szd9rsn9cx56tybwuo0, TYPE:MX, NAME:example.com, CONTENT:aspmx.l.google.com 174 | [2] ID:a38q5zlhnhpycw05xld4gvlpb8ucelfd, TYPE:MX, NAME:example.com, CONTENT:alt2.aspmx.l.google.com 175 | [3] ID:ge8m5b52vjm4uv22kbk7ba506obuknnn, TYPE:MX, NAME:example.com, CONTENT:mail.example.com 176 | [A] Add New DNS Record 177 | [Q] Quit 178 | 179 | Type '1', '2' or '3' to update an existing record, 'A' to add a new record or 'Q' to quit without changes and then press enter: 180 | ``` 181 | 182 | Rather than add a new record, I want to update an existing record and so type `3` and press enter to update the appropriate record. 183 | 184 | --- 185 | 186 | When deleting a record, the script only ever performs steps 1 and 2. If it can't find a record matching type (`-t TYPE`), name (`-n NAME`) and content (`-c CONTENT`), it exits. 187 | 188 | To delete the **MX** record now pointing to **mail.example.net** use: 189 | 190 | `./cf-dns.sh -d example.com -t MX -n example.com -c mail.example.net -Z` 191 | 192 | ## Examples 193 | 194 | #### Add New DNS Records 195 | 196 | `./cf-dns.sh -d example.com -t A -n example.com -c 203.0.113.50 -x N -l 1 -C "'A' Record"` 197 | 198 | | Type | Comment | Name | Content | Priority | Proxy status | TTL | 199 | | ---- | ---------- | ----------- | ------------ | -------- | ------------ | ---- | 200 | | A | 'A' Record | example.com | 203.0.113.50 | N/A | DNS Only | Auto | 201 | 202 |
203 | 204 | :point_right: As Cloudflare defaults **Proxy status** to _DNS Only_ (`false`) and **TTL** to _Auto_ (`1`), the following are functionally equivalent: 205 | 206 | `./cf-dns.sh -d example.com -t A -n example.com -c 203.0.113.50 -x N -C "'A' Record"` 207 | 208 | `./cf-dns.sh -d example.com -t A -n example.com -c 203.0.113.50 -l 1 -C "'A' Record"` 209 | 210 | `./cf-dns.sh -d example.com -t A -n example.com -c 203.0.113.50 -C "'A' Record"` 211 | 212 | --- 213 | 214 | `./cf-dns.sh -d example.com -t AAAA -n example.com -c 2001:db8:c010:46d6::1 -x y -l 1 -C "'AAAA' Record"` 215 | 216 | | Type | Comment | Name | Content | Priority | Proxy status | TTL | 217 | |------|------------- |-------------|-----------------------|----------|----------------|------| 218 | | AAAA |'AAAA' Record | example.com | 2001:db8:c010:46d6::1 | N/A | Proxied | Auto | 219 | 220 | --- 221 | 222 | `./cf-dns.sh -d example.com -t CNAME -n www -c example.com -x Y -l 120 -C "'CNAME' Record"` 223 | 224 | | Type | Comment | Name | Content | Priority | Proxy status | TTL | 225 | | ----- | -------------- | ---- | ----------- | -------- | ------------ | ---- | 226 | | CNAME | 'CNAME' Record | www | example.com | N/A | Proxied | Auto | 227 | 228 |
229 | 230 | :point\_right: Despite attempting to set the **TTL** to _2 min_ (`120`), the script forces a **TTL** of _Auto_ (`1`) as Cloudflare only allows records with a **Proxy status** of _DNS Only_ (`false`) to have a **TTL** other than _Auto_ (`1`).  231 | 232 | --- 233 | 234 | `./cf-dns.sh -d example.com -t MX -n example.com -c alt2.aspmx.l.google.com -p 10 -l 1 -C "1st 'MX' Record"` 235 | 236 | | Type | Comment | Name | Content | Priority | Proxy status | TTL | 237 | | ---- | ----------------- | ----------- | ----------------------- | -------- | ------------ | ---- | 238 | | MX | 1st 'MX' Record | example.com | alt2.aspmx.l.google.com | 10 | DNS Only | Auto | 239 | 240 |
241 | 242 | :point_right: As the script defaults **Priority** to _10_ and Cloudflare defaults **TTL** to _Auto_ (`1`), the following are functionally equivalent: 243 | 244 | `./cf-dns.sh -d example.com -t MX -n example.com -c alt2.aspmx.l.google.com -p 10 -C "1st 'MX' Record"` 245 | 246 | `./cf-dns.sh -d example.com -t MX -n example.com -c alt2.aspmx.l.google.com -l 1 -C "1st 'MX' Record"` 247 | 248 | `./cf-dns.sh -d example.com -t MX -n example.com -c alt2.aspmx.l.google.com -C "1st 'MX' Record"` 249 | 250 | --- 251 | 252 | `./cf-dns.sh -d example.com -t MX -n example.com -c aspmx.l.google.com -C "2nd 'MX' Record"` 253 | 254 | | Type | Comment | Name | Content | Priority | Proxy status | TTL | 255 | | ---- | ----------------- | ----------- | ------------------ | -------- | ------------ | ---- | 256 | | MX | 1st 'MX' Record | example.com | aspmx.l.google.com | 10 | DNS Only | Auto | 257 | 258 | --- 259 | 260 | `./cf-dns.sh -d example.com -t TXT -n dkim._domainkey -c 'v=DKIM1; p=MFswDQYJKoZIhvcNAQEBBQADSgAwRwJAXemJxxGR7kgbyS2FK8FOtCxAgPHW9mA7SCcHK77dWM2wBTZyKRxd7eJARaaWHS1B4CxDdWh02Eqy7mygwUwZSwIDAQAB' -l 1 -C "'DKIM' Record"` 261 | 262 | | Type | Comment | Name | Content | Priority | Proxy status | TTL | 263 | | ---- | ------------- | --------------- | ----------------------------- | -------- | ------------ | ---- | 264 | | TXT | 'DKIM' Record | dkim._domainkey | v=DKIM1; p=MFswDQYJKoZIhvc... | N/A | DNS only | Auto | 265 | 266 | --- 267 | 268 | `./cf-dns.sh -d example.com -t TXT -n _dmarc -c 'v=DMARC1; p=none; pct=100; rua=mailto:postmaster@mail.example.net; sp=none; aspf=r;' -l 1 -C "'DMARC' Record" -k` 269 | 270 | | Type | Comment | Name | Content | Priority | Proxy status | TTL | 271 | | ---- | -------------- | ------ | ------------------------------- | -------- | ------------ | ---- | 272 | | TXT | 'DMARC' Record | _dmarc | v=DMARC1; p=none; pct=100; r... | N/A | DNS only | Auto | 273 | 274 | :point\_right: This example uses the legacy API key (`-k`) to authenticate. 275 | 276 | --- 277 | 278 | `./cf-dns.sh -d example.com -t TXT -n example.com -c "v=spf1 mx ~all" -x Y -l 1 -C "'SPF' Record"` 279 | 280 | | Type | Comment | Name | Content | Priority | Proxy status | TTL | 281 | | ---- | ------------ | ----------- | -------------- | -------- | ------------ | ---- | 282 | | TXT | 'SPF' Record | example.com | v=spf1 mx ~all | N/A | DNS Only | Auto | 283 | 284 |
285 | 286 | :point_right: Despite attempting to set the **Proxy status** to _Proxied_ (`true`), the script does not include this in the payload as MX and TXT records are not proxiable on Cloudflare which forces **Proxy status** to _DNS Only_ (`false`) for these record types. 287 | 288 | --- 289 | 290 | 291 | #### Update Existing DNS Records 292 | 293 | `./cf-dns.sh -d example.com -t A -n example.com -c 198.51.100.54` 294 | 295 | | Type | Comment | Name | Content | Priority | Proxy status | TTL | 296 | | ---- | ---------- | ----------- | ------------------- | -------- | ------------ | ---- | 297 | | A | 'A' Record | example.com | _**198.51.100.54**_ | N/A | DNS Only | Auto | 298 | 299 | --- 300 | 301 | `./cf-dns.sh -d example.com -t AAAA -n example.com -c 2001:db8:c010:46d6::1 -x n` 302 | 303 | | Type | Comment | Name | Content | Priority | Proxy status | TTL | 304 | | ---- | ------------- | ----------- | --------------------- | -------- | -------------- | ---- | 305 | | AAAA | 'AAAA' Record | example.com | 2001:db8:c010:46d6::1 | N/A | _**DNS only**_ | Auto | 306 | 307 | --- 308 | 309 | `./cf-dns.sh -d example.com -t CNAME -n www -c example.com -C "Cloudflare truncates comments longer than 100 characters, and doesn't support record tags on its free plan."` 310 | 311 | | Type | Comment | Name | Content | Priority | Proxy status | TTL | 312 | | ----- | ---------------------------------------------------------------------------------------------------------- | ---- | ----------- | -------- | ------------ | ---- | 313 | | CNAME | _**Cloudflare truncates comments longer than 100 characters, and doesn't support record tags on its ...**_ | www | example.com | N/A | Proxied | Auto | 314 | 315 |
316 | 317 | :point_right: As Cloudflare only allows comments upto a maximum of 100 characters, the script truncates comments longer than 97 characters and appends `...`. 318 | 319 | --- 320 | 321 | `./cf-dns.sh -d example.com -t MX -n example.com -c mail.example.com -p 15 -l 3600` 322 | 323 | | Type | Comment | Name | Content | Priority | Proxy status | TTL | 324 | | ---- | ----------------- | ----------- | ---------------- | -------- | ------------ | ---------- | 325 | | MX | First 'MX' Record | example.com | mail.example.com | _**15**_ | DNS Only | _**1 hr**_ | 326 | 327 | --- 328 | 329 | `./cf-dns.sh -d veward.com -t TXT -n dkim._domainkey -c 'v=DKIM1; p=MFswDQYJKoZIhvcNAQEBBQADSgAwRwJAYWXi4K8r0xVWXeY5b7nXrdO24E1Yd7bv /mNIGcR0FlHdf2Ng3gO1fzAq/x/ae2PIhG1TEj2+mh1BVK1u2oc7/wIDAQAB' -l 1` 330 | 331 | | Type | Name | Content | Priority | Proxy status | TTL | 332 | | ---- | -------------- | ----------------------------------- | -------- | ------------ | ---- | 333 | | TXT | dkim._domainkey | v=DKIM1; p=_**MFswDQYJKoZIhvc...**_ | N/A | DNS only | Auto | 334 | 335 | --- 336 | 337 | `./cf-dns.sh -d example.com -t TXT -n _dmarc -c 'v=DMARC1; p=quarantine; pct=75; rua=mailto:postmaster@mail.example.net; sp=reject; aspf=r;' -l 1` 338 | 339 | | Type | Name | Content | Priority | Proxy status | TTL | 340 | | ---- | ------ | ------------------------------------------------ | -------- | ------------ | ---- | 341 | | TXT | _dmarc | v=DMARC1; p=_**quarantine**_; pct=_**75**_; r... | N/A | DNS only | Auto | 342 | 343 | --- 344 | 345 | #### Delete Existing DNS Records 346 | 347 | `./cf-dns.sh -d example.com -t A -n example.com -c 203.0.113.50 -Z -a` 348 | 349 | | Type | Name | Content | Priority | Proxy status | TTL | 350 | | ---- | ----------- | ------------ | -------- | ------------ | ---- | 351 | | A | example.com | 203.0.113.50 | N/A | Proxied | Auto | 352 | 353 | :point_right: This example uses the `-a` option which suppresses the prompt asking to confirm deletion. 354 | 355 | A DNS record to be deleted is only matched using the combined values of type (`-t TYPE`), name (`-n NAME`) and content (`-c CONTENT`). If no match is found, a second attempt using only type (`-t TYPE`), and name (`-n NAME`) is not made as it is when adding or updating a DNS record. 356 | 357 | --- 358 | 359 |
360 | 361 | ## Limitations 362 | 363 | - Requires an existing zone record for the domain being updated. 364 | 365 | - Only **A**, **AAAA**, **CNAME**, **MX** and **TXT** type DNS records can be added, updated or deleted. 366 | 367 | - Does not support record tags as these are not available on Cloudflare's Free plan. 368 | 369 | - The script is unable to match a record using a combination of type (`-t TYPE`), name (`-n NAME`) and content (`-c CONTENT`) if the content contains a comma (`,`). While this appears to be an issue with the Cloudflare API, the script cannot currently delete DNS records whose content contains a comma (`,`) and may require user interaction to update such records. 370 | 371 | ## Get an Existing Domain's Current DNS Records 372 | 373 | `get-dns.py` is a script that gets all of an existing domain's current DNS records. For each DNS record it displays a sub-Set of the data returned from the API call together with the arguments required to delete that record and create it using the `cf-dns.sh` script. It can optionally include all of the record's data as raw JSON. Output can be directed to a file or to the user's screen. 374 | 375 | ### Usage 376 | `./get-dns.py -h/--help` 377 | 378 | `./get-dns.py -d/--domain DOMAIN [-k/--key] [-p/--pretty] [-r/--raw] [-s/--screen]` 379 | 380 | ### Example 381 | 382 | `./get-dns.py --domain=example.com --raw --screen` 383 | 384 | ### Sample Output 385 | 386 | ``` 387 | ... 388 | 389 | Record: 4/7 390 | 391 | { 392 | "comment": "2nd 'MX' Record", 393 | "content": "aspmx.l.google.com", 394 | "created_on": "2020-09-17T11:18:19.583054Z", 395 | "id": "7bdb2e46037df332e5abdd45f8f981f5", 396 | "meta": {}, 397 | "modified_on": "2020-09-17T11:18:19.583054Z", 398 | "name": "example.com", 399 | "priority": 5, 400 | "proxiable": false, 401 | "proxied": false, 402 | "settings": {}, 403 | "tags": [], 404 | "ttl": 3600, 405 | "type": "MX" 406 | } 407 | 408 | Domain: example.com 409 | Type: MX 410 | Name: example.com 411 | Content: aspmx.l.google.com 412 | Priority: 5 413 | Proxiable: False 414 | Proxied: False 415 | TTL: 3600 416 | Comment: 2nd 'MX' Record 417 | Modified: 2020-09-17T11:18:19.583054Z 418 | ./cf-dns.sh -d example.com -t MX -n example.com -c aspmx.l.google.com -p 5 -l 3600 -C "2nd 'MX' Record" [-k] [-S] [-A] 419 | ./cf-dns.sh -d example.com -t MX -n example.com -c aspmx.l.google.com -Z [-a] [-k] [-S] 420 | 421 | * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * 422 | 423 | Record: 5/7 424 | 425 | ... 426 | 427 | ``` -------------------------------------------------------------------------------- /auth.json.template: -------------------------------------------------------------------------------- 1 | { 2 | "cloudflare": { 3 | "email": "your-cloudflare-email", 4 | "key": "your-cloudflare-api-key", 5 | "token": "your-cloudflare-api-token" 6 | } 7 | } -------------------------------------------------------------------------------- /cf-dns.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # AUTHOR: Steve Ward [steve at tech-otaku dot com] 4 | # URL: https://github.com/tech-otaku/cloudflare-dns.git 5 | # README: https://github.com/tech-otaku/cloudflare-dns/blob/main/README.md 6 | 7 | # USAGE: ./cf-dns.sh -d DOMAIN -n NAME -t TYPE -c CONTENT -p PRIORITY -l TTL -x PROXIED -C COMMENT [-k] [-o] 8 | # EXAMPLE: ./cf-dns.sh -d example.com -t A -n example.com -c 203.0.113.50 -l 1 -x y -C 'A comment' 9 | # See the README for more examples 10 | 11 | 12 | 13 | # # # # # # # # # # # # # # # # # # # # 14 | # START-UP CHECKS 15 | # 16 | 17 | # Exit with error if Python 3 is not installed 18 | if [ ! $(command -v python3) ]; then 19 | printf "\nERROR: * * * This script requires Python 3. * * *\n" 20 | exit 1 21 | fi 22 | 23 | # Exit with error if the Cloudflare credentials file doesn't exist 24 | if [ ! -f ./auth.json ]; then 25 | printf "\nERROR: * * * The file containing your Cloudflare credentials '%s' doesn't exist. * * *\n" $(pwd)/auth.json 26 | exit 1 27 | fi 28 | 29 | # Unset all variables 30 | unset ANSWER APIKEY AUTO COMMENT CONTENT DELETE DNS_ID DOMAIN HEADER_EMAIL HEADER_KEY HEADER_TOKEN EMAIL KEY MODE NAME PAYLOAD OVERRIDE PRIORITY PROXIED RECORD REQUEST_HEADER REQUEST_URL RESPONSE TMPFILE TOKEN TTL TYPE ZONE_ID 31 | 32 | 33 | 34 | # # # # # # # # # # # # # # # # # # # # 35 | # CONSTANTS 36 | # 37 | 38 | EMAIL=$(cat ./auth.json | python3 -c "import sys, json; print(json.load(sys.stdin)['cloudflare']['email'])") 39 | KEY=$(cat ./auth.json | python3 -c "import sys, json; print(json.load(sys.stdin)['cloudflare']['key'])") 40 | TOKEN=$(cat ./auth.json | python3 -c "import sys, json; print(json.load(sys.stdin)['cloudflare']['token'])") 41 | 42 | 43 | 44 | # # # # # # # # # # # # # # # # # # # # 45 | # DEFAULTS 46 | # 47 | 48 | HEADER_TOKEN="Bearer $TOKEN" 49 | HEADER_EMAIL="" # When using a Cloudlare API token to authenticate, this legacy API key credential is included in the request as an empty X-Auth-Email header (--header 'X-Auth-Email: '), but ultimately ignored by curl 50 | HEADER_KEY="" # When using a Cloudlare API token to authenticate, this legacy API key credential is included in the request as an empty X-Auth-Key header (--header 'X-Auth-Key: '), but ultimately ignored by curl 51 | MODE="--silent" 52 | #PRIORITY="5" 53 | #PROXIED="true" 54 | #TTL="1" 55 | 56 | 57 | 58 | # # # # # # # # # # # # # # # # # # # # 59 | # FUNCTION DECLARATIONS 60 | # 61 | 62 | # Function to execute when the script terminates 63 | function tidy_up { 64 | rm -f $TMPFILE 65 | } 66 | 67 | # Ensure the `tidy_up` function is executed every time the script terminates regardless of exit status 68 | trap tidy_up EXIT 69 | 70 | # Function to display usage help 71 | function usage { 72 | cat << EOF 73 | 74 | Syntax: 75 | ./$(basename $0) -h 76 | ./$(basename $0) -d DOMAIN -n NAME -t TYPE -c CONTENT -p PRIORITY -l TTL -x PROXIED -C COMMENT [-k] [-s] [-o] [-A] 77 | ./$(basename $0) -d DOMAIN -n NAME -t TYPE -c CONTENT -Z [-a] [-k] [-s] [-o] 78 | 79 | Options: 80 | -a Auto mode. Do not prompt for user interaction. 81 | -A Force add new DNS record. Prompts to overwrite existing DNS record(s) if omitted. 82 | -c CONTENT DNS record content. REQUIRED. 83 | -C COMMENT Comment about the DNS Record. 84 | -d DOMAIN The domain name. REQUIRED. 85 | -h This help message. 86 | -k Use legacy API key for authentication. API token is used if omitted. 87 | -l TTL Time to live for DNS record. 88 | -n NAME DNS record name. REQUIRED. 89 | -o Override use of NAME.DOMAIN to reference applicable DNS record. 90 | -p PRIORITY The priority value for an MX type DNS record. Must be an integer >= 0. 91 | -S Show curl's progress meter and error messages. Curl is silent if omitted. 92 | -t TYPE DNS record type. Must be one of A, AAAA, CNAME, MX or TXT. REQUIRED. 93 | -x PROXIED Should the DNS record be proxied? Must be one of y, Y, n or N. 94 | -Z DELETE Delete a given DNS record. 95 | 96 | Example: ./$(basename $0) -d example.com -t A -n example.com -c 203.0.113.50 -l 1 -x y -C 'A comment' 97 | Example: ./$(basename $0) -d example.com -t A -n example.com -c 203.0.113.50 -Z -a 98 | 99 | See https://github.com/tech-otaku/cloudflare-dns/blob/main/README.md for more examples. 100 | 101 | EOF 102 | } 103 | 104 | 105 | 106 | # # # # # # # # # # # # # # # # # # # # 107 | # COMMAND-LINE OPTIONS 108 | # 109 | 110 | # Exit with error if no command line options given 111 | if [[ ! $@ =~ ^\-.+ ]]; then 112 | printf "\nERROR: * * * No options given. * * *\n" 113 | usage 114 | exit 1 115 | fi 116 | 117 | # Prevent an option that expects an argument from taking the next option as an argument if its own argument is omitted. i.e. -d -n www 118 | while getopts ':aAc:C:d:hkl:n:op:St:x:Z' opt; do 119 | if [[ $OPTARG =~ ^\-.? ]]; then 120 | printf "\nERROR: * * * '%s' is not valid argument for option '-%s'\n" $OPTARG $opt 121 | usage 122 | exit 1 123 | fi 124 | done 125 | 126 | # Reset OPTIND so getopts can be called a second time 127 | OPTIND=1 128 | 129 | # Process command line options 130 | while getopts ':aAc:C:d:hkl:n:op:St:x:Z' opt; do 131 | case $opt in 132 | a) 133 | # This variable is only ever tested to confirm if it's set (non-zero length string) or not (zero length string). Its actual value is of no significance. 134 | AUTO=true 135 | ;; 136 | A) 137 | # This variable is only ever tested to confirm if it's set (non-zero length string) or not (zero length string). Its actual value is of no significance. 138 | ADD=true 139 | ;; 140 | c) 141 | CONTENT=$OPTARG 142 | ;; 143 | C) 144 | if [[ -z $OPTARG ]]; then 145 | COMMENT=" " 146 | else 147 | COMMENT=$OPTARG 148 | fi 149 | ;; 150 | d) 151 | DOMAIN=$OPTARG 152 | ;; 153 | h) 154 | usage 155 | exit 0 156 | ;; 157 | k) 158 | # This variable is only ever tested to confirm if it's set (non-zero length string) or not (zero length string). Its actual value is of no significance. 159 | APIKEY=true 160 | ;; 161 | l) 162 | TTL=$OPTARG 163 | ;; 164 | n) 165 | NAME=$OPTARG 166 | ;; 167 | o) 168 | # This variable is only ever tested to confirm if it's set (non-zero length string) or not (zero length string). Its actual value is of no significance. 169 | OVERRIDE=true 170 | ;; 171 | p) 172 | PRIORITY=$OPTARG 173 | ;; 174 | S) 175 | MODE="--no-silent" 176 | ;; 177 | t) 178 | TYPE=$(echo $OPTARG | tr '[:lower:]' '[:upper:]') 179 | ;; 180 | x) 181 | PROXIED=$OPTARG 182 | ;; 183 | Z) 184 | # This variable is only ever tested to confirm if it's set (non-zero length string) or not (zero length string). Its actual value is of no significance. 185 | DELETE=true 186 | ;; 187 | :) 188 | printf "\nERROR: * * * Argument missing from '-%s' option * * *\n" $OPTARG 189 | usage 190 | exit 1 191 | ;; 192 | ?) 193 | printf "\nERROR: * * * Invalid option: '-%s' * * *\n" $OPTARG 194 | usage 195 | exit 1 196 | ;; 197 | esac 198 | done 199 | 200 | 201 | 202 | # # # # # # # # # # # # # # # # # # # # 203 | # USAGE CHECKS 204 | # 205 | 206 | # Domain (-d DOMAIN), Type (-t TYPE), Name (-n NAME) and Content (-c CONTENT) are required for all DNS record types, 207 | # whether creating a new DNS record (POST request), updating an existing DNS record (PATCH request) or deleting an existing DNS record (DELETE request) 208 | 209 | # Domain (-d DOMAIN) is missing 210 | if [ -z "$DOMAIN" ] || [[ "$DOMAIN" == -* ]]; then 211 | printf "\nERROR: * * * No domain was specified. * * *\n" 212 | usage 213 | exit 1 214 | fi 215 | 216 | # Type (-t TYPE) is missing or not handled by this script 217 | if [ -z "$TYPE" ] || [[ ! $TYPE =~ ^(A|AAAA|CNAME|MX|TXT)$ ]]; then 218 | printf "\nERROR: * * * DNS record type missing or invalid. * * *\n" 219 | usage 220 | exit 1 221 | fi 222 | 223 | # Name (-n NAME) is missing 224 | if [ -z "$NAME" ] || [[ "$NAME" == -* ]]; then 225 | printf "\nERROR: * * * No DNS record name was specified. * * *\n" 226 | usage 227 | exit 1 228 | fi 229 | 230 | # Content (-c CONTENT) is missing 231 | if [ -z "$CONTENT" ] || [[ "$CONTENT" == -* ]]; then 232 | printf "\nERROR: * * * No DNS record content was specified. * * *\n" 233 | usage 234 | exit 1 235 | fi 236 | 237 | # Depending on the record type and the action being undertaken, certain data passed to the script maybe unnecessary and should not be included in the payload. 238 | # By explicitly unsetting the appropriate variables this data can be excluded from the payload. 239 | 240 | # Priority (-p PRIORITY) is only required for MX rcords and Proxy status (-x PROXIED) is only required for A, AAAA and CNAME records when these record types are being created 241 | 242 | if [ -z "$DELETE" ]; then # Record is being created or updated 243 | 244 | # Priority (-p PRIORITY) is not necessary 245 | if [ $TYPE != "MX" ]; then 246 | if [ ! -z "$PRIORITY" ]; then 247 | # Exclude from payload 248 | unset PRIORITY 249 | fi 250 | fi 251 | 252 | # Proxy status (-x PROXIED) is not necessary 253 | if [[ ! $TYPE =~ ^(A|AAAA|CNAME)$ ]]; then 254 | if [ ! -z "$PROXIED" ]; then 255 | # Exclude from payload 256 | unset PROXIED # If omitted, the Cloudflare API will default Proxy Status (-x PROXIED) to false for new MX or TXT records as these record types are not proxiable. Alternatively, use PROXIED="false". 257 | # PROXIED="false" # The Cloudflare API will accept a Proxy Status (-x PROXIED) of false for new MX or TXT records. Note: a Proxy Status (-x PROXIED) of true returns "code 9004, This record type cannot be proxied." 258 | fi 259 | fi 260 | 261 | fi 262 | 263 | # Comment (-C COMMENT), Priority (-p PRIORITY), Proxied (-x PROXIED) and TTL (-l TTL) aren't necessary if an existing DNS record is being deleted (DELETE request) 264 | 265 | if [ ! -z "$DELETE" ]; then # Record is being deleted 266 | 267 | # Comment (-C COMMENT) is not neccesary 268 | if [ ! -z "$COMMENT" ]; then 269 | # Exclude from payload 270 | unset COMMENT 271 | fi 272 | 273 | # Priority (-p PRIORITY) is not neccesary 274 | if [ ! -z "$PRIORITY" ]; then 275 | # Exclude from payload 276 | unset PRIORITY 277 | fi 278 | 279 | # Proxy status (-x PROXIED) is not neccesary 280 | if [ ! -z "$PROXIED" ]; then 281 | # Exclude from payload 282 | unset PROXIED 283 | fi 284 | 285 | # TTL (-l TTL) is not neccesary 286 | if [ ! -z "$TTL" ]; then 287 | # Exclude from payload 288 | unset TTL 289 | fi 290 | 291 | fi 292 | 293 | # Priority (-p PRIORITY), Proxied (-x PROXIED) and TTL (-l TTL) need to be validated when creating a new DNS record (POST request) or updating an existing DNS record (PATCH request) 294 | 295 | # Priority (-p PRIORITY). If given, must be an integer between 0 and 65535 296 | # Priority (-p PRIORITY) is required when creating a new MX record, but not when updating an existing one. However, at this point it's not known if this 297 | # is a new or existing MX record. Consequently we can't force an error on a missing Priority (-p PRIORITY), but only check the validity of one if it's given. 298 | # Later, if the script determines this is a new MX record without a Priority (-p PRIORITY) it will add a default value of 10 to the payload. 299 | if [ ! -z $PRIORITY ]; then 300 | if [ $TYPE == "MX" ]; then 301 | if [[ ! $PRIORITY =~ ^[0-9]*$ ]] || [ $PRIORITY -lt 0 ] || [ $PRIORITY -gt 65535 ] ; then 302 | printf "\nERROR: * * * Invalid priority value (%s). Must be an integer between 0 and 65535. * * *\n" $PRIORITY 303 | usage 304 | exit 1 305 | fi 306 | fi 307 | fi 308 | 309 | 310 | # Proxy status (-x PROXIED). If omitted for A, AAAA or CNAME records, the Cloudflare API defaults this to 'false' i.e DNS Only. 311 | if [ ! -z $PROXIED ]; then 312 | if [[ $TYPE =~ ^(A|AAAA|CNAME)$ ]]; then 313 | if [[ ! $PROXIED =~ ^([yY]|[nN]){1}$ ]]; then 314 | printf "\nERROR: * * * Invalid proxied status (%s). Must be one of y, Y, n, or N. * * *\n" $PROXIED 315 | usage 316 | exit 1 317 | else 318 | PROXIED=$( [[ $PROXIED =~ ^(y|Y)$ ]] && echo "true" || echo "false" ) 319 | fi 320 | fi 321 | fi 322 | 323 | 324 | # TTL (-l TTL). The Cloudflare API allows integer values between 60 and 86400 seconds, or 1 for Auto, but on the Cloudflare dashboard only the following values can be entered: 325 | # Auto (1), 1 min (60), 2 min (120), 5 min (300), 10 min (600), 15 min (900), 30 min (1800), 1 hr (3600), 2 hr (7200), 5 hr (18000), 12 hr (43200), 1 day (86400) 326 | if [ ! -z $TTL ]; then # The TTL (-t TTL) need only be validated if a value has been passed to the script. It is not required and is defaulted to Auto (1) by the Cloudflare API if omitted. 327 | if [[ ! $TTL =~ ^(1|60|120|300|600|900|1800|3600|7200|18000|43200|86400)$ ]]; then 328 | printf "\nERROR: * * * Invalid TTL value (%s). Must be one of 1, 60, 120, 300, 600, 900, 1800, 3600, 7200, 18000, 43200 or 86400 * * *\n" $TTL 329 | usage 330 | exit 1 331 | fi 332 | fi 333 | 334 | 335 | 336 | # # # # # # # # # # # # # # # # # # # # 337 | # OVERRIDES 338 | # 339 | 340 | # Use legacy API key to authenticate instead of API token 341 | if [ ! -z "$APIKEY" ]; then 342 | HEADER_TOKEN="" # When using a Cloudlare legacy API key to authenticate, the API token is included in the request as as an empty Authorization header (--header 'Authorization: '), but ultimately ignored by curl 343 | HEADER_EMAIL="$EMAIL" 344 | HEADER_KEY="$KEY" 345 | fi 346 | 347 | # Append domain name to supplied DNS record name. Ensures that all DNS records are managed using their correct naming convention: 'www.example.com' as opposed to 'www' 348 | # if [ -z "$OVERRIDE" ]; then # Only if '-o' otion given 349 | if [[ ! ("$NAME" == *"$DOMAIN"*) ]]; then 350 | NAME=$NAME.$DOMAIN 351 | fi 352 | # fi 353 | 354 | # Override TTL (-l TTL). A value other than Auto (1) can only be set if the DNS record's Proxy status (-x PROXIED) is DNS only (false) 355 | if [ ! -z $TTL ] && [ ! -z $PROXIED ]; then 356 | if [ $TTL != "1" ] && [ $PROXIED == "true" ]; then 357 | TTL=1 358 | fi 359 | fi 360 | 361 | # Comment 362 | if [[ ! -z $COMMENT ]]; then 363 | # Replace any double quotes with single quotes. 364 | COMMENT=${COMMENT//\"/\'} 365 | # Truncate comment if it exceeds the maximum number of characters (100) allowed by Cloudflare 366 | if [ ${#COMMENT} -gt 100 ]; then 367 | COMMENT=$(echo $COMMENT | cut -c -97)... 368 | fi 369 | fi 370 | 371 | 372 | # # # # # # # # # # # # # # # # # # # # 373 | # ADD | UPDATE | DELETE DNS RECORDS 374 | # 375 | 376 | # Get the domain's zone ID 377 | printf "\nAttempting to get zone ID for domain '%s'\n" $DOMAIN 378 | 379 | ZONE_ID=$( 380 | curl $MODE -X GET "https://api.cloudflare.com/client/v4/zones?name=$DOMAIN" \ 381 | --header "Authorization: $HEADER_TOKEN" \ 382 | --header "X-Auth-Email: $HEADER_EMAIL" \ 383 | --header "X-Auth-Key: $HEADER_KEY" \ 384 | --header "Content-Type: application/json" \ 385 | | python3 -c "import sys,json;data=json.loads(sys.stdin.read()); print(data['result'][0]['id'] if data['result'] else '')" 386 | ) 387 | 388 | if [ -z "$ZONE_ID" ]; then 389 | printf "\nABORTING: * * * The domain '%s' doesn't exist on Cloudflare * * *\n" "$DOMAIN" 390 | exit 1 391 | else 392 | printf ">>> %s\n" "$ZONE_ID" 393 | fi 394 | 395 | # Get the DNS record's ID based on type, name and content 396 | printf "\nAttempting to get ID for DNS '%s' record named '%s' whose content is '%s'\n" "$TYPE" "$NAME" "$CONTENT" 397 | 398 | DNS_ID=$( 399 | curl $MODE -G "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \ 400 | --data-urlencode "type=$TYPE" \ 401 | --data-urlencode "name=$NAME" \ 402 | --data-urlencode "content=$CONTENT" \ 403 | --header "Authorization: $HEADER_TOKEN" \ 404 | --header "X-Auth-Email: $HEADER_EMAIL" \ 405 | --header "X-Auth-Key: $HEADER_KEY" \ 406 | | python3 -c "import sys,json;data=json.loads(sys.stdin.read()); print(data['result'][0]['id'] if data['result'] else '')" 407 | ) 408 | 409 | if [ -z "$DNS_ID" ]; then 410 | printf ">>> %s\n" "No record found (1)" 411 | else 412 | printf ">>> %s\n" "$DNS_ID" 413 | fi 414 | 415 | # Add a new DNS record or update an existing one. 416 | if [ -z "$DELETE" ]; then 417 | 418 | # If no DNS record was found matching type, name and content look for all DNS records matching only type and name 419 | if [ -z "$DNS_ID" ]; then 420 | 421 | TMPFILE=$(mktemp) 422 | 423 | printf "\nAttempting to get all DNS records whose type is '%s' named '%s'\n" "$TYPE" "$NAME" 424 | curl $MODE -G "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \ 425 | --data-urlencode "type=$TYPE" \ 426 | --data-urlencode "name=$NAME" \ 427 | --header "Authorization: $HEADER_TOKEN" \ 428 | --header "X-Auth-Email: $HEADER_EMAIL" \ 429 | --header "X-Auth-Key: $HEADER_KEY" \ 430 | | python3 -c $'import sys,json\ndata=json.loads(sys.stdin.read())\nif data["success"]:\n\tfor dict in data["result"]:print(dict["id"] + "\t" + dict["type"] + "\t" + dict["name"] + "\t" + dict["content"])\nelse:print("ERROR(" + str(data["errors"][0]["code"]) + "): " + data["errors"][0]["message"])' > $TMPFILE 431 | 432 | if [ $(wc -l < $TMPFILE) -gt 0 ]; then 433 | printf "\nFound %s existing DNS record(s) whose type is '%s' named '%s'\n" $(wc -l < $TMPFILE) "$TYPE" "$NAME" 434 | i=0 435 | while read record; do 436 | i=$((i+1)) 437 | printf '[%s] ID:%s, TYPE:%s, NAME:%s, CONTENT:%s\n' $i "$(printf '%s' "$record" | cut -d$'\t' -f1)" "$(printf '%s' "$record" | cut -d$'\t' -f2)" "$(printf '%s' "$record" | cut -d$'\t' -f3)" "$(printf '%s' "$record" | cut -d$'\t' -f4)" 438 | done < $TMPFILE 439 | echo "[A] Add New DNS Record" 440 | echo -e "[Q] Quit\n" 441 | 442 | while true; do 443 | read -p "Type $(for((x=1;x<=$i;++x)); do printf "'%s', " $x; done | rev | cut -c3- | sed 's/ ,/ ro /' | rev) to update an existing record, 'A' to add a new record or 'Q' to quit without changes and then press enter: " ANSWER 444 | case $ANSWER in 445 | [1-$i]) 446 | DNS_ID=$(sed -n $ANSWER'p' < $TMPFILE | cut -d$'\t' -f1 | cut -d$'\t' -f2); 447 | break;; 448 | [aA]) 449 | unset DNS_ID; 450 | break;; 451 | [qQ]) 452 | exit 453 | ;; 454 | *) 455 | # echo "Please enter a valid option." 456 | ;; 457 | esac 458 | done 459 | 460 | else 461 | 462 | printf ">>> %s\n" "No record(s) found (2)" 463 | 464 | fi 465 | 466 | fi 467 | 468 | # Create the payload. 469 | # Must always include type, name and content. 470 | PAYLOAD="\"type\":\"$TYPE\",\"name\":\"$NAME\",\"content\":\"$CONTENT\"" 471 | # Can optionally include priority, proxied status, ttl and comment 472 | PAYLOAD+=$(if [ ! -z $PRIORITY ]; then echo ",\"priority\":$PRIORITY"; fi) 473 | PAYLOAD+=$(if [ ! -z $PROXIED ]; then echo ",\"proxied\":$PROXIED"; fi) 474 | PAYLOAD+=$(if [ ! -z $TTL ]; then echo ",\"ttl\":$TTL"; fi) 475 | PAYLOAD+=$(if [[ ! -z $COMMENT ]]; then echo ",\"comment\":\""$COMMENT"\""; fi) # Use [[ rather than [ as spaces in comment will throw an error 476 | 477 | if [ -z "$DNS_ID" ]; then 478 | # DNS record doesn't exist. Create a new one. 479 | if [ $TYPE == "MX" ] && [ -z $PRIORITY ]; then 480 | # This is a new MX record without a Priority (-p PRIORITY), so add a default value of 10 to the payload. 481 | PAYLOAD+=",\"priority\":10" 482 | fi 483 | REQUEST_TYPE="POST" 484 | REQUEST_URL="https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/" 485 | printf "\nAdding new DNS '%s' record named '%s'\n" $TYPE $NAME 486 | else 487 | # DNS record already exists. Update the existing record. 488 | REQUEST_TYPE="PATCH" 489 | REQUEST_URL="https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$DNS_ID" 490 | printf "\nUpdating existing DNS '%s' record named '%s'\n" $TYPE $NAME 491 | fi 492 | 493 | curl $MODE -X "$REQUEST_TYPE" "$REQUEST_URL" \ 494 | --header "Authorization: $HEADER_TOKEN" \ 495 | --header "X-Auth-Email: $HEADER_EMAIL" \ 496 | --header "X-Auth-Key: $HEADER_KEY" \ 497 | --header "Content-Type: application/json" \ 498 | --data '{'"$PAYLOAD"'}' \ 499 | | python3 -m json.tool --sort-keys 500 | 501 | # Delete an existing DNS record 502 | else 503 | 504 | RECORD=$(printf "DNS '%s' record named '%s' whose content is '%s'" "$TYPE" "$NAME" "$CONTENT") 505 | 506 | if [ -z "$DNS_ID" ]; then 507 | printf "\nWARNING: * * * No $RECORD exists * * *\n" 508 | else 509 | if [ -z $AUTO ]; then 510 | read -r -p "$(echo -e '\n'Delete the $RECORD [Y/n]?) " RESPONSE 511 | else 512 | RESPONSE=Y 513 | fi 514 | 515 | if [[ "$RESPONSE" =~ ^([yY][eE][sS]|[yY])$ ]]; then 516 | printf "\nDeleteing the $RECORD\n" 517 | curl $MODE -X DELETE "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$DNS_ID" \ 518 | --header "Authorization: $HEADER_TOKEN" \ 519 | --header "X-Auth-Email: $HEADER_EMAIL" \ 520 | --header "X-Auth-Key: $HEADER_KEY" \ 521 | --header "Content-Type: application/json" \ 522 | | python3 -m json.tool --sort-keys 523 | else 524 | printf "\nThe $RECORD has NOT been deleted.\n" 525 | fi 526 | fi 527 | fi 528 | -------------------------------------------------------------------------------- /get-dns.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse, datetime, os, json, sys 4 | from contextlib import redirect_stdout 5 | 6 | # Check Python version 7 | if not sys.version_info >= (3, 6): 8 | print('Python 3.6 or later is required to run this script.') 9 | sys.exit(1) 10 | 11 | try: 12 | import requests 13 | except ImportError as e: 14 | print('Please install the \'requests\' library using \'pip install requests\'') 15 | sys.exit(1) 16 | 17 | 18 | def get_args(): 19 | 20 | parser = argparse.ArgumentParser(description='Get all DNS records for a given domain (-d/--domain)', formatter_class=lambda prog: argparse.HelpFormatter(prog, max_help_position=50)) 21 | parser.add_argument('-d', '--domain', required=True, help='the domain to target') 22 | parser.add_argument('-k', '--key', action='store_true', help='use legacy API key to authenticate') 23 | parser.add_argument('-p', '--pretty', action='store_true', help='pretty-print raw JSON data in output (requires -r/--raw)') 24 | parser.add_argument('-r', '--raw', action='store_true', help='include raw JSON data in output') 25 | parser.add_argument('-s', '--screen', action='store_true', help='send output to screen') 26 | 27 | return parser.parse_args() 28 | 29 | def get_credentials(): 30 | 31 | # Attempt to open the file containing the user's Cloudflare credentials 32 | try: 33 | with open('auth.json', 'r', encoding='utf-8') as f: 34 | return json.load(f) 35 | 36 | except FileNotFoundError as e: 37 | sys.exit(e) 38 | 39 | 40 | def set_headers(key, credentials): 41 | 42 | # Define authentication method 43 | if key: 44 | # Legacy API key (-k/--key) 45 | headers = { 46 | 'X-Auth-Email': credentials['cloudflare']['email'], 47 | 'X-Auth-Key': credentials['cloudflare']['key'] 48 | } 49 | else: 50 | # API token 51 | headers = { 52 | 'Authorization': 'Bearer ' + credentials['cloudflare']['token'] 53 | } 54 | 55 | headers.update({'Content-Type': 'application/json'}) 56 | 57 | return headers 58 | 59 | 60 | def main(): 61 | 62 | args = get_args() 63 | 64 | credentials = get_credentials() 65 | 66 | headers = set_headers(args.key, credentials) 67 | 68 | # params={'name': args.domain} 69 | 70 | 71 | # Attempt to get the zone ID of the targeted domain 72 | try: 73 | response = requests.get('https://api.cloudflare.com/client/v4/zones', params={'name': args.domain}, headers=headers) 74 | response.raise_for_status() 75 | 76 | except requests.HTTPError as e: # == requests.exceptions.HTTPError as e: 77 | # An HTTP error is not raised if the domain doesn't exist on your Cloudflare account. Any HTTP errors are likely due to incorrect Cloudflare credentials: 78 | # Legacy API key: 79 | # 400 if email is invalid (does not contain '@' character or domain part contains non-alphanumeric characters other than '-' ) 80 | # 403 if email is incorrect (doesn't match) 81 | # 400 if legacy API key is invalid (less than 37 alphnumeric characters or contains upper-case letters, lower-case letters not in the range 'a' to 'f' or non-aplhnumeric characters) 82 | # 403 if legacy API key is incorrect (doesn't match) 83 | # API Token 84 | # 400 if API token is invalid (less than 40 alphanumeric characters or contains non-aplhnumeric characters) 85 | # 403 if API token is incorrect (doesn't match) 86 | 87 | # The response from the Cloudflare API is serialised JSON content. 88 | error_info = response.json() # == json.loads(response.text) 89 | 90 | if response.status_code == 400: 91 | # 400 (6003) Invalid request headers, (6102) Invalid format for X-Auth-Email header [Legacy API Key] 92 | # 400 (6003) Invalid request headers, (6103) Invalid format for X-Auth-Key header [Legacy API Key] 93 | # 400 (6003) Invalid request headers, (6111) Invalid format for Authorization header [API Token] 94 | print(f"* * * ERROR: ({str(error_info['errors'][0]['code'])}) {error_info['errors'][0]['message']}, ({str(error_info['errors'][0]['error_chain'][0]['code'])}) {error_info['errors'][0]['error_chain'][0]['message']} * * *") 95 | 96 | elif response.status_code == 403: 97 | # 403 (9103) Unknown X-Auth-Key or X-Auth-Email [Legacy API Key] 98 | # 403 (9109) Invalid Access Token [API Token] 99 | print('* * * ERROR: (' + str(error_info['errors'][0]['code']) +') ' + error_info['errors'][0]['message'] + ' * * *') 100 | print(f"* * * ERROR: ({str(error_info['errors'][0]['code'])}) {error_info['errors'][0]['message']} * * *") 101 | 102 | else: 103 | print(f'* * * ERROR: {e} * * *') 104 | 105 | sys.exit() 106 | 107 | # The response from the Cloudflare API is serialised JSON content. 108 | zone_info = response.json() # == json.loads(response.text) 109 | 110 | try: 111 | #The domain's zone ID is stored in the 'id' key of the first [0] element of the 'result' key array (list). If the domain does not exist on your Cloudflare account, the JSON returned contains an empty 'result' key array and attempting to access the 'id' key will raise an 'IndexError' exception. 112 | zone_id = zone_info['result'][0]['id'] 113 | # As of 2024-11-30 the zone name is no longer included in each individual DNS record so we need to grab it here to use later in the script. See https://developers.cloudflare.com/fundamentals/api/reference/deprecations/#2024-11-30 114 | zone_name = zone_info['result'][0]['name'] 115 | except IndexError as e: 116 | sys.exit(e) 117 | 118 | # Now we have the domain's zone ID, we can get all of its DNS records 119 | response = requests.get('https://api.cloudflare.com/client/v4/zones/' + zone_id + '/dns_records', headers=headers) 120 | 121 | # The response from the Cloudflare API is serialised JSON content. 122 | dns_records_info = response.json() # == json.loads(response.text) 123 | 124 | output = f'Generated on {datetime.datetime.now().strftime("%d/%m/%Y")} at {datetime.datetime.now().strftime("%H:%M:%S")}\n\n' 125 | 126 | # for record in dns_records_info['result']: 127 | for count, record in enumerate(dns_records_info['result'], start=1): 128 | 129 | output += f'Record: {str(count)}/{str(len(dns_records_info["result"]))}\n\n' 130 | 131 | if args.raw: 132 | # Include raw JSON in output (-r/--raw) 133 | 134 | indent=None 135 | if args.pretty: 136 | # indent='\t' 137 | indent=2 138 | 139 | # Serialise 140 | output += json.dumps(record, sort_keys=True, indent=indent) + '\n\n' 141 | 142 | """ 143 | Available keys for 'record' 144 | comment 145 | content 146 | created_on 147 | id 148 | locked 149 | meta { 150 | auto_added 151 | managed_by_apps 152 | managed_by_argo_tunnel 153 | source 154 | } 155 | modified_on 156 | name 157 | priority 'MX' records only 158 | proxiable 159 | proxied 160 | ttl 161 | type 162 | zone_id Deprecated 2024-11-30. See https://developers.cloudflare.com/fundamentals/api/reference/deprecations/#2024-11-30 163 | zone_name Deprecated 2024-11-30. See https://developers.cloudflare.com/fundamentals/api/reference/deprecations/#2024-11-30 164 | """ 165 | 166 | domain = zone_name 167 | record_type = record['type'] 168 | name = record['name'] 169 | content = record['content'] 170 | if 'priority' in record: # 'record' contains the 'priority' key 171 | priority = str(record['priority']) 172 | proxiable = str(record['proxiable']) 173 | proxied = str(record['proxied']) 174 | ttl = str(record['ttl']) 175 | modified = record['modified_on'] 176 | comment='' 177 | if record['comment'] is not None: 178 | comment = record['comment'] 179 | 180 | output += f'Domain: {domain}\nType: {record_type}\nName: {name}\nContent: {content}\n' + (f'Priority: {priority}\n' if 'priority' in record else '') + f'Proxiable: {proxiable}\nProxied: {proxied}\nTTL: {ttl}\nComment: {comment}\nModified: {modified}\n' 181 | 182 | if record_type.upper() in ('A','AAAA','CNAME','MX','TXT'): 183 | 184 | if f'.{domain}' in name: 185 | # Remove '.domain' from 'name' e.g 'dkim._domainkey.example.com' should be passed as 'dkim._domainkey' to cf-dns.sh 186 | name = name.replace(f'.{domain}', '') 187 | 188 | if record_type.upper() in 'TXT': 189 | # Enclose 'content' in quotes to deal with characters it may contain that would otherwise need to be escaped 190 | content = f'\'{content}\'' 191 | 192 | if proxiable.lower() in 'true': 193 | if proxied.lower() in 'true': 194 | proxied = 'Y' 195 | 196 | if proxied.lower() in 'false': 197 | proxied = 'N' 198 | 199 | # if comment in 'none': 200 | 201 | # comment = comment.replace('None', '') 202 | if comment: 203 | comment = f'\'{comment}\'' 204 | # comment = f'\'{comment.replace("on", "")}\'' 205 | # comment = comment.replace("on", "") 206 | 207 | output += f'./cf-dns.sh -d {domain} -t {record_type} -n {name} -c {content}' + (f' -p {priority}' if record_type.upper() in 'MX' else '') + (f' -x {proxied}' if proxiable.lower() in 'true' else '') + f' -l {ttl}' + (f' -C {comment}' if comment else '') + ' [-k] [-S] [-A]\n' 208 | 209 | output += f'./cf-dns.sh -d {domain} -t {record_type} -n {name} -c {content} -Z [-a] [-k] [-S]\n\n' 210 | 211 | output += '* ' * 50 + '\n' 212 | 213 | else: 214 | 215 | output += f'* * * Type \'{record_type}\' records are not handled by the script: cf-dns.sh * * *\n\n' 216 | 217 | if args.screen: 218 | # Send output to screen 219 | print(output) 220 | else: 221 | # Redirect output to a newly created file (default) if the user hasn't chosen to display output to screen (-s/--screen) 222 | with open(os.environ['HOME'] + '/' + args.domain + '-' + datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f')[:-3] + '.txt', 'w') as f: 223 | with redirect_stdout(f): 224 | print(output) 225 | 226 | print(f'Output written to {f.name}') 227 | 228 | if __name__== '__main__' : 229 | main() --------------------------------------------------------------------------------