Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b510082d4 | ||
|
|
5e62a65316 | ||
|
|
1332f546fa | ||
|
|
14c919b380 |
@@ -115,9 +115,14 @@ For a clean machine, the supported entry points are the release packages:
|
|||||||
|
|
||||||
```bat
|
```bat
|
||||||
Start-SguServerBootstrap.cmd 192.168.50.10
|
Start-SguServerBootstrap.cmd 192.168.50.10
|
||||||
Start-SguClientEnrollment.cmd 192.168.50.10
|
Start-SguClientEnrollment.cmd 192.168.50.10 192.168.50.11
|
||||||
```
|
```
|
||||||
|
|
||||||
|
El segundo argumento es la IP fija, única, del cliente en la red privada. Si se
|
||||||
|
omite y ese adaptador todavía usa una dirección `169.254.x.x`, el enrolador la
|
||||||
|
solicita de forma interactiva. En equipos con dos NIC selecciona el adaptador
|
||||||
|
sin puerta de enlace y conserva el `Default Switch` para Internet.
|
||||||
|
|
||||||
Linux clients are enrolled through their native PAM/SSSD stack instead of the
|
Linux clients are enrolled through their native PAM/SSSD stack instead of the
|
||||||
Windows Credential Provider:
|
Windows Credential Provider:
|
||||||
|
|
||||||
|
|||||||
@@ -100,9 +100,17 @@ el equipo y explica que se debe actualizar la edición.
|
|||||||
2. Ejecutar con la IP fija actual del controlador de dominio:
|
2. Ejecutar con la IP fija actual del controlador de dominio:
|
||||||
|
|
||||||
```bat
|
```bat
|
||||||
Start-SguClientEnrollment.cmd 192.168.50.10
|
Start-SguClientEnrollment.cmd 192.168.50.10 192.168.50.11
|
||||||
```
|
```
|
||||||
|
|
||||||
|
El primer argumento es el controlador de dominio y el segundo es una dirección
|
||||||
|
IPv4 fija, libre y exclusiva del cliente en la red privada. Si se omite la IP
|
||||||
|
del cliente, el bootstrap la solicita cuando el adaptador sólo tiene APIPA
|
||||||
|
(`169.254.x.x`). En una VM con Internet por `Default Switch` y otra NIC para
|
||||||
|
`Laboratorio AD`, el bootstrap elige la NIC sin puerta de enlace y no cambia la
|
||||||
|
ruta predeterminada. Si falla, la ventana elevada permanece abierta y el mismo
|
||||||
|
error queda en `C:\ProgramData\SGU\Bootstrap\Client\latest-error.log`.
|
||||||
|
|
||||||
Después de UAC, se solicita interactivamente la credencial autorizada para unir
|
Después de UAC, se solicita interactivamente la credencial autorizada para unir
|
||||||
equipos. La contraseña existe sólo en memoria. El bootstrap:
|
equipos. La contraseña existe sólo en memoria. El bootstrap:
|
||||||
|
|
||||||
@@ -128,6 +136,8 @@ Para elegir adaptador o nombre del equipo explícitamente:
|
|||||||
powershell.exe -NoProfile -ExecutionPolicy Bypass `
|
powershell.exe -NoProfile -ExecutionPolicy Bypass `
|
||||||
-File .\Invoke-SguClientBootstrap.ps1 `
|
-File .\Invoke-SguClientBootstrap.ps1 `
|
||||||
-DomainControllerIPv4Address 192.168.50.10 `
|
-DomainControllerIPv4Address 192.168.50.10 `
|
||||||
|
-ClientIPv4Address 192.168.50.11 `
|
||||||
|
-ClientPrefixLength 24 `
|
||||||
-NetworkInterfaceAlias 'Ethernet' `
|
-NetworkInterfaceAlias 'Ethernet' `
|
||||||
-NewComputerName 'LCI-101'
|
-NewComputerName 'LCI-101'
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Para una instalación limpia de Windows se prefiere el único punto de entrada
|
|||||||
empaquetado:
|
empaquetado:
|
||||||
|
|
||||||
```bat
|
```bat
|
||||||
Start-SguClientEnrollment.cmd 192.168.50.10
|
Start-SguClientEnrollment.cmd 192.168.50.10 192.168.50.11
|
||||||
```
|
```
|
||||||
|
|
||||||
Este comando realiza el intercambio de certificados descrito abajo sin mover
|
Este comando realiza el intercambio de certificados descrito abajo sin mover
|
||||||
@@ -12,6 +12,13 @@ una clave privada y luego ejecuta la transacción proveedor-primero. Las
|
|||||||
instrucciones completas están en
|
instrucciones completas están en
|
||||||
[`bootstrap-recovery.md`](bootstrap-recovery.md).
|
[`bootstrap-recovery.md`](bootstrap-recovery.md).
|
||||||
|
|
||||||
|
El primer argumento es la IP fija del controlador; el segundo es una IP fija y
|
||||||
|
única para el cliente en la misma subred. Si el segundo se omite y la NIC
|
||||||
|
privada no tiene una IP válida, se solicita en pantalla. El bootstrap prefiere
|
||||||
|
la única NIC activa sin puerta de enlace para no reemplazar el adaptador de
|
||||||
|
Internet. Ante cualquier error conserva la ventana y escribe el diagnóstico en
|
||||||
|
`C:\ProgramData\SGU\Bootstrap\Client\latest-error.log`.
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
param(
|
param(
|
||||||
[ipaddress]$DomainControllerIPv4Address,
|
[ipaddress]$DomainControllerIPv4Address,
|
||||||
[string]$NetworkInterfaceAlias,
|
[string]$NetworkInterfaceAlias,
|
||||||
|
[ipaddress]$ClientIPv4Address,
|
||||||
|
[ValidateRange(1, 32)]
|
||||||
|
[int]$ClientPrefixLength = 24,
|
||||||
[PSCredential]$DomainCredential,
|
[PSCredential]$DomainCredential,
|
||||||
[string]$DomainName = 'lci.lasalle.mx',
|
[string]$DomainName = 'lci.lasalle.mx',
|
||||||
[string]$DomainNetbios = 'LCI',
|
[string]$DomainNetbios = 'LCI',
|
||||||
@@ -16,6 +19,7 @@ param(
|
|||||||
[securestring]$VpnClientCertificatePfxPassword,
|
[securestring]$VpnClientCertificatePfxPassword,
|
||||||
[string]$VpnClientRootCertificatePath,
|
[string]$VpnClientRootCertificatePath,
|
||||||
[string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'),
|
[string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'),
|
||||||
|
[switch]$PauseOnError,
|
||||||
[switch]$SkipRestart
|
[switch]$SkipRestart
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,6 +28,35 @@ $brokerRecordName = 'sgu-auth'
|
|||||||
$brokerDnsName = "$brokerRecordName.$DomainName"
|
$brokerDnsName = "$brokerRecordName.$DomainName"
|
||||||
$brokerEndpoint = "https://${brokerDnsName}:8443/v1/authenticate"
|
$brokerEndpoint = "https://${brokerDnsName}:8443/v1/authenticate"
|
||||||
$temporaryRoot = Join-Path $env:ProgramData ("SGU\Bootstrap\Client-" + [Guid]::NewGuid().ToString('N'))
|
$temporaryRoot = Join-Path $env:ProgramData ("SGU\Bootstrap\Client-" + [Guid]::NewGuid().ToString('N'))
|
||||||
|
$bootstrapLogRoot = Join-Path $env:ProgramData 'SGU\Bootstrap\Client'
|
||||||
|
$bootstrapErrorLog = Join-Path $bootstrapLogRoot 'latest-error.log'
|
||||||
|
|
||||||
|
trap {
|
||||||
|
$failure = $_
|
||||||
|
$failureText = @(
|
||||||
|
"SGU client enrollment failed at $((Get-Date).ToString('s')).",
|
||||||
|
'',
|
||||||
|
$failure.Exception.Message,
|
||||||
|
'',
|
||||||
|
$failure.ScriptStackTrace
|
||||||
|
) -join [Environment]::NewLine
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Path $bootstrapLogRoot -Force | Out-Null
|
||||||
|
[IO.File]::WriteAllText($bootstrapErrorLog, $failureText, [Text.UTF8Encoding]::new($false))
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# Keep the original enrollment error when diagnostics cannot be written.
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host 'SGU client enrollment did not complete.' -ForegroundColor Red
|
||||||
|
Write-Host $failure.Exception.Message -ForegroundColor Red
|
||||||
|
Write-Host "Diagnostic log: $bootstrapErrorLog" -ForegroundColor Yellow
|
||||||
|
if ($PauseOnError -and [Environment]::UserInteractive) {
|
||||||
|
Read-Host 'Press ENTER to close this window' | Out-Null
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
function Assert-Administrator {
|
function Assert-Administrator {
|
||||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
@@ -62,21 +95,133 @@ function Resolve-ClientInterfaceAlias {
|
|||||||
return $RequestedAlias
|
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')
|
$upAdapters = @(Get-NetAdapter | Where-Object Status -eq 'Up')
|
||||||
|
$withoutDefaultGateway = @($upAdapters | Where-Object {
|
||||||
|
-not (Get-NetIPConfiguration -InterfaceIndex $_.ifIndex).IPv4DefaultGateway
|
||||||
|
})
|
||||||
|
if ($withoutDefaultGateway.Count -eq 1) {
|
||||||
|
return [string]$withoutDefaultGateway[0].Name
|
||||||
|
}
|
||||||
if ($upAdapters.Count -eq 1) {
|
if ($upAdapters.Count -eq 1) {
|
||||||
return [string]$upAdapters[0].Name
|
return [string]$upAdapters[0].Name
|
||||||
}
|
}
|
||||||
|
|
||||||
$aliases = ($upAdapters.Name | Sort-Object) -join ', '
|
$aliases = ($upAdapters.Name | Sort-Object) -join ', '
|
||||||
throw "Could not select a network adapter. Re-run with -NetworkInterfaceAlias. Available adapters: $aliases"
|
throw "Could not select the private domain adapter unambiguously. Re-run with -NetworkInterfaceAlias. Available adapters: $aliases"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-IPv4AddressesSharePrefix {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][ipaddress]$FirstAddress,
|
||||||
|
[Parameter(Mandatory)][ipaddress]$SecondAddress,
|
||||||
|
[Parameter(Mandatory)][ValidateRange(1, 32)][int]$PrefixLength
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($FirstAddress.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or
|
||||||
|
$SecondAddress.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstBytes = $FirstAddress.GetAddressBytes()
|
||||||
|
$secondBytes = $SecondAddress.GetAddressBytes()
|
||||||
|
$remainingBits = $PrefixLength
|
||||||
|
for ($index = 0; $index -lt 4; $index++) {
|
||||||
|
$bits = [Math]::Min(8, $remainingBits)
|
||||||
|
$mask = if ($bits -eq 0) {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
elseif ($bits -eq 8) {
|
||||||
|
255
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
256 - [int][Math]::Pow(2, 8 - $bits)
|
||||||
|
}
|
||||||
|
if (($firstBytes[$index] -band $mask) -ne ($secondBytes[$index] -band $mask)) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
$remainingBits -= $bits
|
||||||
|
}
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-UsableClientIPv4Address {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][ipaddress]$Address,
|
||||||
|
[Parameter(Mandatory)][ipaddress]$DomainControllerAddress,
|
||||||
|
[Parameter(Mandatory)][ValidateRange(1, 32)][int]$PrefixLength
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
|
||||||
|
throw "The SGU client address '$Address' must be IPv4."
|
||||||
|
}
|
||||||
|
if ($Address.IPAddressToString -eq $DomainControllerAddress.IPAddressToString) {
|
||||||
|
throw 'The SGU client and domain controller cannot use the same IPv4 address.'
|
||||||
|
}
|
||||||
|
if ($Address.IPAddressToString -match '^(0\.|127\.|169\.254\.|22[4-9]\.|23\d\.)') {
|
||||||
|
throw "The SGU client address '$Address' is not usable on the private domain network."
|
||||||
|
}
|
||||||
|
if (-not (Test-IPv4AddressesSharePrefix -FirstAddress $Address `
|
||||||
|
-SecondAddress $DomainControllerAddress -PrefixLength $PrefixLength)) {
|
||||||
|
throw "The SGU client address '$Address/$PrefixLength' is not on the same network as domain controller $DomainControllerAddress."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-ClientDomainAddress {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$InterfaceAlias,
|
||||||
|
[Parameter(Mandatory)][ipaddress]$DomainControllerAddress,
|
||||||
|
[ipaddress]$RequestedAddress,
|
||||||
|
[Parameter(Mandatory)][ValidateRange(1, 32)][int]$PrefixLength
|
||||||
|
)
|
||||||
|
|
||||||
|
$adapter = Get-NetAdapter -Name $InterfaceAlias -ErrorAction Stop
|
||||||
|
$matchingAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
|
||||||
|
-ErrorAction SilentlyContinue |
|
||||||
|
Where-Object {
|
||||||
|
$_.AddressState -eq 'Preferred' -and
|
||||||
|
$_.IPAddress -notmatch '^(127\.|169\.254\.)' -and
|
||||||
|
(Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]$_.IPAddress) `
|
||||||
|
-SecondAddress $DomainControllerAddress -PrefixLength $PrefixLength)
|
||||||
|
} |
|
||||||
|
Select-Object -First 1
|
||||||
|
|
||||||
|
if (-not $RequestedAddress -and $matchingAddress) {
|
||||||
|
return [ipaddress]$matchingAddress.IPAddress
|
||||||
|
}
|
||||||
|
if (-not $RequestedAddress) {
|
||||||
|
$RequestedAddress = [ipaddress](Read-Host "Fixed IPv4 address for this SGU client on '$InterfaceAlias'")
|
||||||
|
}
|
||||||
|
Assert-UsableClientIPv4Address -Address $RequestedAddress `
|
||||||
|
-DomainControllerAddress $DomainControllerAddress -PrefixLength $PrefixLength
|
||||||
|
|
||||||
|
Set-NetIPInterface -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -Dhcp Disabled
|
||||||
|
$existingAddresses = @(Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
|
||||||
|
-ErrorAction SilentlyContinue | Where-Object PrefixOrigin -ne 'WellKnown')
|
||||||
|
foreach ($existingAddress in $existingAddresses) {
|
||||||
|
if ($existingAddress.IPAddress -ne $RequestedAddress.IPAddressToString -or
|
||||||
|
[int]$existingAddress.PrefixLength -ne $PrefixLength) {
|
||||||
|
Remove-NetIPAddress -InputObject $existingAddress -Confirm:$false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not (Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
|
||||||
|
-IPAddress $RequestedAddress.IPAddressToString -ErrorAction SilentlyContinue)) {
|
||||||
|
New-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
|
||||||
|
-IPAddress $RequestedAddress.IPAddressToString -PrefixLength $PrefixLength | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$addressReadyDeadline = (Get-Date).AddSeconds(20)
|
||||||
|
do {
|
||||||
|
$configuredAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex `
|
||||||
|
-AddressFamily IPv4 -IPAddress $RequestedAddress.IPAddressToString `
|
||||||
|
-ErrorAction SilentlyContinue
|
||||||
|
if ($configuredAddress -and $configuredAddress.AddressState -eq 'Preferred') {
|
||||||
|
return $RequestedAddress
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
} while ((Get-Date) -lt $addressReadyDeadline)
|
||||||
|
|
||||||
|
$observedState = if ($configuredAddress) { $configuredAddress.AddressState } else { 'Missing' }
|
||||||
|
throw "The SGU client address '$RequestedAddress' did not become ready on '$InterfaceAlias' within 20 seconds. Observed state: $observedState."
|
||||||
}
|
}
|
||||||
|
|
||||||
function Test-TcpPort {
|
function Test-TcpPort {
|
||||||
@@ -103,6 +248,23 @@ function Test-TcpPort {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Wait-TcpPort {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][ipaddress]$Address,
|
||||||
|
[Parameter(Mandatory)][int]$Port,
|
||||||
|
[int]$TimeoutSeconds = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||||
|
do {
|
||||||
|
if (Test-TcpPort -Address $Address -Port $Port -TimeoutMilliseconds 2000) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 750
|
||||||
|
} while ((Get-Date) -lt $deadline)
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
function Connect-SguAzureP2s {
|
function Connect-SguAzureP2s {
|
||||||
param([Parameter(Mandatory)][string]$ConnectionName)
|
param([Parameter(Mandatory)][string]$ConnectionName)
|
||||||
|
|
||||||
@@ -210,12 +372,15 @@ if ($ConnectivityMode -eq 'AzureP2S') {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
$NetworkInterfaceAlias = Resolve-ClientInterfaceAlias -RequestedAlias $NetworkInterfaceAlias
|
$NetworkInterfaceAlias = Resolve-ClientInterfaceAlias -RequestedAlias $NetworkInterfaceAlias
|
||||||
|
$ClientIPv4Address = Set-ClientDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
|
||||||
|
-DomainControllerAddress $DomainControllerIPv4Address `
|
||||||
|
-RequestedAddress $ClientIPv4Address -PrefixLength $ClientPrefixLength
|
||||||
Set-DnsClientServerAddress -InterfaceAlias $NetworkInterfaceAlias `
|
Set-DnsClientServerAddress -InterfaceAlias $NetworkInterfaceAlias `
|
||||||
-ServerAddresses $DomainControllerIPv4Address.IPAddressToString
|
-ServerAddresses $DomainControllerIPv4Address.IPAddressToString
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not (Test-TcpPort -Address $DomainControllerIPv4Address -Port 5985)) {
|
if (-not (Wait-TcpPort -Address $DomainControllerIPv4Address -Port 5985 -TimeoutSeconds 20)) {
|
||||||
throw "The domain controller at $DomainControllerIPv4Address is not accepting WinRM on TCP 5985. Run the server bootstrap first and verify the selected IP."
|
throw "The domain controller at $DomainControllerIPv4Address did not accept WinRM on TCP 5985 after 20 seconds. Run the server bootstrap first and verify the selected IP."
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not $DomainCredential) {
|
if (-not $DomainCredential) {
|
||||||
@@ -428,7 +593,8 @@ finally {
|
|||||||
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $priorTrustedHosts -Force
|
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $priorTrustedHosts -Force
|
||||||
}
|
}
|
||||||
if (-not $winRmWasRunning) {
|
if (-not $winRmWasRunning) {
|
||||||
Stop-Service WinRM -Force -ErrorAction SilentlyContinue
|
Stop-Service WinRM -Force -NoWait -WarningAction SilentlyContinue `
|
||||||
|
-ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
$DomainCredential = $null
|
$DomainCredential = $null
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ Bootstrap reproducible para el laboratorio SGU.
|
|||||||
- **Advertencia:** el bootstrap de servidor crea un bosque nuevo. No restaura los SID, contraseñas ni relaciones de confianza del bosque anterior; para conservarlos se requiere una recuperación de bosque desde una copia de estado del sistema.
|
- **Advertencia:** el bootstrap de servidor crea un bosque nuevo. No restaura los SID, contraseñas ni relaciones de confianza del bosque anterior; para conservarlos se requiere una recuperación de bosque desde una copia de estado del sistema.
|
||||||
- `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-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.
|
- `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.
|
||||||
|
- En clientes Hyper-V con dos NIC, el bootstrap selecciona la red privada sin puerta de enlace, solicita o acepta la IP fija del cliente, espera a que la dirección y WinRM estén disponibles y conserva en pantalla y archivo cualquier error de enrolamiento.
|
||||||
- `sgu-linux-client-bootstrap-$Version.zip`: une clientes Debian/Ubuntu o RHEL/Fedora/Rocky/AlmaLinux con realmd, Kerberos y SSSD. Solicita interactivamente la contraseña de unión y no instala el Credential Provider de Windows.
|
- `sgu-linux-client-bootstrap-$Version.zip`: une clientes Debian/Ubuntu o RHEL/Fedora/Rocky/AlmaLinux con realmd, Kerberos y SSSD. Solicita interactivamente la contraseña de unión y no instala el Credential Provider de Windows.
|
||||||
- `sgu-azure-infrastructure-$Version.zip`: despliega mediante Bicep una VM Windows Server 2025, red privada, IP pública protegida por NSG y Azure VPN Gateway P2S; también genera certificados por equipo y descarga el perfil de cliente.
|
- `sgu-azure-infrastructure-$Version.zip`: despliega mediante Bicep una VM Windows Server 2025, red privada, IP pública protegida por NSG y Azure VPN Gateway P2S; también genera certificados por equipo y descarga el perfil de cliente.
|
||||||
- El bootstrap Azure conserva la IP privada administrada por la NIC de Azure, autoriza el pool P2S en los firewalls SGU y nunca publica LDAP, Kerberos, SMB, RPC, WinRM ni el Auth Broker directamente a Internet.
|
- El bootstrap Azure conserva la IP privada administrada por la NIC de Azure, autoriza el pool P2S en los firewalls SGU y nunca publica LDAP, Kerberos, SMB, RPC, WinRM ni el Auth Broker directamente a Internet.
|
||||||
@@ -119,6 +120,8 @@ Bootstrap reproducible para el laboratorio SGU.
|
|||||||
- El Auth Broker clasifica sin tareas programadas cada cuenta autenticada: `AL` se agrega a `SGU-Alumnos`, `AD` a `SGU-Administrativos` y `DO` a `SGU-Docentes`; el bootstrap crea cada grupo dentro de la OU de su rol y migra idempotentemente cualquier grupo heredado sin cambiar su SID.
|
- El Auth Broker clasifica sin tareas programadas cada cuenta autenticada: `AL` se agrega a `SGU-Alumnos`, `AD` a `SGU-Administrativos` y `DO` a `SGU-Docentes`; el bootstrap crea cada grupo dentro de la OU de su rol y migra idempotentemente cualquier grupo heredado sin cambiar su SID.
|
||||||
- El Auth Broker resuelve la dirección guardada de administrativos y docentes mediante `GetDireccion`, `GetLocalidadListado` y `GetColoniasListado`, evitando conservar los valores transitorios `Seleccione...` de los controles dinámicos de SGU.
|
- El Auth Broker resuelve la dirección guardada de administrativos y docentes mediante `GetDireccion`, `GetLocalidadListado` y `GetColoniasListado`, evitando conservar los valores transitorios `Seleccione...` de los controles dinámicos de SGU.
|
||||||
- El enrolamiento y la reparación de clientes Windows crean y verifican idempotentemente la cuenta local estándar `alumno`, sin pertenencia al grupo de administradores.
|
- El enrolamiento y la reparación de clientes Windows crean y verifican idempotentemente la cuenta local estándar `alumno`, sin pertenencia al grupo de administradores.
|
||||||
|
- La descripción de la cuenta local administrada respeta el límite de 48 caracteres de Windows 10 Enterprise.
|
||||||
|
- La validación de expiración de contraseña usa el indicador de cuenta compatible con Windows 10 y 11, en lugar de una propiedad que Windows 10 no expone.
|
||||||
- El enriquecimiento obtiene el sexo de los módulos SGU de personal/alumnos, lo conserva como la línea administrada `SGU-Gender: Male|Female` en Notas de AD y adapta el fondo de Windows/Linux; cuando falta utiliza redacción neutral.
|
- El enriquecimiento obtiene el sexo de los módulos SGU de personal/alumnos, lo conserva como la línea administrada `SGU-Gender: Male|Female` en Notas de AD y adapta el fondo de Windows/Linux; cuando falta utiliza redacción neutral.
|
||||||
- El servidor configura WEF/WEC para registrar sesiones y fallos, inventariar el estado alcanzable de las máquinas cada cinco minutos y conservar durante 183 días tanto esos eventos como el diagnóstico estructurado del Auth Broker.
|
- El servidor configura WEF/WEC para registrar sesiones y fallos, inventariar el estado alcanzable de las máquinas cada cinco minutos y conservar durante 183 días tanto esos eventos como el diagnóstico estructurado del Auth Broker.
|
||||||
- Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host.
|
- Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host.
|
||||||
|
|||||||
@@ -5,7 +5,15 @@ param()
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$userName = 'alumno'
|
$userName = 'alumno'
|
||||||
$plainTextPassword = 'ingenieria'
|
$plainTextPassword = 'ingenieria'
|
||||||
$description = 'Cuenta local estandar de recuperacion para equipos SGU'
|
$description = 'Cuenta local estandar SGU para recuperacion'
|
||||||
|
$passwordNeverExpiresFlag = 0x10000
|
||||||
|
|
||||||
|
function Get-LocalUserFlags {
|
||||||
|
param([Parameter(Mandatory)][string]$Name)
|
||||||
|
|
||||||
|
$directoryEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$Name,user")
|
||||||
|
return [int]$directoryEntry.InvokeGet('UserFlags')
|
||||||
|
}
|
||||||
|
|
||||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||||
@@ -40,6 +48,16 @@ try {
|
|||||||
-Description $description | Out-Null
|
-Description $description | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Windows 10's Get-LocalUser object has PasswordExpires but does not expose
|
||||||
|
# PasswordNeverExpires. Enforce and verify the underlying UF_DONT_EXPIRE_PASSWD
|
||||||
|
# flag so the result is consistent across Windows 10 and Windows 11.
|
||||||
|
$directoryEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$userName,user")
|
||||||
|
$userFlags = [int]$directoryEntry.InvokeGet('UserFlags')
|
||||||
|
if (($userFlags -band $passwordNeverExpiresFlag) -eq 0) {
|
||||||
|
$directoryEntry.InvokeSet('UserFlags', ($userFlags -bor $passwordNeverExpiresFlag))
|
||||||
|
$directoryEntry.CommitChanges()
|
||||||
|
}
|
||||||
|
|
||||||
$user = Get-LocalUser -Name $userName -ErrorAction Stop
|
$user = Get-LocalUser -Name $userName -ErrorAction Stop
|
||||||
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
||||||
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
|
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
|
||||||
@@ -74,11 +92,16 @@ if (@($verifiedAdministrators).SID.Value -contains $verifiedUser.SID.Value) {
|
|||||||
if ($verifiedUsers.SID.Value -notcontains $verifiedUser.SID.Value) {
|
if ($verifiedUsers.SID.Value -notcontains $verifiedUser.SID.Value) {
|
||||||
throw "The local account '$userName' does not belong to the local Users group."
|
throw "The local account '$userName' does not belong to the local Users group."
|
||||||
}
|
}
|
||||||
|
$verifiedPasswordNeverExpires =
|
||||||
|
((Get-LocalUserFlags -Name $userName) -band $passwordNeverExpiresFlag) -ne 0
|
||||||
|
if (-not $verifiedPasswordNeverExpires) {
|
||||||
|
throw "The local account '$userName' password is not configured to never expire."
|
||||||
|
}
|
||||||
|
|
||||||
[pscustomobject]@{
|
[pscustomobject]@{
|
||||||
UserName = $verifiedUser.Name
|
UserName = $verifiedUser.Name
|
||||||
Enabled = $verifiedUser.Enabled
|
Enabled = $verifiedUser.Enabled
|
||||||
IsAdministrator = $false
|
IsAdministrator = $false
|
||||||
IsStandardUser = $true
|
IsStandardUser = $true
|
||||||
PasswordNeverExpires = $verifiedUser.PasswordNeverExpires
|
PasswordNeverExpires = $verifiedPasswordNeverExpires
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
@echo off
|
@echo off
|
||||||
setlocal
|
setlocal
|
||||||
set "SGU_BOOTSTRAP_IP=%~1"
|
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"
|
set "SGU_CLIENT_IP=%~2"
|
||||||
exit /b %errorlevel%
|
set "SGU_NETWORK_ALIAS=%~3"
|
||||||
|
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"'),'-PauseOnError'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',$env:SGU_BOOTSTRAP_IP) }; if ($env:SGU_CLIENT_IP) { $arguments += @('-ClientIPv4Address',$env:SGU_CLIENT_IP) }; if ($env:SGU_NETWORK_ALIAS) { $arguments += @('-NetworkInterfaceAlias',('"' + $env:SGU_NETWORK_ALIAS + '"')) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode"
|
||||||
|
set "SGU_EXIT_CODE=%errorlevel%"
|
||||||
|
if not "%SGU_EXIT_CODE%"=="0" (
|
||||||
|
echo.
|
||||||
|
echo SGU client enrollment did not complete. Review the elevated window or:
|
||||||
|
echo C:\ProgramData\SGU\Bootstrap\Client\latest-error.log
|
||||||
|
pause
|
||||||
|
)
|
||||||
|
exit /b %SGU_EXIT_CODE%
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ $interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\P
|
|||||||
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
|
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
|
||||||
$issues = [Collections.Generic.List[string]]::new()
|
$issues = [Collections.Generic.List[string]]::new()
|
||||||
$standardLocalUserName = 'alumno'
|
$standardLocalUserName = 'alumno'
|
||||||
|
$passwordNeverExpiresFlag = 0x10000
|
||||||
|
|
||||||
$computer = Get-CimInstance Win32_ComputerSystem
|
$computer = Get-CimInstance Win32_ComputerSystem
|
||||||
if ($RequireDomainJoined -and -not $computer.PartOfDomain) {
|
if ($RequireDomainJoined -and -not $computer.PartOfDomain) {
|
||||||
@@ -93,8 +94,7 @@ $standardLocalUserPresent = [bool]$standardLocalUser
|
|||||||
$standardLocalUserEnabled = $standardLocalUserPresent -and $standardLocalUser.Enabled
|
$standardLocalUserEnabled = $standardLocalUserPresent -and $standardLocalUser.Enabled
|
||||||
$standardLocalUserIsAdministrator = $false
|
$standardLocalUserIsAdministrator = $false
|
||||||
$standardLocalUserInUsersGroup = $false
|
$standardLocalUserInUsersGroup = $false
|
||||||
$standardLocalUserPasswordNeverExpires =
|
$standardLocalUserPasswordNeverExpires = $false
|
||||||
$standardLocalUserPresent -and $standardLocalUser.PasswordNeverExpires
|
|
||||||
if ($standardLocalUserPresent) {
|
if ($standardLocalUserPresent) {
|
||||||
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
||||||
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
|
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
|
||||||
@@ -106,6 +106,16 @@ if ($standardLocalUserPresent) {
|
|||||||
$administratorMembers.SID.Value -contains $standardLocalUser.SID.Value
|
$administratorMembers.SID.Value -contains $standardLocalUser.SID.Value
|
||||||
$standardLocalUserInUsersGroup =
|
$standardLocalUserInUsersGroup =
|
||||||
$standardMembers.SID.Value -contains $standardLocalUser.SID.Value
|
$standardMembers.SID.Value -contains $standardLocalUser.SID.Value
|
||||||
|
try {
|
||||||
|
$directoryEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$standardLocalUserName,user")
|
||||||
|
$userFlags = [int]$directoryEntry.InvokeGet('UserFlags')
|
||||||
|
$standardLocalUserPasswordNeverExpires =
|
||||||
|
($userFlags -band $passwordNeverExpiresFlag) -ne 0
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# Report the account as invalid when Windows cannot read its flags.
|
||||||
|
$standardLocalUserPasswordNeverExpires = $false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (-not $standardLocalUserPresent) {
|
if (-not $standardLocalUserPresent) {
|
||||||
$issues.Add("The required standard local user '$standardLocalUserName' is missing.")
|
$issues.Add("The required standard local user '$standardLocalUserName' is missing.")
|
||||||
@@ -120,7 +130,7 @@ elseif (-not $standardLocalUserInUsersGroup) {
|
|||||||
$issues.Add("The required standard local user '$standardLocalUserName' does not belong to the local Users group.")
|
$issues.Add("The required standard local user '$standardLocalUserName' does not belong to the local Users group.")
|
||||||
}
|
}
|
||||||
elseif (-not $standardLocalUserPasswordNeverExpires) {
|
elseif (-not $standardLocalUserPasswordNeverExpires) {
|
||||||
$issues.Add("The required standard local user '$standardLocalUserName' does not retain its enrollment password.")
|
$issues.Add("The required standard local user '$standardLocalUserName' password is not configured to never expire.")
|
||||||
}
|
}
|
||||||
|
|
||||||
$settings = $null
|
$settings = $null
|
||||||
|
|||||||
@@ -25,6 +25,22 @@ $networkFunctions = $serverAst.FindAll({
|
|||||||
}, $true)
|
}, $true)
|
||||||
Invoke-Expression (($networkFunctions | ForEach-Object { $_.Extent.Text }) -join [Environment]::NewLine)
|
Invoke-Expression (($networkFunctions | ForEach-Object { $_.Extent.Text }) -join [Environment]::NewLine)
|
||||||
|
|
||||||
|
$clientTokens = $null
|
||||||
|
$clientParseErrors = $null
|
||||||
|
$clientAst = [Management.Automation.Language.Parser]::ParseFile(
|
||||||
|
$clientBootstrapPath,
|
||||||
|
[ref]$clientTokens,
|
||||||
|
[ref]$clientParseErrors)
|
||||||
|
if ($clientParseErrors.Count -gt 0) {
|
||||||
|
throw ($clientParseErrors -join [Environment]::NewLine)
|
||||||
|
}
|
||||||
|
$clientNetworkFunctions = $clientAst.FindAll({
|
||||||
|
param($node)
|
||||||
|
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and
|
||||||
|
$node.Name -eq 'Test-IPv4AddressesSharePrefix'
|
||||||
|
}, $true)
|
||||||
|
Invoke-Expression (($clientNetworkFunctions | ForEach-Object { $_.Extent.Text }) -join [Environment]::NewLine)
|
||||||
|
|
||||||
Describe 'SGU public-cloud network safety' {
|
Describe 'SGU public-cloud network safety' {
|
||||||
It 'canonicalizes a host address to its IPv4 network' {
|
It 'canonicalizes a host address to its IPv4 network' {
|
||||||
ConvertTo-NetworkCidr -Address ([ipaddress]'10.77.0.4') `
|
ConvertTo-NetworkCidr -Address ([ipaddress]'10.77.0.4') `
|
||||||
@@ -58,6 +74,38 @@ Describe 'SGU public-cloud network safety' {
|
|||||||
'VpnProfilePackagePath') | Should Be $true
|
'VpnProfilePackagePath') | Should Be $true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
It 'accepts an explicit static IPv4 address for a private Windows adapter' {
|
||||||
|
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
|
||||||
|
'ClientIPv4Address') | Should Be $true
|
||||||
|
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
|
||||||
|
'ClientPrefixLength') | Should Be $true
|
||||||
|
}
|
||||||
|
|
||||||
|
It 'matches a client and domain controller within the requested prefix' {
|
||||||
|
Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]'192.168.50.11') `
|
||||||
|
-SecondAddress ([ipaddress]'192.168.50.10') -PrefixLength 24 |
|
||||||
|
Should Be $true
|
||||||
|
Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]'192.168.51.11') `
|
||||||
|
-SecondAddress ([ipaddress]'192.168.50.10') -PrefixLength 24 |
|
||||||
|
Should Be $false
|
||||||
|
Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]'10.77.15.20') `
|
||||||
|
-SecondAddress ([ipaddress]'10.77.0.4') -PrefixLength 16 |
|
||||||
|
Should Be $true
|
||||||
|
}
|
||||||
|
|
||||||
|
It 'prefers the private adapter instead of the Internet default route' {
|
||||||
|
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
|
||||||
|
$source | Should Match '\$withoutDefaultGateway\.Count -eq 1'
|
||||||
|
$source | Should Not Match "Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0\.0\.0\.0/0'"
|
||||||
|
}
|
||||||
|
|
||||||
|
It 'waits for the new address and WinRM route to stabilize' {
|
||||||
|
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
|
||||||
|
$source | Should Match "AddressState -eq 'Preferred'"
|
||||||
|
$source | Should Match 'function Wait-TcpPort'
|
||||||
|
$source | Should Match 'Wait-TcpPort -Address \$DomainControllerIPv4Address -Port 5985'
|
||||||
|
}
|
||||||
|
|
||||||
It 'uses an all-user machine-certificate VPN profile' {
|
It 'uses an all-user machine-certificate VPN profile' {
|
||||||
$source = Get-Content -LiteralPath $azureClientPath -Raw
|
$source = Get-Content -LiteralPath $azureClientPath -Raw
|
||||||
$source | Should Match '-AuthenticationMethod MachineCertificate'
|
$source | Should Match '-AuthenticationMethod MachineCertificate'
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||||
|
$localUserScriptPath = Join-Path $repositoryRoot 'scripts\Set-SguStandardLocalUser.ps1'
|
||||||
|
$enrollmentTestScriptPath = Join-Path $repositoryRoot 'scripts\Test-SguClientEnrollment.ps1'
|
||||||
|
|
||||||
|
$tokens = $null
|
||||||
|
$parseErrors = $null
|
||||||
|
$scriptAst = [Management.Automation.Language.Parser]::ParseFile(
|
||||||
|
$localUserScriptPath,
|
||||||
|
[ref]$tokens,
|
||||||
|
[ref]$parseErrors)
|
||||||
|
if ($parseErrors.Count -gt 0) {
|
||||||
|
throw ($parseErrors -join [Environment]::NewLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
$descriptionAssignment = $scriptAst.Find({
|
||||||
|
param($node)
|
||||||
|
$node -is [Management.Automation.Language.AssignmentStatementAst] -and
|
||||||
|
$node.Left.Extent.Text -eq '$description'
|
||||||
|
}, $true)
|
||||||
|
$description = $descriptionAssignment.Right.Extent.Text.Trim("'")
|
||||||
|
|
||||||
|
Describe 'SGU Windows client enrollment scripts' {
|
||||||
|
It 'keeps the local-user description within the Windows 10 limit' {
|
||||||
|
($description.Length -le 48) | Should Be $true
|
||||||
|
}
|
||||||
|
|
||||||
|
It 'declares the managed local student account' {
|
||||||
|
$source = Get-Content -LiteralPath $localUserScriptPath -Raw
|
||||||
|
$source | Should Match "\$userName = 'alumno'"
|
||||||
|
$source | Should Match "\$plainTextPassword = 'ingenieria'"
|
||||||
|
}
|
||||||
|
|
||||||
|
It 'uses the cross-version Windows account flag for password expiration' {
|
||||||
|
$localUserSource = Get-Content -LiteralPath $localUserScriptPath -Raw
|
||||||
|
$enrollmentTestSource = Get-Content -LiteralPath $enrollmentTestScriptPath -Raw
|
||||||
|
$localUserSource | Should Match '\$passwordNeverExpiresFlag = 0x10000'
|
||||||
|
$enrollmentTestSource | Should Match '\$passwordNeverExpiresFlag = 0x10000'
|
||||||
|
$localUserSource | Should Not Match '\$verifiedUser\.PasswordNeverExpires'
|
||||||
|
$enrollmentTestSource | Should Not Match '\$standardLocalUser\.PasswordNeverExpires'
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user