Add one-command server and client bootstraps
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" `
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -33,6 +33,21 @@ if (-not $ekuExtension -or
|
||||
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')) {
|
||||
$trustedCertificate = Import-Certificate `
|
||||
-FilePath $CertificatePath `
|
||||
@@ -57,6 +72,13 @@ if ($PSCmdlet.ShouldProcess($candidate.Thumbprint, 'Trust and allow the SGU clie
|
||||
[System.ServiceProcess.ServiceControllerStatus]::Running,
|
||||
[TimeSpan]::FromSeconds(20))
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($lockTaken) {
|
||||
$mutex.ReleaseMutex()
|
||||
}
|
||||
$mutex.Dispose()
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
Subject = $candidate.Subject
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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%
|
||||
@@ -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%
|
||||
@@ -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)
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
},
|
||||
"Broker": {
|
||||
"Tls": {
|
||||
"AllowedClientThumbprints": [
|
||||
"SET-BY-DEPLOYMENT"
|
||||
],
|
||||
"AllowedClientThumbprints": [],
|
||||
"CheckCertificateRevocation": true
|
||||
},
|
||||
"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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user