aboutsummaryrefslogtreecommitdiff
path: root/net/ddns-scripts/files/usr/lib/ddns/update_gcp_v1.sh
blob: 5bd096f46cc2b0a267f3699a1fd7222e5a5d1ebe (plain)
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
#!/bin/sh
#
#.Distributed under the terms of the GNU General Public License (GPL) version 2.0
#.2022 Chris Barrick <chrisbarrick@google.com>
#
# This script sends DDNS updates using the Google Cloud DNS REST API.
# See: https://cloud.google.com/dns/docs/reference/v1
#
# This script uses a GCP service account. The user is responsible for creating
# the service account, ensuring it has permission to update DNS records, and
# for generating a service account key to be used by this script. The records
# to be updated must already exist.
#
# Arguments:
#
# - $username: The service account name.
#   Example: ddns-service-account@my-dns-project.iam.gserviceaccount.com
#
# - $password: The service account key. You can paste the key directly into the
#   "password" field or upload the key file to the router and set the field
#   equal to the file path. This script supports JSON keys or the raw private
#   key as a PEM file. P12 keys are not supported. File names must end with
#   `*.json` or `*.pem`.
#
# - $domain: The domain to update.
#
# - $param_enc: The additional required arguments, as form-urlencoded data,
#   i.e. `key1=value1&key2=value2&...`. The required arguments are:
#   - project: The name of the GCP project that owns the DNS records.
#   - zone: The DNS zone in the GCP API.
#   - Example: `project=my-dns-project&zone=my-dns-zone`
#
# - $param_opt: Optional TTL for the records, in seconds. Defaults to 3600 (1h).
#
# Dependencies:
# - ddns-scripts  (for the base functionality)
# - openssl-util  (for the authentication flow)
# - curl          (for the GCP REST API)

. /usr/share/libubox/jshn.sh


# Authentication
# ---------------------------------------------------------------------------
# The authentication flow works like this:
#
#   1. Construct a JWT claim for access to the DNS readwrite scope.
#   2. Sign the JWT with the service accout key, proving we have access.
#   3. Exchange the JWT for an access token, valid for 5m.
#   4. Use the access token for API calls.
#
# See https://developers.google.com/identity/protocols/oauth2/service-account

# A URL-safe variant of base64 encoding, used by JWTs.
base64_urlencode() {
	openssl base64 | tr '/+' '_-' | tr -d '=\n'
}

# Prints the service account private key in PEM format.
get_service_account_key() {
	# The "password" field provides us with the service account key.
	# We allow the user to provide it to us in a few different formats.
	#
	# 1. If $password is a string ending in `*.json`, it is a file path,
	#    pointing to a JSON service account key as downloaded from GCP.
	#
	# 2. If $password is a string ending with `*.pem`, it is a PEM private
	#    key, extracted from the JSON service account key.
	#
	# 3. If $password starts with `{`, then the JSON service account key
	#    was pasted directly into the password field.
	#
	# 4. If $password starts with `---`, then the PEM private key was pasted
	#    directly into the password field.
	#
	# We do not support P12 service account keys.
	case "${password}" in
	(*".json")
		jsonfilter -i "${password}" -e @.private_key
	;;
	(*".pem")
		cat "${password}"
	;;
	("{"*)
		jsonfilter -s "${password}" -e @.private_key
	;;
	("---"*)
		printf "%s" "${password}"
	;;
	(*)
		write_log 14 "Could not parse the service account key."
	;;
	esac
}

# Sign stdin using the service account key. Prints the signature.
# The input is the JWT header-payload. Used to construct a signed JWT.
sign() {
	# Dump the private key to a tmp file so openssl can get to it.
	local tmp_keyfile="$(mktemp -t gcp_dns_sak.pem.XXXXXX)"
	chmod 600 ${tmp_keyfile}
	get_service_account_key > ${tmp_keyfile}
	openssl dgst -binary -sha256 -sign ${tmp_keyfile}
	rm ${tmp_keyfile}
}

# Print the JWT header in JSON format.
# Currently, Google only supports RS256.
jwt_header() {
	json_init
	json_add_string "alg" "RS256"
	json_add_string "typ" "JWT"
	json_dump
}

# Prints the JWT claim-set in JSON format.
# The claim is for 5m of readwrite access to the Cloud DNS API.
jwt_claim_set() {
	local iat=$(date -u +%s)  # Current UNIX time, UTC.
	local exp=$(( iat + 300 ))  # Expiration is 5m in the future.

	json_init
	json_add_string "iss" "${username}"
	json_add_string "scope" "https://www.googleapis.com/auth/ndev.clouddns.readwrite"
	json_add_string "aud" "https://oauth2.googleapis.com/token"
	json_add_string "iat" "${iat}"
	json_add_string "exp" "${exp}"
	json_dump
}

# Generate a JWT signed by the service account key, which can be exchanged for
# a Google Cloud access token, authorized for Cloud DNS.
get_jwt() {
	local header=$(jwt_header | base64_urlencode)
	local payload=$(jwt_claim_set | base64_urlencode)
	local header_payload="${header}.${payload}"
	local signature=$(printf "%s" ${header_payload} | sign | base64_urlencode)
	echo "${header_payload}.${signature}"
}

# Request an access token for the Google Cloud service account.
get_access_token_raw() {
	local grant_type="urn:ietf:params:oauth:grant-type:jwt-bearer"
	local assertion=$(get_jwt)

	${CURL} -v https://oauth2.googleapis.com/token \
		--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \
		--data-urlencode "assertion=${assertion}" \
		| jsonfilter -e @.access_token
}

# Get the access token, stripping the trailing dots.
get_access_token() {
	# Since tokens may contain internal dots, we only trim the suffix if it
	# starts with at least 8 dots. (The access token has *many* trailing dots.)
	local access_token="$(get_access_token_raw)"
	echo "${access_token%%........*}"
}


# Google Cloud DNS API
# ---------------------------------------------------------------------------
# Cloud DNS offers a straight forward RESTful API.
#
# - The main class is a ResourceRecordSet. It's a collection of DNS records
#   that share the same domain, type, TTL, etc. Within a record set, the only
#   difference between the records are their values.
#
# - The record sets live under a ManagedZone, which in turn lives under a
#   Project. All we need to know about these are their names.
#
# - This implementation only makes PATCH requests to update existing record
#   sets. The user must have already created at least one A or AAAA record for
#   the domain they are updating. It's fine to start with a dummy, like 0.0.0.0.
#
# - The API requires SSL, and this implementation uses curl.

# Prints a ResourceRecordSet in JSON format.
format_record_set() {
	local domain="$1"
	local record_type="$2"
	local ttl="$3"
	shift 3 # The remaining arguments are the IP addresses for this record set.

	json_init
	json_add_string "kind" "dns#resourceRecordSet"
	json_add_string "name" "${domain}."  # trailing dot on the domain
	json_add_string "type" "${record_type}"
	json_add_string "ttl" "${ttl}"
	json_add_array "rrdatas"
	for value in $@; do
		json_add_string "" "${value}"
	done
	json_close_array
	json_dump
}

# Makes an HTTP PATCH request to the Cloud DNS API.
patch_record_set() {
	local access_token="$1"
	local project="$2"
	local zone="$3"
	local domain="$4"
	local record_type="$5"
	local ttl="$6"
	shift 6 # The remaining arguments are the IP addresses for this record set.

	# Note the trailing dot after the domain name. It's fully qualified.
	local url="https://dns.googleapis.com/dns/v1/projects/${project}/managedZones/${zone}/rrsets/${domain}./${record_type}"
	local record_set=$(format_record_set ${domain} ${record_type} ${ttl} $@)

	${CURL} -v ${url} \
		-X PATCH \
		-H "Content-Type: application/json" \
		-H "Authorization: Bearer ${access_token}" \
		-d "${record_set}"
}


# Main entrypoint
# ---------------------------------------------------------------------------

# Parse the $param_enc into project and zone variables.
# The arguments are the names for those variables.
parse_project_zone() {
	local project_var=$1
	local zone_var=$2

	IFS='&'
	for entry in $param_enc
	do
		case "${entry}" in
		('project='*)
			local project_val=$(echo "${entry}" | cut -d'=' -f2)
			eval "${project_var}=${project_val}"
		;;
		('zone='*)
			local zone_val=$(echo "${entry}" | cut -d'=' -f2)
			eval "${zone_var}=${zone_val}"
		;;
		esac
	done
	unset IFS
}

main() {
	local access_token project zone ttl record_type

	# Dependency checking
	[ -z "${CURL_SSL}" ] && write_log 14 "Google Cloud DNS requires cURL with SSL support"
	[ -z "$(openssl version)" ] && write_log 14 "Google Cloud DNS update requires openssl-utils"

	# Argument parsing
	[ -z ${param_opt} ] && ttl=3600 || ttl="${param_opt}"
	[ $use_ipv6 -ne 0 ] && record_type="AAAA" || record_type="A"
	parse_project_zone project zone

	# Sanity checks
	[ -z "${username}" ] && write_log 14 "Config is missing 'username' (service account name)"
	[ -z "${password}" ] && write_log 14 "Config is missing 'password' (service account key)"
	[ -z "${domain}" ] && write_log 14 "Config is missing 'domain'"
	[ -z "${project}" ] && write_log 14 "Could not parse project name from 'param_enc'"
	[ -z "${zone}" ] && write_log 14 "Could not parse zone name from 'param_enc'"
	[ -z "${ttl}" ] && write_log 14 "Could not parse TTL from 'param_opt'"
	[ -z "${record_type}" ] && write_log 14 "Could not determine the record type"

	# Push the record!
	access_token="$(get_access_token)"
	patch_record_set "${access_token}" "${project}" "${zone}" "${domain}" "${record_type}" "${ttl}" "${__IP}"
}

main $@