Last active
August 10, 2026 15:44
-
-
Save tcartwright/2df67c5847704a6009cfa0b354ccb949 to your computer and use it in GitHub Desktop.
POWERSHELL: Sync Time From Internet: Gets the current UTC time from an internet time source and resets this machine's LOCAL clock to the correct current local time. It NEVER changes the time zone and NEVER touches the "adjust for DST automatically" switch.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| Sets this machine's clock to real internet time, correctly, on boxes where auto-DST is off. | |
| .DESCRIPTION | |
| Manual counterpart to timeset.cfm, for fixing a cycle server by hand - and the reference | |
| implementation of the DST-safe conversion, because the obvious way to write it is wrong. | |
| The trap | |
| -------- | |
| Converting UTC to local time with [datetimeoffset]::LocalDateTime relies on | |
| TimeZoneInfo.Local carrying the zone's DST rules. Windows' "automatically adjust clock | |
| for daylight saving time" setting, when OFF, sets DynamicDaylightTimeDisabled=1 and .NET | |
| then reports the zone with NO adjustment rules. An August instant resolves as EST (-5) | |
| instead of EDT (-4), so the clock is set an hour behind - and an hour is far more than | |
| Azure DevOps allows when validating an agent's OAuth tokens, so the agent stays offline | |
| while every visible symptom says the date is correct. | |
| Observed on a cycle server: TimeZoneInfo.Local had 0 adjustment rules, and | |
| ([datetimeoffset]'2026-08-10T15:03:32Z').LocalDateTime returned 10:03 rather than 11:03. | |
| This script uses FindSystemTimeZoneById instead, which reads the zone definition straight | |
| from the registry and carries the DST rules regardless of that switch. It reports both | |
| answers so the discrepancy is visible on affected machines. | |
| Corroboration | |
| ------------- | |
| Time is taken from several sources and at least two must agree before anything is | |
| changed, so one unreachable or lying source cannot move a production clock. NTP is tried | |
| first; if UDP 123 is blocked the plain-HTTP endpoints timeset.cfm uses are the fallback - | |
| those work on port 80 and need no certificate, which matters when the clock is already | |
| too wrong for HTTPS. | |
| The timezone and the auto-DST setting are never touched. Only the clock. | |
| .PARAMETER WhatIf | |
| Report everything and change nothing. Worth running first. | |
| .EXAMPLE | |
| .\Reset-ClockFromInternet.ps1 -WhatIf | |
| .EXAMPLE | |
| .\Reset-ClockFromInternet.ps1 | |
| #> | |
| [CmdletBinding(SupportsShouldProcess)] | |
| param( | |
| [string[]] $NtpServer = @('time.windows.com', 'pool.ntp.org', 'time.nist.gov'), | |
| [string[]] $HttpTimeSource = @('www.msftconnecttest.com', 'detectportal.firefox.com', 'connectivitycheck.gstatic.com'), | |
| [int] $TimeoutMs = 4000, | |
| [int] $MaxDisagreementSeconds = 300 | |
| ) | |
| Set-StrictMode -Version Latest | |
| $ErrorActionPreference = 'Continue' | |
| function Get-NtpUtc { | |
| <# Minimal SNTP client. Reads the transmit timestamp at bytes 40..47, which is seconds | |
| and fractional seconds since 1900-01-01 UTC. #> | |
| param([Parameter(Mandatory)][string] $Server, [int] $Timeout = 4000) | |
| $packet = New-Object byte[] 48 | |
| $packet[0] = 0x1B # LI = 0, version = 3, mode = 3 (client) | |
| $socket = New-Object Net.Sockets.Socket( | |
| [Net.Sockets.AddressFamily]::InterNetwork, | |
| [Net.Sockets.SocketType]::Dgram, | |
| [Net.Sockets.ProtocolType]::Udp) | |
| try { | |
| $socket.ReceiveTimeout = $Timeout | |
| $socket.SendTimeout = $Timeout | |
| $address = @([Net.Dns]::GetHostAddresses($Server) | Where-Object { $_.AddressFamily -eq 'InterNetwork' })[0] | |
| if (-not $address) { throw "no IPv4 address for $Server" } | |
| $socket.Connect((New-Object Net.IPEndPoint($address, 123))) | |
| [void]$socket.Send($packet) | |
| [void]$socket.Receive($packet) | |
| } | |
| finally { $socket.Close() } | |
| $seconds = ([int64]$packet[40] -shl 24) + ([int64]$packet[41] -shl 16) + ([int64]$packet[42] -shl 8) + [int64]$packet[43] | |
| $fraction = ([int64]$packet[44] -shl 24) + ([int64]$packet[45] -shl 16) + ([int64]$packet[46] -shl 8) + [int64]$packet[47] | |
| if ($seconds -eq 0) { throw "empty response from $Server" } | |
| $epoch = New-Object DateTime(1900, 1, 1, 0, 0, 0, [DateTimeKind]::Utc) | |
| $epoch.AddMilliseconds(($seconds * 1000.0) + ($fraction * 1000.0 / 4294967296.0)) | |
| } | |
| function Get-HttpUtc { | |
| param([Parameter(Mandatory)][string] $HostName, [int] $Timeout = 4000) | |
| $request = [Net.HttpWebRequest]::Create(("http://{0}/" -f $HostName)) | |
| $request.Method = 'HEAD' | |
| $request.Timeout = $Timeout | |
| $request.AllowAutoRedirect = $false | |
| $response = $request.GetResponse() | |
| try { $header = $response.Headers['Date'] } finally { $response.Close() } | |
| if (-not $header) { throw "no Date header from $HostName" } | |
| [DateTime]::Parse($header, [Globalization.CultureInfo]::InvariantCulture, | |
| [Globalization.DateTimeStyles]::AdjustToUniversal -bor [Globalization.DateTimeStyles]::AssumeUniversal) | |
| } | |
| # ------------------------------------------------------------------------------ gather time | |
| $readings = @() | |
| foreach ($server in $NtpServer) { | |
| try { | |
| $utc = Get-NtpUtc -Server $server -Timeout $TimeoutMs | |
| $readings += [pscustomobject]@{ Source = "ntp:$server"; Utc = $utc } | |
| Write-Host (" {0,-34} {1:yyyy-MM-dd HH:mm:ss}" -f "ntp:$server", $utc) | |
| } catch { | |
| Write-Host (" {0,-34} unavailable ({1})" -f "ntp:$server", $_.Exception.Message.Split([Environment]::NewLine)[0]) | |
| } | |
| } | |
| # Only fall back when NTP could not corroborate itself - HTTP is second-granularity, which is | |
| # fine for a five-minute tolerance but worse than NTP when NTP is available. | |
| if ($readings.Count -lt 2) { | |
| Write-Host ' (fewer than two NTP replies - falling back to plain-HTTP Date headers)' | |
| foreach ($h in $HttpTimeSource) { | |
| try { | |
| $utc = Get-HttpUtc -HostName $h -Timeout $TimeoutMs | |
| $readings += [pscustomobject]@{ Source = "http:$h"; Utc = $utc } | |
| Write-Host (" {0,-34} {1:yyyy-MM-dd HH:mm:ss}" -f "http:$h", $utc) | |
| } catch { | |
| Write-Host (" {0,-34} unavailable" -f "http:$h") | |
| } | |
| } | |
| } | |
| if ($readings.Count -lt 2) { | |
| throw ("Only {0} time source(s) answered. Refusing to set the clock on a single unverified reading - one hijacked or broken source should not be able to move this machine's clock." -f $readings.Count) | |
| } | |
| # --------------------------------------------------------------------------- corroborate | |
| $agreed = $null | |
| for ($i = 0; $i -lt $readings.Count -and -not $agreed; $i++) { | |
| for ($j = $i + 1; $j -lt $readings.Count; $j++) { | |
| $delta = [Math]::Abs((($readings[$i].Utc) - ($readings[$j].Utc)).TotalSeconds) | |
| if ($delta -le $MaxDisagreementSeconds) { | |
| $agreed = $readings[$i] | |
| Write-Host ("`n taking {0}, corroborated by {1} (agree within {2:N1}s)" -f $agreed.Source, $readings[$j].Source, $delta) | |
| break | |
| } | |
| } | |
| } | |
| if (-not $agreed) { | |
| throw ("No two sources agree within {0}s: {1}" -f $MaxDisagreementSeconds, | |
| (($readings | ForEach-Object { "$($_.Source)=$($_.Utc.ToString('HH:mm:ss'))" }) -join ', ')) | |
| } | |
| $realUtc = $agreed.Utc | |
| # ------------------------------------------------------- inspect what the OS believes | |
| # $zone is the zone as DEFINED (rules read from the registry, ignoring the auto-DST switch). | |
| # TimeZoneInfo.Local is the zone as the OS APPLIES it. When auto-DST is off those differ, and | |
| # the gap between them is the whole problem. | |
| $zone = [TimeZoneInfo]::FindSystemTimeZoneById([TimeZoneInfo]::Local.Id) | |
| $osOffset = [TimeZoneInfo]::Local.GetUtcOffset($realUtc) | |
| $trueOffset = $zone.GetUtcOffset($realUtc) | |
| $offsetGap = $trueOffset - $osOffset | |
| $ddtd = $null | |
| try { | |
| $ddtd = (Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\TimeZoneInformation' ` | |
| -Name DynamicDaylightTimeDisabled -ErrorAction Stop).DynamicDaylightTimeDisabled | |
| } catch { } | |
| $before = Get-Date | |
| $beforeUtc = $before.ToUniversalTime() | |
| $utcSkew = [Math]::Round(($beforeUtc - $realUtc).TotalSeconds, 3) | |
| Write-Host '' | |
| Write-Host '=== clock ===' | |
| Write-Host (" time zone : {0}" -f $zone.Id) | |
| Write-Host (" auto-DST : {0}" -f $(if ($ddtd -eq 1) { 'DISABLED' } else { 'enabled' })) | |
| Write-Host (" DST rules the OS uses : {0}" -f ([TimeZoneInfo]::Local.GetAdjustmentRules()).Count) | |
| Write-Host (" DST rules defined : {0}" -f ($zone.GetAdjustmentRules()).Count) | |
| Write-Host (" DST active right now : {0}" -f $zone.IsDaylightSavingTime($realUtc)) | |
| Write-Host (" offset the OS applies : {0}" -f $osOffset) | |
| Write-Host (" offset that is correct: {0}" -f $trueOffset) | |
| Write-Host '' | |
| Write-Host (" machine local time : {0:yyyy-MM-dd HH:mm:ss.fff}" -f $before) | |
| Write-Host (" machine time (UTC) : {0:yyyy-MM-dd HH:mm:ss.fff} <- what Azure DevOps validates" -f $beforeUtc) | |
| Write-Host (" internet time (UTC) : {0:yyyy-MM-dd HH:mm:ss.fff}" -f $realUtc) | |
| Write-Host (" UTC skew : {0} seconds ({1:N1} min, {2:N2} days)" -f $utcSkew, ($utcSkew / 60), ($utcSkew / 86400)) | |
| # ------------------------------------------------------------------------------------ apply | |
| $identity = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() | |
| if (-not $identity.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { | |
| throw 'This needs an elevated session (SeSystemTimePrivilege, and the timezone setting). Re-run as administrator.' | |
| } | |
| # --- step 1: make the OS apply the right offset ----------------------------------------- | |
| # Windows stores system time as UTC and renders local time as UTC + offset. So a machine can | |
| # show the correct WALL CLOCK and still have the wrong UTC, if the offset it applies is wrong | |
| # - which is exactly what a DST-disabled box in summer does. Setting the clock alone cannot | |
| # fix that: correct the offset first, or correct local time keeps producing wrong UTC. | |
| if ($offsetGap -ne [TimeSpan]::Zero) { | |
| Write-Host '' | |
| Write-Host (" !! The OS applies {0} but this zone is really {1} at this instant - a {2:N0}h error." -f $osOffset, $trueOffset, $offsetGap.TotalHours) | |
| Write-Host ' Because Windows keeps time internally as UTC, this makes UTC wrong even when the' | |
| Write-Host ' wall clock looks right. Setting the clock without fixing this just moves the error.' | |
| if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, ("enable automatic DST for '{0}'" -f $zone.Id))) { | |
| # tzutil is the supported way. Re-selecting the zone without the _dstoff suffix clears | |
| # DynamicDaylightTimeDisabled; poking the registry directly leaves other components | |
| # holding the old value. | |
| & tzutil.exe /s $zone.Id | |
| if ($LASTEXITCODE -ne 0) { throw ("tzutil /s '{0}' failed with exit code {1}" -f $zone.Id, $LASTEXITCODE) } | |
| # .NET caches the local zone for the life of the process. | |
| [TimeZoneInfo]::ClearCachedData() | |
| $nowOffset = [TimeZoneInfo]::Local.GetUtcOffset($realUtc) | |
| Write-Host (" automatic DST enabled - the OS now applies {0}" -f $nowOffset) | |
| if ($nowOffset -ne $trueOffset) { | |
| throw ("Offset is still {0}, expected {1}. Fix the timezone by hand before setting the clock." -f $nowOffset, $trueOffset) | |
| } | |
| } | |
| } | |
| # --- step 2: set the clock --------------------------------------------------------------- | |
| if ([Math]::Abs($utcSkew) -lt 1 -and $offsetGap -eq [TimeSpan]::Zero) { | |
| Write-Host '' | |
| Write-Host ' UTC is already within a second of correct and the offset is right - nothing to do.' | |
| return | |
| } | |
| # Derived from UTC, not from local time, so the result is correct regardless of what the | |
| # offset was when this script started. | |
| $elapsed = (Get-Date).ToUniversalTime() - $beforeUtc | |
| $targetUtc = $realUtc.Add($elapsed) | |
| $correctLocal = [TimeZoneInfo]::ConvertTimeFromUtc($targetUtc, [TimeZoneInfo]::Local) | |
| if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, ("set clock to {0:yyyy-MM-dd HH:mm:ss} local ({1:HH:mm:ss} UTC)" -f $correctLocal, $targetUtc))) { | |
| Set-Date -Date $correctLocal | Out-Null | |
| # Verify against UTC. Local time looking right is what made this problem invisible in the | |
| # first place, so the check that matters is the one on UTC. | |
| $afterUtc = (Get-Date).ToUniversalTime() | |
| $residual = [Math]::Round(($afterUtc - $realUtc.Add((Get-Date).ToUniversalTime() - $beforeUtc)).TotalSeconds, 3) | |
| Write-Host '' | |
| Write-Host (" local time is now : {0:yyyy-MM-dd HH:mm:ss} {1}" -f (Get-Date), [TimeZoneInfo]::Local.GetUtcOffset((Get-Date))) | |
| Write-Host (" UTC is now : {0:yyyy-MM-dd HH:mm:ss}" -f $afterUtc) | |
| Write-Host (" residual UTC error : {0} seconds" -f $residual) | |
| if ([Math]::Abs($residual) -gt 60) { | |
| Write-Host ' !! Still more than a minute out. Do not expect the agent to authenticate.' | |
| } else { | |
| Write-Host ' UTC is correct. Azure DevOps will accept this machine''s tokens.' | |
| } | |
| Write-Host '' | |
| Write-Host ' An agent that failed to authenticate while the clock was wrong does not always retry' | |
| Write-Host ' cleanly. Restart it:' | |
| Write-Host ' Restart-Service vstsagent.* -Force' | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| Tests whether outbound HTTPS works from this machine, and says which layer failed. | |
| .DESCRIPTION | |
| Written to diagnose an Azure DevOps deployment agent that shows offline on a machine | |
| that otherwise looks healthy. "HTTPS is broken" has several very different causes and | |
| they are easy to confuse, so each is tested separately and reported separately: | |
| DNS - does the name resolve at all | |
| TCP - does port 443 accept a connection (firewall, proxy, service down) | |
| TLS - does the handshake complete, and if not, is the CLOCK the reason | |
| HTTP - does the server answer, and how far off is our clock from its own | |
| The clock check is the point. A machine whose clock is wrong by years fails TLS because | |
| the certificate reads as not-yet-valid or expired; a machine wrong by an hour passes TLS | |
| but still cannot authenticate to Azure DevOps, because OAuth tokens carry nbf/exp claims | |
| validated with only a few minutes of tolerance. Those two look identical from the | |
| outside ("agent offline") and need completely different fixes, so both are reported with | |
| the actual numbers rather than a pass/fail. | |
| Read-only. Changes nothing, needs no elevation. | |
| .PARAMETER Target | |
| Hosts to test. Defaults to the endpoints an ADO agent and this repo's deploy scripts | |
| actually need. | |
| .EXAMPLE | |
| .\Test-OutboundHttps.ps1 | |
| .EXAMPLE | |
| .\Test-OutboundHttps.ps1 -Target dev.azure.com, myinternalhost -Port 443 | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [string[]] $Target = @('dev.azure.com', 'vsrm.dev.azure.com', 'login.microsoftonline.com'), | |
| [int] $Port = 443, | |
| [int] $TimeoutMs = 8000, | |
| # Plain-HTTP hosts used only to read a trustworthy Date header. These are the same | |
| # captive-portal endpoints timeset.cfm uses: no certificate to validate, so they still | |
| # answer when the clock is too wrong for HTTPS to work at all. | |
| [string[]] $TimeSource = @('www.msftconnecttest.com', 'detectportal.firefox.com') | |
| ) | |
| Clear-Host | |
| Set-StrictMode -Version Latest | |
| $ErrorActionPreference = 'Continue' | |
| function Write-Result { | |
| param([string] $Layer, [bool] $Ok, [string] $Detail) | |
| $tag = if ($Ok) { 'ok ' } else { 'FAIL' } | |
| Write-Host (" {0} {1,-5} {2}" -f $tag, $Layer, $Detail) | |
| } | |
| # ---------------------------------------------------------------------------- environment | |
| Write-Host '' | |
| Write-Host '=== this machine ===' | |
| Write-Host (" hostname : {0}" -f $env:COMPUTERNAME) | |
| Write-Host (" local time : {0}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff K')) | |
| Write-Host (" local time (UTC) : {0}" -f (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss')) | |
| Write-Host (" time zone : {0}" -f [TimeZoneInfo]::Local.Id) | |
| # Auto-DST off means .NET drops the zone's adjustment rules, so any UTC->local conversion | |
| # lands an hour out during summer. That silently breaks anything that sets the clock. | |
| $ddtd = $null | |
| try { | |
| $ddtd = (Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\TimeZoneInformation' ` | |
| -Name DynamicDaylightTimeDisabled -ErrorAction Stop).DynamicDaylightTimeDisabled | |
| } catch { } | |
| $autoDst = if ($ddtd -eq 1) { 'DISABLED' } else { 'enabled' } | |
| Write-Host (" auto-DST : {0}" -f $autoDst) | |
| Write-Host (" DST rules on Local : {0} (0 means UTC->local conversion will be wrong in summer)" -f ([TimeZoneInfo]::Local.GetAdjustmentRules()).Count) | |
| $proxy = [System.Net.WebRequest]::DefaultWebProxy | |
| $proxyFor = $null | |
| try { $proxyFor = $proxy.GetProxy([Uri]("https://{0}" -f $Target[0])) } catch { } | |
| if ($proxyFor -and $proxyFor.AbsoluteUri -notlike ("*{0}*" -f $Target[0])) { | |
| Write-Host (" proxy : {0}" -f $proxyFor.AbsoluteUri) | |
| } else { | |
| Write-Host ' proxy : none (direct)' | |
| } | |
| Write-Host (" .NET SecurityProtocol: {0}" -f [Net.ServicePointManager]::SecurityProtocol) | |
| # ------------------------------------------------------------------- real time, over HTTP | |
| # Established before the TLS tests so a clock verdict is available even if every HTTPS | |
| # attempt fails. | |
| $realUtc = $null | |
| foreach ($ts in $TimeSource) { | |
| try { | |
| $req = [Net.HttpWebRequest]::Create(("http://{0}/" -f $ts)) | |
| $req.Method = 'HEAD' | |
| $req.Timeout = $TimeoutMs | |
| $req.AllowAutoRedirect = $false | |
| $before = [DateTime]::UtcNow | |
| $resp = $req.GetResponse() | |
| $after = [DateTime]::UtcNow | |
| $hdr = $resp.Headers['Date'] | |
| $resp.Close() | |
| if ($hdr) { | |
| # Round-trip midpoint, so our own latency does not read as skew. | |
| $realUtc = [DateTime]::Parse($hdr, [Globalization.CultureInfo]::InvariantCulture, | |
| [Globalization.DateTimeStyles]::AdjustToUniversal -bor [Globalization.DateTimeStyles]::AssumeUniversal) | |
| $localMid = $before.AddTicks((($after - $before).Ticks / 2)) | |
| $skew = [int]($localMid - $realUtc).TotalSeconds | |
| break | |
| } | |
| } catch { } | |
| } | |
| Write-Host '' | |
| Write-Host '=== clock ===' | |
| if ($null -eq $realUtc) { | |
| Write-Host ' could not reach any plain-HTTP time source - cannot judge the clock' | |
| $skew = $null | |
| } else { | |
| Write-Host (" real UTC (internet) : {0}" -f $realUtc.ToString('yyyy-MM-dd HH:mm:ss')) | |
| Write-Host (" this machine (UTC) : {0}" -f (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss')) | |
| $absSkew = [Math]::Abs($skew) | |
| Write-Host (" skew : {0} s ({1:N1} min, {2:N2} days)" -f $skew, ($skew / 60), ($skew / 86400)) | |
| if ($absSkew -le 60) { | |
| Write-Host ' verdict : clock is fine' | |
| } elseif ($absSkew -le 300) { | |
| Write-Host ' verdict : WARNING - beyond a minute. Borderline for OAuth token validation.' | |
| } else { | |
| Write-Host ' verdict : PROBLEM - too far out for Azure DevOps to accept this machine''s tokens.' | |
| Write-Host ' TLS may still succeed, so the agent can look "connected but unauthorised".' | |
| } | |
| } | |
| # ------------------------------------------------------------------------ per-target tests | |
| foreach ($t in $Target) { | |
| Write-Host '' | |
| Write-Host ("=== {0}:{1} ===" -f $t, $Port) | |
| # --- DNS | |
| $addresses = @() | |
| try { | |
| $addresses = @([Net.Dns]::GetHostAddresses($t) | Where-Object { $_.AddressFamily -eq 'InterNetwork' } | ForEach-Object { $_.IPAddressToString }) | |
| Write-Result 'DNS' ($addresses.Count -gt 0) ($addresses -join ', ') | |
| } catch { | |
| Write-Result 'DNS' $false $_.Exception.Message | |
| continue | |
| } | |
| if (-not $addresses) { continue } | |
| # --- TCP | |
| $tcp = New-Object Net.Sockets.TcpClient | |
| try { | |
| $iar = $tcp.BeginConnect($t, $Port, $null, $null) | |
| if (-not $iar.AsyncWaitHandle.WaitOne($TimeoutMs)) { | |
| Write-Result 'TCP' $false ("no response within {0} ms - firewall or nothing listening" -f $TimeoutMs) | |
| $tcp.Close(); continue | |
| } | |
| $tcp.EndConnect($iar) | |
| Write-Result 'TCP' $true 'connected' | |
| } catch { | |
| Write-Result 'TCP' $false $_.Exception.Message | |
| $tcp.Close(); continue | |
| } | |
| # --- TLS, validating the certificate ourselves so the reason is visible | |
| $chainErrors = 'none' | |
| $tlsOk = $false | |
| $protocol = 'unknown' | |
| $script:certInfo = $null | |
| $script:capturedErrors = $null | |
| try { | |
| $callback = [Net.Security.RemoteCertificateValidationCallback] { | |
| param($senderObj, $certificate, $chain, $errors) | |
| # Copy the values out HERE. The certificate's native handle is released when the | |
| # SslStream closes, and reading its dates afterwards throws "m_safeCertContext is | |
| # an invalid handle" - which previously left the dates empty and made the clock | |
| # verdict below fire on a machine whose clock was perfectly correct. | |
| try { | |
| $c2 = New-Object Security.Cryptography.X509Certificates.X509Certificate2 $certificate | |
| $script:certInfo = [pscustomobject]@{ | |
| Subject = $c2.Subject | |
| NotBefore = $c2.NotBefore | |
| NotAfter = $c2.NotAfter | |
| } | |
| } catch { } | |
| $script:capturedErrors = $errors | |
| return $true # accept, so the handshake completes and we can report the reason | |
| } | |
| $ssl = New-Object Net.Security.SslStream($tcp.GetStream(), $false, $callback) | |
| # The protocols must be named explicitly. AuthenticateAsClient(host) on its own uses | |
| # SslProtocols.Default, which on Windows PowerShell 5.1 / .NET Framework means | |
| # SSL3 + TLS 1.0 - and dev.azure.com requires 1.2 or better, so the handshake fails | |
| # with "the client and server cannot communicate, because they do not possess a common | |
| # algorithm". That reads exactly like a broken box and is purely an artefact of the | |
| # default. Ask for 1.2 and 1.3, falling back if this runtime has no 1.3. | |
| $protocols = [Net.SecurityProtocolType]::Tls12 | |
| try { $protocols = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 } catch { } | |
| $ssl.AuthenticateAsClient($t, $null, [Security.Authentication.SslProtocols]$protocols, $false) | |
| $tlsOk = $true | |
| $protocol = $ssl.SslProtocol | |
| if ($script:capturedErrors -and $script:capturedErrors -ne 'None') { $chainErrors = $script:capturedErrors } | |
| $ssl.Close() | |
| } catch { | |
| Write-Result 'TLS' $false $_.Exception.Message | |
| } | |
| $cert = $script:certInfo | |
| if ($tlsOk) { | |
| Write-Result 'TLS' ($chainErrors -eq 'none') ("protocol={0} chainStatus={1}" -f $protocol, $chainErrors) | |
| if ($null -eq $cert) { | |
| Write-Host ' certificate details unavailable - skipping the clock-vs-cert check rather than guessing' | |
| } else { | |
| $nb = $cert.NotBefore | |
| $na = $cert.NotAfter | |
| $now = Get-Date | |
| Write-Host (" cert valid : {0:yyyy-MM-dd} .. {1:yyyy-MM-dd} subject={2}" -f $nb, $na, $cert.Subject) | |
| # The decisive test: is OUR clock inside the certificate's validity window? | |
| if ($now -lt $nb) { | |
| Write-Host (" >>> THE CLOCK IS WHY TLS FAILS: this machine reads {0:yyyy-MM-dd}, before the certificate becomes valid." -f $now) | |
| } elseif ($now -gt $na) { | |
| Write-Host (" >>> THE CLOCK IS WHY TLS FAILS: this machine reads {0:yyyy-MM-dd}, after the certificate expired." -f $now) | |
| } else { | |
| Write-Host ' clock is inside the certificate validity window, so TLS is not clock-blocked' | |
| } | |
| } | |
| } | |
| $tcp.Close() | |
| # --- HTTP, only meaningful if TLS worked | |
| if ($tlsOk) { | |
| try { | |
| $req = [Net.HttpWebRequest]::Create(("https://{0}/" -f $t)) | |
| $req.Method = 'HEAD' | |
| $req.Timeout = $TimeoutMs | |
| $req.AllowAutoRedirect = $false | |
| $resp = $req.GetResponse() | |
| Write-Result 'HTTP' $true ("{0} {1}" -f [int]$resp.StatusCode, $resp.StatusCode) | |
| $resp.Close() | |
| } catch [Net.WebException] { | |
| if ($_.Exception.Response) { | |
| $code = [int]$_.Exception.Response.StatusCode | |
| # Any HTTP status means the round trip worked; 401/403 on a root path is normal. | |
| Write-Result 'HTTP' $true ("{0} - reachable (status is not the point here)" -f $code) | |
| } else { | |
| Write-Result 'HTTP' $false $_.Exception.Message | |
| } | |
| } catch { | |
| Write-Result 'HTTP' $false $_.Exception.Message | |
| } | |
| } | |
| } | |
| Write-Host '' | |
| Write-Host '=== how to read this ===' | |
| Write-Host ' TCP fails -> firewall, proxy, or nothing listening. Not a clock problem.' | |
| Write-Host ' TLS fails + clock outside cert -> fix the clock; nothing else will work until you do.' | |
| Write-Host ' TLS fine but skew over ~5 min -> the agent will connect and still fail to authenticate.' | |
| Write-Host ' all layers ok but agent offline -> not connectivity. Read the agent''s _diag log next:' | |
| Write-Host ' Get-ChildItem C:\azagent\A1\_diag\Agent_*.log | Sort LastWriteTime | Select -Last 1 | Get-Content -Tail 60' | |
| Write-Host '' |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment