Private/CAWebEndpoints.ps1

<#
.SYNOPSIS
    CA Manager - the internal IIS endpoints the Entra Application Proxy forwards to (dashboard menu
    7, step 7b). Requires Modules\CACore.ps1 (Invoke-CAStep) and, to apply, the Windows Server IIS
    role + the WebAdministration provider.
 
.DESCRIPTION
    Get-CAWebEndpointPlan is PURE - it describes the `/CertEnroll/` virtual directory the App Proxy
    "CRL" app publishes: physical path (the CA's own CertEnroll dir, or CA_CrlSharePath when a CRL
    share is in play), directory browsing on, and the .crl / .crt static-content MIME types.
 
    Test-CAWebEndpoint is a read-only probe (feature installed? vdir present? MIME resolves? does
    http://localhost/CertEnroll/<caname>.crl answer 200?).
 
    Set-CAWebEndpoint is the engine - every mutation via Invoke-CAStep. Plain HTTP only, matching
    the reference client setup (the App Proxy connector forwards to local IIS over HTTP; externalAuthenticationType is
    passthru so there is no pre-auth and nothing to translate).
 
    The `/ocsp/` endpoint comes from the Online Responder role (menu 9) - it creates its own ISAPI
    app under the Default Web Site; nothing here touches it.
#>


# ---------------------------------------------------------------------------
function Get-CAWebEndpointPlan {
    <#
    .SYNOPSIS
        PURE. The CertEnroll virtual-directory plan. -PhysicalPath overrides the default
        (%windir%\System32\CertSrv\CertEnroll); pass CA_CrlSharePath for a CRL-share deployment.
    #>

    param(
        [string]$PhysicalPath = '%windir%\System32\CertSrv\CertEnroll',
        [string]$SiteName     = 'Default Web Site',
        [string]$VDirName     = 'CertEnroll',
        [string]$CACommonName
    )
    [pscustomobject]@{
        SiteName        = $SiteName
        VDirName        = $VDirName
        PhysicalPath    = $PhysicalPath
        DirectoryBrowse = $true
        MimeTypes       = @(
            [pscustomobject]@{ Ext = '.crl'; Mime = 'application/pkix-crl' }
            [pscustomobject]@{ Ext = '.crt'; Mime = 'application/x-x509-ca-cert' }
        )
        VerifyUrl       = "http://localhost/$VDirName/" + $(if ($CACommonName) { "$CACommonName.crl" } else { '' })
    }
}

# ---------------------------------------------------------------------------
function Show-CAWebEndpointPlan {
    param($Plan)
    Write-Host ""
    Write-Host (" IIS virtual directory: {0} -> {1}" -f "/$($Plan.VDirName)/", $Plan.PhysicalPath) -ForegroundColor White
    Write-Host (" site : {0}" -f $Plan.SiteName) -ForegroundColor Gray
    Write-Host (" directory browse: {0}" -f $Plan.DirectoryBrowse) -ForegroundColor Gray
    foreach ($m in $Plan.MimeTypes) { Write-Host (" MIME : {0} -> {1}" -f $m.Ext, $m.Mime) -ForegroundColor Gray }
    Write-Host  " scheme : plain HTTP (the App Proxy connector forwards over HTTP; passthru pre-auth)" -ForegroundColor DarkGray
    if ($Plan.VerifyUrl) { Write-Host (" verify with : {0}" -f $Plan.VerifyUrl) -ForegroundColor DarkGray }
}

# ---------------------------------------------------------------------------
function Test-CAWebEndpoint {
    <#
    .SYNOPSIS
        Read-only. Returns a [pscustomobject] of bools for the dashboard / menu-7 preview.
    #>

    param($Plan)
    $r = [ordered]@{
        IISInstalled       = $null
        VDirPresent        = $null
        VDirPathMatches    = $null
        CrlMimePresent     = $null
        AnswersHttp        = $null
    }
    try {
        if (Get-Command Get-WindowsFeature -ErrorAction SilentlyContinue) {
            $r.IISInstalled = [bool](Get-WindowsFeature -Name Web-Server -ErrorAction Stop).Installed
        }
    } catch { }

    if ($r.IISInstalled) {
        try { Import-Module WebAdministration -ErrorAction Stop } catch { }
        try {
            $vd = Get-WebVirtualDirectory -Site $Plan.SiteName -Name $Plan.VDirName -ErrorAction SilentlyContinue
            $r.VDirPresent = [bool]$vd
            if ($vd) {
                $want = [Environment]::ExpandEnvironmentVariables($Plan.PhysicalPath)
                $r.VDirPathMatches = ($vd.physicalPath -and ($vd.physicalPath.TrimEnd('\') -ieq $want.TrimEnd('\')))
            }
        } catch { }
        try {
            $mm = Get-WebConfigurationProperty -PSPath "IIS:\Sites\$($Plan.SiteName)\$($Plan.VDirName)" `
                -Filter "system.webServer/staticContent/mimeMap[@fileExtension='.crl']" -Name '.' -ErrorAction SilentlyContinue
            $r.CrlMimePresent = [bool]$mm
        } catch { }
    }

    if ($Plan.VerifyUrl -and $Plan.VerifyUrl -notmatch '/$') {
        try {
            $resp = Invoke-WebRequest -UseBasicParsing -Uri $Plan.VerifyUrl -TimeoutSec 10 -ErrorAction Stop
            $r.AnswersHttp = ($resp.StatusCode -eq 200)
        } catch { $r.AnswersHttp = $false }
    }
    return [pscustomobject]$r
}

# ---------------------------------------------------------------------------
function Set-CAWebEndpoint {
    <#
    .SYNOPSIS
        Ensures the IIS role + the CertEnroll virtual directory + MIME types + directory browsing.
        Every mutation via Invoke-CAStep. Idempotent.
    #>

    param([Parameter(Mandatory)]$Plan)

    if (-not (Get-Command Get-WindowsFeature -ErrorAction SilentlyContinue)) {
        Write-Host " Server-Manager cmdlets missing - run menu 6 on the CA (a Windows Server)." -ForegroundColor Red
        return
    }

    $resolvedPath = [Environment]::ExpandEnvironmentVariables($Plan.PhysicalPath)

    # 1. IIS role
    if (-not (Get-WindowsFeature -Name Web-Server -ErrorAction SilentlyContinue).Installed) {
        Invoke-CAStep -Description "Install the Web Server (IIS) role" `
            -Commands @('Install-WindowsFeature -Name Web-Server -IncludeManagementTools') `
            -Action { Install-WindowsFeature -Name Web-Server -IncludeManagementTools | Out-String } | Out-Null
    } else {
        Write-Host " Web-Server role already installed." -ForegroundColor Green
    }

    Import-Module WebAdministration -ErrorAction SilentlyContinue

    # 2. virtual directory
    $existingVd = $null
    try { $existingVd = Get-WebVirtualDirectory -Site $Plan.SiteName -Name $Plan.VDirName -ErrorAction SilentlyContinue } catch { }
    if (-not $existingVd) {
        Invoke-CAStep -Description "Create IIS virtual directory /$($Plan.VDirName)/ -> $resolvedPath" `
            -Commands @("New-WebVirtualDirectory -Site '$($Plan.SiteName)' -Name '$($Plan.VDirName)' -PhysicalPath '$resolvedPath'") `
            -Action { New-WebVirtualDirectory -Site $Plan.SiteName -Name $Plan.VDirName -PhysicalPath $resolvedPath | Out-Null } | Out-Null
    } else {
        Write-Host " Virtual directory /$($Plan.VDirName)/ already exists ($($existingVd.physicalPath))." -ForegroundColor Green
    }

    # 3. directory browsing
    if ($Plan.DirectoryBrowse) {
        Invoke-CAStep -Description "Enable directory browsing on /$($Plan.VDirName)/" `
            -Commands @("Set-WebConfigurationProperty -PSPath 'IIS:\Sites\$($Plan.SiteName)\$($Plan.VDirName)' -Filter '/system.webServer/directoryBrowse' -Name enabled -Value `$true") `
            -Action {
                Set-WebConfigurationProperty -PSPath "IIS:\Sites\$($Plan.SiteName)\$($Plan.VDirName)" `
                    -Filter '/system.webServer/directoryBrowse' -Name enabled -Value $true
            } -ContinueOnError | Out-Null
    }

    # 4. MIME types (site level - inherited by the vdir; add each only if absent)
    foreach ($m in $Plan.MimeTypes) {
        $present = $false
        try {
            $mm = Get-WebConfigurationProperty -PSPath "IIS:\Sites\$($Plan.SiteName)" `
                -Filter "system.webServer/staticContent/mimeMap[@fileExtension='$($m.Ext)']" -Name '.' -ErrorAction SilentlyContinue
            $present = [bool]$mm
        } catch { }
        if ($present) { Write-Host " MIME $($m.Ext) already mapped." -ForegroundColor Green; continue }
        Invoke-CAStep -Description "Map MIME type $($m.Ext) -> $($m.Mime)" `
            -Commands @("Add-WebConfigurationProperty -PSPath 'IIS:\Sites\$($Plan.SiteName)' -Filter 'system.webServer/staticContent' -Name '.' -Value @{fileExtension='$($m.Ext)'; mimeType='$($m.Mime)'}") `
            -Action {
                Add-WebConfigurationProperty -PSPath "IIS:\Sites\$($Plan.SiteName)" `
                    -Filter 'system.webServer/staticContent' -Name '.' `
                    -Value @{ fileExtension = $m.Ext; mimeType = $m.Mime }
            } -ContinueOnError | Out-Null
    }

    # 5. allowDoubleEscaping on /CertEnroll/. The App Proxy connector forwards the CRL path with the
    # slash encoded (/CertEnroll%2fCLIENT-CA.crl); IIS request filtering rejects a double-escaped
    # sequence with 404 by default. Also covers the '(' in a renewed CA's "CAName(1).crl".
    Invoke-CAStep -Description "Allow double-escaping on /$($Plan.VDirName)/ (App Proxy encodes the path slash; renewed-CA CRL names)" `
        -Commands @("Set-WebConfigurationProperty -PSPath 'IIS:\Sites\$($Plan.SiteName)\$($Plan.VDirName)' -Filter 'system.webServer/security/requestFiltering' -Name allowDoubleEscaping -Value `$true") `
        -Action {
            Set-WebConfigurationProperty -PSPath "IIS:\Sites\$($Plan.SiteName)\$($Plan.VDirName)" `
                -Filter 'system.webServer/security/requestFiltering' -Name allowDoubleEscaping -Value $true
        } -ContinueOnError | Out-Null

    # 6. verify (best-effort - the .crl may not exist until certutil -crl has run)
    if ($Plan.VerifyUrl -and $Plan.VerifyUrl -notmatch '/$' -and -not (Get-CADryRun)) {
        try {
            $resp = Invoke-WebRequest -UseBasicParsing -Uri $Plan.VerifyUrl -TimeoutSec 10 -ErrorAction Stop
            Write-Host (" verify: {0} -> HTTP {1} ({2})" -f $Plan.VerifyUrl, $resp.StatusCode, $resp.Headers['Content-Type']) -ForegroundColor Green
        } catch {
            Write-Host (" verify: {0} did not answer 200 yet ({1}) - run 'certutil -crl' if the CRL file is missing." -f $Plan.VerifyUrl, $_.Exception.Message) -ForegroundColor Yellow
        }
    }
}