$script:ElysiumVersion = '2.4.4' 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 ) $allThreeRights = [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' } # DSInternals 7.0 fetches the AD schema via DRS (GetNCChanges) before replicating accounts. # The schema NC has its own ACL - rights on the domain NC do not cover it. # Older DSInternals read schema via LDAP (no special rights needed); v7.0 switched to DRS. $schemaDN = "CN=Schema,CN=Configuration,$DomainDN" $ncsToCheck = [ordered]@{ $DomainDN = $allThreeRights $schemaDN = [ordered]@{ 'Replicating Directory Changes' = [guid]'1131f6aa-9c07-11d1-f79f-00c04fc2dcd2' } } $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, adminCount -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 $adUserWithTokenGroups = Get-ADUser -Identity $samName -Server $Server -Credential $Credential ` -Properties tokenGroups -ErrorAction Stop foreach ($sidBytes in $adUserWithTokenGroups.tokenGroups) { $sid = New-Object System.Security.Principal.SecurityIdentifier(@([byte[]]$sidBytes), 0) [void]$callerSids.Add($sid.Value) } # adminCount=1 means SDProp is managing this account; it runs every 60 min and can # silently revert replication rights or group memberships granted to the account if ($adUser.adminCount -eq 1) { Write-Warning ("Account '{0}' has adminCount=1 (SDProp-protected). It is or was a member of a privileged group. SDProp runs every 60 minutes and may silently revert replication rights or group memberships on this account." -f $Credential.UserName) } # Protected Users group (RID 525) blocks the Kerberos mechanisms DSInternals uses for DRS $domainSidStr = $adUser.SID.Value.Substring(0, $adUser.SID.Value.LastIndexOf('-')) $protectedUsersSid = "$domainSidStr-525" if ($callerSids.Contains($protectedUsersSid)) { Write-Warning ("Account '{0}' is a member of Protected Users. This group restricts Kerberos delegation and RC4 authentication that DSInternals requires for DRS replication - access will be denied regardless of assigned rights." -f $Credential.UserName) } } catch { Write-Warning ("Could not resolve account SIDs for replication permission pre-check: {0}. Skipping." -f $_.Exception.Message) return } $allMissingLines = @() foreach ($ncEntry in $ncsToCheck.GetEnumerator()) { $ncDN = $ncEntry.Key $rightsToCheck = $ncEntry.Value $acl = $null try { $de = New-Object System.DirectoryServices.DirectoryEntry( "LDAP://$Server/$ncDN", $Credential.UserName, $Credential.GetNetworkCredential().Password ) $acl = $de.ObjectSecurity.GetAccessRules( $true, $true, [System.Security.Principal.SecurityIdentifier]) } catch { Write-Warning ("Could not read ACL on '$ncDN' for replication permission pre-check: {0}. Skipping." -f $_.Exception.Message) continue } foreach ($rightName in $rightsToCheck.Keys) { $guid = $rightsToCheck[$rightName] $granted = $false $aceExistsForGuid = $false foreach ($ace in $acl) { if ($ace.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { continue } # InheritOnly ACEs apply to child objects only - the NC root itself is not covered if ([bool]($ace.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly)) { 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 but not assigned to this account or any of its groups)' } else { ' (no ACE found for this right on this object)' } $allMissingLines += "[on $ncDN] $rightName$hint" } } } if ($allMissingLines.Count -gt 0) { $schemaNote = '' if ($allMissingLines | Where-Object { $_ -match [regex]::Escape($schemaDN) }) { $schemaNote = ("`n`nNOTE: DSInternals 7.0 fetches the AD schema via DRS before replicating accounts." + " Grant 'Replicating Directory Changes' on CN=Configuration,$DomainDN" + " (covers Schema NC via inheritance) in addition to the domain NC rights.") } throw ("Account '{0}' failed replication permission check:`n - {1}{2}" -f ` $Credential.UserName, ($allMissingLines -join "`n - "), $schemaNote) } Write-Host ("[+] Replication permissions verified for '{0}' on domain NC and schema NC." -f $Credential.UserName) } function Test-DCClockSkew { param( [Parameter(Mandatory)][string]$Server, [Parameter(Mandatory)][System.Management.Automation.PSCredential]$Credential ) try { $rootDse = New-Object System.DirectoryServices.DirectoryEntry( "LDAP://$Server/RootDSE", $Credential.UserName, $Credential.GetNetworkCredential().Password ) $dcTimeStr = $rootDse.Properties['currentTime'][0] $dcTime = [datetime]::ParseExact( $dcTimeStr, 'yyyyMMddHHmmss.0Z', [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal).ToUniversalTime() $skewSeconds = [Math]::Abs(([datetime]::UtcNow - $dcTime).TotalSeconds) if ($skewSeconds -gt 300) { Write-Warning ("Clock skew of {0:N0}s with '{1}' exceeds Kerberos limit of 300s - authentication will fail. Sync the clock: w32tm /resync /force" -f $skewSeconds, $Server) } elseif ($skewSeconds -gt 60) { Write-Warning ("Clock skew of {0:N0}s detected with '{1}'. Kerberos allows up to 300s - approaching the limit." -f $skewSeconds, $Server) } else { Write-Host ("[+] Clock skew with '{0}': {1:N0}s (OK)." -f $Server, $skewSeconds) } } catch { Write-Warning ("Could not check clock skew against '{0}': {1}" -f $Server, $_.Exception.Message) } }