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"). 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, 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 = $Root 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 '\')) } $service | Add-Member -MemberType ScriptMethod -Name GetServerName -Value { $part = @($this.Root.TrimStart('\', '/') -split '[\\/]+' | Where-Object { $_ -ne '' }) if ($part.Count -eq 0) { return '' } return $part[0] } $service | Add-Member -MemberType ScriptMethod -Name GetShareName -Value { $part = @($this.Root.TrimStart('\', '/') -split '[\\/]+' | Where-Object { $_ -ne '' }) if ($part.Count -lt 2) { return '' } return $part[1] } $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. if ([System.IO.Path]::IsPathRooted($RelativePath)) { return $RelativePath } return $this.Combine($RelativePath) } $service | Add-Member -MemberType ScriptMethod -Name TestContent -Value { param([string] $RelativePath) $this.Record('TestContent', @($RelativePath)) $this.AssertUsablePath($RelativePath) $path = $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 = $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.Root.StartsWith('\\')) { throw (New-Object System.ArgumentException ( "HDTConfigurationError: the content root '$($this.Root)' 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.Root)'. 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.Root)', 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 -------------- $this.SmbService.NewMapping($this.Root, $userName, $password) $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.Root)' 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) } $this.IsConnected = $true return $this.Root } $service | Add-Member -MemberType ScriptMethod -Name SafeRemoveMapping -Value { try { $this.SmbService.RemoveMapping($this.Root) } 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.IsConnected = $false } return $service } |