diff --git a/README.md b/README.md index 3693252..f41c1e9 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ accepted the credential. Operational documentation: +- [One-command server recovery and client enrollment](docs/bootstrap-recovery.md) - [Broker location, health, timeout, and recovery](docs/broker-operations.md) - [Windows domain join and remote-access onboarding](docs/windows-client-onboarding.md) - [Required Credential Provider client enrollment](docs/client-enrollment.md) @@ -94,6 +95,20 @@ Follow [docs/lab-runbook.md](docs/lab-runbook.md). Review Never disable the built-in Microsoft password Credential Provider. It is the supported recovery path if a third-party provider fails to load. +For a clean machine, the supported entry points are the release packages: + +```bat +Start-SguServerBootstrap.cmd 192.168.50.10 +Start-SguClientEnrollment.cmd 192.168.50.10 +``` + +The server command creates a new forest and resumes by itself after its required +restart. The client command registers a unique non-exportable mTLS certificate, +installs and validates SGU before domain join, then enables the managed remote +access configuration after restart. See +[bootstrap-recovery.md](docs/bootstrap-recovery.md) for edition limitations, +network parameters, security properties, and release publication. + ## Upstream license The Lithnet source remains under its MIT license in [LICENSE](LICENSE). Project diff --git a/docs/bootstrap-recovery.md b/docs/bootstrap-recovery.md new file mode 100644 index 0000000..7da9984 --- /dev/null +++ b/docs/bootstrap-recovery.md @@ -0,0 +1,146 @@ +# Recuperación desde cero y alta en una sola ejecución + +Los releases entregan dos ZIP independientes. Cada uno contiene sus binarios, +scripts, instalador offline requerido y un manifiesto SHA-256 interno. No contienen +contraseñas, claves privadas ni certificados reutilizables. + +> Importante: reconstruir un bosque con el mismo nombre DNS **no** restaura el +> bosque anterior. Cambia el SID del dominio. Los equipos unidos al bosque viejo +> deberán borrarse/reinstalarse o salir de aquel dominio y unirse al nuevo. Para +> conservar cuentas, SID, contraseñas y relaciones de confianza se necesita una +> copia de estado del sistema de Active Directory y un procedimiento de +> recuperación de bosque, no este bootstrap. + +## Windows Server nuevo + +Compatible con Windows Server con Windows PowerShell 5.1. El servidor necesita +una interfaz privada para el dominio y, para autenticar contra SGU, salida HTTPS +por esa u otra interfaz. + +1. Descargar y extraer `sgu-server-bootstrap-VERSION.zip`. +2. Abrir el directorio extraído. +3. Ejecutar, indicando la IP fija que tendrá el controlador: + +```bat +Start-SguServerBootstrap.cmd 192.168.50.10 +``` + +El iniciador solicita elevación UAC. El script pide una contraseña de Directory +Services Restore Mode sin guardarla, instala AD DS/DNS/GPMC, crea el bosque y +reinicia una vez. Un trabajo local bajo `SYSTEM` continúa automáticamente un +minuto después del arranque; no hace falta ejecutar un segundo comando. + +Cuando hay más de una interfaz candidata, o se necesita gateway/prefijo/forwarders +explícitos, usar Windows PowerShell elevado: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File .\Initialize-SguDomainController.ps1 ` + -ServerIPv4Address 192.168.50.10 ` + -PrefixLength 24 ` + -NetworkInterfaceAlias 'Ethernet' ` + -DnsForwarders 172.21.80.1,1.1.1.1 +``` + +El proceso crea o configura de forma idempotente: + +- bosque y DNS `lci.lasalle.mx` / `LCI`; +- registro `sgu-auth.lci.lasalle.mx` apuntando a la IP proporcionada; +- `OU=Laboratorio`, `OU=Usuarios-SGU` y sus OU `Docentes`, `Alumnos` y + `Administrativos`; +- grupo de seguridad `SG-Laboratorio-Usuarios-RDP`; +- GPO de experiencia del equipo y restricciones de sesión SGU; +- certificado de servidor no exportable y broker mTLS en TCP 8443; +- recurso `\\SERVIDOR\Packages`, con lectura para Domain Computers; +- RDP con NLA, WinRM/PowerShell Remoting y reglas administrativas sólo en el + perfil Domain; +- pantalla, suspensión e hibernación en Nunca. + +El broker arranca con una lista de clientes vacía. Eso no abre el servicio: mTLS +rechaza todos los certificados hasta que el primer cliente registra el suyo. +Los archivos opcionales colocados en `payload\server-content\Packages` al crear +el release se copian al recurso compartido. Si allí existe `wallpaper.jpg`, +`wallpaper.jpeg`, `wallpaper.png` o `wallpaper.bmp`, la GPO de usuarios lo aplica +automáticamente como fondo con ajuste Fill. + +Estado y diagnóstico: + +```powershell +Get-Content C:\ProgramData\SGU\Bootstrap\Server\bootstrap.log +Get-Content C:\ProgramData\SGU\Bootstrap\Server\bootstrap-complete.json +Get-ScheduledTask SGU-Complete-Domain-Controller-Bootstrap -ErrorAction SilentlyContinue +``` + +La tarea programada se elimina únicamente cuando todas las verificaciones +finales concluyen. Si algo externo falla, corregirlo y volver a ejecutar el mismo +script; las operaciones terminadas se reutilizan. + +## Windows 10/11 nuevo + +Se admiten Pro, Enterprise y Education. Windows Home no puede unirse a Active +Directory local ni actuar como host RDP; el bootstrap lo detecta antes de cambiar +el equipo y explica que se debe actualizar la edición. + +1. Descargar y extraer `sgu-client-bootstrap-VERSION.zip`. +2. Ejecutar con la IP fija actual del controlador de dominio: + +```bat +Start-SguClientEnrollment.cmd 192.168.50.10 +``` + +Después de UAC, se solicita interactivamente la credencial autorizada para unir +equipos. La contraseña existe sólo en memoria. El bootstrap: + +1. apunta el DNS del adaptador al IP proporcionado; +2. abre una sesión WinRM autenticada con el DC y verifica que pertenece al + dominio esperado; +3. crea en el cliente un certificado mTLS RSA-3072 no exportable y envía sólo su + parte pública al broker; +4. recupera por esa sesión autenticada el certificado público del broker; +5. instala el runtime .NET 10 offline y el Credential Provider; +6. valida binarios, registro COM, certificados y salud del broker; +7. instala el guardián de reparación al arranque; +8. sólo entonces ejecuta `Add-Computer` dentro de `OU=Laboratorio` y reinicia; +9. al arrancar, activa RDP/NLA, WinRM y las reglas Domain, y vuelve a validar el + enrolamiento. + +Para elegir adaptador o nombre del equipo explícitamente: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File .\Invoke-SguClientBootstrap.ps1 ` + -DomainControllerIPv4Address 192.168.50.10 ` + -NetworkInterfaceAlias 'Ethernet' ` + -NewComputerName 'LCI-101' +``` + +La IP del argumento es siempre la IP fija **actual del servidor**, no una IP que +queda compilada en el Credential Provider. El proveedor usa después el nombre +DNS `sgu-auth.lci.lasalle.mx`, que el bootstrap del servidor actualiza. + +Un administrador del dominio todavía puede ignorar deliberadamente este flujo y +ejecutar `Add-Computer` a mano; ninguna GPO puede impedir a un administrador del +bosque modificar el dominio. Para la operación soportada, el script aplica una +transacción proveedor-primero y se niega a unir un equipo que no haya pasado las +validaciones. + +## Crear y publicar un release + +Desde el repositorio y con el SDK fijado en `global.json`: + +```powershell +.\scripts\New-SguBootstrapPackages.ps1 -Version 0.1.0 +.\scripts\Publish-GiteaRelease.ps1 -Version 0.1.0 +``` + +El segundo comando usa `GITEA_TOKEN` sólo en memoria o, si no está definido, +solicita la credencial existente a Git Credential Manager. No coloca el token en +la línea de comandos. Para empaquetar recursos institucionales adicionales: + +```powershell +.\scripts\New-SguBootstrapPackages.ps1 ` + -Version 0.1.0 ` + -ServerContentPath C:\Preparacion\Packages +``` + +`SHA256SUMS-VERSION.txt` permite comprobar ambos ZIP antes de usarlos. diff --git a/docs/client-enrollment.md b/docs/client-enrollment.md index 7880d23..c782b88 100644 --- a/docs/client-enrollment.md +++ b/docs/client-enrollment.md @@ -1,5 +1,17 @@ # Enrolamiento obligatorio de clientes SGU +Para una instalación limpia de Windows se prefiere el único punto de entrada +empaquetado: + +```bat +Start-SguClientEnrollment.cmd 192.168.50.10 +``` + +Este comando realiza el intercambio de certificados descrito abajo sin mover +una clave privada y luego ejecuta la transacción proveedor-primero. Las +instrucciones completas están en +[`bootstrap-recovery.md`](bootstrap-recovery.md). + El flujo administrado instala y valida el Credential Provider **antes** de ejecutar `Add-Computer`. La pertenencia al dominio es el último cambio; si falta el runtime, un certificado, el registro COM, la directiva predeterminada o la diff --git a/docs/lab-runbook.md b/docs/lab-runbook.md index 29c9645..07e8bbd 100644 --- a/docs/lab-runbook.md +++ b/docs/lab-runbook.md @@ -12,6 +12,11 @@ Validated lab inventory: Run guest commands from an elevated PowerShell console inside each VM. Do not put an institutional password on a command line or in a script file. +For replacement machines, use the release bootstraps in +[`bootstrap-recovery.md`](bootstrap-recovery.md). They consolidate the manual +steps below into one server command and one client command, including the +required reboot/resume and per-client certificate registration. + ## 1. Build on the Windows 11 host ```powershell diff --git a/scripts/Deploy-AuthBroker.ps1 b/scripts/Deploy-AuthBroker.ps1 index a7cf44a..aa4a982 100644 --- a/scripts/Deploy-AuthBroker.ps1 +++ b/scripts/Deploy-AuthBroker.ps1 @@ -6,9 +6,8 @@ param( [Parameter(Mandatory)] [string]$ServerCertificateSubject, - [Parameter(Mandatory)] [ValidatePattern('^[0-9A-Fa-f ]{40,59}$')] - [string[]]$AllowedClientThumbprints, + [string[]]$AllowedClientThumbprints = @(), [string]$NtlmEndpoint = 'https://sgu.ulsa.edu.mx/', [string[]]$AllowedNtlmRedirectHosts = @('sgu.ulsa.edu.mx'), diff --git a/scripts/Enable-SguServerRemoteManagement.ps1 b/scripts/Enable-SguServerRemoteManagement.ps1 new file mode 100644 index 0000000..14ed788 --- /dev/null +++ b/scripts/Enable-SguServerRemoteManagement.ps1 @@ -0,0 +1,94 @@ +[CmdletBinding(SupportsShouldProcess)] +param() + +$ErrorActionPreference = 'Stop' +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run this script from an elevated Windows PowerShell session.' +} + +$operatingSystem = Get-CimInstance Win32_OperatingSystem +if ([int]$operatingSystem.ProductType -eq 1) { + throw 'This helper is for Windows Server. Use Enable-LabRemoteAccess.ps1 on a Windows client.' +} + +if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable secure administrative RDP and PowerShell Remoting')) { + foreach ($powerChange in @( + @('monitor-timeout-ac', '0'), + @('monitor-timeout-dc', '0'), + @('standby-timeout-ac', '0'), + @('standby-timeout-dc', '0'), + @('hibernate-timeout-ac', '0'), + @('hibernate-timeout-dc', '0'))) { + & powercfg.exe /change $powerChange[0] $powerChange[1] + if ($LASTEXITCODE -ne 0) { + throw "powercfg /change $($powerChange[0]) failed with exit code $LASTEXITCODE." + } + } + & powercfg.exe /hibernate off + if ($LASTEXITCODE -ne 0) { + throw "powercfg /hibernate off failed with exit code $LASTEXITCODE." + } + + Set-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' ` + -Name fDenyTSConnections -Type DWord -Value 0 + Set-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' ` + -Name UserAuthentication -Type DWord -Value 1 + + Set-Service -Name TermService -StartupType Automatic + Start-Service -Name TermService + Get-NetFirewallRule -Name 'RemoteDesktop-UserMode-In-TCP','RemoteDesktop-UserMode-In-UDP' ` + -ErrorAction SilentlyContinue | + Set-NetFirewallRule -Enabled True -Profile Domain + + $enableRemoting = Start-Process ` + -FilePath "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" ` + -ArgumentList @( + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + 'Enable-PSRemoting -Force -SkipNetworkProfileCheck') ` + -Wait -PassThru -WindowStyle Hidden + if ($enableRemoting.ExitCode -ne 0) { + throw "Enable-PSRemoting returned $($enableRemoting.ExitCode)." + } + + Set-Service -Name WinRM -StartupType Automatic + Start-Service -Name WinRM + Get-NetFirewallRule -Name 'WINRM-HTTP-In-TCP','WINRM-HTTP-In-TCP-NoScope' ` + -ErrorAction SilentlyContinue | + Set-NetFirewallRule -Enabled True -Profile Domain + Get-NetFirewallRule -Name 'WINRM-HTTP-In-TCP-PUBLIC' -ErrorAction SilentlyContinue | + Disable-NetFirewallRule + + $administrativeRules = @( + 'RemoteEventLogSvc-In-TCP', + 'RemoteEventLogSvc-NP-In-TCP', + 'RemoteEventLogSvc-RPCSS-In-TCP', + 'RemoteSvcAdmin-In-TCP', + 'RemoteSvcAdmin-NP-In-TCP', + 'RemoteSvcAdmin-RPCSS-In-TCP', + 'WMI-RPCSS-In-TCP', + 'WMI-WINMGMT-In-TCP', + 'WMI-ASYNC-In-TCP' + ) + Get-NetFirewallRule -Name $administrativeRules -ErrorAction SilentlyContinue | + Set-NetFirewallRule -Enabled True -Profile Domain +} + +[pscustomobject]@{ + ComputerName = $env:COMPUTERNAME + RemoteDesktopEnabled = (Get-ItemPropertyValue ` + 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' ` + -Name fDenyTSConnections) -eq 0 + NetworkLevelAuthentication = (Get-ItemPropertyValue ` + 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' ` + -Name UserAuthentication) -eq 1 + TermService = (Get-Service TermService).Status + WinRM = (Get-Service WinRM).Status + FirewallProfile = 'Domain' + AdministrativeAccessOnly = $true + AlwaysOnPowerPolicyApplied = $true +} diff --git a/scripts/Enroll-SguDomainClient.ps1 b/scripts/Enroll-SguDomainClient.ps1 index b48149c..be6d775 100644 --- a/scripts/Enroll-SguDomainClient.ps1 +++ b/scripts/Enroll-SguDomainClient.ps1 @@ -75,6 +75,14 @@ $guardParams = @{ } if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before joining the domain')) { + # The broker uses a domain DNS name even before the machine joins the + # domain. Point at AD DNS first so the provider-first health check works on + # a completely clean Windows installation. + Set-DnsClientServerAddress ` + -InterfaceAlias $NetworkInterfaceAlias ` + -ServerAddresses $DomainDnsServerAddresses + Resolve-DnsName -Type SRV "_ldap._tcp.dc._msdcs.$DomainName" -ErrorAction Stop | Out-Null + & (Join-Path $PSScriptRoot 'Install-CredentialProvider.ps1') @installParams | Out-Null & (Join-Path $PSScriptRoot 'Install-SguEnrollmentGuard.ps1') @guardParams | Out-Null @@ -94,11 +102,6 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo -RemoteDesktopPrincipal $RemoteDesktopPrincipal } - Set-DnsClientServerAddress ` - -InterfaceAlias $NetworkInterfaceAlias ` - -ServerAddresses $DomainDnsServerAddresses - Resolve-DnsName -Type SRV "_ldap._tcp.dc._msdcs.$DomainName" -ErrorAction Stop | Out-Null - if (-not $DomainCredential) { $DomainCredential = Get-Credential ` -UserName "$DomainNetbios\Administrator" ` diff --git a/scripts/Initialize-SguDomainController.ps1 b/scripts/Initialize-SguDomainController.ps1 new file mode 100644 index 0000000..d278469 --- /dev/null +++ b/scripts/Initialize-SguDomainController.ps1 @@ -0,0 +1,514 @@ +#Requires -Version 5.1 +[CmdletBinding(SupportsShouldProcess)] +param( + [ipaddress]$ServerIPv4Address, + [ValidateRange(1, 32)] + [int]$PrefixLength = 24, + [string]$NetworkInterfaceAlias, + [ipaddress]$DefaultGateway, + [ipaddress[]]$DnsForwarders = @(), + [string]$DomainName = 'lci.lasalle.mx', + [string]$DomainNetbios = 'LCI', + [string]$BrokerRecordName = 'sgu-auth', + [string]$PackageSharePath = 'C:\Packages', + [securestring]$SafeModeAdministratorPassword, + [switch]$SkipRestart, + [switch]$Resume +) + +$ErrorActionPreference = 'Stop' +$bootstrapRoot = Join-Path $env:ProgramData 'SGU\Bootstrap\Server' +$statePath = Join-Path $bootstrapRoot 'bootstrap-state.json' +$completionPath = Join-Path $bootstrapRoot 'bootstrap-complete.json' +$logPath = Join-Path $bootstrapRoot 'bootstrap.log' +$taskName = 'SGU-Complete-Domain-Controller-Bootstrap' + +function Write-BootstrapLog { + param([Parameter(Mandatory)][string]$Message) + + $line = '{0:u} {1}' -f (Get-Date), $Message + Write-Host $line + if (Test-Path -LiteralPath $bootstrapRoot) { + Add-Content -LiteralPath $logPath -Value $line -Encoding UTF8 + } +} + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run this bootstrap from an elevated Windows PowerShell session.' + } +} + +function Assert-PackageManifest { + param([Parameter(Mandatory)][string]$PackageRoot) + + $manifestPath = Join-Path $PackageRoot 'package-manifest.json' + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw 'package-manifest.json is missing. Use the complete SGU server bootstrap release.' + } + + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + foreach ($entry in $manifest.Files) { + $path = Join-Path $PackageRoot ([string]$entry.Path) + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Bootstrap package file is missing: $($entry.Path)" + } + $actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash + if ($actual -ne [string]$entry.Sha256) { + throw "Bootstrap package integrity check failed: $($entry.Path)" + } + } +} + +function Get-DomainBaseDn { + param([Parameter(Mandatory)][string]$DnsDomainName) + + return (($DnsDomainName -split '\.') | ForEach-Object { "DC=$_" }) -join ',' +} + +function Resolve-PrivateInterfaceAlias { + param([string]$RequestedAlias) + + if ($RequestedAlias) { + Get-NetAdapter -Name $RequestedAlias -ErrorAction Stop | Out-Null + return $RequestedAlias + } + + $upAdapters = @(Get-NetAdapter | Where-Object Status -eq 'Up') + $withoutGateway = @($upAdapters | Where-Object { + -not (Get-NetIPConfiguration -InterfaceIndex $_.ifIndex).IPv4DefaultGateway + }) + if ($withoutGateway.Count -eq 1) { + return [string]$withoutGateway[0].Name + } + if ($upAdapters.Count -eq 1) { + return [string]$upAdapters[0].Name + } + + $aliases = ($upAdapters.Name | Sort-Object) -join ', ' + throw "Could not select the private domain adapter unambiguously. Re-run with -NetworkInterfaceAlias. Available adapters: $aliases" +} + +function Set-StaticDomainAddress { + param( + [Parameter(Mandatory)][string]$InterfaceAlias, + [Parameter(Mandatory)][ipaddress]$Address, + [Parameter(Mandatory)][int]$NetworkPrefixLength, + [ipaddress]$Gateway + ) + + $adapter = Get-NetAdapter -Name $InterfaceAlias -ErrorAction Stop + Set-NetIPInterface -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -Dhcp Disabled + + $addresses = @(Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | + Where-Object PrefixOrigin -ne 'WellKnown') + foreach ($existingAddress in $addresses) { + if ($existingAddress.IPAddress -ne $Address.IPAddressToString -or + [int]$existingAddress.PrefixLength -ne $NetworkPrefixLength) { + Remove-NetIPAddress -InputObject $existingAddress -Confirm:$false + } + } + + $matchingAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 ` + -IPAddress $Address.IPAddressToString -ErrorAction SilentlyContinue + if (-not $matchingAddress) { + $addressParameters = @{ + InterfaceIndex = $adapter.ifIndex + IPAddress = $Address.IPAddressToString + PrefixLength = $NetworkPrefixLength + AddressFamily = 'IPv4' + } + if ($Gateway) { + $addressParameters.DefaultGateway = $Gateway.IPAddressToString + } + New-NetIPAddress @addressParameters | Out-Null + } + + if ($Gateway) { + $defaultRoutes = @(Get-NetRoute -InterfaceIndex $adapter.ifIndex ` + -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue) + foreach ($route in $defaultRoutes) { + if ($route.NextHop -ne $Gateway.IPAddressToString) { + Remove-NetRoute -InputObject $route -Confirm:$false + } + } + if (-not (Get-NetRoute -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 ` + -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | + Where-Object NextHop -eq $Gateway.IPAddressToString)) { + New-NetRoute -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 ` + -DestinationPrefix '0.0.0.0/0' -NextHop $Gateway.IPAddressToString | Out-Null + } + } + + Set-DnsClientServerAddress -InterfaceIndex $adapter.ifIndex ` + -ServerAddresses $Address.IPAddressToString +} + +function Register-ResumeTask { + param([Parameter(Mandatory)][string]$ScriptPath) + + $powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" + $action = New-ScheduledTaskAction -Execute $powerShell ` + -Argument "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$ScriptPath`" -Resume" + $trigger = New-ScheduledTaskTrigger -AtStartup + $trigger.Delay = 'PT1M' + $settings = New-ScheduledTaskSettingsSet ` + -StartWhenAvailable ` + -ExecutionTimeLimit (New-TimeSpan -Minutes 30) ` + -RestartCount 3 ` + -RestartInterval (New-TimeSpan -Minutes 2) + Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger ` + -Settings $settings -User 'SYSTEM' -RunLevel Highest -Force | Out-Null +} + +function Ensure-OrganizationalUnit { + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Server + ) + + $distinguishedName = "OU=$Name,$Path" + $existing = Get-ADOrganizationalUnit -Identity $distinguishedName -Server $Server ` + -ErrorAction SilentlyContinue + if (-not $existing) { + New-ADOrganizationalUnit -Name $Name -Path $Path ` + -ProtectedFromAccidentalDeletion $true -Server $Server | Out-Null + } + return $distinguishedName +} + +function Set-PackageShare { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$NetbiosName, + [Parameter(Mandatory)][string]$DomainSid + ) + + New-Item -ItemType Directory -Path $Path -Force | Out-Null + $domainAdminsSid = [Security.Principal.SecurityIdentifier]::new("$DomainSid-512") + $domainUsersSid = [Security.Principal.SecurityIdentifier]::new("$DomainSid-513") + $domainComputersSid = [Security.Principal.SecurityIdentifier]::new("$DomainSid-515") + $systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') + + $acl = New-Object Security.AccessControl.DirectorySecurity + $acl.SetAccessRuleProtection($true, $false) + $inheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit' + $propagation = [Security.AccessControl.PropagationFlags]::None + $allow = [Security.AccessControl.AccessControlType]::Allow + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $systemSid, [Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, $propagation, $allow)) + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $domainAdminsSid, [Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, $propagation, $allow)) + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $domainComputersSid, [Security.AccessControl.FileSystemRights]'ReadAndExecute, Synchronize', + $inheritance, $propagation, $allow)) + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $domainUsersSid, [Security.AccessControl.FileSystemRights]'ReadAndExecute, Synchronize', + $inheritance, $propagation, $allow)) + Set-Acl -LiteralPath $Path -AclObject $acl + + $domainAdmins = $domainAdminsSid.Translate([Security.Principal.NTAccount]).Value + $domainUsers = $domainUsersSid.Translate([Security.Principal.NTAccount]).Value + $domainComputers = $domainComputersSid.Translate([Security.Principal.NTAccount]).Value + $share = Get-SmbShare -Name Packages -ErrorAction SilentlyContinue + if ($share -and $share.Path -ne $Path) { + throw "The existing Packages share points to $($share.Path), not $Path." + } + if (-not $share) { + New-SmbShare -Name Packages -Path $Path -FullAccess $domainAdmins ` + -ReadAccess $domainComputers,$domainUsers -FolderEnumerationMode AccessBased | Out-Null + } + else { + Grant-SmbShareAccess -Name Packages -AccountName $domainAdmins ` + -AccessRight Full -Force | Out-Null + Grant-SmbShareAccess -Name Packages -AccountName $domainComputers ` + -AccessRight Read -Force | Out-Null + Grant-SmbShareAccess -Name Packages -AccountName $domainUsers ` + -AccessRight Read -Force | Out-Null + } +} + +Assert-Administrator +$operatingSystem = Get-CimInstance Win32_OperatingSystem +if ([int]$operatingSystem.ProductType -eq 1) { + throw 'The domain controller bootstrap requires Windows Server, not a Windows client edition.' +} + +$existingState = $null +if (Test-Path -LiteralPath $statePath -PathType Leaf) { + $existingState = Get-Content -LiteralPath $statePath -Raw | ConvertFrom-Json +} + +if ($Resume -or (-not $ServerIPv4Address -and $existingState)) { + if (-not $existingState) { + throw 'The persisted bootstrap state is missing; start the server bootstrap normally.' + } + $ServerIPv4Address = [ipaddress][string]$existingState.ServerIPv4Address + $PrefixLength = [int]$existingState.PrefixLength + $NetworkInterfaceAlias = [string]$existingState.NetworkInterfaceAlias + $DefaultGateway = if ($existingState.DefaultGateway) { [ipaddress][string]$existingState.DefaultGateway } else { $null } + $DnsForwarders = @($existingState.DnsForwarders | ForEach-Object { [ipaddress][string]$_ }) + $DomainName = [string]$existingState.DomainName + $DomainNetbios = [string]$existingState.DomainNetbios + $BrokerRecordName = [string]$existingState.BrokerRecordName + $PackageSharePath = [string]$existingState.PackageSharePath +} + +if (-not $ServerIPv4Address) { + $ServerIPv4Address = [ipaddress](Read-Host 'Fixed IPv4 address for this domain controller') +} + +$sourceRoot = $PSScriptRoot +if (-not $Resume) { + Assert-PackageManifest -PackageRoot $sourceRoot + New-Item -ItemType Directory -Path $bootstrapRoot -Force | Out-Null + if ((Resolve-Path -LiteralPath $sourceRoot).Path -ne (Resolve-Path -LiteralPath $bootstrapRoot).Path) { + Copy-Item -Path (Join-Path $sourceRoot '*') -Destination $bootstrapRoot -Recurse -Force + } + Assert-PackageManifest -PackageRoot $bootstrapRoot +} +else { + Assert-PackageManifest -PackageRoot $bootstrapRoot +} + +$NetworkInterfaceAlias = Resolve-PrivateInterfaceAlias -RequestedAlias $NetworkInterfaceAlias +$baseDn = Get-DomainBaseDn -DnsDomainName $DomainName +$brokerDnsName = "$BrokerRecordName.$DomainName" +$stagedScriptPath = Join-Path $bootstrapRoot 'Initialize-SguDomainController.ps1' +$scriptsRoot = Join-Path $bootstrapRoot 'payload\scripts' +$brokerPublishPath = Join-Path $bootstrapRoot 'payload\broker' + +foreach ($requiredPath in @( + $stagedScriptPath, + (Join-Path $scriptsRoot 'Deploy-AuthBroker.ps1'), + (Join-Path $scriptsRoot 'New-LabCertificate.ps1'), + (Join-Path $scriptsRoot 'Set-LabBrokerDns.ps1'), + (Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1'), + (Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1'), + (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1'), + (Join-Path $brokerPublishPath 'SGU.AuthBroker.exe'))) { + if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) { + throw "The server bootstrap package is incomplete: $requiredPath" + } +} + +if (-not $existingState) { + if ($DnsForwarders.Count -eq 0) { + $DnsForwarders = @(Get-DnsClientServerAddress -AddressFamily IPv4 | + Where-Object InterfaceAlias -ne $NetworkInterfaceAlias | + Select-Object -ExpandProperty ServerAddresses | + Where-Object { $_ -and $_ -ne $ServerIPv4Address.IPAddressToString } | + ForEach-Object { [ipaddress]$_ } | + Select-Object -Unique) + } + + $existingState = [ordered]@{ + Phase = 'Promote' + ServerIPv4Address = $ServerIPv4Address.IPAddressToString + PrefixLength = $PrefixLength + NetworkInterfaceAlias = $NetworkInterfaceAlias + DefaultGateway = if ($DefaultGateway) { $DefaultGateway.IPAddressToString } else { $null } + DnsForwarders = @($DnsForwarders | ForEach-Object IPAddressToString) + DomainName = $DomainName + DomainNetbios = $DomainNetbios + BrokerRecordName = $BrokerRecordName + PackageSharePath = $PackageSharePath + } + [IO.File]::WriteAllText( + $statePath, + ($existingState | ConvertTo-Json -Depth 4), + [Text.UTF8Encoding]::new($false)) +} + +Write-BootstrapLog "Configuring $NetworkInterfaceAlias as $ServerIPv4Address/$PrefixLength." +Set-StaticDomainAddress -InterfaceAlias $NetworkInterfaceAlias ` + -Address $ServerIPv4Address -NetworkPrefixLength $PrefixLength -Gateway $DefaultGateway + +$computer = Get-CimInstance Win32_ComputerSystem +if (-not $computer.PartOfDomain) { + if ([string]$existingState.Phase -eq 'Finalize') { + throw 'Active Directory promotion completed but Windows has not restarted. Restart the server to continue automatically.' + } + + if (-not $SafeModeAdministratorPassword) { + $SafeModeAdministratorPassword = Read-Host ` + 'Directory Services Restore Mode password (not stored)' -AsSecureString + } + + Write-BootstrapLog 'Installing Active Directory Domain Services, DNS, and management tools.' + Install-WindowsFeature AD-Domain-Services,DNS,GPMC,RSAT-AD-Tools ` + -IncludeManagementTools | Out-Null + Register-ResumeTask -ScriptPath $stagedScriptPath + + Write-BootstrapLog "Creating the $DomainName forest. Windows must restart once." + Install-ADDSForest ` + -DomainName $DomainName ` + -DomainNetbiosName $DomainNetbios ` + -InstallDns ` + -SafeModeAdministratorPassword $SafeModeAdministratorPassword ` + -NoRebootOnCompletion ` + -Force | Out-Null + + $existingState.Phase = 'Finalize' + [IO.File]::WriteAllText( + $statePath, + ($existingState | ConvertTo-Json -Depth 4), + [Text.UTF8Encoding]::new($false)) + + if ($SkipRestart) { + Write-BootstrapLog 'Promotion succeeded. Restart manually; finalization will resume at startup.' + return [pscustomobject]@{ + Phase = 'AwaitingRestart' + DomainName = $DomainName + ServerIPv4Address = $ServerIPv4Address.IPAddressToString + ResumeTask = $taskName + } + } + + Restart-Computer -Force + return +} + +if (-not $computer.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreCase)) { + throw "This server belongs to $($computer.Domain), not $DomainName." +} + +Write-BootstrapLog 'Finalizing Active Directory, DNS, policies, broker, shares, and remote management.' +Import-Module ActiveDirectory -ErrorAction Stop +$domain = Get-ADDomain -Identity $DomainName -Server $env:COMPUTERNAME + +$laboratoryOuDn = Ensure-OrganizationalUnit -Name 'Laboratorio' -Path $baseDn -Server $env:COMPUTERNAME +$usersOuDn = Ensure-OrganizationalUnit -Name 'Usuarios-SGU' -Path $baseDn -Server $env:COMPUTERNAME +foreach ($ouName in @('Docentes', 'Alumnos', 'Administrativos')) { + Ensure-OrganizationalUnit -Name $ouName -Path $usersOuDn -Server $env:COMPUTERNAME | Out-Null +} + +$remoteDesktopGroupName = 'SG-Laboratorio-Usuarios-RDP' +$remoteDesktopGroup = Get-ADGroup -Identity $remoteDesktopGroupName -Server $env:COMPUTERNAME ` + -ErrorAction SilentlyContinue +if (-not $remoteDesktopGroup) { + New-ADGroup -Name $remoteDesktopGroupName -SamAccountName $remoteDesktopGroupName ` + -GroupCategory Security -GroupScope Global -Path $laboratoryOuDn ` + -Description 'SGU users permitted to use Remote Desktop on laboratory clients.' ` + -Server $env:COMPUTERNAME | Out-Null + $remoteDesktopGroup = Get-ADGroup -Identity $remoteDesktopGroupName -Server $env:COMPUTERNAME +} + +& (Join-Path $scriptsRoot 'Set-LabBrokerDns.ps1') ` + -ZoneName $DomainName ` + -RecordName $BrokerRecordName ` + -IPv4Address $ServerIPv4Address ` + -ExternalForwarders $DnsForwarders | Out-Null + +$certificateDirectory = Join-Path $bootstrapRoot 'certificates' +$serverCertificate = Get-ChildItem Cert:\LocalMachine\My | + Where-Object { + $_.Subject -eq "CN=$brokerDnsName" -and + $_.HasPrivateKey -and + $_.NotAfter -gt (Get-Date).AddDays(30) + } | + Sort-Object NotAfter -Descending | + Select-Object -First 1 +if (-not $serverCertificate) { + $certificateResult = & (Join-Path $scriptsRoot 'New-LabCertificate.ps1') ` + -Role BrokerServer ` + -BrokerDnsName $brokerDnsName ` + -OutputDirectory $certificateDirectory + $serverCertificate = Get-ChildItem Cert:\LocalMachine\My | + Where-Object Thumbprint -eq $certificateResult.Thumbprint | + Select-Object -First 1 +} +else { + New-Item -ItemType Directory -Path $certificateDirectory -Force | Out-Null + $publicCertificatePath = Join-Path $certificateDirectory 'sgu-auth-broker.cer' + Export-Certificate -Cert $serverCertificate -FilePath $publicCertificatePath -Force | Out-Null + if (-not (Get-ChildItem Cert:\LocalMachine\Root | Where-Object Thumbprint -eq $serverCertificate.Thumbprint)) { + Import-Certificate -FilePath $publicCertificatePath ` + -CertStoreLocation Cert:\LocalMachine\Root | Out-Null + } +} + +$allowedClientThumbprints = @() +$brokerConfigurationPath = 'C:\Program Files\SGU\AuthBroker\appsettings.Production.json' +if (Test-Path -LiteralPath $brokerConfigurationPath -PathType Leaf) { + $priorConfiguration = Get-Content -LiteralPath $brokerConfigurationPath -Raw | ConvertFrom-Json + $allowedClientThumbprints = @($priorConfiguration.Broker.Tls.AllowedClientThumbprints) +} + +& (Join-Path $scriptsRoot 'Deploy-AuthBroker.ps1') ` + -PublishPath $brokerPublishPath ` + -ServerCertificateSubject $brokerDnsName ` + -AllowedClientThumbprints $allowedClientThumbprints ` + -LdapHost $env:COMPUTERNAME ` + -BaseDn $baseDn ` + -DomainNetbios $DomainNetbios ` + -UpnSuffix $DomainName ` + -RemoteDesktopGroupDn $remoteDesktopGroup.DistinguishedName ` + -DefaultCompany 'La Salle' ` + -CreateMissingOus ` + -DisableCertificateRevocationCheckForLab | Out-Null + +& (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') | Out-Null + +$contentPath = Join-Path $bootstrapRoot 'payload\server-content\Packages' +if (Test-Path -LiteralPath $contentPath -PathType Container) { + New-Item -ItemType Directory -Path $PackageSharePath -Force | Out-Null + Copy-Item -Path (Join-Path $contentPath '*') -Destination $PackageSharePath -Recurse -Force +} +Set-PackageShare -Path $PackageSharePath -NetbiosName $DomainNetbios ` + -DomainSid $domain.DomainSID.Value + +& (Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1') ` + -TargetOuDn $laboratoryOuDn -DomainController $env:COMPUTERNAME | Out-Null +$userPolicyParameters = @{ + TargetOuDn = $usersOuDn + DomainController = $env:COMPUTERNAME +} +$wallpaper = Get-ChildItem -LiteralPath $PackageSharePath -File -ErrorAction SilentlyContinue | + Where-Object { $_.BaseName -eq 'wallpaper' -and $_.Extension -in @('.jpg','.jpeg','.png','.bmp') } | + Sort-Object Name | + Select-Object -First 1 +if ($wallpaper) { + $userPolicyParameters.WallpaperPath = "\\$env:COMPUTERNAME\Packages\$($wallpaper.Name)" +} +& (Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1') @userPolicyParameters | Out-Null + +$validation = [ordered]@{ + CompletedAt = (Get-Date).ToString('o') + ComputerName = $env:COMPUTERNAME + DomainName = $DomainName + ServerIPv4Address = $ServerIPv4Address.IPAddressToString + BrokerDnsName = $brokerDnsName + BrokerCertificateThumbprint = $serverCertificate.Thumbprint + BrokerService = (Get-Service SGUAuthBroker).Status.ToString() + BrokerPortListening = [bool](Get-NetTCPConnection -LocalPort 8443 -State Listen -ErrorAction SilentlyContinue) + WinRM = (Get-Service WinRM).Status.ToString() + RemoteDesktop = (Get-Service TermService).Status.ToString() + PackageShare = "\\$env:COMPUTERNAME\Packages" + LaboratoryOu = $laboratoryOuDn + UsersOu = $usersOuDn + RemoteDesktopGroup = $remoteDesktopGroup.DistinguishedName +} + +if ($validation.BrokerService -ne 'Running' -or + -not $validation.BrokerPortListening -or + $validation.WinRM -ne 'Running' -or + $validation.RemoteDesktop -ne 'Running') { + throw 'Server finalization did not pass service validation. Review bootstrap.log and re-run the bootstrap.' +} + +[IO.File]::WriteAllText( + $completionPath, + ($validation | ConvertTo-Json -Depth 4), + [Text.UTF8Encoding]::new($false)) +if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false +} +Remove-Item -LiteralPath $statePath -Force -ErrorAction SilentlyContinue +Write-BootstrapLog 'SGU domain controller bootstrap completed successfully.' +[pscustomobject]$validation diff --git a/scripts/Invoke-SguClientBootstrap.ps1 b/scripts/Invoke-SguClientBootstrap.ps1 new file mode 100644 index 0000000..dd310f8 --- /dev/null +++ b/scripts/Invoke-SguClientBootstrap.ps1 @@ -0,0 +1,327 @@ +#Requires -Version 5.1 +[CmdletBinding(SupportsShouldProcess)] +param( + [ipaddress]$DomainControllerIPv4Address, + [string]$NetworkInterfaceAlias, + [PSCredential]$DomainCredential, + [string]$DomainName = 'lci.lasalle.mx', + [string]$DomainNetbios = 'LCI', + [string]$ComputerOuDn, + [string]$NewComputerName, + [switch]$SkipRestart +) + +$ErrorActionPreference = 'Stop' +$brokerRecordName = 'sgu-auth' +$brokerDnsName = "$brokerRecordName.$DomainName" +$brokerEndpoint = "https://${brokerDnsName}:8443/v1/authenticate" +$temporaryRoot = Join-Path $env:ProgramData ("SGU\Bootstrap\Client-" + [Guid]::NewGuid().ToString('N')) + +function Assert-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run this bootstrap from an elevated Windows PowerShell session.' + } +} + +function Assert-PackageManifest { + param([Parameter(Mandatory)][string]$PackageRoot) + + $manifestPath = Join-Path $PackageRoot 'package-manifest.json' + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw 'package-manifest.json is missing. Use the complete SGU client bootstrap release.' + } + + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + foreach ($entry in $manifest.Files) { + $path = Join-Path $PackageRoot ([string]$entry.Path) + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Bootstrap package file is missing: $($entry.Path)" + } + $actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash + if ($actual -ne [string]$entry.Sha256) { + throw "Bootstrap package integrity check failed: $($entry.Path)" + } + } +} + +function Resolve-ClientInterfaceAlias { + param([string]$RequestedAlias) + + if ($RequestedAlias) { + Get-NetAdapter -Name $RequestedAlias -ErrorAction Stop | Out-Null + return $RequestedAlias + } + + $defaultRoute = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' ` + -ErrorAction SilentlyContinue | + Sort-Object RouteMetric,InterfaceMetric | + Select-Object -First 1 + if ($defaultRoute) { + return [string](Get-NetAdapter -InterfaceIndex $defaultRoute.InterfaceIndex).Name + } + + $upAdapters = @(Get-NetAdapter | Where-Object Status -eq 'Up') + if ($upAdapters.Count -eq 1) { + return [string]$upAdapters[0].Name + } + + $aliases = ($upAdapters.Name | Sort-Object) -join ', ' + throw "Could not select a network adapter. Re-run with -NetworkInterfaceAlias. Available adapters: $aliases" +} + +function Test-TcpPort { + param( + [Parameter(Mandatory)][ipaddress]$Address, + [Parameter(Mandatory)][int]$Port, + [int]$TimeoutMilliseconds = 5000 + ) + + $client = [Net.Sockets.TcpClient]::new() + try { + $connect = $client.BeginConnect($Address, $Port, $null, $null) + if (-not $connect.AsyncWaitHandle.WaitOne($TimeoutMilliseconds)) { + return $false + } + $client.EndConnect($connect) + return $true + } + catch { + return $false + } + finally { + $client.Dispose() + } +} + +Assert-Administrator +$operatingSystem = Get-CimInstance Win32_OperatingSystem +if ([int]$operatingSystem.ProductType -ne 1) { + throw 'The client bootstrap supports Windows 10/11 workstations. Use the server bootstrap on Windows Server.' +} + +$edition = (Get-WindowsEdition -Online).Edition +if ($edition -match '^Core' -or $edition -match 'Home') { + throw "Windows edition '$edition' cannot join an on-premises Active Directory domain or host RDP. Upgrade to Pro, Enterprise, or Education, then run this same bootstrap again." +} + +if (-not $DomainControllerIPv4Address) { + $DomainControllerIPv4Address = [ipaddress](Read-Host 'Fixed IPv4 address of the SGU domain controller') +} +if (-not $ComputerOuDn) { + $baseDn = (($DomainName -split '\.') | ForEach-Object { "DC=$_" }) -join ',' + $ComputerOuDn = "OU=Laboratorio,$baseDn" +} + +$packageRoot = $PSScriptRoot +Assert-PackageManifest -PackageRoot $packageRoot +$scriptsRoot = Join-Path $packageRoot 'payload\scripts' +$providerPublishPath = Join-Path $packageRoot 'payload\credential-provider' +$runtimeInstaller = Get-ChildItem (Join-Path $packageRoot 'payload\prerequisites') ` + -Filter '*x64*.exe' -File -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | + Select-Object -First 1 +foreach ($requiredPath in @( + (Join-Path $scriptsRoot 'Enroll-SguDomainClient.ps1'), + (Join-Path $scriptsRoot 'Register-SguClientCertificate.ps1'), + (Join-Path $providerPublishPath 'SGU.CredentialProvider.comhost.dll'))) { + if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) { + throw "The client bootstrap package is incomplete: $requiredPath" + } +} +if (-not $runtimeInstaller) { + throw 'The offline Microsoft .NET 10 x64 runtime installer is missing from the client package.' +} + +$NetworkInterfaceAlias = Resolve-ClientInterfaceAlias -RequestedAlias $NetworkInterfaceAlias +Set-DnsClientServerAddress -InterfaceAlias $NetworkInterfaceAlias ` + -ServerAddresses $DomainControllerIPv4Address.IPAddressToString + +if (-not (Test-TcpPort -Address $DomainControllerIPv4Address -Port 5985)) { + throw "The domain controller at $DomainControllerIPv4Address is not accepting WinRM on TCP 5985. Run the server bootstrap first and verify the selected IP." +} + +if (-not $DomainCredential) { + $DomainCredential = Get-Credential ` + -UserName "$DomainNetbios\Administrator" ` + -Message "Credential permitted to enroll this computer in $DomainName" +} + +New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null +$clientCertificatePath = Join-Path $temporaryRoot 'client.cer' +$serverCertificatePath = Join-Path $temporaryRoot 'server.cer' +$remoteRegistrationScript = $null +$remoteClientCertificate = $null +$session = $null +$winRmWasRunning = (Get-Service WinRM).Status -eq 'Running' +$priorTrustedHosts = $null + +try { + if (-not $winRmWasRunning) { + Set-Service WinRM -StartupType Manual + Start-Service WinRM + } + $priorTrustedHosts = [string](Get-Item WSMan:\localhost\Client\TrustedHosts).Value + $trustedHostValues = @($priorTrustedHosts -split ',' | ForEach-Object Trim | Where-Object { $_ }) + if ($trustedHostValues -notcontains $DomainControllerIPv4Address.IPAddressToString) { + $trustedHostValues += $DomainControllerIPv4Address.IPAddressToString + Set-Item WSMan:\localhost\Client\TrustedHosts -Value ($trustedHostValues -join ',') -Force + } + + $session = New-PSSession ` + -ComputerName $DomainControllerIPv4Address.IPAddressToString ` + -Credential $DomainCredential ` + -Authentication Negotiate + + $serverIdentity = Invoke-Command -Session $session -ScriptBlock { + $computer = Get-CimInstance Win32_ComputerSystem + $brokerService = Get-Service SGUAuthBroker -ErrorAction SilentlyContinue + [pscustomobject]@{ + ComputerName = $env:COMPUTERNAME + Domain = $computer.Domain + BrokerService = if ($brokerService) { $brokerService.Status.ToString() } else { 'Missing' } + } + } + if (-not $serverIdentity.Domain -or + -not $serverIdentity.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreCase)) { + throw "The server at $DomainControllerIPv4Address belongs to $($serverIdentity.Domain), not $DomainName." + } + if ($serverIdentity.BrokerService -ne 'Running') { + throw "The SGU Authentication Broker is not running on $($serverIdentity.ComputerName)." + } + + $certificateSubject = "CN=SGU Credential Provider Client $env:COMPUTERNAME" + $clientCertificate = Get-ChildItem Cert:\LocalMachine\My | + Where-Object { + $_.Subject -eq $certificateSubject -and + $_.HasPrivateKey -and + $_.NotAfter -gt (Get-Date).AddDays(30) + } | + Sort-Object NotAfter -Descending | + Select-Object -First 1 + if (-not $clientCertificate) { + $clientCertificate = New-SelfSignedCertificate ` + -Subject $certificateSubject ` + -CertStoreLocation Cert:\LocalMachine\My ` + -KeyAlgorithm RSA ` + -KeyLength 3072 ` + -HashAlgorithm SHA256 ` + -KeyExportPolicy NonExportable ` + -NotAfter (Get-Date).AddYears(2) ` + -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.2') + } + Export-Certificate -Cert $clientCertificate -FilePath $clientCertificatePath -Force | Out-Null + if (-not (Get-ChildItem Cert:\LocalMachine\Root | Where-Object Thumbprint -eq $clientCertificate.Thumbprint)) { + Import-Certificate -FilePath $clientCertificatePath ` + -CertStoreLocation Cert:\LocalMachine\Root | Out-Null + } + + $remoteTemporaryRoot = Invoke-Command -Session $session -ScriptBlock { + $path = Join-Path $env:ProgramData ("SGU\Enrollment\Incoming-" + [Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $path -Force | Out-Null + $path + } + $remoteRegistrationScript = Join-Path $remoteTemporaryRoot 'Register-SguClientCertificate.ps1' + $remoteClientCertificate = Join-Path $remoteTemporaryRoot 'client.cer' + Copy-Item -LiteralPath (Join-Path $scriptsRoot 'Register-SguClientCertificate.ps1') ` + -Destination $remoteRegistrationScript -ToSession $session + Copy-Item -LiteralPath $clientCertificatePath ` + -Destination $remoteClientCertificate -ToSession $session + + Invoke-Command -Session $session -ScriptBlock { + param($RegistrationScript, $CertificatePath) + & $RegistrationScript -CertificatePath $CertificatePath | Out-Null + } -ArgumentList $remoteRegistrationScript,$remoteClientCertificate + + $serverCertificateBase64 = Invoke-Command -Session $session -ScriptBlock { + param($ExpectedSubject) + $certificate = Get-ChildItem Cert:\LocalMachine\My | + Where-Object { + $_.Subject -eq "CN=$ExpectedSubject" -and + $_.HasPrivateKey -and + $_.NotAfter -gt (Get-Date) + } | + Sort-Object NotAfter -Descending | + Select-Object -First 1 + if (-not $certificate) { + throw "The broker certificate for $ExpectedSubject is missing." + } + [Convert]::ToBase64String($certificate.RawData) + } -ArgumentList $brokerDnsName + [IO.File]::WriteAllBytes($serverCertificatePath, [Convert]::FromBase64String($serverCertificateBase64)) + $serverCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new($serverCertificatePath) + Import-Certificate -FilePath $serverCertificatePath ` + -CertStoreLocation Cert:\LocalMachine\Root | Out-Null + + Resolve-DnsName -Type SRV "_ldap._tcp.dc._msdcs.$DomainName" -ErrorAction Stop | Out-Null + $brokerAddress = Resolve-DnsName -Name $brokerDnsName -Type A -ErrorAction Stop | + Where-Object IPAddress -eq $DomainControllerIPv4Address.IPAddressToString + if (-not $brokerAddress) { + throw "$brokerDnsName does not resolve to $DomainControllerIPv4Address." + } + + $enrollmentParameters = @{ + PublishPath = $providerPublishPath + BrokerEndpoint = $brokerEndpoint + ClientCertificateThumbprint = $clientCertificate.Thumbprint + ServerCertificateThumbprint = $serverCertificate.Thumbprint + DomainCredential = $DomainCredential + DomainName = $DomainName + DomainNetbios = $DomainNetbios + ComputerOuDn = $ComputerOuDn + NetworkInterfaceAlias = $NetworkInterfaceAlias + DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString) + RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP" + DotNetRuntimeInstallerPath = $runtimeInstaller.FullName + SkipRestart = $true + } + if ($NewComputerName) { + $enrollmentParameters.NewComputerName = $NewComputerName + } + + $result = & (Join-Path $scriptsRoot 'Enroll-SguDomainClient.ps1') @enrollmentParameters +} +finally { + if ($session) { + if ($remoteRegistrationScript -or $remoteClientCertificate) { + Invoke-Command -Session $session -ScriptBlock { + param($Paths) + foreach ($path in $Paths) { + if ($path) { + Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue + } + } + if ($Paths.Count -gt 0 -and $Paths[0]) { + Remove-Item -LiteralPath (Split-Path $Paths[0] -Parent) ` + -Force -ErrorAction SilentlyContinue + } + } -ArgumentList (,@($remoteRegistrationScript,$remoteClientCertificate)) ` + -ErrorAction SilentlyContinue + } + Remove-PSSession $session + } + if ($null -ne $priorTrustedHosts) { + Set-Item WSMan:\localhost\Client\TrustedHosts -Value $priorTrustedHosts -Force + } + if (-not $winRmWasRunning) { + Stop-Service WinRM -Force -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue + $DomainCredential = $null +} + +if ($SkipRestart) { + [pscustomobject]@{ + ComputerName = if ($NewComputerName) { $NewComputerName } else { $env:COMPUTERNAME } + DomainName = $DomainName + ProviderInstalled = $true + ClientCertificateRegistered = $true + BrokerEndpoint = $brokerEndpoint + RestartRequired = $true + EnrollmentResult = $result + } + return +} + +Restart-Computer -Force diff --git a/scripts/New-SguBootstrapPackages.ps1 b/scripts/New-SguBootstrapPackages.ps1 new file mode 100644 index 0000000..52f1274 --- /dev/null +++ b/scripts/New-SguBootstrapPackages.ps1 @@ -0,0 +1,169 @@ +#Requires -Version 5.1 +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidatePattern('^\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?$')] + [string]$Version, + [string]$OutputRoot = (Join-Path $PSScriptRoot '..\artifacts\releases'), + [string]$ServerContentPath, + [switch]$SkipBuild +) + +$ErrorActionPreference = 'Stop' +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$resolvedOutputRoot = [IO.Path]::GetFullPath($OutputRoot) +if (-not $resolvedOutputRoot.StartsWith($repositoryRoot + '\', [StringComparison]::OrdinalIgnoreCase)) { + throw 'OutputRoot must be beneath the repository root.' +} + +function Copy-RequiredFile { + param( + [Parameter(Mandatory)][string]$Source, + [Parameter(Mandatory)][string]$Destination + ) + + if (-not (Test-Path -LiteralPath $Source -PathType Leaf)) { + throw "Required package input is missing: $Source" + } + New-Item -ItemType Directory -Path (Split-Path $Destination -Parent) -Force | Out-Null + Copy-Item -LiteralPath $Source -Destination $Destination -Force +} + +function Write-PackageManifest { + param( + [Parameter(Mandatory)][string]$PackageRoot, + [Parameter(Mandatory)][string]$PackageVersion, + [Parameter(Mandatory)][string]$PackageKind + ) + + $resolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path.TrimEnd('\') + $files = @(Get-ChildItem -LiteralPath $resolvedPackageRoot -Recurse -File | + Where-Object Name -ne 'package-manifest.json' | + Sort-Object FullName | + ForEach-Object { + [ordered]@{ + Path = $_.FullName.Substring($resolvedPackageRoot.Length).TrimStart('\') + Sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + Length = $_.Length + } + }) + $manifest = [ordered]@{ + SchemaVersion = 1 + Product = 'SGU Credential Provider' + PackageKind = $PackageKind + Version = $PackageVersion + CreatedAt = (Get-Date).ToUniversalTime().ToString('o') + Files = $files + } + [IO.File]::WriteAllText( + (Join-Path $resolvedPackageRoot 'package-manifest.json'), + ($manifest | ConvertTo-Json -Depth 6), + [Text.UTF8Encoding]::new($false)) +} + +if (-not $SkipBuild) { + & (Join-Path $PSScriptRoot 'Publish-Lab.ps1') -Configuration Release ` + -OutputRoot (Join-Path $repositoryRoot 'artifacts') | Out-Null +} + +$brokerOutput = Join-Path $repositoryRoot 'artifacts\broker' +$providerOutput = Join-Path $repositoryRoot 'artifacts\credential-provider' +$prerequisiteRoot = Join-Path $repositoryRoot 'artifacts\prerequisites' +$runtimeInstaller = Get-ChildItem -LiteralPath $prerequisiteRoot -Filter '*x64*.exe' ` + -File -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | + Select-Object -First 1 +if (-not $runtimeInstaller) { + throw 'Place the offline Microsoft .NET 10 x64 runtime installer in artifacts\prerequisites.' +} + +New-Item -ItemType Directory -Path $resolvedOutputRoot -Force | Out-Null +$clientRoot = Join-Path $resolvedOutputRoot "sgu-client-bootstrap-$Version" +$serverRoot = Join-Path $resolvedOutputRoot "sgu-server-bootstrap-$Version" +$clientZip = "$clientRoot.zip" +$serverZip = "$serverRoot.zip" +foreach ($target in @($clientRoot,$serverRoot,$clientZip,$serverZip)) { + if (Test-Path -LiteralPath $target) { + throw "Release target already exists: $target" + } +} + +New-Item -ItemType Directory -Path $clientRoot,$serverRoot -Force | Out-Null + +Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Invoke-SguClientBootstrap.ps1') ` + -Destination (Join-Path $clientRoot 'Invoke-SguClientBootstrap.ps1') +Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguClientEnrollment.cmd') ` + -Destination (Join-Path $clientRoot 'Start-SguClientEnrollment.cmd') +$clientScripts = @( + 'Enable-LabRemoteAccess.ps1', + 'Enroll-SguDomainClient.ps1', + 'Install-CredentialProvider.ps1', + 'Install-SguEnrollmentGuard.ps1', + 'Register-SguClientCertificate.ps1', + 'Repair-SguClientEnrollment.ps1', + 'Test-SguClientEnrollment.ps1' +) +foreach ($scriptName in $clientScripts) { + Copy-RequiredFile -Source (Join-Path $PSScriptRoot $scriptName) ` + -Destination (Join-Path $clientRoot "payload\scripts\$scriptName") +} +Copy-Item -Path (Join-Path $providerOutput '*') ` + -Destination (New-Item -ItemType Directory ` + -Path (Join-Path $clientRoot 'payload\credential-provider') -Force).FullName ` + -Recurse -Force +Copy-RequiredFile -Source $runtimeInstaller.FullName ` + -Destination (Join-Path $clientRoot "payload\prerequisites\$($runtimeInstaller.Name)") +Write-PackageManifest -PackageRoot $clientRoot -PackageVersion $Version -PackageKind Client +Compress-Archive -Path (Join-Path $clientRoot '*') -DestinationPath $clientZip ` + -CompressionLevel Optimal + +Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Initialize-SguDomainController.ps1') ` + -Destination (Join-Path $serverRoot 'Initialize-SguDomainController.ps1') +Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguServerBootstrap.cmd') ` + -Destination (Join-Path $serverRoot 'Start-SguServerBootstrap.cmd') +$serverScripts = @( + 'Deploy-AuthBroker.ps1', + 'Enable-SguServerRemoteManagement.ps1', + 'New-LabCertificate.ps1', + 'Register-SguClientCertificate.ps1', + 'Set-LabBrokerDns.ps1', + 'Set-SguDomainComputerPolicies.ps1', + 'Set-SguDomainUserPolicies.ps1' +) +foreach ($scriptName in $serverScripts) { + Copy-RequiredFile -Source (Join-Path $PSScriptRoot $scriptName) ` + -Destination (Join-Path $serverRoot "payload\scripts\$scriptName") +} +Copy-Item -Path (Join-Path $brokerOutput '*') ` + -Destination (New-Item -ItemType Directory ` + -Path (Join-Path $serverRoot 'payload\broker') -Force).FullName ` + -Recurse -Force +$serverContentTarget = Join-Path $serverRoot 'payload\server-content\Packages' +New-Item -ItemType Directory -Path $serverContentTarget -Force | Out-Null +if ($ServerContentPath) { + if (-not (Test-Path -LiteralPath $ServerContentPath -PathType Container)) { + throw 'ServerContentPath does not exist.' + } + Copy-Item -Path (Join-Path $ServerContentPath '*') ` + -Destination $serverContentTarget -Recurse -Force +} +Write-PackageManifest -PackageRoot $serverRoot -PackageVersion $Version -PackageKind Server +Compress-Archive -Path (Join-Path $serverRoot '*') -DestinationPath $serverZip ` + -CompressionLevel Optimal + +$checksums = @( + ("{0} {1}" -f (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash, (Split-Path $clientZip -Leaf)) + ("{0} {1}" -f (Get-FileHash -LiteralPath $serverZip -Algorithm SHA256).Hash, (Split-Path $serverZip -Leaf)) +) +$checksumsPath = Join-Path $resolvedOutputRoot "SHA256SUMS-$Version.txt" +[IO.File]::WriteAllLines($checksumsPath, $checksums, [Text.UTF8Encoding]::new($false)) + +[pscustomobject]@{ + Version = $Version + ClientPackage = $clientZip + ClientSha256 = (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash + ServerPackage = $serverZip + ServerSha256 = (Get-FileHash -LiteralPath $serverZip -Algorithm SHA256).Hash + Checksums = $checksumsPath + RuntimeInstaller = $runtimeInstaller.Name +} diff --git a/scripts/Publish-GiteaRelease.ps1 b/scripts/Publish-GiteaRelease.ps1 new file mode 100644 index 0000000..9ba650b --- /dev/null +++ b/scripts/Publish-GiteaRelease.ps1 @@ -0,0 +1,155 @@ +#Requires -Version 5.1 +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory)] + [ValidatePattern('^\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?$')] + [string]$Version, + [string]$ReleaseDirectory = (Join-Path $PSScriptRoot '..\artifacts\releases'), + [uri]$GiteaBaseUri = 'https://gitea.lci.ulsa.mx', + [string]$Owner = 'alexrg', + [string]$Repository = 'SGU-CredentialProvider', + [string]$TargetCommitish = 'main', + [switch]$Draft +) + +$ErrorActionPreference = 'Stop' +$tagName = "v$Version" +$assetPaths = @( + (Join-Path $ReleaseDirectory "sgu-client-bootstrap-$Version.zip"), + (Join-Path $ReleaseDirectory "sgu-server-bootstrap-$Version.zip"), + (Join-Path $ReleaseDirectory "SHA256SUMS-$Version.txt") +) +foreach ($assetPath in $assetPaths) { + if (-not (Test-Path -LiteralPath $assetPath -PathType Leaf)) { + throw "Release asset is missing: $assetPath" + } +} + +$token = $env:GITEA_TOKEN +if (-not $token) { + $credentialInput = "protocol=$($GiteaBaseUri.Scheme)`nhost=$($GiteaBaseUri.Host)`n`n" + $credentialOutput = $credentialInput | & git credential fill + if ($LASTEXITCODE -ne 0) { + throw 'Git Credential Manager could not supply Gitea credentials. Set GITEA_TOKEN for this process.' + } + $credentialValues = @{} + foreach ($line in $credentialOutput) { + $parts = $line -split '=', 2 + if ($parts.Count -eq 2) { + $credentialValues[$parts[0]] = $parts[1] + } + } + $token = $credentialValues.password +} +if (-not $token) { + throw 'No Gitea token is available. Set GITEA_TOKEN for this process or sign in through Git Credential Manager.' +} + +Add-Type -AssemblyName System.Net.Http +$handler = [Net.Http.HttpClientHandler]::new() +$client = [Net.Http.HttpClient]::new($handler) +$client.BaseAddress = [uri]($GiteaBaseUri.AbsoluteUri.TrimEnd('/') + '/') +$client.DefaultRequestHeaders.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new('token', $token) +$client.DefaultRequestHeaders.UserAgent.ParseAdd('SGU-CredentialProvider-Release/1.0') + +function Invoke-GiteaJson { + param( + [Parameter(Mandatory)][Net.Http.HttpMethod]$Method, + [Parameter(Mandatory)][string]$RelativeUri, + [object]$Body, + [switch]$AllowNotFound + ) + + $request = [Net.Http.HttpRequestMessage]::new($Method, $RelativeUri) + if ($null -ne $Body) { + $json = $Body | ConvertTo-Json -Depth 6 + $request.Content = [Net.Http.StringContent]::new($json, [Text.Encoding]::UTF8, 'application/json') + } + try { + $response = $client.SendAsync($request).GetAwaiter().GetResult() + if ($AllowNotFound -and $response.StatusCode -eq [Net.HttpStatusCode]::NotFound) { + return $null + } + $content = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + if (-not $response.IsSuccessStatusCode) { + throw "Gitea returned HTTP $([int]$response.StatusCode): $content" + } + if ($content) { + return $content | ConvertFrom-Json + } + } + finally { + $request.Dispose() + } +} + +try { + $encodedOwner = [Uri]::EscapeDataString($Owner) + $encodedRepository = [Uri]::EscapeDataString($Repository) + $encodedTag = [Uri]::EscapeDataString($tagName) + $repositoryPath = "api/v1/repos/$encodedOwner/$encodedRepository" + $existingRelease = Invoke-GiteaJson -Method ([Net.Http.HttpMethod]::Get) ` + -RelativeUri "$repositoryPath/releases/tags/$encodedTag" -AllowNotFound + if ($existingRelease) { + throw "Release $tagName already exists. Choose a new version." + } + + $releaseNotes = @" +Bootstrap reproducible para el laboratorio SGU. + +- `sgu-server-bootstrap-$Version.zip`: crea el bosque AD/DNS, OUs, grupo RDP, GPO, recurso `Packages`, broker mTLS y administración remota; se reanuda solo después del reinicio. +- `sgu-client-bootstrap-$Version.zip`: registra un certificado mTLS único, instala y valida el Credential Provider antes de unir el equipo al dominio, habilita RDP/WinRM y se repara al arranque. +- Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host. + +Las contraseñas se solicitan de forma interactiva y no se escriben en archivos ni en la línea de comandos. Verifique los ZIP con `SHA256SUMS-$Version.txt`. +"@ + $releaseBody = [ordered]@{ + tag_name = $tagName + target_commitish = $TargetCommitish + name = "SGU Credential Provider $Version" + body = $releaseNotes + draft = [bool]$Draft + prerelease = $Version -match '-' + } + + if (-not $PSCmdlet.ShouldProcess("$Owner/$Repository $tagName", 'Create Gitea release and upload bootstrap assets')) { + return + } + + $release = Invoke-GiteaJson -Method ([Net.Http.HttpMethod]::Post) ` + -RelativeUri "$repositoryPath/releases" -Body $releaseBody + + foreach ($assetPath in $assetPaths) { + $assetName = Split-Path $assetPath -Leaf + $uploadUri = "$repositoryPath/releases/$($release.id)/assets?name=$([Uri]::EscapeDataString($assetName))" + $stream = [IO.File]::OpenRead((Resolve-Path -LiteralPath $assetPath).Path) + $multipart = [Net.Http.MultipartFormDataContent]::new() + $fileContent = [Net.Http.StreamContent]::new($stream) + $fileContent.Headers.ContentType = [Net.Http.Headers.MediaTypeHeaderValue]::new('application/octet-stream') + $multipart.Add($fileContent, 'attachment', $assetName) + try { + $response = $client.PostAsync($uploadUri, $multipart).GetAwaiter().GetResult() + $content = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + if (-not $response.IsSuccessStatusCode) { + throw "Gitea asset upload returned HTTP $([int]$response.StatusCode): $content" + } + } + finally { + $multipart.Dispose() + $stream.Dispose() + } + } + + [pscustomobject]@{ + Tag = $tagName + ReleaseName = $release.name + ReleaseUrl = $release.html_url + Draft = [bool]$release.draft + Assets = $assetPaths | ForEach-Object { Split-Path $_ -Leaf } + } +} +finally { + $token = $null + $client.Dispose() + $handler.Dispose() +} diff --git a/scripts/Register-SguClientCertificate.ps1 b/scripts/Register-SguClientCertificate.ps1 index 6a345c9..545eca0 100644 --- a/scripts/Register-SguClientCertificate.ps1 +++ b/scripts/Register-SguClientCertificate.ps1 @@ -33,29 +33,51 @@ if (-not $ekuExtension -or throw 'The certificate is not valid for TLS client authentication.' } -if ($PSCmdlet.ShouldProcess($candidate.Thumbprint, 'Trust and allow the SGU client certificate')) { - $trustedCertificate = Import-Certificate ` - -FilePath $CertificatePath ` - -CertStoreLocation Cert:\LocalMachine\Root | - Select-Object -First 1 - - $configuration = Get-Content -LiteralPath $BrokerConfigurationPath -Raw | ConvertFrom-Json - $allowed = @($configuration.Broker.Tls.AllowedClientThumbprints | - ForEach-Object { $_ -replace ' ', '' }) - if ($allowed -notcontains $trustedCertificate.Thumbprint) { - $configuration.Broker.Tls.AllowedClientThumbprints = @($allowed + $trustedCertificate.Thumbprint) - $backupPath = "$BrokerConfigurationPath.before-$($trustedCertificate.Thumbprint.Substring(0, 12)).bak" - Copy-Item -LiteralPath $BrokerConfigurationPath -Destination $backupPath -Force - [IO.File]::WriteAllText( - $BrokerConfigurationPath, - ($configuration | ConvertTo-Json -Depth 8), - [Text.UTF8Encoding]::new($false)) +$mutex = [Threading.Mutex]::new($false, 'Global\SGUAuthBroker-Client-AllowList') +$lockTaken = $false +try { + try { + $lockTaken = $mutex.WaitOne([TimeSpan]::FromSeconds(30)) + } + catch [Threading.AbandonedMutexException] { + # The previous updater exited unexpectedly, but this process now owns + # the abandoned mutex and can safely rebuild the allow-list from disk. + $lockTaken = $true + } + if (-not $lockTaken) { + throw 'Timed out waiting to update the broker client allow-list.' } - Restart-Service -Name $serviceName -Force - (Get-Service -Name $serviceName).WaitForStatus( - [System.ServiceProcess.ServiceControllerStatus]::Running, - [TimeSpan]::FromSeconds(20)) + if ($PSCmdlet.ShouldProcess($candidate.Thumbprint, 'Trust and allow the SGU client certificate')) { + $trustedCertificate = Import-Certificate ` + -FilePath $CertificatePath ` + -CertStoreLocation Cert:\LocalMachine\Root | + Select-Object -First 1 + + $configuration = Get-Content -LiteralPath $BrokerConfigurationPath -Raw | ConvertFrom-Json + $allowed = @($configuration.Broker.Tls.AllowedClientThumbprints | + ForEach-Object { $_ -replace ' ', '' }) + if ($allowed -notcontains $trustedCertificate.Thumbprint) { + $configuration.Broker.Tls.AllowedClientThumbprints = @($allowed + $trustedCertificate.Thumbprint) + $backupPath = "$BrokerConfigurationPath.before-$($trustedCertificate.Thumbprint.Substring(0, 12)).bak" + Copy-Item -LiteralPath $BrokerConfigurationPath -Destination $backupPath -Force + [IO.File]::WriteAllText( + $BrokerConfigurationPath, + ($configuration | ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false)) + } + + Restart-Service -Name $serviceName -Force + (Get-Service -Name $serviceName).WaitForStatus( + [System.ServiceProcess.ServiceControllerStatus]::Running, + [TimeSpan]::FromSeconds(20)) + } +} +finally { + if ($lockTaken) { + $mutex.ReleaseMutex() + } + $mutex.Dispose() } [pscustomobject]@{ diff --git a/scripts/Set-LabBrokerDns.ps1 b/scripts/Set-LabBrokerDns.ps1 index 08dad65..ef51af3 100644 --- a/scripts/Set-LabBrokerDns.ps1 +++ b/scripts/Set-LabBrokerDns.ps1 @@ -11,8 +11,11 @@ $ErrorActionPreference = 'Stop' $existing = Get-DnsServerResourceRecord -ZoneName $ZoneName -Name $RecordName -RRType A -ErrorAction SilentlyContinue if ($existing) { $current = @($existing.RecordData.IPv4Address.IPAddressToString) - if ($current -notcontains $IPv4Address.IPAddressToString) { - throw "$RecordName.$ZoneName already exists with a different address: $($current -join ', ')." + if ($current.Count -ne 1 -or $current[0] -ne $IPv4Address.IPAddressToString) { + # The fixed lab address is an explicit bootstrap input and may change + # when the server is rebuilt. Replace only this exact A record set. + $existing | Remove-DnsServerResourceRecord -ZoneName $ZoneName -Force + Add-DnsServerResourceRecordA -ZoneName $ZoneName -Name $RecordName -IPv4Address $IPv4Address } } else { diff --git a/scripts/Set-SguDomainUserPolicies.ps1 b/scripts/Set-SguDomainUserPolicies.ps1 index 43cc0b8..fd62c25 100644 --- a/scripts/Set-SguDomainUserPolicies.ps1 +++ b/scripts/Set-SguDomainUserPolicies.ps1 @@ -2,7 +2,8 @@ param( [string]$TargetOuDn = 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx', [string]$GpoName = 'SGU - User session restrictions', - [string]$DomainController = $env:COMPUTERNAME + [string]$DomainController = $env:COMPUTERNAME, + [string]$WallpaperPath ) $ErrorActionPreference = 'Stop' @@ -81,6 +82,25 @@ if ($PSCmdlet.ShouldProcess($GpoName, 'Prevent SGU users from manually locking w -ValueName 'ScreenSaveActive' ` -Type String ` -Value '0' | Out-Null + + if ($WallpaperPath) { + Set-GPRegistryValue ` + -Name $GpoName ` + -Domain $domainName ` + -Server $DomainController ` + -Key $policyKey ` + -ValueName 'Wallpaper' ` + -Type String ` + -Value $WallpaperPath | Out-Null + Set-GPRegistryValue ` + -Name $GpoName ` + -Domain $domainName ` + -Server $DomainController ` + -Key $policyKey ` + -ValueName 'WallpaperStyle' ` + -Type String ` + -Value '10' | Out-Null + } } $configuredValue = Get-GPRegistryValue ` @@ -101,6 +121,15 @@ $link = @(Get-GPInheritance -Target $TargetOuDn -Domain $domainName -Server $Dom $linkEnabled = $link -and ( $link.Enabled -eq $true -or [string]$link.Enabled -eq 'Yes') +$configuredWallpaper = $null +if ($WallpaperPath) { + $configuredWallpaper = (Get-GPRegistryValue ` + -Name $GpoName ` + -Domain $domainName ` + -Server $DomainController ` + -Key $policyKey ` + -ValueName 'Wallpaper').Value +} [pscustomobject]@{ GpoName = $GpoName @@ -109,4 +138,5 @@ $linkEnabled = $link -and ( LinkEnabled = [bool]$linkEnabled DisableLockWorkstation = [int]$configuredValue.Value ScreenSaverDisabled = [string]$screenSaverValue.Value -eq '0' + Wallpaper = $configuredWallpaper } diff --git a/scripts/Start-SguClientEnrollment.cmd b/scripts/Start-SguClientEnrollment.cmd new file mode 100644 index 0000000..ab18dc5 --- /dev/null +++ b/scripts/Start-SguClientEnrollment.cmd @@ -0,0 +1,5 @@ +@echo off +setlocal +set "SGU_BOOTSTRAP_IP=%~1" +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"')); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',$env:SGU_BOOTSTRAP_IP) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode" +exit /b %errorlevel% diff --git a/scripts/Start-SguServerBootstrap.cmd b/scripts/Start-SguServerBootstrap.cmd new file mode 100644 index 0000000..cc0e8ed --- /dev/null +++ b/scripts/Start-SguServerBootstrap.cmd @@ -0,0 +1,5 @@ +@echo off +setlocal +set "SGU_BOOTSTRAP_IP=%~1" +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Initialize-SguDomainController.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"')); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-ServerIPv4Address',$env:SGU_BOOTSTRAP_IP) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode" +exit /b %errorlevel% diff --git a/src/SGU.AuthBroker/Options/BrokerOptions.cs b/src/SGU.AuthBroker/Options/BrokerOptions.cs index 735bfe8..241c599 100644 --- a/src/SGU.AuthBroker/Options/BrokerOptions.cs +++ b/src/SGU.AuthBroker/Options/BrokerOptions.cs @@ -14,10 +14,9 @@ public sealed class BrokerOptions public void Validate() { - if (Tls.AllowedClientThumbprints.Length == 0 || - Tls.AllowedClientThumbprints.Any(value => !IsCertificateThumbprint(value))) + if (Tls.AllowedClientThumbprints.Any(value => !IsCertificateThumbprint(value))) { - throw new InvalidOperationException("At least one client certificate thumbprint is required."); + throw new InvalidOperationException("Every configured client certificate thumbprint must be valid."); } if (!Uri.TryCreate(Ntlm.Endpoint, UriKind.Absolute, out Uri? endpoint) || endpoint.Scheme != Uri.UriSchemeHttps) diff --git a/src/SGU.AuthBroker/appsettings.json b/src/SGU.AuthBroker/appsettings.json index 94d4163..6f9d857 100644 --- a/src/SGU.AuthBroker/appsettings.json +++ b/src/SGU.AuthBroker/appsettings.json @@ -21,9 +21,7 @@ }, "Broker": { "Tls": { - "AllowedClientThumbprints": [ - "SET-BY-DEPLOYMENT" - ], + "AllowedClientThumbprints": [], "CheckCertificateRevocation": true }, "Ntlm": { diff --git a/tests/SGU.AuthBroker.Tests/BrokerOptionsTests.cs b/tests/SGU.AuthBroker.Tests/BrokerOptionsTests.cs new file mode 100644 index 0000000..4bc8bae --- /dev/null +++ b/tests/SGU.AuthBroker.Tests/BrokerOptionsTests.cs @@ -0,0 +1,31 @@ +using SGU.AuthBroker.Options; +using Xunit; + +namespace SGU.AuthBroker.Tests; + +public sealed class BrokerOptionsTests +{ + [Fact] + public void ValidateAllowsAnEmptyClientAllowListDuringServerBootstrap() + { + BrokerOptions options = new(); + + options.Validate(); + } + + [Fact] + public void ValidateRejectsAnInvalidConfiguredClientThumbprint() + { + BrokerOptions options = new() + { + Tls = new TlsOptions + { + AllowedClientThumbprints = ["not-a-thumbprint"] + } + }; + + InvalidOperationException exception = Assert.Throws(options.Validate); + + Assert.Contains("thumbprint", exception.Message, StringComparison.OrdinalIgnoreCase); + } +}