ddns-scripts: add support for Google Cloud DNS

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>
This commit is contained in:
Chris Barrick 2022-12-03 23:00:51 -05:00 committed by Chris Barrick
parent 02e154d3e5
commit cbdc67bd10
3 changed files with 315 additions and 1 deletions

View 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))

View file

@ -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 $@

View file

@ -0,0 +1,10 @@
{
"name": "cloud.google.com-v1",
"ipv4": {
"url": "update_gcp_v1.sh"
},
"ipv6": {
"url": "update_gcp_v1.sh"
}
}