NetBoxing.psm1
function AddToHash ([hashtable] $Hash, [string[]] $Key, $Value) { $k, $Key = $Key if ($Key) { if ($Hash[$K] -isnot [hashtable]) {$Hash[$K] = @{}} AddToHash -Hash $Hash[$K] -Key $Key -Value $Value } else { $Hash[$K] = $Value } } function ChangesOnly ([PSObject] $Item, [hashtable] $Changes) { function NotIdentical ($Item, $Changes) { ($Changes -is [hashtable] -and (ChangesOnly -Item $Item -Changes $Changes).Count) -or ($Changes -isnot [hashtable] -and $Item -cne $Changes) } $expand = $null $Changes = $Changes.Clone() foreach ($key in @($Changes.Keys)) { if ($key -ceq '___EXPAND') { $expand = $Changes.$key $null = $Changes.Remove($key) } elseif ($Changes.$key -is [hashtable]) { if (-not ($Changes.$key = ChangesOnly -Item $Item.$key -Changes $Changes.$key).Count) { $Changes.Remove($key) } } elseif ($Changes.$key -is [array]) { if ($Changes.$key.Count -and $Changes.$key[0] -is [hashtable] -and $Changes.$key[0].___APPEND) { $append, $Changes.$key = $Changes.$key if (($ik = $Item.$key) -isnot [array]) { $ik = @() } $identical = $true $combined = @($ik | Select-Object -Property $append.___APPEND) foreach ($c in $Changes.$key) { $exist = $false foreach ($i in $ik) { if (-not (NotIdentical -Item $i -Changes $c)) { $exist = $true break } } if (-not $exist) { $combined += $c $identical = $false } } if ($identical) { $Changes.Remove($key) } else { $Changes.$key = $combined } } else { if ($Item.$key -is [array] -and $Item.$key.Count -eq $Changes.$key.Count) { $identical = $true for ($i=0; $i -lt $Changes.$key.Count; $i++) { if (NotIdentical -Item $Item.$key[$i] -Changes $Changes.$key[$i]) { $identical = $false break } } if ($identical) { $Changes.Remove($key) } } elseif ($Item.$key -eq $null -and $Changes.$key.Count -eq 0) { $Changes.Remove($key) } } } else { if ($Item.$key -ceq $Changes.$key) { $null = $Changes.Remove($key) } } } if ($expand) { $Changes.$expand } else { $Changes } } function FlattenHash ([hashtable] $Hash, [string] $Prefix) { $return = @{} @($Hash.Keys) | ForEach-Object -Process { $n = if ($Prefix) {$Prefix + '.' + $_} else {$_} if ($Hash[$_] -is [hashtable]) { $return += FlattenHash -Hash $Hash[$_] -Prefix $n } else { $return[$n] = $Hash[$_] } } $return } # Override Write-Verbose in this module so calling function is added to the message function script:Write-Verbose { [CmdletBinding()] param ( [Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true)] [String] $Message ) begin {} process { try { $PSBoundParameters['Message'] = $((Get-PSCallStack)[1].Command) + ': ' + $PSBoundParameters['Message'] } catch {} Microsoft.PowerShell.Utility\Write-Verbose @PSBoundParameters } end {} } function Connect-Netbox { <# .SYNOPSIS Connect to NetBox .DESCRIPTION Connect to Netbox. Or that is, tell the PowerShell module URI and token - so the other functions in the module know what to connect to. This function doesn't actually connect to anything. .PARAMETER Uri Uri. Eg. https://netbox.yourdomain.tld .PARAMETER Token API token created in NetBox .EXAMPLE Connect-Netbox -Uri https://netbox.yourdomain.tld -Token abcabcabc #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Uri, [Parameter(Mandatory = $true)] [String] $Token ) $origErrorActionPreference = $ErrorActionPreference try { $ErrorActionPreference = 'Stop' $script:baseUri = $Uri -replace '/$' $script:apiToken = $Token } catch { $msg = $_.ToString() + "`r`n" + $_.InvocationInfo.PositionMessage.ToString() Write-Verbose -Message "Encountered an error: $msg" Write-Error -ErrorAction $origErrorActionPreference -Message $msg } } function Find-NetboxObject { <# .SYNOPSIS Find object(s) in NetBox .DESCRIPTION Find object(s) in NetBox .PARAMETER Uri Either API part ("dcim/sites/") or full URI ("https://netbox.yourdomain.tld/api/dcim/sites/") .PARAMETER Properties Hashtable with properties .PARAMETER FindBy Which properties should be used to find object .EXAMPLE Find-NetboxObject -Uri ipam/prefixes/ -Properties @{vlan = @{vid = 3999}} Find all prefixes attaced to VLAN 3999. .EXAMPLE Find-NetboxObject -Uri ipam/prefixes/ -FindBy 'vlan.vid' -Properties @{vlan = @{vid = 3999}; otherproperty='foobar'} Find all prefixes attaced to VLAN 3999. "otherproperty" is ignored in search. .EXAMPLE Find-NetboxObject ipam/vlans/ -Properties @{group=@{slug='test'}} -FindBy 'group=group.slug' Find all VLANs belonging to VLAN group "test". Sometimes the NetBox API want queries "different". It's not "?group_slug=test" but "?group=test" If the "Findby" is omitted in this example, then NetBox will return all VLAN objects back, and the filtering will be done only on client side. Stuff like "Got 18 objects back from server and returned 2" can be seen in verbose output. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Uri, [Parameter(Mandatory = $true)] [hashtable] $Properties, [Parameter()] [string[]] $FindBy ) begin { Write-Verbose -Message "Begin (ErrorActionPreference: $ErrorActionPreference)" $origErrorActionPreference = $ErrorActionPreference $verbose = $PSBoundParameters.ContainsKey('Verbose') -or ($VerbosePreference -ne 'SilentlyContinue') } process { Write-Verbose -Message "Process begin (ErrorActionPreference: $ErrorActionPreference)" try { # Make sure that we don't continue on error, and that we catches the error $ErrorActionPreference = 'Stop' if (-not $FindBy) { $FindBy = (FlattenHash -Hash $Properties).Keys } $queryProperties = @{} $queryData = [System.Web.HttpUtility]::ParseQueryString([String]::Empty) foreach ($f in $Findby) { $key = $f -replace '^custom_fields.','cf.' -replace '\.','_' if (($a, $b = $f -split '=') -and $b) { $f = $b $key = $a } # Is it insecure? Yes! Is it quick and dirty? Yes! Does it do the job? Yes! $val = .([scriptblock]::Create("`$Properties.$f")) AddToHash -Hash $queryProperties -Key ($f -split '\.') -Value $val $queryData.Add($key, $val) } $findUri = '{0}?{1}' -f $uri, $queryData.ToString() # Return (sometimes we risk getting more data back from Netbox than we wanted - that's why we also check locally) $cAll = $cReturned = 0 Invoke-NetboxRequest -Uri $findUri -Follow | Where-Object -FilterScript { ++$cAll -not (ChangesOnly -Item $_ -Changes $queryProperties).Count -and ++$cReturned } Write-Verbose -Message "Got $cAll objects back from server and returned $cReturned" } catch { Write-Verbose -Message "Encountered an error: $_" Write-Error -ErrorAction $origErrorActionPreference -Exception $_.Exception } finally { $ErrorActionPreference = $origErrorActionPreference } Write-Verbose -Message 'Process end' } end { Write-Verbose -Message 'End' } } function Invoke-NetboxPatch { <# .SYNOPSIS Patch object in Netbox .DESCRIPTION Patch object in Netbox .PARAMETER Uri Either API part ("dcim/sites/") or full URI ("https://netbox.yourdomain.tld/api/dcim/sites/") .PARAMETER Item Original unpatched object .PARAMETER Changes Hashtable with changes to be made to object .PARAMETER NoUpdate Don't update object, only show what would be sent to server (as a warning) .PARAMETER Wait After patch is sent to NetBox, wait with a "Press enter to continue" prompt .EXAMPLE Invoke-NetboxPatch -Uri tenancy/tenants/3/ -Changes @{description = 'example'} Patch tenant 3 with description. This is always sent to Netbox, even if description hasn't changes. The function doesn't know the previous state of the properties. .EXAMPLE $v = Invoke-NetboxRequest ipam/vlans/1/ ; Invoke-NetboxPatch -Item $v -Changes @{description = 'example'} Fetch VLAN object with id 1 and change description. If description is already correct, then a patch request isn't sent to Netbox. Old versions of Netbox didn't have an "url" property in objects. If that's the case, then this should be added: -Uri "ipam/vlans/$($v.id)/" #> [CmdletBinding()] param ( [Parameter()] [string] $Uri, [Parameter()] [PSObject] $Item, [Parameter(Mandatory = $true)] [hashtable] $Changes, [Parameter()] [switch] $NoUpdate, [Parameter()] [switch] $Wait ) begin { Write-Verbose -Message "Begin (ErrorActionPreference: $ErrorActionPreference)" $origErrorActionPreference = $ErrorActionPreference $verbose = $PSBoundParameters.ContainsKey('Verbose') -or ($VerbosePreference -ne 'SilentlyContinue') } process { Write-Verbose -Message "Process begin (ErrorActionPreference: $ErrorActionPreference)" try { # Make sure that we don't continue on error, and that we catches the error $ErrorActionPreference = 'Stop' if (-not $Uri) { $Uri = $Item.url } if (-not $Uri) { throw 'Cannot find URI for Netbox object' } $body = ChangesOnly -Item $Item -Changes $Changes if ($body.Count) { if ($NoUpdate) { Write-Warning -Message "Skipping changes on $Uri" Write-Warning -Message ($body | ConvertTo-Json -Depth 9) } else { Invoke-NetboxRequest -Uri $Uri -FullResponse -Method Patch -Body $body if ($Wait) { Read-Host -Prompt 'Press enter to continue' } } } } catch { Write-Verbose -Message "Encountered an error: $_" Write-Error -ErrorAction $origErrorActionPreference -Exception $_.Exception } finally { $ErrorActionPreference = $origErrorActionPreference } Write-Verbose -Message 'Process end' } end { Write-Verbose -Message 'End' } } function Invoke-NetboxRequest { <# .SYNOPSIS Send HTTP request to NetBox .DESCRIPTION Send HTTP request to NetBox .PARAMETER Uri Either API part ("dcim/sites/") or full URI ("https://netbox.yourdomain.tld/api/dcim/sites/") .PARAMETER Method HTTP method Get, Post, ... .PARAMETER Body Object (or hashtable) that should be sent if Method is POST or PATCH .PARAMETER FullResponse Return the full object returned from Netbox - and not only the "relevant" part .PARAMETER Follow If result from NetBox contains more than 50 objects, then follow next-page links and get it all .EXAMPLE Invoke-NetboxRequest dcim/sites/ -Follow Fetch all sites from NetBox .EXAMPLE Invoke-NetboxRequest -Uri https://netbox.yourdomain.tld/api/dcim/sites/1/ Fetch site with ID 1 from Netbox .EXAMPLE Invoke-NetboxRequest -Uri tenancy/tenants/ -Method Post -Body @{name='Example Tenant'; slug='example-tenant'} Create new tenant #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)] [string] $Uri, [Parameter()] [Microsoft.PowerShell.Commands.WebRequestMethod] $Method, [Parameter()] [PSObject] $Body, [Parameter()] [switch] $FullResponse, [Parameter()] [switch] $Follow ) begin { Write-Verbose -Message "Begin (ErrorActionPreference: $ErrorActionPreference)" $origErrorActionPreference = $ErrorActionPreference $verbose = $PSBoundParameters.ContainsKey('Verbose') -or ($VerbosePreference -ne 'SilentlyContinue') $origSecurityProtocol = [Net.ServicePointManager]::SecurityProtocol if (-not $script:baseUri -or -not $script:apiToken) { throw 'Please login with Connect-Netbox first' } } process { Write-Verbose -Message "Process begin (ErrorActionPreference: $ErrorActionPreference)" try { # Make sure that we don't continue on error, and that we catches the error $ErrorActionPreference = 'Stop' # Why isn't this default!? [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $null = $PSBoundParameters.Remove('FullResponse') $null = $PSBoundParameters.Remove('Follow') $PSBoundParameters['Uri'] = $Uri $PSBoundParameters['Headers'] = @{Authorization = "Token $($script:apiToken)"} $PSBoundParameters['ContentType'] = 'application/json; charset=utf-8' $PSBoundParameters['UseBasicParsing'] = $true if ($Body) { $PSBoundParameters['Body'] = $Body | ConvertTo-Json -Depth 99 Write-Verbose -Message $PSBoundParameters['Body'] } if ($PSBoundParameters['Uri'] -notmatch '^http(s)?://') { $PSBoundParameters['Uri'] = "$($script:baseUri)/api/$($PSBoundParameters['Uri'] -replace '^/')" } do { # Server send UTF8 back but does not send info about it in header #$response = Invoke-RestMethod @PSBoundParameters 'Sending request to {0}' -f $PSBoundParameters['Uri'] | Write-Verbose $resp = Invoke-WebRequest @PSBoundParameters $response = [system.Text.Encoding]::UTF8.GetString($resp.RawContentStream.ToArray()) | ConvertFrom-Json if ($FullResponse -or $response.results -isnot [array]) { $response } else { $response.results } } while ($Follow -and ($PSBoundParameters['Uri'] = $response.next)) } catch { # If error was encountered inside this function then stop doing more # But still respect the ErrorAction that comes when calling this function # And also return the line number where the original error occured $msg = $_.ToString() + "`r`n" + $_.InvocationInfo.PositionMessage.ToString() Write-Verbose -Message "Encountered an error: $msg" Write-Error -ErrorAction $origErrorActionPreference -Exception $_.Exception -Message $msg } finally { $ErrorActionPreference = $origErrorActionPreference [Net.ServicePointManager]::SecurityProtocol = $origSecurityProtocol } Write-Verbose -Message 'Process end' } end { Write-Verbose -Message 'End' } } function Invoke-NetboxUpsert { <# .SYNOPSIS Update (patch) or create NetBox object .DESCRIPTION Update (patch) or create NetBox object If existing object is found .PARAMETER Uri Either API part ("dcim/sites/") or full URI ("https://netbox.yourdomain.tld/api/dcim/sites/") .PARAMETER Properties Properties that should be set when updating or creating object .PARAMETER PropertiesNew Properties that should only be set when creating object - not when updating .PARAMETER FindBy Which properties should be used to find existing object .PARAMETER Item Existing NetBox object can be passed (normally not used). .PARAMETER Multi Changes to multiple objects is allowed. Normally only changes to one object is allowed. If this is set, no new objects will be created, only existing will be updated. .PARAMETER NoCreate Don't create object, only show what would be sent to server (as a warning) .PARAMETER NoUpdate Don't update object, only show what would be sent to server (as a warning) .PARAMETER Wait After post/patch is sent to NetBox, wait with a "Press enter to continue" prompt .EXAMPLE Invoke-NetboxUpsert -Uri ipam/prefixes/ -FindBy 'prefix' -Properties @{prefix='10.0.0.0/30'; description='example'} If prefix 10.0.0.0/30 already exist, then set description. If it doesn't exist, then create it. .EXAMPLE Invoke-NetboxUpsert -Uri ipam/prefixes/ -FindBy 'vlan.vid' -Properties @{vlan=@{vid=3999}; description='example'} -Multi -NoUpdate Find all prefixes attached to VLAN 3999 and show which changes that should be made (as warning). Remove -NoUpdate to send patch requests to NetBox #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Uri, [Parameter(Mandatory = $true)] [hashtable] $Properties, [Parameter()] [hashtable] $PropertiesNew = @{}, [Parameter(Mandatory = $true)] [string[]] $FindBy, [Parameter()] [AllowNull()] [AllowEmptyCollection()] [PSObject[]] $Item, [Parameter()] [switch] $Multi, [Parameter()] [switch] $NoCreate, [Parameter()] [switch] $NoUpdate, [Parameter()] [switch] $Wait ) begin { Write-Verbose -Message "Begin (ErrorActionPreference: $ErrorActionPreference)" $origErrorActionPreference = $ErrorActionPreference $verbose = $PSBoundParameters.ContainsKey('Verbose') -or ($VerbosePreference -ne 'SilentlyContinue') } process { Write-Verbose -Message "Process begin (ErrorActionPreference: $ErrorActionPreference)" try { # Make sure that we don't continue on error, and that we catches the error $ErrorActionPreference = 'Stop' if ($Item -or ($Item = @(Find-NetboxObject -Uri $Uri -Properties $Properties -FindBy $FindBy))) { if ($Item.Count -eq 1 -or $Multi) { foreach ($itemObj in $Item) { if (-not ($itemUri = $itemObj.url)) { if (-not $itemObj.id) {throw 'No ID found on NetBox object'} $itemUri = '{0}{1}/' -f $Uri, $itemObj.id } if ($updatedItem = Invoke-NetboxPatch -Uri $itemUri -Item $itemObj -Changes $Properties -NoUpdate:$NoUpdate -Wait:$Wait) { $updatedItem } else { $itemObj } } } else { throw "$($findUri) matched more than one item - matched $($Item.Count)" } } elseif (-not $Multi) { $body = ChangesOnly -Item @{} -Changes ($Properties + $PropertiesNew) if ($NoCreate) { Write-Warning -Message "Not creating $Uri" Write-Warning -Message ($body | ConvertTo-Json -Depth 9) } else { Invoke-NetboxRequest -Uri $Uri -Method Post -FullResponse -Body $body if ($Wait) { Read-Host -Prompt 'Press enter to continue' } } } else { Write-Verbose -Message 'Multi=true, but zero objects found' } } catch { Write-Verbose -Message "Encountered an error: $_" Write-Error -ErrorAction $origErrorActionPreference -Exception $_.Exception } finally { $ErrorActionPreference = $origErrorActionPreference } Write-Verbose -Message 'Process end' } end { Write-Verbose -Message 'End' } } Export-ModuleMember -Function Invoke-NetboxRequest Export-ModuleMember -Function Invoke-NetboxPatch Export-ModuleMember -Function Find-NetboxObject Export-ModuleMember -Function Invoke-NetboxUpsert Export-ModuleMember -Function Connect-Netbox |