Add one-command server and client bootstraps

This commit is contained in:
2026-09-03 16:34:21 -06:00
parent 742ae9c2b5
commit 3a10098239
19 changed files with 1569 additions and 37 deletions
+15
View File
@@ -43,6 +43,7 @@ accepted the credential.
Operational documentation: Operational documentation:
- [One-command server recovery and client enrollment](docs/bootstrap-recovery.md)
- [Broker location, health, timeout, and recovery](docs/broker-operations.md) - [Broker location, health, timeout, and recovery](docs/broker-operations.md)
- [Windows domain join and remote-access onboarding](docs/windows-client-onboarding.md) - [Windows domain join and remote-access onboarding](docs/windows-client-onboarding.md)
- [Required Credential Provider client enrollment](docs/client-enrollment.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 Never disable the built-in Microsoft password Credential Provider. It is the
supported recovery path if a third-party provider fails to load. 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 ## Upstream license
The Lithnet source remains under its MIT license in [LICENSE](LICENSE). Project The Lithnet source remains under its MIT license in [LICENSE](LICENSE). Project
+146
View File
@@ -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.
+12
View File
@@ -1,5 +1,17 @@
# Enrolamiento obligatorio de clientes SGU # 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 El flujo administrado instala y valida el Credential Provider **antes** de
ejecutar `Add-Computer`. La pertenencia al dominio es el último cambio; si falta 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 el runtime, un certificado, el registro COM, la directiva predeterminada o la
+5
View File
@@ -12,6 +12,11 @@ Validated lab inventory:
Run guest commands from an elevated PowerShell console inside each VM. Do not 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. 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 ## 1. Build on the Windows 11 host
```powershell ```powershell
+1 -2
View File
@@ -6,9 +6,8 @@ param(
[Parameter(Mandatory)] [Parameter(Mandatory)]
[string]$ServerCertificateSubject, [string]$ServerCertificateSubject,
[Parameter(Mandatory)]
[ValidatePattern('^[0-9A-Fa-f ]{40,59}$')] [ValidatePattern('^[0-9A-Fa-f ]{40,59}$')]
[string[]]$AllowedClientThumbprints, [string[]]$AllowedClientThumbprints = @(),
[string]$NtlmEndpoint = 'https://sgu.ulsa.edu.mx/', [string]$NtlmEndpoint = 'https://sgu.ulsa.edu.mx/',
[string[]]$AllowedNtlmRedirectHosts = @('sgu.ulsa.edu.mx'), [string[]]$AllowedNtlmRedirectHosts = @('sgu.ulsa.edu.mx'),
@@ -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
}
+8 -5
View File
@@ -75,6 +75,14 @@ $guardParams = @{
} }
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before joining the domain')) { 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-CredentialProvider.ps1') @installParams | Out-Null
& (Join-Path $PSScriptRoot 'Install-SguEnrollmentGuard.ps1') @guardParams | 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 -RemoteDesktopPrincipal $RemoteDesktopPrincipal
} }
Set-DnsClientServerAddress `
-InterfaceAlias $NetworkInterfaceAlias `
-ServerAddresses $DomainDnsServerAddresses
Resolve-DnsName -Type SRV "_ldap._tcp.dc._msdcs.$DomainName" -ErrorAction Stop | Out-Null
if (-not $DomainCredential) { if (-not $DomainCredential) {
$DomainCredential = Get-Credential ` $DomainCredential = Get-Credential `
-UserName "$DomainNetbios\Administrator" ` -UserName "$DomainNetbios\Administrator" `
+514
View File
@@ -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
+327
View File
@@ -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
+169
View File
@@ -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
}
+155
View File
@@ -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()
}
+22
View File
@@ -33,6 +33,21 @@ if (-not $ekuExtension -or
throw 'The certificate is not valid for TLS client authentication.' throw 'The certificate is not valid for TLS client authentication.'
} }
$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.'
}
if ($PSCmdlet.ShouldProcess($candidate.Thumbprint, 'Trust and allow the SGU client certificate')) { if ($PSCmdlet.ShouldProcess($candidate.Thumbprint, 'Trust and allow the SGU client certificate')) {
$trustedCertificate = Import-Certificate ` $trustedCertificate = Import-Certificate `
-FilePath $CertificatePath ` -FilePath $CertificatePath `
@@ -57,6 +72,13 @@ if ($PSCmdlet.ShouldProcess($candidate.Thumbprint, 'Trust and allow the SGU clie
[System.ServiceProcess.ServiceControllerStatus]::Running, [System.ServiceProcess.ServiceControllerStatus]::Running,
[TimeSpan]::FromSeconds(20)) [TimeSpan]::FromSeconds(20))
} }
}
finally {
if ($lockTaken) {
$mutex.ReleaseMutex()
}
$mutex.Dispose()
}
[pscustomobject]@{ [pscustomobject]@{
Subject = $candidate.Subject Subject = $candidate.Subject
+5 -2
View File
@@ -11,8 +11,11 @@ $ErrorActionPreference = 'Stop'
$existing = Get-DnsServerResourceRecord -ZoneName $ZoneName -Name $RecordName -RRType A -ErrorAction SilentlyContinue $existing = Get-DnsServerResourceRecord -ZoneName $ZoneName -Name $RecordName -RRType A -ErrorAction SilentlyContinue
if ($existing) { if ($existing) {
$current = @($existing.RecordData.IPv4Address.IPAddressToString) $current = @($existing.RecordData.IPv4Address.IPAddressToString)
if ($current -notcontains $IPv4Address.IPAddressToString) { if ($current.Count -ne 1 -or $current[0] -ne $IPv4Address.IPAddressToString) {
throw "$RecordName.$ZoneName already exists with a different address: $($current -join ', ')." # 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 { else {
+31 -1
View File
@@ -2,7 +2,8 @@
param( param(
[string]$TargetOuDn = 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx', [string]$TargetOuDn = 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx',
[string]$GpoName = 'SGU - User session restrictions', [string]$GpoName = 'SGU - User session restrictions',
[string]$DomainController = $env:COMPUTERNAME [string]$DomainController = $env:COMPUTERNAME,
[string]$WallpaperPath
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
@@ -81,6 +82,25 @@ if ($PSCmdlet.ShouldProcess($GpoName, 'Prevent SGU users from manually locking w
-ValueName 'ScreenSaveActive' ` -ValueName 'ScreenSaveActive' `
-Type String ` -Type String `
-Value '0' | Out-Null -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 ` $configuredValue = Get-GPRegistryValue `
@@ -101,6 +121,15 @@ $link = @(Get-GPInheritance -Target $TargetOuDn -Domain $domainName -Server $Dom
$linkEnabled = $link -and ( $linkEnabled = $link -and (
$link.Enabled -eq $true -or $link.Enabled -eq $true -or
[string]$link.Enabled -eq 'Yes') [string]$link.Enabled -eq 'Yes')
$configuredWallpaper = $null
if ($WallpaperPath) {
$configuredWallpaper = (Get-GPRegistryValue `
-Name $GpoName `
-Domain $domainName `
-Server $DomainController `
-Key $policyKey `
-ValueName 'Wallpaper').Value
}
[pscustomobject]@{ [pscustomobject]@{
GpoName = $GpoName GpoName = $GpoName
@@ -109,4 +138,5 @@ $linkEnabled = $link -and (
LinkEnabled = [bool]$linkEnabled LinkEnabled = [bool]$linkEnabled
DisableLockWorkstation = [int]$configuredValue.Value DisableLockWorkstation = [int]$configuredValue.Value
ScreenSaverDisabled = [string]$screenSaverValue.Value -eq '0' ScreenSaverDisabled = [string]$screenSaverValue.Value -eq '0'
Wallpaper = $configuredWallpaper
} }
+5
View File
@@ -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%
+5
View File
@@ -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%
+2 -3
View File
@@ -14,10 +14,9 @@ public sealed class BrokerOptions
public void Validate() public void Validate()
{ {
if (Tls.AllowedClientThumbprints.Length == 0 || if (Tls.AllowedClientThumbprints.Any(value => !IsCertificateThumbprint(value)))
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) if (!Uri.TryCreate(Ntlm.Endpoint, UriKind.Absolute, out Uri? endpoint) || endpoint.Scheme != Uri.UriSchemeHttps)
+1 -3
View File
@@ -21,9 +21,7 @@
}, },
"Broker": { "Broker": {
"Tls": { "Tls": {
"AllowedClientThumbprints": [ "AllowedClientThumbprints": [],
"SET-BY-DEPLOYMENT"
],
"CheckCertificateRevocation": true "CheckCertificateRevocation": true
}, },
"Ntlm": { "Ntlm": {
@@ -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<InvalidOperationException>(options.Validate);
Assert.Contains("thumbprint", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}