Private/CAAppProxy.ps1

<#
.SYNOPSIS
    CA Manager - Entra Application Proxy: the private-network connector on the CA box, a connector
    group, and the two published apps (CRL/AIA + OCSP) that expose the CA's revocation endpoints to
    the internet (dashboard menu 6, steps 6c-6f). Requires Modules\CACore.ps1 (Invoke-CAStep),
    Modules\CAGraph.ps1 (Connect-CAGraph + the request helpers), and the Microsoft.Graph.Authentication
    module. Applying the connector install also needs Application Administrator on the tenant.
 
.DESCRIPTION
    Reproduces a real client's hand-built setup (Get-CAManagerEntraProxyInventory.ps1): connector group
    "CertChecks", apps "CRL" + "OCSP-Relay", externalAuthenticationType=passthru, plain HTTP
    end-to-end, host-header translation on, links-in-body off, SSO none,
    appRoleAssignmentRequired=false (CRL/OCSP clients are anonymous).
 
    Pure helpers (Get-CAAppProxyFqdn / Get-CAUrlHost / Get-CAAppProxyPublishingBody /
    Get-CAAppProxyPlan) are unit-tested. Everything that mutates the tenant goes through
    Invoke-CAGraphStep (from CAGraph.ps1); the connector .exe download / silent install /
    RegisterConnector.ps1 go through Invoke-CAStep directly - so the whole of menu 6 walks
    end-to-end in DRY RUN on any box with Microsoft.Graph.Authentication + internet, changing nothing.
 
    The composed <prefix>-<tenant>.msappproxy.net hostname is a PLACEHOLDER only. Entra assigns the
    real externalUrl (it appends "-1" on a prefix collision, keeps the path, may use a vanity
    domain); menu 6 always reads it back and overwrites CA_AppProxyCrlFqdn / CA_AppProxyOcspFqdn.
#>


# =========================================================================
# PURE helpers
# =========================================================================
function Get-CAAppProxyFqdn {
    <#
    .SYNOPSIS
        Composes '<prefix>-<tenant>.msappproxy.net'. PLACEHOLDER only - the real host comes back from
        onPremisesPublishing.externalUrl after the app is created.
    #>

    param(
        [Parameter(Mandatory)][string]$Prefix,
        [Parameter(Mandatory)][string]$Tenant
    )
    $p = $Prefix.Trim().ToLower().Trim('-')
    $t = $Tenant.Trim().ToLower() -replace '(?i)\.onmicrosoft\.com$', '' -replace '(?i)\.msappproxy\.net$', ''
    $t = $t.Trim('-')
    return "$p-$t.msappproxy.net"
}

function Get-CAUrlHost {
    <#
    .SYNOPSIS
        'http://crl-x.msappproxy.net/CertEnroll/' -> 'crl-x.msappproxy.net'. Blank/no-scheme -> ''.
    #>

    param([string]$Url)
    if ([string]::IsNullOrWhiteSpace($Url)) { return '' }
    if ($Url -match '^(?i)https?://([^/]+)') { return $Matches[1] }
    return ($Url -replace '/.*$', '')
}

function Get-CAAppProxyPublishingBody {
    <#
    .SYNOPSIS
        PURE. The onPremisesPublishing PATCH body - the exact reference-client shape.
    #>

    param(
        [Parameter(Mandatory)][string]$InternalUrl,
        [Parameter(Mandatory)][string]$ExternalUrl,
        [bool]$BackendCertValidation
    )
    @{
        onPremisesPublishing = @{
            externalAuthenticationType            = 'passthru'
            internalUrl                           = $InternalUrl
            externalUrl                           = $ExternalUrl
            isOnPremPublishingEnabled             = $true
            isTranslateHostHeaderEnabled          = $true
            isTranslateLinksInBodyEnabled         = $false
            isBackendCertificateValidationEnabled = [bool]$BackendCertValidation
            applicationServerTimeout             = 'Default'
            singleSignOnSettings                 = @{ singleSignOnMode = 'none' }
        }
    }
}

function Get-CAAppProxyPlan {
    <#
    .SYNOPSIS
        PURE. The whole menu-7 App Proxy plan (connector group + the two app specs) from CAAnswers,
        the CA's own FQDN, and the msappproxy tenant label.
    #>

    param(
        $CAAnswers,
        [Parameter(Mandatory)][string]$CAHostFqdn,
        [Parameter(Mandatory)][string]$TenantLabel
    )
    $grp     = if ($CAAnswers.CA_AppProxyConnectorGroup) { $CAAnswers.CA_AppProxyConnectorGroup } else { 'CertChecks' }
    $crlName  = if ($CAAnswers.CA_AppProxyCrlAppName)     { $CAAnswers.CA_AppProxyCrlAppName }     else { 'CRL Relay' }
    $ocspName = if ($CAAnswers.CA_AppProxyOcspAppName)    { $CAAnswers.CA_AppProxyOcspAppName }    else { 'OCSP Relay' }

    # Prefer a prefix already implied by a saved FQDN ('crl-foo.msappproxy.net' -> 'crl'); else default.
    $crlPrefix  = 'crl'
    $ocspPrefix = 'ocsprelay'
    if ($CAAnswers.CA_AppProxyCrlFqdn  -match '^(?i)([a-z0-9]+)-') { $crlPrefix  = $Matches[1].ToLower() }
    if ($CAAnswers.CA_AppProxyOcspFqdn -match '^(?i)([a-z0-9]+)-') { $ocspPrefix = $Matches[1].ToLower() }

    [pscustomobject]@{
        ConnectorGroupName = $grp
        ConnectorMachine   = $CAHostFqdn
        Apps = @(
            [pscustomobject]@{
                Key = 'CRL'; DisplayName = $crlName; Prefix = $crlPrefix
                InternalUrl      = "http://$CAHostFqdn/CertEnroll/"
                ExternalUrlGuess = "http://$(Get-CAAppProxyFqdn -Prefix $crlPrefix -Tenant $TenantLabel)/CertEnroll/"
                BackendCertValidation = $true
            }
            [pscustomobject]@{
                Key = 'OCSP'; DisplayName = $ocspName; Prefix = $ocspPrefix
                InternalUrl      = "http://$CAHostFqdn/ocsp/"
                ExternalUrlGuess = "http://$(Get-CAAppProxyFqdn -Prefix $ocspPrefix -Tenant $TenantLabel)/ocsp/"
                BackendCertValidation = $false
            }
        )
    }
}

function Show-CAAppProxyPlan {
    param($Plan)
    Write-Host ""
    Write-Host (" Connector group : {0} (connector: {1})" -f $Plan.ConnectorGroupName, $Plan.ConnectorMachine) -ForegroundColor White
    foreach ($a in $Plan.Apps) {
        Write-Host ""
        Write-Host (" App '{0}' ({1})" -f $a.DisplayName, $a.Key) -ForegroundColor White
        Write-Host (" internal : {0}" -f $a.InternalUrl) -ForegroundColor Gray
        Write-Host (" external : {0} (placeholder - Entra assigns the real host)" -f $a.ExternalUrlGuess) -ForegroundColor Gray
        Write-Host (" publish : passthru / HTTP / translate-host-header=on / links-in-body=off / SSO=none / anonymous") -ForegroundColor DarkGray
        Write-Host (" backend cert validation : {0}" -f $a.BackendCertValidation) -ForegroundColor DarkGray
    }
}

# =========================================================================
# 7c - connector install + registration
# =========================================================================
function Get-CAConnectorPaths {
    <#
    .SYNOPSIS
        Read-only. Returns the install dir / RegisterConnector.ps1 path / PS module path+name for
        whichever connector generation is installed (or would be), or $null if neither dir exists.
        Legacy = "Microsoft AAD App Proxy Connector"; modern = "Microsoft Entra private network connector".
    #>

    $pf = ${env:ProgramFiles}
    $candidates = @(
        [pscustomobject]@{ InstallDir = Join-Path $pf 'Microsoft Entra private network connector'; ModuleName = 'MicrosoftEntraPrivateNetworkConnectorPSModule' }
        [pscustomobject]@{ InstallDir = Join-Path $pf 'Microsoft AAD App Proxy Connector';         ModuleName = 'AppProxyPSModule' }
    )
    foreach ($c in $candidates) {
        if (-not (Test-Path $c.InstallDir)) { continue }
        # newer builds move RegisterConnector.ps1 into a subfolder (\PowerShell\, \Modules\...) - search.
        $reg = Get-ChildItem -Path $c.InstallDir -Recurse -Filter 'RegisterConnector.ps1' -ErrorAction SilentlyContinue |
            Select-Object -First 1
        $modDir = Get-ChildItem -Path $c.InstallDir -Recurse -Directory -Filter 'Modules' -ErrorAction SilentlyContinue |
            Select-Object -First 1
        return [pscustomobject]@{
            InstallDir     = $c.InstallDir
            RegisterScript = if ($reg) { $reg.FullName } else { Join-Path $c.InstallDir 'RegisterConnector.ps1' }
            ModulePath     = if ($modDir) { $modDir.FullName } elseif ($reg) { Join-Path (Split-Path $reg.FullName) 'Modules' } else { Join-Path $c.InstallDir 'Modules' }
            ModuleName     = $c.ModuleName
            RegisterFound  = [bool]$reg
        }
    }
    return $null
}

function Get-CAConnectorRegistrationToken {
    <#
    .SYNOPSIS
        Acquires an INTERACTIVE token for the connector-REGISTRATION resource (NOT Microsoft Graph -
        different audience). Returns @{ Token; TenantId }, or $null so the caller falls back to
        -AuthenticationMode Credentials (Get-Credential).
    #>

    param([string]$TenantId)

    # public client + resource used by RegisterConnector.ps1's own interactive flow
    $connectorClientId = '55747057-9b5d-4bd4-b387-abf52a8bd489'
    $scope             = 'https://proxy.cloudwebappproxy.net/registerapp/user_impersonation'
    $authority         = if ($TenantId) { "https://login.microsoftonline.com/$TenantId" } else { 'https://login.microsoftonline.com/common' }

    # MSAL's embedded WebView is IE-based; on Windows Server it fails (blank window / no navigation)
    # while IE Enhanced Security Configuration is ON for the current user. Warn early - the fix is
    # Server Manager -> Local Server -> IE Enhanced Security Configuration -> Off for Administrators.
    try {
        $adminEsc = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A7-37EF-4b3f-8CFC-4F3A74704073}' -Name IsInstalled -ErrorAction Stop).IsInstalled
        if ($adminEsc -eq 1) {
            Write-Host " NOTE: IE Enhanced Security Configuration is ON. If the sign-in window is blank, turn it" -ForegroundColor Yellow
            Write-Host " off (Server Manager -> Local Server -> IE Enhanced Security Configuration -> Off)," -ForegroundColor Yellow
            Write-Host " or answer 'n' at the next prompt to register with a username/password instead." -ForegroundColor Yellow
        }
    } catch { }

    try {
        $modBase = (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication | Select-Object -First 1).ModuleBase
        $dll = Get-ChildItem -Path $modBase -Recurse -Filter 'Microsoft.Identity.Client.dll' -ErrorAction SilentlyContinue | Select-Object -First 1
        if (-not $dll) { return $null }
        Add-Type -Path $dll.FullName -ErrorAction Stop

        $app = [Microsoft.Identity.Client.PublicClientApplicationBuilder]::Create($connectorClientId).
                    WithAuthority($authority).
                    WithDefaultRedirectUri().
                    Build()
        $result = $app.AcquireTokenInteractive([string[]]@($scope)).ExecuteAsync().GetAwaiter().GetResult()
        return @{ Token = $result.AccessToken; TenantId = $result.TenantId }
    } catch {
        Write-Host " Could not acquire a registration token interactively ($($_.Exception.Message))." -ForegroundColor DarkYellow
        return $null
    }
}

function Assert-CAValidExe {
    <#
    .SYNOPSIS
        Throws (with an actionable message) unless $Path is a real Windows PE executable - guards
        against a redirect that saved an HTML page / a truncated or AV-locked download.
    #>

    param([Parameter(Mandatory)][string]$Path)
    if (-not (Test-Path $Path)) { throw "installer not found at $Path" }
    $len = (Get-Item $Path).Length
    $mz = ''
    try { $fs = [IO.File]::OpenRead($Path); $b = New-Object byte[] 2; [void]$fs.Read($b, 0, 2); $fs.Close(); $mz = -join ([char[]]$b) } catch { }
    if ($len -lt 500KB -or $mz -ne 'MZ') {
        throw ("the downloaded file is not a valid installer (size $([math]::Round($len/1KB)) KB, header '$mz'). " +
               "The aka.ms link is likely blocked or served a page. Download it by hand from the Entra admin " +
               "center (Enterprise applications -> Application proxy -> Download connector service) and re-run " +
               "menu 6, or point Install-CAAppProxyConnector -InstallerPath at the .exe.")
    }
}

function Get-CAConnectorInstallerUrls {
    <#
    .SYNOPSIS
        Candidate download URLs for the connector installer, best first. The tenant-scoped
        download.msappproxy.net URL is what the portal's "Download connector service" button hits
        (plain GET, no auth); the aka.ms short links have historically redirected there but are
        unreliable (aka.ms/aadapplicationproxyconnector currently 302s to bing.com).
    #>

    param([string]$TenantId)
    $u = @()
    if ($TenantId) {
        $u += "https://download.msappproxy.net/Subscription/$TenantId/Connector/DownloadConnectorInstaller"
        $u += "https://download.msappproxy.net/Subscription/$TenantId/Connector/download"
    }
    $u += 'https://aka.ms/EntraPrivateNetworkConnector'
    $u += 'https://aka.ms/aadapplicationproxyconnector'
    return $u
}

function Get-CAConnectorInstaller {
    <#
    .SYNOPSIS
        Fetches the connector installer to -OutFile, trying each Get-CAConnectorInstallerUrls
        candidate with curl.exe -L (in-box, clean redirects) then Invoke-WebRequest. Validates each
        result is a real PE; throws an actionable message if none is.
    #>

    param(
        [Parameter(Mandatory)][string]$OutFile,
        [string]$TenantId,
        [string[]]$Url
    )
    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
    if (-not $Url) { $Url = Get-CAConnectorInstallerUrls -TenantId $TenantId }
    $curl = Get-Command curl.exe -ErrorAction SilentlyContinue

    foreach ($u in $Url) {
        Remove-Item $OutFile -ErrorAction SilentlyContinue
        Write-Host " trying $u" -ForegroundColor DarkGray
        if ($curl) { & $curl.Source -sL --fail --retry 2 -o $OutFile $u 2>$null }
        if (-not (Test-Path $OutFile) -or (Get-Item $OutFile).Length -lt 500KB) {
            try { Invoke-WebRequest -UseBasicParsing -Uri $u -OutFile $OutFile -MaximumRedirection 8 -ErrorAction Stop } catch { }
        }
        try { Assert-CAValidExe -Path $OutFile; Write-Host " got a valid installer from $u" -ForegroundColor Green; return }
        catch { }
    }
    throw ("None of the known download URLs returned a valid installer. Download it by hand from the " +
           "Entra admin center (Enterprise applications -> Application proxy -> Download connector service), " +
           "then re-run menu 6 and give 6c the path to the .exe.")
}

function Install-CAAppProxyConnector {
    <#
    .SYNOPSIS
        Downloads + silently installs the Entra private network connector and registers it to the
        tenant. Idempotent - skips when WAPCSvc is already Running. Every step via Invoke-CAStep.
 
    .PARAMETER InstallerPath
        Use an already-downloaded AADApplicationProxyConnectorInstaller.exe instead of fetching it
        (for when the aka.ms download is blocked / the box has no direct internet). Get it by hand
        from: Entra admin center -> Enterprise applications -> Application proxy -> Download connector service.
    #>

    param(
        [string]$TenantId,
        [string]$InstallerPath
    )

    $svc = Get-Service -Name WAPCSvc -ErrorAction SilentlyContinue
    if ($svc -and $svc.Status -eq 'Running') {
        Write-Host " Connector already installed (WAPCSvc is Running) - skipping install/register." -ForegroundColor Green
        return
    }

    $installer = if ($InstallerPath) { $InstallerPath } else { Join-Path $env:TEMP 'AADApplicationProxyConnectorInstaller.exe' }

    if (-not $InstallerPath) {
        Invoke-CAStep -Description "Download the tenant-scoped connector installer" `
            -Commands @("curl.exe -L -o <tmp> (download.msappproxy.net/Subscription/$TenantId/Connector/... then aka.ms/*)") `
            -Action { Get-CAConnectorInstaller -OutFile $installer -TenantId $TenantId } | Out-Null
    } elseif (-not (Test-Path $installer)) {
        throw "InstallerPath '$installer' does not exist."
    } else {
        Write-Host " Using supplied installer: $installer" -ForegroundColor Gray
        Assert-CAValidExe -Path $installer
    }

    # The tenant-scoped installer from download.msappproxy.net has this tenant baked in and does the
    # registration IN its own setup wizard (interactive sign-in). Modern builds ship NO
    # RegisterConnector.ps1 / PS module, so there's nothing to call afterwards - the installer IS the
    # register step. Run it interactively (no /q); the tech completes the wizard + sign-in.
    $paths = Get-CAConnectorPaths
    if ($paths -and $paths.RegisterFound -and -not $svc) {
        # legacy "Microsoft AAD App Proxy Connector" generation - silent install + RegisterConnector.ps1
        Invoke-CAStep -Description "Silently install the connector (registration deferred)" `
            -Commands @("& '$installer' REGISTERCONNECTOR=`"false`" /q") `
            -Action {
                Assert-CAValidExe -Path $installer
                $p = Start-Process -FilePath $installer -ArgumentList 'REGISTERCONNECTOR="false"', '/q' -Wait -PassThru
                if ($p.ExitCode -ne 0) { throw "connector installer exited $($p.ExitCode)" }
            } | Out-Null
        $paths = Get-CAConnectorPaths
        $regCmd = "& '$($paths.RegisterScript)' -modulePath '$($paths.ModulePath)' -moduleName '$($paths.ModuleName)' -AuthenticationMode Interactive -Feature ApplicationProxy"
        Invoke-CAStep -Description "Register the connector (RegisterConnector.ps1, interactive sign-in)" `
            -Commands @($regCmd) `
            -Action {
                & $paths.RegisterScript -modulePath $paths.ModulePath -moduleName $paths.ModuleName -AuthenticationMode Interactive -Feature ApplicationProxy
                if ($LASTEXITCODE -ne 0) {
                    Write-Host "`n RegisterConnector.ps1 exited $LASTEXITCODE. Run this here, sign in, then press Enter:" -ForegroundColor Yellow
                    Write-Host " $regCmd" -ForegroundColor Cyan
                    Read-Host " [Enter] once registered"
                }
            } -ContinueOnError | Out-Null
    }
    else {
        # modern "Microsoft Entra private network connector": no RegisterConnector.ps1 - the
        # tenant-scoped installer registers in its own setup. Try /passive first (it MAY
        # auto-register off the baked-in tenant); if WAPCSvc doesn't come up, run it interactively so
        # the tech completes the sign-in.
        Invoke-CAStep -Description "Install + register the connector (tenant-scoped installer)" `
            -Commands @("Start-Process '$installer' /passive ; if not Running -> Start-Process '$installer' (wizard sign-in)") `
            -Action {
                Assert-CAValidExe -Path $installer
                Write-Host " trying /passive..." -ForegroundColor DarkGray
                Start-Process -FilePath $installer -ArgumentList '/passive' -Wait
                for ($i = 0; $i -lt 8; $i++) { if ((Get-Service WAPCSvc -ErrorAction SilentlyContinue).Status -eq 'Running') { break }; Start-Sleep 5 }
                if ((Get-Service WAPCSvc -ErrorAction SilentlyContinue).Status -ne 'Running') {
                    Write-Host " /passive didn't register - launching the setup wizard; accept the EULA and sign in when prompted." -ForegroundColor Cyan
                    Start-Process -FilePath $installer -Wait
                    Read-Host " [Enter] once the wizard has finished and you've signed in"
                }
            } -ContinueOnError | Out-Null
    }

    if (-not (Get-CADryRun)) {
        for ($i = 0; $i -lt 18; $i++) {
            $svc = Get-Service -Name WAPCSvc -ErrorAction SilentlyContinue
            if ($svc -and $svc.Status -eq 'Running') { break }
            Start-Sleep -Seconds 5
        }
        $ok = ($svc -and $svc.Status -eq 'Running')
        Write-Host (" WAPCSvc: {0}" -f $(if ($svc) { $svc.Status } else { 'not found' })) -ForegroundColor $(if ($ok) { 'Green' } else { 'Yellow' })
        if (-not $ok) { Write-Host " Connector not Running yet - it may still be registering; re-check, or re-run menu 6." -ForegroundColor Yellow }
    }
}

function Get-CAAppProxyConnector {
    <#
    .SYNOPSIS
        The tenant's connector object matching -MachineName (FQDN or NetBIOS, case-insensitive), or $null.
    #>

    param([Parameter(Mandatory)][string]$MachineName)
    $short = ($MachineName -split '\.')[0]
    $all = @(Get-CAGraphAll "$script:CAGraphBeta/onPremisesPublishingProfiles/applicationProxy/connectors" -Quiet)
    return ($all | Where-Object {
        $_.machineName -and (($_.machineName -ieq $MachineName) -or (($_.machineName -split '\.')[0] -ieq $short))
    } | Select-Object -First 1)
}

# =========================================================================
# 7d - connector group
# =========================================================================
function New-CAAppProxyConnectorGroup {
    <#
    .SYNOPSIS
        Ensures an applicationProxy connector group named -Name (reuse if present). Returns its id.
    #>

    param([Parameter(Mandatory)][string]$Name)
    $existing = @(Get-CAGraphAll "$script:CAGraphBeta/onPremisesPublishingProfiles/applicationProxy/connectorGroups" -Quiet) |
        Where-Object { $_.name -eq $Name -and $_.connectorGroupType -eq 'applicationProxy' } | Select-Object -First 1
    if ($existing) {
        Write-Host " Connector group '$Name' already exists (id $($existing.id))." -ForegroundColor Green
        return $existing.id
    }
    $body = Invoke-CAGraphStep -Description "Create connector group '$Name'" -Method POST `
        -Uri "$script:CAGraphBeta/onPremisesPublishingProfiles/applicationProxy/connectorGroups" `
        -Body @{ name = $Name }
    if ($body) { return $body.id }
    if (Get-CADryRun) { return '<connectorGroupId>' }   # dry-run - so downstream steps still preview
    return $null
}

function Add-CAAppProxyConnectorToGroup {
    param(
        [Parameter(Mandatory)][string]$ConnectorId,
        [Parameter(Mandatory)][string]$GroupId
    )
    $member = Get-CAGraphOne "$script:CAGraphBeta/onPremisesPublishingProfiles/applicationProxy/connectors/$ConnectorId/memberOf" -Quiet
    if ($member -and (@($member.value).id -contains $GroupId)) {
        Write-Host " Connector already in group $GroupId." -ForegroundColor Green
        return
    }
    $ref = @{ '@odata.id' = "$script:CAGraphBeta/onPremisesPublishingProfiles/applicationProxy/connectorGroups/$GroupId" }
    try {
        Invoke-CAGraphStep -Description "Add the connector to group $GroupId" -Method POST `
            -Uri "$script:CAGraphBeta/onPremisesPublishingProfiles/applicationProxy/connectors/$ConnectorId/memberOf/`$ref" `
            -Body $ref | Out-Null
    } catch {
        # some tenants/builds want PUT for the single-valued memberOf ref
        Invoke-CAGraphStep -Description "Add the connector to group $GroupId (PUT fallback)" -Method PUT `
            -Uri "$script:CAGraphBeta/onPremisesPublishingProfiles/applicationProxy/connectors/$ConnectorId/memberOf/`$ref" `
            -Body $ref | Out-Null
    }
}

# =========================================================================
# 7e - the two Entra apps
# =========================================================================
function Get-CAAppProxyApp {
    <#
    .SYNOPSIS
        Finds an App Proxy app by displayName via the servicePrincipal tag scan (the inventory
        script's proven path). Returns @{ AppObjectId; AppId; SpId } or $null.
    #>

    param([Parameter(Mandatory)][string]$DisplayName)
    $sps = @(Get-CAGraphAll "$script:CAGraphBeta/servicePrincipals?`$select=id,appId,displayName,tags&`$top=999" -Quiet)
    $sp = $sps | Where-Object { $_.tags -contains 'WindowsAzureActiveDirectoryOnPremApp' -and $_.displayName -eq $DisplayName } | Select-Object -First 1
    if (-not $sp) { return $null }
    $app = (Get-CAGraphOne "$script:CAGraphV1/applications?`$filter=appId eq '$($sp.appId)'&`$select=id,appId,displayName" -Quiet).value | Select-Object -First 1
    if (-not $app) { $app = (Get-CAGraphOne "$script:CAGraphBeta/applications?`$filter=appId eq '$($sp.appId)'&`$select=id,appId,displayName" -Quiet).value | Select-Object -First 1 }
    if (-not $app) { return $null }
    return @{ AppObjectId = $app.id; AppId = $sp.appId; SpId = $sp.id }
}

function New-CAAppProxyApp {
    <#
    .SYNOPSIS
        Instantiates the on-prem-app gallery template (creates application + servicePrincipal in one
        call). Reuses an existing app of the same displayName. Returns @{ AppObjectId; AppId; SpId }.
    #>

    param([Parameter(Mandatory)][string]$DisplayName)
    $hit = Get-CAAppProxyApp -DisplayName $DisplayName
    if ($hit) {
        Write-Host " App '$DisplayName' already exists (appId $($hit.AppId))." -ForegroundColor Green
        return $hit
    }
    $tmpl = '8adf8e6e-67b2-4cf2-a259-e3dc5476c621'   # "on-premises application" App Proxy template
    $body = Invoke-CAGraphStep -Description "Instantiate App Proxy app '$DisplayName'" -Method POST `
        -Uri "$script:CAGraphV1/applicationTemplates/$tmpl/instantiate" `
        -Body @{ displayName = $DisplayName }
    if (-not $body) {
        # dry-run - hand back placeholders so the onPremisesPublishing / connectorGroup / SP steps still preview
        if (Get-CADryRun) { return @{ AppObjectId = '<new-app-object-id>'; AppId = '<new-app-id>'; SpId = '<new-sp-id>' } }
        return $null
    }
    Start-Sleep -Seconds 5             # replication settle before the PATCH
    $appId = $body.application.appId
    $spId  = Resolve-CAAppProxySpId -AppId $appId -Fallback $body.servicePrincipal.id
    return @{ AppObjectId = $body.application.id; AppId = $appId; SpId = $spId }
}

function Set-CAAppProxyPublishing {
    param(
        [Parameter(Mandatory)][string]$AppObjectId,
        [Parameter(Mandatory)]$Body
    )
    $attempt = 0
    while ($true) {
        $attempt++
        try {
            Invoke-CAGraphStep -Description "Set onPremisesPublishing on application $AppObjectId (attempt $attempt)" -Method PATCH `
                -Uri "$script:CAGraphBeta/applications/$AppObjectId" -Body $Body | Out-Null
            return
        } catch {
            if ($attempt -ge 3 -or (Get-CADryRun)) { throw }
            Write-Host " onPremisesPublishing PATCH failed ($($_.Exception.Message)) - retrying in 5s..." -ForegroundColor DarkYellow
            Start-Sleep -Seconds 5
        }
    }
}

function Set-CAAppProxyAppConnectorGroup {
    param(
        [Parameter(Mandatory)][string]$AppObjectId,
        [Parameter(Mandatory)][string]$GroupId
    )
    Invoke-CAGraphStep -Description "Assign application $AppObjectId to connector group $GroupId" -Method PUT `
        -Uri "$script:CAGraphBeta/applications/$AppObjectId/connectorGroup/`$ref" `
        -Body @{ '@odata.id' = "$script:CAGraphBeta/onPremisesPublishingProfiles/applicationProxy/connectorGroups/$GroupId" } | Out-Null
}

function Resolve-CAAppProxySpId {
    <#
    .SYNOPSIS
        The current servicePrincipal object-id for an appId, polling past the replication window that
        follows applicationTemplates/instantiate (the id it hands back can 404 for a few seconds).
    #>

    param([Parameter(Mandatory)][string]$AppId, [string]$Fallback)
    for ($i = 0; $i -lt 8; $i++) {
        $sp = (Get-CAGraphOne "$script:CAGraphV1/servicePrincipals?`$filter=appId eq '$AppId'&`$select=id" -Quiet).value | Select-Object -First 1
        if ($sp -and $sp.id) { return $sp.id }
        if (Get-CADryRun) { return $Fallback }
        Start-Sleep -Seconds 5
    }
    return $Fallback
}

function Set-CAAppProxyServicePrincipal {
    <#
    .SYNOPSIS
        Anonymous (appRoleAssignmentRequired=false) + HideApp, keeping the App Proxy tag. Re-resolves
        the SP id from -AppId on a 404 (instantiate's id replicates a beat behind the application).
    #>

    param(
        [Parameter(Mandatory)][string]$SpId,
        [string]$AppId
    )
    $id = $SpId
    $attempt = 0
    while ($true) {
        $attempt++
        $cur = Get-CAGraphOne "$script:CAGraphBeta/servicePrincipals/$id?`$select=tags,appRoleAssignmentRequired" -Quiet
        $tags = @('WindowsAzureActiveDirectoryOnPremApp', 'HideApp')
        if ($cur -and $cur.tags) { $tags = @(@($cur.tags) + $tags | Select-Object -Unique) }
        try {
            Invoke-CAGraphStep -Description "Service principal $id : anonymous + hidden" -Method PATCH `
                -Uri "$script:CAGraphBeta/servicePrincipals/$id" `
                -Body @{ appRoleAssignmentRequired = $false; tags = $tags } | Out-Null
            return
        } catch {
            if ($attempt -ge 4 -or (Get-CADryRun) -or -not $AppId -or $_.Exception.Message -notmatch '404|ResourceNotFound') { throw }
            Write-Host " SP $id not visible yet - re-resolving from appId and retrying..." -ForegroundColor DarkYellow
            Start-Sleep -Seconds 5
            $id = Resolve-CAAppProxySpId -AppId $AppId -Fallback $id
        }
    }
}

function Get-CAAppProxyExternalUrl {
    param([Parameter(Mandatory)][string]$AppObjectId)
    $full = Get-CAGraphOne "$script:CAGraphBeta/applications/$AppObjectId?`$select=id,onPremisesPublishing" -Quiet
    if ($full -and $full.onPremisesPublishing) { return $full.onPremisesPublishing.externalUrl }
    return $null
}

function Publish-CAAppProxyApp {
    <#
    .SYNOPSIS
        Full lifecycle for one app: instantiate/reuse -> onPremisesPublishing -> connector group ->
        SP flags -> read back the real external URL. Returns a result object.
    #>

    param(
        [Parameter(Mandatory)]$AppSpec,
        [Parameter(Mandatory)][string]$GroupId
    )
    $app = New-CAAppProxyApp -DisplayName $AppSpec.DisplayName
    $body = Get-CAAppProxyPublishingBody -InternalUrl $AppSpec.InternalUrl -ExternalUrl $AppSpec.ExternalUrlGuess -BackendCertValidation $AppSpec.BackendCertValidation

    if ($app -and $app.AppObjectId) {
        Set-CAAppProxyPublishing        -AppObjectId $app.AppObjectId -Body $body
        Set-CAAppProxyAppConnectorGroup -AppObjectId $app.AppObjectId -GroupId $GroupId
        try {
            Set-CAAppProxyServicePrincipal -SpId $app.SpId -AppId $app.AppId
        } catch {
            # the app is otherwise fully configured; the anonymous/hidden flags are recoverable by
            # re-running menu 6 (which reuses the app). Don't lose the external-URL write-back over this.
            Write-Host " WARNING: could not set the SP flags ($($_.Exception.Message)). Re-run menu 6 to finish - the app itself is published." -ForegroundColor Yellow
        }
        $real = Get-CAAppProxyExternalUrl -AppObjectId $app.AppObjectId
    } else {
        # dry-run: nothing was created; report the guess
        $real = $null
    }

    $actual = if ($real) { $real } else { $AppSpec.ExternalUrlGuess }
    [pscustomobject]@{
        Key                  = $AppSpec.Key
        DisplayName          = $AppSpec.DisplayName
        AppId                = if ($app) { $app.AppId } else { $null }
        RequestedExternalUrl = $AppSpec.ExternalUrlGuess
        ActualExternalUrl    = $actual
        ActualHost           = (Get-CAUrlHost $actual)
    }
}