9496063b97
Test-ReplicationPermissions now recognizes: - GenericAll as satisfying replication rights - Blanket ExtendedRight (empty ObjectType) ACEs Also adds diagnostic hints distinguishing between 'missing ACE entirely' and 'ACE exists but not for you'. All versions bumped to unified v2.2.3.
410 lines
17 KiB
PowerShell
410 lines
17 KiB
PowerShell
$script:ElysiumVersion = '2.2.3'
|
|
|
|
function Invoke-RestartWithExecutable {
|
|
param(
|
|
[string]$ExecutablePath,
|
|
[hashtable]$BoundParameters,
|
|
[object[]]$UnboundArguments
|
|
)
|
|
|
|
if (-not $ExecutablePath) { return }
|
|
if (-not $PSCommandPath) { return }
|
|
|
|
$argList = @('-NoLogo', '-NoProfile', '-File', $PSCommandPath)
|
|
|
|
if ($BoundParameters) {
|
|
foreach ($entry in $BoundParameters.GetEnumerator()) {
|
|
$key = "-$($entry.Key)"
|
|
$value = $entry.Value
|
|
if ($value -is [System.Management.Automation.SwitchParameter]) {
|
|
if ($value.IsPresent) { $argList += $key }
|
|
} else {
|
|
$argList += $key
|
|
$argList += $value
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($UnboundArguments) {
|
|
$argList += $UnboundArguments
|
|
}
|
|
|
|
& $ExecutablePath @argList
|
|
exit $LASTEXITCODE
|
|
}
|
|
|
|
function Restart-WithPwshIfAvailable {
|
|
param(
|
|
[hashtable]$BoundParameters,
|
|
[object[]]$UnboundArguments
|
|
)
|
|
|
|
if ($PSVersionTable.PSVersion.Major -ge 7 -or $PSVersionTable.PSEdition -eq 'Core') { return }
|
|
$pwsh = Get-Command -Name 'pwsh' -ErrorAction SilentlyContinue
|
|
if (-not $pwsh) { return }
|
|
Write-Host ("PowerShell 7 detected at '{0}'; relaunching script under pwsh..." -f $pwsh.Path)
|
|
Invoke-RestartWithExecutable -ExecutablePath $pwsh.Path -BoundParameters $BoundParameters -UnboundArguments $UnboundArguments
|
|
}
|
|
|
|
function Restart-WithWindowsPowerShellIfAvailable {
|
|
param(
|
|
[hashtable]$BoundParameters,
|
|
[object[]]$UnboundArguments
|
|
)
|
|
|
|
if ($PSVersionTable.PSEdition -eq 'Desktop') { return }
|
|
$powershellCmd = Get-Command -Name 'powershell.exe' -ErrorAction SilentlyContinue
|
|
$powershellPath = $null
|
|
if ($powershellCmd) {
|
|
$powershellPath = $powershellCmd.Path
|
|
} else {
|
|
$defaultPath = Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
|
if (Test-Path -LiteralPath $defaultPath) {
|
|
$powershellPath = $defaultPath
|
|
}
|
|
}
|
|
if (-not $powershellPath) {
|
|
Write-Warning 'Windows PowerShell (powershell.exe) was not found; continuing under current host.'
|
|
return
|
|
}
|
|
Write-Host ("Windows PowerShell detected at '{0}'; relaunching script under powershell.exe..." -f $powershellPath)
|
|
Invoke-RestartWithExecutable -ExecutablePath $powershellPath -BoundParameters $BoundParameters -UnboundArguments $UnboundArguments
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Settings loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Read-KeyValueSettingsFile {
|
|
param([Parameter(Mandatory)][string]$Path)
|
|
$result = @{}
|
|
if (-not (Test-Path -LiteralPath $Path)) { return $result }
|
|
foreach ($line in (Get-Content -LiteralPath $Path)) {
|
|
if ($null -eq $line) { continue }
|
|
$trimmed = $line.Trim()
|
|
if (-not $trimmed) { continue }
|
|
if ($trimmed.StartsWith('#')) { continue }
|
|
$kv = $line -split '=', 2
|
|
if ($kv.Count -ne 2) { continue }
|
|
$key = $kv[0].Trim()
|
|
$value = $kv[1].Trim()
|
|
if (-not $key) { continue }
|
|
if ($value.StartsWith("'") -and $value.EndsWith("'") -and $value.Length -ge 2) {
|
|
$value = $value.Substring(1, $value.Length - 2)
|
|
}
|
|
$result[$key] = $value
|
|
}
|
|
return $result
|
|
}
|
|
|
|
function Read-ElysiumSettings {
|
|
param([Parameter(Mandatory)][string]$ScriptRoot)
|
|
$settingsPath = Join-Path -Path $ScriptRoot -ChildPath 'ElysiumSettings.txt'
|
|
if (-not (Test-Path -LiteralPath $settingsPath)) {
|
|
throw "Settings file not found at $settingsPath"
|
|
}
|
|
return Read-KeyValueSettingsFile -Path $settingsPath
|
|
}
|
|
|
|
function Get-SettingsValue {
|
|
param(
|
|
[hashtable]$Settings,
|
|
[string]$Key
|
|
)
|
|
if (-not $Settings) { return $null }
|
|
if ($Settings.ContainsKey($Key)) { return $Settings[$Key] }
|
|
return $null
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parallel execution helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Get-FunctionDefinitionText {
|
|
param([Parameter(Mandatory)][string]$Name)
|
|
$cmd = Get-Command -Name $Name -CommandType Function -ErrorAction Stop
|
|
return $cmd.ScriptBlock.Ast.Extent.Text
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Azure Blob Storage helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Build-BlobUri {
|
|
param(
|
|
[string]$Account,
|
|
[string]$Container,
|
|
[string]$Sas,
|
|
[string]$BlobName
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Account)) { throw 'storageAccountName is missing or empty.' }
|
|
if ([string]::IsNullOrWhiteSpace($Container)) { throw 'containerName is missing or empty.' }
|
|
if ([string]::IsNullOrWhiteSpace($Sas)) { throw 'sasToken is missing or empty.' }
|
|
if ([string]::IsNullOrWhiteSpace($BlobName)) { throw 'BlobName cannot be empty.' }
|
|
|
|
$sas = $Sas.Trim()
|
|
if (-not $sas.StartsWith('?')) { $sas = '?' + $sas }
|
|
$normalizedBlob = $BlobName.Replace('\', '/').TrimStart('/')
|
|
$builder = [System.UriBuilder]::new("https://$Account.blob.core.windows.net/$Container/$normalizedBlob")
|
|
$builder.Query = $sas.TrimStart('?')
|
|
return $builder.Uri.AbsoluteUri
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Storage path utilities
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Combine-StoragePath {
|
|
param(
|
|
[string]$Prefix,
|
|
[string]$Name
|
|
)
|
|
|
|
$cleanName = $Name.Replace('\', '/').TrimStart('/')
|
|
if ([string]::IsNullOrWhiteSpace($Prefix)) { return $cleanName }
|
|
$normalizedPrefix = $Prefix.Replace('\', '/').Trim('/')
|
|
if ([string]::IsNullOrEmpty($normalizedPrefix)) { return $cleanName }
|
|
return "$normalizedPrefix/$cleanName"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# AWS SigV4 / S3 helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Get-Bytes([string]$s) { return [System.Text.Encoding]::UTF8.GetBytes($s) }
|
|
|
|
function Get-HashHex([byte[]]$bytes) {
|
|
if ($null -eq $bytes) { $bytes = [byte[]]@() }
|
|
$sha = [System.Security.Cryptography.SHA256]::Create()
|
|
try {
|
|
$ms = New-Object System.IO.MemoryStream -ArgumentList (,$bytes)
|
|
try {
|
|
$hash = $sha.ComputeHash([System.IO.Stream]$ms)
|
|
} finally { $ms.Dispose() }
|
|
return ([BitConverter]::ToString($hash)).Replace('-', '').ToLowerInvariant()
|
|
} finally { $sha.Dispose() }
|
|
}
|
|
|
|
function HmacSha256([byte[]]$key, [string]$data) {
|
|
$h = [System.Security.Cryptography.HMACSHA256]::new($key)
|
|
try {
|
|
$b = [System.Text.Encoding]::UTF8.GetBytes($data)
|
|
$ms = New-Object System.IO.MemoryStream -ArgumentList (,$b)
|
|
try {
|
|
return $h.ComputeHash([System.IO.Stream]$ms)
|
|
} finally { $ms.Dispose() }
|
|
} finally { $h.Dispose() }
|
|
}
|
|
|
|
function GetSignatureKey([string]$secret, [string]$dateStamp, [string]$regionName, [string]$serviceName) {
|
|
$kDate = HmacSha256 (Get-Bytes ('AWS4' + $secret)) $dateStamp
|
|
$kRegion = HmacSha256 $kDate $regionName
|
|
$kService = HmacSha256 $kRegion $serviceName
|
|
HmacSha256 $kService 'aws4_request'
|
|
}
|
|
|
|
function UriEncode([string]$data, [bool]$encodeSlash) {
|
|
if ($null -eq $data) { return '' }
|
|
$enc = [System.Uri]::EscapeDataString($data)
|
|
if (-not $encodeSlash) { $enc = $enc -replace '%2F', '/' }
|
|
return $enc
|
|
}
|
|
|
|
function BuildCanonicalPath([System.Uri]$uri) {
|
|
$segments = $uri.AbsolutePath.Split('/')
|
|
$encoded = @()
|
|
foreach ($s in $segments) { $encoded += (UriEncode $s $false) }
|
|
$path = ($encoded -join '/')
|
|
if (-not $path.StartsWith('/')) { $path = '/' + $path }
|
|
return $path
|
|
}
|
|
|
|
function ToHex([byte[]]$b) { ([BitConverter]::ToString($b)).Replace('-', '').ToLowerInvariant() }
|
|
|
|
function BuildAuthHeaders($method, [System.Uri]$uri, [string]$region, [string]$accessKey, [string]$secretKey, [string]$payloadHash) {
|
|
$algorithm = 'AWS4-HMAC-SHA256'
|
|
$timestamp = (Get-Date).ToUniversalTime()
|
|
$amzDate = $timestamp.ToString('yyyyMMddTHHmmssZ')
|
|
$dateStamp = $timestamp.ToString('yyyyMMdd')
|
|
$hostHeader = $uri.Host
|
|
if (-not $uri.IsDefaultPort) { $hostHeader = "${hostHeader}:$($uri.Port)" }
|
|
$canonicalUri = BuildCanonicalPath $uri
|
|
$canonicalQueryString = ''
|
|
$canonicalHeaders = "host:$hostHeader`n" + "x-amz-content-sha256:$payloadHash`n" + "x-amz-date:$amzDate`n"
|
|
$signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
|
|
$canonicalRequest = "$method`n$canonicalUri`n$canonicalQueryString`n$canonicalHeaders`n$signedHeaders`n$payloadHash"
|
|
$credentialScope = "$dateStamp/$region/s3/aws4_request"
|
|
$stringToSign = "$algorithm`n$amzDate`n$credentialScope`n$((Get-HashHex (Get-Bytes $canonicalRequest)))"
|
|
$signingKey = GetSignatureKey $secretKey $dateStamp $region 's3'
|
|
$signature = ToHex (HmacSha256 $signingKey $stringToSign)
|
|
$authHeader = "$algorithm Credential=$accessKey/$credentialScope, SignedHeaders=$signedHeaders, Signature=$signature"
|
|
@{
|
|
'x-amz-date' = $amzDate
|
|
'x-amz-content-sha256' = $payloadHash
|
|
'Authorization' = $authHeader
|
|
}
|
|
}
|
|
|
|
function BuildS3Uri([string]$endpointUrl, [string]$bucket, [string]$key, [bool]$forcePathStyle) {
|
|
$base = [System.Uri]$endpointUrl
|
|
$builder = [System.UriBuilder]::new($base)
|
|
$normalizedKey = $key.Replace('\', '/').TrimStart('/')
|
|
if ($forcePathStyle) {
|
|
$path = $builder.Path.TrimEnd('/')
|
|
if ([string]::IsNullOrEmpty($path)) { $path = '/' }
|
|
$builder.Path = ($path.TrimEnd('/') + '/' + $bucket + '/' + $normalizedKey)
|
|
} else {
|
|
$builder.Host = "$bucket." + $builder.Host
|
|
$path = $builder.Path.TrimEnd('/')
|
|
if ([string]::IsNullOrEmpty($path)) { $path = '/' }
|
|
$builder.Path = ($path.TrimEnd('/') + '/' + $normalizedKey)
|
|
}
|
|
return $builder.Uri
|
|
}
|
|
|
|
function Ensure-AWSS3Module {
|
|
try { $null = [Amazon.S3.AmazonS3Client]; return } catch {}
|
|
try { Import-Module -Name AWS.Tools.S3 -ErrorAction Stop; return } catch {}
|
|
try { Import-Module -Name AWSPowerShell.NetCore -ErrorAction Stop; return } catch {}
|
|
throw "AWS Tools for PowerShell not found. Install with: Install-Module AWS.Tools.S3 -Scope CurrentUser"
|
|
}
|
|
|
|
function New-S3Client {
|
|
param(
|
|
[string]$EndpointUrl,
|
|
[string]$Region,
|
|
[string]$AccessKeyId,
|
|
[string]$SecretAccessKey,
|
|
[bool]$ForcePathStyle = $true
|
|
)
|
|
Ensure-AWSS3Module
|
|
$creds = New-Object Amazon.Runtime.BasicAWSCredentials($AccessKeyId, $SecretAccessKey)
|
|
$cfg = New-Object Amazon.S3.AmazonS3Config
|
|
if ($EndpointUrl) { $cfg.ServiceURL = $EndpointUrl }
|
|
if ($Region) { try { $cfg.RegionEndpoint = [Amazon.RegionEndpoint]::GetBySystemName($Region) } catch {} }
|
|
$cfg.ForcePathStyle = [bool]$ForcePathStyle
|
|
return (New-Object Amazon.S3.AmazonS3Client($creds, $cfg))
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Active Directory credential and permission helpers
|
|
# (requires the ActiveDirectory module to be loaded before calling)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
function Get-ValidatedADCredential {
|
|
param (
|
|
[Parameter(Mandatory)][string]$DomainName,
|
|
[Parameter(Mandatory)][string]$Server,
|
|
[int]$MaxAttempts = 3
|
|
)
|
|
|
|
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
|
|
$credential = Get-Credential -Message "Enter AD credentials with replication rights for $DomainName (attempt $attempt/$MaxAttempts)"
|
|
if ($null -eq $credential) {
|
|
throw "Credential prompt was cancelled."
|
|
}
|
|
|
|
try {
|
|
Get-ADDomain -Server $Server -Credential $credential -ErrorAction Stop | Out-Null
|
|
Write-Verbose ("Credential pre-check succeeded for '{0}' against '{1}'." -f $credential.UserName, $Server)
|
|
return $credential
|
|
} catch {
|
|
$message = $_.Exception.Message
|
|
if ($message -match 'rejected the client credentials|unknown user name|bad password|logon failure') {
|
|
Write-Warning ("Credentials were rejected for '{0}' (attempt {1}/{2})." -f $credential.UserName, $attempt, $MaxAttempts)
|
|
if ($attempt -lt $MaxAttempts) { continue }
|
|
throw "Credentials were rejected by domain controller '$Server' after $MaxAttempts attempts."
|
|
}
|
|
throw "Credential pre-check failed against '$Server': $message"
|
|
}
|
|
}
|
|
}
|
|
|
|
function Test-ReplicationPermissions {
|
|
param(
|
|
[Parameter(Mandatory)][string]$DomainDN,
|
|
[Parameter(Mandatory)][string]$Server,
|
|
[Parameter(Mandatory)][System.Management.Automation.PSCredential]$Credential
|
|
)
|
|
|
|
$requiredRights = [ordered]@{
|
|
'Replicating Directory Changes' = [guid]'1131f6aa-9c07-11d1-f79f-00c04fc2dcd2'
|
|
'Replicating Directory Changes All' = [guid]'1131f6ab-9c07-11d1-f79f-00c04fc2dcd2'
|
|
'Replicating Directory Changes In Filtered Set' = [guid]'89e95b76-444d-4c62-991a-0facbeda640c'
|
|
}
|
|
|
|
$callerSids = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
|
try {
|
|
$samName = $Credential.UserName -replace '^.*\\', ''
|
|
$adUser = Get-ADUser -Identity $samName -Server $Server -Credential $Credential `
|
|
-Properties SID, DistinguishedName -ErrorAction Stop
|
|
[void]$callerSids.Add($adUser.SID.Value)
|
|
|
|
# tokenGroups is a constructed attribute containing all SIDs in the user's token,
|
|
# including nested group memberships — more reliable than walking MemberOf recursively
|
|
$userDe = New-Object System.DirectoryServices.DirectoryEntry(
|
|
"LDAP://$Server/$($adUser.DistinguishedName)",
|
|
$Credential.UserName,
|
|
$Credential.GetNetworkCredential().Password
|
|
)
|
|
$userDe.RefreshCache(@('tokenGroups'))
|
|
foreach ($sidBytes in $userDe.Properties['tokenGroups']) {
|
|
$sid = New-Object System.Security.Principal.SecurityIdentifier($sidBytes, 0)
|
|
[void]$callerSids.Add($sid.Value)
|
|
}
|
|
} catch {
|
|
Write-Warning ("Could not resolve account SIDs for replication permission pre-check: {0}. Skipping." -f $_.Exception.Message)
|
|
return
|
|
}
|
|
|
|
$acl = $null
|
|
try {
|
|
$de = New-Object System.DirectoryServices.DirectoryEntry(
|
|
"LDAP://$Server/$DomainDN",
|
|
$Credential.UserName,
|
|
$Credential.GetNetworkCredential().Password
|
|
)
|
|
$acl = $de.ObjectSecurity.GetAccessRules(
|
|
$true, $true, [System.Security.Principal.SecurityIdentifier])
|
|
} catch {
|
|
Write-Warning ("Could not read domain object ACL for replication permission pre-check: {0}. Skipping." -f $_.Exception.Message)
|
|
return
|
|
}
|
|
|
|
$missing = @()
|
|
foreach ($rightName in $requiredRights.Keys) {
|
|
$guid = $requiredRights[$rightName]
|
|
$granted = $false
|
|
$aceExistsForGuid = $false
|
|
foreach ($ace in $acl) {
|
|
if ($ace.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { continue }
|
|
$rights = $ace.ActiveDirectoryRights
|
|
$hasExtended = [bool]($rights -band [System.DirectoryServices.ActiveDirectoryRights]::ExtendedRight)
|
|
$hasGenericAll = [bool]($rights -band [System.DirectoryServices.ActiveDirectoryRights]::GenericAll)
|
|
# Match: exact GUID, OR ExtendedRight with empty ObjectType (all extended rights), OR GenericAll
|
|
$isMatch = $hasGenericAll `
|
|
-or ($hasExtended -and $ace.ObjectType -eq [guid]::Empty) `
|
|
-or ($hasExtended -and $ace.ObjectType -eq $guid)
|
|
if (-not $isMatch) { continue }
|
|
if ($ace.ObjectType -eq $guid) { $aceExistsForGuid = $true }
|
|
if ($callerSids.Contains($ace.IdentityReference.Value)) { $granted = $true; break }
|
|
}
|
|
if (-not $granted) {
|
|
$hint = if ($aceExistsForGuid) {
|
|
' (ACE exists on the domain object but is not assigned to this account or any of its groups)'
|
|
} else {
|
|
' (no ACE found for this right on the domain object at all)'
|
|
}
|
|
$missing += $rightName + $hint
|
|
}
|
|
}
|
|
|
|
if ($missing.Count -gt 0) {
|
|
throw ("Account '{0}' failed replication permission check on '{1}':`n - {2}`n`nGrant these extended rights on the domain object to allow DCSync-based hash retrieval." -f `
|
|
$Credential.UserName, $DomainDN, ($missing -join "`n - "))
|
|
}
|
|
|
|
Write-Host ("[+] Replication permissions verified for '{0}'." -f $Credential.UserName)
|
|
}
|