NinjaOneLogger.psm1
|
#Requires -Version 5.1 Set-StrictMode -Version Latest # NinjaOneLogger — client logger for RMM scripts. Buffers records, serialises them # as NDJSON, and ships them wherever you point it. Three independent delivery modes: # # -UploadUrl Direct POST/PUT the NDJSON straight to any HTTP endpoint you own # (a storage SAS/presigned URL, a webhook, a Logic App, an # HTTP collector). No broker, no dependency on any of our infra. # -BrokerUrl Broker Ask a NinjaOneLogger broker for a short-lived, write-only # upload URL first. Adds the zero-standing-credential model. # -Path File Write to local disk. No network at all. # # The module is useful on its own; the broker is an optional add-on for the # credential-free model. Neither requires the other. $script:NinjaOneLogState = $null # $IsWindows/$IsMacOS/$IsLinux only exist in PS 6+; Windows PowerShell 5.1 is # Windows by definition. Get-Variable keeps this safe under Set-StrictMode. function Get-NinjaOneLogOsName { if (Get-Variable -Name IsWindows -ErrorAction SilentlyContinue) { if ($IsWindows) { return 'windows' } if ($IsMacOS) { return 'darwin' } if ($IsLinux) { return 'linux' } return 'unknown' } return 'windows' } # {tenant} {source} {device} {run_id} {date} in a URL or path are substituted per run. # Without this, a fixed destination would have every run overwrite the previous one. function Expand-NinjaOneLogTemplate { param([string]$Template, [string]$Tenant, [string]$Source, [string]$Device, [string]$RunId) $out = $Template $out = $out.Replace('{tenant}', $Tenant).Replace('{source}', $Source) $out = $out.Replace('{device}', $Device).Replace('{run_id}', $RunId) $out.Replace('{date}', (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd')) } <# .SYNOPSIS Start a logging run. Pick exactly one destination: -UploadUrl, -BrokerUrl, or -Path. .EXAMPLE # Direct to any endpoint you already have — no broker, no extra infrastructure. Initialize-NinjaOneLogger -Source 'disk-report' -UploadUrl 'https://my.webhook/ingest' .EXAMPLE # Direct to an Azure Blob SAS URL you generated yourself. Initialize-NinjaOneLogger -Source 'disk-report' ` -UploadUrl 'https://acct.blob.core.windows.net/logs/{device}/{run_id}.ndjson?<sas>' ` -Method PUT -Headers @{ 'x-ms-blob-type' = 'BlockBlob' } .EXAMPLE # Via a NinjaOneLogger broker (no long-lived credential on the endpoint). Initialize-NinjaOneLogger -Source 'disk-report' -BrokerUrl 'https://broker' -Token '<token>' .EXAMPLE # Local file only, no network. Initialize-NinjaOneLogger -Source 'disk-report' -Path 'C:\Logs\{source}-{date}.ndjson' #> function Initialize-NinjaOneLogger { [CmdletBinding(DefaultParameterSetName = 'Broker')] param( [Parameter(Mandatory)][string]$Source, # --- Direct mode: your endpoint, your rules. Independent of any broker. --- [Parameter(Mandatory, ParameterSetName = 'Direct')][string]$UploadUrl, [Parameter(ParameterSetName = 'Direct')][ValidateSet('POST', 'PUT')][string]$Method = 'POST', [Parameter(ParameterSetName = 'Direct')][hashtable]$Headers, [Parameter(ParameterSetName = 'Direct')][string]$ContentType = 'application/x-ndjson', # --- Broker mode: adds short-lived, write-only upload URLs. --- [Parameter(ParameterSetName = 'Broker')][string]$BrokerUrl = $env:NINJAONELOG_BROKER_URL, [Parameter(ParameterSetName = 'Broker')][string]$Token = $env:NINJAONELOG_TOKEN, # --- File mode: local disk, no network. --- [Parameter(Mandatory, ParameterSetName = 'File')][string]$Path, # --- common --- # COMPUTERNAME only exists on Windows; MachineName keeps PS 7 on Linux/macOS working. [string]$Device = $(if ($env:NINJAONELOG_DEVICE) { $env:NINJAONELOG_DEVICE } elseif ($env:COMPUTERNAME) { $env:COMPUTERNAME } else { [Environment]::MachineName }), [string]$Tenant = $env:NINJAONELOG_TENANT, [string]$SpoolPath = (Join-Path $(if ($env:ProgramData) { $env:ProgramData } else { [IO.Path]::GetTempPath() }) 'NinjaOneLogger/spool'), [int]$MaxSpoolFiles = 500 ) $mode = $PSCmdlet.ParameterSetName if ($mode -eq 'Broker') { # Default set, so an unconfigured caller lands here — say what's missing and # that a broker isn't the only option. if (-not $BrokerUrl) { throw "NinjaOneLogger: no destination. Use -UploadUrl <your endpoint>, -Path <file>, or -BrokerUrl (or set NINJAONELOG_BROKER_URL)." } if (-not $Token) { throw "NinjaOneLogger: -Token not set (or NINJAONELOG_TOKEN) for broker mode." } } # Validated in every mode. Device, Source and Tenant are all substituted into # -UploadUrl and -Path, so the allow-list is what stops URL/path injection here, # not just a broker 400. The (?=.*[A-Za-z0-9]) lookahead rejects dot-only values # ("." / ".."), which would otherwise traverse directories in File mode. $nameRule = '^(?=.*[A-Za-z0-9])[A-Za-z0-9._-]{1,64}$' if ($Device -notmatch $nameRule) { throw "NinjaOneLogger: Device '$Device' is invalid (must match $nameRule). Pass -Device or set NINJAONELOG_DEVICE." } if ($Source -notmatch $nameRule) { throw "NinjaOneLogger: Source '$Source' is invalid (must match $nameRule)." } # Tenant is optional (it is informational on the record), so only validate a value. if ($Tenant -and $Tenant -notmatch $nameRule) { throw "NinjaOneLogger: Tenant '$Tenant' is invalid (must match $nameRule)." } # Windows PowerShell 5.1 may negotiate TLS 1.0 by default; force 1.2. if ($mode -ne 'File') { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } $script:NinjaOneLogState = [pscustomobject]@{ Mode = $mode BrokerUrl = $(if ($BrokerUrl) { $BrokerUrl.TrimEnd('/') } else { $null }) Token = $Token UploadUrl = $UploadUrl Method = $Method Headers = $(if ($Headers) { $Headers } else { @{} }) ContentType = $ContentType Path = $Path Source = $Source Device = $Device Tenant = $Tenant RunId = [guid]::NewGuid().ToString() Os = Get-NinjaOneLogOsName Records = New-Object System.Collections.Generic.List[string] SpoolPath = $SpoolPath MaxSpoolFiles = $MaxSpoolFiles } Write-Verbose "NinjaOneLogger initialised: mode=$mode source=$Source device=$Device run=$($script:NinjaOneLogState.RunId)" } function Write-NinjaOneLogRecord { [CmdletBinding()] param([Parameter(Mandatory, ValueFromPipeline)]$InputObject) begin { if (-not $script:NinjaOneLogState) { throw "NinjaOneLogger: call Initialize-NinjaOneLogger first." } } process { $s = $script:NinjaOneLogState $envelope = [ordered]@{ ts = (Get-Date).ToUniversalTime().ToString('o') tenant = $s.Tenant device = $s.Device source = $s.Source run_id = $s.RunId schema = 1 os = $s.Os data = $InputObject } # -Compress keeps each record on one line: NDJSON = one JSON object per line. $s.Records.Add(($envelope | ConvertTo-Json -Compress -Depth 20)) } } <# .SYNOPSIS Flush the buffered records to the destination chosen in Initialize-NinjaOneLogger. .PARAMETER PassThru Also emit the NDJSON text, so you can hand it to anything this module doesn't cover. #> function Send-NinjaOneLog { [CmdletBinding()] param([switch]$KeepBuffer, [switch]$PassThru) $s = $script:NinjaOneLogState if (-not $s) { throw "NinjaOneLogger: call Initialize-NinjaOneLogger first." } if ($s.Records.Count -eq 0) { Write-Verbose 'NinjaOneLogger: nothing to send.'; return } $ndjson = ($s.Records -join "`n") + "`n" $bytes = [Text.Encoding]::UTF8.GetBytes($ndjson) try { if ($s.Mode -ne 'File') { Send-NinjaOneLogSpool } # drain older spool first Invoke-NinjaOneLogUpload -Bytes $bytes -Source $s.Source -Device $s.Device -RunId $s.RunId Write-Verbose "NinjaOneLogger: delivered $($s.Records.Count) record(s) via $($s.Mode)." if (-not $KeepBuffer) { $s.Records.Clear() } } catch { Write-Warning "NinjaOneLogger: delivery failed ($($_.Exception.Message)); spooling locally." Save-NinjaOneLogSpool -Bytes $bytes -RunId $s.RunId } if ($PassThru) { $ndjson } } function Send-NinjaOneLogSpool { [CmdletBinding()] param() $s = $script:NinjaOneLogState if (-not $s -or -not (Test-Path $s.SpoolPath)) { return } foreach ($f in Get-ChildItem -Path $s.SpoolPath -Filter '*.ndjson' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime) { try { $bytes = [IO.File]::ReadAllBytes($f.FullName) $runId = [IO.Path]::GetFileNameWithoutExtension($f.Name) # Re-file under the source/device recorded in the spooled record itself, # not this run's — the spool is shared across scripts, so a drain from a # different source must not mis-partition another script's data. $src = $s.Source; $dev = $s.Device try { $first = ([Text.Encoding]::UTF8.GetString($bytes) -split "`n", 2)[0] if ($first) { $rec = $first | ConvertFrom-Json if ($rec.PSObject.Properties['source'] -and $rec.source) { $src = $rec.source } if ($rec.PSObject.Properties['device'] -and $rec.device) { $dev = $rec.device } } } catch { } Invoke-NinjaOneLogUpload -Bytes $bytes -Source $src -Device $dev -RunId $runId Remove-Item $f.FullName -Force Write-Verbose "NinjaOneLogger: flushed spooled $($f.Name) (source=$src)." } catch { Write-Verbose "NinjaOneLogger: spool $($f.Name) still failing; leaving in place."; break } } } # --- internal helpers ------------------------------------------------------- # Servers explain *why* they rejected a request in the response body # ({"error":"invalid 'device'"}). Without this the caller only ever sees # "400 (Bad Request)", which is useless when debugging a remote endpoint. function Get-NinjaOneLogHttpDetail { param($ErrorRecord) # PS 7 exposes the body on ErrorDetails; 5.1 needs the raw response stream. try { if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) { return $ErrorRecord.ErrorDetails.Message } } catch { } try { $resp = $ErrorRecord.Exception.Response if ($resp) { $reader = New-Object IO.StreamReader($resp.GetResponseStream()) try { return $reader.ReadToEnd() } finally { $reader.Dispose() } } } catch { } return '' } # Content-Type must go through -ContentType on Windows PowerShell 5.1; everything # else goes via -Headers. Splitting here keeps both HTTP senders 5.1-safe. function Send-NinjaOneLogHttp { param([string]$Uri, [string]$HttpMethod, [byte[]]$Bytes, [hashtable]$HeaderMap, [string]$FallbackContentType) $contentType = $FallbackContentType $headers = @{} foreach ($k in $HeaderMap.Keys) { if ("$k" -ieq 'Content-Type') { $contentType = $HeaderMap[$k] } else { $headers[$k] = $HeaderMap[$k] } } Invoke-WebRequest -Method $HttpMethod -Uri $Uri -Body $Bytes -ContentType $contentType ` -Headers $headers -TimeoutSec 60 -UseBasicParsing | Out-Null } function Invoke-NinjaOneLogUpload { param([byte[]]$Bytes, [string]$Source, [string]$Device, [string]$RunId) $s = $script:NinjaOneLogState switch ($s.Mode) { 'Direct' { Invoke-NinjaOneLogDirect -Bytes $Bytes -Source $Source -Device $Device -RunId $RunId } 'File' { Save-NinjaOneLogToFile -Bytes $Bytes -Source $Source -Device $Device -RunId $RunId } default { Invoke-NinjaOneLogBroker -Bytes $Bytes -Source $Source -Device $Device -RunId $RunId } } } # Direct: no handshake, no broker — just deliver the bytes to the caller's endpoint. function Invoke-NinjaOneLogDirect { param([byte[]]$Bytes, [string]$Source, [string]$Device, [string]$RunId) $s = $script:NinjaOneLogState $uri = Expand-NinjaOneLogTemplate -Template $s.UploadUrl -Tenant $s.Tenant ` -Source $Source -Device $Device -RunId $RunId try { Send-NinjaOneLogHttp -Uri $uri -HttpMethod $s.Method -Bytes $Bytes ` -HeaderMap $s.Headers -FallbackContentType $s.ContentType } catch { $detail = Get-NinjaOneLogHttpDetail $_ if ($detail) { throw "endpoint rejected the upload: $($_.Exception.Message) - $detail" } throw } } # Broker: swap a tenant token for a short-lived, write-only URL, then upload to it. function Invoke-NinjaOneLogBroker { param([byte[]]$Bytes, [string]$Source, [string]$Device, [string]$RunId) $s = $script:NinjaOneLogState $body = @{ device = $Device; source = $Source; run_id = $RunId; content_type = 'application/x-ndjson' } | ConvertTo-Json try { $slot = Invoke-RestMethod -Method Post -Uri "$($s.BrokerUrl)/v1/upload-url" ` -Headers @{ Authorization = "Bearer $($s.Token)" } -ContentType 'application/json' -Body $body -TimeoutSec 30 } catch { $detail = Get-NinjaOneLogHttpDetail $_ if ($detail) { throw "broker rejected the request: $($_.Exception.Message) - $detail" } throw } # Replay exactly the headers the broker returned, so one client works against any # backend (S3 = Content-Type; Azure Blob = Content-Type + x-ms-blob-type). $headerMap = @{} if ($slot.PSObject.Properties['headers'] -and $slot.headers) { foreach ($h in $slot.headers.PSObject.Properties) { $headerMap[$h.Name] = $h.Value } } try { Send-NinjaOneLogHttp -Uri $slot.url -HttpMethod 'PUT' -Bytes $Bytes ` -HeaderMap $headerMap -FallbackContentType 'application/x-ndjson' } catch { # Storage errors (expired SAS, missing header, wrong content type) come back # as XML/JSON in the body; surfacing it beats a bare 403. $detail = Get-NinjaOneLogHttpDetail $_ if ($detail) { throw "storage rejected the upload: $($_.Exception.Message) - $detail" } throw } } function Save-NinjaOneLogToFile { param([byte[]]$Bytes, [string]$Source, [string]$Device, [string]$RunId) $s = $script:NinjaOneLogState $target = Expand-NinjaOneLogTemplate -Template $s.Path -Tenant $s.Tenant ` -Source $Source -Device $Device -RunId $RunId $dir = Split-Path $target -Parent if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } # Append: several runs may target the same file when the template has no {run_id}. $fs = [IO.File]::Open($target, [IO.FileMode]::Append, [IO.FileAccess]::Write, [IO.FileShare]::Read) try { $fs.Write($Bytes, 0, $Bytes.Length) } finally { $fs.Dispose() } } function Save-NinjaOneLogSpool { param([byte[]]$Bytes, [string]$RunId) $s = $script:NinjaOneLogState if (-not (Test-Path $s.SpoolPath)) { New-Item -ItemType Directory -Path $s.SpoolPath -Force | Out-Null } $existing = @(Get-ChildItem -Path $s.SpoolPath -Filter '*.ndjson' -ErrorAction SilentlyContinue) if ($existing.Count -ge $s.MaxSpoolFiles) { Write-Warning "NinjaOneLogger: spool full ($($s.MaxSpoolFiles)); dropping oldest." $existing | Sort-Object LastWriteTime | Select-Object -First 1 | Remove-Item -Force -ErrorAction SilentlyContinue } [IO.File]::WriteAllBytes((Join-Path $s.SpoolPath "$RunId.ndjson"), $Bytes) } Export-ModuleMember -Function Initialize-NinjaOneLogger, Write-NinjaOneLogRecord, Send-NinjaOneLog, Send-NinjaOneLogSpool |