Skip to content

Commit e172703

Browse files
committed
Better Synology integration (error handling), stop enforcing proxied entries (#2)
1 parent 0255989 commit e172703

File tree

4 files changed

+161
-80
lines changed

4 files changed

+161
-80
lines changed

README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ A tiny command line utility for implementing DDNS with Cloudflare.
1313

1414
```
1515
usage: cloudflareddns [-h] [--email EMAIL] [--key KEY] [--hostname HOSTNAME]
16-
[--ip IP] [--verbose] [--version]
16+
[--ip IP] [--ttl TTL] [--verbose] [--version]
1717
1818
Update DDNS in Cloudflare.
1919
@@ -23,6 +23,7 @@ optional arguments:
2323
--key KEY Cloudflare API key
2424
--hostname HOSTNAME Hostname to set IP for
2525
--ip IP The IP address
26+
--ttl TTL
2627
--verbose
2728
--version show program's version number and exit
2829
```
@@ -82,3 +83,28 @@ Installing with `pip` is easiest:
8283

8384
pip install cloudflareddns
8485

86+
### Usage in Python scripts
87+
88+
```python
89+
from cloudflareddns import cloudflareddns
90+
hostname = 'foo.example.com'
91+
ip = '1.2.3.4'
92+
if cloudflareddns.updateRecord(hostname, ip):
93+
print('Record is OK')
94+
...
95+
```
96+
97+
Requires using environment variables (see tips below).
98+
99+
### Tips
100+
101+
In non-Synology system, it's best to place your Cloudflare credentials into `~/.bashrc` as opposed
102+
to passing them on the command-line. A `.bashrc` may have:
103+
104+
```bash
105+
export CF_EMAIL="john@example.com"
106+
export CF_KEY="xxxxxx"
107+
```
108+
109+
Don't forget to `source ~/.bashrc` if you have just put credentials in there.
110+
The `cloudflareddns` will pick those up, so no need to pass `--email` or `--key` every time.

cloudflareddns/__about__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.0.2"
1+
__version__ = "0.0.3"

cloudflareddns/cloudflareddns.py

Lines changed: 79 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -2,114 +2,122 @@
22

33
import argparse
44
import logging as log # for verbose output
5+
import os
56
import socket # to get default hostname
67
import sys
78

89
import CloudFlare
910
import tldextract
11+
from CloudFlare.exceptions import CloudFlareAPIError
1012

1113
from .__about__ import __version__
1214

1315

14-
def update(cf_username, cf_key, hostname, ip, proxied=True, ttl=120):
15-
16-
log.info("Updating {} to {}".format(hostname, ip))
16+
def update(cfUsername, cfKey, hostname, ip, ttl=None):
17+
"""
18+
Create or update desired DNS record.
19+
Returns Synology-friendly status strings:
20+
https://community.synology.com/enu/forum/17/post/57640?reply=213305
21+
"""
22+
log.debug("Updating {} to {}".format(hostname, ip))
1723

1824
# get zone name correctly (from hostname)
19-
zone_domain = tldextract.extract(hostname).registered_domain
20-
log.info("Zone domain of hostname is {}".format(zone_domain))
25+
zoneDomain = tldextract.extract(hostname).registered_domain
26+
log.debug("Zone domain of hostname is {}".format(zoneDomain))
2127

2228
if ':' in ip:
23-
ip_address_type = 'AAAA'
29+
ipAddressType = 'AAAA'
2430
else:
25-
ip_address_type = 'A'
31+
ipAddressType = 'A'
2632

27-
cf = CloudFlare.CloudFlare(email=cf_username, token=cf_key)
33+
cf = CloudFlare.CloudFlare(email=cfUsername, token=cfKey)
2834
# now get the zone id
29-
zones = []
3035
try:
31-
params = {'name': zone_domain}
36+
params = {'name': zoneDomain}
3237
zones = cf.zones.get(params=params)
33-
except CloudFlare.exceptions.CloudFlareAPIError as e:
34-
log.error('bad auth - %s' % e)
35-
exit(1)
38+
except CloudFlareAPIError as e:
39+
log.error('Bad auth - %s' % e)
40+
return 'badauth'
3641
except Exception as e:
37-
exit('/zones.get - %s - api call failed' % e)
42+
log.error('/zones.get - %s - api call failed' % e)
43+
return '911'
3844

3945
if len(zones) == 0:
40-
log.error('no host')
41-
exit(1)
46+
log.error('No host')
47+
return 'nohost'
4248

4349
if len(zones) != 1:
44-
exit('/zones.get - %s - api call returned %d items' % (zone_domain, len(zones)))
50+
log.error('/zones.get - %s - api call returned %d items' % (zoneDomain, len(zones)))
51+
return 'notfqdn'
4552

4653
zone_id = zones[0]['id']
47-
log.info("Zone ID is {}".format(zone_id))
54+
log.debug("Zone ID is {}".format(zone_id))
4855

49-
dns_records = []
5056
try:
51-
params = {'name': hostname, 'match': 'all', 'type': ip_address_type}
57+
params = {'name': hostname, 'match': 'all', 'type': ipAddressType}
5258
dns_records = cf.zones.dns_records.get(zone_id, params=params)
53-
except CloudFlare.exceptions.CloudFlareAPIError as e:
54-
exit('/zones/dns_records %s - %d %s - api call failed' % (hostname, e, e))
55-
56-
updated = False
59+
except CloudFlareAPIError as e:
60+
log.error('/zones/dns_records %s - %d %s - api call failed' % (hostname, e, e))
61+
return '911'
62+
63+
desiredRecordData = {
64+
'name': hostname,
65+
'type': ipAddressType,
66+
'content': ip
67+
}
68+
if ttl:
69+
desiredRecordData['ttl'] = ttl
5770

5871
# update the record - unless it's already correct
59-
for dns_record in dns_records:
60-
old_ip = dns_record['content']
61-
old_ip_type = dns_record['type']
72+
for dnsRecord in dns_records:
73+
oldIp = dnsRecord['content']
74+
oldIpType = dnsRecord['type']
6275

63-
if ip_address_type not in ['A', 'AAAA']:
76+
if ipAddressType not in ['A', 'AAAA']:
6477
# we only deal with A / AAAA records
6578
continue
6679

67-
if ip_address_type != old_ip_type:
80+
if ipAddressType != oldIpType:
6881
# only update the correct address type (A or AAAA)
6982
# we don't see this becuase of the search params above
70-
log.info('IGNORED: %s %s ; wrong address family' % (hostname, old_ip))
83+
log.debug('IGNORED: %s %s ; wrong address family' % (hostname, oldIp))
7184
continue
7285

73-
if ip == old_ip:
74-
log.info('UNCHANGED: %s %s' % (hostname, ip))
75-
updated = True
76-
continue
86+
if ip == oldIp:
87+
log.info('UNCHANGED: %s == %s' % (hostname, ip))
88+
# nothing to do, record already matches to desired IP
89+
return 'nochg'
7790

7891
# Yes, we need to update this record - we know it's the same address type
92+
dnsRecordId = dnsRecord['id']
7993

80-
dns_record_id = dns_record['id']
81-
dns_record = {
82-
'name': hostname,
83-
'type': ip_address_type,
84-
'content': ip,
85-
'proxied': proxied,
86-
'ttl': ttl
87-
}
88-
try:
89-
cf.zones.dns_records.put(zone_id, dns_record_id, data=dns_record)
90-
except CloudFlare.exceptions.CloudFlareAPIError as e:
91-
exit('/zones.dns_records.put %s - %d %s - api call failed' % (hostname, e, e))
92-
log.info('UPDATED: %s %s -> %s' % (hostname, old_ip, ip))
93-
updated = True
94-
95-
if not updated:
96-
# no exsiting dns record to update - so create dns record
97-
dns_record = {
98-
'name': hostname,
99-
'type': ip_address_type,
100-
'content': ip,
101-
'ttl': ttl
102-
}
10394
try:
104-
cf.zones.dns_records.post(zone_id, data=dns_record)
95+
cf.zones.dns_records.put(zone_id, dnsRecordId, data=desiredRecordData)
10596
except CloudFlare.exceptions.CloudFlareAPIError as e:
106-
exit('/zones.dns_records.post %s - %d %s - api call failed' % (hostname, e, e))
97+
log.error('/zones.dns_records.put %s - %d %s - api call failed' % (hostname, e, e))
98+
return '911'
99+
log.info('UPDATED: %s %s -> %s' % (hostname, oldIp, ip))
100+
return 'good'
101+
102+
# no exsiting dns record to update - so create dns record
103+
try:
104+
cf.zones.dns_records.post(zone_id, data=desiredRecordData)
107105
log.info('CREATED: %s %s' % (hostname, ip))
106+
return 'good'
107+
except CloudFlare.exceptions.CloudFlareAPIError as e:
108+
log.error('/zones.dns_records.post %s - %d %s - api call failed' % (hostname, e, e))
109+
return '911'
108110

109-
# reached far enough, all good then (the text is required by Synology)
110-
print('good')
111111

112112

113+
# reached far enough without genuine return/exception catching, must be an error
114+
# using 'badagent' just because it is unique to other statuses used above
115+
return 'badagent'
116+
117+
def updateRecord(hostname, ip, ttl=None):
118+
res = update(os.environ['CF_EMAIL'], os.environ['CF_KEY'], hostname, ip, ttl)
119+
return res in ['good', 'nochg']
120+
113121
def main():
114122
parser = argparse.ArgumentParser(description='Update DDNS in Cloudflare.')
115123
parser.add_argument('--email', help='Cloudflare account emai')
@@ -118,31 +126,31 @@ def main():
118126
help='Hostname to set IP for')
119127
parser.add_argument('--ip', dest='ip',
120128
help='The IP address')
129+
parser.add_argument('--ttl', type=int, help='TTL in seconds')
121130
parser.add_argument('--verbose', dest='verbose', action='store_true')
122131

123132
parser.add_argument('--version', action='version',
124133
version='%(prog)s {version}'.format(version=__version__))
125134

126-
parser.set_defaults(hostname=socket.getfqdn())
135+
parser.set_defaults(hostname=socket.getfqdn(), ttl=None, email=os.environ['CF_EMAIL'],
136+
key=os.environ['CF_KEY'])
127137

128138
args = parser.parse_args()
129139

130140
if args.verbose:
131141
log.basicConfig(format="%(levelname)s: %(message)s", level=log.DEBUG)
132-
log.info("Verbose output.")
142+
log.debug("Verbose output.")
133143
else:
134-
log.basicConfig(format="%(levelname)s: %(message)s")
135-
136-
cf_username = args.email
137-
cf_key = args.key
138-
hostname = args.hostname
139-
ip = args.ip
144+
log.basicConfig(format="%(message)s", level=log.INFO)
140145

141-
update(cf_username, cf_key, hostname, ip)
146+
update(args.email, args.key, args.hostname, args.ip, args.ttl)
142147

143148

144149
def syno():
145-
update(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4])
150+
"""
151+
In Synology wrapper, we echo the return value of the "update" for users to see errors:
152+
"""
153+
print(update(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], 120))
146154

147155

148156
if __name__ == '__main__':

tests/test_cloudflareddns.py

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111

1212
def test_update():
1313

14-
cf_username = os.environ['CF_EMAIL']
15-
cf_key = os.environ['CF_KEY']
14+
cfUsername = os.environ['CF_EMAIL']
15+
cfKey = os.environ['CF_KEY']
1616
# hostname should be Python version specific so that different versions tests
1717
# don't jump onto each other, so test271.example.com, test368.example.com, etc.
1818
hostname = 'python{}.cloudflareddns.test.{}'.format(
@@ -24,13 +24,60 @@ def test_update():
2424

2525
print("Updating to random IP: {}".format(ip))
2626

27-
# cf_username, cf_key, hostname, ip, proxied=False
28-
cloudflareddns.update(cf_username, cf_key, hostname, ip, False)
27+
# cfUsername, cfKey, hostname, ip, ttl=None
28+
cloudflareddns.update(cfUsername, cfKey, hostname, ip, 120)
2929

3030
time.sleep(180)
3131

3232
# fetch record
33-
new_ip = socket.gethostbyname(hostname)
34-
print("Resolved IP after update is: {}".format(new_ip))
33+
newIp = socket.gethostbyname(hostname)
34+
print("Resolved IP after update is: {}".format(newIp))
3535

36-
assert new_ip == ip
36+
assert newIp == ip
37+
38+
def test_update_success_status():
39+
40+
cfUsername = os.environ['CF_EMAIL']
41+
cfKey = os.environ['CF_KEY']
42+
# hostname should be Python version specific so that different versions tests
43+
# don't jump onto each other, so test271.example.com, test368.example.com, etc.
44+
hostname = 'python{}.cloudflareddns.test.{}'.format(
45+
platform.python_version(),
46+
os.environ['CLOUDFLAREDDNS_TEST_DOMAIN'])
47+
48+
faker = Faker()
49+
ip = faker.ipv4()
50+
51+
print("Updating to random IP: {}".format(ip))
52+
53+
res = cloudflareddns.update(cfUsername, cfKey, hostname, ip, 120)
54+
55+
# fetch record
56+
assert res in ['good', 'nochg']
57+
58+
def test_update_record_func_success():
59+
60+
hostname = 'python{}.cloudflareddns.test.{}'.format(
61+
platform.python_version(),
62+
os.environ['CLOUDFLAREDDNS_TEST_DOMAIN'])
63+
64+
faker = Faker()
65+
ip = faker.ipv4()
66+
67+
res = cloudflareddns.updateRecord(hostname, ip)
68+
69+
assert res is True
70+
71+
def test_update_record_func_failure():
72+
73+
# something we surely don't own and can't update:
74+
hostname = 'foo.example.com'.format(
75+
platform.python_version(),
76+
os.environ['CLOUDFLAREDDNS_TEST_DOMAIN'])
77+
78+
faker = Faker()
79+
ip = faker.ipv4()
80+
81+
res = cloudflareddns.updateRecord(hostname, ip)
82+
83+
assert res is False

0 commit comments

Comments
 (0)