# Report-AssetToShopDB.ps1
#
# Reports a PC's identity to ShopDB (Flask) so the computers/machines record
# stays current with whatever the PC actually is right now: hostname, BIOS
# serial, pc-type, logged-in user, DNC machine number (2001, 2002, ... when
# present) and its corp/AESFMA IPv4 address.
#
# TARGET: the ADR-006 collector API.
#     POST <shopdb>/api/collector/computers
# The server is NOT baked in. It comes from HKLM:\SOFTWARE\GE\ShopDB BaseUrl,
# which Install-GEEnforce.ps1 provisions and the enforcement client already
# needs, or from -ApiUrl in the manifest entry's Args. ADR-015: a site name in
# product code is a defect, and this script ships to every site.
# The computers plugin's apply_collector_payload upserts idempotently by
# hostname (create if missing, patch-style update if present - only the fields
# posted here change, so it never clobbers model/VNC/WinRM). Machine number maps
# to Asset.assetnumber; the placeholder 9999 is skipped server-side.
#
# AUTH: the collector API does NOT honor the GE-Enforce IP allowlist (that only
# covers the geenforce fetch/report endpoints). It needs a collector.ingest key,
# sent as the X-API-Key header. The key is read from HKLM:\SOFTWARE\GE\ShopDB
# CollectorKey (same secret store the GE-Enforce client uses; provisioned at
# imaging), or overridden via the manifest entry's Args -ApiKey. Never bake the
# key into the manifest JSON on the share.
#
# Deployed in common\ (runs on EVERY shopfloor pc-type). Non-DNC bays report
# identity with no machineNo, so no PC-to-machine link is built - by design.
# Runs every GE-Enforce cycle as a Type=PS1 / DetectionMethod=Always entry under
# the SYSTEM task. Always exits 0 so "last run result" stays clean; failures are
# logged, never thrown.
#
# WHY ONE NIC ONLY:
#   Some bays carry two NICs - a private controller NIC and the routable
#   corporate NIC. Only the routable one belongs in ShopDB. A site may name its
#   corporate ranges (-AllowedRanges, or the CollectorRanges registry value); with
#   none configured the NIC carrying the DEFAULT ROUTE is used, which expresses
#   the same intent without knowing any site's addressing.

param(
    # Flask collector endpoint for the computers plugin. Empty resolves from
    # HKLM:\SOFTWARE\GE\ShopDB BaseUrl; override here if the path ever moves.
    [string]$ApiUrl = '',

    # Comma-separated CIDRs naming this site's routable ranges, e.g.
    # '10.20.0.0/23,10.21.4.0/26'. Empty uses the default-route NIC instead.
    [string]$AllowedRanges = '',

    # collector.ingest key (X-API-Key). Default: read from the GE-Enforce secret
    # store in the registry. Override with -ApiKey via Args for testing.
    [string]$ApiKey = '',

    [int]$TimeoutSec = 30
)

$ErrorActionPreference = 'Continue'

# Force TLS 1.2 - older images default to SystemDefault which may negotiate a
# protocol the site rejects; the collector POST is HTTPS.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

$logDir = 'C:\Logs\Shopfloor'
if (-not (Test-Path $logDir)) {
    New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
}
$logFile = Join-Path $logDir ('report-asset-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))

function Log([string]$msg) {
    $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
    "$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
}

# Server from the GE-Enforce config hive when not passed via Args. Any site
# running this script is running the enforcement client, which cannot work
# without BaseUrl, so it is present wherever this is deployed.
if (-not $ApiUrl) {
    foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
        try {
            if (Test-Path $regPath) {
                $base = [string](Get-ItemProperty -Path $regPath -Name BaseUrl -ErrorAction Stop).BaseUrl
                if ($base -and $base.Trim()) {
                    $ApiUrl = $base.Trim().TrimEnd('/') + '/api/collector/computers'
                    break
                }
            }
        } catch {}
    }
}
if (-not $ApiUrl) {
    Log 'ERROR no ShopDB URL (HKLM:\SOFTWARE\GE\ShopDB BaseUrl or -ApiUrl). Skipping.'
    exit 0
}

# A site may name its routable ranges rather than rely on the default route.
if (-not $AllowedRanges) {
    foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
        try {
            if (Test-Path $regPath) {
                $v = [string](Get-ItemProperty -Path $regPath -Name CollectorRanges -ErrorAction Stop).CollectorRanges
                if ($v -and $v.Trim()) { $AllowedRanges = $v.Trim(); break }
            }
        } catch {}
    }
}

# collector key from the GE-Enforce secret store when not passed via Args.
if (-not $ApiKey) {
    foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
        try {
            if (Test-Path $regPath) {
                $v = [string](Get-ItemProperty -Path $regPath -Name CollectorKey -ErrorAction Stop).CollectorKey
                if ($v -and $v.Trim()) { $ApiKey = $v.Trim(); break }
            }
        } catch {}
    }
}
if (-not $ApiKey) {
    Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
    exit 0
}

# Routable ranges, if this site named any. NO SITE ADDRESSING IS BAKED IN: an
# unconfigured site falls through to the default-route NIC below (ADR-015).
#
# NOT named $allowedRanges: PowerShell variable names are case-insensitive, so
# that collides with the [string] parameter above and the array is silently
# COERCED to a string. .Count on a string is 1, so the script then believes a
# range is configured and never falls back to the default route.
$rangeList = @()
foreach ($cidr in ($AllowedRanges -split ',')) {
    $cidr = $cidr.Trim()
    if (-not $cidr) { continue }
    $parts = $cidr -split '/'
    if ($parts.Count -ne 2) { Log "WARN ignoring malformed range '$cidr'"; continue }
    $rangeList += @{ Network = $parts[0].Trim(); PrefixLen = [int]$parts[1] }
}

function ConvertTo-Uint32([string]$ip) {
    $bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes()
    [Array]::Reverse($bytes)
    return [BitConverter]::ToUInt32($bytes, 0)
}

function Test-InAllowedRange([string]$ip) {
    try {
        $ipInt = ConvertTo-Uint32 $ip
        foreach ($r in $rangeList) {
            $netInt = ConvertTo-Uint32 $r.Network
            $mask   = [uint32]([math]::Pow(2, 32) - [math]::Pow(2, 32 - $r.PrefixLen))
            if (($ipInt -band $mask) -eq ($netInt -band $mask)) { return $true }
        }
    } catch {}
    return $false
}

Log '=== Report asset to ShopDB (collector) ==='

# hostname - the collector identity field. required.
$hostname = $env:COMPUTERNAME

# BIOS serial - optional now (collector keys on hostname). Sent when present.
$serialNumber = ''
try {
    $serialNumber = (Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber
    if ($serialNumber) { $serialNumber = $serialNumber.Trim() }
} catch {
    Log "WARN could not read BIOS serial: $($_.Exception.Message)"
}

# Machine identifier - optional, sent only if found. Maps to Asset.assetnumber
# server-side (9999 placeholder skipped there and here).
#   1. eDNC registry (WOW6432Node, then native) - DNC/collections bays (2001...).
#   2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3).
#   3. C:\Enrollment\machine-number.txt - imaging value.
# keyence / genspect / part-marker have no per-bay id -> no machineNo sent.
# Those bays name their DEVICE instead, in asset-id.txt below.
$machineNo = ''
foreach ($regPath in @(
    'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
    'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General'
)) {
    if ($machineNo) { break }
    try {
        if (Test-Path $regPath) {
            $v = [string](Get-ItemProperty -Path $regPath -Name MachineNo -ErrorAction Stop).MachineNo
            if ($v -and $v.Trim() -ne '9999') { $machineNo = $v.Trim() }
        }
    } catch {
        Log "WARN could not read MachineNo from ${regPath}: $($_.Exception.Message)"
    }
}
if (-not $machineNo) {
    $cmmFile = 'C:\Enrollment\cmm\cmmid.txt'
    if (Test-Path -LiteralPath $cmmFile) {
        try {
            $v = ([string](Get-Content -LiteralPath $cmmFile -First 1 -ErrorAction Stop)).Trim()
            if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $cmmFile (CMM bay id): $machineNo" }
        } catch { Log "WARN could not read ${cmmFile}: $($_.Exception.Message)" }
    }
}
if (-not $machineNo) {
    $mnFile = 'C:\Enrollment\machine-number.txt'
    if (Test-Path -LiteralPath $mnFile) {
        try {
            $v = ([string](Get-Content -LiteralPath $mnFile -First 1 -ErrorAction Stop)).Trim()
            if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $mnFile (imaging value): $machineNo" }
        } catch { Log "WARN could not read ${mnFile}: $($_.Exception.Message)" }
    }
}

# OS name string (caption + feature-update + build), e.g.
# "Microsoft Windows 11 Enterprise 23H2 (build 22631)". Server upserts each
# distinct string into operatingsystems.
$osVersion = ''
$lastBootTime = ''
try {
    $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
    $osVersion = "$($os.Caption)".Trim()
    $displayVersion = ''
    try {
        $displayVersion = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion
    } catch {}
    if ($displayVersion)  { $osVersion += " $displayVersion" }
    if ($os.BuildNumber)  { $osVersion += " (build $($os.BuildNumber))" }
    $osVersion = $osVersion.Trim()
    # ISO 8601 - collector parses via datetime.fromisoformat.
    try { $lastBootTime = $os.LastBootUpTime.ToString('yyyy-MM-ddTHH:mm:ss') } catch {}
} catch {}

# interactive console user. runs as SYSTEM, so use Win32_ComputerSystem.UserName
# (console session owner). empty when nobody logged on -> omitted so an
# unattended bay does not blank the last-known user.
$loggedInUser = ''
try {
    $loggedInUser = (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).UserName
    if ($loggedInUser) { $loggedInUser = ($loggedInUser -split '\\')[-1].Trim() }
} catch {
    Log "WARN could not read logged-in user: $($_.Exception.Message)"
}

# The DEVICE hanging off this PC - the ONE enrollment file for every bay that
# has no NTLARS/eDNC MachineNo registry to identify it. It names the asset and
# nothing else: it does not say what kind of device that is, because the pc-type
# already does, so the same file serves a part marker, a Keyence, a Genspect or
# anything added later without a new file or a client change.
#
# Deliberately NOT machine-number.txt. machinenumber answers "which bay is
# this" and is what GE-Enforce TargetMachineNumbers gates on, so naming a device
# there would silently stop every bay-gated manifest entry from matching.
#
# WHY A FILE AT ALL: every other identity the server can fall back to is derived
# from the PC - a prior link from this PC, or an asset number built from this
# hostname - so none of them survive a PC SWAP. The replacement box mints a
# SECOND record for the same physical device while the first keeps its history
# under a dead PC's name. This file is what survives.
#
# The server ADOPTS the named asset, refuses a value it cannot resolve, and
# refuses one that resolves to the wrong kind of thing - so a typo warns instead
# of inventing a phantom device.
$deviceId = ''
$idFile = 'C:\Enrollment\asset-id.txt'
if (Test-Path -LiteralPath $idFile) {
    try {
        $deviceId = ([string](Get-Content -LiteralPath $idFile -First 1 -ErrorAction Stop)).Trim()
        if ($deviceId) { Log "deviceId from ${idFile}: $deviceId" }
    } catch { Log "WARN could not read ${idFile}: $($_.Exception.Message)" }
}
# 0.12.0 shipped measuringtool-id.txt before the file was generalised. Read it
# as a fallback so a bay already staged with one keeps reporting.
if (-not $deviceId) {
    $mtFile = 'C:\Enrollment\measuringtool-id.txt'
    if (Test-Path -LiteralPath $mtFile) {
        try {
            $deviceId = ([string](Get-Content -LiteralPath $mtFile -First 1 -ErrorAction Stop)).Trim()
            if ($deviceId) { Log "deviceId from ${mtFile} (pre-0.13 name): $deviceId" }
        } catch { Log "WARN could not read ${mtFile}: $($_.Exception.Message)" }
    }
}

# imaging pc-type (gea-shopfloor-*), read from the enrollment file. Sent only
# when present; absent -> server leaves existing pctype untouched (a bare report
# never re-types a PC). Unmapped values return a warning, not an error.
$pcType = ''
$ptFile = 'C:\Enrollment\pc-type.txt'
if (Test-Path -LiteralPath $ptFile) {
    try {
        $pcType = (Get-Content -LiteralPath $ptFile -First 1 -ErrorAction Stop).Trim()
    } catch { Log "WARN could not read ${ptFile}: $($_.Exception.Message)" }
}

# PC make/model from WMI. Server resolves/creates vendor + model. Sent only when
# present so a WMI read failure does not blank the model.
$manufacturer = ''
$model = ''
try {
    $cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
    $manufacturer = "$($cs.Manufacturer)".Trim()
    $model        = "$($cs.Model)".Trim()
} catch {
    Log "WARN could not read make/model: $($_.Exception.Message)"
}

# The routable IPv4 - a physical, connected NIC. The collector schema takes a
# single ipaddress string, so report the corporate NIC and drop any
# controller/machine-LAN NIC. With ranges configured the IP must be in one;
# with none, the NIC carrying the default route is the routable one by
# definition, which needs no knowledge of a site's addressing.
$corpIp = ''
$defaultRouteIfIndexes = @()
if ($rangeList.Count -eq 0) {
    try {
        $defaultRouteIfIndexes = @(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop |
                                   Sort-Object RouteMetric |
                                   Select-Object -ExpandProperty InterfaceIndex -Unique)
    } catch {
        Log "WARN could not read the route table: $($_.Exception.Message)"
    }
}
try {
    $ipObjs = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop |
              Where-Object { $_.IPAddress -notmatch '^169\.254' -and $_.IPAddress -ne '127.0.0.1' }
    foreach ($ipo in $ipObjs) {
        if ($corpIp) { break }
        $adapter = $null
        try { $adapter = Get-NetAdapter -InterfaceIndex $ipo.InterfaceIndex -ErrorAction Stop } catch {}
        if (-not $adapter) { continue }
        if (-not $adapter.HardwareInterface) { continue }
        if ($adapter.Status -ne 'Up') { continue }
        if ($rangeList.Count -gt 0) {
            if (Test-InAllowedRange $ipo.IPAddress) { $corpIp = $ipo.IPAddress }
        } elseif ($defaultRouteIfIndexes -contains $ipo.InterfaceIndex) {
            $corpIp = $ipo.IPAddress
        }
    }
} catch {
    Log "WARN interface enumeration failed: $($_.Exception.Message)"
}
if (-not $corpIp) { Log 'WARN no routable IPv4 NIC found; posting identity without ipaddress.' }

# collector schema fields (lowercase concatenated). hostname is required; the
# rest are sent only when present so a partial read never blanks a good value.
$body = @{ hostname = $hostname }
if ($serialNumber) { $body['serialnumber'] = $serialNumber }
if ($machineNo)    { $body['machinenumber'] = $machineNo }
if ($pcType)       { $body['pctype']        = $pcType }
if ($deviceId)     { $body['deviceid']      = $deviceId }
if ($manufacturer) { $body['vendorname']    = $manufacturer }
if ($model)        { $body['modelnumber']   = $model }
if ($osVersion)    { $body['osname']        = $osVersion }
if ($lastBootTime) { $body['lastboottime']  = $lastBootTime }
if ($loggedInUser) { $body['loggedinuser']  = $loggedInUser }
if ($corpIp)       { $body['ipaddress']     = $corpIp }
$body['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss')

$json = $body | ConvertTo-Json -Compress -Depth 4

Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10} deviceId={11}" -f `
    $ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp, $deviceId)

try {
    $resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
        -ContentType 'application/json' `
        -Headers @{ 'X-API-Key' = $ApiKey } `
        -TimeoutSec $TimeoutSec -ErrorAction Stop
    Log ("RESPONSE {0}" -f ($resp | ConvertTo-Json -Compress -Depth 4))
} catch {
    Log "ERROR POST failed: $($_.Exception.Message)"
}

exit 0
