├── .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()
--------------------------------------------------------------------------------