Skip to content

Instantly share code, notes, and snippets.

@HelioCampos
Forked from devblackops/chefquery.ps1
Created February 9, 2019 08:31
Show Gist options
  • Save HelioCampos/873ea5458042033fb7061a5dd6b6b802 to your computer and use it in GitHub Desktop.
Save HelioCampos/873ea5458042033fb7061a5dd6b6b802 to your computer and use it in GitHub Desktop.
PowerShell script to query Chef nodes via REST API. Assumes you have the BouncyCastle.Crypto.dll in the same folder as the script.
function Get-Base64 {
param (
$data
)
# if the $data is a string then ensure it is a byte array
if ($data.GetType().Name -eq "String") {
$data = [System.Text.Encoding]::UTF8.GetBytes($data)
}
# Return the base64 representation of the string
[System.Convert]::ToBase64String($data)
}
function Get-Checksum {
<#
.SYNOPSIS
Return the checksum of the specified file
#>
param (
[Parameter(ParameterSetName="file")]
[string]
# Path to the file to get the checksum for
$path,
[Parameter(ParameterSetName="string")]
[string]
# The string to get the checksum for
$string,
[ValidateSet("sha1", "md5", "sha256")]
[string]
# Algorithm to use when generating the checksum
$algorithm = "md5",
[string]
# The encoding method to use
$encoding = "ASCII",
[switch]
# If trim is specified then the system will trim whitespace from the begining and end of input
$trim,
[switch]
# Disable the Base64 encoding
$nobase64
)
# $md5 = new-object -TypeName System.Security.Cryptography.MD5CryptoServiceProvider
# $hash = [System.BitConverter]::ToString($md5.ComputeHash([System.IO.File]::ReadAllBytes($path)))
$encoder = "{0}Encoding" -f $encoding
$algo = "{0}CryptoServiceProvider" -f $algorithm
$provider = New-Object -TypeName System.Security.Cryptography.$algo
$engine = New-Object -TypeName System.Text.$encoder
# Use the ParameterSetName to determine if a string or a path has been specified
# If it is a path then get the contents
switch ($PScmdlet.ParameterSetName) {
"file" {
$string = Get-Content -Path $path -Raw -Encoding UTF8
}
}
# Strip characters from the string
# This is the the UNIX to Windows line ending problem
# Ruby is UNIX based and will strip LF from files when they are read in
#$string = $string -replace "`r", ""
# work out the checksum of the file
$hash = ([System.BitConverter]::ToString($provider.ComputeHash($engine.GetBytes($string)))).replace("-", "").tolower()
if ($nobase64) {
$checksum = $hash
} else {
# So that the hash is the same as that is generated by chef-client, it needs to be packed
# and then base64 encoded
$packed = for($i = 0; $i -lt $hash.length; $i += 2) {
[char][int]::Parse($hash.substring($i,2), 'HexNumber')
}
# Now build up the checksum that is base64 encoded
$checksum = Get-Base64 -data $packed
}
# Return the hash to the calling function
return $checksum
}
function Get-KeyPairFromPem {
<#
.SYNOPSIS
Creates a key object based on PEM data passed to the function
.DESCRIPTION
BouncyCastle encryption does not read in a PEM file directly, it has to be
imported so that it is converted to an object.
This function checks to see if the PEM that has been passed is actually
a string representation of the PEM file or a path to the file.
If it is a file then it is read into a string.
Finally another check is performed to determine if the key supplied is the
Public or the Private key. This is so that the object can be setup correctly.
#>
param (
[string]
# Path to the Pem file or a string representation of it
$pem
)
# See if the pem begins with -----BEGIN
# If it does then the pem file has been passed raw and does not need to be read in from the
# file system
if (!($pem.StartsWith("-----BEGIN "))) {
$pem = Get-Content -Path $pem -raw
}
# Read the string in as a stream so it can be used by bouncycastle
$stream = New-Object System.IO.StringReader $pem
$pr = New-Object Org.BouncyCastle.OpenSsl.PemReader $stream
# Determine if the key that has been passed is the public key
# This has implications for how the objects are created
if ($pem.StartsWith("-----BEGIN PUBLIC KEY-----")) {
$key = [Org.BouncyCastle.Crypto.AsymmetricKeyParameter] ($pr.ReadObject())
} else {
$key = [Org.BouncyCastle.Crypto.AsymmetricCipherKeyPair] ($pr.ReadObject())
}
$pr.Reader.Close()
$pr.Reader.Dispose()
$stream.Close()
$stream.Dispose()
return $key
}
function Initialize-BouncyCastle {
# Load the Bouncycastle library without locking the DLL or needlessly re-loading
try {
$assemblyLoaded = New-Object Org.BouncyCastle.Crypto.Engines.RsaEngine -ErrorAction SilentlyContinue
} catch {
$dll = '.\BouncyCastle.Crypto.dll'
if ( !(Test-Path $dll) ) {
throw "Unable to find the BouncyCastle library: $dll"
}
$fileStream = ([System.IO.FileInfo] (Get-Item -Path $dll)).OpenRead()
$assemblyBytes = New-Object -TypeName byte[] -ArgumentList $fileStream.Length
$fileStream.Read($assemblyBytes, 0, $fileStream.Length) | Out-Null
$fileStream.Close()
$assemblyLoaded = [System.Reflection.Assembly]::Load($assemblyBytes);
}
}
function Set-Headers {
<#
.SYNOPSIS
Build up the headers that are required for a chef API query
.DESCRIPTION
Function to build up the headers for the query against the chef server.
It will also build up the hash that is required for the body and then sign it
#>
param (
[string]
$path,
[string]
$method = "GET",
$data,
[hashtable]
$headers,
[string]
# Attribute in the chefconfig to use as the UserId
$useritem = "client",
[Alias('KeyPath')]
[string]
$Key
#[string]
# Attribute in the chefconfig to use as the key
#$keyitem = "key"
)
# generate a timestamp, this must be UTC
$timestamp = Get-Date -Date ([DateTime]::UTCNow) -uformat "+%Y-%m-%dT%H:%M:%SZ"
# Determine the SHA1 hash of the content
$content_hash = Get-CheckSum -string $data -algorithm SHA1
# define the headers hash table
$headers = @{
'X-Ops-Sign' = 'algorithm=sha1;version=1.0'
'X-Ops-UserId' = $useritem
'X-Ops-Timestamp' = $timestamp
'X-Ops-Content-Hash' = $content_hash
'X-Chef-Version' = '12.0.2'
}
# Create ArrayList to hold the parts of the header that need to be encrypted
$al = New-Object System.Collections.ArrayList
$al.Add(("Method:{0}" -f $method.ToUpper())) | Out-Null
$al.Add(("Hashed Path:{0}" -f $(Get-CheckSum -string $path -algorithm SHA1))) | Out-Null
$al.Add(("X-Ops-Content-Hash:{0}" -f $content_hash)) | Out-Null
$al.Add(("X-Ops-Timestamp:{0}" -f $timestamp)) | Out-Null
$al.Add(("X-Ops-UserId:{0}" -f $useritem.trim())) | Out-Null
$canonicalized_header = $al -join "`n"
## Build up the path to the pem. this might be an absolute path in which case use that
#if ([System.IO.Path]::IsPathRooted($script:session.config.$keyitem)) {
# $pempath = $script:session.config.$keyitem
# } else {
# $pempath = Join-Path $script:session.config.paths.conf $script:session.config.$keyitem
# }
#$pemPath = 'C:\Users\bolin\chef-repo\.chef\bolin.pem'
$cipher = Invoke-Encrypt -data $canonicalized_header -pem $Key -private
# the signature now needs to be split into lines of 60 characters each
$signature = $cipher -split "(.{60})" | Where-Object {$_}
# Add the signature to the header
$loop = 1
$signature.split("`r") | Foreach-Object {
# Add each bit to the header
$headers[$("X-Ops-Authorization-{0}" -f $loop)] = $_
# increment the counter
$loop ++
}
# return the headers to the calling function
return $headers
}
function Invoke-ChefQuery {
<#
.SYNOPSIS
Run the desired query against chef and pass back an object
#>
[CmdletBinding()]
param (
[parameter(Mandatory)]
[string]$OrgUri,
[alias("path")]
# Path that is being requested from the chef server
$uri,
[ValidateSet('GET', 'PUT', 'POST', 'DELETE')]
[string]
# Method to be used on the REST request
$method = "GET",
# Data that needs to be passed with the request
$data = [String]::Empty,
[string]
# Attribute in the chefconfig to use as the UserId
$useritem = "bolin",
[string]
# Attribute in the chefconfig to use as the key
$KeyPath = "key",
[switch]
# Denote wether the system should get the raw data from the file
# insetad of an object
$raw,
[string]
# Content type of the request
$contenttype = "application/json",
[string]
# The Md5 checksum of the content
$data_checksum = $false,
[switch]
# State whether to passthru, e.g. any errors should be passed back to the calling function as well
$passthru
)
# if the data is a hashtable convert it to a json string
if ($data -is [Hashtable] -or $data -is [System.Collections.Generic.Dictionary`2[System.String,System.Object]]) {
$data = $data | ConvertTo-JSON -Depth ([int]::MaxValue)
}
# If the path is a string then turn it into a System URI object
if ($uri -is [String]) {
$uri = [System.Uri] $uri
# If the scheme is empty build up a uri based on the server in configuration and the path that has been specified
if ([String]::IsNullOrEmpty($uri.Scheme)) {
$uri = [System.Uri] ("{0}{1}" -f $OrgUri, $uri.OriginalString)
}
}
# Get the content of the key path
$tmpFile = New-TemporaryFile
Invoke-WebRequest -Uri $KeyPath -OutFile $tmpFile.FullName -Verbose:$false
$key = Get-Content -Path $tmpFile -Raw
Remove-Item -Path $tmpFile -Force
#write-verbose -Message $tmpFile.FullName
# Sign the request and build up the headers
$headers = Set-Headers -Path $uri.AbsolutePath -Method $method -data $data -useritem $useritem -Key $key
# if the data_checksum is not false add it to the headers
if ($data_checksum -ne $false) {
$headers["content-md5"] = $data_checksum
}
# Build up a splat hash to pass to invoke-rest method
# this is so that the headers that the options being sent can be show in verbose mode
$splathash = @{uri = $uri.OriginalString
headers = $headers
method = $method
body = $data
contenttype = $contenttype}
# if the raw parameter has been specified then set the accept object
if ($raw) {
$splathash.accept = "*/*"
}
# Run the request against the chef server
$response = Invoke-ChefRestMethod @splathash
# Analyse the information that has come back from the server
if (200..204 -contains $response.statuscode) {
# set the return value
$return = $response.data
# if not raw then turn the response data into a hashtable
#if (!$raw -and ![String]::IsNullOrEmpty($return)) {
# $return = _ConvertFromJsonToHashtable -InputObject $return
#}
if (!$raw -and ![String]::IsNullOrEmpty($return)) {
$return = ConvertFrom-Json -InputObject $return
}
} else {
#$content = $response.data | _ConvertFromJsonToHashtable
$content = $response.data | ConvertFrom-Json
# define the return variable
$return = $content
$return.statuscode = $response.statuscode
}
# add the api version of the server to the session variable
# this is so that plugins can use the information to determine how to work
#$script:session.apiversion = $response.apiversion
# return an object generated from the JSON
$return
}
function Invoke-ChefRestMethod {
[CmdletBinding()]
param (
[string]
# The URI of the end point that needs to used
$uri,
[hashtable]
# Hash of headers that need to be added to the request
$headers = @{},
[string]
# The accept string.
$accept = "application/json",
[ValidateSet('GET', 'PUT', 'POST', 'DELETE')]
[string]
# REST method, defaults to GET
$method = "GET",
[string]
# The body to be passed with a POST or PUT request
$body,
[string]
# Set the content type to be applued to the request
$contenttype = "application/json",
[string]
# Path to where the file should be downloaded to
$outfile
)
$method = $method.ToUpper()
#write-verbose $uri
# Function variables
$data = $false
# Disable SSL checks
[Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}
# Build up the request using .NET classes as it is not possible to set the correct Accept header using
# Invoke-RestMethod
# https://connect.microsoft.com/PowerShell/feedback/details/757249/invoke-restmethod-accept-header
$request = [System.Net.WebRequest]::create($uri)
# Set the request method
$request.Method = $method
# Set the agent
# $request.UserAgent = "Chef Knife/11.8.0 (ruby-1.9.3-p448; ohai-6.20.0; i386-mingw32; +http://opscode.com)"
$userAgent = "POSHOrigin/{0} (PowerShell {1})" -f 1, $PSVersionTable.PSVersion.ToString()
$request.UserAgent = $userAgent
# loop round the headers that have been passed
$headers.keys | ForEach-Object {
$request.headers.add($_, $headers.item($_))
}
# Set the Accept
$request.Accept = $accept
# if the content type is not false then add it to the request
# only set the conttentype if the accept is not '*/*'. this is so that files can
# be downloaded from the cookbook
if ($contenttype -ne $false -and $accept -ne "*/*") {
$request.ContentType = $contenttype
}
# Prepare the body to pass to the endpoint
if ($method -eq "POST" -or $method -eq "PUT") {
# get the number of bytes the payload includes
$enc = [System.Text.Encoding]::GetEncoding("UTF-8")
[byte[]] $bytes = $enc.GetBytes($body)
# Set the contentlength of the request
$request.ContentLength = $bytes.length
# add the body to the request stream
$request_stream = [System.IO.Stream] $request.GetRequestStream()
$request_stream.Write($bytes, 0, $bytes.length)
}
#$headers.Add('Accept', $accept)
#write-host $method
#write-host ($headers | fl * | out-string)
#write-host $uri
#write-host $userAgent
#write-host $body
#write-host $contenttype
# Send the request to the server and get the response
try {
$response = $request.GetResponse()
# Take the response and read from the stream
$response_stream = $response.GetResponseStream()
$return = @{}
# if an outfile has been set ensure a filestream object is used
if ([String]::IsNullOrEmpty($outfile)) {
$sr = New-Object system.IO.StreamReader $response_stream
# Get information about the api version that is actually in use from the response headers
# this will be used to determine how to interpret the response from the server
$api_info = @{}
$api_header = $response.GetResponseHeader("X-Ops-API-Info")
# Split on the ; character to get the components of the API information
$components = $api_header -split ";"
foreach($component in $components) {
# split the component using the = sign
$parts = $component -split "="
# now set the api_info hashtable
$api_info.$($parts[0]) = $parts[1]
}
$return.data = $sr.ReadToEnd()
#write-verbose $return.data
$return.apiversion = $api_info.version
} else {
$fs = New-Object System.IO.FileStream -ArgumentList $outfile,Create
# ensure the file is downloaded in chunks
$buffer = New-Object Byte[] 10KB
$count = $response_stream.Read($buffer, 0, $buffer.length)
while ($count -gt 0) {
$fs.Write($buffer, 0, $count)
$count = $response_stream.Read($buffer, 0, $buffer.length)
}
$fs.Flush()
$fs.Close()
$fs.Dispose()
}
# build up the return object to be sent to the calling function
$return.statuscode = [int32] ($response.StatusCode)
} catch [System.Net.WebException] {
write-error $_.Exception
# An exception has occured
# get the body of the response as it will have the error message in it
$response = $_.Exception.Response.GetResponseStream()
$sr = New-Object System.IO.StreamReader $response
# build up the return object to be sent to the calling function
$return = @{
data = $sr.ReadToEnd()
statuscode = [int32] $($_.Exception.Response.StatusCode)
}
# determine when to exit and when not to
if ([int]$return.StatusCode -ge 500) {
$data = $false
}
}
$response_stream.close()
$return
}
function Invoke-Encrypt {
[CmdletBinding()]
param (
# String to encrypt
$data,
[alias('pemPath')]
# the item in the config to use to sign the data
$pem,
[switch]
# The default way to encrypt with RSA is to use the public key
# By setting this switch the private key will be used instead
$private
)
# determine if the path to the key specified in the configuration is an absolute URL
# or if not then build up the path relative to the module directory
#if ([System.IO.Path]::IsPathRooted($script:session.config.$keyitem)) {
# $signing_key = $script:session.config.$keyitem
#} else {
# $signing_key = "{0}\{1}" -f $script:session.config.paths.conf, $script:session.config.$keyitem
#}
Initialize-BouncyCastle
$keys = Get-KeyPairFromPem -pem $pem
$engine = New-Object Org.BouncyCastle.Crypto.Encodings.Pkcs1Encoding (New-Object Org.BouncyCastle.Crypto.Engines.RsaEngine)
# use the public or private key for encryption, if the keys is a valid object
if ($keys -is [Org.BouncyCastle.Crypto.AsymmetricCipherKeyPair]) {
# this is a valid key pair so the choice of public or private key encryption is allowed
if ($private) {
$engine.Init($true, $keys.Private)
} else {
$engine.Init($true, $keys.Public)
}
} elseif ($keys -is [Org.BouncyCastle.Crypto.Parameters.RsaKeyParameters]) {
# only the public key has been passed so just add the key to initialise the engine
$engine.Init($true, $keys)
}
# Get a byte array from the data if it is a string
if ($data -is [String]) {
$encoding = New-Object System.Text.ASCIIEncoding
$dataBytes = $encoding.GetBytes($data)
} elseif ($data -is [Byte[]]) {
$dataBytes = $data
}
$encrypted = $engine.ProcessBlock($dataBytes, 0, $dataBytes.Length)
# Delete temporary key
#Remove-Item -Path $pem -Force
# return the base64 encoded string
return [Convert]::ToBase64String($encrypted)
}
$Node = '<nodename>'
$orgUrl = '<chef org url>'
$KeyPath = '<pem url>'
$params = @{
Method = 'GET'
OrgUri = $orgUrl
uri = "/nodes/$Node"
UserItem = ($KeyPath.split('/') | Select-Object -Last 1).Split('.')[0]
KeyPath = $KeyPath
}
Invoke-ChefQuery @params
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment