Public/New-HDTSmbContentProvider.ps1
|
function New-HDTSmbContentProvider { <# .SYNOPSIS Creates the Smb IContentProvider - a deployment share, mapped with the deployment account, and refused when it comes back as a guest. .DESCRIPTION The provider interface over a UNC share, and the refusals in the one place they can be enforced: the moment the mapping is made. THE REFUSALS, IN ORDER, AND WHY EACH ONE IS HERE: 1. A root that is not UNC is an HDTConfigurationError. A local path is New-HDTLocalContentProvider's job. 2. A credential with a username and an EMPTY password is an HDTSecurityError: an empty password is an anonymous logon wearing a name. 3. NO CREDENTIAL AT ALL is an HDTSecurityError unless -AllowAnonymous was passed explicitly. The refusal to fall back to guest starts here - not supplying a credential IS the fallback, and a provider that shrugged would deploy from whatever the server felt like handing over. 4. With no credential and EnableInsecureGuestLogons turned on, it refuses before mapping. HDT REPORTS THE MACHINE'S SMB CLIENT POSTURE, IT DOES NOT CHANGE IT: turning a security setting off to make a deployment work is the opposite of what the check is for. THEN IT MAPS, AND READS THE ESTABLISHED IDENTITY BACK. That read-back is the whole point of this file. A mapping can succeed and still have authenticated as nobody: - no connection row for the server -> the mapping did not take; - a UserName that is empty, 'Guest', anything ending '\Guest', or 'ANONYMOUS LOGON' (case-insensitive) -> HDTSecurityError naming the server, AND THE MAPPING IT JUST MADE IS TORN DOWN, because a refusal that left the share attached would be a refusal in name only; - a dialect beginning '1.' -> SMB1, refused outright; - a dialect below 3.0 -> WARN and continue. A 2.1 file server is legitimate and refusing it would be HDT deciding a fleet's infrastructure for it; - an unencrypted connection -> WARN once, naming the server (signing and encryption "where the server supports them"). IT MAPS TO A DRIVE LETTER, AND Root BECOMES THAT LETTER. MDT connected the deployment share as a drive and ran everything from it; HDT does the same, for a reason that is not tidiness. CMD.EXE REFUSES A UNC WORKING DIRECTORY: started in one it prints "UNC paths are not supported", moves itself to %SystemRoot%, and an application whose install command names its own installer relatively - which is what every vendor documents - then runs in C:\Windows and cannot find it. The InstallApplications step runs each command through %ComSpec% /c in the application's source folder, so that folder has to be a path cmd.exe can stand in. THE LETTER IS THE FIRST FREE ONE FROM Z DOWNWARD, the rule MDT used and PSD still uses. RemoteRoot keeps the UNC path throughout - it is what the mapping is made and torn down by, and what a refusal names - and Disconnect puts Root back, because a root pointing at a letter that is no longer mapped is worse than no root at all. Connect is re-entrant: calling it twice maps once. Disconnect NEVER THROWS. It runs in a finally, and a teardown that throws is a teardown that does not finish. THE RESOLUTION RULES ARE IDENTICAL TO THE LOCAL PROVIDER'S, which is "A content projection plus a provider swap, not a parallel code path" as far as a step is concerned - asserted by tests/contract/ContentProvider.Contract.Tests.ps1 over all three implementations, and by the operation-list equality test in tests/unit/Invoke-HDTApplyImageStep.Tests.ps1. Segments are collapsed by hand rather than by [IO.Path]::GetFullPath, which SILENTLY CLAMPS '..' AT THE ROOT OF A UNC SHARE - GetFullPath('\\server\Share\..\..\Windows') is '\\server\Share\Windows' on both engines - and would therefore turn an escape into a legal path instead of reporting it. THE ERROR ID TRAVELS IN THE MESSAGE. A refusal raised inside a ScriptMethod reaches its caller as ScriptMethodRuntimeException and loses an ErrorRecord's FullyQualifiedErrorId, so HDTSecurityError and HDTConfigurationError are written into the sentence. The SmbShare module underneath is reached through New-HDTSmbService, which is a dumb adapter; everything above is here, where it is unit tested against New-HDTFakeSmbService with nothing mapped. .PARAMETER Root The deployment share, as a UNC path: \\server\share. .PARAMETER Credential The deployment account. Read from Control\share-credential.json by Get-HDTShareCredential in a real run. .PARAMETER AllowAnonymous Connect with the caller's own identity and no credential. It exists so that "no credential" is something an operator SAYS, rather than something that happens by omission. .PARAMETER SmbService An ISmbService. Defaults to the real adapter. .PARAMETER FileSystem An IFileSystem. Defaults to the real adapter. .PARAMETER Journal The shared cross-service operation journal. When supplied, every recorded call is appended to it in addition to $Operations, numbered globally across services. .OUTPUTS System.Management.Automation.PSCustomObject with the five IContentProvider ScriptMethods, plus Root (the mapped drive once connected, the UNC path before and after), RemoteRoot (always the UNC path), ServiceName, Operations and GetOperationName(). Note that Get-Member -MemberType Method does NOT list a ScriptMethod - use -MemberType Method, ScriptMethod. .EXAMPLE $secret = Get-HDTShareCredential -WorkspaceRoot 'X:\Deploy' $credential = New-Object System.Management.Automation.PSCredential $secret.UserName, (ConvertTo-SecureString $secret.Password -AsPlainText -Force) $content = New-HDTSmbContentProvider -Root '\\server\HdtShare' -Credential $credential try { $content.Connect() $content.ResolveContent('OperatingSystems\Win11-LTSC-2024\sources\install.wim') } finally { $content.Disconnect() } .EXAMPLE $content = New-HDTSmbContentProvider -Root '\\localhost\HDTIntegration$' -AllowAnonymous The caller's own identity, said out loud. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds a stateless service adapter object; it changes no state. Connect is where a mapping is made, and it is a method.')] [CmdletBinding()] [OutputType([object])] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $Root, [Parameter()] [AllowNull()] [pscredential] $Credential, [Parameter()] [switch] $AllowAnonymous, [Parameter()] [AllowNull()] [object] $SmbService, [Parameter()] [AllowNull()] [object] $FileSystem, [Parameter()] [AllowNull()] [System.Collections.ArrayList] $Journal ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if ($null -eq $SmbService) { $SmbService = New-HDTSmbService } if ($null -eq $FileSystem) { $FileSystem = New-HDTFileSystem } $service = [pscustomobject] @{ # Root MOVES: the UNC path until Connect maps it, the drive while it is # mapped, the UNC path again after Disconnect. RemoteRoot never moves, # and it is what every SmbService call is made with. Root = $Root RemoteRoot = $Root Drive = '' Credential = $Credential AllowAnonymous = [bool] $AllowAnonymous SmbService = $SmbService FileSystem = $FileSystem Operations = [System.Collections.ArrayList]::new() Journal = $Journal ServiceName = 'ContentProvider' IsConnected = $false } $service | Add-Member -MemberType ScriptMethod -Name Record -Value { param([string] $Operation, [object[]] $Argument) [void] $this.Operations.Add([pscustomobject] @{ Sequence = $this.Operations.Count + 1 Operation = $Operation Arguments = $Argument }) if ($null -ne $this.Journal) { [void] $this.Journal.Add([pscustomobject] @{ Sequence = $this.Journal.Count + 1 Service = $this.ServiceName Operation = $Operation Arguments = $Argument }) } } $service | Add-Member -MemberType ScriptMethod -Name GetOperationName -Value { return , ([string[]] @($this.Operations | ForEach-Object { $_.Operation })) } $service | Add-Member -MemberType ScriptMethod -Name AssertUsablePath -Value { param([string] $Path) if ([string]::IsNullOrWhiteSpace($Path)) { throw (New-Object System.ArgumentException ( "HDTConfigurationError: a content path must not be empty. The provider was asked to resolve nothing against the content root '$($this.Root)'.")) } } $service | Add-Member -MemberType ScriptMethod -Name Combine -Value { param([string] $RelativePath) $segment = [System.Collections.ArrayList]::new() foreach ($part in ($RelativePath -split '[\\/]+')) { if (($part -eq '') -or ($part -eq '.')) { continue } if ($part -eq '..') { if ($segment.Count -eq 0) { throw (New-Object System.ArgumentException ( "HDTConfigurationError: the content path '$RelativePath' escapes the content root '$($this.Root)'. A step asking for content outside the workspace is a defect, not a path to follow.")) } $segment.RemoveAt($segment.Count - 1) continue } [void] $segment.Add($part) } if ($segment.Count -eq 0) { return $this.Root } return ($this.Root.TrimEnd('\', '/') + '\' + ($segment -join '\')) } # THE SHARE UNDER ANOTHER NAME IS NOT SOMEWHERE ELSE. A caller that already # holds '\\server\Share\Applications\7Zip\source' is the normal case, not an # odd one: a step is given the workspace root it was told to deploy from, and # the catalog builds paths under it. Those name the same files the mapping # does, so once the mapping exists they are answered through it - otherwise # the letter would exist and the one caller that most needs it, the install # step making a working directory, would never see it. # # THIS IS NOT THE RE-ROOTING DESIGN 9.3 FORBIDS. That rule is about media # registered where it stands - a captured image on a local disk stays where # it was put. A path under this provider's own share is not another location # to be dragged into the share; it IS the share. $service | Add-Member -MemberType ScriptMethod -Name Localise -Value { param([string] $Path) if (-not $this.IsConnected) { return $Path } if (-not $Path.StartsWith($this.RemoteRoot, [System.StringComparison]::OrdinalIgnoreCase)) { return $Path } $tail = $Path.Substring($this.RemoteRoot.Length).TrimStart('\', '/') if ([string]::IsNullOrEmpty($tail)) { return $this.Root } return ($this.Root.TrimEnd('\', '/') + '\' + $tail) } $service | Add-Member -MemberType ScriptMethod -Name GetServerName -Value { $part = @($this.RemoteRoot.TrimStart('\', '/') -split '[\\/]+' | Where-Object { $_ -ne '' }) if ($part.Count -eq 0) { return '' } return $part[0] } $service | Add-Member -MemberType ScriptMethod -Name GetShareName -Value { $part = @($this.RemoteRoot.TrimStart('\', '/') -split '[\\/]+' | Where-Object { $_ -ne '' }) if ($part.Count -lt 2) { return '' } return $part[1] } # MDT'S RULE, KEPT: the first free letter walking DOWN from Z. Down rather # than up because the low letters are spoken for by things that arrive later # and expect them - a USB stick, a second disk, the optical drive - and a # deployment share sitting on F: would be where one of them lands. It stops # at E: for the same reason: A and B are the floppy letters Windows still # reserves, C is the system volume, and D is the optical drive by convention # on nearly every machine HDT will deploy. $service | Add-Member -MemberType ScriptMethod -Name SelectDriveLetter -Value { $used = [System.Collections.Hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($current in @($this.SmbService.GetUsedDriveLetter())) { if ([string]::IsNullOrWhiteSpace($current)) { continue } $used[$current.Trim().Substring(0, 1)] = $true } foreach ($code in 90..69) { $letter = [string] [char] $code if (-not $used.ContainsKey($letter)) { return ($letter + ':') } } throw (New-Object System.InvalidOperationException ( "HDTEnvironmentError: there is no free drive letter between E: and Z: to map '$($this.RemoteRoot)' to. HDT maps the deployment share to a letter because cmd.exe cannot hold a UNC working directory - an application installing from the share would run in C:\Windows instead. Free a letter and run this again.")) } $service | Add-Member -MemberType ScriptMethod -Name TestGuestIdentity -Value { param([string] $UserName) if ([string]::IsNullOrWhiteSpace($UserName)) { return $true } $name = $UserName.Trim() if ($name -match '(?i)^(.*\\)?guest$') { return $true } if ($name -match '(?i)anonymous logon') { return $true } return $false } $service | Add-Member -MemberType ScriptMethod -Name ResolveContent -Value { param([string] $RelativePath) $this.Record('ResolveContent', @($RelativePath)) $this.AssertUsablePath($RelativePath) # DESIGN 9.3: media registered where it stands is not re-rooted - but a # path already under this share is the share, so it goes through the # mapping. Localise leaves everything else alone. if ([System.IO.Path]::IsPathRooted($RelativePath)) { return $this.Localise($RelativePath) } return $this.Combine($RelativePath) } $service | Add-Member -MemberType ScriptMethod -Name TestContent -Value { param([string] $RelativePath) $this.Record('TestContent', @($RelativePath)) $this.AssertUsablePath($RelativePath) $path = $this.Localise($RelativePath) if (-not [System.IO.Path]::IsPathRooted($RelativePath)) { $path = $this.Combine($RelativePath) } return [bool] $this.FileSystem.TestPath($path) } $service | Add-Member -MemberType ScriptMethod -Name CopyContent -Value { param([string] $RelativePath, [string] $Destination) $this.Record('CopyContent', @($RelativePath, $Destination)) $this.AssertUsablePath($RelativePath) if ([string]::IsNullOrWhiteSpace($Destination)) { throw (New-Object System.ArgumentException ( "HDTConfigurationError: CopyContent was given no destination for '$RelativePath'.")) } $source = $this.Localise($RelativePath) if (-not [System.IO.Path]::IsPathRooted($RelativePath)) { $source = $this.Combine($RelativePath) } if (-not $this.FileSystem.TestPath($source)) { throw (New-Object System.IO.FileNotFoundException ("Could not find content '$source'.", $source)) } $parent = [System.IO.Path]::GetDirectoryName($Destination) if (-not [string]::IsNullOrWhiteSpace($parent)) { $this.FileSystem.CreateDirectory($parent) } $this.FileSystem.CopyItem($source, $Destination) return $Destination } $service | Add-Member -MemberType ScriptMethod -Name Connect -Value { $this.Record('Connect', @()) # Re-entrant: a launcher that connects, and a step that connects again # because it cannot know, must produce one mapping. if ($this.IsConnected) { return $this.Root } # -- what it refuses before it maps -------------------------------- if (-not $this.RemoteRoot.StartsWith('\\')) { throw (New-Object System.ArgumentException ( "HDTConfigurationError: the content root '$($this.RemoteRoot)' is not a UNC path. The Smb provider connects to \\server\share; content on this machine is New-HDTLocalContentProvider's job.")) } $userName = '' $password = '' if ($null -ne $this.Credential) { $userName = [string] $this.Credential.UserName $password = [string] $this.Credential.GetNetworkCredential().Password if ([string]::IsNullOrEmpty($password)) { throw (New-Object System.Security.SecurityException ( "HDTSecurityError: the credential for '$userName' has an empty password. An empty password is an anonymous logon with a name on it, and HDT does not deploy from a share it authenticated to as nobody.")) } } elseif (-not $this.AllowAnonymous) { throw (New-Object System.Security.SecurityException ( "HDTSecurityError: no credential was supplied for '$($this.RemoteRoot)'. Not supplying one is exactly the guest fallback HDT refuses. Pass -Credential, or pass -AllowAnonymous to connect as this machine's own identity on purpose.")) } else { $configuration = $this.SmbService.GetClientConfiguration() if ($configuration.EnableInsecureGuestLogons) { throw (New-Object System.Security.SecurityException ( "HDTSecurityError: this machine has EnableInsecureGuestLogons turned on and no credential was supplied for '$($this.RemoteRoot)', so the connection could silently become a guest session. HDT reports this setting and does not change it: changing a machine's security posture to make a deployment work is the opposite of what the check is for.")) } } # -- the mapping, and the identity it actually produced -------------- # # THE LETTER IS CHOSEN BEFORE ANYTHING IS MAPPED. A machine with none # free has to fail having attached nothing, rather than leave a share # connected to a letter this object never recorded and cannot tear down. $drive = $this.SelectDriveLetter() $this.SmbService.NewMapping($this.RemoteRoot, $userName, $password, $drive) $server = $this.GetServerName() $connection = @($this.SmbService.GetConnection($server)) if ($connection.Count -eq 0) { $this.SafeRemoveMapping() throw (New-Object System.InvalidOperationException ( "HDTEnvironmentError: the mapping to '$($this.RemoteRoot)' did not take - no SMB connection to '$server' came back. HDT will not read content from a path it cannot prove it is connected to.")) } # Get-SmbConnection returns a row per share and IPC$ is always one of # them, so the row for the share that was actually mapped is the one # judged. Judging whichever came back first would make the refusal # depend on enumeration order. $row = $connection[0] $share = $this.GetShareName() foreach ($candidate in $connection) { if ([string] $candidate.ShareName -eq $share) { $row = $candidate break } } if ($this.TestGuestIdentity([string] $row.UserName)) { $this.SafeRemoveMapping() throw (New-Object System.Security.SecurityException ( ("HDTSecurityError: the connection to '{0}' came back as '{1}' - it fell back to guest, and HDT will not deploy from a share it did not authenticate to. The mapping has been removed. Check that the deployment account is enabled and that its password matches the one Set-HDTShareCredential wrote." -f $server, [string] $row.UserName))) } $dialect = [string] $row.Dialect if ($dialect.StartsWith('1.')) { $this.SafeRemoveMapping() throw (New-Object System.Security.SecurityException ( "HDTSecurityError: the connection to '$server' negotiated SMB dialect '$dialect'. SMB1 is refused outright, and the mapping has been removed.")) } $major = 0 $part = @($dialect -split '\.') if ($part.Count -gt 0) { $major = [int] ($part[0] -as [int]) } if ($major -lt 3) { Write-Warning ("The connection to '{0}' negotiated SMB dialect '{1}'. HDT continues - a 2.x file server is legitimate - but SMB 3 is where encryption and the strongest signing live." -f $server, $dialect) } if (-not $row.Encrypted) { Write-Warning ("The connection to '{0}' is not encrypted. HDT uses SMB signing and encryption where the server supports them; this one does not, so the deployment credential and every file it reads cross the network in clear." -f $server) } # ONLY NOW. Every refusal above tears the mapping down and leaves this # object rooted where it started, so a caller that swallowed the error # cannot go on to resolve content against a letter that is not there. $this.Drive = $drive $this.Root = $drive + '\' $this.IsConnected = $true return $this.Root } $service | Add-Member -MemberType ScriptMethod -Name SafeRemoveMapping -Value { try { # BY THE REMOTE PATH, not the letter: it is the one still right when # Connect refused before it ever chose one. $this.SmbService.RemoveMapping($this.RemoteRoot) } catch { # A refusal must not be replaced by the failure of its own cleanup: # the sentence the caller needs is the one about the identity. $null = $_ } } $service | Add-Member -MemberType ScriptMethod -Name Disconnect -Value { $this.Record('Disconnect', @()) $this.SafeRemoveMapping() $this.Drive = '' $this.Root = $this.RemoteRoot $this.IsConnected = $false } return $service } |