Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial GCloud plugin #5

Merged
merged 7 commits into from
May 1, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions Posh-ACME/DnsPlugins/GCloud-Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# How To Use the GCloud DNS Plugin

This plugin works against the [Google Cloud DNS](https://cloud.google.com/dns) provider. It is assumed that you have already setup a project, billing, and created the DNS zone(s) you will be working against. If not, check the [Cloud DNS Quickstart](https://cloud.google.com/dns/quickstart).

## Setup

We need to create a service account and give it permission to add TXT records to the zone(s) we'll be issuing certificates for.

### Create a Custom Role

It's always a good idea to limit a service account's access to only what is needed to perform its function. So rather than giving it the default `DNS Administrator` role, we'll create a custom one that is less dangerous. Start by going to the [IAM Roles](https://console.cloud.google.com/iam-admin/roles) page and make sure the correct project is selected.

- Filter the Roles for "dns" and find the `DNS Administrator` role
- Open the context menu for the role and click `Create role from this role`
- Title: `DNS Zone Editor`
- Description: `List/Read Zones and Write Zone data`
- ID: `DNSZoneEditor`
- Role launch stage: `General Availability`
- In the list of permissions, uncheck all **except** the following:
- `dns.changes.create`
- `dns.changes.get`
- `dns.changes.list`
- `dns.managedZones.get`
- `dns.managedZones.list`
- `dns.resourceRecordSets.create`
- `dns.resourceRecordSets.delete`
- `dns.resourceRecordSets.get`
- `dns.resourceRecordSets.list`
- `dns.resourceRecordSets.update`
- Click `Create`

This will give the account it is applied to the ability to edit all record types for all existing zones in the current project. Unfortunately, the current Google APIs don't allow us to further restrict this role so that the account can only modify TXT records or only specific zones.

### Create a Service Account

Start by going to the [Service accounts](https://console.cloud.google.com/iam-admin/serviceaccounts) page and make sure the correct project is selected.

- Click `Create service account`
- Service account name: `posh-acme`
- Role: `DNS Zone Editor`
- Check `Furnish a new private key`
- Key type: `JSON`
- Click `Create`
- A JSON file should be automatically downloaded. **Don't lose it**.

## Using the Plugin

The only plugin argument you need is the path to the JSON account file you downloaded. The plugin will cache the contents of this file on first use in case the original gets deleted or moved. But you must always specify the path regardless.

```powershell
New-PACertificate test.example.com -DnsPlugin GCloud -PluginArgs @{GCKeyFile='<path to json>'}
```
335 changes: 335 additions & 0 deletions Posh-ACME/DnsPlugins/GCloud.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,335 @@
function Add-DnsTxtGCloud {
[CmdletBinding()]
param(
[Parameter(Mandatory,Position=0)]
[string]$RecordName,
[Parameter(Mandatory,Position=1)]
[string]$TxtValue,
[Parameter(Mandatory,Position=2)]
[string]$GCKeyFile,
[pscustomobject]$GCKeyObj,
[Parameter(ValueFromRemainingArguments)]
$ExtraParams
)

# Cloud DNS API Reference
# https://cloud.google.com/dns/api/v1beta2/

Connect-GCloudDns $GCKeyFile $GCKeyObj
$token = $script:GCToken

Write-Verbose "Attempting to find hosted zone for $RecordName"
if (!($zoneID = Find-GCZone $RecordName)) {
throw "Unable to find Google hosted zone for $RecordName"
}

$recRoot = "https://www.googleapis.com/dns/v1beta2/projects/$($token.ProjectID)/managedZones/$zoneID"

# query the current txt record set
try {
$response = Invoke-RestMethod "$recRoot/rrsets?type=TXT&name=$RecordName." -Headers $script:GCToken.AuthHeader
Write-Debug ($response | ConvertTo-Json -Depth 5)
} catch { throw }
$rrsets = $response.rrsets

if ($rrsets.Count -eq 0) {
# create a new record from scratch
Write-Debug "Creating new record for $RecordName"
$changeBody = @{additions=@(@{
name="$RecordName.";
type='TXT';
ttl=10;
rrdatas=@("`"$TxtValue`"")
})}
} else {
if ("`"$TxtValue`"" -in $rrsets[0].rrdatas) {
Write-Debug "Record $RecordName already contains $TxtValue. Nothing to do."
return
}

# append to the existing value list which basically involves
# both deleting and re-creating the record in the same "change"
# operation
Write-Debug "Appending to $RecordName with $($rrsets[0].Count) existing value(s)"
$toDelete = $rrsets[0] | ConvertTo-Json | ConvertFrom-Json
$rrsets[0].rrdatas += "`"$TxtValue`""
$changeBody = @{
deletions=@($toDelete);
additions=@($rrsets[0]);
}
}

Write-Verbose "Sending update for $RecordName"
Write-Debug ($changeBody | ConvertTo-Json -Depth 5)
try {
$response = Invoke-RestMethod "$recRoot/changes" -Method Post -Body ($changeBody | ConvertTo-Json -Depth 5) -Headers $script:GCToken.AuthHeader -ContentType 'application/json'
Write-Debug ($response | ConvertTo-Json -Depth 5)
} catch { throw }

<#
.SYNOPSIS
Add a DNS TXT record to Google Cloud DNS

.DESCRIPTION
Add a DNS TXT record to Google Cloud DNS

.PARAMETER RecordName
The fully qualified name of the TXT record.

.PARAMETER TxtValue
The value of the TXT record.

.PARAMETER GCKeyFile
Path to a service account JSON file that contains the account's private key and other metadata. This should have been downloaded when originally creating the service account.

.PARAMETER GCKeyObj
A cached copy of the service account JSON object. This parameter is managed by the plugin and you shouldn't ever need to specify it manually.

.PARAMETER ExtraParams
This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports.

.EXAMPLE
Add-DnsTxtGCloud '_acme-challenge.site1.example.com' 'asdfqwer12345678' -GCKeyFile .\account.json

Adds a TXT record for the specified site with the specified value using the specified Google Cloud service account.
#>
}

function Remove-DnsTxtGCloud {
[CmdletBinding()]
param(
[Parameter(Mandatory,Position=0)]
[string]$RecordName,
[Parameter(Mandatory,Position=1)]
[string]$TxtValue,
[Parameter(Mandatory,Position=2)]
[string]$GCKeyFile,
[pscustomobject]$GCKeyObj,
[Parameter(ValueFromRemainingArguments)]
$ExtraParams
)

# Cloud DNS API Reference
# https://cloud.google.com/dns/api/v1beta2/

Connect-GCloudDns $GCKeyFile $GCKeyObj
$token = $script:GCToken

Write-Verbose "Attempting to find hosted zone for $RecordName"
if (!($zoneID = Find-GCZone $RecordName)) {
throw "Unable to find Google hosted zone for $RecordName"
}

$recRoot = "https://www.googleapis.com/dns/v1beta2/projects/$($token.ProjectID)/managedZones/$zoneID"

# query the current txt record set
try {
$response = Invoke-RestMethod "$recRoot/rrsets?type=TXT&name=$RecordName." -Headers $script:GCToken.AuthHeader
Write-Debug ($response | ConvertTo-Json -Depth 5)
} catch { throw }
$rrsets = $response.rrsets

if ($rrsets.Count -eq 0) {
Write-Debug "Record $RecordName already deleted."
return
} else {
if ("`"$TxtValue`"" -notin $rrsets[0].rrdatas) {
Write-Debug "Record $RecordName doesn't contain $TxtValue. Nothing to do."
return
}

# removing the value involves deleting the existing record and
# re-creating it without the value in the same change set. But if it's
# the last one, we just want to delete it.
Write-Debug "Removing from $RecordName with $($rrsets[0].Count) existing value(s)"
$changeBody = @{
deletions=@(($rrsets[0] | ConvertTo-Json | ConvertFrom-Json))
}
if ($rrsets[0].rrdatas.Count -gt 1) {
$rrsets[0].rrdatas = @($rrsets[0].rrdatas | Where-Object { $_ -ne "`"$TxtValue`"" })
$changeBody.additions = @($rrsets[0])
}
}

Write-Verbose "Sending update for $RecordName"
Write-Debug ($changeBody | ConvertTo-Json -Depth 5)
try {
$response = Invoke-RestMethod "$recRoot/changes" -Method Post -Body ($changeBody | ConvertTo-Json -Depth 5) -Headers $script:GCToken.AuthHeader -ContentType 'application/json'
Write-Debug ($response | ConvertTo-Json -Depth 5)
} catch { throw }

<#
.SYNOPSIS
Remove a DNS TXT record from Google Cloud DNS

.DESCRIPTION
Remove a DNS TXT record from Google Cloud DNS

.PARAMETER RecordName
The fully qualified name of the TXT record.

.PARAMETER TxtValue
The value of the TXT record.

.PARAMETER GCKeyFile
Path to a service account JSON file that contains the account's private key and other metadata. This should have been downloaded when originally creating the service account.

.PARAMETER GCKeyObj
A cached copy of the service account JSON object. This parameter is managed by the plugin and you shouldn't ever need to specify it manually.

.PARAMETER ExtraParams
This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports.

.EXAMPLE
Remove-DnsTxtGCloud '_acme-challenge.site1.example.com' 'asdfqwer12345678' -GCKeyFile .\account.json

Removes a TXT record the specified site with the specified value using the specified Google Cloud service account.
#>
}

function Save-DnsTxtGCloud {
[CmdletBinding()]
param(
[Parameter(ValueFromRemainingArguments)]
$ExtraParams
)

# Nothing to do. Google Cloud DNS doesn't require a save step

<#
.SYNOPSIS
Not required for Google Cloud DNS.

.DESCRIPTION
Google Cloud DNS does not require calling this function to commit changes to DNS records.

.PARAMETER ExtraParams
This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports.
#>
}

############################
# Helper Functions
############################

function Connect-GCloudDns {
[CmdletBinding()]
param(
[Parameter(Mandatory,Position=0)]
[string]$GCKeyFile,
[Parameter(Position=1)]
[pscustomobject]$GCKeyObj
)

# Using OAuth 2.0 for Server to Server Applications
# https://developers.google.com/identity/protocols/OAuth2ServiceAccount

# just return if we've already got a valid non-expired token
if ($script:GCToken -and [DateTimeOffset]::Now -lt $script:GCToken.Expires) {
return
}

Write-Verbose "Signing into GCloud DNS"

# We want to save the contents of GCKeyFile so the user isn't necessarily stuck
# keeping it wherever it originally was when they ran the command. But we're always
# going to use it rather than the cached version as long as in continues to exist
# so they can update it as necessary.
$GCKeyFile = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($GCKeyFile)
if (Test-Path $GCKeyFile -PathType Leaf) {
Write-Debug "Using key file"
$GCKeyObj = Get-Content $GCKeyFile | ConvertFrom-Json

# merge and save updated plugin args
Merge-PluginArgs @{GCKeyObj=$GCKeyObj} | Out-Null
} elseif (!$GCKeyObj) {
throw "Key file $GCKeyFile not found and no cached data exists."
} else {
Write-Debug "Using cached key data"
}

Write-Debug "Loading private key for $($GCKeyObj.client_email)"
$key = Import-Pem -InputString $GCKeyObj.private_key | ConvertFrom-BCKey

$unixNow = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()

# build the claim set for DNS read/write
$jwtClaim = @{
iss = $GCKeyObj.client_email;
aud = $GCKeyObj.token_uri;
scope = 'https://www.googleapis.com/auth/ndev.clouddns.readwrite';
exp = ($unixNow + 3600).ToString();
iat = $unixNow.ToString();
}
Write-Debug "Claim set: $($jwtClaim | ConvertTo-Json)"

# build a signed jwt
$header = @{alg='RS256';typ='JWT'}
$jwt = New-Jws $key $header ($jwtClaim | ConvertTo-Json -Compress) -Compact -NoHeaderValidation

# build the POST body
$authBody = "assertion=$jwt&grant_type=$([uri]::EscapeDataString('urn:ietf:params:oauth:grant-type:jwt-bearer'))"

# attempt to sign in
try {
Write-Debug "Sending OAuth2 login"
$response = Invoke-RestMethod $GCKeyObj.token_uri -Method Post -Body $authBody
Write-Debug ($response | ConvertTo-Json)
} catch { throw }

# save a custom token to memory
$script:GCToken = @{
AuthHeader = @{Authorization="$($response.token_type) $($response.access_token)"};
Expires = [DateTimeOffset]::Now.AddSeconds(3300);
ProjectID = $GCKeyObj.project_id;
}

}

function Find-GCZone {
[CmdletBinding()]
param(
[Parameter(Mandatory,Position=0)]
[string]$RecordName
)

# setup a module variable to cache the record to zone mapping
# so it's quicker to find later
if (!$script:GCRecordZones) { $script:GCRecordZones = @{} }

# check for the record in the cache
if ($script:GCRecordZones.ContainsKey($RecordName)) {
return $script:GCRecordZones.$RecordName
}

$token = $script:GCToken
$projRoot = "https://www.googleapis.com/dns/v1beta2/projects/$($token.ProjectID)"

# get the list of available zones
try {
$zones = (Invoke-RestMethod "$projRoot/managedZones" -Headers $script:GCToken.AuthHeader).managedZones
} catch { throw }

# Since Google could be hosting both apex and sub-zones, we need to find the closest/deepest
# sub-zone that would hold the record rather than just adding it to the apex. So for something
# like _acme-challenge.site1.sub1.sub2.example.com, we'd look for zone matches in the following
# order:
# - site1.sub1.sub2.example.com
# - sub1.sub2.example.com
# - sub2.example.com
# - example.com

$pieces = $RecordName.Split('.')
for ($i=1; $i -lt ($pieces.Count-1); $i++) {
$zoneTest = "$( $pieces[$i..($pieces.Count-1)] -join '.' )."
Write-Debug "Checking $zoneTest"

if ($zoneTest -in $zones.dnsName) {
$zoneID = ($zones | Where-Object { $_.dnsName -eq $zoneTest }).id
$script:GCRecordZones.$RecordName = $zoneID
return $zoneID
}
}

return $null
}
Loading