Compare commits

...
3 Commits
6 changed files with 125 additions and 10 deletions
+36 -4
View File
@@ -178,6 +178,7 @@ function Set-ClientDomainAddress {
$matchingAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 ` $matchingAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
-ErrorAction SilentlyContinue | -ErrorAction SilentlyContinue |
Where-Object { Where-Object {
$_.AddressState -eq 'Preferred' -and
$_.IPAddress -notmatch '^(127\.|169\.254\.)' -and $_.IPAddress -notmatch '^(127\.|169\.254\.)' -and
(Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]$_.IPAddress) ` (Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]$_.IPAddress) `
-SecondAddress $DomainControllerAddress -PrefixLength $PrefixLength) -SecondAddress $DomainControllerAddress -PrefixLength $PrefixLength)
@@ -207,7 +208,20 @@ function Set-ClientDomainAddress {
New-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 ` New-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
-IPAddress $RequestedAddress.IPAddressToString -PrefixLength $PrefixLength | Out-Null -IPAddress $RequestedAddress.IPAddressToString -PrefixLength $PrefixLength | Out-Null
} }
return $RequestedAddress
$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 {
@@ -234,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)
@@ -348,8 +379,8 @@ else {
-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) {
@@ -562,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
+3 -1
View File
@@ -112,7 +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 y conserva en pantalla y archivo cualquier error de enrolamiento. - 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.
@@ -120,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.
+25 -2
View File
@@ -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
} }
+13 -3
View File
@@ -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
+7
View File
@@ -99,6 +99,13 @@ Describe 'SGU public-cloud network safety' {
$source | Should Not Match "Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0\.0\.0\.0/0'" $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'
+41
View File
@@ -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'
}
}