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