-
Notifications
You must be signed in to change notification settings - Fork 8
/
Copy pathgcp_ddns.py
317 lines (265 loc) · 13.3 KB
/
gcp_ddns.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
""" A dynamic DNS client Google Cloud DDNS
This script will, based on its configuration file, query the GCloud DNS API.
It will create a Resource Record Set (RRSET)in GCloud if no such record
exists that matches the configuration file. If a match is found, the script
will check its host's current public IP address, and if it is found to be
different than that in GCloud, will first delete the RRSET, then create a
new RRSET.
Every x seconds, as defined by the user with the variable interval, the script
will repeat the process.
"""
import time
import sys
import os
import yaml
import logging
import signal
from google.cloud import dns, exceptions as cloudexc
from google.auth import exceptions as authexc
from google.api_core import exceptions as corexc
from googleapiclient import discovery, errors
import requests
CONFIG_PARAMS = ['project_id', 'managed_zone', 'host', 'ttl', 'interval']
# This makes sure that SIGTERM signal is handled (for example from Docker)
def handle_sigterm(*args):
raise KeyboardInterrupt()
signal.signal(signal.SIGTERM, handle_sigterm)
# noinspection PyUnboundLocalVariable
def main():
# initialize console logger
logging.getLogger().setLevel(logging.DEBUG)
logFormatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
consoleHandler = logging.StreamHandler()
consoleHandler.setFormatter(logFormatter)
logging.getLogger().addHandler(consoleHandler)
# You can provide the config file as the first parameter
if len(sys.argv) == 2:
config_file = sys.argv[1]
elif len(sys.argv) > 2:
logging.error("Usage: python gcp_ddns.py [path_to_config_file.yaml]")
return 1
else:
config_file = "ddns-config.yaml"
# Read YAML configuration file and set initial parameters for logfile and api key
with open(config_file, 'r') as stream:
try:
config = yaml.safe_load(stream)
logging.info(config)
if 'api-key' in config:
api_key = config['api-key']
else:
logging.error(f"api_key must be defined in {config_file}")
exit(1)
if 'logfile' in config:
logfile = config['logfile']
else:
logging.error(f"logfile must be defined in {config_file}")
exit(1)
# iterate through our required config parameters and each host entry in the config file
# check that all requisite parameters are included in the file before proceeding.
except yaml.YAMLError:
logging.error(f"There was an error loading configuration file: {config_file}")
exit(1)
# ensure that the provided credential file exists
if not os.path.isfile(api_key):
logging.error(
"Credential file not found. By default this program checks for ddns-api-key.json in this directory.\n"
+ "You can specify the path to the credentials as an argument to this script. "
+ "Usage: python gcp_ddns.py [path_to_config_file.json]"
)
return 1
# initialize file logger
fileHandler = logging.FileHandler(filename=logfile, mode="w")
fileHandler.setFormatter(logFormatter)
logging.getLogger().addHandler(fileHandler)
# set OS environ for google authentication
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = api_key
# setup our objects that will be used to query the Google API
# N.B. cache_discover if false. This prevents google module exceptions
# This is not a performance critical script, so shouldn't be a problem.
service = discovery.build("dns", "v1", cache_discovery=False)
# this is the program's main loop. Exit with ctl-c
while True:
try:
for count, config_host in enumerate(config['hosts'], start=1):
for key in CONFIG_PARAMS:
if key not in config_host:
logging.error(f"{key} not found in config file {config_file}. Please ensure it is.")
exit(1)
project = config_host["project_id"]
managed_zone = config_host["managed_zone"]
domain = config_host["domain"]
host = config_host["host"]
ttl = config_host["ttl"]
type = config_host['type']
interval = config_host["interval"]
# confirm that the last character of host is a '.'. This is a google requirement
if host[-1] != ".":
logging.error(
f"The host entry in the configuration file must end with a '.', e.g. www.example.com. "
)
return 1
# attempt to get IP address early on. This will help us determine
# if we have an internet connection. If we don't we should catch
# the exception, sleep and go to the top of the loop
# http get request to fetch our public IP address from ipify.org
# if it fails for whatever reason, sleep, and go back to the top of the loop
try:
if type == "A":
ipify_response = requests.get("https://api.ipify.org?format=json")
if type == "AAAA":
ipify_response = requests.get("https://api6.ipify.org?format=json")
except requests.exceptions.ConnectionError as exc:
logging.error(f"Timed out trying to reach api.ipify.org", exc_info=exc)
time.sleep(interval)
except requests.exceptions.RequestException as exc:
logging.error(
f"Requests error when trying to fetch current local IP. Exception: {exc}",
exc_info=exc
)
continue
# check that we got a valid response. If not, sleep for interval and go to the top of the loop
if ipify_response.status_code != 200:
logging.error(
f"API request unsuccessful. Expected HTTP 200, got {gcp_record_set.status_code}"
)
time.sleep(interval)
# no point going further if we didn't get a valid response,
# but we also want to try again later, should there be a temporary server issue with ipify.org
continue
# this is our public IP address.
ip = ipify_response.json()["ip"]
# this is where we build our resource record set and what we will use to call the api
# further down in the script.
request = service.resourceRecordSets().list(
project=project, managedZone=managed_zone, name=host, type=type
)
# Use Google's dns.Client to create client object and zone object
# Note: Client() will pull the credentials from the os.environ from above
try:
client = dns.Client(project=project)
except authexc.DefaultCredentialsError:
logging.error(
"Provided credentials failed. Please ensure you have correct credentials."
)
return 1
except authexc.GoogleAuthError:
logging.error(
"Provided credentials failed. Please ensure you have correct credentials."
)
return 1
# this is the object which will be sent to Google and queried by us.
zone = client.zone(managed_zone, domain)
# build the record set based on our configuration file
record_set = {"name": host, "type": type, "ttl": ttl, "rrdatas": [ip]}
# attempt to get the DNS information of our host from Google
try:
gcp_record_set = request.execute() # API call
except errors.HttpError as e:
logging.error(
f"Access forbidden. You most likely have a configuration error. Full error: {e}"
)
return 1
except corexc.Forbidden as e:
logging.error(
f"Access forbidden. You most likely have a configuration error. Full error: {e}"
)
return 1
# ensure that we got a valid response
if gcp_record_set is not None and len(gcp_record_set["rrsets"]) > 0:
rrset = gcp_record_set["rrsets"][0]
google_ip = rrset["rrdatas"][0]
google_host = rrset["name"]
google_ttl = rrset["ttl"]
google_type = rrset["type"]
logging.debug(
f"config_h: {host} current_ip: {ip} g_host: {rrset['name']} g_ip: {google_ip}"
)
# ensure that the record we received has the same name as the record we want to create
if google_host == host:
logging.info("Config file host and google host record match")
if google_ip == ip:
logging.info(
f"IP and Host information match. Nothing to do here. "
)
else:
# host record exists, but IPs are different. We need to update the record in the cloud.
# To do this, we must first delete the current record, then create a new record
del_record_set = {
"name": host,
"type": google_type,
"ttl": google_ttl,
"rrdatas": [google_ip],
}
logging.debug(f"Deleting record {del_record_set}")
if not dns_change(zone, del_record_set, "delete"):
logging.error(
f"Failed to delete record set {del_record_set}"
)
logging.debug(f"Creating record {record_set}")
if not dns_change(zone, record_set, "create"):
logging.error(f"Failed to create record set {record_set}")
else:
# for whatever reason, the record returned from google doesn't match the host
# we have configured in our config file. Exit and log
logging.error(
"Configured hostname doesn't match hostname returned from google. No actions taken"
)
else:
# response to our request returned no results, so we'll create a DNS record
logging.info(f"No record found. Creating a new record: {record_set}")
if not dns_change(zone, record_set, "create"):
logging.error(f"Failed to create record set {record_set}")
# only go to sleep if we have cycled through all hosts
if count == len(config['hosts']):
logging.info(
f"Going to sleep for {interval} seconds "
)
time.sleep(interval)
except KeyboardInterrupt:
logging.error("\nCtl-c received. Goodbye!")
break
return 0
def dns_change(zone, rs, cmd):
""" Function to create or delete a DNS record
:param zone: google.cloud.dns.zone.ManagedZone'
The zone which we are configuring in Google Cloud DNS
:param rs: dict
Contains all the elements we need to create the record set to be submitted to the API
:param cmd: str
Either 'create' or 'delete'. This decides which action to take towards Google Cloud
:return: bool
True if we succeeded in a creation or deletion of a record set, otherwise False
"""
change = zone.changes()
# build the record set to be deleted or created
record_set = zone.resource_record_set(
rs["name"], rs["type"], rs["ttl"], rs["rrdatas"]
)
if cmd == "delete":
change.delete_record_set(record_set)
logging.debug(f"Deleting record set: {record_set}")
elif cmd == "create":
change.add_record_set(record_set)
logging.debug(f"creating record set : {record_set}")
else:
return False
try:
change.create() # API request
except corexc.FailedPrecondition as e:
logging.error(
f"A precondition for the change failed. Most likely an error in your configuration file. Error: {e}"
)
return False
except cloudexc.exceptions as e:
logging.error(f"A cloudy error occurred. Error: {e}")
return False
# get and print status
while change.status != "done":
logging.info(f"Waiting for {cmd} changes to complete")
time.sleep(10) # or whatever interval is appropriate
change.reload() # API request
logging.info(f"{cmd.title()} Status: {change.status}")
return True
if __name__ == "__main__":
main()