Compare commits

..
7 Commits
26 changed files with 1286 additions and 94 deletions
+30 -8
View File
@@ -9,7 +9,7 @@ source and adds an SGU-specific provider, an mTLS-protected broker, Active
Directory synchronization, deployment scripts, and tests.
Ready-to-run bootstrap packages are published on the
[releases page](https://github.lci.ulsa.mx/alexrg/SGU-CredentialProvider/releases).
[releases page](https://gitea.lci.ulsa.mx/alexrg/SGU-CredentialProvider/releases).
## Authentication contract
@@ -44,6 +44,13 @@ skips that optional field. Missing or changed presentation HTML never blocks
authentication or password synchronization after the lightweight NTLM root has
accepted the credential.
For administrative staff and professors, the location page is enriched with its
ASP.NET PageMethods responses. `GetDireccion` supplies the saved state,
municipality and neighborhood identifiers; `GetLocalidadListado` resolves the
municipality name, and `GetColoniasListado` validates or supplies the
neighborhood name. This avoids reading the temporary `Seleccione...` values
visible while the browser populates those controls asynchronously.
Operational documentation:
- [One-command server recovery and client enrollment](docs/bootstrap-recovery.md)
@@ -55,11 +62,11 @@ Operational documentation:
- [Domain monitoring, usage reports, and six-month retention](docs/monitoring.md)
- [Decision: do not persist password verifiers in Redis](docs/decisions/0001-no-password-cache.md)
| Prefix | Role | Default OU |
|---|---|---|
| `DO` | Professor / docente | `OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
| `AL` | Student / alumno | `OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
| `AD` | Administrative | `OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
| Prefix | Role | Default OU | Security group in the same OU |
|---|---|---|---|
| `DO` | Professor / docente | `OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` | `SGU-Docentes` |
| `AL` | Student / alumno | `OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` | `SGU-Alumnos` |
| `AD` | Administrative | `OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` | `SGU-Administrativos` |
If the broker or institutional NTLM authority is unavailable, the provider
submits the unchanged credentials to Windows for normal AD/cached-domain
@@ -104,13 +111,28 @@ ports remain private even though the VM owns a public IP.
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:
For a clean machine, choose the release package that matches the workstation:
- `sgu-windows10-legacy-client-bootstrap-VERSION.zip` for Windows 10;
- `sgu-windows11-client-bootstrap-VERSION.zip` for Windows 11, including the
modern Azure P2S/pre-logon flow.
Both use the same direct-lab entry point:
```bat
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.
El manifiesto identifica el perfil `Windows10Legacy` o `Windows11Modern` y el
bootstrap valida el build antes de hacer cambios. Las correcciones comunes se
mantienen en ambos; Windows 11 conserva además sus puntos de entrada modernos.
Linux clients are enrolled through their native PAM/SSSD stack instead of the
Windows Credential Provider:
+10 -3
View File
@@ -28,7 +28,11 @@ It uses the administrative incident overview for `AD`, the student information
page for `AL`, and the portal menu as a conservative base for `DO`. After the
incident page confirms an `AD` employee number, two additional GETs in the same
in-memory session read the structured name and selected sex from
`datos/personales.aspx` and the address from `datos/ubicacion.aspx`. Docentes request
`datos/personales.aspx` and the address inputs from `datos/ubicacion.aspx`. The
broker then calls the location page's `GetDireccion`, `GetLocalidadListado`, and
`GetColoniasListado` methods to correlate the saved state, municipality, and
neighborhood identifiers instead of reading transient `Seleccione...` options.
Docentes request
`nomina/consultanomina.aspx` for a matching employee number, email, employee
type and job title, then attempt the same two shared staff modules without
requiring any optional route to exist. A supplemental
@@ -76,8 +80,11 @@ password outcome.
Every synchronization also enforces one idempotent security-group membership
from the classified institutional prefix: `AL` to `SGU-Alumnos`, `AD` to
`SGU-Administrativos`, and `DO` to `SGU-Docentes`. This happens synchronously
inside the broker before the institutional password is written to AD. A missing
`SGU-Administrativos`, and `DO` to `SGU-Docentes`. Each role group is stored
inside its corresponding user OU. During an upgrade,
the bootstrap moves a legacy group from the `Usuarios-SGU` root while preserving
its SID and memberships instead of creating a duplicate. Membership enforcement
happens synchronously inside the broker before the institutional password is written to AD. A missing
or inaccessible role group therefore fails provisioning instead of leaving a
new usable account without its authorization classification. Existing accounts
are repaired automatically on their next successful SGU authentication.
+1 -1
View File
@@ -122,7 +122,7 @@ $w11 = .\scripts\New-SguAzureP2sCertificates.ps1 -ClientName 'Windows11'
Copie a la VM Windows 11 de Hyper-V:
- `sgu-client-bootstrap-VERSION.zip` extraído;
- `sgu-windows11-client-bootstrap-VERSION.zip` extraído;
- `$vpn.PackagePath`;
- `$w11.ClientCertificatePath`;
- `sgu-azure-p2s-root.cer`.
+20 -2
View File
@@ -96,13 +96,29 @@ 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`.
1. Descargar y extraer el paquete correspondiente:
`sgu-windows10-legacy-client-bootstrap-VERSION.zip` o
`sgu-windows11-client-bootstrap-VERSION.zip`.
2. Ejecutar con la IP fija actual del controlador de dominio:
```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`.
Cada manifiesto fija su perfil y evita cruzar paquetes: Windows 10 utiliza
`Windows10Legacy` (build menor a 22000) y Windows 11 `Windows11Modern` (build
22000 o posterior). El ZIP moderno conserva tanto el enrolamiento directo como
Azure P2S/pre-logon; el ZIP legado contiene el flujo directo. El código común y
las garantías de seguridad son idénticos.
Después de UAC, se solicita interactivamente la credencial autorizada para unir
equipos. La contraseña existe sólo en memoria. El bootstrap:
@@ -128,6 +144,8 @@ Para elegir adaptador o nombre del equipo explícitamente:
powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File .\Invoke-SguClientBootstrap.ps1 `
-DomainControllerIPv4Address 192.168.50.10 `
-ClientIPv4Address 192.168.50.11 `
-ClientPrefixLength 24 `
-NetworkInterfaceAlias 'Ethernet' `
-NewComputerName 'LCI-101'
```
+31 -10
View File
@@ -1,10 +1,14 @@
# Enrolamiento obligatorio de clientes SGU
Para una instalación limpia de Windows se prefiere el único punto de entrada
empaquetado:
Para una instalación limpia se selecciona primero el ZIP correspondiente:
- `sgu-windows10-legacy-client-bootstrap-VERSION.zip` para Windows 10;
- `sgu-windows11-client-bootstrap-VERSION.zip` para Windows 11.
Ambos conservan el punto de entrada directo:
```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
@@ -12,6 +16,20 @@ una clave privada y luego ejecuta la transacción proveedor-primero. Las
instrucciones completas están en
[`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 manifiesto contiene el perfil `Windows10Legacy` o `Windows11Modern` y el
bootstrap rechaza un ZIP que no corresponda al build instalado. El paquete de
Windows 11 conserva además `Start-SguAzureClientEnrollment.cmd` y el instalador
P2S de equipo; el legado de Windows 10 se limita al transporte directo del
laboratorio. Credential Provider, mTLS, cuenta `alumno`, RustDesk, monitorización
y autorreparación siguen saliendo de la misma base de código.
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
@@ -72,11 +90,13 @@ Orden de la transacción:
1. instala .NET y los binarios versionados;
2. registra COM, configura SGU como proveedor predeterminado y oculta el
último usuario que cerró sesión;
3. instala el guard de autorreparación;
4. exige health mTLS del broker y ejecuta las comprobaciones locales;
5. configura DNS del dominio;
6. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` y reinicia;
7. al arrancar, el guard habilita RDP, NLA y WinRM y comprueba el estado final.
3. crea o actualiza la cuenta local estándar `alumno` con la contraseña
`ingenieria`, la habilita y garantiza que no pertenezca a Administradores;
4. instala el guard de autorreparación;
5. exige health mTLS del broker y ejecuta las comprobaciones locales;
6. configura DNS del dominio;
7. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` y reinicia;
8. al arrancar, el guard habilita RDP, NLA y WinRM y comprueba el estado final.
La directiva de Windows **Assign a default credential provider** selecciona SGU
por defecto. El instalador también habilita **Interactive logon: Don't display
@@ -138,8 +158,9 @@ Start-ScheduledTask -TaskName SGU-CredentialProvider-EnrollmentGuard
Un resultado válido exige simultáneamente binario y registro COM, configuración,
certificados, .NET 10, proveedor SGU predeterminado, último usuario oculto,
enumeración local deshabilitada y proveedor de contraseña de Microsoft
preservado. El script de reparación se encuentra en
enumeración local deshabilitada, cuenta local estándar `alumno` habilitada y
fuera del grupo Administradores, y proveedor de contraseña de Microsoft
preservado. El guard recrea o corrige esa cuenta de forma idempotente. El script de reparación se encuentra en
`C:\ProgramData\SGU\Enrollment` con ACL exclusiva para `SYSTEM` y
administradores.
+28 -4
View File
@@ -76,16 +76,23 @@ if (-not $serverCertificate.Verify()) {
}
Import-Module ActiveDirectory -ErrorAction Stop
function ConvertTo-LdapFilterValue {
param([Parameter(Mandatory)][string]$Value)
return $Value.Replace('\', '\5c').Replace('*', '\2a').Replace('(', '\28').Replace(')', '\29').Replace(([string][char]0), '\00')
}
$usersOuName = 'Usuarios-SGU'
$usersOuDn = "OU=$usersOuName,$BaseDn"
if ([string]::IsNullOrWhiteSpace($ProfessorGroupDn)) {
$ProfessorGroupDn = "CN=SGU-Docentes,$usersOuDn"
$ProfessorGroupDn = "CN=SGU-Docentes,OU=Docentes,$usersOuDn"
}
if ([string]::IsNullOrWhiteSpace($StudentGroupDn)) {
$StudentGroupDn = "CN=SGU-Alumnos,$usersOuDn"
$StudentGroupDn = "CN=SGU-Alumnos,OU=Alumnos,$usersOuDn"
}
if ([string]::IsNullOrWhiteSpace($AdministrativeGroupDn)) {
$AdministrativeGroupDn = "CN=SGU-Administrativos,$usersOuDn"
$AdministrativeGroupDn = "CN=SGU-Administrativos,OU=Administrativos,$usersOuDn"
}
if ($CreateMissingOus) {
@@ -152,13 +159,30 @@ foreach ($definition in $roleGroupDefinitions) {
throw "$($definition.Role)GroupDn must start with a simple CN component."
}
$groupName = $groupDnMatch.Groups['Name'].Value
$groupPath = $groupDnMatch.Groups['Path'].Value
if ($groupName.Length -gt 20) {
throw "$($definition.Role) group name exceeds the 20-character sAMAccountName limit."
}
$matchingGroups = @(Get-ADGroup `
-LDAPFilter "(sAMAccountName=$(ConvertTo-LdapFilterValue -Value $groupName))" `
-SearchBase $BaseDn -SearchScope Subtree -Server $LdapHost -ErrorAction Stop)
if ($matchingGroups.Count -gt 1) {
throw "More than one Active Directory group uses sAMAccountName $groupName; the bootstrap cannot select one safely."
}
if ($matchingGroups.Count -eq 1) {
if ($matchingGroups[0].GroupCategory -ne 'Security') {
throw "$($definition.Role)GroupDn must identify a security group."
}
Move-ADObject -Identity $matchingGroups[0].DistinguishedName `
-TargetPath $groupPath -Server $LdapHost -Confirm:$false -ErrorAction Stop
}
else {
New-ADGroup -Name $groupName -SamAccountName $groupName `
-GroupCategory Security -GroupScope Global `
-Path $groupDnMatch.Groups['Path'].Value `
-Path $groupPath `
-Description $definition.Description -Server $LdapHost | Out-Null
}
$roleGroup = Get-ADGroup -Identity $definition.Dn -Server $LdapHost -ErrorAction Stop
}
if (-not $roleGroup) {
+4
View File
@@ -41,6 +41,7 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra
foreach ($scriptName in @(
'Install-CredentialProvider.ps1',
'Install-SguEnrollmentGuard.ps1',
'Set-SguStandardLocalUser.ps1',
'Test-SguClientEnrollment.ps1',
'Repair-SguClientEnrollment.ps1',
'Enable-LabRemoteAccess.ps1',
@@ -115,6 +116,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
-ServerAddress $RustDeskServerAddress `
-ServerPublicKey $RustDeskServerPublicKey
}
$localStudentUser = & (Join-Path $PSScriptRoot 'Set-SguStandardLocalUser.ps1')
& (Join-Path $PSScriptRoot 'Install-SguEnrollmentGuard.ps1') @guardParams | Out-Null
$testParameters = @{ RequireBrokerHealth = $true }
@@ -142,6 +144,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
$postJoinParameters.RustDeskServerAddress = $RustDeskServerAddress
}
$postJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @postJoinParameters
$postJoin | Add-Member -NotePropertyName StandardLocalUser -NotePropertyValue $localStudentUser
$postJoin | Add-Member -NotePropertyName RustDesk -NotePropertyValue $rustDeskResult
return $postJoin
}
@@ -175,6 +178,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
DomainName = $DomainName
ConnectivityMode = $ConnectivityMode
ProviderValidatedBeforeJoin = $true
StandardLocalUser = $localStudentUser
RustDesk = $rustDeskResult
RestartRequired = [bool]$SkipRestart
}
+1
View File
@@ -32,6 +32,7 @@ $sourceScripts = @(
'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1',
'Install-SguRustDeskClient.ps1',
'Set-SguStandardLocalUser.ps1',
'Test-SguClientEnrollment.ps1',
'Repair-SguClientEnrollment.ps1'
)
+214 -13
View File
@@ -3,11 +3,16 @@
param(
[ipaddress]$DomainControllerIPv4Address,
[string]$NetworkInterfaceAlias,
[ipaddress]$ClientIPv4Address,
[ValidateRange(1, 32)]
[int]$ClientPrefixLength = 24,
[PSCredential]$DomainCredential,
[string]$DomainName = 'lci.lasalle.mx',
[string]$DomainNetbios = 'LCI',
[string]$ComputerOuDn,
[string]$NewComputerName,
[ValidateSet('Auto', 'Windows10Legacy', 'Windows11Modern')]
[string]$CompatibilityProfile = 'Auto',
[ValidateSet('Direct', 'AzureP2S')]
[string]$ConnectivityMode = 'Direct',
[string]$VpnConnectionName = 'SGU Azure P2S',
@@ -16,6 +21,7 @@ param(
[securestring]$VpnClientCertificatePfxPassword,
[string]$VpnClientRootCertificatePath,
[string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'),
[switch]$PauseOnError,
[switch]$SkipRestart
)
@@ -24,6 +30,35 @@ $brokerRecordName = 'sgu-auth'
$brokerDnsName = "$brokerRecordName.$DomainName"
$brokerEndpoint = "https://${brokerDnsName}:8443/v1/authenticate"
$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 {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
@@ -52,6 +87,7 @@ function Assert-PackageManifest {
throw "Bootstrap package integrity check failed: $($entry.Path)"
}
}
return $manifest
}
function Resolve-ClientInterfaceAlias {
@@ -62,21 +98,133 @@ function Resolve-ClientInterfaceAlias {
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')
$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) {
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"
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 {
@@ -103,6 +251,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 {
param([Parameter(Mandatory)][string]$ConnectionName)
@@ -147,7 +312,38 @@ if (-not $ComputerOuDn) {
}
$packageRoot = $PSScriptRoot
Assert-PackageManifest -PackageRoot $packageRoot
$packageManifest = Assert-PackageManifest -PackageRoot $packageRoot
$manifestProfile = if ($packageManifest.PSObject.Properties['CompatibilityProfile']) {
[string]$packageManifest.CompatibilityProfile
}
else {
'Auto'
}
if ($CompatibilityProfile -ne 'Auto' -and $manifestProfile -ne 'Auto' -and
$CompatibilityProfile -ne $manifestProfile) {
throw "The requested compatibility profile '$CompatibilityProfile' does not match package profile '$manifestProfile'."
}
if ($CompatibilityProfile -eq 'Auto') {
$CompatibilityProfile = $manifestProfile
}
$windowsBuild = [int]$operatingSystem.BuildNumber
if ($CompatibilityProfile -eq 'Auto') {
$CompatibilityProfile = if ($windowsBuild -lt 22000) {
'Windows10Legacy'
}
else {
'Windows11Modern'
}
}
if ($CompatibilityProfile -eq 'Windows10Legacy' -and $windowsBuild -ge 22000) {
throw "The Windows 10 legacy package cannot enroll Windows build $windowsBuild. Use the Windows 11 modern client package."
}
if ($CompatibilityProfile -eq 'Windows11Modern' -and $windowsBuild -lt 22000) {
throw "The Windows 11 modern package cannot enroll Windows build $windowsBuild. Use the Windows 10 legacy client package."
}
if ($CompatibilityProfile -eq 'Windows10Legacy' -and $ConnectivityMode -eq 'AzureP2S') {
throw 'Azure P2S pre-logon enrollment belongs to the Windows 11 modern package. Use Direct connectivity for the Windows 10 legacy package.'
}
$scriptsRoot = Join-Path $packageRoot 'payload\scripts'
$providerPublishPath = Join-Path $packageRoot 'payload\credential-provider'
$runtimeInstaller = Get-ChildItem (Join-Path $packageRoot 'payload\prerequisites') `
@@ -210,12 +406,15 @@ if ($ConnectivityMode -eq 'AzureP2S') {
}
else {
$NetworkInterfaceAlias = Resolve-ClientInterfaceAlias -RequestedAlias $NetworkInterfaceAlias
$ClientIPv4Address = Set-ClientDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
-DomainControllerAddress $DomainControllerIPv4Address `
-RequestedAddress $ClientIPv4Address -PrefixLength $ClientPrefixLength
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 (Wait-TcpPort -Address $DomainControllerIPv4Address -Port 5985 -TimeoutSeconds 20)) {
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) {
@@ -428,7 +627,8 @@ finally {
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $priorTrustedHosts -Force
}
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
$DomainCredential = $null
@@ -443,6 +643,7 @@ if ($SkipRestart) {
ClientCertificateRegistered = $true
BrokerEndpoint = $brokerEndpoint
ConnectivityMode = $ConnectivityMode
CompatibilityProfile = $CompatibilityProfile
VpnConnectionName = if ($ConnectivityMode -eq 'AzureP2S') { $VpnConnectionName } else { $null }
RestartRequired = $true
RustDesk = if ($result) { $result.RustDesk } else { $null }
+49 -15
View File
@@ -33,7 +33,10 @@ function Write-PackageManifest {
param(
[Parameter(Mandatory)][string]$PackageRoot,
[Parameter(Mandatory)][string]$PackageVersion,
[Parameter(Mandatory)][string]$PackageKind
[Parameter(Mandatory)][string]$PackageKind,
[ValidateSet('Windows10Legacy', 'Windows11Modern')]
[string]$CompatibilityProfile,
[string]$TargetOperatingSystem
)
$resolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path.TrimEnd('\')
@@ -48,13 +51,19 @@ function Write-PackageManifest {
}
})
$manifest = [ordered]@{
SchemaVersion = 1
SchemaVersion = 2
Product = 'SGU Credential Provider'
PackageKind = $PackageKind
Version = $PackageVersion
CreatedAt = (Get-Date).ToUniversalTime().ToString('o')
Files = $files
}
if ($CompatibilityProfile) {
$manifest['CompatibilityProfile'] = $CompatibilityProfile
}
if ($TargetOperatingSystem) {
$manifest['TargetOperatingSystem'] = $TargetOperatingSystem
}
[IO.File]::WriteAllText(
(Join-Path $resolvedPackageRoot 'package-manifest.json'),
($manifest | ConvertTo-Json -Depth 6),
@@ -78,21 +87,28 @@ if (-not $runtimeInstaller) {
}
New-Item -ItemType Directory -Path $resolvedOutputRoot -Force | Out-Null
$clientRoot = Join-Path $resolvedOutputRoot "sgu-client-bootstrap-$Version"
$windows11ClientRoot = Join-Path $resolvedOutputRoot "sgu-windows11-client-bootstrap-$Version"
$windows10ClientRoot = Join-Path $resolvedOutputRoot "sgu-windows10-legacy-client-bootstrap-$Version"
$clientRoot = $windows11ClientRoot
$serverRoot = Join-Path $resolvedOutputRoot "sgu-server-bootstrap-$Version"
$linuxClientRoot = Join-Path $resolvedOutputRoot "sgu-linux-client-bootstrap-$Version"
$azureRoot = Join-Path $resolvedOutputRoot "sgu-azure-infrastructure-$Version"
$clientZip = "$clientRoot.zip"
$windows11ClientZip = "$windows11ClientRoot.zip"
$windows10ClientZip = "$windows10ClientRoot.zip"
$serverZip = "$serverRoot.zip"
$linuxClientZip = "$linuxClientRoot.zip"
$azureZip = "$azureRoot.zip"
foreach ($target in @($clientRoot,$serverRoot,$linuxClientRoot,$azureRoot,$clientZip,$serverZip,$linuxClientZip,$azureZip)) {
foreach ($target in @(
$windows11ClientRoot,$windows10ClientRoot,$serverRoot,$linuxClientRoot,$azureRoot,
$windows11ClientZip,$windows10ClientZip,$serverZip,$linuxClientZip,$azureZip)) {
if (Test-Path -LiteralPath $target) {
throw "Release target already exists: $target"
}
}
New-Item -ItemType Directory -Path $clientRoot,$serverRoot,$linuxClientRoot,$azureRoot -Force | Out-Null
New-Item -ItemType Directory `
-Path $windows11ClientRoot,$windows10ClientRoot,$serverRoot,$linuxClientRoot,$azureRoot `
-Force | Out-Null
$welcomeFontNames = @(
'IndivisaTextSans-Regular.otf',
'IndivisaTextSans-Bold.otf',
@@ -105,10 +121,6 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Invoke-SguClientBootstrap.ps
-Destination (Join-Path $clientRoot 'Invoke-SguClientBootstrap.ps1')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguClientEnrollment.cmd') `
-Destination (Join-Path $clientRoot 'Start-SguClientEnrollment.cmd')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguAzureClientEnrollment.cmd') `
-Destination (Join-Path $clientRoot 'Start-SguAzureClientEnrollment.cmd')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Install-SguAzureP2sClient.ps1') `
-Destination (Join-Path $clientRoot 'Install-SguAzureP2sClient.ps1')
$clientScripts = @(
'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1',
@@ -118,6 +130,7 @@ $clientScripts = @(
'Install-SguRustDeskClient.ps1',
'Register-SguClientCertificate.ps1',
'Repair-SguClientEnrollment.ps1',
'Set-SguStandardLocalUser.ps1',
'Test-SguClientEnrollment.ps1'
)
foreach ($scriptName in $clientScripts) {
@@ -140,8 +153,26 @@ foreach ($fontName in $welcomeFontNames) {
}
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 `
# Both Windows packages share the provider and enrollment implementation. The
# Windows 10 artifact freezes the direct-network compatibility surface, while
# the Windows 11 artifact adds the modern Azure P2S/pre-logon entry point.
Copy-Item -Path (Join-Path $windows11ClientRoot '*') `
-Destination $windows10ClientRoot -Recurse -Force
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguAzureClientEnrollment.cmd') `
-Destination (Join-Path $windows11ClientRoot 'Start-SguAzureClientEnrollment.cmd')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Install-SguAzureP2sClient.ps1') `
-Destination (Join-Path $windows11ClientRoot 'Install-SguAzureP2sClient.ps1')
Write-PackageManifest -PackageRoot $windows10ClientRoot -PackageVersion $Version `
-PackageKind WindowsClient -CompatibilityProfile Windows10Legacy `
-TargetOperatingSystem 'Windows 10 Pro, Enterprise, or Education (build below 22000)'
Write-PackageManifest -PackageRoot $windows11ClientRoot -PackageVersion $Version `
-PackageKind WindowsClient -CompatibilityProfile Windows11Modern `
-TargetOperatingSystem 'Windows 11 Pro, Enterprise, or Education (build 22000 or later)'
Compress-Archive -Path (Join-Path $windows10ClientRoot '*') -DestinationPath $windows10ClientZip `
-CompressionLevel Optimal
Compress-Archive -Path (Join-Path $windows11ClientRoot '*') -DestinationPath $windows11ClientZip `
-CompressionLevel Optimal
# Linux clients use their native PAM/SSSD sign-in stack rather than the Windows
@@ -241,7 +272,8 @@ Compress-Archive -Path (Join-Path $azureRoot '*') -DestinationPath $azureZip `
-CompressionLevel Optimal
$checksums = @(
("{0} {1}" -f (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash, (Split-Path $clientZip -Leaf))
("{0} {1}" -f (Get-FileHash -LiteralPath $windows10ClientZip -Algorithm SHA256).Hash, (Split-Path $windows10ClientZip -Leaf))
("{0} {1}" -f (Get-FileHash -LiteralPath $windows11ClientZip -Algorithm SHA256).Hash, (Split-Path $windows11ClientZip -Leaf))
("{0} {1}" -f (Get-FileHash -LiteralPath $serverZip -Algorithm SHA256).Hash, (Split-Path $serverZip -Leaf))
("{0} {1}" -f (Get-FileHash -LiteralPath $linuxClientZip -Algorithm SHA256).Hash, (Split-Path $linuxClientZip -Leaf))
("{0} {1}" -f (Get-FileHash -LiteralPath $azureZip -Algorithm SHA256).Hash, (Split-Path $azureZip -Leaf))
@@ -251,8 +283,10 @@ $checksumsPath = Join-Path $resolvedOutputRoot "SHA256SUMS-$Version.txt"
[pscustomobject]@{
Version = $Version
ClientPackage = $clientZip
ClientSha256 = (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash
Windows10LegacyClientPackage = $windows10ClientZip
Windows10LegacyClientSha256 = (Get-FileHash -LiteralPath $windows10ClientZip -Algorithm SHA256).Hash
Windows11ClientPackage = $windows11ClientZip
Windows11ClientSha256 = (Get-FileHash -LiteralPath $windows11ClientZip -Algorithm SHA256).Hash
LinuxClientPackage = $linuxClientZip
LinuxClientSha256 = (Get-FileHash -LiteralPath $linuxClientZip -Algorithm SHA256).Hash
ServerPackage = $serverZip
+11 -3
View File
@@ -15,7 +15,8 @@ param(
$ErrorActionPreference = 'Stop'
$tagName = "v$Version"
$assetPaths = @(
(Join-Path $ReleaseDirectory "sgu-client-bootstrap-$Version.zip"),
(Join-Path $ReleaseDirectory "sgu-windows10-legacy-client-bootstrap-$Version.zip"),
(Join-Path $ReleaseDirectory "sgu-windows11-client-bootstrap-$Version.zip"),
(Join-Path $ReleaseDirectory "sgu-server-bootstrap-$Version.zip"),
(Join-Path $ReleaseDirectory "sgu-linux-client-bootstrap-$Version.zip"),
(Join-Path $ReleaseDirectory "sgu-azure-infrastructure-$Version.zip"),
@@ -111,12 +112,19 @@ 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.
- `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-windows10-legacy-client-bootstrap-$Version.zip`: perfil directo para Windows 10 de laboratorio, con las correcciones de NIC privada, límites de cuentas locales y compatibilidad de sus APIs heredadas.
- `sgu-windows11-client-bootstrap-$Version.zip`: perfil completo para Windows 11; conserva el enrolamiento directo y añade Azure P2S con certificado de máquina y entrada previa al inicio de sesión.
- Ambos clientes comparten los mismos binarios, seguridad mTLS, Credential Provider, cuenta estándar, RustDesk, supervisión y autorreparación; el manifiesto impide ejecutar accidentalmente el paquete de la otra versión de Windows.
- 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-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.
- Los Windows 11 Pro pueden instalar un perfil IKEv2 de todos los usuarios con certificado de máquina, DNS dividido para `lci.lasalle.mx` y ejecutarlo desde la pantalla de inicio de sesión antes de autenticar una cuenta de dominio nueva.
- 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 estos grupos de seguridad de forma idempotente.
- 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 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 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.
+2
View File
@@ -11,9 +11,11 @@ $installScript = Join-Path $enrollmentRoot 'Install-CredentialProvider.ps1'
$remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1'
$monitoringScript = Join-Path $enrollmentRoot 'Enable-SguClientMonitoring.ps1'
$rustDeskScript = Join-Path $enrollmentRoot 'Install-SguRustDeskClient.ps1'
$localUserScript = Join-Path $enrollmentRoot 'Set-SguStandardLocalUser.ps1'
$before = & $testScript
if (-not $before.IsValid) {
& $localUserScript | Out-Null
$installParams = @{
PublishPath = [string]$configuration.PublishPath
BrokerEndpoint = [string]$configuration.BrokerEndpoint
+107
View File
@@ -0,0 +1,107 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess)]
param()
$ErrorActionPreference = 'Stop'
$userName = 'alumno'
$plainTextPassword = 'ingenieria'
$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()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated Windows PowerShell session.'
}
if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Create or update standard local user $userName")) {
return
}
$securePassword = ConvertTo-SecureString $plainTextPassword -AsPlainText -Force
try {
$user = Get-LocalUser -Name $userName -ErrorAction SilentlyContinue
if ($user -and $user.SID.Value.EndsWith('-500', [StringComparison]::Ordinal)) {
throw "The local account '$userName' is the built-in Administrator account and cannot be converted to a standard user."
}
if ($user) {
Set-LocalUser -Name $userName `
-Password $securePassword `
-PasswordNeverExpires $true `
-Description $description
if (-not $user.Enabled) {
Enable-LocalUser -Name $userName
}
}
else {
New-LocalUser -Name $userName `
-Password $securePassword `
-PasswordNeverExpires `
-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
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
$administratorsGroup = Get-LocalGroup -SID $administratorsSid -ErrorAction Stop
$usersGroup = Get-LocalGroup -SID $usersSid -ErrorAction Stop
$administratorMembers = @(Get-LocalGroupMember -Group $administratorsGroup -ErrorAction Stop)
if ($administratorMembers.SID.Value -contains $user.SID.Value) {
Remove-LocalGroupMember -Group $administratorsGroup -Member $user -Confirm:$false
}
$standardMembers = @(Get-LocalGroupMember -Group $usersGroup -ErrorAction Stop)
if ($standardMembers.SID.Value -notcontains $user.SID.Value) {
Add-LocalGroupMember -Group $usersGroup -Member $user
}
}
finally {
$securePassword = $null
}
$verifiedUser = Get-LocalUser -Name $userName -ErrorAction Stop
$verifiedAdministratorsGroup = Get-LocalGroup `
-SID ([Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')) `
-ErrorAction Stop
$verifiedUsersGroup = Get-LocalGroup `
-SID ([Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')) `
-ErrorAction Stop
$verifiedAdministrators = @(Get-LocalGroupMember -Group $verifiedAdministratorsGroup -ErrorAction Stop)
$verifiedUsers = @(Get-LocalGroupMember -Group $verifiedUsersGroup -ErrorAction Stop)
if (@($verifiedAdministrators).SID.Value -contains $verifiedUser.SID.Value) {
throw "The local account '$userName' still belongs to the local Administrators group."
}
if ($verifiedUsers.SID.Value -notcontains $verifiedUser.SID.Value) {
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]@{
UserName = $verifiedUser.Name
Enabled = $verifiedUser.Enabled
IsAdministrator = $false
IsStandardUser = $true
PasswordNeverExpires = $verifiedPasswordNeverExpires
}
+9 -2
View File
@@ -4,5 +4,12 @@ set "SGU_BOOTSTRAP_IP=%~1"
set "SGU_VPN_PACKAGE=%~2"
set "SGU_VPN_PFX=%~3"
set "SGU_VPN_ROOT=%~4"
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"'),'-ConnectivityMode','AzureP2S'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',('"' + $env:SGU_BOOTSTRAP_IP + '"')) }; if ($env:SGU_VPN_PACKAGE) { $arguments += @('-VpnProfilePackagePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PACKAGE) + '"')) }; if ($env:SGU_VPN_PFX) { $arguments += @('-VpnClientCertificatePfxPath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PFX) + '"')) }; if ($env:SGU_VPN_ROOT) { $arguments += @('-VpnClientRootCertificatePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_ROOT) + '"')) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode"
exit /b %errorlevel%
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"'),'-PauseOnError','-ConnectivityMode','AzureP2S'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',('"' + $env:SGU_BOOTSTRAP_IP + '"')) }; if ($env:SGU_VPN_PACKAGE) { $arguments += @('-VpnProfilePackagePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PACKAGE) + '"')) }; if ($env:SGU_VPN_PFX) { $arguments += @('-VpnClientCertificatePfxPath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PFX) + '"')) }; if ($env:SGU_VPN_ROOT) { $arguments += @('-VpnClientRootCertificatePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_ROOT) + '"')) }; $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 Windows 11 Azure enrollment did not complete. Review:
echo C:\ProgramData\SGU\Bootstrap\Client\latest-error.log
pause
)
exit /b %SGU_EXIT_CODE%
+11 -2
View File
@@ -1,5 +1,14 @@
@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%
set "SGU_CLIENT_IP=%~2"
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%
+51
View File
@@ -19,6 +19,8 @@ $defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
$issues = [Collections.Generic.List[string]]::new()
$standardLocalUserName = 'alumno'
$passwordNeverExpiresFlag = 0x10000
$computer = Get-CimInstance Win32_ComputerSystem
if ($RequireDomainJoined -and -not $computer.PartOfDomain) {
@@ -87,6 +89,50 @@ if (-not $passwordProviderPreserved) {
$issues.Add('The built-in Microsoft password provider registration is missing.')
}
$standardLocalUser = Get-LocalUser -Name $standardLocalUserName -ErrorAction SilentlyContinue
$standardLocalUserPresent = [bool]$standardLocalUser
$standardLocalUserEnabled = $standardLocalUserPresent -and $standardLocalUser.Enabled
$standardLocalUserIsAdministrator = $false
$standardLocalUserInUsersGroup = $false
$standardLocalUserPasswordNeverExpires = $false
if ($standardLocalUserPresent) {
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
$administratorsGroup = Get-LocalGroup -SID $administratorsSid -ErrorAction Stop
$usersGroup = Get-LocalGroup -SID $usersSid -ErrorAction Stop
$administratorMembers = @(Get-LocalGroupMember -Group $administratorsGroup -ErrorAction Stop)
$standardMembers = @(Get-LocalGroupMember -Group $usersGroup -ErrorAction Stop)
$standardLocalUserIsAdministrator =
$administratorMembers.SID.Value -contains $standardLocalUser.SID.Value
$standardLocalUserInUsersGroup =
$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) {
$issues.Add("The required standard local user '$standardLocalUserName' is missing.")
}
elseif (-not $standardLocalUserEnabled) {
$issues.Add("The required standard local user '$standardLocalUserName' is disabled.")
}
elseif ($standardLocalUserIsAdministrator) {
$issues.Add("The required standard local user '$standardLocalUserName' belongs to the local Administrators group.")
}
elseif (-not $standardLocalUserInUsersGroup) {
$issues.Add("The required standard local user '$standardLocalUserName' does not belong to the local Users group.")
}
elseif (-not $standardLocalUserPasswordNeverExpires) {
$issues.Add("The required standard local user '$standardLocalUserName' password is not configured to never expire.")
}
$settings = $null
try {
$settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json
@@ -207,6 +253,11 @@ $result = [pscustomobject]@{
LastSignedInUserHidden = $lastSignedInUserHidden
LocalUserEnumerationDisabled = $localUserEnumerationDisabled
PasswordProviderPreserved = $passwordProviderPreserved
StandardLocalUserPresent = $standardLocalUserPresent
StandardLocalUserEnabled = $standardLocalUserEnabled
StandardLocalUserIsAdministrator = $standardLocalUserIsAdministrator
StandardLocalUserInUsersGroup = $standardLocalUserInUsersGroup
StandardLocalUserPasswordNeverExpires = $standardLocalUserPasswordNeverExpires
SettingsPresent = [bool]$settings
ClientCertificatePresent = [bool]$clientCertificatePresent
ServerCertificateTrusted = $serverCertificateTrusted
@@ -0,0 +1,20 @@
namespace SGU.AuthBroker.Core.Profiles;
public sealed record SguAdministrativeLocationSelection(
string? StateId = null,
string? MunicipalityId = null,
string? NeighborhoodId = null,
string? StateName = null,
string? MunicipalityName = null,
string? NeighborhoodName = null,
string? PostalCode = null)
{
public bool HasValues =>
StateId is not null ||
MunicipalityId is not null ||
NeighborhoodId is not null ||
StateName is not null ||
MunicipalityName is not null ||
NeighborhoodName is not null ||
PostalCode is not null;
}
@@ -1,6 +1,7 @@
using System.Net;
using System.Net.Mail;
using System.Text;
using System.Text.Json;
namespace SGU.AuthBroker.Core.Profiles;
@@ -102,7 +103,14 @@ public static class SguProfileParser
return profile.HasValues ? profile : null;
}
public static InstitutionalProfile? ParseAdministrativeLocation(string html)
public static InstitutionalProfile? ParseAdministrativeLocation(string html) =>
ParseAdministrativeLocation(html, null, null, null);
public static InstitutionalProfile? ParseAdministrativeLocation(
string html,
SguAdministrativeLocationSelection? selection,
string? localitiesJson,
string? neighborhoodsJson)
{
ArgumentNullException.ThrowIfNull(html);
@@ -112,18 +120,79 @@ public static class SguProfileParser
string? interiorNumber = NormalizeAddressUnit(
ExtractInputValue(html, AdministrativeInteriorNumberId));
string? streetLine = BuildAdministrativeStreetLine(street, exteriorNumber, interiorNumber);
string? neighborhood = NormalizeTitle(
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId),
256);
string? neighborhood = NormalizeTitle(FirstNonEmpty(
selection?.NeighborhoodName,
ResolveNeighborhoodName(neighborhoodsJson, selection),
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId)), 256);
string? city = NormalizeTitle(FirstNonEmpty(
selection?.MunicipalityName,
ResolveLocalityName(localitiesJson, selection),
ExtractSelectedOptionText(html, AdministrativeCityId)), 128);
string? state = NormalizeTitle(FirstNonEmpty(
selection?.StateName,
ExtractOptionTextByValue(html, AdministrativeStateId, selection?.StateId),
ExtractSelectedOptionText(html, AdministrativeStateId)), 128);
string? postalCode = NormalizePostalCode(FirstNonEmpty(
selection?.PostalCode,
ExtractInputValue(html, AdministrativePostalCodeId)));
InstitutionalProfile profile = new(
StreetAddress: BuildStreetAddress(streetLine, neighborhood, null, null),
City: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeCityId), 128),
State: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeStateId), 128),
PostalCode: NormalizePostalCode(ExtractInputValue(html, AdministrativePostalCodeId)));
City: city,
State: state,
PostalCode: postalCode);
return profile.HasValues ? profile : null;
}
public static SguAdministrativeLocationSelection? ParseAdministrativeLocationSelection(
string json,
string? expectedPostalCode)
{
ArgumentNullException.ThrowIfNull(json);
string? expected = NormalizePostalCode(expectedPostalCode);
try
{
using JsonDocument document = JsonDocument.Parse(json);
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
{
return null;
}
foreach (JsonElement value in values.EnumerateArray())
{
string? postalCode = NormalizePostalCode(GetJsonString(value, "p_Cp"));
if (expected is not null &&
!string.Equals(postalCode, expected, StringComparison.Ordinal))
{
continue;
}
SguAdministrativeLocationSelection selection = new(
StateId: NormalizeCatalogId(GetJsonString(value, "p_IdEstado")),
MunicipalityId: NormalizeCatalogId(GetJsonString(value, "p_IdMunicipio")),
NeighborhoodId: NormalizeCatalogId(GetJsonString(value, "p_IdCP")),
StateName: Limit(GetJsonString(value, "p_NombreEstado"), 128),
MunicipalityName: Limit(GetJsonString(value, "p_NombreMunicipio"), 128),
NeighborhoodName: Limit(FirstNonEmpty(
GetJsonString(value, "p_NombreColonia"),
GetJsonString(value, "p_NombreAsentamiento"),
GetJsonString(value, "p_Nombre")), 256),
PostalCode: postalCode);
if (selection.HasValues)
{
return selection;
}
}
}
catch (JsonException)
{
return null;
}
return null;
}
public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
{
ArgumentNullException.ThrowIfNull(html);
@@ -350,6 +419,186 @@ public static class SguProfileParser
return null;
}
private static string? ExtractOptionTextByValue(string html, string id, string? expectedValue)
{
if (string.IsNullOrWhiteSpace(expectedValue))
{
return null;
}
string? openingTag = FindOpeningTag(html, "select", id);
if (openingTag is null)
{
return null;
}
int openingTagIndex = html.IndexOf(openingTag, StringComparison.OrdinalIgnoreCase);
int contentStart = openingTagIndex + openingTag.Length;
int contentEnd = html.IndexOf("</select", contentStart, StringComparison.OrdinalIgnoreCase);
if (openingTagIndex < 0 || contentEnd < 0)
{
return null;
}
string optionsHtml = html[contentStart..contentEnd];
int searchFrom = 0;
while (searchFrom < optionsHtml.Length)
{
int optionStart = optionsHtml.IndexOf("<option", searchFrom, StringComparison.OrdinalIgnoreCase);
if (optionStart < 0)
{
break;
}
int optionTagEnd = optionsHtml.IndexOf('>', optionStart);
int optionEnd = optionTagEnd < 0
? -1
: optionsHtml.IndexOf("</option", optionTagEnd + 1, StringComparison.OrdinalIgnoreCase);
if (optionTagEnd < 0 || optionEnd < 0)
{
break;
}
string optionTag = optionsHtml[optionStart..(optionTagEnd + 1)];
string? optionValue = ExtractAttributeValue(optionTag, "value");
if (string.Equals(optionValue, expectedValue, StringComparison.Ordinal))
{
return NormalizeText(optionsHtml[(optionTagEnd + 1)..optionEnd]);
}
searchFrom = optionEnd + "</option".Length;
}
return null;
}
private static string? ResolveLocalityName(
string? json,
SguAdministrativeLocationSelection? selection)
{
if (string.IsNullOrWhiteSpace(json) ||
string.IsNullOrWhiteSpace(selection?.MunicipalityId))
{
return null;
}
try
{
using JsonDocument document = JsonDocument.Parse(json);
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
{
return null;
}
foreach (JsonElement value in values.EnumerateArray())
{
if (string.Equals(
NormalizeCatalogId(GetJsonString(value, "Id_Municipio")),
selection.MunicipalityId,
StringComparison.Ordinal) &&
(selection.StateId is null || string.Equals(
NormalizeCatalogId(GetJsonString(value, "ID_Estado")),
selection.StateId,
StringComparison.Ordinal)))
{
return GetJsonString(value, "Nombre");
}
}
}
catch (JsonException)
{
return null;
}
return null;
}
private static string? ResolveNeighborhoodName(
string? json,
SguAdministrativeLocationSelection? selection)
{
if (string.IsNullOrWhiteSpace(json) || selection is null)
{
return null;
}
try
{
using JsonDocument document = JsonDocument.Parse(json);
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
{
return null;
}
List<string> postalCodeMatches = [];
foreach (JsonElement value in values.EnumerateArray())
{
string? neighborhoodId = NormalizeCatalogId(GetJsonString(value, "p_IdCP"));
string? postalCode = NormalizePostalCode(GetJsonString(value, "p_Cp"));
string? name = GetJsonString(value, "p_Nombre");
if (name is null)
{
continue;
}
if (selection.NeighborhoodId is not null &&
string.Equals(neighborhoodId, selection.NeighborhoodId, StringComparison.Ordinal))
{
return name;
}
if (selection.PostalCode is not null &&
string.Equals(postalCode, selection.PostalCode, StringComparison.Ordinal))
{
postalCodeMatches.Add(name);
}
}
return postalCodeMatches.Count == 1 ? postalCodeMatches[0] : null;
}
catch (JsonException)
{
return null;
}
}
private static bool TryGetPageMethodArray(JsonElement root, out JsonElement values)
{
values = default;
return root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("d", out values) &&
values.ValueKind == JsonValueKind.Array;
}
private static string? GetJsonString(JsonElement value, string propertyName)
{
if (value.ValueKind != JsonValueKind.Object ||
!value.TryGetProperty(propertyName, out JsonElement property))
{
return null;
}
return property.ValueKind switch
{
JsonValueKind.String => property.GetString(),
JsonValueKind.Number => property.GetRawText(),
_ => null
};
}
private static string? NormalizeCatalogId(string? value)
{
string? candidate = value?.Trim();
return string.IsNullOrEmpty(candidate) ||
candidate.Length > 32 ||
!candidate.All(char.IsAsciiLetterOrDigit)
? null
: candidate;
}
private static string? FirstNonEmpty(params string?[] values) =>
values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
private static string? FindOpeningTag(string html, string tagName, string id)
=> FindOpeningTagByAttribute(html, tagName, "id", id);
+3 -3
View File
@@ -176,11 +176,11 @@ public sealed class ActiveDirectoryOptions
public string AdministrativeOuDn { get; init; } = "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
public string ProfessorGroupDn { get; init; } = "CN=SGU-Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
public string ProfessorGroupDn { get; init; } = "CN=SGU-Docentes,OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
public string StudentGroupDn { get; init; } = "CN=SGU-Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
public string StudentGroupDn { get; init; } = "CN=SGU-Alumnos,OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
public string AdministrativeGroupDn { get; init; } = "CN=SGU-Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
public string AdministrativeGroupDn { get; init; } = "CN=SGU-Administrativos,OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
public string RemoteDesktopGroupDn { get; init; } = string.Empty;
@@ -1,5 +1,7 @@
using System.Diagnostics;
using System.Net;
using System.Text;
using System.Text.Json;
using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles;
@@ -431,9 +433,6 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
pages.Add((
options.AdministrativePersonalProfilePath,
SguProfileParser.ParseAdministrativePersonal));
pages.Add((
options.AdministrativeLocationProfilePath,
SguProfileParser.ParseAdministrativeLocation));
foreach ((string path, Func<string, InstitutionalProfile?> parser) in pages)
{
@@ -474,7 +473,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
"SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
identity.Role,
elapsed.ElapsedMilliseconds);
break;
return profile;
}
catch (Exception exception)
{
@@ -487,6 +486,142 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
}
}
return await TryEnrichStaffLocationAsync(
client,
profile,
identity,
allowedHosts,
timeoutToken,
requestCancellationToken,
elapsed).ConfigureAwait(false);
}
private async Task<InstitutionalProfile> TryEnrichStaffLocationAsync(
HttpClient client,
InstitutionalProfile profile,
UserIdentity identity,
HashSet<string> allowedHosts,
CancellationToken timeoutToken,
CancellationToken requestCancellationToken,
Stopwatch elapsed)
{
string path = options.AdministrativeLocationProfilePath;
Uri locationPageUri = GetProfileUri(path);
try
{
string? html = await TryFetchAdditionalProfilePageAsync(
client,
locationPageUri,
allowedHosts,
timeoutToken).ConfigureAwait(false);
if (html is null)
{
logger.LogWarning(
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile page {Path} did not return usable HTML for role {Role}; preserving fields already collected.",
path,
identity.Role);
return profile;
}
InstitutionalProfile? staticLocation = SguProfileParser.ParseAdministrativeLocation(html);
if (staticLocation is null)
{
logger.LogWarning(
BrokerEventIds.ProfileHtmlUnexpected,
"Optional SGU profile page {Path} returned HTML without its supported field IDs for role {Role}; preserving fields already collected.",
path,
identity.Role);
return profile;
}
profile = profile.Overlay(staticLocation);
if (string.IsNullOrWhiteSpace(staticLocation.PostalCode))
{
return profile;
}
string? directionJson = await TryPostProfilePageMethodAsync(
client,
GetAdministrativeLocationMethodUri("GetDireccion"),
locationPageUri,
new Dictionary<string, string>
{
["CodigoPostal"] = staticLocation.PostalCode
},
allowedHosts,
timeoutToken).ConfigureAwait(false);
if (directionJson is null)
{
return profile;
}
SguAdministrativeLocationSelection? selection =
SguProfileParser.ParseAdministrativeLocationSelection(
directionJson,
staticLocation.PostalCode);
if (selection is null)
{
logger.LogWarning(
BrokerEventIds.ProfileHtmlUnexpected,
"SGU location method GetDireccion returned an unexpected payload for role {Role}; preserving the static address fields.",
identity.Role);
return profile;
}
string? localitiesJson = null;
if (!string.IsNullOrWhiteSpace(selection.StateId))
{
localitiesJson = await TryPostProfilePageMethodAsync(
client,
GetAdministrativeLocationMethodUri("GetLocalidadListado"),
locationPageUri,
new Dictionary<string, string>
{
["pIdEstado"] = selection.StateId
},
allowedHosts,
timeoutToken).ConfigureAwait(false);
}
string? neighborhoodsJson = await TryPostProfilePageMethodAsync(
client,
GetAdministrativeLocationMethodUri("GetColoniasListado"),
locationPageUri,
new Dictionary<string, string>
{
["pIdEstado"] = string.Empty,
["pLocalidad"] = string.Empty,
["CodigoPostal"] = selection.PostalCode ?? staticLocation.PostalCode
},
allowedHosts,
timeoutToken).ConfigureAwait(false);
InstitutionalProfile? resolvedLocation = SguProfileParser.ParseAdministrativeLocation(
html,
selection,
localitiesJson,
neighborhoodsJson);
return profile.Overlay(resolvedLocation);
}
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
{
logger.LogWarning(
BrokerEventIds.ProfileEnrichmentTimeout,
"SGU optional staff location enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
identity.Role,
elapsed.ElapsedMilliseconds);
}
catch (Exception exception)
{
logger.LogWarning(
BrokerEventIds.ProfileEnrichmentFailure,
exception,
"SGU optional staff location enrichment failed for role {Role} after {ElapsedMilliseconds} ms; preserving fields already collected.",
identity.Role,
elapsed.ElapsedMilliseconds);
}
return profile;
}
@@ -545,6 +680,46 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
return null;
}
private async Task<string?> TryPostProfilePageMethodAsync(
HttpClient client,
Uri requestedUri,
Uri referrerUri,
IReadOnlyDictionary<string, string> payload,
HashSet<string> allowedHosts,
CancellationToken cancellationToken)
{
if (!IsAllowedHttpsUri(requestedUri, allowedHosts) ||
!IsAllowedHttpsUri(referrerUri, allowedHosts))
{
return null;
}
using HttpRequestMessage request = new(HttpMethod.Post, requestedUri);
request.Headers.Referrer = referrerUri;
request.Content = new StringContent(
JsonSerializer.Serialize(payload),
Encoding.UTF8,
"application/json");
using HttpResponseMessage response = await client
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
int statusCode = (int)response.StatusCode;
if (statusCode is >= 200 and < 300)
{
return await ReadLimitedStringAsync(
response.Content,
options.MaxProfileBytes,
cancellationToken).ConfigureAwait(false);
}
logger.LogWarning(
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile method {Path} returned HTTP {StatusCode}.",
requestedUri.AbsolutePath,
statusCode);
return null;
}
private static void AddCredential(
Uri uri,
CredentialCache credentialCache,
@@ -658,6 +833,12 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
private Uri GetProfileUri(string path) =>
new(new Uri(options.Endpoint, UriKind.Absolute), path);
private Uri GetAdministrativeLocationMethodUri(string methodName)
{
Uri pageUri = GetProfileUri(options.AdministrativeLocationProfilePath);
return new Uri($"{pageUri.GetLeftPart(UriPartial.Path).TrimEnd('/')}/{methodName}");
}
private async Task<InstitutionalProfile?> TryReadProfileAsync(
HttpResponseMessage response,
UserIdentity identity,
+3 -3
View File
@@ -50,9 +50,9 @@
"ProfessorOuDn": "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"StudentOuDn": "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"AdministrativeOuDn": "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"ProfessorGroupDn": "CN=SGU-Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"StudentGroupDn": "CN=SGU-Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"AdministrativeGroupDn": "CN=SGU-Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"ProfessorGroupDn": "CN=SGU-Docentes,OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"StudentGroupDn": "CN=SGU-Alumnos,OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"AdministrativeGroupDn": "CN=SGU-Administrativos,OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"RemoteDesktopGroupDn": "",
"DefaultCompany": "La Salle",
"CreateMissingOus": false
+60
View File
@@ -25,6 +25,22 @@ $networkFunctions = $serverAst.FindAll({
}, $true)
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' {
It 'canonicalizes a host address to its IPv4 network' {
ConvertTo-NetworkCidr -Address ([ipaddress]'10.77.0.4') `
@@ -56,6 +72,50 @@ Describe 'SGU public-cloud network safety' {
'ConnectivityMode') | Should Be $true
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
'VpnProfilePackagePath') | Should Be $true
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
'CompatibilityProfile') | 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 'keeps legacy and modern Windows package profiles isolated by build' {
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
$source.Contains("if (`$CompatibilityProfile -eq 'Windows10Legacy' -and `$windowsBuild -ge 22000)") |
Should Be $true
$source.Contains("if (`$CompatibilityProfile -eq 'Windows11Modern' -and `$windowsBuild -lt 22000)") |
Should Be $true
$source.Contains("if (`$CompatibilityProfile -eq 'Windows10Legacy' -and `$ConnectivityMode -eq 'AzureP2S')") |
Should Be $true
}
It 'uses an all-user machine-certificate VPN profile' {
+65
View File
@@ -0,0 +1,65 @@
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$localUserScriptPath = Join-Path $repositoryRoot 'scripts\Set-SguStandardLocalUser.ps1'
$enrollmentTestScriptPath = Join-Path $repositoryRoot 'scripts\Test-SguClientEnrollment.ps1'
$packageScriptPath = Join-Path $repositoryRoot 'scripts\New-SguBootstrapPackages.ps1'
$releaseScriptPath = Join-Path $repositoryRoot 'scripts\Publish-GiteaRelease.ps1'
$azureLauncherPath = Join-Path $repositoryRoot 'scripts\Start-SguAzureClientEnrollment.cmd'
$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'
}
It 'publishes separate legacy Windows 10 and modern Windows 11 artifacts' {
$packageSource = Get-Content -LiteralPath $packageScriptPath -Raw
$releaseSource = Get-Content -LiteralPath $releaseScriptPath -Raw
$packageSource | Should Match 'sgu-windows10-legacy-client-bootstrap-\$Version'
$packageSource | Should Match 'sgu-windows11-client-bootstrap-\$Version'
$packageSource | Should Match '-CompatibilityProfile Windows10Legacy'
$packageSource | Should Match '-CompatibilityProfile Windows11Modern'
$releaseSource | Should Match 'sgu-windows10-legacy-client-bootstrap-\$Version\.zip'
$releaseSource | Should Match 'sgu-windows11-client-bootstrap-\$Version\.zip'
}
It 'keeps Azure P2S in the modern Windows 11 artifact' {
$packageSource = Get-Content -LiteralPath $packageScriptPath -Raw
$azureLauncher = Get-Content -LiteralPath $azureLauncherPath -Raw
$packageSource.Contains("Join-Path `$windows11ClientRoot 'Start-SguAzureClientEnrollment.cmd'") |
Should Be $true
$packageSource.Contains("Join-Path `$windows10ClientRoot 'Start-SguAzureClientEnrollment.cmd'") |
Should Be $false
$azureLauncher | Should Match '-PauseOnError'
}
}
@@ -228,6 +228,65 @@ public sealed class SguProfileParserTests
Assert.Null(profile.Email);
}
[Fact]
public void ResolvesAdministrativeAddressFromPageMethodIdentifiers()
{
const string html = """
<html><body>
<input id='ctl00_contenedor_txtCalle' value='RETORNO 1, SUR 16' />
<input id='ctl00_contenedor_txtNoExt' value='74' />
<input id='ctl00_contenedor_txtNoInt' value='' />
<input id='ctl00_contenedor_txtCP' value='08500' />
<select id='ctl00_contenedor_ddlEstado'>
<option selected='selected' value='0'>Seleccione...</option>
<option value='09'>CIUDAD DE MÉXICO</option>
</select>
<select id='ctl00_contenedor_ddlLocalidad'>
<option selected='selected' value='0'>Seleccione alguna localidad...</option>
</select>
<select id='ctl00_contenedor_ddlColonia'>
<option selected='selected' value='0,0'>Seleccione alguna colonia...</option>
</select>
</body></html>
""";
const string directionJson = """
{"d":[{"p_IdCP":"091263","p_IdEstado":"09","p_NombreEstado":"","p_IdMunicipio":"006","p_NombreMunicipio":"","p_NombreColonia":"","p_Cp":"08500"}]}
""";
const string localitiesJson = """
{"d":[{"ID_Estado":"09","Id_Municipio":"002","Nombre":"AZCAPOTZALCO"},{"ID_Estado":"09","Id_Municipio":"006","Nombre":"IZTACALCO"}]}
""";
const string neighborhoodsJson = """
{"d":[{"p_IdCP":"091263","p_Nombre":"AGRÍCOLA ORIENTAL","p_Cp":"08500"}]}
""";
SguAdministrativeLocationSelection? selection =
SguProfileParser.ParseAdministrativeLocationSelection(directionJson, "08500");
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativeLocation(
html,
selection,
localitiesJson,
neighborhoodsJson);
Assert.NotNull(selection);
Assert.Equal("09", selection.StateId);
Assert.Equal("006", selection.MunicipalityId);
Assert.Equal("091263", selection.NeighborhoodId);
Assert.NotNull(profile);
Assert.Equal("Retorno 1, Sur 16 74\r\nAgrícola Oriental", profile.StreetAddress);
Assert.Equal("Iztacalco", profile.City);
Assert.Equal("Ciudad de México", profile.State);
Assert.Equal("08500", profile.PostalCode);
}
[Theory]
[InlineData("not-json")]
[InlineData("{\"d\":{}}")]
[InlineData("{\"d\":[]}")]
public void RejectsUnexpectedAdministrativeLocationPayloads(string json)
{
Assert.Null(SguProfileParser.ParseAdministrativeLocationSelection(json, "08500"));
}
[Fact]
public void AdministrativePagesOverlayTheVerifiedIncidentsProfile()
{
@@ -31,9 +31,9 @@ public sealed class BrokerOptionsTests
}
[Theory]
[InlineData(InstitutionalRole.Student, "CN=SGU-Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
[InlineData(InstitutionalRole.Administrative, "CN=SGU-Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
[InlineData(InstitutionalRole.Professor, "CN=SGU-Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
[InlineData(InstitutionalRole.Student, "CN=SGU-Alumnos,OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
[InlineData(InstitutionalRole.Administrative, "CN=SGU-Administrativos,OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
[InlineData(InstitutionalRole.Professor, "CN=SGU-Docentes,OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
public void DefaultRoleGroupMappingsMatchInstitutionalPrefixes(InstitutionalRole role, string expectedGroupDn)
{
ActiveDirectoryOptions options = new();
@@ -145,14 +145,24 @@ public sealed class NtlmCredentialValidatorTests
<input id="ctl00_contenedor_txtNoExt" value="15" />
<input id="ctl00_contenedor_txtCP" value="01000" />
<select id="ctl00_contenedor_ddlEstado">
<option selected="selected">CIUDAD DE MÉXICO</option>
<option selected="selected" value="0">Seleccione...</option>
<option value="09">CIUDAD DE MÉXICO</option>
</select>
<select id="ctl00_contenedor_ddlLocalidad">
<option selected="selected">ÁLVARO OBREGÓN</option>
<option selected="selected" value="0">Seleccione alguna localidad...</option>
</select>
<select id="ctl00_contenedor_ddlColonia">
<option>FLORIDA</option>
<option selected="selected" value="0,0">Seleccione alguna colonia...</option>
</select>
"""),
JsonResponse("""
{"d":[{"p_IdCP":"090001","p_IdEstado":"09","p_NombreEstado":"","p_IdMunicipio":"010","p_NombreMunicipio":"","p_NombreColonia":"FLORIDA","p_Cp":"01000"}]}
"""),
JsonResponse("""
{"d":[{"ID_Estado":"09","Id_Municipio":"010","Nombre":"ÁLVARO OBREGÓN"}]}
"""),
JsonResponse("""
{"d":[{"p_IdCP":"090001","p_Nombre":"FLORIDA","p_Cp":"01000"}]}
"""));
NtlmCredentialValidator validator = CreateValidator(handler);
@@ -180,9 +190,17 @@ public sealed class NtlmCredentialValidatorTests
"/psulsa/",
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx"
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetDireccion",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetLocalidadListado",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetColoniasListado"
],
handler.RequestPaths);
Assert.Equal("{\"CodigoPostal\":\"01000\"}", handler.RequestBodies[5]);
Assert.Equal("{\"pIdEstado\":\"09\"}", handler.RequestBodies[6]);
Assert.Equal(
"{\"pIdEstado\":\"\",\"pLocalidad\":\"\",\"CodigoPostal\":\"01000\"}",
handler.RequestBodies[7]);
}
[Fact]
@@ -242,14 +260,24 @@ public sealed class NtlmCredentialValidatorTests
<input id="ctl00_contenedor_txtNoExt" value="15" />
<input id="ctl00_contenedor_txtCP" value="01000" />
<select id="ctl00_contenedor_ddlEstado">
<option selected="selected">CIUDAD DE MÉXICO</option>
<option selected="selected" value="0">Seleccione...</option>
<option value="09">CIUDAD DE MÉXICO</option>
</select>
<select id="ctl00_contenedor_ddlLocalidad">
<option selected="selected">ÁLVARO OBREGÓN</option>
<option selected="selected" value="0">Seleccione alguna localidad...</option>
</select>
<select id="ctl00_contenedor_ddlColonia">
<option selected="selected">FLORIDA</option>
<option selected="selected" value="0,0">Seleccione alguna colonia...</option>
</select>
"""),
JsonResponse("""
{"d":[{"p_IdCP":"090001","p_IdEstado":"09","p_NombreEstado":"","p_IdMunicipio":"010","p_NombreMunicipio":"","p_NombreColonia":"FLORIDA","p_Cp":"01000"}]}
"""),
JsonResponse("""
{"d":[{"ID_Estado":"09","Id_Municipio":"010","Nombre":"ÁLVARO OBREGÓN"}]}
"""),
JsonResponse("""
{"d":[{"p_IdCP":"090001","p_Nombre":"FLORIDA","p_Cp":"01000"}]}
"""));
NtlmCredentialValidator validator = CreateValidator(handler);
@@ -280,7 +308,10 @@ public sealed class NtlmCredentialValidatorTests
"/psulsa/menu.aspx",
"/psulsa/gadmon/nomina/consultanomina.aspx",
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx"
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetDireccion",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetLocalidadListado",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetColoniasListado"
],
handler.RequestPaths);
}
@@ -363,18 +394,29 @@ public sealed class NtlmCredentialValidatorTests
Content = new StringContent(content)
};
private static HttpResponseMessage JsonResponse(string content) =>
new(HttpStatusCode.OK)
{
Content = new StringContent(content, null, "application/json")
};
private sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
{
private readonly Queue<HttpResponseMessage> responses = new(responses);
public List<string> RequestPaths { get; } = [];
public List<string?> RequestBodies { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
RequestPaths.Add(request.RequestUri!.AbsolutePath);
RequestBodies.Add(request.Content is null
? null
: request.Content.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult());
if (responses.Count == 0)
{
throw new InvalidOperationException("The validator sent more requests than expected.");