ddns-scripts: add support for Google Cloud DNS
authorChris Barrick <chrisbarrick@google.com>
Sun, 4 Dec 2022 04:00:51 +0000 (23:00 -0500)
committerChris Barrick <cbarrick1@gmail.com>
Fri, 9 Dec 2022 03:04:33 +0000 (22:04 -0500)
The implementation uses a GCP service account. The user is expected to
create and secure a service account and generate a private key. The
"password" field can contain the key inline or be a file path pointing
to the key file on the router.

The GCP project name and Cloud DNS ManagedZone must also be provided.
These are taken as form-urlencoded key-value pairs in param_enc. The TTL
can optionally be supplied in param_opt.

Signed-off-by: Chris Barrick <chrisbarrick@google.com>
net/ddns-scripts/Makefile
net/ddns-scripts/files/usr/lib/ddns/update_gcp_v1.sh [new file with mode: 0755]
net/ddns-scripts/files/usr/share/ddns/default/cloud.google.com-v1.json [new file with mode: 0644]

index 8c51476c303200db5e639c4fde216560a74804c8..9e6e57ba76fe7305ba0b63827babb6620f44f3b1 100644 (file)
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
 
 PKG_NAME:=ddns-scripts
 PKG_VERSION:=2.8.2
-PKG_RELEASE:=29
+PKG_RELEASE:=30
 
 PKG_LICENSE:=GPL-2.0
 
@@ -70,6 +70,17 @@ define Package/ddns-scripts-cloudflare/description
 endef
 
 
+define Package/ddns-scripts-gcp
+  $(call Package/ddns-scripts/Default)
+  TITLE:=Extension for Google Cloud DNS API v1
+  DEPENDS:=ddns-scripts +curl +openssl-util
+endef
+
+define Package/ddns-scripts-gcp/description
+  Dynamic DNS Client scripts extension for Google Cloud DNS API v1 (requires curl)
+endef
+
+
 define Package/ddns-scripts-freedns
   $(call Package/ddns-scripts/Default)
   TITLE:=Extension for freedns.42.pl
@@ -323,6 +334,7 @@ define Package/ddns-scripts-services/install
 
        # Remove special services
        rm $(1)/usr/share/ddns/default/cloudflare.com-v4.json
+       rm $(1)/usr/share/ddns/default/cloud.google.com-v1.json
        rm $(1)/usr/share/ddns/default/freedns.42.pl.json
        rm $(1)/usr/share/ddns/default/godaddy.com-v1.json
        rm $(1)/usr/share/ddns/default/digitalocean.com-v2.json
@@ -358,6 +370,25 @@ exit 0
 endef
 
 
+define Package/ddns-scripts-gcp/install
+       $(INSTALL_DIR) $(1)/usr/lib/ddns
+       $(INSTALL_BIN) ./files/usr/lib/ddns/update_gcp_v1.sh \
+               $(1)/usr/lib/ddns
+
+       $(INSTALL_DIR) $(1)/usr/share/ddns/default
+       $(INSTALL_DATA) ./files/usr/share/ddns/default/cloud.google.com-v1.json \
+               $(1)/usr/share/ddns/default/
+endef
+
+define Package/ddns-scripts-gcp/prerm
+#!/bin/sh
+if [ -z "$${IPKG_INSTROOT}" ]; then
+       /etc/init.d/ddns stop
+fi
+exit 0
+endef
+
+
 define Package/ddns-scripts-freedns/install
        $(INSTALL_DIR) $(1)/usr/lib/ddns
        $(INSTALL_BIN) ./files/usr/lib/ddns/update_freedns_42_pl.sh \
@@ -608,6 +639,7 @@ endef
 $(eval $(call BuildPackage,ddns-scripts))
 $(eval $(call BuildPackage,ddns-scripts-services))
 $(eval $(call BuildPackage,ddns-scripts-cloudflare))
+$(eval $(call BuildPackage,ddns-scripts-gcp))
 $(eval $(call BuildPackage,ddns-scripts-freedns))
 $(eval $(call BuildPackage,ddns-scripts-godaddy))
 $(eval $(call BuildPackage,ddns-scripts-digitalocean))
diff --git a/net/ddns-scripts/files/usr/lib/ddns/update_gcp_v1.sh b/net/ddns-scripts/files/usr/lib/ddns/update_gcp_v1.sh
new file mode 100755 (executable)
index 0000000..5bd096f
--- /dev/null
@@ -0,0 +1,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 $@
diff --git a/net/ddns-scripts/files/usr/share/ddns/default/cloud.google.com-v1.json b/net/ddns-scripts/files/usr/share/ddns/default/cloud.google.com-v1.json
new file mode 100644 (file)
index 0000000..eee707b
--- /dev/null
@@ -0,0 +1,10 @@
+{
+       "name": "cloud.google.com-v1",
+       "ipv4": {
+               "url": "update_gcp_v1.sh"
+       },
+       "ipv6": {
+               "url": "update_gcp_v1.sh"
+       }
+}
+