Advertisement
DragonHawk

WUOffline.PSM1

Jun 8th, 2021
2,950
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
  1. <#
  2.  
  3. WUOffline (Windows Update Offline) module for PowerShell
  4.  
  5. Since the Windows Update subsystem can also provide updates for other products
  6. (like Office or SQL), the updates this module finds, may be more than just
  7. updates for Windows.  But they're still updates handled by Windows Update,
  8. and are thus still Windows Updates.  Got that?
  9.  
  10. Written by Benjamin Scott
  11. Last Modified 2020AUG14
  12. Originally inspired by Scan-UpdatesOffline.ps1
  13.   Dated 12/12/2019
  14.   by Andrei Stoica of Microsoft
  15.   https://gallery.technet.microsoft.com/Using-WUA-to-Scan-for-f7e5e0be
  16.   Retrieved 2019 JAN 15
  17.  
  18. NOTE_BOUND_PARAM_MODULE:
  19. $PSBoundParameters.ContainsKey() acts weird in a module, outside of
  20. the exported function itself.  So anything that depends on the actual
  21. bound parameters has to be checked in the exported function.
  22.  
  23. NOTE_NULL_STRING_PARAM:
  24. PoSh forces any [string] parameter to contain a string, even if not set.
  25. PoSh will not allow anything cast as [string] to contain $null.
  26. In either case, the empty string gets stored instead.
  27. So if you want to distinquish between "parameter not specified" and
  28. "parameter explictly set to empty string", you have to check for the
  29. existence of the parameter (not the value), and then store a special
  30. string-null-value, which then evaluates as equal to $null.
  31.  
  32. #>
  33.  
  34. ########################################################################
  35. # safety
  36.  
  37. # throw errors on undefined variables
  38. Set-StrictMode -Version 1
  39.  
  40. # abort immediately on error
  41. $ErrorActionPreference = [System.Management.Automation.ActionPreference]::Stop
  42.  
  43. ########################################################################
  44. # constants
  45.  
  46. # https://docs.microsoft.com/en-us/windows/win32/api/wuapicommon/ne-wuapicommon-serverselection
  47. Set-Variable -option Constant -name SearchOthers -Value 3
  48.  
  49. # UpdateServiceOption
  50. # https://docs.microsoft.com/en-us/windows/win32/api/wuapi/ne-wuapi-updateserviceoption
  51. Set-Variable -option Constant -name VolatileService -Value 0
  52. Set-Variable -option Constant -name NonVolatileService -Value 1
  53.  
  54. # WU needs a name for the offline scan catalog "service"
  55. Set-Variable -option Constant -name svcName -Value "WSUSSCN2_CAB"
  56.  
  57. ########################################################################
  58. # exported functions
  59.  
  60. # ----------------------------------------------------------------------
  61. function Get-WinUpdate {
  62. <#
  63. .SYNOPSIS
  64.  
  65. Get-WindUpdate.  Get concise information about Windows Updates.
  66.  
  67. .DESCRIPTION
  68.  
  69. FIXME write description
  70.  
  71. .EXAMPLE
  72.  
  73. Get-WinUpdate C:\WinUpdate\wsusscn2.cab | Format-Table
  74.  
  75. Scan for needed updates and display the results in tabular form. No
  76. progress information will be given.
  77.  
  78. .EXAMPLE
  79.  
  80. Get-WinUpdate -Verbose -Catalog C:\WinUpdate\wsusscn2.cab | Export-CSV -Path C:\WinUpdate\updates.CSV
  81.  
  82. Scan for needed updates, and store the results in a Comma Separated
  83. Values (CSV) file, suitable for Excel or other programs. The -Verbose
  84. switch means major operations will be identified as they are performed,
  85. and a few simple statistics will be given.
  86.  
  87. .EXAMPLE
  88.  
  89. Get-WinUpdate C:\WinUpdate\wsusscn2.cab -Exclude 890830 | % { $_.Links -split " " } > urls.txt
  90.  
  91. Scan for needed updates, and store the URLs that need to be downloaded
  92. into a text file. Exclude update 890830 (the Malicious Software Removal
  93. Tool included every month). The URL list can then be given to downloader
  94. programs such as WGET, CURL, GetRight, etc.
  95.  
  96. .EXAMPLE
  97.  
  98. Get-WinUpdate -Installed C:\WinUpdate\wsusscn2.cab | Export-CSV C:\WinUpdate\installed.CSV
  99.  
  100. Scan and report all updates installed on the machine, as well as any
  101. needed. Store the results in a CSV.
  102.  
  103. .PARAMETER Catalog
  104.  
  105. Full path and file name of Windows Update offline scan catalog.
  106. Typically named WSUSSCN2.CAB and obtained from the
  107. http://go.microsoft.com/fwlink/?LinkId=76054 redirector.
  108.  
  109. .PARAMETER Installed
  110.  
  111. Instead of searching for Windows Updates that are needed, search for
  112. updates which are already installed. This can be used to report which
  113. updates have been installed on the machine.
  114.  
  115. .PARAMETER All
  116.  
  117. Search for both updates which are needed, and those which are already
  118. installed. This can be used to provide a "status report" for a machine.
  119. If both -All and -Installed are specified, -All wins.
  120.  
  121. .PARAMETER Superseded
  122.  
  123. When searching for Windows Updates, include potentially-superseded
  124. updates.  This is a Windows Update internal option.
  125.  
  126. .PARAMETER Include
  127.  
  128. A list of one or more strings, which are checked agains the MSKB IDs of
  129. updates. Only updates where the MSKB ID exactly matches an include
  130. string are subject to further processing; the rest are omitted. If no
  131. -Include is specified, all updates are processed (subject to -Exclude).
  132.  
  133. If an update matches both -Include and -Exclude, it is excluded.
  134.  
  135. .PARAMETER Exclude
  136.  
  137. A list of one or more strings, which are checked agains the MSKB IDs of
  138. updates. Any update where the MSKB ID exactly matches an exclude string,
  139. is omitted from further processing.
  140.  
  141. If an update matches both -Include and -Exclude, it is excluded.
  142.  
  143. .PARAMETER Query
  144.  
  145. Explictly specify the query that will be given to the Windows Update
  146. engine for the update search.  Overrides -All or -Installed, but not
  147. -Include or -Exclude (the former influence the query given to WU; the
  148. latter are applied to the results from the WU search).
  149.  
  150. For query syntax, see: https://docs.microsoft.com/en-us/windows/win32/api/wuapi/nf-wuapi-iupdatesearcher-search
  151.  
  152. .PARAMETER ShowDebug
  153.  
  154. Shortcut to setting DebugPreference=Continue for this script run.  Tons of
  155. debugging/internal progress information is always written to the Debug
  156. output/object stream.  This switch will reveal that, without the constant
  157. prompting that a full -Debug entails.
  158.  
  159. .INPUTS
  160.  
  161. None.  You cannot pipe objects to this script.
  162.  
  163. .OUTPUTS
  164.  
  165. A stream of PowerShell custom objects, each one representing a single
  166. top-level Update. A single top-level Update may have multiple "bundled"
  167. package files associated with it. The members of the object include the
  168. MSKB ID, the full title of the update, and the URLs for the associated
  169. package file(s). The output objects are suitable for piping to other
  170. PowerShell cmdlets for viewing, storage, or further processing.
  171.  
  172. The Warning and/or Verbose streams can be consulted for operational
  173. status and results.
  174.  
  175. .NOTES
  176.  
  177. For -Include and -Exclude, the matching uses the numeric part of the
  178. MSKB ID only, without any leading prefix like "KB".  The matching is done
  179. by this script, not the WU Search facility, because the latter only
  180. accepts update GUIDs, which can only be determined by looking at the
  181. results of a Search. (Or so it appears.  Better ideas welcome.)
  182.  
  183. .LINK
  184. Install-WinUpdate
  185.  
  186. .LINK
  187. Start-WUScan
  188.  
  189. .LINK
  190. Install-WUUpdates
  191.  
  192. .NOTES
  193.  
  194. #>
  195.  
  196. [CmdletBinding()]
  197. Param(
  198.  
  199. [Parameter(Mandatory=$true,HelpMessage="Full absolute path to offline scan catalog (WSUSSCN2.CAB)")]
  200. [string]$Catalog,
  201.  
  202. [Parameter(Mandatory=$false,HelpMessage="Report installed updates instead of needed?")]
  203. [switch]$Installed = $false,
  204.  
  205. [Parameter(Mandatory=$false,HelpMessage="Report both installed and needed updates?")]
  206. [switch]$All = $false,
  207.  
  208. [Parameter(Mandatory=$false,HelpMessage="Include superseded updates?  Defaults to false.")]
  209. [switch]$Superseded = $false,
  210.  
  211. [Parameter(Mandatory=$false,HelpMessage="Only process updates matching this KB ID.  -Exclude overrides.")]
  212. [string[]]$Include,
  213.  
  214. [Parameter(Mandatory=$false,HelpMessage="Do not process updates matching this MS KB.  Overrides -Include.")]
  215. [string[]]$Exclude,
  216.  
  217. [Parameter(Mandatory=$false,HelpMessage="Explictly specify the WU Search query to run.  Overrides -Installed or -All.")]
  218. [string]$Query,
  219.  
  220. [Parameter(
  221.     Mandatory=$False,
  222.     HelpMessage="Display debug output (without debug prompting)?"
  223.     )]
  224. [switch]$ShowDebug
  225.  
  226. ) # Param
  227.  
  228. If ($ShowDebug) {
  229.     $DebugPreference = 'Continue'
  230. }
  231.  
  232. Write-Debug "Get-WinUpdate: START"
  233.  
  234. # see NOTE_BOUND_PARAM_MODULE
  235. # see NOTE_NULL_STRING_PARAM
  236. if (-not $PSBoundParameters.ContainsKey('Query')) {
  237.     $Query = [System.Management.Automation.Language.NullString]::Value
  238.     }
  239.  
  240. # this is the main point of divergence between Get- and Install-WinUpdate
  241. # $installable is not used for Get-, only for -Install
  242. $installable = $null
  243.  
  244. main $installable
  245.  
  246. Write-Debug "Get-WinUpdate: EXIT"
  247.  
  248. } # Get-WinUpdate
  249.  
  250. # ----------------------------------------------------------------------
  251. function Install-WinUpdate {
  252. <#
  253. .SYNOPSIS
  254.  
  255. Install-WindUpdate.  Install Update packages from local files.
  256.  
  257. .DESCRIPTION
  258.  
  259. FIXME write description
  260.  
  261. .EXAMPLE
  262.  
  263. Install-WinUpdate C:\WinUpdate\wsusscn2.cab C:\WinUpdate\pkgs
  264.  
  265. Scans for needed updates, and then attempts to install them, using
  266. package files previously placed in the C:\WinUpdate\pkgs directory. No
  267. output will be given, unless a package is missing, a reboot is required,
  268. or a problem is detected.
  269.  
  270. .EXAMPLE
  271.  
  272. Install-WinUpdate -Verbose -Catalog C:\WinUpdate\wsusscn2.cab -Repo C:\WinUpdate\pkgs
  273.  
  274. Scans for needed updates, and then attempts to install them, using
  275. package files previously placed in the C:\WinUpdate\pkgs directory.
  276. Major steps and a few statistics are reported as they occur.
  277.  
  278. .EXAMPLE
  279.  
  280. Install-WinUpdate C:\WinUpdate\wsusscn2.cab C:\WinUpdate\pkgs -Include 4566424
  281.  
  282. Install only updates with MSKB matching "4566424".  In this case, it is a
  283. Servicing Stack Update, being installed before other updates.
  284.  
  285. .PARAMETER Catalog
  286.  
  287. Full path and file name of Windows Update offline scan catalog.
  288. Typically named WSUSSCN2.CAB and obtained from the
  289. http://go.microsoft.com/fwlink/?LinkId=76054 redirector.
  290.  
  291. .PARAMETER Repo
  292.  
  293. Full path and name of a directory/folder containing Windows Update
  294. package files. These may be retrived by obtaining a list of URLs using
  295. Get-WinUpdate, and then copying the resulting files to the target
  296. system.
  297.  
  298. .PARAMETER Superseded
  299.  
  300. When searching for Windows Updates, include potentially-superseded
  301. updates.  This is a Windows Update internal option.
  302.  
  303. .PARAMETER Include
  304.  
  305. A list of one or more strings, which are checked agains the MSKB IDs of
  306. updates. Only updates where the MSKB ID exactly matches an include
  307. string are subject to further processing; the rest are omitted. If no
  308. -Include is specified, all updates are processed (subject to -Exclude).
  309.  
  310. If an update matches both -Include and -Exclude, it is excluded.
  311.  
  312. .PARAMETER Exclude
  313.  
  314. A list of one or more strings, which are checked agains the MSKB IDs of
  315. updates. Any update where the MSKB ID exactly matches an exclude string,
  316. is omitted from further processing.
  317.  
  318. If an update matches both -Include and -Exclude, it is excluded.
  319.  
  320. .PARAMETER Query
  321.  
  322. Explictly specify the query that will be given to the Windows Update
  323. engine for the update search.  Does not override Include or -Exclude
  324. (those are applied to the results from the WU search).
  325.  
  326. For query syntax, see: https://docs.microsoft.com/en-us/windows/win32/api/wuapi/nf-wuapi-iupdatesearcher-search
  327.  
  328. .PARAMETER ShowDebug
  329.  
  330. Shortcut to setting DebugPreference=Continue for this script run.  Tons of
  331. debugging/internal progress information is always written to the Debug
  332. output/object stream.  This switch will reveal that, without the constant
  333. prompting that a full -Debug entails.
  334.  
  335. .INPUTS
  336.  
  337. None.  You cannot pipe objects to this script.
  338.  
  339. .OUTPUTS
  340.  
  341. None. The Output stream should be empty. The Warning and/or Verbose
  342. streams can be consulted for operational status and results.
  343.  
  344. .NOTES
  345.  
  346. For -Include and -Exclude, the matching uses the numeric part of the
  347. MSKB ID only, without any leading prefix like "KB".  The matching is done
  348. by this script, not the WU Search facility, because the latter only
  349. accepts update GUIDs, which can only be determined by looking at the
  350. results of a Search. (Or so it appears.  Better ideas welcome.)
  351.  
  352. .LINK
  353. Get-WinUpdate
  354.  
  355. .LINK
  356. Start-WUScan
  357.  
  358. .LINK
  359. Install-WUUpdate
  360.  
  361. .NOTES
  362.  
  363. #>
  364.  
  365. [CmdletBinding()]
  366. Param(
  367.  
  368. [Parameter(Mandatory=$true,HelpMessage="Full absolute path to offline scan catalog (WSUSSCN2.CAB)")]
  369. [string]$Catalog,
  370.  
  371. [Parameter(Mandatory=$true,HelpMessage="Repository.  Path to the directory/folder containing update package files to load.")]
  372. [string]$Repo,
  373.  
  374. [Parameter(Mandatory=$false,HelpMessage="Include superseded updates?  Defaults to false.")]
  375. [switch]$Superseded = $false,
  376.  
  377. [Parameter(Mandatory=$false,HelpMessage="Only process updates matching this KB ID.  -Exclude overrides.")]
  378. [string[]]$Include,
  379.  
  380. [Parameter(Mandatory=$false,HelpMessage="Do not process updates matching this MS KB.  Overrides -Include.")]
  381. [string[]]$Exclude,
  382.  
  383. [Parameter(Mandatory=$false,HelpMessage="Explictly specify the WU Search query to run.")]
  384. [string]$Query,
  385.  
  386. [Parameter(
  387.     Mandatory=$False,
  388.     HelpMessage="Display debug output (without debug prompting)?"
  389.     )]
  390. [switch]$ShowDebug
  391.  
  392. ) # Param
  393.  
  394. If ($ShowDebug) {
  395.     $DebugPreference = 'Continue'
  396. }
  397.  
  398. Write-Debug "Install-WinUpdate: START"
  399.  
  400. # see NOTE_BOUND_PARAM_MODULE
  401. # see NOTE_NULL_STRING_PARAM
  402. if (-not $PSBoundParameters.ContainsKey('Query')) {
  403.     $Query = [System.Management.Automation.Language.NullString]::Value
  404.     }
  405.  
  406. # since Install- doesn't have -All or -Installed switches, we dummy
  407. # up some variables to take their place.
  408. $All = $false
  409. $Installed = $false
  410.  
  411. # this will hold a list of updates we will actually try to install
  412. $installable = New-Object -COMObject Microsoft.Update.UpdateColl
  413.  
  414. main $installable
  415.  
  416. Write-Debug "Install-WinUpdate: EXIT"
  417.  
  418. } # Install-WinUpdate
  419.  
  420. ########################################################################
  421. # internal functions
  422.  
  423. # ----------------------------------------------------------------------
  424. function main ($installable) {
  425.  
  426. Write-Debug "main: START"
  427.  
  428. # Explictly recast $Superseded as boolean.  Otherwise this:
  429. #   $sch.IncludePotentiallySupersededUpdates = $Superseded
  430. # will throw an error like this:
  431. #   Specified cast is not valid
  432. # I presume there is some weirdness with DCOM/WU and the [switch] type.
  433. [boolean]$Superseded = $Superseded
  434.  
  435. # global counters
  436. $script:topcount = 0
  437. $script:skipped = 0
  438.  
  439. check_args
  440. $Query = build_query
  441. $WU = init_WU
  442. $results = search_WU $WU.sch
  443. process_WU_results $results
  444.  
  445. # the service tends to hang around in background if not explictly removed
  446. Write-Debug "main: removing WU Service..."
  447. $WU.mgr.RemoveService($WU.svcID)
  448.  
  449. Write-Debug "main: EXIT"
  450.  
  451. } # main
  452.  
  453. # ----------------------------------------------------------------------
  454. function check_args () {
  455. # sanity check the arguments/parameters given by external caller
  456.  
  457. # catalog file
  458. if (-not [System.IO.Path]::IsPathRooted($Catalog) ) {
  459.     Throw "Catalog path must be absolute, not relative: $Catalog"
  460.     }
  461. if (-not (Test-Path -PathType Leaf $Catalog)) {
  462.     Throw "Catalog file does not appear to exist: $Catalog"
  463.     }
  464.  
  465. # for Install- also do the repo
  466. if ($installable) {
  467.     if (-not [System.IO.Path]::IsPathRooted($Repo) ) {
  468.         Throw "Repository path must be absolute, not relative: $Repo"
  469.         }
  470.     if (-not (Test-Path -PathType Container $Repo)) {
  471.         Throw "Repository does not exist or is not a directory: $Repo"
  472.         }
  473.     }
  474.  
  475. } # check_args
  476.  
  477. # ----------------------------------------------------------------------
  478. function build_query () {
  479. # Unless $Query was explictly specified, we need to build it.
  480. # If we're installing, $All and $Installed will never be true,
  481. # so the final else default will always be used, but that's good.
  482.  
  483. # Discovering NOTE_NULL_STRING_PARAM made this interesting.
  484.  
  485. # if Query is non-null, the parameter was specified by caller
  486. $explict_query = ($null -ne $Query)
  487.  
  488. if ($explict_query) {
  489.     Write-Debug "build_query: using explict query"
  490.     return $Query
  491.     }
  492. else {
  493.     Write-Debug "build_query: building query automatically"
  494.     if ($All) {
  495.         return "IsInstalled=0 or IsInstalled=1"
  496.         }
  497.     elseif ($Installed) {
  498.         return "IsInstalled=1"
  499.         }
  500.     else {
  501.         return "IsInstalled=0"
  502.         }
  503.     }
  504.  
  505. } # build_query
  506.  
  507. # ----------------------------------------------------------------------
  508. function init_WU () {
  509. # initialize Windows Update and its various objects
  510.  
  511. Write-Verbose "Creating WU Session..."
  512. $ses = New-Object -ComObject Microsoft.Update.Session
  513.  
  514. Write-Verbose "Creating WU Manager..."
  515. $mgr = New-Object -ComObject Microsoft.Update.ServiceManager
  516.  
  517. Write-Verbose "Creating WU Service from Scan Package..."
  518. $duration = Measure-Command {
  519.     $svc = $mgr.AddScanPackageService($svcName, $Catalog, $VolatileService)
  520.     }
  521. elapsed $duration
  522.  
  523. $svcID = $svc.ServiceID.ToString()
  524. Write-Debug "init_WU: ServiceID = <$svcID>"
  525.  
  526. Write-Verbose "Creating WU Searcher..."
  527. $sch = $ses.CreateUpdateSearcher()
  528.  
  529. Write-Verbose "Setting up search parameters..."
  530. $sch.ServerSelection = $SearchOthers
  531. $sch.IncludePotentiallySupersededUpdates = $Superseded
  532. $sch.CanAutomaticallyUpgradeService = $false
  533.  
  534. # IUpdateSearcher.Online
  535. # https://docs.microsoft.com/en-us/windows/win32/api/wuapi/nf-wuapi-iupdatesearcher-get_online
  536. # Docs would lead you to believe we would want this turned off.
  537. # But if you do that, Search detects zero updates.  So don't do that.
  538. #$sch.Online = $false
  539.  
  540. Write-Verbose "Connecting Searcher to Service..."
  541. $sch.ServiceID = $svcID
  542.  
  543. # collect the WU objects into a single custom object
  544. $wrapper = [PSCustomObject] @{
  545.     ses = $ses
  546.     mgr = $mgr
  547.     svc = $svc
  548.     sch = $sch
  549.     svcID = $svcID
  550.     }
  551.  
  552. Write-Debug "init_WU: exiting"
  553.  
  554. return $wrapper
  555.  
  556. } # init_WU
  557.  
  558. # ----------------------------------------------------------------------
  559. function search_WU ($searcher) {
  560. # tell WU to search for updates that match our $Query
  561.  
  562. Write-Verbose "Searching for updates..."
  563. $duration = Measure-Command {
  564.     $results = $searcher.Search($Query)
  565.     }
  566. elapsed $duration
  567.  
  568. return $results
  569.  
  570. } # search_WU
  571.  
  572. # ----------------------------------------------------------------------
  573. function process_WU_results ($results) {
  574. # process the results of a Windows Update Searcher .Search()
  575.  
  576. report_OpResult $results.ResultCode
  577.  
  578. report_warnings $results.Warnings
  579.  
  580. Write-Verbose "Search found $( $results.Updates.Count ) top-level updates (before include/exclude)"
  581.  
  582. $processed = process_update_list -updates $results.updates -parent $null -parentKB $null -installable $installable
  583.  
  584. Write-Verbose "Kept $script:topcount updates after processing, omitted $script:skipped"
  585.  
  586. if ($script:topcount -lt 1) {
  587.     Write-Warning "Zero updates found (after processing)"
  588.     return
  589.     }
  590.  
  591. if ($installable) {
  592.     # When installing, we ignore the $processsed results.
  593.     # Instead we're interested in what gets put in $installable.
  594.     install_updates $installable
  595.     }
  596. else {
  597.     # When just reporting needed updates, we emit the processed list.
  598.     # The external caller should get a stream of objects with update info.
  599.     # The results can then by piped to Format-Table, Export-CSV, etc.
  600.     Write-Output $processed
  601. }
  602.  
  603. } # process_WU_results
  604.  
  605. # ----------------------------------------------------------------------
  606. function hex ($dword) {
  607. # converts a DWORD (32-bit unsigned int) to hexidecimal string
  608.  
  609. return ("0x" + $dword.ToString("X8"))
  610.  
  611. } # hex
  612.  
  613. # ----------------------------------------------------------------------
  614. function elapsed ($span) {
  615. # reports a human-friendly interpretation of the given timespan
  616.  
  617. # spaces and literal "m" and "s" get escaped with \
  618. $span = $span.ToString('m\m\ ss\s')
  619. Write-Verbose "Duration: $span"
  620.  
  621. } # elapsed
  622.  
  623. # ----------------------------------------------------------------------
  624. function report_OpResult ($code) {
  625. # interprets an OperationsResultCode and informs the operator
  626. # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-uamg/a0eb1e12-0a6a-47c9-a70f-f272d87b5227
  627.  
  628. $name = switch ($code) {
  629.     0 { "NotStarted" }
  630.     1 { "InProgress" }
  631.     2 { "Succeeded" }
  632.     3 { "SucceededWithErrors" }
  633.     4 { "Failed" }
  634.     5 { "Aborted" }
  635.     default { "UNKNOWN" }
  636.     }
  637.  
  638. $msg = "ResultCode: $code ($name)"
  639.  
  640. # if we got the desired 2/Success, that is reported as a verbose detail
  641. # if it is something else, report it as a warning
  642. if ($code -eq 2 ) {
  643.     Write-Verbose $msg
  644.     }
  645. else {
  646.     Write-Warning $msg
  647.     }
  648.  
  649. } # report_OpResult
  650.  
  651. # ----------------------------------------------------------------------
  652. function report_warnings ($warns) {
  653.  
  654. $warnCount = $warns.Count
  655. if ($warnCount -gt 0) {
  656.     Write-Warning "Search reported $warnCount warnings"
  657.     }
  658.  
  659. } # report_warnings
  660.  
  661. # ----------------------------------------------------------------------
  662. function process_update_list ($updates, $parent, $parentKB, $installable) {
  663.  
  664. Write-Debug "process_update_list: entering with parentKB=<$parentKB>"
  665.  
  666. foreach ($update in $updates) {
  667.     process_update_single $update $parent $parentKB $installable
  668.     }
  669.  
  670. Write-Debug "process_update_list: exiting with parentKB=<$parentKB>"
  671.  
  672. } # process_update_list
  673.  
  674. # ----------------------------------------------------------------------
  675. function process_update_single ($update, $parent, $parentKB, $installable) {
  676.  
  677. Write-Debug "process_update_single: parentKB=<$parentKB>"
  678.  
  679. # flatten KB
  680. $KB = ($update.KBArticleIDs -join '/')
  681. Write-Debug "process_update_single: KB=<$KB>"
  682.  
  683. # if there is an include list, and the KB is *NOT* in it, skip the update
  684. # except if this KB has a blank KB ID
  685. #   in which case we can't make a determination
  686. #   but those should all be bundled updates for a parent
  687. #   and so should be caught when their parent KB ID was filtered
  688. if ($Include -and $KB -and ($Include -notcontains $KB)) {
  689.     Write-Debug "process_update_single: skipping not-included"
  690.     $script:skipped++
  691.     return
  692.     }
  693.  
  694. # if the KB is in the list of excludes, skip it
  695. if ($Exclude -contains $KB) {
  696.     Write-Debug "process_update_single: skipping excluded"
  697.     $script:skipped++
  698.     return
  699.     }
  700.  
  701. # we assume:
  702. #   every top-level update has a KB ID
  703. #   every bundled update has no KB ID (gets it from parent)
  704. # anything else blows our model out of the water
  705. if ($parent) { # we are a bundled
  706.     # if KB is defined and not empty, we puke
  707.     if (($KB) -and ($KB -ne [string]::Empty)) {
  708.         throw "encountered a bundled update with its own KB ID (KB=<$KB> parent=<$parentKB>)"
  709.         }
  710.     }
  711. else { # no parent, we are top-level
  712.     # increase the counter of top-level updates
  713.     # (possibly right before we throw an error, but good if not)
  714.     $script:topcount++
  715.     # if KB is not defined, or KB is empty, we puke
  716.     if ((-not $KB) -or ($KB -eq [string]::Empty)) {
  717.         throw "encountered a top-level update without a KB ID"
  718.         }
  719.     }
  720.  
  721. # $effectiveKB is used in error messages, reporting, and the like.
  722. # (Because $KB will be empty if we are a bundled update, and that's not
  723. # very useful to the operator.)
  724. $effectiveKB = if ($parent) { $parentKB } else { $KB }
  725.  
  726. # add a NoteProperty to track if we've added this to $installable
  727. Add-Member -InputObject $update -MemberType NoteProperty -TypeName boolean -Name InstAdded -Value $false
  728.  
  729. $title = $update.Title
  730.  
  731. # turn the categories into a single string, separated by slashes
  732. $cats = (( $update.Categories | Select-Object -ExpandProperty Name ) -join '/')
  733.  
  734. # init $links to empty list
  735. $links = @()
  736.  
  737. # add links from *this* update
  738. $links_more = process_download_list -KB $effectiveKB -update $update -parent $parent -installable $installable
  739. Write-Debug "process_update_single: links from this update: <$( $links_more )>"
  740. # conditional because sometimes $null would show up as a member of $links
  741. if ($links_more) { $links += $links_more }
  742.  
  743. # interpret various properties into a concise status string
  744. # Installed=Installed-for-all-products, Present=Installed-for-some
  745. $status = switch ($true) {
  746.     ($update.IsInstalled)   { "Installed"   ; break }
  747.     ($update.IsPresent) { "Present" ; break }
  748.     ($update.IsDownloaded)  { "Loaded"  ; break }
  749.     default         { "Needed"  ; break }
  750.     }
  751. Write-Debug "process_update_single: status=<$status>"
  752.  
  753. # BundledUpdate
  754. # if we have a parent, we are ourselves a BundledUpdate
  755. # a BundledUpdate with nested BundledUpdates blows our little mind
  756. if (($parent) -and ($update.BundledUpdates.Count -gt 0)) {
  757.     Throw "encountered a bundle with a nested bundle (parentKB=<$parentKB>)"
  758.     }
  759. $bundled = process_update_list -updates $update.BundledUpdates -parent $update -parentKB $KB -installable $installable
  760.  
  761. # all we've ever seen from bundled updates is URLs
  762. # so add links from bundled updates, and ignore the rest
  763. $links_more = $bundled.links
  764. Write-Debug "process_update_single: links from bundled updates: <$( $links_more )>"
  765. if ($links_more) { $links += $links_more }
  766.  
  767. # flatten links into a space-separated string
  768. # since URLs cannot contain spaces, this works out nicely
  769. $links = ($links -join ' ')
  770.  
  771. # We haven now gathered and prepared all the info.
  772. # We collect the info in a custom object to present it in a convenient format.
  773. $update_info = [PSCustomObject] @{
  774.     KB       = $effectiveKB
  775.     Status   = $status
  776.     Title    = $title
  777.     Cats     = $cats
  778.     Links    = $links
  779.     }
  780.  
  781. Write-Debug "process_update_single: exiting parentKB=<$parentKB> KB=<$KB>"
  782.  
  783. return $update_info
  784.  
  785. } # process_update
  786.  
  787. # ----------------------------------------------------------------------
  788. function process_download_list ($KB, $update, $parent, $installable) {
  789. # loop through the download list and get all the URLs
  790. # loop through the download list and get all the URLs
  791. # each URL is emitted to the output stream
  792. # effectively making the return value a list of strings
  793. # if $installable is defined, we also try to load the package file
  794.  
  795. Write-Debug "process_download_list: entering with KB=<$KB>"
  796.  
  797. foreach ($download in $update.DownloadContents) {
  798.  
  799.     $url = $download.DownloadUrl
  800.     Write-Debug "process_download_list: url=<$url>"
  801.  
  802.     Write-Output $url
  803.  
  804.     # if we're trying to install, also try to load the package
  805.     if ($installable) {
  806.         load_update_pkg -KB $KB -update $update -parent $parent -installable $installable
  807.         }
  808.  
  809.     } # foreach $download
  810.  
  811. } # process_download_list
  812.  
  813. # ----------------------------------------------------------------------
  814. function load_update_pkg ($KB, $update, $parent, $installable) {
  815.  
  816. # get the base file name (with extension) from the URL
  817. $leaf = Split-Path -Leaf $url
  818. Write-Debug "load_update_pkg: leaf=<$leaf>"
  819.  
  820. # look in the pository for a file with of the same name
  821. $file = Join-Path $repo $leaf
  822. Write-Debug "load_update_pkg: file=<$file>"
  823.  
  824. # if the file does not exist in the repository...
  825. if (-not (Test-Path $file) ) {
  826.     # tell the operator
  827.     Write-Warning "Could not find package file, skipping:"
  828.     Write-Warning "  KB=<$KB>"
  829.     Write-Warning "  File=<$leaf>"
  830.     # move on to the next thing
  831.     return
  832.     }
  833.  
  834. # if we found it, assume it's the right file, and try to load it
  835. Write-Verbose "Loading package: $leaf"
  836.  
  837. # .CopyToCache() requires an object implementing IStringCollection
  838. # so we have to wrap $file in a compatible class else first
  839. $sc = New-Object -COMObject Microsoft.Update.StringColl
  840. # StringColl.Add() tends to return zero (0), discard that
  841. $sc.Add($file) | Out-Null
  842.  
  843. # now add the package file to the BundledUpdate
  844. $update.CopyToCache($sc)
  845.  
  846. # now we want to add the top-level update to the $installable collection
  847. # if we have a $parent update, top would be the parent
  848. # if no $parent, this update is the top-level update
  849. $topUpdate = if ($parent) { $parent } else { $update }
  850.  
  851. # if we've already added this, don't do it again
  852. # (this is just the list of (top-level) updates to *install*-- every package is added regardless)
  853. if ($topUpdate.InstAdded) {
  854.     Write-Debug "load_update_pkg: topUpdate already added to installable, not adding again"
  855.     return
  856.     }
  857.  
  858. # package was loaded, add this update to the list to install
  859. # here .Add returns the index of the new member, discard that
  860. Write-Debug "load_update_pkg: adding topUpdate to installable"
  861. $installable.Add($topUpdate) | Out-Null
  862.  
  863. Write-Debug "load_update_pkg: marking topUpdate as added"
  864. $topUpdate.InstAdded = $true
  865.  
  866. Write-Debug "load_update_pkg: exiting at end"
  867.  
  868. } # load_update_pkg
  869.  
  870. # ----------------------------------------------------------------------
  871. function install_updates ($installable) {
  872.  
  873. Write-Verbose "Loaded $( $installable.Count ) updates"
  874.  
  875. Write-Verbose "Creating WU Installer..."
  876. $ins = New-Object -COMObject Microsoft.Update.Installer
  877.  
  878. Write-Verbose "Feeding update list to Installer..."
  879. $ins.Updates = $installable
  880.  
  881. Write-Verbose "Installing updates..."
  882. $duration = Measure-Command {
  883.     $results = $ins.Install()
  884.     }
  885. elapsed $duration
  886.  
  887. # interpret results for overall install attempt
  888.  
  889. report_OpResult $results.ResultCode
  890.  
  891. $hr = $results.HResult
  892. if ($hr -ne 0) {
  893.     $hr = hex $hr
  894.     Write-Warning "Install process had overall HRESULT $hr"
  895.     }
  896.  
  897. # interpret results for individual updates
  898.  
  899. Write-Verbose "Checking installation results..."
  900. # we have to check results of each update individually
  901. # and we have to use a counter because .GetUpdateResult takes an index
  902. for ($index = 0 ; $index -lt $installable.Count ; $index++) {
  903.     check_update_result -installable $installable -index $index
  904.     }
  905.  
  906. # I tried putting $ins.Commit here, just in case,
  907. # but that just threw a method-does-not-exist error.
  908. # https://docs.microsoft.com/en-us/windows/win32/api/wuapi/nf-wuapi-iupdateinstaller4-commit
  909.  
  910. # check for the nearly-inevitable reboot
  911. if ($results.RebootRequired) {
  912.     Write-Warning "Windows Update says a reboot is required."
  913.     }
  914.  
  915. } # install_updates
  916.  
  917. # ----------------------------------------------------------------------
  918. function check_update_result ($installable, $index) {
  919.  
  920. $update = $installable.Item($index)
  921.  
  922. # flatten KB
  923. $KB = $( $update.KBArticleIDs )
  924. Write-Debug "results check: KB=<$KB>"
  925.  
  926. # if there is an include list, and the KB is *NOT* in it, skip the update
  927. # don't need to worry about bundled updates with blank $KB here --
  928. #   the $installable list is just top-level updates
  929. if ($Include -and ($Include -notcontains $KB)) {
  930.     Write-Debug "check_update_result: skipping not-included"
  931.     return
  932.     }
  933.  
  934. # if the KB is in the list of excludes, skip it
  935. if ($Exclude -contains $KB) {
  936.     Write-Debug "check_update_result: skipping excluded"
  937.     return
  938.     }
  939.  
  940. # get the results for this update in particular
  941. # Note that GetUpdateResult() here does not match the docs.
  942. # https://docs.microsoft.com/en-us/windows/win32/api/wuapi/nf-wuapi-iinstallationresult-getupdateresult
  943. # They claim it takes two arguments and returns an HRESULT.
  944. # In practice it takes just the index, and returns an
  945. # object implementing IUpdateInstallationResult.
  946. $upRes = $results.GetUpdateResult($index)
  947.  
  948. $rc = $upRes.ResultCode
  949. $hr = $upRes.HResult
  950.  
  951. # if either code indicates trouble, report them both
  952. if ( ($hr -ne 0) -or ($rc -ne 2) ) {
  953.     $hr = hex $hr
  954.     Write-Warning "Update $KB had HRESULT $hr and ResultCode $rc"
  955.     }
  956.  
  957. } # check_update_result
  958.  
  959. ########################################################################
  960. # exports
  961.  
  962. Export-ModuleMember -Function Get-WinUpdate
  963. Export-ModuleMember -Function Install-WinUpdate
  964.  
  965. ########################################################################
  966.  
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement