diff --git a/frontend/src/utils/siteSettings.js b/frontend/src/utils/siteSettings.js index a333a950..7edf5914 100644 --- a/frontend/src/utils/siteSettings.js +++ b/frontend/src/utils/siteSettings.js @@ -115,6 +115,21 @@ export async function getServicenowUrls() { return { enabled, incidentUrl, changeUrl, searchUrl } } +// Deep links into the Azure portal for a device ShopDB knows the Entra ids of. +// +// Both are TEMPLATES held in settings rather than built here, because the host +// is a tenant fact: this site is on the US Government cloud (portal.azure.us) +// and the commercial cloud is portal.azure.com. Microsoft also moves these +// blade paths from time to time, and a site can fix that itself by editing a +// setting instead of waiting for a release. +// +// Empty is the default and means "no link" - the id still renders as text. +export async function getEntraDeviceUrls() { + const entraUrl = await getSetting('entra_device_url', '') + const intuneUrl = await getSetting('intune_device_url', '') + return { entraUrl, intuneUrl } +} + // Printer hostname template. {ip} is replaced with the dash-separated IP. export async function getPrinterHostnameTemplate() { return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net') // ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override. diff --git a/plugins/computers/client/Report-AssetToShopDB.ps1 b/plugins/computers/client/Report-AssetToShopDB.ps1 index 40b3079e..a673dc9d 100644 --- a/plugins/computers/client/Report-AssetToShopDB.ps1 +++ b/plugins/computers/client/Report-AssetToShopDB.ps1 @@ -31,10 +31,11 @@ # # 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. +# corporate NIC. This script reports ALL of them and lets the server decide +# which is the PC's address, from the collector_corporate_ranges setting. +# Choosing here was wrong: a bay cannot know a site's addressing, and a CMM +# whose 10Gbit controller link carries a default route reported the +# machine-side address as the PC's - with the corporate one never sent. param( # Flask collector endpoint for the computers plugin. Empty resolves from @@ -90,18 +91,6 @@ if (-not $ApiUrl) { 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')) { @@ -118,38 +107,13 @@ if (-not $ApiKey) { 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 +# -AllowedRanges and the CollectorRanges registry value are ACCEPTED AND +# IGNORED. Deciding which NIC is the PC's address moved to the server, where +# the site's addressing actually lives: this script now reports every NIC and +# takes no view. The parameter stays so a scheduled task still passing it does +# not fail on an unknown argument. +if ($AllowedRanges) { + Log 'NOTE -AllowedRanges is ignored; set collector_corporate_ranges in ShopDB settings instead.' } Log '=== Report asset to ShopDB (collector) ===' @@ -299,42 +263,48 @@ try { 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 = '' +# EVERY connected hardware NIC with a routable IPv4, and no opinion about +# which one matters. Choosing used to happen here and this was the wrong place +# for it: a bay cannot know a site's addressing. A CMM whose Marvell AQtion +# 10Gbit link to its controller sits on 100.0.0.1 reported THAT as the PC's +# address - the link carries a default route too - and the corporate address +# was never sent at all, so ShopDB could not even show it as a second row. +# +# The server picks the primary now, from the site's configured corporate +# ranges, and records the rest. Report what is here; let it decide. +$interfaces = @() $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 { + $defaultRouteIfIndexes = @(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop | + 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 + $interfaces += @{ + ipaddress = $ipo.IPAddress + macaddress = $adapter.MacAddress + adaptername = $adapter.InterfaceDescription + isdefaultroute = [bool]($defaultRouteIfIndexes -contains $ipo.InterfaceIndex) } } } catch { Log "WARN interface enumeration failed: $($_.Exception.Message)" } -if (-not $corpIp) { Log 'WARN no routable IPv4 NIC found; posting identity without ipaddress.' } +if ($interfaces.Count -eq 0) { + Log 'WARN no routable IPv4 NIC found; posting identity without any interface.' +} else { + Log ("Reporting {0} NIC(s): {1}" -f $interfaces.Count, + (($interfaces | ForEach-Object { $_.ipaddress }) -join ', ')) +} # collector schema fields (lowercase concatenated). hostname is required; the # rest are sent only when present so a partial read never blanks a good value. @@ -348,7 +318,7 @@ 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 } +if ($interfaces.Count -gt 0) { $body['interfaces'] = $interfaces } $body['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') $json = $body | ConvertTo-Json -Compress -Depth 4 diff --git a/plugins/computers/frontend/views/PCDetail.vue b/plugins/computers/frontend/views/PCDetail.vue index 044691dc..820aa606 100644 --- a/plugins/computers/frontend/views/PCDetail.vue +++ b/plugins/computers/frontend/views/PCDetail.vue @@ -196,13 +196,35 @@ >

Entra Device Info

+
Device ID - {{ computer.computer.azuredeviceid }} + + {{ computer.computer.azuredeviceid }} + Intune +
Object ID - {{ computer.computer.azureobjectid }} + + {{ computer.computer.azureobjectid }} + Entra +
Device Category @@ -342,6 +364,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue' import AssetDetailTabs from '@/components/AssetDetailTabs.vue' import AssetRelationships from '@/components/AssetRelationships.vue' import { useIdentifierFlags } from '@/composables/identifierSettings' +import { getEntraDeviceUrls } from '@/utils/siteSettings' import { setRecordTitle } from '@/composables/pageTitle' import { useToast } from '@/composables/toast' import { apiError } from '@/utils/apiError' @@ -374,6 +397,22 @@ const assetPanels = ref(null) // which is the answer somebody arriving from a search or a link came for. const activegroup = ref('details') +// Azure portal deep links, built from templates a site configures. Empty +// templates mean no link at all, which is the default: the host differs per +// tenant (this site is on the US Government cloud) and Microsoft moves these +// blade paths, so guessing one would send people to a dead page. +const entraUrlTemplate = ref('') +const intuneUrlTemplate = ref('') + +function fillTemplate(template, token, value) { + if (!template || !value) return null + return template.split(token).join(encodeURIComponent(value)) +} +const entraDeviceLink = computed(() => fillTemplate( + entraUrlTemplate.value, '{objectid}', computer.value?.computer?.azureobjectid)) +const intuneDeviceLink = computed(() => fillTemplate( + intuneUrlTemplate.value, '{deviceid}', computer.value?.computer?.azuredeviceid)) + // Only offer a tab that has something in it. An empty tab is worse than an // absent one - it reads as broken rather than as "this site does not collect // that". Details always shows because core fills it regardless of plugins. @@ -451,6 +490,13 @@ function relativeAge(value) { } onMounted(async () => { + // Non-critical: a site that has not configured these just gets no link, so a + // failure here must not stop the page loading. + getEntraDeviceUrls().then(({ entraUrl, intuneUrl }) => { + entraUrlTemplate.value = entraUrl + intuneUrlTemplate.value = intuneUrl + }).catch(() => {}) + try { const response = await computersApi.get(route.params.id) computer.value = response.data.data @@ -606,6 +652,18 @@ async function confirmRetire() {