Private/Kinds/AppConnection.ps1
|
# The AppConnection Kind: the database server and the web service an application is # configured to talk to, read out of the application's own configuration. # # A client-server business application that is slow or will not start is, more often than # not, waiting for its SQL Server. Which Server that is differs per Customer, so no Check # Definition can name it - but the application's configuration file does, on every machine # it is installed on. A Check Definition names the file and where in it the Server stands; # this Kind reads that and measures what it finds. # # Built for, and observed on 2026-10-09 with, a .NET application that keeps one # <ConnectionFileItem> per connection in an XML file under %APPDATA%, each with a Server, a # Database and a WebServiceAddress - and with a Username and a Password beside them. # # Credentials. This Kind never reads one. From an XML file it takes the nodes a Check # Definition names and no others; from a connection string only the keys that name the # Server and the database (ConvertFrom-AppConnectionString). Nothing it does needs to sign # in: what it asks a SQL Server is what the server tells anybody who reaches its port. # # What is measured: # # - Reachability, by the Server Kind and judged by it (name resolution, TCP connects, # ping), the way the Odbc Kind hands over the Servers it finds. A web service address # is a host and a port like any other. # - The SQL Server's pre-login answer. The first packet of a TDS connection is answered # before any sign-in with the server's version and whether it encrypts # ([MS-TDS] 2.2.6.5 PRELOGIN). Observed: a SQL Server 2019 answered "15.0.x" and # encryption option 0 in 31 ms. A port that is open and does not answer this is not a # SQL Server, or one that is too busy to talk - either is worth knowing. # - For a named instance without a port, the port the SQL Server Browser gives out on # UDP 1434 ([MC-SQLR] CLNT_UCAST_INST), which is how every client finds it. # Which keys of a connection string name the Server and the database. Every other key is # dropped unread - among them the ones that carry credentials. $script:AppConnectionServerKeys = @('data source', 'server', 'address', 'addr', 'network address') $script:AppConnectionDatabaseKeys = @('initial catalog', 'database') function ConvertFrom-AppConnectionString { <# .SYNOPSIS The Server and the database a connection string names, and nothing else of it. Pure. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$ConnectionString) $server = $null $database = $null foreach ($pair in ("$ConnectionString" -split ';')) { $parts = $pair -split '=', 2 if ($parts.Count -lt 2) { continue } $key = $parts[0].Trim().ToLowerInvariant() $value = $parts[1].Trim().Trim('"').Trim("'").Trim() if (-not $value) { continue } if (-not $server -and $script:AppConnectionServerKeys -contains $key) { $server = $value } if (-not $database -and $script:AppConnectionDatabaseKeys -contains $key) { $database = $value } } if (-not $server -and -not $database) { return } [pscustomobject]@{ Server = $server; Database = $database } } function ConvertTo-AppConnectionUrlTarget { <# .SYNOPSIS A web service address as a host and a port. Pure; nothing for what is not one. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][AllowEmptyString()][string]$Url) $uri = $null if (-not [uri]::TryCreate("$Url".Trim(), [UriKind]::Absolute, [ref]$uri)) { return } if ($uri.Scheme -notin 'http', 'https' -or -not $uri.Host) { return } if ($uri.IsLoopback) { return } [pscustomobject]@{ Url = "$Url".Trim() Scheme = $uri.Scheme HostName = $uri.Host Port = $uri.Port Entry = '{0}:{1}' -f $uri.Host, $uri.Port } } function Read-AppConnectionNode { <# .SYNOPSIS The text of the one node an XPath names below an item, or $null. .DESCRIPTION An empty XPath is a field the Check Definition did not name, and is not read. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)]$Item, [AllowNull()][AllowEmptyString()][string]$XPath) if (-not "$XPath".Trim()) { return $null } $node = $null try { $node = $Item.SelectSingleNode($XPath) } catch { return $null } if ($null -eq $node) { return $null } $text = "$($node.InnerText)".Trim() if ($node -is [System.Xml.XmlAttribute]) { $text = "$($node.Value)".Trim() } if ($text) { $text } else { $null } } function ConvertFrom-AppConnectionXml { <# .SYNOPSIS The connections one XML document holds, as the Check Definition says to read it. Pure. .DESCRIPTION -Source names the items (Item, an XPath) and, relative to each, where its name, Server, database, web service address and default flag stand - or a connection string, of which only the Server and the database are taken. An item that names neither a Server nor an address is not a connection and is left out. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)][xml]$Document, [AllowNull()]$Source) $itemPath = "$(Get-DataProperty $Source 'Item')".Trim() if (-not $itemPath) { $itemPath = '/*' } $items = @() try { $items = @($Document.SelectNodes($itemPath)) } catch { return } foreach ($item in $items) { $server = Read-AppConnectionNode -Item $item -XPath (Get-DataProperty $Source 'Server') $database = Read-AppConnectionNode -Item $item -XPath (Get-DataProperty $Source 'Database') $string = Read-AppConnectionNode -Item $item -XPath (Get-DataProperty $Source 'ConnectionString') if ($string) { $parsed = ConvertFrom-AppConnectionString -ConnectionString $string # Not kept: the string is where the password is. $string = $null if (-not $server) { $server = Get-DataProperty $parsed 'Server' } if (-not $database) { $database = Get-DataProperty $parsed 'Database' } } $url = Read-AppConnectionNode -Item $item -XPath (Get-DataProperty $Source 'Url') if (-not $server -and -not $url) { continue } $default = "$(Read-AppConnectionNode -Item $item -XPath (Get-DataProperty $Source 'Default'))" [pscustomobject]@{ Name = Read-AppConnectionNode -Item $item -XPath (Get-DataProperty $Source 'Name') Server = $server Database = $database Url = $url IsDefault = [bool]($default -match '^(true|1|yes)$') } } } function Get-AppConnectionSqlBrowserPort { <# .SYNOPSIS The TCP port the SQL Server Browser names for an instance, or $null. .DESCRIPTION One UDP datagram to port 1434: 0x04 and the instance name. The answer is text, "...;tcp;<port>;...". No answer is the usual case behind a firewall, and is $null. #> [CmdletBinding()] [OutputType([int])] param([Parameter(Mandatory)][string]$HostName, [Parameter(Mandatory)][string]$Instance, [int]$TimeoutMs = 2000) $udp = New-Object System.Net.Sockets.UdpClient try { $udp.Client.ReceiveTimeout = $TimeoutMs $request = [byte[]](@(4) + [Text.Encoding]::ASCII.GetBytes($Instance) + @(0)) $null = $udp.Send($request, $request.Length, $HostName, 1434) $from = New-Object System.Net.IPEndPoint ([System.Net.IPAddress]::Any), 0 $answer = $udp.Receive([ref]$from) ConvertFrom-AppConnectionBrowserAnswer -Text ([Text.Encoding]::ASCII.GetString($answer)) } catch { $null } finally { $udp.Close() } } function ConvertFrom-AppConnectionBrowserAnswer { <# .SYNOPSIS The TCP port out of a SQL Server Browser answer, or $null. Pure. #> [CmdletBinding()] [OutputType([int])] param([AllowNull()][AllowEmptyString()][string]$Text) if ("$Text" -match '(?i);tcp;(\d{1,5})(;|$)') { $port = [int]$Matches[1] if ($port -ge 1 -and $port -le 65535) { return $port } } $null } function ConvertFrom-AppConnectionPreLogin { <# .SYNOPSIS The version and the encryption option out of a TDS pre-login answer. Pure. .DESCRIPTION The answer is a TDS packet (8 bytes of header), then a table of options - token, offset, length, five bytes each, ended by 0xFF - and then their data, the offsets counted from the end of the header. Token 0 is the version: major, minor, and the build as two bytes, high first. Token 1 is one byte: 0 off (the sign-in alone is encrypted), 1 on, 2 not supported, 3 required. Nothing for bytes that are not that. #> [CmdletBinding()] [OutputType([psobject])] param([AllowNull()][byte[]]$Bytes) if ($null -eq $Bytes -or $Bytes.Length -lt 14 -or $Bytes[0] -ne 4) { return } $version = $null $encryption = $null $position = 8 while ($position -lt $Bytes.Length -and $Bytes[$position] -ne 0xFF) { if ($position + 4 -ge $Bytes.Length) { return } $token = $Bytes[$position] $offset = 8 + ($Bytes[$position + 1] * 256) + $Bytes[$position + 2] $length = ($Bytes[$position + 3] * 256) + $Bytes[$position + 4] if ($offset + $length -gt $Bytes.Length) { return } if ($token -eq 0 -and $length -ge 4) { $version = '{0}.{1}.{2}' -f $Bytes[$offset], $Bytes[$offset + 1], (($Bytes[$offset + 2] * 256) + $Bytes[$offset + 3]) } if ($token -eq 1 -and $length -ge 1) { $encryption = [int]$Bytes[$offset] } $position += 5 } if (-not $version) { return } [pscustomobject]@{ Version = $version; Encryption = $encryption } } function Get-AppConnectionPreLogin { <# .SYNOPSIS Asks a SQL Server who it is, without signing in. Never throws. .DESCRIPTION Sends the pre-login packet every client sends first - version, encryption "off", no instance, thread 0, no MARS - and reads the one packet that answers it. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)][string]$HostName, [Parameter(Mandatory)][int]$Port, [int]$TimeoutMs = 3000) $result = [ordered]@{ Answered = $false; Version = $null; Encryption = $null; Milliseconds = $null; Error = $null } $client = New-Object System.Net.Sockets.TcpClient try { $watch = [Diagnostics.Stopwatch]::StartNew() if (-not ($client.ConnectAsync($HostName, $Port).Wait($TimeoutMs) -and $client.Connected)) { $result.Error = 'no connection' return [pscustomobject]$result } $stream = $client.GetStream() $stream.ReadTimeout = $TimeoutMs $stream.WriteTimeout = $TimeoutMs # Five options and the terminator are 26 bytes, so the data begins at offset 26. $options = [byte[]](0,0,26,0,6, 1,0,32,0,1, 2,0,33,0,1, 3,0,34,0,4, 4,0,38,0,1, 0xFF) $data = [byte[]](9,0,0,0,0,0, 0, 0, 0,0,0,0, 0) $length = 8 + $options.Length + $data.Length $packet = [byte[]](@(0x12, 1, [byte]($length -shr 8), [byte]($length -band 255), 0, 0, 1, 0) + $options + $data) $stream.Write($packet, 0, $packet.Length) $buffer = New-Object byte[] 512 $read = $stream.Read($buffer, 0, $buffer.Length) $watch.Stop() $answer = $null if ($read -gt 0) { $answer = ConvertFrom-AppConnectionPreLogin -Bytes ([byte[]]$buffer[0..($read - 1)]) } if ($answer) { $result.Answered = $true $result.Version = $answer.Version $result.Encryption = $answer.Encryption $result.Milliseconds = [math]::Round($watch.Elapsed.TotalMilliseconds, 1) } else { $result.Error = 'not a pre-login answer' } } catch { $result.Error = $_.Exception.GetBaseException().Message } finally { $client.Dispose() } [pscustomobject]$result } function Get-AppConnectionData { [CmdletBinding()] [OutputType([psobject])] param([hashtable]$Parameters = @{}) $sources = New-Object 'System.Collections.Generic.List[psobject]' $connections = New-Object 'System.Collections.Generic.List[psobject]' foreach ($source in @(Get-Parameter $Parameters 'Sources' @())) { $configured = "$(Get-DataProperty $source 'Path')".Trim() if (-not $configured) { continue } $path = [Environment]::ExpandEnvironmentVariables($configured) $exists = Test-Path -LiteralPath $path -PathType Leaf $failure = $null $found = @() if ($exists) { try { $document = New-Object System.Xml.XmlDocument # No DTD, no external entity: the file is the application's, not ours. $document.XmlResolver = $null $document.Load($path) $found = @(ConvertFrom-AppConnectionXml -Document $document -Source $source) } catch { $failure = $_.Exception.GetBaseException().Message } } foreach ($connection in $found) { $connection | Add-Member -NotePropertyName 'Source' -NotePropertyValue $path -Force $connections.Add($connection) } $sources.Add([pscustomobject]@{ ConfiguredPath = $configured; Path = $path; Exists = [bool]$exists; Error = $failure; Connections = $found.Count }) } # Every SQL Server once, however many connections name it. $sqlServers = New-Object 'System.Collections.Generic.List[psobject]' # One Server entry per host, with every port: a SQL Server and a web service on one # machine are one name to resolve and one host to ping, however the file spells it. $hosts = New-Object 'System.Collections.Generic.List[string]' $ports = @{} $addTarget = { param([string]$HostName, $Port) $key = $HostName.ToLowerInvariant() if (-not $ports.ContainsKey($key)) { $hosts.Add($HostName); $ports[$key] = New-Object 'System.Collections.Generic.List[int]' } if ($Port -and -not $ports[$key].Contains([int]$Port)) { $ports[$key].Add([int]$Port) } } $seen = @{} foreach ($connection in $connections) { $target = ConvertTo-OdbcServerTarget -Server (Get-DataProperty $connection 'Server') if (-not $target) { continue } $key = ('{0}\{1},{2}' -f $target.HostName, $target.Instance, $target.Port).ToLowerInvariant() if ($seen.ContainsKey($key)) { continue } $seen[$key] = $true $port = $null $portFrom = $null if ($target.Port) { $port = [int]$target.Port; $portFrom = 'Given' } elseif (-not $target.Instance) { $port = $script:OdbcDefaultSqlPort; $portFrom = 'Default' } else { $port = Get-AppConnectionSqlBrowserPort -HostName $target.HostName -Instance $target.Instance if ($port) { $portFrom = 'Browser' } } $preLogin = $null if ($port) { $preLogin = Get-AppConnectionPreLogin -HostName $target.HostName -Port $port } & $addTarget $target.HostName $port $sqlServers.Add([pscustomobject]@{ HostName = $target.HostName Instance = $target.Instance Port = $port PortFrom = $portFrom Databases = @($connections | Where-Object { "$(Get-DataProperty $_ 'Server')" -eq "$(Get-DataProperty $connection 'Server')" } | ForEach-Object { Get-DataProperty $_ 'Database' } | Where-Object { $_ } | Select-Object -Unique) PreLogin = $preLogin }) } $urls = @($connections | ForEach-Object { ConvertTo-AppConnectionUrlTarget -Url (Get-DataProperty $_ 'Url') } | Where-Object { $_ }) foreach ($url in $urls) { & $addTarget $url.HostName $url.Port } $entries = @(foreach ($name in $hosts) { $list = $ports[$name.ToLowerInvariant()] if ($list.Count) { '{0}:{1}' -f $name, ($list -join ',') } else { $name } }) # Reachability is the Server Kind's, as for the Servers the Odbc Kind finds. $server = $null if ($entries.Count) { $server = Get-ServerData -Parameters @{ Servers = @($entries) } } [pscustomobject]@{ PSTypeName = 'Gutcheck.Data.AppConnection' App = "$(Get-Parameter $Parameters 'App' '')" Configured = [bool]@(Get-Parameter $Parameters 'Sources' @()).Count Sources = @($sources) Connections = @($connections) SqlServers = @($sqlServers) Urls = @($urls | ForEach-Object { $_.Url } | Select-Object -Unique) Server = $server } } function Get-AppConnectionSqlProduct { <# .SYNOPSIS The name a SQL Server version is sold under: "15.0.4420" is SQL Server 2019. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Version) $parts = "$Version" -split '\.' $name = switch ($parts[0]) { '17' { '2025' } '16' { '2022' } '15' { '2019' } '14' { '2017' } '13' { '2016' } '12' { '2014' } '11' { '2012' } '10' { if ($parts.Count -gt 1 -and $parts[1] -eq '50') { '2008 R2' } else { '2008' } } '9' { '2005' } default { '' } } if ($name) { return 'SQL Server {0}' -f $name } 'SQL Server' } function Get-AppConnectionSqlVersionText { <# .SYNOPSIS A SQL Server version with the release it is: "SQL Server 2019 (Version 15.0.4420)". Nothing where no version was read. Pure. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Version) if (-not "$Version".Trim()) { return '' } (Get-Text 'Value.AppConnection.SqlVersion') -f (Get-AppConnectionSqlProduct -Version $Version), $Version } function ConvertTo-AppConnectionFinding { [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Data, [hashtable]$Parameters = @{}, [AllowNull()]$Situation ) $check = Get-Text 'Check.AppConnection.Connections' if (-not (Get-DataProperty $Data 'Configured')) { return New-Finding -Category Apps -Check $check -Severity INFO ` -Value (Get-Text 'Value.AppConnection.NotConfigured') -Hint (Get-Text 'Hint.AppConnection.NotConfigured') } $sources = Get-DataCollection $Data 'Sources' foreach ($source in $sources) { $path = Get-DataProperty $source 'Path' if (Get-DataProperty $source 'Error') { New-Finding -Category Apps -Check $check -Severity INFO ` -Value ((Get-Text 'Value.AppConnection.Unreadable') -f $path, (Get-DataProperty $source 'Error')) ` -Hint (Get-Text 'Hint.AppConnection.Unreadable') } elseif (-not (Get-DataProperty $source 'Exists')) { New-Finding -Category Apps -Check $check -Severity INFO ` -Value ((Get-Text 'Value.AppConnection.NoFile') -f $path) -Hint (Get-Text 'Hint.AppConnection.NoFile') } } $connections = Get-DataCollection $Data 'Connections' if (-not $connections.Count) { if (@($sources | Where-Object { (Get-DataProperty $_ 'Exists') -and -not (Get-DataProperty $_ 'Error') }).Count) { New-Finding -Category Apps -Check $check -Severity INFO ` -Value (Get-Text 'Value.AppConnection.NoneFound') -Hint (Get-Text 'Hint.AppConnection.NoneFound') } return } # What is configured, in one line: a Technician should see the Server's name without # opening the evidence. $named = @($connections | ForEach-Object { $target = "$(Get-DataProperty $_ 'Server')" if (-not $target) { $target = "$(Get-DataProperty $_ 'Url')" } if (Get-DataProperty $_ 'Database') { $target = (Get-Text 'Value.AppConnection.ServerDatabase') -f $target, (Get-DataProperty $_ 'Database') } $target } | Select-Object -Unique) New-Finding -Category Apps -Check $check -Severity INFO -Value ('{0}: {1}' -f $named.Count, ($named -join ', ')) foreach ($sql in (Get-DataCollection $Data 'SqlServers')) { New-AppConnectionSqlFinding -SqlServer $sql -Data $Data -Parameters $Parameters } $server = Get-DataProperty $Data 'Server' if ($server) { ConvertTo-ServerFinding -Data $server -Parameters $Parameters -Situation $Situation } } function New-AppConnectionSqlFinding { <# .SYNOPSIS What one SQL Server said about itself. Pure. .DESCRIPTION Nothing where the port did not open: that is the Server Kind's Finding, and a second one would say the same thing twice. A port that opened and gave no pre-login answer is WARN. A version older than SqlMinimumSupportedMajor is WARN: Microsoft no longer fixes it. That a server does not force encryption is said and not judged - inside one building it is the rule. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)]$SqlServer, [AllowNull()]$Data, [hashtable]$Parameters = @{}) $minimum = ConvertTo-Number (Get-Parameter $Parameters 'SqlMinimumSupportedMajor' 14) $slowWarn = Get-Parameter $Parameters 'SqlAnswerWarnMs' 500 $slowFail = Get-Parameter $Parameters 'SqlAnswerFailMs' ([double]::MaxValue) $hostName = "$(Get-DataProperty $SqlServer 'HostName')" $instance = "$(Get-DataProperty $SqlServer 'Instance')" $name = $hostName if ($instance) { $name = '{0}\{1}' -f $hostName, $instance } $check = (Get-Text 'Check.AppConnection.SqlServer') -f $name $port = ConvertTo-Number (Get-DataProperty $SqlServer 'Port') if ($null -eq $port) { return New-Finding -Category Apps -Check $check -Severity WARN ` -Value (Get-Text 'Value.AppConnection.BrowserSilent') -Hint (Get-Text 'Hint.AppConnection.BrowserSilent') } $preLogin = Get-DataProperty $SqlServer 'PreLogin' if (-not (Get-DataProperty $preLogin 'Answered')) { # Whether the port opened at all is in what the Server Kind measured. $opened = @((Get-DataCollection (Get-DataProperty $Data 'Server') 'Servers') | Where-Object { "$(Get-DataProperty $_ 'HostName')" -eq $hostName -and @((Get-DataCollection $_ 'PortResults') | Where-Object { (ConvertTo-Number $_.Port) -eq $port -and (ConvertTo-Number $_.Successes) -gt 0 }).Count }).Count if (-not $opened) { return } return New-Finding -Category Apps -Check $check -Severity WARN ` -Value ((Get-Text 'Value.AppConnection.NoPreLogin') -f $port) -Hint (Get-Text 'Hint.AppConnection.NoPreLogin') } $version = "$(Get-DataProperty $preLogin 'Version')" $lines = @((Get-Text 'Value.AppConnection.SqlVersion') -f (Get-AppConnectionSqlProduct -Version $version), $version) if ("$(Get-DataProperty $SqlServer 'PortFrom')" -eq 'Browser') { $lines += (Get-Text 'Value.AppConnection.PortFromBrowser') -f $port } $databases = Get-DataCollection $SqlServer 'Databases' if ($databases.Count) { $lines += (Get-Text 'Value.AppConnection.Databases') -f ($databases -join ', ') } $lines += switch (ConvertTo-Number (Get-DataProperty $preLogin 'Encryption')) { 0 { Get-Text 'Value.AppConnection.EncryptionOff' } 1 { Get-Text 'Value.AppConnection.EncryptionOn' } 2 { Get-Text 'Value.AppConnection.EncryptionNone' } 3 { Get-Text 'Value.AppConnection.EncryptionRequired' } default { Get-Text 'Value.AppConnection.EncryptionUnknown' } } $ms = ConvertTo-Number (Get-DataProperty $preLogin 'Milliseconds') $slow = 'OK' if ($null -ne $ms) { $lines += (Get-Text 'Value.AppConnection.AnsweredIn') -f $ms $slow = Get-Severity $ms $slowWarn $slowFail } $old = 'OK' $hints = @() $major = ConvertTo-Number (@($version -split '\.')[0]) if ($null -ne $major -and $null -ne $minimum -and $major -lt $minimum) { $old = 'WARN' $hints += Get-Text 'Hint.AppConnection.SqlOutOfSupport' } if ($slow -ne 'OK') { $hints += Get-Text 'Hint.AppConnection.SqlSlow' } New-Finding -Category Apps -Check $check -Severity (Get-WorstSeverity $old $slow) ` -Value ($lines -join ' | ') -Hint ($hints -join ' | ') } function ConvertTo-AppConnectionSection { [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) $sqlServers = Get-DataCollection $Data 'SqlServers' New-Section -Title (Get-Text 'Title.AppConnection.Connections') -Row @( foreach ($connection in (Get-DataCollection $Data 'Connections')) { $target = ConvertTo-OdbcServerTarget -Server (Get-DataProperty $connection 'Server') $sql = $null if ($target) { $sql = @($sqlServers | Where-Object { "$(Get-DataProperty $_ 'HostName')" -eq $target.HostName -and "$(Get-DataProperty $_ 'Instance')" -eq "$($target.Instance)" })[0] } $preLogin = Get-DataProperty $sql 'PreLogin' $row = [ordered]@{} $row[(Get-Text 'Column.AppConnection.Name')] = Get-DataProperty $connection 'Name' $row[(Get-Text 'Column.AppConnection.Default')] = Get-TriStateText ([bool](Get-DataProperty $connection 'IsDefault')) $row[(Get-Text 'Column.AppConnection.Server')] = Get-DataProperty $connection 'Server' $row[(Get-Text 'Column.AppConnection.Port')] = Get-DataProperty $sql 'Port' $row[(Get-Text 'Column.AppConnection.Database')] = Get-DataProperty $connection 'Database' $row[(Get-Text 'Column.AppConnection.Url')] = Get-DataProperty $connection 'Url' # Which SQL Server that is, and the number: a version is not shown bare. $row[(Get-Text 'Column.AppConnection.SqlVersion')] = Get-AppConnectionSqlVersionText -Version (Get-DataProperty $preLogin 'Version') $row[(Get-Text 'Column.AppConnection.AnswerMs')] = Get-DataProperty $preLogin 'Milliseconds' $row[(Get-Text 'Column.AppConnection.Source')] = Get-DataProperty $connection 'Source' [pscustomobject]$row } ) $server = Get-DataProperty $Data 'Server' if ($server) { ConvertTo-ServerSection -Data $server } } |