Private/CAUrls.ps1

<#
.SYNOPSIS
    CA Manager - AIA / CDP / OCSP publication-URL routing (dashboard menu option 5). Requires
    Modules\CACore.ps1 dot-sourced.
 
.DESCRIPTION
    Get-CAPublicationUrlPlan is a PURE function - it turns the CA_* answer fields into the exact
    CRLPublicationURLs / CACertPublicationURLs MULTI_SZ entries certutil -setreg expects, following
    the recipe captured from a real client's live issuing CA (see Examples_Sources\ for the captured recipe). It
    makes no assumptions beyond that captured recipe, so if any of it is wrong it's a one-line data
    fix here.
 
    Set-CAPublicationUrls applies the plan via certutil -setreg (behind Invoke-CAStep, so it honours
    dry-run), republishes the CRL, and restarts CertSvc.
 
    ORDER-CRITICAL: this must run BEFORE any certificate is issued - every issued cert bakes its
    CDP/AIA/OCSP URLs in permanently.
 
.NOTES
    certutil -setreg replacement tokens used below:
      %1 ServerDNSName %2 ServerShortName %3 CaName %4 CertificateNameSuffix (renewal)
      %6 ConfigContainer %7 CATruncatedName %8 CRLNameSuffix %9 DeltaCRLAllowed
      %10 CDPObjectClass %11 CAObjectClass
    Flag values:
      1 ServerPublish 2 AddToCertCDP/AIA 4 AddToFreshestCRL 8 AddToCrlCDP
      32 AddToCertOCSP 64 ServerPublishDelta 128 AddToIDP
      -> 65 = ServerPublish + ServerPublishDelta ; 6 = AddToCertCDP + AddToFreshestCRL
#>


function Get-CAPublicationUrlPlan {
    <#
    .SYNOPSIS
        Builds the target CDP and AIA MULTI_SZ entry lists from the CA_* answers. Pure - no I/O.
 
    .PARAMETER CrlFqdn
        External hostname the App Proxy publishes for CRL + CA-cert (AIA) retrieval, e.g.
        'crl-<company>.msappproxy.net'. (CA_AppProxyCrlFqdn)
 
    .PARAMETER OcspFqdn
        External hostname the App Proxy publishes for OCSP, e.g. 'ocsprelay-<company>.msappproxy.net'.
        (CA_AppProxyOcspFqdn)
 
    .PARAMETER DeltaCrl
        $true to publish delta CRLs (adds the ServerPublishDelta flag to the publish targets).
 
    .PARAMETER CrlSharePath
        UNC or local path of the CRL distribution share, or '' / $null for None. When set, an extra
        publish target is added for it. (CA_CrlSharePath, when CA_CrlShareMode is not None)
    #>

    param(
        [Parameter(Mandatory)][string]$CrlFqdn,
        [Parameter(Mandatory)][string]$OcspFqdn,
        [bool]$DeltaCrl = $true,
        [string]$CrlSharePath
    )

    $pub = if ($DeltaCrl) { 65 } else { 1 }   # ServerPublish (+ ServerPublishDelta)

    $cdp = @(
        "${pub}:%windir%\system32\CertSrv\CertEnroll\%3%8%9.crl"
        "${pub}:ldap:///CN=%7%8,CN=%2,CN=CDP,CN=Public Key Services,CN=Services,%6%10"
        "6:http://$CrlFqdn/CertEnroll/%3%8%9.crl"
    )
    if (-not [string]::IsNullOrWhiteSpace($CrlSharePath)) {
        $p = ($CrlSharePath.TrimEnd('\', '/')) -replace '\\', '/'
        $cdp += "${pub}:file://$p/%3%8%9.crl"
    }

    $aia = @(
        "1:%windir%\system32\CertSrv\CertEnroll\%1_%3%4.crt"
        "1:ldap:///CN=%7,CN=AIA,CN=Public Key Services,CN=Services,%6%11"
        # Filename MUST match the local-publish entry above (%1_%3%4.crt) - the CA only ever writes the
        # CA-cert file to disk under that name (server-DNS-name-prefixed, %4 disambiguates renewals).
        # That reference client's own live capture has this entry as bare "%3.crt", mismatched against
        # its own "%1_%3%4.crt" local-publish filename - a pre-existing bug
        # transcribed faithfully from that recipe, caught live on a client's CA server 2026-09-10 (external AIA
        # cert retrieval 404s while CDP, whose local/external filenames DO match - %3%8%9.crl both
        # places - works fine). Use the matching filename here instead of perpetuating it.
        "2:http://$CrlFqdn/CertEnroll/%1_%3%4.crt"
        "32:http://$OcspFqdn/ocsp"
    )

    return [pscustomobject]@{
        Cdp = $cdp
        Aia = $aia
        # the certutil -setreg argument form (entries joined with a literal \n)
        CdpSetRegValue = ($cdp -join "\n")
        AiaSetRegValue = ($aia -join "\n")
    }
}

function Set-CAPublicationUrls {
    <#
    .SYNOPSIS
        Applies a Get-CAPublicationUrlPlan result to the local CA: certutil -setreg for CDP + AIA,
        then certutil -crl (republish), then restart CertSvc. Every mutating call goes through
        Invoke-CAStep so dry-run just prints the commands.
    #>

    param(
        [Parameter(Mandatory)]$Plan,
        [switch]$SkipCrlRepublish,
        [switch]$SkipServiceRestart
    )

    # The CA only substitutes its %1-%12 tokens in these paths - it does NOT expand environment
    # variables like %windir%/%SystemRoot% (it strips the percents -> a bogus relative "windir\..."
    # path -> 0x8007010b ERROR_DIRECTORY at CRL-publish). AD CS's own install default writes the
    # literal path for this reason. Expand env vars here (this runs on the CA), leaving %1-%12 alone.
    $cdpVal = (($Plan.Cdp | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_) }) -join "\n")
    $aiaVal = (($Plan.Aia | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_) }) -join "\n")

    Invoke-CAStep -Description "Set CRL distribution points (CRLPublicationURLs)" `
        -Commands @("certutil -setreg CA\CRLPublicationURLs `"$cdpVal`"") `
        -Action {
            $o = & certutil.exe -setreg CA\CRLPublicationURLs $cdpVal 2>&1 | Out-String
            if ($LASTEXITCODE -ne 0) { throw "certutil -setreg CA\CRLPublicationURLs failed (exit $LASTEXITCODE): $o" }
            $o
        } | Out-Null

    Invoke-CAStep -Description "Set authority information access + OCSP (CACertPublicationURLs)" `
        -Commands @("certutil -setreg CA\CACertPublicationURLs `"$aiaVal`"") `
        -Action {
            $o = & certutil.exe -setreg CA\CACertPublicationURLs $aiaVal 2>&1 | Out-String
            if ($LASTEXITCODE -ne 0) { throw "certutil -setreg CA\CACertPublicationURLs failed (exit $LASTEXITCODE): $o" }
            $o
        } | Out-Null

    if (-not $SkipServiceRestart) {
        Invoke-CAStep -Description "Restart the CA service so the new URL registry values take effect" `
            -Commands @("Restart-Service CertSvc (then wait for the CA's RPC endpoint via certutil -ping)") `
            -Action {
                Restart-Service -Name CertSvc -Force
                # 'Running' != RPC-ready - certutil -ping returns 0 only once the CA answers RPC.
                $rpcUp = $false
                for ($i = 0; $i -lt 20; $i++) {
                    & certutil.exe -ping 2>&1 | Out-Null
                    if ($LASTEXITCODE -eq 0) { $rpcUp = $true; break }
                    Start-Sleep -Seconds 3
                }
                if (-not $rpcUp) { throw "CertSvc restarted but the CA is not answering RPC (certutil -ping) after 60s" }
            } | Out-Null
    }

    if (-not $SkipCrlRepublish) {
        Invoke-CAStep -Description "Republish the CRL so the new CDP/AIA are reflected" `
            -Commands @("certutil -crl") `
            -Action {
                $o = ''
                for ($i = 1; $i -le 4; $i++) {
                    $o = & certutil.exe -crl 2>&1 | Out-String
                    if ($LASTEXITCODE -eq 0) { return $o }
                    if ($o -notmatch '0x800706ba|RPC server is unavailable|1722') { break }   # only retry the RPC-warmup race
                    Start-Sleep -Seconds 5
                }
                throw "certutil -crl failed (exit $LASTEXITCODE): $o"
            } -ContinueOnError | Out-Null
    }
}