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:
parent
02e154d3e5
commit
cbdc67bd10
3 changed files with 315 additions and 1 deletions
|
@ -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))
|
||||
|
|
272
net/ddns-scripts/files/usr/lib/ddns/update_gcp_v1.sh
Executable file
272
net/ddns-scripts/files/usr/lib/ddns/update_gcp_v1.sh
Executable 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 $@
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"name": "cloud.google.com-v1",
|
||||
"ipv4": {
|
||||
"url": "update_gcp_v1.sh"
|
||||
},
|
||||
"ipv6": {
|
||||
"url": "update_gcp_v1.sh"
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in a new issue