Compare commits

...
7 Commits
41 changed files with 2711 additions and 125 deletions
+22 -7
View File
@@ -9,7 +9,7 @@ source and adds an SGU-specific provider, an mTLS-protected broker, Active
Directory synchronization, deployment scripts, and tests. Directory synchronization, deployment scripts, and tests.
Ready-to-run bootstrap packages are published on the 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 ## 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 authentication or password synchronization after the lightweight NTLM root has
accepted the credential. 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: Operational documentation:
- [One-command server recovery and client enrollment](docs/bootstrap-recovery.md) - [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) - [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) - [Decision: do not persist password verifiers in Redis](docs/decisions/0001-no-password-cache.md)
| Prefix | Role | Default OU | | Prefix | Role | Default OU | Security group in the same OU |
|---|---|---| |---|---|---|---|
| `DO` | Professor / docente | `OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` | | `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` | | `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` | | `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 If the broker or institutional NTLM authority is unavailable, the provider
submits the unchanged credentials to Windows for normal AD/cached-domain submits the unchanged credentials to Windows for normal AD/cached-domain
@@ -97,6 +104,9 @@ the latest .NET 10 x64 runtime. The broker is published self-contained.
Follow [docs/lab-runbook.md](docs/lab-runbook.md). Review Follow [docs/lab-runbook.md](docs/lab-runbook.md). Review
[docs/security.md](docs/security.md) before production deployment and [docs/security.md](docs/security.md) before production deployment and
[docs/architecture.md](docs/architecture.md) for the component contract. [docs/architecture.md](docs/architecture.md) for the component contract.
For a public Azure VM connected to local Hyper-V clients through Azure VPN
Gateway, use [docs/azure-vpn-deployment.md](docs/azure-vpn-deployment.md). AD
ports remain private even though the VM owns a public IP.
Never disable the built-in Microsoft password Credential Provider. It is the Never disable the built-in Microsoft password Credential Provider. It is the
supported recovery path if a third-party provider fails to load. supported recovery path if a third-party provider fails to load.
@@ -105,9 +115,14 @@ For a clean machine, the supported entry points are the release packages:
```bat ```bat
Start-SguServerBootstrap.cmd 192.168.50.10 Start-SguServerBootstrap.cmd 192.168.50.10
Start-SguClientEnrollment.cmd 192.168.50.10 Start-SguClientEnrollment.cmd 192.168.50.10 192.168.50.11
``` ```
El segundo argumento es la IP fija, única, del cliente en la red privada. Si se
omite y ese adaptador todavía usa una dirección `169.254.x.x`, el enrolador la
solicita de forma interactiva. En equipos con dos NIC selecciona el adaptador
sin puerta de enlace y conserva el `Default Switch` para Internet.
Linux clients are enrolled through their native PAM/SSSD stack instead of the Linux clients are enrolled through their native PAM/SSSD stack instead of the
Windows Credential Provider: Windows Credential Provider:
+17 -5
View File
@@ -27,8 +27,12 @@ accepted it. The broker then makes separately bounded, best-effort profile GETs.
It uses the administrative incident overview for `AD`, the student information 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 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 incident page confirms an `AD` employee number, two additional GETs in the same
in-memory session read the structured name from `datos/personales.aspx` and the in-memory session read the structured name and selected sex from
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 `nomina/consultanomina.aspx` for a matching employee number, email, employee
type and job title, then attempt the same two shared staff modules without type and job title, then attempt the same two shared staff modules without
requiring any optional route to exist. A supplemental requiring any optional route to exist. A supplemental
@@ -61,7 +65,9 @@ passes the submitted password directly to ADSI `SetPassword`.
When the authenticated HTML exposes recognized stable IDs, the broker also When the authenticated HTML exposes recognized stable IDs, the broker also
updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`, updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`,
`department`, `employeeType`, `employeeID`, `streetAddress`, `l`, `st`, and `department`, `employeeType`, `employeeID`, `streetAddress`, `l`, `st`, and
`postalCode` attributes. Administrative and student numbers must match the six `postalCode` attributes. The SGU sex value is normalized to `Male`/`Female` and
written as the managed `SGU-Gender:` line in the built-in `info` attribute while
preserving unrelated notes. Administrative and student numbers must match the six
numeric digits of the requested identity before any role-specific metadata is numeric digits of the requested identity before any role-specific metadata is
trusted. Administrative personal and location pages are accepted only after trusted. Administrative personal and location pages are accepted only after
that incident-page match. Docente payroll metadata must match the requested that incident-page match. Docente payroll metadata must match the requested
@@ -74,8 +80,11 @@ password outcome.
Every synchronization also enforces one idempotent security-group membership Every synchronization also enforces one idempotent security-group membership
from the classified institutional prefix: `AL` to `SGU-Alumnos`, `AD` to from the classified institutional prefix: `AL` to `SGU-Alumnos`, `AD` to
`SGU-Administrativos`, and `DO` to `SGU-Docentes`. This happens synchronously `SGU-Administrativos`, and `DO` to `SGU-Docentes`. Each role group is stored
inside the broker before the institutional password is written to AD. A missing 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 or inaccessible role group therefore fails provisioning instead of leaving a
new usable account without its authorization classification. Existing accounts new usable account without its authorization classification. Existing accounts
are repaired automatically on their next successful SGU authentication. are repaired automatically on their next successful SGU authentication.
@@ -118,6 +127,9 @@ settings for managed clients.
That computer GPO also owns the base lock-screen image and a per-logon command That computer GPO also owns the base lock-screen image and a per-logon command
for the personalized desktop wallpaper. The client-side renderer reads the for the personalized desktop wallpaper. The client-side renderer reads the
managed `SGU-Gender: Male|Female` line from the user's built-in `info` attribute
(without requiring an irreversible AD schema extension). It uses neutral Spanish
when that optional enrichment is unavailable. The renderer also reads the
authenticated user's `displayName` plus the computer object's `location` and authenticated user's `displayName` plus the computer object's `location` and
immediate parent OU, then composes those values over the bundled dark-blue immediate parent OU, then composes those values over the bundled dark-blue
background with the bundled Indivisa fonts. Missing directory attributes degrade background with the bundled Indivisa fonts. Missing directory attributes degrade
+179
View File
@@ -0,0 +1,179 @@
# Active Directory SGU en Azure con VPN Point-to-Site
Esta variante conserva Active Directory en una VM Windows Server 2025 con IP
pública de Azure, pero **no publica Active Directory en Internet**. La IP pública
sirve para el ciclo de vida y, opcionalmente, RDP desde un único CIDR
administrativo. DNS, Kerberos, LDAP, SMB, RPC, WinRM, Auth Broker, monitoreo y
RustDesk viajan por Azure VPN Gateway Point-to-Site (P2S).
La plantilla crea:
- VNet `10.77.0.0/16`, subnet del DC `10.77.0.0/24` y `GatewaySubnet`;
- Windows Server 2025 con IP privada estática `10.77.0.4` reservada en la NIC;
- IP pública Standard para la VM, protegida por NSG;
- VPN Gateway `VpnGw1` con IKEv2/SSTP y autenticación por certificados;
- pool P2S `172.30.0.0/24`, autorizado en los firewalls SGU;
- DNS de la NIC del servidor apuntando a `10.77.0.4`.
Los prefijos son parámetros. Deben ser RFC1918 y no deben solaparse con las
redes usadas por Hyper-V, el `Default Switch`, Wi-Fi o Ethernet locales.
## 1. Crear la autoridad P2S y el certificado de administración
En la estación administrativa donde está el repositorio:
```powershell
$p2s = .\scripts\New-SguAzureP2sCertificates.ps1 `
-ClientName 'AdminWorkstation'
```
Se pide una contraseña para proteger el PFX. La clave privada de la autoridad
raíz permanece no exportable en `Cert:\CurrentUser\My`; Azure recibe solamente
el `.cer` público. El PFX es una credencial de acceso a la VNet: se debe copiar
únicamente a la VM correspondiente y eliminarse de ubicaciones compartidas
después de importarlo.
## 2. Desplegar Azure
Requisitos: Azure CLI, una sesión iniciada con `az login`, permisos para crear
red, gateway, IP pública y VM, y una suscripción seleccionable.
```powershell
$azure = .\scripts\Deploy-SguAzureInfrastructure.ps1 `
-SubscriptionId '00000000-0000-0000-0000-000000000000' `
-ResourceGroupName 'rg-sgu-lab' `
-Location 'centralus' `
-AdministratorUsername 'azureadmin' `
-P2sRootCertificatePath $p2s.RootCertificatePath
```
La contraseña local de la VM se solicita como `SecureString`, se coloca sólo en
un archivo temporal con ACL exclusiva para el usuario actual y se elimina al
terminar. No aparece en los argumentos de Azure CLI ni queda guardada en el
repositorio.
Por omisión ningún puerto administrativo de la VM se abre desde Internet. Para
habilitar temporalmente RDP durante el bootstrap, indique exclusivamente su IP
pública actual:
```powershell
-AdministratorSourceAddressPrefix '203.0.113.10/32'
```
No utilice `0.0.0.0/0`. El despliegue de un VPN Gateway suele tardar bastante
más que la VM; el comando espera hasta que Azure entregue un resultado final.
## 3. Descargar P2S y entrar por la IP privada
Cuando el gateway esté `Succeeded`:
```powershell
$vpn = .\scripts\Get-SguAzureP2sPackage.ps1 `
-SubscriptionId '00000000-0000-0000-0000-000000000000' `
-ResourceGroupName 'rg-sgu-lab' `
-VpnGatewayName $azure.VpnGatewayName
.\scripts\Install-SguAzureP2sClient.ps1 `
-VpnProfilePackagePath $vpn.PackagePath `
-ClientCertificatePfxPath $p2s.ClientCertificatePath `
-ClientRootCertificatePath $p2s.RootCertificatePath `
-Connect
```
Con el túnel conectado, use RDP contra `10.77.0.4` y habilite la redirección de
una unidad local para copiar `sgu-server-bootstrap-VERSION.zip` a la VM. La NIC
ya apunta a su futura dirección DNS propia, por lo que la resolución pública no
está disponible hasta que el bootstrap instale DNS y sus reenviadores. Así no es
necesario abrir 3389 en la IP pública. La opción
`AdministratorSourceAddressPrefix` queda como ruta de recuperación temporal,
no como el camino normal.
## 4. Ejecutar el bootstrap dentro de Windows Server
Descargue y extraiga `sgu-server-bootstrap-VERSION.zip` dentro de la VM. La IP
que recibe el bootstrap es la **privada** de la NIC, nunca la pública:
```bat
Start-SguAzureServerBootstrap.cmd 10.77.0.4 172.30.0.0/24
```
El modo `PlatformManaged` comprueba que Azure ya asignó `10.77.0.4/24`, pero no
deshabilita DHCP, no reemplaza la ruta predeterminada y no reinicia el adaptador.
El DNS de AD publica únicamente la dirección privada. `168.63.129.16` se usa
como reenviador DNS de la plataforma Azure.
Después del reinicio de promoción, verificar:
```powershell
Get-Content C:\ProgramData\SGU\Bootstrap\Server\bootstrap-complete.json
Resolve-DnsName _ldap._tcp.dc._msdcs.lci.lasalle.mx -Type SRV -Server 10.77.0.4
```
El JSON debe indicar `NetworkConfigurationMode = PlatformManaged`, el pool P2S
en `TrustedClientNetworks` y ambos prefijos en `AllowedRemoteAddresses`.
## 5. Emitir un certificado y enrolar cada VM Hyper-V
En la estación administrativa, emita una credencial distinta por equipo:
```powershell
$w11 = .\scripts\New-SguAzureP2sCertificates.ps1 -ClientName 'Windows11'
```
Copie a la VM Windows 11 de Hyper-V:
- `sgu-client-bootstrap-VERSION.zip` extraído;
- `$vpn.PackagePath`;
- `$w11.ClientCertificatePath`;
- `sgu-azure-p2s-root.cer`.
Desde la carpeta extraída del bootstrap de cliente, instale P2S y enrole:
```bat
Start-SguAzureClientEnrollment.cmd 10.77.0.4 C:\SGU\sgu-azure-vpn-client.zip C:\SGU\sgu-azure-p2s-Windows11.pfx C:\SGU\sgu-azure-p2s-root.cer
```
En una sola ejecución el comando:
1. importa el certificado de cliente en `LocalMachine\My` sin dejar la
contraseña en disco;
2. instala un perfil IKEv2 de todos los usuarios llamado `SGU Azure P2S`;
3. agrega la ruta `10.77.0.0/16` y una regla NRPT que envía sólo
`.lci.lasalle.mx` al DNS `10.77.0.4`;
4. conecta P2S con certificado de máquina;
5. registra mTLS, instala SGU/RustDesk y une el equipo al dominio;
6. reinicia Windows.
Si la red local bloquea IKEv2 (UDP 500/4500), el paquete de Azure también
incluye un perfil SSTP sobre TCP 443, pero ese fallback todavía requiere
instalación manual con el instalador oficial incluido en `WindowsAmd64`.
Windows 11 Pro admite unión a AD y VPN nativa, pero Microsoft no licencia el
**Always On VPN device tunnel** para Pro. Por ello el perfil se instala para
todos los usuarios y se puede seleccionar desde el control de red de la
pantalla de inicio de sesión; antes del primer logon de una cuenta de dominio,
conecte `SGU Azure P2S` allí. Enterprise/Education pueden recibir posteriormente
un device tunnel Always On, pero eso no es requisito del enrolamiento SGU.
Validación dentro del cliente, con la VPN conectada:
```powershell
Get-VpnConnection -Name 'SGU Azure P2S' -AllUserConnection
Get-DnsClientNrptRule | Where-Object DisplayName -like 'SGU Azure P2S*'
Test-NetConnection 10.77.0.4 -Port 5985
Resolve-DnsName _ldap._tcp.dc._msdcs.lci.lasalle.mx -Type SRV
nltest.exe /dsgetdc:lci.lasalle.mx
```
## Seguridad y referencias
No agregue reglas NSG públicas para 53, 88, 135, 389, 445, 464, 636, 3268,
3269 ni RPC dinámico. El conjunto de puertos necesario para una unión de dominio
es precisamente la razón de encapsularlo en P2S.
- [Azure VPN Gateway P2S con certificados](https://learn.microsoft.com/en-us/azure/vpn-gateway/point-to-site-certificate-gateway)
- [Cliente P2S nativo de Windows](https://learn.microsoft.com/en-us/azure/vpn-gateway/point-to-site-vpn-client-certificate)
- [Instalación de certificados P2S](https://learn.microsoft.com/en-us/azure/vpn-gateway/point-to-site-how-to-vpn-client-install-azure-cert)
- [Puertos necesarios para unir un dominio](https://learn.microsoft.com/en-us/troubleshoot/windows-server/identity/active-directory-domain-join-troubleshooting-guidance)
- [Requisitos de edición de Windows](https://learn.microsoft.com/en-us/windows/security/licensing-and-edition-requirements)
- [Limitación de Always On device tunnel](https://learn.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-always-on-device-tunnel)
+18 -3
View File
@@ -1,6 +1,6 @@
# Recuperación desde cero y alta en una sola ejecución # Recuperación desde cero y alta en una sola ejecución
Los releases entregan tres ZIP independientes. Cada uno contiene sus binarios, Los releases entregan cuatro ZIP independientes. Cada uno contiene sus binarios,
scripts, instalador offline requerido y un manifiesto SHA-256 interno. No contienen scripts, instalador offline requerido y un manifiesto SHA-256 interno. No contienen
contraseñas, claves privadas ni certificados reutilizables. contraseñas, claves privadas ni certificados reutilizables.
@@ -17,6 +17,11 @@ Compatible con Windows Server con Windows PowerShell 5.1. El servidor necesita
una interfaz privada para el dominio y, para autenticar contra SGU, salida HTTPS una interfaz privada para el dominio y, para autenticar contra SGU, salida HTTPS
por esa u otra interfaz. por esa u otra interfaz.
Cuando el servidor vive en Azure, no se configura la IP dentro del sistema
operativo. La NIC reserva la IP privada y se usa el modo `PlatformManaged`; el
procedimiento completo, incluidos VPN Gateway y los clientes Hyper-V, está en
[azure-vpn-deployment.md](azure-vpn-deployment.md).
1. Descargar y extraer `sgu-server-bootstrap-VERSION.zip`. 1. Descargar y extraer `sgu-server-bootstrap-VERSION.zip`.
2. Abrir el directorio extraído. 2. Abrir el directorio extraído.
3. Ejecutar, indicando la IP fija que tendrá el controlador: 3. Ejecutar, indicando la IP fija que tendrá el controlador:
@@ -95,9 +100,17 @@ el equipo y explica que se debe actualizar la edición.
2. Ejecutar con la IP fija actual del controlador de dominio: 2. Ejecutar con la IP fija actual del controlador de dominio:
```bat ```bat
Start-SguClientEnrollment.cmd 192.168.50.10 Start-SguClientEnrollment.cmd 192.168.50.10 192.168.50.11
``` ```
El primer argumento es el controlador de dominio y el segundo es una dirección
IPv4 fija, libre y exclusiva del cliente en la red privada. Si se omite la IP
del cliente, el bootstrap la solicita cuando el adaptador sólo tiene APIPA
(`169.254.x.x`). En una VM con Internet por `Default Switch` y otra NIC para
`Laboratorio AD`, el bootstrap elige la NIC sin puerta de enlace y no cambia la
ruta predeterminada. Si falla, la ventana elevada permanece abierta y el mismo
error queda en `C:\ProgramData\SGU\Bootstrap\Client\latest-error.log`.
Después de UAC, se solicita interactivamente la credencial autorizada para unir Después de UAC, se solicita interactivamente la credencial autorizada para unir
equipos. La contraseña existe sólo en memoria. El bootstrap: equipos. La contraseña existe sólo en memoria. El bootstrap:
@@ -123,6 +136,8 @@ Para elegir adaptador o nombre del equipo explícitamente:
powershell.exe -NoProfile -ExecutionPolicy Bypass ` powershell.exe -NoProfile -ExecutionPolicy Bypass `
-File .\Invoke-SguClientBootstrap.ps1 ` -File .\Invoke-SguClientBootstrap.ps1 `
-DomainControllerIPv4Address 192.168.50.10 ` -DomainControllerIPv4Address 192.168.50.10 `
-ClientIPv4Address 192.168.50.11 `
-ClientPrefixLength 24 `
-NetworkInterfaceAlias 'Ethernet' ` -NetworkInterfaceAlias 'Ethernet' `
-NewComputerName 'LCI-101' -NewComputerName 'LCI-101'
``` ```
@@ -186,4 +201,4 @@ la línea de comandos. Para empaquetar recursos institucionales adicionales:
-ServerContentPath C:\Preparacion\Packages -ServerContentPath C:\Preparacion\Packages
``` ```
`SHA256SUMS-VERSION.txt` permite comprobar los tres ZIP antes de usarlos. `SHA256SUMS-VERSION.txt` permite comprobar los cuatro ZIP antes de usarlos.
+18 -8
View File
@@ -4,7 +4,7 @@ Para una instalación limpia de Windows se prefiere el único punto de entrada
empaquetado: empaquetado:
```bat ```bat
Start-SguClientEnrollment.cmd 192.168.50.10 Start-SguClientEnrollment.cmd 192.168.50.10 192.168.50.11
``` ```
Este comando realiza el intercambio de certificados descrito abajo sin mover Este comando realiza el intercambio de certificados descrito abajo sin mover
@@ -12,6 +12,13 @@ una clave privada y luego ejecuta la transacción proveedor-primero. Las
instrucciones completas están en instrucciones completas están en
[`bootstrap-recovery.md`](bootstrap-recovery.md). [`bootstrap-recovery.md`](bootstrap-recovery.md).
El primer argumento es la IP fija del controlador; el segundo es una IP fija y
única para el cliente en la misma subred. Si el segundo se omite y la NIC
privada no tiene una IP válida, se solicita en pantalla. El bootstrap prefiere
la única NIC activa sin puerta de enlace para no reemplazar el adaptador de
Internet. Ante cualquier error conserva la ventana y escribe el diagnóstico en
`C:\ProgramData\SGU\Bootstrap\Client\latest-error.log`.
El flujo administrado instala y valida el Credential Provider **antes** de El flujo administrado instala y valida el Credential Provider **antes** de
ejecutar `Add-Computer`. La pertenencia al dominio es el último cambio; si falta ejecutar `Add-Computer`. La pertenencia al dominio es el último cambio; si falta
el runtime, un certificado, el registro COM, la directiva predeterminada o la el runtime, un certificado, el registro COM, la directiva predeterminada o la
@@ -72,11 +79,13 @@ Orden de la transacción:
1. instala .NET y los binarios versionados; 1. instala .NET y los binarios versionados;
2. registra COM, configura SGU como proveedor predeterminado y oculta el 2. registra COM, configura SGU como proveedor predeterminado y oculta el
último usuario que cerró sesión; último usuario que cerró sesión;
3. instala el guard de autorreparación; 3. crea o actualiza la cuenta local estándar `alumno` con la contraseña
4. exige health mTLS del broker y ejecuta las comprobaciones locales; `ingenieria`, la habilita y garantiza que no pertenezca a Administradores;
5. configura DNS del dominio; 4. instala el guard de autorreparación;
6. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` y reinicia; 5. exige health mTLS del broker y ejecuta las comprobaciones locales;
7. al arrancar, el guard habilita RDP, NLA y WinRM y comprueba el estado final. 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 La directiva de Windows **Assign a default credential provider** selecciona SGU
por defecto. El instalador también habilita **Interactive logon: Don't display por defecto. El instalador también habilita **Interactive logon: Don't display
@@ -138,8 +147,9 @@ Start-ScheduledTask -TaskName SGU-CredentialProvider-EnrollmentGuard
Un resultado válido exige simultáneamente binario y registro COM, configuración, Un resultado válido exige simultáneamente binario y registro COM, configuración,
certificados, .NET 10, proveedor SGU predeterminado, último usuario oculto, certificados, .NET 10, proveedor SGU predeterminado, último usuario oculto,
enumeración local deshabilitada y proveedor de contraseña de Microsoft enumeración local deshabilitada, cuenta local estándar `alumno` habilitada y
preservado. El script de reparación se encuentra en 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 `C:\ProgramData\SGU\Enrollment` con ACL exclusiva para `SYSTEM` y
administradores. administradores.
+17 -5
View File
@@ -1,5 +1,14 @@
# Security model # Security model
## Public Azure deployment
Owning a public Azure IP does not make the domain controller an Internet-facing
directory service. The supported cloud topology exposes no AD DS, DNS, SMB,
RPC, WinRM, broker, monitoring, or RustDesk port publicly. Hyper-V and later
physical Windows clients enter the VNet through certificate-authenticated Azure
VPN Gateway P2S; the Azure NSG and Windows firewall accept the P2S pool and the
private VNet only. See [azure-vpn-deployment.md](azure-vpn-deployment.md).
## Password handling ## Password handling
- The Credential Provider receives the password in Lithnet's secure password - The Credential Provider receives the password in Lithnet's secure password
@@ -33,15 +42,18 @@
- Administrative enrichment first verifies the employee number and reads - Administrative enrichment first verifies the employee number and reads
employee type/status, email, job title, and department from the incident employee type/status, email, job title, and department from the incident
overview. Only after that match, it reads given names and paternal/maternal overview. Only after that match, it reads given names and paternal/maternal
surnames from the personal page plus street, exterior/interior number, surnames and the normalized `Male`/`Female` value from the personal page plus
neighborhood, locality, state, and postal code from the location page. street, exterior/interior number, neighborhood, locality, state, and postal
- Administrative birth date, RFC, CURP, sex, blood type, marital status, code from the location page. AD stores only the controlled `SGU-Gender` line,
not the original HTML field.
- Administrative birth date, RFC, CURP, blood type, marital status,
nationality, telephone, email lists, housing type, and emergency-contact nationality, telephone, email lists, housing type, and emergency-contact
fields are ignored. fields are ignored.
- Student enrichment reads only the matching student number, given names, - Student enrichment reads only the matching student number, given names,
paternal/maternal surnames, email, career, street, neighborhood, paternal/maternal surnames, email, career, street, neighborhood,
city/municipality, state, and postal code from known element IDs. city/municipality, state, postal code, and normalized sex from known element
- Student CURP, birth date, sex, blood type, marital status, telephone, mobile, IDs.
- Student CURP, birth date, blood type, marital status, telephone, mobile,
guardian, medical, financial, and academic-history values are ignored. guardian, medical, financial, and academic-history values are ignored.
- Professor enrichment keeps the menu display name as its base. From the payroll - Professor enrichment keeps the menu display name as its base. From the payroll
consultation header it reads only a matching employee number, name, email, consultation header it reads only a matching employee number, name, email,
+8 -2
View File
@@ -16,14 +16,20 @@ El generador consulta Active Directory con la identidad ya autenticada y sin
guardar credenciales. Obtiene: guardar credenciales. Obtiene:
- `displayName` del usuario; si falta, utiliza `sAMAccountName`. - `displayName` del usuario; si falta, utiliza `sAMAccountName`.
- La línea administrada `SGU-Gender: Male|Female` del atributo `info`; el Auth
Broker la obtiene del SGU y conserva cualquier otra nota que ya exista.
- `location` del objeto de equipo. - `location` del objeto de equipo.
- La OU padre inmediata a partir de `distinguishedName`. - La OU padre inmediata a partir de `distinguishedName`.
El texto secundario sigue estas reglas: El saludo usa `Bienvenido/ubicado` para `Male` y `Bienvenida/ubicada` para
`Female`. Cuando el enriquecimiento no produjo este dato, utiliza la redacción
neutral `Te damos la bienvenida` y `Ubicación:`. El texto secundario sigue estas
reglas:
1. Con `location` y OU: `Estás ubicado en la Sala de Inmersión del Centro de Experiencia Digital.` 1. Con `location` y OU: `Estás ubicado en la Sala de Inmersión del Centro de Experiencia Digital.`
2. Con sólo uno de los datos: muestra únicamente el dato disponible. 2. Con sólo uno de los datos: muestra únicamente el dato disponible.
3. Sin ambos: `Bienvenido al Laboratorio de Cómputo de Ingeniería.` 3. Sin ambos: el texto adaptado `Bienvenido/Bienvenida al Laboratorio...`; sin
sexo disponible, la forma neutral `Acceso al Laboratorio de Cómputo de Ingeniería.`
La ausencia de AD, de un atributo o de una tipografía nunca bloquea la sesión. La ausencia de AD, de un atributo o de una tipografía nunca bloquea la sesión.
Los errores de generación se registran en Los errores de generación se registran en
+323
View File
@@ -0,0 +1,323 @@
targetScope = 'resourceGroup'
@description('Short prefix used for every Azure resource.')
@minLength(3)
@maxLength(18)
param deploymentPrefix string = 'sgu-lab'
@description('Azure region for the virtual network, gateway, and VM.')
param location string = resourceGroup().location
@description('Windows Server VM administrator name. This must not be Administrator.')
@minLength(1)
@maxLength(20)
param administratorUsername string
@secure()
@description('Windows Server VM administrator password.')
param administratorPassword string
@description('Windows Server computer name; Active Directory limits this to 15 characters.')
@minLength(1)
@maxLength(15)
param computerName string = 'SGU-DC01'
@description('VM size for the Windows Server 2025 domain controller.')
param vmSize string = 'Standard_D2s_v5'
@description('Address space assigned to the Azure virtual network.')
param virtualNetworkAddressPrefix string = '10.77.0.0/16'
@description('Subnet that contains the domain controller.')
param domainControllerSubnetPrefix string = '10.77.0.0/24'
@description('Reserved Azure VPN Gateway subnet. Use /27 or larger.')
param gatewaySubnetPrefix string = '10.77.255.0/27'
@description('Static private IP reserved on the Azure NIC for AD DS and DNS.')
param domainControllerPrivateIp string = '10.77.0.4'
@description('Point-to-site client pool. It must not overlap the VNet or local Hyper-V networks.')
param vpnClientAddressPoolPrefix string = '172.30.0.0/24'
@description('Name presented for the trusted P2S root certificate.')
param p2sRootCertificateName string = 'SGU-P2S-Root'
@description('Base64 DER bytes of the trusted P2S root certificate, without PEM markers.')
param p2sRootCertificateData string
@description('Optional public CIDR allowed to RDP to the VM public IP, for example 203.0.113.10/32. Leave empty to expose no management port.')
param administratorSourceAddressPrefix string = ''
var virtualNetworkName = '${deploymentPrefix}-vnet'
var domainControllerSubnetName = 'DomainControllers'
var gatewaySubnetName = 'GatewaySubnet'
var networkSecurityGroupName = '${deploymentPrefix}-dc-nsg'
var domainControllerPublicIpName = '${deploymentPrefix}-dc-pip'
var gatewayPublicIpName = '${deploymentPrefix}-vpngw-pip'
var networkInterfaceName = '${deploymentPrefix}-dc-nic'
var virtualMachineName = '${deploymentPrefix}-dc'
var virtualNetworkGatewayName = '${deploymentPrefix}-vpngw'
resource networkSecurityGroup 'Microsoft.Network/networkSecurityGroups@2024-05-01' = {
name: networkSecurityGroupName
location: location
properties: {
securityRules: concat([
{
name: 'Allow-SGU-P2S-clients'
properties: {
priority: 100
access: 'Allow'
direction: 'Inbound'
protocol: '*'
sourcePortRange: '*'
destinationPortRange: '*'
sourceAddressPrefix: vpnClientAddressPoolPrefix
destinationAddressPrefix: domainControllerPrivateIp
description: 'AD, DNS, broker, monitoring, and RustDesk are reachable only through the authenticated P2S address pool.'
}
}
], empty(administratorSourceAddressPrefix) ? [] : [
{
name: 'Allow-RDP-from-administrator'
properties: {
priority: 110
access: 'Allow'
direction: 'Inbound'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRange: '3389'
sourceAddressPrefix: administratorSourceAddressPrefix
destinationAddressPrefix: domainControllerPrivateIp
description: 'Optional bootstrap-only RDP access from one explicitly supplied public CIDR.'
}
}
])
}
}
resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' = {
name: virtualNetworkName
location: location
properties: {
addressSpace: {
addressPrefixes: [
virtualNetworkAddressPrefix
]
}
subnets: [
{
name: domainControllerSubnetName
properties: {
addressPrefix: domainControllerSubnetPrefix
networkSecurityGroup: {
id: networkSecurityGroup.id
}
}
}
{
name: gatewaySubnetName
properties: {
addressPrefix: gatewaySubnetPrefix
}
}
]
}
}
resource domainControllerPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = {
name: domainControllerPublicIpName
location: location
sku: {
name: 'Standard'
}
properties: {
publicIPAllocationMethod: 'Static'
publicIPAddressVersion: 'IPv4'
idleTimeoutInMinutes: 30
}
}
resource gatewayPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = {
name: gatewayPublicIpName
location: location
sku: {
name: 'Standard'
}
properties: {
publicIPAllocationMethod: 'Static'
publicIPAddressVersion: 'IPv4'
}
}
resource networkInterface 'Microsoft.Network/networkInterfaces@2024-05-01' = {
name: networkInterfaceName
location: location
properties: {
enableAcceleratedNetworking: true
dnsSettings: {
dnsServers: [
domainControllerPrivateIp
]
}
ipConfigurations: [
{
name: 'ipconfig1'
properties: {
privateIPAllocationMethod: 'Static'
privateIPAddressVersion: 'IPv4'
privateIPAddress: domainControllerPrivateIp
subnet: {
id: resourceId('Microsoft.Network/virtualNetworks/subnets', virtualNetworkName, domainControllerSubnetName)
}
publicIPAddress: {
id: domainControllerPublicIp.id
}
}
}
]
}
dependsOn: [
virtualNetwork
]
}
resource virtualMachine 'Microsoft.Compute/virtualMachines@2024-07-01' = {
name: virtualMachineName
location: location
identity: {
type: 'SystemAssigned'
}
properties: {
hardwareProfile: {
vmSize: vmSize
}
securityProfile: {
securityType: 'TrustedLaunch'
uefiSettings: {
secureBootEnabled: true
vTpmEnabled: true
}
}
osProfile: {
computerName: computerName
adminUsername: administratorUsername
adminPassword: administratorPassword
windowsConfiguration: {
provisionVMAgent: true
enableAutomaticUpdates: true
patchSettings: {
patchMode: 'AutomaticByPlatform'
assessmentMode: 'AutomaticByPlatform'
enableHotpatching: false
}
}
}
storageProfile: {
imageReference: {
publisher: 'MicrosoftWindowsServer'
offer: 'WindowsServer'
sku: '2025-datacenter-azure-edition'
version: 'latest'
}
osDisk: {
createOption: 'FromImage'
managedDisk: {
storageAccountType: 'Premium_LRS'
}
deleteOption: 'Delete'
}
}
networkProfile: {
networkInterfaces: [
{
id: networkInterface.id
properties: {
primary: true
deleteOption: 'Delete'
}
}
]
}
diagnosticsProfile: {
bootDiagnostics: {
enabled: true
}
}
}
}
resource virtualNetworkGateway 'Microsoft.Network/virtualNetworkGateways@2024-05-01' = {
name: virtualNetworkGatewayName
location: location
properties: {
gatewayType: 'Vpn'
vpnType: 'RouteBased'
activeActive: false
enableBgp: false
ipConfigurations: [
{
name: 'gateway-ipconfig'
properties: {
privateIPAllocationMethod: 'Dynamic'
subnet: {
id: resourceId('Microsoft.Network/virtualNetworks/subnets', virtualNetworkName, gatewaySubnetName)
}
publicIPAddress: {
id: gatewayPublicIp.id
}
}
}
]
sku: {
name: 'VpnGw1'
tier: 'VpnGw1'
}
vpnClientConfiguration: {
vpnClientAddressPool: {
addressPrefixes: [
vpnClientAddressPoolPrefix
]
}
vpnClientProtocols: [
'IkeV2'
'SSTP'
]
vpnAuthenticationTypes: [
'Certificate'
]
vpnClientRootCertificates: [
{
name: p2sRootCertificateName
properties: {
publicCertData: p2sRootCertificateData
}
}
]
}
}
dependsOn: [
virtualNetwork
]
}
output domainControllerName string = virtualMachine.name
output domainControllerPrivateIp string = domainControllerPrivateIp
output domainControllerPublicIp string = domainControllerPublicIp.properties.ipAddress
output virtualNetworkName string = virtualNetwork.name
output virtualNetworkAddressPrefix string = virtualNetworkAddressPrefix
output vpnGatewayName string = virtualNetworkGateway.name
output vpnClientAddressPoolPrefix string = vpnClientAddressPoolPrefix
output serverBootstrapArguments array = [
'-ServerIPv4Address'
domainControllerPrivateIp
'-PrefixLength'
last(split(domainControllerSubnetPrefix, '/'))
'-NetworkConfigurationMode'
'PlatformManaged'
'-TrustedClientNetworks'
vpnClientAddressPoolPrefix
'-DnsForwarders'
'168.63.129.16'
]
+28 -4
View File
@@ -76,16 +76,23 @@ if (-not $serverCertificate.Verify()) {
} }
Import-Module ActiveDirectory -ErrorAction Stop 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' $usersOuName = 'Usuarios-SGU'
$usersOuDn = "OU=$usersOuName,$BaseDn" $usersOuDn = "OU=$usersOuName,$BaseDn"
if ([string]::IsNullOrWhiteSpace($ProfessorGroupDn)) { if ([string]::IsNullOrWhiteSpace($ProfessorGroupDn)) {
$ProfessorGroupDn = "CN=SGU-Docentes,$usersOuDn" $ProfessorGroupDn = "CN=SGU-Docentes,OU=Docentes,$usersOuDn"
} }
if ([string]::IsNullOrWhiteSpace($StudentGroupDn)) { if ([string]::IsNullOrWhiteSpace($StudentGroupDn)) {
$StudentGroupDn = "CN=SGU-Alumnos,$usersOuDn" $StudentGroupDn = "CN=SGU-Alumnos,OU=Alumnos,$usersOuDn"
} }
if ([string]::IsNullOrWhiteSpace($AdministrativeGroupDn)) { if ([string]::IsNullOrWhiteSpace($AdministrativeGroupDn)) {
$AdministrativeGroupDn = "CN=SGU-Administrativos,$usersOuDn" $AdministrativeGroupDn = "CN=SGU-Administrativos,OU=Administrativos,$usersOuDn"
} }
if ($CreateMissingOus) { if ($CreateMissingOus) {
@@ -152,13 +159,30 @@ foreach ($definition in $roleGroupDefinitions) {
throw "$($definition.Role)GroupDn must start with a simple CN component." throw "$($definition.Role)GroupDn must start with a simple CN component."
} }
$groupName = $groupDnMatch.Groups['Name'].Value $groupName = $groupDnMatch.Groups['Name'].Value
$groupPath = $groupDnMatch.Groups['Path'].Value
if ($groupName.Length -gt 20) { if ($groupName.Length -gt 20) {
throw "$($definition.Role) group name exceeds the 20-character sAMAccountName limit." 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 ` New-ADGroup -Name $groupName -SamAccountName $groupName `
-GroupCategory Security -GroupScope Global ` -GroupCategory Security -GroupScope Global `
-Path $groupDnMatch.Groups['Path'].Value ` -Path $groupPath `
-Description $definition.Description -Server $LdapHost | Out-Null -Description $definition.Description -Server $LdapHost | Out-Null
}
$roleGroup = Get-ADGroup -Identity $definition.Dn -Server $LdapHost -ErrorAction Stop $roleGroup = Get-ADGroup -Identity $definition.Dn -Server $LdapHost -ErrorAction Stop
} }
if (-not $roleGroup) { if (-not $roleGroup) {
+140
View File
@@ -0,0 +1,140 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][string]$SubscriptionId,
[string]$ResourceGroupName = 'rg-sgu-lab',
[string]$Location = 'centralus',
[string]$DeploymentPrefix = 'sgu-lab',
[Parameter(Mandatory)][string]$AdministratorUsername,
[securestring]$AdministratorPassword,
[Parameter(Mandatory)][string]$P2sRootCertificatePath,
[string]$ComputerName = 'SGU-DC01',
[string]$VmSize = 'Standard_D2s_v5',
[string]$VirtualNetworkAddressPrefix = '10.77.0.0/16',
[string]$DomainControllerSubnetPrefix = '10.77.0.0/24',
[ipaddress]$DomainControllerPrivateIp = '10.77.0.4',
[string]$GatewaySubnetPrefix = '10.77.255.0/27',
[string]$VpnClientAddressPoolPrefix = '172.30.0.0/24',
[string]$AdministratorSourceAddressPrefix = '',
[string]$TemplateFile = (Join-Path $PSScriptRoot '..\infra\azure\main.bicep')
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
throw 'Azure CLI is required. Install it from https://aka.ms/installazurecliwindows and run az login.'
}
if (-not (Test-Path -LiteralPath $TemplateFile -PathType Leaf)) {
throw "Azure Bicep template not found: $TemplateFile"
}
if (-not (Test-Path -LiteralPath $P2sRootCertificatePath -PathType Leaf)) {
throw "P2S root certificate not found: $P2sRootCertificatePath"
}
if (-not $AdministratorPassword) {
$AdministratorPassword = Read-Host 'Password for the local Azure VM administrator' -AsSecureString
}
$rootCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new(
(Resolve-Path -LiteralPath $P2sRootCertificatePath).Path)
if (-not ($rootCertificate.Extensions | Where-Object {
$_.Oid -and $_.Oid.Value -eq '2.5.29.19' -and $_.Format($false) -match 'CA' })) {
throw 'P2sRootCertificatePath must contain a certificate-authority certificate.'
}
$rootCertificateData = [Convert]::ToBase64String($rootCertificate.RawData)
$account = & az account show --output json 2>$null
if ($LASTEXITCODE -ne 0) {
throw 'Azure CLI is not signed in. Run az login, then retry.'
}
& az account set --subscription $SubscriptionId --only-show-errors
if ($LASTEXITCODE -ne 0) {
throw "Could not select Azure subscription $SubscriptionId."
}
if ($PSCmdlet.ShouldProcess("$ResourceGroupName in $Location", 'Create Azure VNet, Windows Server 2025 VM, public IP, and P2S VPN Gateway')) {
& az group create --name $ResourceGroupName --location $Location --only-show-errors --output none
if ($LASTEXITCODE -ne 0) {
throw "Could not create or update resource group $ResourceGroupName."
}
$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ("sgu-azure-" + [Guid]::NewGuid().ToString('N'))
$parametersPath = Join-Path $temporaryRoot 'parameters.json'
$passwordPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($AdministratorPassword)
try {
New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null
$acl = Get-Acl -LiteralPath $temporaryRoot
$acl.SetAccessRuleProtection($true, $false)
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
[Security.Principal.WindowsIdentity]::GetCurrent().User,
[Security.AccessControl.FileSystemRights]::FullControl,
[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit',
[Security.AccessControl.PropagationFlags]::None,
[Security.AccessControl.AccessControlType]::Allow))
Set-Acl -LiteralPath $temporaryRoot -AclObject $acl
$plainPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($passwordPointer)
$parameters = [ordered]@{
'$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#'
contentVersion = '1.0.0.0'
parameters = [ordered]@{
deploymentPrefix = @{ value = $DeploymentPrefix }
location = @{ value = $Location }
administratorUsername = @{ value = $AdministratorUsername }
administratorPassword = @{ value = $plainPassword }
computerName = @{ value = $ComputerName }
vmSize = @{ value = $VmSize }
virtualNetworkAddressPrefix = @{ value = $VirtualNetworkAddressPrefix }
domainControllerSubnetPrefix = @{ value = $DomainControllerSubnetPrefix }
gatewaySubnetPrefix = @{ value = $GatewaySubnetPrefix }
domainControllerPrivateIp = @{ value = $DomainControllerPrivateIp.IPAddressToString }
vpnClientAddressPoolPrefix = @{ value = $VpnClientAddressPoolPrefix }
p2sRootCertificateData = @{ value = $rootCertificateData }
administratorSourceAddressPrefix = @{ value = $AdministratorSourceAddressPrefix }
}
}
[IO.File]::WriteAllText(
$parametersPath,
($parameters | ConvertTo-Json -Depth 8),
[Text.UTF8Encoding]::new($false))
$plainPassword = $null
$parameters.parameters.administratorPassword.value = $null
$deploymentName = 'sgu-{0}' -f (Get-Date -Format 'yyyyMMdd-HHmmss')
$deploymentOutput = & az deployment group create `
--name $deploymentName `
--resource-group $ResourceGroupName `
--template-file (Resolve-Path -LiteralPath $TemplateFile).Path `
--parameters "@$parametersPath" `
--only-show-errors `
--output json
if ($LASTEXITCODE -ne 0) {
throw 'Azure deployment failed. Review the Azure CLI error above; no bootstrap credential was persisted by this script.'
}
$deployment = ($deploymentOutput -join [Environment]::NewLine) | ConvertFrom-Json
}
finally {
if ($passwordPointer -ne [IntPtr]::Zero) {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($passwordPointer)
}
$AdministratorPassword = $null
if ($temporaryRoot -and (Test-Path -LiteralPath $temporaryRoot)) {
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
$values = @{}
foreach ($property in $deployment.properties.outputs.PSObject.Properties) {
$values[$property.Name] = $property.Value.value
}
[pscustomobject]@{
ResourceGroupName = $ResourceGroupName
DeploymentName = $deploymentName
DomainControllerName = $values.domainControllerName
DomainControllerPrivateIp = $values.domainControllerPrivateIp
DomainControllerPublicIp = $values.domainControllerPublicIp
VpnGatewayName = $values.vpnGatewayName
VpnClientAddressPoolPrefix = $values.vpnClientAddressPoolPrefix
ServerBootstrapArguments = $values.serverBootstrapArguments
}
}
+19
View File
@@ -22,6 +22,8 @@ param(
[string]$NewComputerName, [string]$NewComputerName,
[string]$NetworkInterfaceAlias = 'Ethernet', [string]$NetworkInterfaceAlias = 'Ethernet',
[string[]]$DomainDnsServerAddresses = @('192.168.50.10'), [string[]]$DomainDnsServerAddresses = @('192.168.50.10'),
[ValidateSet('Direct', 'AzureP2S')]
[string]$ConnectivityMode = 'Direct',
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP', [string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
[string]$DotNetRuntimeInstallerPath, [string]$DotNetRuntimeInstallerPath,
[string]$RustDeskServerAddress, [string]$RustDeskServerAddress,
@@ -39,6 +41,7 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra
foreach ($scriptName in @( foreach ($scriptName in @(
'Install-CredentialProvider.ps1', 'Install-CredentialProvider.ps1',
'Install-SguEnrollmentGuard.ps1', 'Install-SguEnrollmentGuard.ps1',
'Set-SguStandardLocalUser.ps1',
'Test-SguClientEnrollment.ps1', 'Test-SguClientEnrollment.ps1',
'Repair-SguClientEnrollment.ps1', 'Repair-SguClientEnrollment.ps1',
'Enable-LabRemoteAccess.ps1', 'Enable-LabRemoteAccess.ps1',
@@ -90,9 +93,21 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
# The broker uses a domain DNS name even before the machine joins the # The broker uses a domain DNS name even before the machine joins the
# domain. Point at AD DNS first so the provider-first health check works on # domain. Point at AD DNS first so the provider-first health check works on
# a completely clean Windows installation. # a completely clean Windows installation.
if ($ConnectivityMode -eq 'Direct') {
Set-DnsClientServerAddress ` Set-DnsClientServerAddress `
-InterfaceAlias $NetworkInterfaceAlias ` -InterfaceAlias $NetworkInterfaceAlias `
-ServerAddresses $DomainDnsServerAddresses -ServerAddresses $DomainDnsServerAddresses
}
else {
$nrptDisplayName = "SGU Azure P2S DNS - $DomainName"
$nrptRule = Get-DnsClientNrptRule -ErrorAction SilentlyContinue |
Where-Object DisplayName -eq $nrptDisplayName |
Select-Object -First 1
if (-not $nrptRule -or
@($DomainDnsServerAddresses | Where-Object { @($nrptRule.NameServers) -contains $_ }).Count -eq 0) {
throw "AzureP2S enrollment requires the managed NRPT rule '$nrptDisplayName'. Run Install-SguAzureP2sClient.ps1 first."
}
}
Resolve-DnsName -Type SRV "_ldap._tcp.dc._msdcs.$DomainName" -ErrorAction Stop | Out-Null Resolve-DnsName -Type SRV "_ldap._tcp.dc._msdcs.$DomainName" -ErrorAction Stop | Out-Null
& (Join-Path $PSScriptRoot 'Install-CredentialProvider.ps1') @installParams | Out-Null & (Join-Path $PSScriptRoot 'Install-CredentialProvider.ps1') @installParams | Out-Null
@@ -101,6 +116,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
-ServerAddress $RustDeskServerAddress ` -ServerAddress $RustDeskServerAddress `
-ServerPublicKey $RustDeskServerPublicKey -ServerPublicKey $RustDeskServerPublicKey
} }
$localStudentUser = & (Join-Path $PSScriptRoot 'Set-SguStandardLocalUser.ps1')
& (Join-Path $PSScriptRoot 'Install-SguEnrollmentGuard.ps1') @guardParams | Out-Null & (Join-Path $PSScriptRoot 'Install-SguEnrollmentGuard.ps1') @guardParams | Out-Null
$testParameters = @{ RequireBrokerHealth = $true } $testParameters = @{ RequireBrokerHealth = $true }
@@ -128,6 +144,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
$postJoinParameters.RustDeskServerAddress = $RustDeskServerAddress $postJoinParameters.RustDeskServerAddress = $RustDeskServerAddress
} }
$postJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @postJoinParameters $postJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @postJoinParameters
$postJoin | Add-Member -NotePropertyName StandardLocalUser -NotePropertyValue $localStudentUser
$postJoin | Add-Member -NotePropertyName RustDesk -NotePropertyValue $rustDeskResult $postJoin | Add-Member -NotePropertyName RustDesk -NotePropertyValue $rustDeskResult
return $postJoin return $postJoin
} }
@@ -159,7 +176,9 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
[pscustomobject]@{ [pscustomobject]@{
ComputerName = if ($NewComputerName) { $NewComputerName } else { $env:COMPUTERNAME } ComputerName = if ($NewComputerName) { $NewComputerName } else { $env:COMPUTERNAME }
DomainName = $DomainName DomainName = $DomainName
ConnectivityMode = $ConnectivityMode
ProviderValidatedBeforeJoin = $true ProviderValidatedBeforeJoin = $true
StandardLocalUser = $localStudentUser
RustDesk = $rustDeskResult RustDesk = $rustDeskResult
RestartRequired = [bool]$SkipRestart RestartRequired = [bool]$SkipRestart
} }
+48
View File
@@ -0,0 +1,48 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][string]$SubscriptionId,
[Parameter(Mandatory)][string]$ResourceGroupName,
[Parameter(Mandatory)][string]$VpnGatewayName,
[string]$OutputPath = (Join-Path $PSScriptRoot '..\artifacts\azure-p2s\sgu-azure-vpn-client.zip')
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
throw 'Azure CLI is required.'
}
& az account set --subscription $SubscriptionId --only-show-errors
if ($LASTEXITCODE -ne 0) {
throw "Could not select Azure subscription $SubscriptionId."
}
if ($PSCmdlet.ShouldProcess($OutputPath, 'Generate and download the Azure P2S client package')) {
$downloadUriText = & az network vnet-gateway vpn-client generate `
--resource-group $ResourceGroupName `
--name $VpnGatewayName `
--processor-architecture Amd64 `
--authentication-method EAPTLS `
--only-show-errors `
--output tsv
$downloadUriText = ($downloadUriText -join '').Trim()
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($downloadUriText)) {
throw 'Azure did not generate a P2S client package URL.'
}
$downloadUri = $null
if (-not [uri]::TryCreate($downloadUriText, [UriKind]::Absolute, [ref]$downloadUri) -or
$downloadUri.Scheme -ne 'https') {
throw 'Azure returned an invalid VPN client package URL.'
}
$resolvedOutputPath = [IO.Path]::GetFullPath($OutputPath)
New-Item -ItemType Directory -Path (Split-Path $resolvedOutputPath -Parent) -Force | Out-Null
Invoke-WebRequest -Uri $downloadUri -OutFile $resolvedOutputPath -UseBasicParsing
if ((Get-Item -LiteralPath $resolvedOutputPath).Length -lt 1024) {
throw 'The downloaded VPN client package is unexpectedly small.'
}
[pscustomobject]@{
PackagePath = $resolvedOutputPath
Sha256 = (Get-FileHash -LiteralPath $resolvedOutputPath -Algorithm SHA256).Hash
VpnGatewayName = $VpnGatewayName
}
}
+125 -7
View File
@@ -6,6 +6,9 @@ param(
[int]$PrefixLength = 24, [int]$PrefixLength = 24,
[string]$NetworkInterfaceAlias, [string]$NetworkInterfaceAlias,
[ipaddress]$DefaultGateway, [ipaddress]$DefaultGateway,
[ValidateSet('GuestStatic', 'PlatformManaged')]
[string]$NetworkConfigurationMode = 'GuestStatic',
[string[]]$TrustedClientNetworks = @(),
[ipaddress[]]$DnsForwarders = @(), [ipaddress[]]$DnsForwarders = @(),
[string]$DomainName = 'lci.lasalle.mx', [string]$DomainName = 'lci.lasalle.mx',
[string]$DomainNetbios = 'LCI', [string]$DomainNetbios = 'LCI',
@@ -69,6 +72,67 @@ function Get-DomainBaseDn {
return (($DnsDomainName -split '\.') | ForEach-Object { "DC=$_" }) -join ',' return (($DnsDomainName -split '\.') | ForEach-Object { "DC=$_" }) -join ','
} }
function Test-PrivateIPv4Address {
param([Parameter(Mandatory)][ipaddress]$Address)
if ($Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
return $false
}
$bytes = $Address.GetAddressBytes()
return $bytes[0] -eq 10 -or
($bytes[0] -eq 172 -and $bytes[1] -ge 16 -and $bytes[1] -le 31) -or
($bytes[0] -eq 192 -and $bytes[1] -eq 168)
}
function ConvertTo-NetworkCidr {
param(
[Parameter(Mandatory)][ipaddress]$Address,
[Parameter(Mandatory)][ValidateRange(1, 32)][int]$NetworkPrefixLength
)
if ($Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
throw 'Only IPv4 networks are supported by the SGU bootstrap.'
}
$addressBytes = $Address.GetAddressBytes()
$networkBytes = [byte[]]::new(4)
$remainingBits = $NetworkPrefixLength
for ($index = 0; $index -lt 4; $index++) {
$mask = if ($remainingBits -ge 8) {
255
}
elseif ($remainingBits -le 0) {
0
}
else {
256 - [Math]::Pow(2, 8 - $remainingBits)
}
$networkBytes[$index] = [byte]($addressBytes[$index] -band [int]$mask)
$remainingBits -= 8
}
return "$(($networkBytes | ForEach-Object { [string]$_ }) -join '.')/$NetworkPrefixLength"
}
function ConvertTo-PrivateNetworkCidr {
param([Parameter(Mandatory)][string]$Cidr)
if ($Cidr -notmatch '^([^/]+)/(\d{1,2})$') {
throw "Trusted client network '$Cidr' must use IPv4 CIDR notation, for example 172.30.0.0/24."
}
$address = $null
if (-not [ipaddress]::TryParse($Matches[1], [ref]$address) -or
$address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
throw "Trusted client network '$Cidr' is not a valid IPv4 network."
}
$networkPrefixLength = [int]$Matches[2]
if ($networkPrefixLength -lt 1 -or $networkPrefixLength -gt 32) {
throw "Trusted client network '$Cidr' has an invalid prefix length."
}
if (-not (Test-PrivateIPv4Address -Address $address)) {
throw "Trusted client network '$Cidr' is not private RFC1918 space. The bootstrap never exposes AD services to public client addresses."
}
return ConvertTo-NetworkCidr -Address $address -NetworkPrefixLength $networkPrefixLength
}
function Resolve-PrivateInterfaceAlias { function Resolve-PrivateInterfaceAlias {
param([string]$RequestedAlias) param([string]$RequestedAlias)
@@ -147,6 +211,27 @@ function Set-StaticDomainAddress {
-ServerAddresses $Address.IPAddressToString -ServerAddresses $Address.IPAddressToString
} }
function Assert-PlatformManagedDomainAddress {
param(
[Parameter(Mandatory)][string]$InterfaceAlias,
[Parameter(Mandatory)][ipaddress]$Address,
[Parameter(Mandatory)][int]$NetworkPrefixLength
)
$adapter = Get-NetAdapter -Name $InterfaceAlias -ErrorAction Stop
$matchingAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
-IPAddress $Address.IPAddressToString -ErrorAction SilentlyContinue |
Where-Object PrefixLength -eq $NetworkPrefixLength |
Select-Object -First 1
if (-not $matchingAddress) {
$observed = @(Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
-ErrorAction SilentlyContinue |
Where-Object PrefixOrigin -ne 'WellKnown' |
ForEach-Object { "$($_.IPAddress)/$($_.PrefixLength)" }) -join ', '
throw "PlatformManaged mode expected $Address/$NetworkPrefixLength on $InterfaceAlias, but found: $observed. Configure a static private IP on the Azure NIC before running the bootstrap; do not assign it inside Windows."
}
}
function Register-ResumeTask { function Register-ResumeTask {
param([Parameter(Mandatory)][string]$ScriptPath) param([Parameter(Mandatory)][string]$ScriptPath)
@@ -310,6 +395,8 @@ if ($Resume -or (-not $ServerIPv4Address -and $existingState)) {
$PrefixLength = [int]$existingState.PrefixLength $PrefixLength = [int]$existingState.PrefixLength
$NetworkInterfaceAlias = [string]$existingState.NetworkInterfaceAlias $NetworkInterfaceAlias = [string]$existingState.NetworkInterfaceAlias
$DefaultGateway = if ($existingState.DefaultGateway) { [ipaddress][string]$existingState.DefaultGateway } else { $null } $DefaultGateway = if ($existingState.DefaultGateway) { [ipaddress][string]$existingState.DefaultGateway } else { $null }
$NetworkConfigurationMode = if ($existingState.NetworkConfigurationMode) { [string]$existingState.NetworkConfigurationMode } else { 'GuestStatic' }
$TrustedClientNetworks = if ($existingState.TrustedClientNetworks) { @($existingState.TrustedClientNetworks | ForEach-Object { [string]$_ }) } else { @() }
$DnsForwarders = @($existingState.DnsForwarders | ForEach-Object { [ipaddress][string]$_ }) $DnsForwarders = @($existingState.DnsForwarders | ForEach-Object { [ipaddress][string]$_ })
$DomainName = [string]$existingState.DomainName $DomainName = [string]$existingState.DomainName
$DomainNetbios = [string]$existingState.DomainNetbios $DomainNetbios = [string]$existingState.DomainNetbios
@@ -321,6 +408,16 @@ if ($Resume -or (-not $ServerIPv4Address -and $existingState)) {
if (-not $ServerIPv4Address) { if (-not $ServerIPv4Address) {
$ServerIPv4Address = [ipaddress](Read-Host 'Fixed IPv4 address for this domain controller') $ServerIPv4Address = [ipaddress](Read-Host 'Fixed IPv4 address for this domain controller')
} }
if (-not (Test-PrivateIPv4Address -Address $ServerIPv4Address)) {
throw 'ServerIPv4Address must be the private address of the domain controller. An Azure public IP is never assigned to AD or published in domain DNS.'
}
$domainSubnet = ConvertTo-NetworkCidr -Address $ServerIPv4Address `
-NetworkPrefixLength $PrefixLength
$TrustedClientNetworks = @($TrustedClientNetworks |
ForEach-Object { ConvertTo-PrivateNetworkCidr -Cidr $_ } |
Where-Object { $_ -ne $domainSubnet } |
Select-Object -Unique)
$allowedRemoteAddresses = @($domainSubnet) + $TrustedClientNetworks
$sourceRoot = $PSScriptRoot $sourceRoot = $PSScriptRoot
if (-not $Resume) { if (-not $Resume) {
@@ -383,6 +480,8 @@ if (-not $existingState) {
PrefixLength = $PrefixLength PrefixLength = $PrefixLength
NetworkInterfaceAlias = $NetworkInterfaceAlias NetworkInterfaceAlias = $NetworkInterfaceAlias
DefaultGateway = if ($DefaultGateway) { $DefaultGateway.IPAddressToString } else { $null } DefaultGateway = if ($DefaultGateway) { $DefaultGateway.IPAddressToString } else { $null }
NetworkConfigurationMode = $NetworkConfigurationMode
TrustedClientNetworks = $TrustedClientNetworks
DnsForwarders = @($DnsForwarders | ForEach-Object IPAddressToString) DnsForwarders = @($DnsForwarders | ForEach-Object IPAddressToString)
DomainName = $DomainName DomainName = $DomainName
DomainNetbios = $DomainNetbios DomainNetbios = $DomainNetbios
@@ -396,9 +495,16 @@ if (-not $existingState) {
[Text.UTF8Encoding]::new($false)) [Text.UTF8Encoding]::new($false))
} }
if ($NetworkConfigurationMode -eq 'PlatformManaged') {
Write-BootstrapLog "Validating platform-managed address $ServerIPv4Address/$PrefixLength on $NetworkInterfaceAlias without changing DHCP, routes, or the Azure NIC."
Assert-PlatformManagedDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
-Address $ServerIPv4Address -NetworkPrefixLength $PrefixLength
}
else {
Write-BootstrapLog "Configuring $NetworkInterfaceAlias as $ServerIPv4Address/$PrefixLength." Write-BootstrapLog "Configuring $NetworkInterfaceAlias as $ServerIPv4Address/$PrefixLength."
Set-StaticDomainAddress -InterfaceAlias $NetworkInterfaceAlias ` Set-StaticDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
-Address $ServerIPv4Address -NetworkPrefixLength $PrefixLength -Gateway $DefaultGateway -Address $ServerIPv4Address -NetworkPrefixLength $PrefixLength -Gateway $DefaultGateway
}
$computer = Get-CimInstance Win32_ComputerSystem $computer = Get-CimInstance Win32_ComputerSystem
if (-not $computer.PartOfDomain) { if (-not $computer.PartOfDomain) {
@@ -472,8 +578,17 @@ Wait-ActiveDirectoryReady -ExpectedBaseDn $baseDn
$domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias ` $domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias `
-ErrorAction SilentlyContinue -ErrorAction SilentlyContinue
if (-not $domainProfile -or $domainProfile.NetworkCategory -ne 'DomainAuthenticated') { if (-not $domainProfile -or $domainProfile.NetworkCategory -ne 'DomainAuthenticated') {
if ($NetworkConfigurationMode -eq 'GuestStatic') {
Write-BootstrapLog "Refreshing $NetworkInterfaceAlias so Windows detects the domain network profile." Write-BootstrapLog "Refreshing $NetworkInterfaceAlias so Windows detects the domain network profile."
Restart-NetAdapter -Name $NetworkInterfaceAlias -Confirm:$false Restart-NetAdapter -Name $NetworkInterfaceAlias -Confirm:$false
}
else {
# Restarting an Azure NIC from inside the guest can sever the only
# management path. Refresh NLA instead; this does not change the
# platform-managed address, DHCP lease, route, or link state.
Write-BootstrapLog 'Refreshing Network Location Awareness without restarting the Azure adapter.'
Restart-Service NlaSvc -Force -ErrorAction SilentlyContinue
}
for ($attempt = 1; $attempt -le 15; $attempt++) { for ($attempt = 1; $attempt -le 15; $attempt++) {
Start-Sleep -Seconds 2 Start-Sleep -Seconds 2
$domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias ` $domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias `
@@ -577,7 +692,7 @@ if (Test-Path -LiteralPath $brokerConfigurationPath -PathType Leaf) {
-RemoteDesktopGroupDn $remoteDesktopGroup.DistinguishedName ` -RemoteDesktopGroupDn $remoteDesktopGroup.DistinguishedName `
-DefaultCompany 'La Salle' ` -DefaultCompany 'La Salle' `
-FirewallLocalAddress $ServerIPv4Address ` -FirewallLocalAddress $ServerIPv4Address `
-FirewallRemoteAddress "$($ServerIPv4Address.IPAddressToString)/$PrefixLength" ` -FirewallRemoteAddress $allowedRemoteAddresses `
-CreateMissingOus ` -CreateMissingOus `
-DisableCertificateRevocationCheckForLab | Out-Null -DisableCertificateRevocationCheckForLab | Out-Null
@@ -591,9 +706,8 @@ foreach ($hostRecord in $hostRecords) {
} }
} }
$privateSubnet = "$($ServerIPv4Address.IPAddressToString)/$PrefixLength"
& (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') ` & (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') `
-AllowedRemoteAddress $privateSubnet | Out-Null -AllowedRemoteAddress $allowedRemoteAddresses | Out-Null
$contentPath = Join-Path $bootstrapRoot 'payload\server-content\Packages' $contentPath = Join-Path $bootstrapRoot 'payload\server-content\Packages'
if (Test-Path -LiteralPath $contentPath -PathType Container) { if (Test-Path -LiteralPath $contentPath -PathType Container) {
@@ -613,7 +727,7 @@ if (-not $packageFirewallRule) {
-Protocol TCP ` -Protocol TCP `
-LocalPort 445 ` -LocalPort 445 `
-LocalAddress $ServerIPv4Address.IPAddressToString ` -LocalAddress $ServerIPv4Address.IPAddressToString `
-RemoteAddress $privateSubnet ` -RemoteAddress $allowedRemoteAddresses `
-Profile Any | Out-Null -Profile Any | Out-Null
} }
else { else {
@@ -621,7 +735,7 @@ else {
$packageFirewallRule | Get-NetFirewallAddressFilter | $packageFirewallRule | Get-NetFirewallAddressFilter |
Set-NetFirewallAddressFilter ` Set-NetFirewallAddressFilter `
-LocalAddress $ServerIPv4Address.IPAddressToString ` -LocalAddress $ServerIPv4Address.IPAddressToString `
-RemoteAddress $privateSubnet | Out-Null -RemoteAddress $allowedRemoteAddresses | Out-Null
} }
$collectorFqdn = "$env:COMPUTERNAME.$DomainName" $collectorFqdn = "$env:COMPUTERNAME.$DomainName"
@@ -642,7 +756,7 @@ $userPolicyParameters = @{
$rustDeskServer = & (Join-Path $scriptsRoot 'Install-SguRustDeskServer.ps1') ` $rustDeskServer = & (Join-Path $scriptsRoot 'Install-SguRustDeskServer.ps1') `
-ServerAddress $rustDeskDnsName ` -ServerAddress $rustDeskDnsName `
-FirewallRemoteAddress $privateSubnet -FirewallRemoteAddress $allowedRemoteAddresses
$rustDeskManagementRoot = Join-Path $env:ProgramData 'SGU\RustDesk' $rustDeskManagementRoot = Join-Path $env:ProgramData 'SGU\RustDesk'
New-Item -ItemType Directory -Path $rustDeskManagementRoot -Force | Out-Null New-Item -ItemType Directory -Path $rustDeskManagementRoot -Force | Out-Null
foreach ($scriptName in @( foreach ($scriptName in @(
@@ -680,6 +794,9 @@ $validation = [ordered]@{
ComputerName = $env:COMPUTERNAME ComputerName = $env:COMPUTERNAME
DomainName = $DomainName DomainName = $DomainName
ServerIPv4Address = $ServerIPv4Address.IPAddressToString ServerIPv4Address = $ServerIPv4Address.IPAddressToString
NetworkConfigurationMode = $NetworkConfigurationMode
TrustedClientNetworks = $TrustedClientNetworks
AllowedRemoteAddresses = $allowedRemoteAddresses
BrokerDnsName = $brokerDnsName BrokerDnsName = $brokerDnsName
BrokerCertificateThumbprint = $serverCertificate.Thumbprint BrokerCertificateThumbprint = $serverCertificate.Thumbprint
BrokerService = (Get-Service SGUAuthBroker).Status.ToString() BrokerService = (Get-Service SGUAuthBroker).Status.ToString()
@@ -714,7 +831,8 @@ if ($validation.BrokerService -ne 'Running' -or
-not $validation.RustDeskHbbsListening -or -not $validation.RustDeskHbbsListening -or
-not $validation.RustDeskHbbrListening -or -not $validation.RustDeskHbbrListening -or
$validation.EventCollector -ne 'Running' -or $validation.EventCollector -ne 'Running' -or
-not $validation.EventSubscription) { -not $validation.EventSubscription -or
$validation.DomainNetworkProfile -ne 'DomainAuthenticated') {
throw 'Server finalization did not pass service validation. Review bootstrap.log and re-run the bootstrap.' throw 'Server finalization did not pass service validation. Review bootstrap.log and re-run the bootstrap.'
} }
+123
View File
@@ -0,0 +1,123 @@
#Requires -Version 5.1
#Requires -RunAsAdministrator
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][string]$VpnProfilePackagePath,
[Parameter(Mandatory)][string]$ClientCertificatePfxPath,
[securestring]$ClientCertificatePfxPassword,
[Parameter(Mandatory)][string]$ClientRootCertificatePath,
[string]$ConnectionName = 'SGU Azure P2S',
[string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'),
[ipaddress]$DomainControllerIPv4Address = '10.77.0.4',
[string]$DomainName = 'lci.lasalle.mx',
[switch]$Connect
)
$ErrorActionPreference = 'Stop'
foreach ($path in @($VpnProfilePackagePath,$ClientCertificatePfxPath,$ClientRootCertificatePath)) {
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "Required P2S file not found: $path"
}
}
if (-not $ClientCertificatePfxPassword) {
$ClientCertificatePfxPassword = Read-Host 'Password protecting the P2S client PFX' -AsSecureString
}
$temporaryRoot = Join-Path $env:ProgramData ("SGU\AzureP2S\Import-" + [Guid]::NewGuid().ToString('N'))
try {
Expand-Archive -LiteralPath $VpnProfilePackagePath -DestinationPath $temporaryRoot -Force
$vpnSettingsPath = Get-ChildItem -LiteralPath $temporaryRoot -Recurse -Filter VpnSettings.xml -File |
Select-Object -First 1 -ExpandProperty FullName
if (-not $vpnSettingsPath) {
throw 'The Azure package does not contain Generic\VpnSettings.xml. Generate it with IKEv2 enabled.'
}
[xml]$vpnSettings = Get-Content -LiteralPath $vpnSettingsPath -Raw
$vpnServerNode = $vpnSettings.SelectSingleNode('//*[local-name()="VpnServer"]')
if (-not $vpnServerNode -or [string]::IsNullOrWhiteSpace($vpnServerNode.InnerText)) {
throw 'VpnSettings.xml does not contain the Azure VPN gateway FQDN.'
}
$vpnServer = $vpnServerNode.InnerText.Trim()
$serverRootPath = Get-ChildItem -LiteralPath (Split-Path $vpnSettingsPath -Parent) `
-Filter VpnServerRoot.cer -File | Select-Object -First 1 -ExpandProperty FullName
if ($serverRootPath) {
Import-Certificate -FilePath $serverRootPath -CertStoreLocation Cert:\LocalMachine\Root | Out-Null
}
$clientRoot = Import-Certificate -FilePath $ClientRootCertificatePath `
-CertStoreLocation Cert:\LocalMachine\Root | Select-Object -First 1
$clientCertificates = @(Import-PfxCertificate -FilePath $ClientCertificatePfxPath `
-Password $ClientCertificatePfxPassword -CertStoreLocation Cert:\LocalMachine\My)
$clientCertificate = $clientCertificates |
Where-Object {
$_.HasPrivateKey -and
$_.NotAfter -gt (Get-Date) -and
@($_.EnhancedKeyUsageList | ForEach-Object ObjectId) -contains '1.3.6.1.5.5.7.3.2'
} |
Sort-Object NotAfter -Descending |
Select-Object -First 1
if (-not $clientCertificate) {
throw 'The imported PFX does not contain a valid Client Authentication certificate with a private key.'
}
if ($PSCmdlet.ShouldProcess($ConnectionName, 'Install an all-user IKEv2 Azure P2S connection using a machine certificate')) {
$existingConnection = Get-VpnConnection -Name $ConnectionName -AllUserConnection `
-ErrorAction SilentlyContinue
if ($existingConnection) {
Remove-VpnConnection -Name $ConnectionName -AllUserConnection -Force
}
Add-VpnConnection `
-Name $ConnectionName `
-ServerAddress $vpnServer `
-TunnelType Ikev2 `
-AuthenticationMethod MachineCertificate `
-MachineCertificateIssuerFilter $clientRoot `
-MachineCertificateEKUFilter '1.3.6.1.5.5.7.3.2' `
-EncryptionLevel Required `
-SplitTunneling `
-AllUserConnection `
-DnsSuffix $DomainName `
-Force | Out-Null
foreach ($prefix in $AzureNetworkPrefixes) {
Add-VpnConnectionRoute -ConnectionName $ConnectionName `
-DestinationPrefix $prefix -AllUserConnection -PassThru | Out-Null
}
$nrptDisplayName = "SGU Azure P2S DNS - $DomainName"
Get-DnsClientNrptRule -ErrorAction SilentlyContinue |
Where-Object DisplayName -eq $nrptDisplayName |
Remove-DnsClientNrptRule -Force
Add-DnsClientNrptRule `
-Namespace ".$DomainName" `
-NameServers $DomainControllerIPv4Address.IPAddressToString `
-DisplayName $nrptDisplayName `
-Comment 'Managed by SGU Azure P2S bootstrap; routes only the AD namespace to the domain controller.' | Out-Null
}
if ($Connect) {
& "$env:SystemRoot\System32\rasdial.exe" $ConnectionName
if ($LASTEXITCODE -ne 0) {
throw "Windows could not connect $ConnectionName. Verify UDP 500/4500 (IKEv2) or use the Azure-generated SSTP profile when the local network blocks IKEv2."
}
}
$connection = Get-VpnConnection -Name $ConnectionName -AllUserConnection
[pscustomobject]@{
ConnectionName = $connection.Name
ServerAddress = $connection.ServerAddress
TunnelType = $connection.TunnelType
AllUserConnection = $true
AuthenticationMethod = $connection.AuthenticationMethod
ConnectionStatus = $connection.ConnectionStatus
ClientCertificateThumbprint = $clientCertificate.Thumbprint
DomainControllerIPv4Address = $DomainControllerIPv4Address.IPAddressToString
DomainDnsNamespace = ".$DomainName"
AzureNetworkPrefixes = $AzureNetworkPrefixes
AvailableBeforeLogon = $true
}
}
finally {
$ClientCertificatePfxPassword = $null
if (Test-Path -LiteralPath $temporaryRoot) {
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
+1
View File
@@ -32,6 +32,7 @@ $sourceScripts = @(
'Enable-LabRemoteAccess.ps1', 'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1', 'Enable-SguClientMonitoring.ps1',
'Install-SguRustDeskClient.ps1', 'Install-SguRustDeskClient.ps1',
'Set-SguStandardLocalUser.ps1',
'Test-SguClientEnrollment.ps1', 'Test-SguClientEnrollment.ps1',
'Repair-SguClientEnrollment.ps1' 'Repair-SguClientEnrollment.ps1'
) )
+2 -1
View File
@@ -6,7 +6,8 @@ param(
[string]$InstallRoot = "$env:ProgramFiles\SGU\RustDeskServer", [string]$InstallRoot = "$env:ProgramFiles\SGU\RustDeskServer",
[string]$DataRoot = "$env:ProgramData\SGU\RustDesk\Server", [string]$DataRoot = "$env:ProgramData\SGU\RustDesk\Server",
[string]$FirewallRemoteAddress = '192.168.50.0/24', [ValidateNotNullOrEmpty()]
[string[]]$FirewallRemoteAddress = @('192.168.50.0/24'),
[uri]$DownloadUri = 'https://github.com/rustdesk/rustdesk-server/releases/download/1.1.16/rustdesk-server-windows-x86_64-unsigned.zip', [uri]$DownloadUri = 'https://github.com/rustdesk/rustdesk-server/releases/download/1.1.16/rustdesk-server-windows-x86_64-unsigned.zip',
[ValidatePattern('^[A-Fa-f0-9]{64}$')] [ValidatePattern('^[A-Fa-f0-9]{64}$')]
[string]$ExpectedSha256 = 'B865A3A62FC8755B45480C508F1C4871C3338590408DDA8C58C7E9C373B7ADB0' [string]$ExpectedSha256 = 'B865A3A62FC8755B45480C508F1C4871C3338590408DDA8C58C7E9C373B7ADB0'
+255 -11
View File
@@ -3,11 +3,23 @@
param( param(
[ipaddress]$DomainControllerIPv4Address, [ipaddress]$DomainControllerIPv4Address,
[string]$NetworkInterfaceAlias, [string]$NetworkInterfaceAlias,
[ipaddress]$ClientIPv4Address,
[ValidateRange(1, 32)]
[int]$ClientPrefixLength = 24,
[PSCredential]$DomainCredential, [PSCredential]$DomainCredential,
[string]$DomainName = 'lci.lasalle.mx', [string]$DomainName = 'lci.lasalle.mx',
[string]$DomainNetbios = 'LCI', [string]$DomainNetbios = 'LCI',
[string]$ComputerOuDn, [string]$ComputerOuDn,
[string]$NewComputerName, [string]$NewComputerName,
[ValidateSet('Direct', 'AzureP2S')]
[string]$ConnectivityMode = 'Direct',
[string]$VpnConnectionName = 'SGU Azure P2S',
[string]$VpnProfilePackagePath,
[string]$VpnClientCertificatePfxPath,
[securestring]$VpnClientCertificatePfxPassword,
[string]$VpnClientRootCertificatePath,
[string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'),
[switch]$PauseOnError,
[switch]$SkipRestart [switch]$SkipRestart
) )
@@ -16,6 +28,35 @@ $brokerRecordName = 'sgu-auth'
$brokerDnsName = "$brokerRecordName.$DomainName" $brokerDnsName = "$brokerRecordName.$DomainName"
$brokerEndpoint = "https://${brokerDnsName}:8443/v1/authenticate" $brokerEndpoint = "https://${brokerDnsName}:8443/v1/authenticate"
$temporaryRoot = Join-Path $env:ProgramData ("SGU\Bootstrap\Client-" + [Guid]::NewGuid().ToString('N')) $temporaryRoot = Join-Path $env:ProgramData ("SGU\Bootstrap\Client-" + [Guid]::NewGuid().ToString('N'))
$bootstrapLogRoot = Join-Path $env:ProgramData 'SGU\Bootstrap\Client'
$bootstrapErrorLog = Join-Path $bootstrapLogRoot 'latest-error.log'
trap {
$failure = $_
$failureText = @(
"SGU client enrollment failed at $((Get-Date).ToString('s')).",
'',
$failure.Exception.Message,
'',
$failure.ScriptStackTrace
) -join [Environment]::NewLine
try {
New-Item -ItemType Directory -Path $bootstrapLogRoot -Force | Out-Null
[IO.File]::WriteAllText($bootstrapErrorLog, $failureText, [Text.UTF8Encoding]::new($false))
}
catch {
# Keep the original enrollment error when diagnostics cannot be written.
}
Write-Host ''
Write-Host 'SGU client enrollment did not complete.' -ForegroundColor Red
Write-Host $failure.Exception.Message -ForegroundColor Red
Write-Host "Diagnostic log: $bootstrapErrorLog" -ForegroundColor Yellow
if ($PauseOnError -and [Environment]::UserInteractive) {
Read-Host 'Press ENTER to close this window' | Out-Null
}
exit 1
}
function Assert-Administrator { function Assert-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent() $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
@@ -54,21 +95,133 @@ function Resolve-ClientInterfaceAlias {
return $RequestedAlias return $RequestedAlias
} }
$defaultRoute = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' `
-ErrorAction SilentlyContinue |
Sort-Object RouteMetric,InterfaceMetric |
Select-Object -First 1
if ($defaultRoute) {
return [string](Get-NetAdapter -InterfaceIndex $defaultRoute.InterfaceIndex).Name
}
$upAdapters = @(Get-NetAdapter | Where-Object Status -eq 'Up') $upAdapters = @(Get-NetAdapter | Where-Object Status -eq 'Up')
$withoutDefaultGateway = @($upAdapters | Where-Object {
-not (Get-NetIPConfiguration -InterfaceIndex $_.ifIndex).IPv4DefaultGateway
})
if ($withoutDefaultGateway.Count -eq 1) {
return [string]$withoutDefaultGateway[0].Name
}
if ($upAdapters.Count -eq 1) { if ($upAdapters.Count -eq 1) {
return [string]$upAdapters[0].Name return [string]$upAdapters[0].Name
} }
$aliases = ($upAdapters.Name | Sort-Object) -join ', ' $aliases = ($upAdapters.Name | Sort-Object) -join ', '
throw "Could not select a network adapter. Re-run with -NetworkInterfaceAlias. Available adapters: $aliases" throw "Could not select the private domain adapter unambiguously. Re-run with -NetworkInterfaceAlias. Available adapters: $aliases"
}
function Test-IPv4AddressesSharePrefix {
param(
[Parameter(Mandatory)][ipaddress]$FirstAddress,
[Parameter(Mandatory)][ipaddress]$SecondAddress,
[Parameter(Mandatory)][ValidateRange(1, 32)][int]$PrefixLength
)
if ($FirstAddress.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or
$SecondAddress.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
return $false
}
$firstBytes = $FirstAddress.GetAddressBytes()
$secondBytes = $SecondAddress.GetAddressBytes()
$remainingBits = $PrefixLength
for ($index = 0; $index -lt 4; $index++) {
$bits = [Math]::Min(8, $remainingBits)
$mask = if ($bits -eq 0) {
0
}
elseif ($bits -eq 8) {
255
}
else {
256 - [int][Math]::Pow(2, 8 - $bits)
}
if (($firstBytes[$index] -band $mask) -ne ($secondBytes[$index] -band $mask)) {
return $false
}
$remainingBits -= $bits
}
return $true
}
function Assert-UsableClientIPv4Address {
param(
[Parameter(Mandatory)][ipaddress]$Address,
[Parameter(Mandatory)][ipaddress]$DomainControllerAddress,
[Parameter(Mandatory)][ValidateRange(1, 32)][int]$PrefixLength
)
if ($Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
throw "The SGU client address '$Address' must be IPv4."
}
if ($Address.IPAddressToString -eq $DomainControllerAddress.IPAddressToString) {
throw 'The SGU client and domain controller cannot use the same IPv4 address.'
}
if ($Address.IPAddressToString -match '^(0\.|127\.|169\.254\.|22[4-9]\.|23\d\.)') {
throw "The SGU client address '$Address' is not usable on the private domain network."
}
if (-not (Test-IPv4AddressesSharePrefix -FirstAddress $Address `
-SecondAddress $DomainControllerAddress -PrefixLength $PrefixLength)) {
throw "The SGU client address '$Address/$PrefixLength' is not on the same network as domain controller $DomainControllerAddress."
}
}
function Set-ClientDomainAddress {
param(
[Parameter(Mandatory)][string]$InterfaceAlias,
[Parameter(Mandatory)][ipaddress]$DomainControllerAddress,
[ipaddress]$RequestedAddress,
[Parameter(Mandatory)][ValidateRange(1, 32)][int]$PrefixLength
)
$adapter = Get-NetAdapter -Name $InterfaceAlias -ErrorAction Stop
$matchingAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
-ErrorAction SilentlyContinue |
Where-Object {
$_.AddressState -eq 'Preferred' -and
$_.IPAddress -notmatch '^(127\.|169\.254\.)' -and
(Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]$_.IPAddress) `
-SecondAddress $DomainControllerAddress -PrefixLength $PrefixLength)
} |
Select-Object -First 1
if (-not $RequestedAddress -and $matchingAddress) {
return [ipaddress]$matchingAddress.IPAddress
}
if (-not $RequestedAddress) {
$RequestedAddress = [ipaddress](Read-Host "Fixed IPv4 address for this SGU client on '$InterfaceAlias'")
}
Assert-UsableClientIPv4Address -Address $RequestedAddress `
-DomainControllerAddress $DomainControllerAddress -PrefixLength $PrefixLength
Set-NetIPInterface -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -Dhcp Disabled
$existingAddresses = @(Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
-ErrorAction SilentlyContinue | Where-Object PrefixOrigin -ne 'WellKnown')
foreach ($existingAddress in $existingAddresses) {
if ($existingAddress.IPAddress -ne $RequestedAddress.IPAddressToString -or
[int]$existingAddress.PrefixLength -ne $PrefixLength) {
Remove-NetIPAddress -InputObject $existingAddress -Confirm:$false
}
}
if (-not (Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
-IPAddress $RequestedAddress.IPAddressToString -ErrorAction SilentlyContinue)) {
New-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
-IPAddress $RequestedAddress.IPAddressToString -PrefixLength $PrefixLength | Out-Null
}
$addressReadyDeadline = (Get-Date).AddSeconds(20)
do {
$configuredAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex `
-AddressFamily IPv4 -IPAddress $RequestedAddress.IPAddressToString `
-ErrorAction SilentlyContinue
if ($configuredAddress -and $configuredAddress.AddressState -eq 'Preferred') {
return $RequestedAddress
}
Start-Sleep -Milliseconds 500
} while ((Get-Date) -lt $addressReadyDeadline)
$observedState = if ($configuredAddress) { $configuredAddress.AddressState } else { 'Missing' }
throw "The SGU client address '$RequestedAddress' did not become ready on '$InterfaceAlias' within 20 seconds. Observed state: $observedState."
} }
function Test-TcpPort { function Test-TcpPort {
@@ -95,6 +248,47 @@ 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)
$connection = Get-VpnConnection -Name $ConnectionName -AllUserConnection `
-ErrorAction SilentlyContinue
if (-not $connection) {
throw "The all-user VPN connection '$ConnectionName' is not installed. Run Install-SguAzureP2sClient.ps1 in this VM first."
}
if ($connection.TunnelType -notcontains 'Ikev2' -and $connection.TunnelType -ne 'Ikev2') {
throw "The VPN connection '$ConnectionName' is not configured for IKEv2."
}
if ($connection.ConnectionStatus -ne 'Connected') {
& "$env:SystemRoot\System32\rasdial.exe" $ConnectionName
if ($LASTEXITCODE -ne 0) {
throw "Could not connect the Azure P2S profile '$ConnectionName'. Verify the machine certificate and that UDP 500/4500 is permitted by the local network."
}
}
$connection = Get-VpnConnection -Name $ConnectionName -AllUserConnection
if ($connection.ConnectionStatus -ne 'Connected') {
throw "The Azure P2S profile '$ConnectionName' did not reach Connected state."
}
return $connection
}
Assert-Administrator Assert-Administrator
$operatingSystem = Get-CimInstance Win32_OperatingSystem $operatingSystem = Get-CimInstance Win32_OperatingSystem
if ([int]$operatingSystem.ProductType -ne 1) { if ([int]$operatingSystem.ProductType -ne 1) {
@@ -135,12 +329,58 @@ if (-not $runtimeInstaller) {
throw 'The offline Microsoft .NET 10 x64 runtime installer is missing from the client package.' throw 'The offline Microsoft .NET 10 x64 runtime installer is missing from the client package.'
} }
if ($ConnectivityMode -eq 'AzureP2S') {
$existingVpnConnection = Get-VpnConnection -Name $VpnConnectionName -AllUserConnection `
-ErrorAction SilentlyContinue
if (-not $existingVpnConnection) {
$installerPath = Join-Path $packageRoot 'Install-SguAzureP2sClient.ps1'
if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) {
throw 'Install-SguAzureP2sClient.ps1 is missing from the client bootstrap package.'
}
foreach ($vpnInput in @(
@{ Name = 'VpnProfilePackagePath'; Value = $VpnProfilePackagePath },
@{ Name = 'VpnClientCertificatePfxPath'; Value = $VpnClientCertificatePfxPath },
@{ Name = 'VpnClientRootCertificatePath'; Value = $VpnClientRootCertificatePath })) {
if ([string]::IsNullOrWhiteSpace([string]$vpnInput.Value)) {
throw "$($vpnInput.Name) is required the first time an Azure P2S client is enrolled."
}
}
$vpnInstallParameters = @{
VpnProfilePackagePath = $VpnProfilePackagePath
ClientCertificatePfxPath = $VpnClientCertificatePfxPath
ClientRootCertificatePath = $VpnClientRootCertificatePath
ConnectionName = $VpnConnectionName
AzureNetworkPrefixes = $AzureNetworkPrefixes
DomainControllerIPv4Address = $DomainControllerIPv4Address
DomainName = $DomainName
}
if ($VpnClientCertificatePfxPassword) {
$vpnInstallParameters.ClientCertificatePfxPassword = $VpnClientCertificatePfxPassword
}
& $installerPath @vpnInstallParameters | Out-Null
}
$vpnConnection = Connect-SguAzureP2s -ConnectionName $VpnConnectionName
$nrptDisplayName = "SGU Azure P2S DNS - $DomainName"
$nrptRule = Get-DnsClientNrptRule -ErrorAction SilentlyContinue |
Where-Object DisplayName -eq $nrptDisplayName |
Select-Object -First 1
if (-not $nrptRule -or
@($nrptRule.NameServers) -notcontains $DomainControllerIPv4Address.IPAddressToString) {
throw "The SGU NRPT rule for $DomainName is missing or does not point to $DomainControllerIPv4Address. Re-run Install-SguAzureP2sClient.ps1."
}
$NetworkInterfaceAlias = $vpnConnection.Name
}
else {
$NetworkInterfaceAlias = Resolve-ClientInterfaceAlias -RequestedAlias $NetworkInterfaceAlias $NetworkInterfaceAlias = Resolve-ClientInterfaceAlias -RequestedAlias $NetworkInterfaceAlias
$ClientIPv4Address = Set-ClientDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
-DomainControllerAddress $DomainControllerIPv4Address `
-RequestedAddress $ClientIPv4Address -PrefixLength $ClientPrefixLength
Set-DnsClientServerAddress -InterfaceAlias $NetworkInterfaceAlias ` Set-DnsClientServerAddress -InterfaceAlias $NetworkInterfaceAlias `
-ServerAddresses $DomainControllerIPv4Address.IPAddressToString -ServerAddresses $DomainControllerIPv4Address.IPAddressToString
}
if (-not (Test-TcpPort -Address $DomainControllerIPv4Address -Port 5985)) { if (-not (Wait-TcpPort -Address $DomainControllerIPv4Address -Port 5985 -TimeoutSeconds 20)) {
throw "The domain controller at $DomainControllerIPv4Address is not accepting WinRM on TCP 5985. Run the server bootstrap first and verify the selected IP." throw "The domain controller at $DomainControllerIPv4Address did not accept WinRM on TCP 5985 after 20 seconds. Run the server bootstrap first and verify the selected IP."
} }
if (-not $DomainCredential) { if (-not $DomainCredential) {
@@ -292,6 +532,7 @@ try {
ComputerOuDn = $ComputerOuDn ComputerOuDn = $ComputerOuDn
NetworkInterfaceAlias = $NetworkInterfaceAlias NetworkInterfaceAlias = $NetworkInterfaceAlias
DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString) DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString)
ConnectivityMode = $ConnectivityMode
RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP" RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP"
DotNetRuntimeInstallerPath = $runtimeInstaller.FullName DotNetRuntimeInstallerPath = $runtimeInstaller.FullName
RustDeskServerAddress = $serverIdentity.RustDeskServerAddress RustDeskServerAddress = $serverIdentity.RustDeskServerAddress
@@ -356,6 +597,7 @@ finally {
} }
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
$DomainCredential = $null $DomainCredential = $null
$VpnClientCertificatePfxPassword = $null
} }
if ($SkipRestart) { if ($SkipRestart) {
@@ -365,6 +607,8 @@ if ($SkipRestart) {
ProviderInstalled = $true ProviderInstalled = $true
ClientCertificateRegistered = $true ClientCertificateRegistered = $true
BrokerEndpoint = $brokerEndpoint BrokerEndpoint = $brokerEndpoint
ConnectivityMode = $ConnectivityMode
VpnConnectionName = if ($ConnectivityMode -eq 'AzureP2S') { $VpnConnectionName } else { $null }
RestartRequired = $true RestartRequired = $true
RustDesk = if ($result) { $result.RustDesk } else { $null } RustDesk = if ($result) { $result.RustDesk } else { $null }
EnrollmentResult = $result EnrollmentResult = $result
+89
View File
@@ -0,0 +1,89 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[ValidatePattern('^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$')]
[string]$ClientName,
[string]$OutputDirectory = (Join-Path $PSScriptRoot '..\artifacts\azure-p2s'),
[securestring]$ClientPfxPassword,
[string]$RootSubject = 'CN=SGU Azure P2S Root',
[ValidateRange(1, 10)]
[int]$ClientValidityYears = 2,
[switch]$Force
)
$ErrorActionPreference = 'Stop'
$resolvedOutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
New-Item -ItemType Directory -Path $resolvedOutputDirectory -Force | Out-Null
$rootCertificatePath = Join-Path $resolvedOutputDirectory 'sgu-azure-p2s-root.cer'
$clientCertificatePath = Join-Path $resolvedOutputDirectory "sgu-azure-p2s-$ClientName.pfx"
if ((Test-Path -LiteralPath $clientCertificatePath -PathType Leaf) -and -not $Force) {
throw "$clientCertificatePath already exists. Use -Force only when you intend to replace that exported client credential."
}
if (-not $ClientPfxPassword) {
$ClientPfxPassword = Read-Host 'Password that will protect the exported P2S client certificate' -AsSecureString
}
$rootCertificate = Get-ChildItem Cert:\CurrentUser\My |
Where-Object {
$_.Subject -eq $RootSubject -and
$_.HasPrivateKey -and
$_.NotAfter -gt (Get-Date).AddYears($ClientValidityYears)
} |
Sort-Object NotAfter -Descending |
Select-Object -First 1
if (-not $rootCertificate) {
if (-not $PSCmdlet.ShouldProcess($RootSubject, 'Create a non-exportable Azure P2S root certificate authority')) {
return
}
$rootCertificate = New-SelfSignedCertificate `
-Type Custom `
-Subject $RootSubject `
-CertStoreLocation Cert:\CurrentUser\My `
-KeyAlgorithm RSA `
-KeyLength 4096 `
-HashAlgorithm SHA256 `
-KeySpec Signature `
-KeyExportPolicy NonExportable `
-KeyUsage CertSign,CRLSign,DigitalSignature `
-NotAfter (Get-Date).AddYears(10) `
-TextExtension @('2.5.29.19={critical}{text}ca=1&pathlength=1')
}
if (-not $PSCmdlet.ShouldProcess($ClientName, 'Issue and export an Azure P2S machine certificate')) {
return
}
$clientSubject = "CN=SGU Azure P2S $ClientName"
$clientCertificate = New-SelfSignedCertificate `
-Type Custom `
-Subject $clientSubject `
-DnsName "sgu-p2s-$ClientName" `
-Signer $rootCertificate `
-CertStoreLocation Cert:\CurrentUser\My `
-KeyAlgorithm RSA `
-KeyLength 3072 `
-HashAlgorithm SHA256 `
-KeySpec Signature `
-KeyExportPolicy Exportable `
-KeyUsage DigitalSignature `
-NotAfter (Get-Date).AddYears($ClientValidityYears) `
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.2')
Export-Certificate -Cert $rootCertificate -FilePath $rootCertificatePath -Force | Out-Null
Export-PfxCertificate -Cert $clientCertificate -FilePath $clientCertificatePath `
-Password $ClientPfxPassword -ChainOption BuildChain -CryptoAlgorithmOption AES256_SHA256 `
-Force | Out-Null
[pscustomobject]@{
RootCertificatePath = $rootCertificatePath
RootCertificateThumbprint = $rootCertificate.Thumbprint
RootCertificateData = [Convert]::ToBase64String($rootCertificate.RawData)
ClientName = $ClientName
ClientCertificatePath = $clientCertificatePath
ClientCertificateThumbprint = $clientCertificate.Thumbprint
ClientCertificateExpires = $clientCertificate.NotAfter
RootPrivateKeyExportable = $false
}
+35 -2
View File
@@ -81,16 +81,18 @@ New-Item -ItemType Directory -Path $resolvedOutputRoot -Force | Out-Null
$clientRoot = Join-Path $resolvedOutputRoot "sgu-client-bootstrap-$Version" $clientRoot = Join-Path $resolvedOutputRoot "sgu-client-bootstrap-$Version"
$serverRoot = Join-Path $resolvedOutputRoot "sgu-server-bootstrap-$Version" $serverRoot = Join-Path $resolvedOutputRoot "sgu-server-bootstrap-$Version"
$linuxClientRoot = Join-Path $resolvedOutputRoot "sgu-linux-client-bootstrap-$Version" $linuxClientRoot = Join-Path $resolvedOutputRoot "sgu-linux-client-bootstrap-$Version"
$azureRoot = Join-Path $resolvedOutputRoot "sgu-azure-infrastructure-$Version"
$clientZip = "$clientRoot.zip" $clientZip = "$clientRoot.zip"
$serverZip = "$serverRoot.zip" $serverZip = "$serverRoot.zip"
$linuxClientZip = "$linuxClientRoot.zip" $linuxClientZip = "$linuxClientRoot.zip"
foreach ($target in @($clientRoot,$serverRoot,$linuxClientRoot,$clientZip,$serverZip,$linuxClientZip)) { $azureZip = "$azureRoot.zip"
foreach ($target in @($clientRoot,$serverRoot,$linuxClientRoot,$azureRoot,$clientZip,$serverZip,$linuxClientZip,$azureZip)) {
if (Test-Path -LiteralPath $target) { if (Test-Path -LiteralPath $target) {
throw "Release target already exists: $target" throw "Release target already exists: $target"
} }
} }
New-Item -ItemType Directory -Path $clientRoot,$serverRoot,$linuxClientRoot -Force | Out-Null New-Item -ItemType Directory -Path $clientRoot,$serverRoot,$linuxClientRoot,$azureRoot -Force | Out-Null
$welcomeFontNames = @( $welcomeFontNames = @(
'IndivisaTextSans-Regular.otf', 'IndivisaTextSans-Regular.otf',
'IndivisaTextSans-Bold.otf', 'IndivisaTextSans-Bold.otf',
@@ -103,6 +105,10 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Invoke-SguClientBootstrap.ps
-Destination (Join-Path $clientRoot 'Invoke-SguClientBootstrap.ps1') -Destination (Join-Path $clientRoot 'Invoke-SguClientBootstrap.ps1')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguClientEnrollment.cmd') ` Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguClientEnrollment.cmd') `
-Destination (Join-Path $clientRoot '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 = @( $clientScripts = @(
'Enable-LabRemoteAccess.ps1', 'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1', 'Enable-SguClientMonitoring.ps1',
@@ -112,6 +118,7 @@ $clientScripts = @(
'Install-SguRustDeskClient.ps1', 'Install-SguRustDeskClient.ps1',
'Register-SguClientCertificate.ps1', 'Register-SguClientCertificate.ps1',
'Repair-SguClientEnrollment.ps1', 'Repair-SguClientEnrollment.ps1',
'Set-SguStandardLocalUser.ps1',
'Test-SguClientEnrollment.ps1' 'Test-SguClientEnrollment.ps1'
) )
foreach ($scriptName in $clientScripts) { foreach ($scriptName in $clientScripts) {
@@ -163,6 +170,8 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Initialize-SguDomainControll
-Destination (Join-Path $serverRoot 'Initialize-SguDomainController.ps1') -Destination (Join-Path $serverRoot 'Initialize-SguDomainController.ps1')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguServerBootstrap.cmd') ` Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguServerBootstrap.cmd') `
-Destination (Join-Path $serverRoot 'Start-SguServerBootstrap.cmd') -Destination (Join-Path $serverRoot 'Start-SguServerBootstrap.cmd')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguAzureServerBootstrap.cmd') `
-Destination (Join-Path $serverRoot 'Start-SguAzureServerBootstrap.cmd')
$serverScripts = @( $serverScripts = @(
'Deploy-AuthBroker.ps1', 'Deploy-AuthBroker.ps1',
'Enable-SguServerRemoteManagement.ps1', 'Enable-SguServerRemoteManagement.ps1',
@@ -211,10 +220,32 @@ Write-PackageManifest -PackageRoot $serverRoot -PackageVersion $Version -Package
Compress-Archive -Path (Join-Path $serverRoot '*') -DestinationPath $serverZip ` Compress-Archive -Path (Join-Path $serverRoot '*') -DestinationPath $serverZip `
-CompressionLevel Optimal -CompressionLevel Optimal
# Azure infrastructure is packaged separately because it runs on the trusted
# administrator workstation, not inside the domain controller or a client.
$azureScriptsRoot = Join-Path $azureRoot 'scripts'
$azureInfrastructureRoot = Join-Path $azureRoot 'infra\azure'
New-Item -ItemType Directory -Path $azureScriptsRoot,$azureInfrastructureRoot -Force | Out-Null
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'infra\azure\main.bicep') `
-Destination (Join-Path $azureInfrastructureRoot 'main.bicep')
foreach ($scriptName in @(
'New-SguAzureP2sCertificates.ps1',
'Deploy-SguAzureInfrastructure.ps1',
'Get-SguAzureP2sPackage.ps1',
'Install-SguAzureP2sClient.ps1')) {
Copy-RequiredFile -Source (Join-Path $PSScriptRoot $scriptName) `
-Destination (Join-Path $azureScriptsRoot $scriptName)
}
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'docs\azure-vpn-deployment.md') `
-Destination (Join-Path $azureRoot 'README.md')
Write-PackageManifest -PackageRoot $azureRoot -PackageVersion $Version -PackageKind AzureInfrastructure
Compress-Archive -Path (Join-Path $azureRoot '*') -DestinationPath $azureZip `
-CompressionLevel Optimal
$checksums = @( $checksums = @(
("{0} {1}" -f (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash, (Split-Path $clientZip -Leaf)) ("{0} {1}" -f (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash, (Split-Path $clientZip -Leaf))
("{0} {1}" -f (Get-FileHash -LiteralPath $serverZip -Algorithm SHA256).Hash, (Split-Path $serverZip -Leaf)) ("{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 $linuxClientZip -Algorithm SHA256).Hash, (Split-Path $linuxClientZip -Leaf))
("{0} {1}" -f (Get-FileHash -LiteralPath $azureZip -Algorithm SHA256).Hash, (Split-Path $azureZip -Leaf))
) )
$checksumsPath = Join-Path $resolvedOutputRoot "SHA256SUMS-$Version.txt" $checksumsPath = Join-Path $resolvedOutputRoot "SHA256SUMS-$Version.txt"
[IO.File]::WriteAllLines($checksumsPath, $checksums, [Text.UTF8Encoding]::new($false)) [IO.File]::WriteAllLines($checksumsPath, $checksums, [Text.UTF8Encoding]::new($false))
@@ -227,6 +258,8 @@ $checksumsPath = Join-Path $resolvedOutputRoot "SHA256SUMS-$Version.txt"
LinuxClientSha256 = (Get-FileHash -LiteralPath $linuxClientZip -Algorithm SHA256).Hash LinuxClientSha256 = (Get-FileHash -LiteralPath $linuxClientZip -Algorithm SHA256).Hash
ServerPackage = $serverZip ServerPackage = $serverZip
ServerSha256 = (Get-FileHash -LiteralPath $serverZip -Algorithm SHA256).Hash ServerSha256 = (Get-FileHash -LiteralPath $serverZip -Algorithm SHA256).Hash
AzureInfrastructurePackage = $azureZip
AzureInfrastructureSha256 = (Get-FileHash -LiteralPath $azureZip -Algorithm SHA256).Hash
Checksums = $checksumsPath Checksums = $checksumsPath
RuntimeInstaller = $runtimeInstaller.Name RuntimeInstaller = $runtimeInstaller.Name
} }
+26 -6
View File
@@ -18,6 +18,7 @@ $assetPaths = @(
(Join-Path $ReleaseDirectory "sgu-client-bootstrap-$Version.zip"), (Join-Path $ReleaseDirectory "sgu-client-bootstrap-$Version.zip"),
(Join-Path $ReleaseDirectory "sgu-server-bootstrap-$Version.zip"), (Join-Path $ReleaseDirectory "sgu-server-bootstrap-$Version.zip"),
(Join-Path $ReleaseDirectory "sgu-linux-client-bootstrap-$Version.zip"), (Join-Path $ReleaseDirectory "sgu-linux-client-bootstrap-$Version.zip"),
(Join-Path $ReleaseDirectory "sgu-azure-infrastructure-$Version.zip"),
(Join-Path $ReleaseDirectory "SHA256SUMS-$Version.txt") (Join-Path $ReleaseDirectory "SHA256SUMS-$Version.txt")
) )
foreach ($assetPath in $assetPaths) { foreach ($assetPath in $assetPaths) {
@@ -27,7 +28,9 @@ foreach ($assetPath in $assetPaths) {
} }
$token = $env:GITEA_TOKEN $token = $env:GITEA_TOKEN
if (-not $token) { $authorizationScheme = 'token'
$authorizationParameter = $token
if (-not $authorizationParameter) {
$credentialInput = "protocol=$($GiteaBaseUri.Scheme)`nhost=$($GiteaBaseUri.Host)`n`n" $credentialInput = "protocol=$($GiteaBaseUri.Scheme)`nhost=$($GiteaBaseUri.Host)`n`n"
$credentialOutput = $credentialInput | & git credential fill $credentialOutput = $credentialInput | & git credential fill
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
@@ -40,17 +43,25 @@ if (-not $token) {
$credentialValues[$parts[0]] = $parts[1] $credentialValues[$parts[0]] = $parts[1]
} }
} }
$token = $credentialValues.password if ($credentialValues.username -and $credentialValues.password) {
$authorizationScheme = 'Basic'
$basicCredential = '{0}:{1}' -f $credentialValues.username,$credentialValues.password
$authorizationParameter = [Convert]::ToBase64String(
[Text.Encoding]::UTF8.GetBytes($basicCredential))
$basicCredential = $null
} }
if (-not $token) { }
throw 'No Gitea token is available. Set GITEA_TOKEN for this process or sign in through Git Credential Manager.' if (-not $authorizationParameter) {
throw 'No Gitea credential is available. Set GITEA_TOKEN for this process or sign in through Git Credential Manager.'
} }
Add-Type -AssemblyName System.Net.Http Add-Type -AssemblyName System.Net.Http
$handler = [Net.Http.HttpClientHandler]::new() $handler = [Net.Http.HttpClientHandler]::new()
$client = [Net.Http.HttpClient]::new($handler) $client = [Net.Http.HttpClient]::new($handler)
$client.BaseAddress = [uri]($GiteaBaseUri.AbsoluteUri.TrimEnd('/') + '/') $client.BaseAddress = [uri]($GiteaBaseUri.AbsoluteUri.TrimEnd('/') + '/')
$client.DefaultRequestHeaders.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new('token', $token) $client.DefaultRequestHeaders.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new(
$authorizationScheme,
$authorizationParameter)
$client.DefaultRequestHeaders.UserAgent.ParseAdd('SGU-CredentialProvider-Release/1.0') $client.DefaultRequestHeaders.UserAgent.ParseAdd('SGU-CredentialProvider-Release/1.0')
function Invoke-GiteaJson { function Invoke-GiteaJson {
@@ -101,8 +112,15 @@ Bootstrap reproducible para el laboratorio SGU.
- **Advertencia:** el bootstrap de servidor crea un bosque nuevo. No restaura los SID, contraseñas ni relaciones de confianza del bosque anterior; para conservarlos se requiere una recuperación de bosque desde una copia de estado del sistema. - **Advertencia:** el bootstrap de servidor crea un bosque nuevo. No restaura los SID, contraseñas ni relaciones de confianza del bosque anterior; para conservarlos se requiere una recuperación de bosque desde una copia de estado del sistema.
- `sgu-server-bootstrap-$Version.zip`: crea el bosque AD/DNS, OUs, grupo RDP, GPO, recurso `Packages`, broker mTLS y administración remota; se reanuda solo después del reinicio. - `sgu-server-bootstrap-$Version.zip`: crea el bosque AD/DNS, OUs, grupo RDP, GPO, recurso `Packages`, broker mTLS y administración remota; se reanuda solo después del reinicio.
- `sgu-client-bootstrap-$Version.zip`: registra un certificado mTLS único, instala y valida el Credential Provider antes de unir el equipo al dominio, habilita RDP/WinRM y se repara al arranque. - `sgu-client-bootstrap-$Version.zip`: registra un certificado mTLS único, instala y valida el Credential Provider antes de unir el equipo al dominio, habilita RDP/WinRM y se repara al arranque.
- En clientes Hyper-V con dos NIC, el bootstrap selecciona la red privada sin puerta de enlace, solicita o acepta la IP fija del cliente, espera a que la dirección y WinRM estén disponibles y conserva en pantalla y archivo cualquier error de enrolamiento.
- `sgu-linux-client-bootstrap-$Version.zip`: une clientes Debian/Ubuntu o RHEL/Fedora/Rocky/AlmaLinux con realmd, Kerberos y SSSD. Solicita interactivamente la contraseña de unión y no instala el Credential Provider de Windows. - `sgu-linux-client-bootstrap-$Version.zip`: une clientes Debian/Ubuntu o RHEL/Fedora/Rocky/AlmaLinux con realmd, Kerberos y SSSD. Solicita interactivamente la contraseña de unión y no instala el Credential Provider de Windows.
- 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. - `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 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.
- 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.
@@ -155,6 +173,8 @@ Las contraseñas se solicitan de forma interactiva y no se escriben en archivos
} }
finally { finally {
$token = $null $token = $null
$authorizationParameter = $null
$credentialValues = $null
$client.Dispose() $client.Dispose()
$handler.Dispose() $handler.Dispose()
} }
+2
View File
@@ -11,9 +11,11 @@ $installScript = Join-Path $enrollmentRoot 'Install-CredentialProvider.ps1'
$remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1' $remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1'
$monitoringScript = Join-Path $enrollmentRoot 'Enable-SguClientMonitoring.ps1' $monitoringScript = Join-Path $enrollmentRoot 'Enable-SguClientMonitoring.ps1'
$rustDeskScript = Join-Path $enrollmentRoot 'Install-SguRustDeskClient.ps1' $rustDeskScript = Join-Path $enrollmentRoot 'Install-SguRustDeskClient.ps1'
$localUserScript = Join-Path $enrollmentRoot 'Set-SguStandardLocalUser.ps1'
$before = & $testScript $before = & $testScript
if (-not $before.IsValid) { if (-not $before.IsValid) {
& $localUserScript | Out-Null
$installParams = @{ $installParams = @{
PublishPath = [string]$configuration.PublishPath PublishPath = [string]$configuration.PublishPath
BrokerEndpoint = [string]$configuration.BrokerEndpoint BrokerEndpoint = [string]$configuration.BrokerEndpoint
+84
View File
@@ -0,0 +1,84 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess)]
param()
$ErrorActionPreference = 'Stop'
$userName = 'alumno'
$plainTextPassword = 'ingenieria'
$description = 'Cuenta local estandar de recuperacion para equipos SGU'
$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
}
$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."
}
[pscustomobject]@{
UserName = $verifiedUser.Name
Enabled = $verifiedUser.Enabled
IsAdministrator = $false
IsStandardUser = $true
PasswordNeverExpires = $verifiedUser.PasswordNeverExpires
}
+59 -6
View File
@@ -8,6 +8,8 @@ param(
[string]$ComputerName = $env:COMPUTERNAME, [string]$ComputerName = $env:COMPUTERNAME,
[string]$Location, [string]$Location,
[string]$OrganizationalUnit, [string]$OrganizationalUnit,
[ValidateSet('Male', 'Female')]
[string]$Gender,
[ValidateRange(640, 16384)] [ValidateRange(640, 16384)]
[int]$CanvasWidth, [int]$CanvasWidth,
[ValidateRange(480, 16384)] [ValidateRange(480, 16384)]
@@ -67,6 +69,22 @@ function Get-ImmediateOrganizationalUnit {
return $null return $null
} }
function Get-SguGenderFromInfo {
param([string]$Info)
if (-not $Info) {
return $null
}
foreach ($line in $Info -split '\r?\n') {
if ($line -match '^\s*SGU-Gender:\s*(Male|Female)\s*$') {
return [Globalization.CultureInfo]::InvariantCulture.TextInfo.ToTitleCase(
$Matches[1].ToLowerInvariant())
}
}
return $null
}
function Get-DirectoryWelcomeMetadata { function Get-DirectoryWelcomeMetadata {
param( param(
[Parameter(Mandatory)][string]$UserName, [Parameter(Mandatory)][string]$UserName,
@@ -93,6 +111,7 @@ function Get-DirectoryWelcomeMetadata {
$userSearcher.Filter = '(&(objectCategory=person)(objectClass=user)(sAMAccountName={0}))' -f ` $userSearcher.Filter = '(&(objectCategory=person)(objectClass=user)(sAMAccountName={0}))' -f `
(ConvertTo-LdapFilterValue -Value $UserName) (ConvertTo-LdapFilterValue -Value $UserName)
[void]$userSearcher.PropertiesToLoad.Add('displayName') [void]$userSearcher.PropertiesToLoad.Add('displayName')
[void]$userSearcher.PropertiesToLoad.Add('info')
$userResult = $userSearcher.FindOne() $userResult = $userSearcher.FindOne()
$directoryDisplayName = if ($userResult -and $userResult.Properties['displayname'].Count) { $directoryDisplayName = if ($userResult -and $userResult.Properties['displayname'].Count) {
[string]$userResult.Properties['displayname'][0] [string]$userResult.Properties['displayname'][0]
@@ -100,6 +119,12 @@ function Get-DirectoryWelcomeMetadata {
else { else {
$null $null
} }
$directoryGender = if ($userResult -and $userResult.Properties['info'].Count) {
Get-SguGenderFromInfo -Info ([string]$userResult.Properties['info'][0])
}
else {
$null
}
} }
finally { finally {
$userSearcher.Dispose() $userSearcher.Dispose()
@@ -136,6 +161,7 @@ function Get-DirectoryWelcomeMetadata {
[pscustomobject]@{ [pscustomobject]@{
DisplayName = $directoryDisplayName DisplayName = $directoryDisplayName
Gender = $directoryGender
Location = $directoryLocation Location = $directoryLocation
OrganizationalUnit = Get-ImmediateOrganizationalUnit -DistinguishedName $computerDn OrganizationalUnit = Get-ImmediateOrganizationalUnit -DistinguishedName $computerDn
} }
@@ -156,11 +182,20 @@ function Get-SpanishArticle {
function Get-WelcomeLocationText { function Get-WelcomeLocationText {
param( param(
[string]$Room, [string]$Room,
[string]$OuName [string]$OuName,
[string]$Gender
) )
$located = 'Est{0}s ubicado en' -f [char]0x00E1 $located = switch ($Gender) {
$engineeringLab = 'Bienvenido al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED 'Male' { 'Est{0}s ubicado en' -f [char]0x00E1 }
'Female' { 'Est{0}s ubicada en' -f [char]0x00E1 }
default { 'Ubicaci{0}n:' -f [char]0x00F3 }
}
$engineeringLab = switch ($Gender) {
'Male' { 'Bienvenido al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED }
'Female' { 'Bienvenida al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED }
default { 'Acceso al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED }
}
$Room = if ($Room) { $Room.Trim() } else { $null } $Room = if ($Room) { $Room.Trim() } else { $null }
$OuName = if ($OuName) { $OuName.Trim() } else { $null } $OuName = if ($OuName) { $OuName.Trim() } else { $null }
@@ -184,6 +219,16 @@ function Get-WelcomeLocationText {
return $engineeringLab return $engineeringLab
} }
function Get-WelcomeHeading {
param([string]$Gender)
switch ($Gender) {
'Male' { return 'Bienvenido,' }
'Female' { return 'Bienvenida,' }
default { return 'Te damos la bienvenida,' }
}
}
function Get-AvailableFontFamily { function Get-AvailableFontFamily {
param( param(
[Parameter(Mandatory)][string[]]$Candidates, [Parameter(Mandatory)][string[]]$Candidates,
@@ -270,10 +315,15 @@ if (-not $DisplayName) {
if (-not $PSBoundParameters.ContainsKey('Location') -and $metadata) { if (-not $PSBoundParameters.ContainsKey('Location') -and $metadata) {
$Location = $metadata.Location $Location = $metadata.Location
} }
$genderWasProvided = $PSBoundParameters.ContainsKey('Gender')
if (-not $genderWasProvided -and $metadata) {
$Gender = $metadata.Gender
}
$welcomeHeading = Get-WelcomeHeading -Gender $Gender
if (-not $PSBoundParameters.ContainsKey('OrganizationalUnit') -and $metadata) { if (-not $PSBoundParameters.ContainsKey('OrganizationalUnit') -and $metadata) {
$OrganizationalUnit = $metadata.OrganizationalUnit $OrganizationalUnit = $metadata.OrganizationalUnit
} }
$locationText = Get-WelcomeLocationText -Room $Location -OuName $OrganizationalUnit $locationText = Get-WelcomeLocationText -Room $Location -OuName $OrganizationalUnit -Gender $Gender
if (-not $CanvasWidth -or -not $CanvasHeight) { if (-not $CanvasWidth -or -not $CanvasHeight) {
try { try {
@@ -366,7 +416,7 @@ try {
$format.Trimming = [Drawing.StringTrimming]::EllipsisWord $format.Trimming = [Drawing.StringTrimming]::EllipsisWord
try { try {
$graphics.FillRectangle($panelBrush, $panelX, $panelY, $panelWidth, $panelHeight) $graphics.FillRectangle($panelBrush, $panelX, $panelY, $panelWidth, $panelHeight)
Draw-CenteredText -Graphics $graphics -Text 'Bienvenido,' -Font $welcomeFont ` Draw-CenteredText -Graphics $graphics -Text $welcomeHeading -Font $welcomeFont `
-Brush $accentBrush -Bounds ([Drawing.RectangleF]::new($panelX, $panelY + 24*$scale, $panelWidth, 50*$scale)) -Format $format -Brush $accentBrush -Bounds ([Drawing.RectangleF]::new($panelX, $panelY + 24*$scale, $panelWidth, 50*$scale)) -Format $format
Draw-CenteredText -Graphics $graphics -Text $DisplayName -Font $nameFont ` Draw-CenteredText -Graphics $graphics -Text $DisplayName -Font $nameFont `
-Brush $whiteBrush -Bounds ([Drawing.RectangleF]::new($panelX + 30*$scale, $panelY + 64*$scale, $panelWidth - 60*$scale, 105*$scale)) -Format $format -Brush $whiteBrush -Bounds ([Drawing.RectangleF]::new($panelX + 30*$scale, $panelY + 64*$scale, $panelWidth - 60*$scale, 105*$scale)) -Format $format
@@ -433,12 +483,15 @@ namespace Sgu {
} }
} }
Write-WelcomeLog -Message ("OK computer={0}; location={1}; ou={2}; output={3}" -f $ComputerName,[bool]$Location,[bool]$OrganizationalUnit,$OutputPath) $genderLogValue = if ($Gender) { $Gender } else { 'Neutral' }
Write-WelcomeLog -Message ("OK computer={0}; gender={1}; location={2}; ou={3}; output={4}" -f $ComputerName,$genderLogValue,[bool]$Location,[bool]$OrganizationalUnit,$OutputPath)
[pscustomobject]@{ [pscustomobject]@{
DisplayName = $DisplayName DisplayName = $DisplayName
ComputerName = $ComputerName ComputerName = $ComputerName
Location = $Location Location = $Location
OrganizationalUnit = $OrganizationalUnit OrganizationalUnit = $OrganizationalUnit
Gender = $Gender
WelcomeHeading = $welcomeHeading
LocationText = $locationText LocationText = $locationText
OutputPath = $OutputPath OutputPath = $OutputPath
Applied = -not $SkipApply Applied = -not $SkipApply
+35 -7
View File
@@ -53,6 +53,7 @@ computer_name=${computer_name^^}
location='' location=''
distinguished_name='' distinguished_name=''
organizational_unit='' organizational_unit=''
gender=''
read_ldif_value() { read_ldif_value() {
local attribute=$1 local attribute=$1
@@ -108,9 +109,18 @@ if [[ -n $DOMAIN_CONTROLLER && -n $BASE_DN ]] &&
user_result=$(ldapsearch -LLL -N -o ldif-wrap=no -Y GSSAPI \ user_result=$(ldapsearch -LLL -N -o ldif-wrap=no -Y GSSAPI \
-H "ldap://${ldap_server}" -b "$BASE_DN" \ -H "ldap://${ldap_server}" -b "$BASE_DN" \
"(&(objectCategory=person)(objectClass=user)(sAMAccountName=${account_name}))" \ "(&(objectCategory=person)(objectClass=user)(sAMAccountName=${account_name}))" \
displayName 2>/dev/null || true) displayName info 2>/dev/null || true)
directory_display_name=$(read_ldif_value displayName "$user_result") directory_display_name=$(read_ldif_value displayName "$user_result")
[[ -n $directory_display_name ]] && display_name=$directory_display_name [[ -n $directory_display_name ]] && display_name=$directory_display_name
directory_info=$(read_ldif_value info "$user_result")
gender=$(printf '%s\n' "$directory_info" | awk -F: '
tolower($1) ~ /^[[:space:]]*sgu-gender[[:space:]]*$/ {
value=tolower($2); gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
if (value == "male") print "Male"
else if (value == "female") print "Female"
exit
}
')
fi fi
else else
log_message 'WARN AD metadata query skipped because Kerberos or LDAP session data was unavailable.' log_message 'WARN AD metadata query skipped because Kerberos or LDAP session data was unavailable.'
@@ -136,6 +146,24 @@ with_article() {
fi fi
} }
case "$gender" in
Male)
welcome_text='Bienvenido,'
located_text='Estás ubicado en'
engineering_lab_text='Bienvenido al Laboratorio de Cómputo de Ingeniería.'
;;
Female)
welcome_text='Bienvenida,'
located_text='Estás ubicada en'
engineering_lab_text='Bienvenida al Laboratorio de Cómputo de Ingeniería.'
;;
*)
welcome_text='Te damos la bienvenida,'
located_text='Ubicación:'
engineering_lab_text='Acceso al Laboratorio de Cómputo de Ingeniería.'
;;
esac
if [[ -n $location && -n $organizational_unit ]]; then if [[ -n $location && -n $organizational_unit ]]; then
room_phrase=$(with_article "$location") room_phrase=$(with_article "$location")
ou_article=$(article_for "$organizational_unit") ou_article=$(article_for "$organizational_unit")
@@ -146,13 +174,13 @@ if [[ -n $location && -n $organizational_unit ]]; then
else else
ou_phrase="de ${organizational_unit}" ou_phrase="de ${organizational_unit}"
fi fi
location_text="Estás ubicado en ${room_phrase} ${ou_phrase}." location_text="${located_text} ${room_phrase} ${ou_phrase}."
elif [[ -n $location ]]; then elif [[ -n $location ]]; then
location_text="Estás ubicado en $(with_article "$location")." location_text="${located_text} $(with_article "$location")."
elif [[ -n $organizational_unit ]]; then elif [[ -n $organizational_unit ]]; then
location_text="Estás ubicado en $(with_article "$organizational_unit")." location_text="${located_text} $(with_article "$organizational_unit")."
else else
location_text='Bienvenido al Laboratorio de Cómputo de Ingeniería.' location_text=$engineering_lab_text
fi fi
width=1600 width=1600
@@ -210,7 +238,7 @@ if ! "${image_command[@]}" "$BASE_IMAGE" \
-gravity center \ -gravity center \
-font "$sans_font" -weight 700 -style Normal -pointsize "$welcome_size" \ -font "$sans_font" -weight 700 -style Normal -pointsize "$welcome_size" \
-fill '#D3E2FF' -stroke 'rgba(0,0,0,0.48)' -strokewidth 1 \ -fill '#D3E2FF' -stroke 'rgba(0,0,0,0.48)' -strokewidth 1 \
-annotate "+0-$(( 92 * scale / 100 ))" 'Bienvenido,' \ -annotate "+0-$(( 92 * scale / 100 ))" "$welcome_text" \
-font "$serif_font" -weight 700 -style Italic -pointsize "$name_size" \ -font "$serif_font" -weight 700 -style Italic -pointsize "$name_size" \
-fill white -annotate "+0-$(( 22 * scale / 100 ))" "$display_name" \ -fill white -annotate "+0-$(( 22 * scale / 100 ))" "$display_name" \
-font "$sans_font" -weight 400 -style Normal -pointsize "$location_size" \ -font "$sans_font" -weight 400 -style Normal -pointsize "$location_size" \
@@ -242,7 +270,7 @@ if [[ $applied == false ]] && command -v xfconf-query >/dev/null 2>&1; then
fi fi
if [[ $applied == true ]]; then if [[ $applied == true ]]; then
log_message "OK computer=${computer_name}; location=$([[ -n $location ]] && printf true || printf false); ou=$([[ -n $organizational_unit ]] && printf true || printf false); output=${output_path}" log_message "OK computer=${computer_name}; gender=${gender:-Neutral}; location=$([[ -n $location ]] && printf true || printf false); ou=$([[ -n $organizational_unit ]] && printf true || printf false); output=${output_path}"
else else
log_message 'WARN Wallpaper rendered, but no supported desktop background API was found.' log_message 'WARN Wallpaper rendered, but no supported desktop background API was found.'
fi fi
@@ -0,0 +1,8 @@
@echo off
setlocal
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%
@@ -0,0 +1,7 @@
@echo off
setlocal
set "SGU_BOOTSTRAP_IP=%~1"
set "SGU_VPN_POOL=%~2"
if "%SGU_VPN_POOL%"=="" set "SGU_VPN_POOL=172.30.0.0/24"
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Initialize-SguDomainController.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"'),'-NetworkConfigurationMode','PlatformManaged','-TrustedClientNetworks',$env:SGU_VPN_POOL,'-DnsForwarders','168.63.129.16'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-ServerIPv4Address',$env:SGU_BOOTSTRAP_IP) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode"
exit /b %errorlevel%
+11 -2
View File
@@ -1,5 +1,14 @@
@echo off @echo off
setlocal setlocal
set "SGU_BOOTSTRAP_IP=%~1" set "SGU_BOOTSTRAP_IP=%~1"
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"')); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',$env:SGU_BOOTSTRAP_IP) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode" set "SGU_CLIENT_IP=%~2"
exit /b %errorlevel% set "SGU_NETWORK_ALIAS=%~3"
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"'),'-PauseOnError'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',$env:SGU_BOOTSTRAP_IP) }; if ($env:SGU_CLIENT_IP) { $arguments += @('-ClientIPv4Address',$env:SGU_CLIENT_IP) }; if ($env:SGU_NETWORK_ALIAS) { $arguments += @('-NetworkInterfaceAlias',('"' + $env:SGU_NETWORK_ALIAS + '"')) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode"
set "SGU_EXIT_CODE=%errorlevel%"
if not "%SGU_EXIT_CODE%"=="0" (
echo.
echo SGU client enrollment did not complete. Review the elevated window or:
echo C:\ProgramData\SGU\Bootstrap\Client\latest-error.log
pause
)
exit /b %SGU_EXIT_CODE%
+41
View File
@@ -19,6 +19,7 @@ $defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' $interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
$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'
$computer = Get-CimInstance Win32_ComputerSystem $computer = Get-CimInstance Win32_ComputerSystem
if ($RequireDomainJoined -and -not $computer.PartOfDomain) { if ($RequireDomainJoined -and -not $computer.PartOfDomain) {
@@ -87,6 +88,41 @@ if (-not $passwordProviderPreserved) {
$issues.Add('The built-in Microsoft password provider registration is missing.') $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 =
$standardLocalUserPresent -and $standardLocalUser.PasswordNeverExpires
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
}
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' does not retain its enrollment password.")
}
$settings = $null $settings = $null
try { try {
$settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json $settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json
@@ -207,6 +243,11 @@ $result = [pscustomobject]@{
LastSignedInUserHidden = $lastSignedInUserHidden LastSignedInUserHidden = $lastSignedInUserHidden
LocalUserEnumerationDisabled = $localUserEnumerationDisabled LocalUserEnumerationDisabled = $localUserEnumerationDisabled
PasswordProviderPreserved = $passwordProviderPreserved PasswordProviderPreserved = $passwordProviderPreserved
StandardLocalUserPresent = $standardLocalUserPresent
StandardLocalUserEnabled = $standardLocalUserEnabled
StandardLocalUserIsAdministrator = $standardLocalUserIsAdministrator
StandardLocalUserInUsersGroup = $standardLocalUserInUsersGroup
StandardLocalUserPasswordNeverExpires = $standardLocalUserPasswordNeverExpires
SettingsPresent = [bool]$settings SettingsPresent = [bool]$settings
ClientCertificatePresent = [bool]$clientCertificatePresent ClientCertificatePresent = [bool]$clientCertificatePresent
ServerCertificateTrusted = $serverCertificateTrusted ServerCertificateTrusted = $serverCertificateTrusted
@@ -0,0 +1,7 @@
namespace SGU.AuthBroker.Core.Profiles;
public enum InstitutionalGender
{
Male,
Female
}
@@ -12,7 +12,8 @@ public sealed record InstitutionalProfile(
string? StreetAddress = null, string? StreetAddress = null,
string? City = null, string? City = null,
string? State = null, string? State = null,
string? PostalCode = null) string? PostalCode = null,
InstitutionalGender? Gender = null)
{ {
public bool HasValues => public bool HasValues =>
EmployeeNumber is not null || EmployeeNumber is not null ||
@@ -26,7 +27,8 @@ public sealed record InstitutionalProfile(
StreetAddress is not null || StreetAddress is not null ||
City is not null || City is not null ||
State is not null || State is not null ||
PostalCode is not null; PostalCode is not null ||
Gender is not null;
public InstitutionalProfile Overlay(InstitutionalProfile? values) => public InstitutionalProfile Overlay(InstitutionalProfile? values) =>
values is null values is null
@@ -44,6 +46,7 @@ public sealed record InstitutionalProfile(
StreetAddress = values.StreetAddress ?? StreetAddress, StreetAddress = values.StreetAddress ?? StreetAddress,
City = values.City ?? City, City = values.City ?? City,
State = values.State ?? State, State = values.State ?? State,
PostalCode = values.PostalCode ?? PostalCode PostalCode = values.PostalCode ?? PostalCode,
Gender = values.Gender ?? Gender
}; };
} }
@@ -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;
using System.Net.Mail; using System.Net.Mail;
using System.Text; using System.Text;
using System.Text.Json;
namespace SGU.AuthBroker.Core.Profiles; namespace SGU.AuthBroker.Core.Profiles;
@@ -14,6 +15,8 @@ public static class SguProfileParser
private const string AdministrativeGivenNameId = "ctl00_contenedor_txtNombre"; private const string AdministrativeGivenNameId = "ctl00_contenedor_txtNombre";
private const string AdministrativePaternalSurnameId = "ctl00_contenedor_txtApaterno"; private const string AdministrativePaternalSurnameId = "ctl00_contenedor_txtApaterno";
private const string AdministrativeMaternalSurnameId = "ctl00_contenedor_txtAmaterno"; private const string AdministrativeMaternalSurnameId = "ctl00_contenedor_txtAmaterno";
private const string AdministrativeGenderId = "ctl00_contenedor_ddlsexo";
private const string AdministrativeGenderName = "ctl00$contenedor$ddlsexo";
private const string AdministrativeStreetId = "ctl00_contenedor_txtCalle"; private const string AdministrativeStreetId = "ctl00_contenedor_txtCalle";
private const string AdministrativeExteriorNumberId = "ctl00_contenedor_txtNoExt"; private const string AdministrativeExteriorNumberId = "ctl00_contenedor_txtNoExt";
private const string AdministrativeInteriorNumberId = "ctl00_contenedor_txtNoInt"; private const string AdministrativeInteriorNumberId = "ctl00_contenedor_txtNoInt";
@@ -34,6 +37,7 @@ public static class SguProfileParser
private const string StudentCityId = "ctl00_contenedor_HistorialAlumno1_lblCiudadAlumnoHP"; private const string StudentCityId = "ctl00_contenedor_HistorialAlumno1_lblCiudadAlumnoHP";
private const string StudentMunicipalityId = "ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP"; private const string StudentMunicipalityId = "ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP";
private const string StudentPostalCodeId = "ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP"; private const string StudentPostalCodeId = "ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP";
private const string StudentGenderId = "ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP";
public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber) public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber)
=> ParseStaffHeader(html, expectedEmployeeNumber); => ParseStaffHeader(html, expectedEmployeeNumber);
@@ -91,11 +95,22 @@ public static class SguProfileParser
InstitutionalProfile profile = new( InstitutionalProfile profile = new(
DisplayName: displayName, DisplayName: displayName,
GivenName: givenName, GivenName: givenName,
Surname: surname); Surname: surname,
Gender: ParseStaffGender(ExtractSelectedOptionValue(
html,
AdministrativeGenderId,
AdministrativeGenderName)));
return profile.HasValues ? profile : null; 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); ArgumentNullException.ThrowIfNull(html);
@@ -105,18 +120,79 @@ public static class SguProfileParser
string? interiorNumber = NormalizeAddressUnit( string? interiorNumber = NormalizeAddressUnit(
ExtractInputValue(html, AdministrativeInteriorNumberId)); ExtractInputValue(html, AdministrativeInteriorNumberId));
string? streetLine = BuildAdministrativeStreetLine(street, exteriorNumber, interiorNumber); string? streetLine = BuildAdministrativeStreetLine(street, exteriorNumber, interiorNumber);
string? neighborhood = NormalizeTitle( string? neighborhood = NormalizeTitle(FirstNonEmpty(
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId), selection?.NeighborhoodName,
256); 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( InstitutionalProfile profile = new(
StreetAddress: BuildStreetAddress(streetLine, neighborhood, null, null), StreetAddress: BuildStreetAddress(streetLine, neighborhood, null, null),
City: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeCityId), 128), City: city,
State: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeStateId), 128), State: state,
PostalCode: NormalizePostalCode(ExtractInputValue(html, AdministrativePostalCodeId))); PostalCode: postalCode);
return profile.HasValues ? profile : null; 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) public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
{ {
ArgumentNullException.ThrowIfNull(html); ArgumentNullException.ThrowIfNull(html);
@@ -151,7 +227,8 @@ public static class SguProfileParser
StreetAddress: streetAddress, StreetAddress: streetAddress,
City: city ?? municipality, City: city ?? municipality,
State: NormalizeTitle(ExtractSpanText(html, StudentStateId), 128), State: NormalizeTitle(ExtractSpanText(html, StudentStateId), 128),
PostalCode: NormalizePostalCode(ExtractSpanText(html, StudentPostalCodeId))); PostalCode: NormalizePostalCode(ExtractSpanText(html, StudentPostalCodeId)),
Gender: ParseStudentGender(ExtractSpanText(html, StudentGenderId)));
return profile.HasValues ? profile : null; return profile.HasValues ? profile : null;
} }
@@ -292,11 +369,248 @@ public static class SguProfileParser
return nonPlaceholderOptions.Count == 1 ? nonPlaceholderOptions[0] : null; return nonPlaceholderOptions.Count == 1 ? nonPlaceholderOptions[0] : null;
} }
private static string? ExtractSelectedOptionValue(string html, string id, string name)
{
string? openingTag = FindOpeningTag(html, "select", id) ??
FindOpeningTagByAttribute(html, "select", "name", name);
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? selectedValue = ExtractAttributeValue(openingTag, "value");
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);
if (optionTagEnd < 0)
{
break;
}
string optionTag = optionsHtml[optionStart..(optionTagEnd + 1)];
string? optionValue = ExtractAttributeValue(optionTag, "value");
if (optionValue is not null &&
(HasAttribute(optionTag, "selected") ||
(selectedValue is not null &&
string.Equals(optionValue, selectedValue, StringComparison.Ordinal))))
{
return NormalizeText(optionValue);
}
searchFrom = optionTagEnd + 1;
}
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) private static string? FindOpeningTag(string html, string tagName, string id)
=> FindOpeningTagByAttribute(html, tagName, "id", id);
private static string? FindOpeningTagByAttribute(
string html,
string tagName,
string attributeName,
string attributeValue)
{ {
foreach (char quote in new[] { '"', '\'' }) foreach (char quote in new[] { '"', '\'' })
{ {
string marker = $"id={quote}{id}{quote}"; string marker = $"{attributeName}={quote}{attributeValue}{quote}";
int searchFrom = 0; int searchFrom = 0;
while (searchFrom < html.Length) while (searchFrom < html.Length)
{ {
@@ -324,6 +638,20 @@ public static class SguProfileParser
return null; return null;
} }
private static InstitutionalGender? ParseStaffGender(string? value) => value?.Trim() switch
{
"1" => InstitutionalGender.Male,
"2" => InstitutionalGender.Female,
_ => null
};
private static InstitutionalGender? ParseStudentGender(string? value) => value?.Trim().ToUpperInvariant() switch
{
"M" => InstitutionalGender.Male,
"F" => InstitutionalGender.Female,
_ => null
};
private static string? ExtractAttributeValue(string openingTag, string attributeName) private static string? ExtractAttributeValue(string openingTag, string attributeName)
{ {
foreach (char quote in new[] { '"', '\'' }) foreach (char quote in new[] { '"', '\'' })
+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 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; public string RemoteDesktopGroupDn { get; init; } = string.Empty;
@@ -11,6 +11,8 @@ public sealed class ActiveDirectorySynchronizer(
BrokerOptions options, BrokerOptions options,
ILogger<ActiveDirectorySynchronizer> logger) : IActiveDirectorySynchronizer ILogger<ActiveDirectorySynchronizer> logger) : IActiveDirectorySynchronizer
{ {
private const string GenderMetadataPrefix = "SGU-Gender:";
private const int InfoAttributeMaximumLength = 1024;
private const int AccountDisabled = 0x0002; private const int AccountDisabled = 0x0002;
private const int NormalAccount = 0x0200; private const int NormalAccount = 0x0200;
private static readonly AuthenticationTypes BindFlags = private static readonly AuthenticationTypes BindFlags =
@@ -164,6 +166,7 @@ public sealed class ActiveDirectorySynchronizer(
SetOptionalProperty(user, "l", profile.City); SetOptionalProperty(user, "l", profile.City);
SetOptionalProperty(user, "st", profile.State); SetOptionalProperty(user, "st", profile.State);
SetOptionalProperty(user, "postalCode", profile.PostalCode); SetOptionalProperty(user, "postalCode", profile.PostalCode);
SetGenderMetadata(user, profile.Gender, identity.UserName, logger);
if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal)) if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal))
{ {
SetOptionalProperty(user, "employeeID", profile.EmployeeNumber); SetOptionalProperty(user, "employeeID", profile.EmployeeNumber);
@@ -201,6 +204,56 @@ public sealed class ActiveDirectorySynchronizer(
} }
} }
private static void SetGenderMetadata(
DirectoryEntry entry,
InstitutionalGender? gender,
string institutionalUser,
ILogger logger)
{
if (gender is null)
{
return;
}
string existing = Convert.ToString(entry.Properties["info"].Value) ?? string.Empty;
string? updated = MergeGenderMetadata(existing, gender);
if (updated is null)
{
logger.LogWarning(
BrokerEventIds.DirectoryOptionalMetadataFailure,
"Gender metadata was not written for {InstitutionalUser} because the Active Directory info attribute has no remaining capacity.",
institutionalUser);
return;
}
entry.Properties["info"].Value = updated;
}
internal static string? MergeGenderMetadata(
string? existing,
InstitutionalGender? gender)
{
if (gender is null)
{
return null;
}
string managedLine = $"{GenderMetadataPrefix} {gender}";
string normalizedExisting = (existing ?? string.Empty)
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n');
string[] preservedLines = string.IsNullOrEmpty(normalizedExisting)
? []
: normalizedExisting
.Split('\n')
.Where(line => !line.TrimStart().StartsWith(
GenderMetadataPrefix,
StringComparison.OrdinalIgnoreCase))
.ToArray();
string updated = string.Join("\r\n", preservedLines.Append(managedLine));
return updated.Length <= InfoAttributeMaximumLength ? updated : null;
}
private void EnsureRoleGroupMembership(DirectoryEntry user, UserIdentity identity) private void EnsureRoleGroupMembership(DirectoryEntry user, UserIdentity identity)
{ {
user.RefreshCache(["distinguishedName"]); user.RefreshCache(["distinguishedName"]);
@@ -1,5 +1,7 @@
using System.Diagnostics; using System.Diagnostics;
using System.Net; using System.Net;
using System.Text;
using System.Text.Json;
using SGU.AuthBroker.Core.Authentication; using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Identity; using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles; using SGU.AuthBroker.Core.Profiles;
@@ -431,9 +433,6 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
pages.Add(( pages.Add((
options.AdministrativePersonalProfilePath, options.AdministrativePersonalProfilePath,
SguProfileParser.ParseAdministrativePersonal)); SguProfileParser.ParseAdministrativePersonal));
pages.Add((
options.AdministrativeLocationProfilePath,
SguProfileParser.ParseAdministrativeLocation));
foreach ((string path, Func<string, InstitutionalProfile?> parser) in pages) 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.", "SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
identity.Role, identity.Role,
elapsed.ElapsedMilliseconds); elapsed.ElapsedMilliseconds);
break; return profile;
} }
catch (Exception exception) 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; return profile;
} }
@@ -545,6 +680,46 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
return null; 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( private static void AddCredential(
Uri uri, Uri uri,
CredentialCache credentialCache, CredentialCache credentialCache,
@@ -658,6 +833,12 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
private Uri GetProfileUri(string path) => private Uri GetProfileUri(string path) =>
new(new Uri(options.Endpoint, UriKind.Absolute), 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( private async Task<InstitutionalProfile?> TryReadProfileAsync(
HttpResponseMessage response, HttpResponseMessage response,
UserIdentity identity, UserIdentity identity,
+3 -3
View File
@@ -50,9 +50,9 @@
"ProfessorOuDn": "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx", "ProfessorOuDn": "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"StudentOuDn": "OU=Alumnos,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", "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", "ProfessorGroupDn": "CN=SGU-Docentes,OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"StudentGroupDn": "CN=SGU-Alumnos,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=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx", "AdministrativeGroupDn": "CN=SGU-Administrativos,OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"RemoteDesktopGroupDn": "", "RemoteDesktopGroupDn": "",
"DefaultCompany": "La Salle", "DefaultCompany": "La Salle",
"CreateMissingOus": false "CreateMissingOus": false
+122
View File
@@ -0,0 +1,122 @@
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$serverBootstrapPath = Join-Path $repositoryRoot 'scripts\Initialize-SguDomainController.ps1'
$clientBootstrapPath = Join-Path $repositoryRoot 'scripts\Invoke-SguClientBootstrap.ps1'
$azureClientPath = Join-Path $repositoryRoot 'scripts\Install-SguAzureP2sClient.ps1'
$bicepPath = Join-Path $repositoryRoot 'infra\azure\main.bicep'
$tokens = $null
$parseErrors = $null
$serverAst = [Management.Automation.Language.Parser]::ParseFile(
$serverBootstrapPath,
[ref]$tokens,
[ref]$parseErrors)
if ($parseErrors.Count -gt 0) {
throw ($parseErrors -join [Environment]::NewLine)
}
$networkFunctionNames = @(
'Test-PrivateIPv4Address',
'ConvertTo-NetworkCidr',
'ConvertTo-PrivateNetworkCidr'
)
$networkFunctions = $serverAst.FindAll({
param($node)
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and
$networkFunctionNames -contains $node.Name
}, $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') `
-NetworkPrefixLength 24 | Should Be '10.77.0.0/24'
}
It 'canonicalizes the trusted P2S pool' {
ConvertTo-PrivateNetworkCidr -Cidr '172.30.4.19/16' |
Should Be '172.30.0.0/16'
}
It 'rejects a public trusted-client CIDR' {
$wasRejected = $false
try {
ConvertTo-PrivateNetworkCidr -Cidr '8.8.8.0/24' | Out-Null
}
catch {
$wasRejected = $true
}
$wasRejected | Should Be $true
}
It 'exposes explicit Azure modes on both bootstraps' {
((Get-Command $serverBootstrapPath).Parameters.Keys -contains
'NetworkConfigurationMode') | Should Be $true
((Get-Command $serverBootstrapPath).Parameters.Keys -contains
'TrustedClientNetworks') | Should Be $true
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
'ConnectivityMode') | Should Be $true
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
'VpnProfilePackagePath') | Should Be $true
}
It 'accepts an explicit static IPv4 address for a private Windows adapter' {
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
'ClientIPv4Address') | Should Be $true
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
'ClientPrefixLength') | Should Be $true
}
It 'matches a client and domain controller within the requested prefix' {
Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]'192.168.50.11') `
-SecondAddress ([ipaddress]'192.168.50.10') -PrefixLength 24 |
Should Be $true
Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]'192.168.51.11') `
-SecondAddress ([ipaddress]'192.168.50.10') -PrefixLength 24 |
Should Be $false
Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]'10.77.15.20') `
-SecondAddress ([ipaddress]'10.77.0.4') -PrefixLength 16 |
Should Be $true
}
It 'prefers the private adapter instead of the Internet default route' {
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
$source | Should Match '\$withoutDefaultGateway\.Count -eq 1'
$source | Should Not Match "Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0\.0\.0\.0/0'"
}
It 'waits for the new address and WinRM route to stabilize' {
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
$source | Should Match "AddressState -eq 'Preferred'"
$source | Should Match 'function Wait-TcpPort'
$source | Should Match 'Wait-TcpPort -Address \$DomainControllerIPv4Address -Port 5985'
}
It 'uses an all-user machine-certificate VPN profile' {
$source = Get-Content -LiteralPath $azureClientPath -Raw
$source | Should Match '-AuthenticationMethod MachineCertificate'
$source | Should Match '-AllUserConnection'
$source | Should Match 'Add-DnsClientNrptRule'
}
It 'limits optional public administration to RDP' {
$template = Get-Content -LiteralPath $bicepPath -Raw
$template | Should Match "name: 'Allow-RDP-from-administrator'"
$template | Should Match "destinationPortRange: '3389'"
$template | Should Not Match "sourceAddressPrefix: '0\.0\.0\.0/0'"
}
}
@@ -133,6 +133,64 @@ public sealed class SguProfileParserTests
Assert.Null(profile.Email); Assert.Null(profile.Email);
} }
[Theory]
[InlineData("ctl00_contenedor_ddlsexo", "ctl00$contenedor$ddlsexo", "1", InstitutionalGender.Male)]
[InlineData("alternate-id", "ctl00$contenedor$ddlsexo", "2", InstitutionalGender.Female)]
public void ParsesStaffGenderFromTheSelectedPersonalDataOption(
string id,
string name,
string selectedValue,
InstitutionalGender expected)
{
string html = $"""
<select id="{id}" name="{name}">
<option value="">Seleccione...</option>
<option value="1"{(selectedValue == "1" ? " selected=\"selected\"" : string.Empty)}>Masculino</option>
<option value="2"{(selectedValue == "2" ? " selected=\"selected\"" : string.Empty)}>Femenino</option>
</select>
""";
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativePersonal(html);
Assert.NotNull(profile);
Assert.Equal(expected, profile.Gender);
}
[Theory]
[InlineData("M", InstitutionalGender.Male)]
[InlineData("f", InstitutionalGender.Female)]
public void ParsesStudentGenderFromTheInformationSpan(
string source,
InstitutionalGender expected)
{
string html = $"""
<span id="ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP">123456</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP">{source}</span>
""";
InstitutionalProfile? profile = SguProfileParser.ParseStudent(html, "123456");
Assert.NotNull(profile);
Assert.Equal(expected, profile.Gender);
}
[Fact]
public void IgnoresUnknownGenderValuesWithoutFailingProfileParsing()
{
const string html = """
<input id="ctl00_contenedor_txtNombre" value="PERSONA" />
<select id="ctl00_contenedor_ddlsexo">
<option selected="selected" value="9">SIN CLASIFICAR</option>
</select>
""";
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativePersonal(html);
Assert.NotNull(profile);
Assert.Equal("Persona", profile.DisplayName);
Assert.Null(profile.Gender);
}
[Fact] [Fact]
public void ParsesAdministrativeAddressFromInputsAndSelectedOptions() public void ParsesAdministrativeAddressFromInputsAndSelectedOptions()
{ {
@@ -170,6 +228,65 @@ public sealed class SguProfileParserTests
Assert.Null(profile.Email); 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] [Fact]
public void AdministrativePagesOverlayTheVerifiedIncidentsProfile() public void AdministrativePagesOverlayTheVerifiedIncidentsProfile()
{ {
@@ -182,7 +299,8 @@ public sealed class SguProfileParserTests
InstitutionalProfile personal = new( InstitutionalProfile personal = new(
DisplayName: "María del Carmen de la Fuente", DisplayName: "María del Carmen de la Fuente",
GivenName: "María del Carmen", GivenName: "María del Carmen",
Surname: "de la Fuente"); Surname: "de la Fuente",
Gender: InstitutionalGender.Female);
InstitutionalProfile location = new( InstitutionalProfile location = new(
StreetAddress: "Calle Uno 10", StreetAddress: "Calle Uno 10",
City: "Ciudad de México", City: "Ciudad de México",
@@ -200,6 +318,7 @@ public sealed class SguProfileParserTests
Assert.Equal("Ingeniería", combined.Department); Assert.Equal("Ingeniería", combined.Department);
Assert.Equal("Calle Uno 10", combined.StreetAddress); Assert.Equal("Calle Uno 10", combined.StreetAddress);
Assert.Equal("01000", combined.PostalCode); Assert.Equal("01000", combined.PostalCode);
Assert.Equal(InstitutionalGender.Female, combined.Gender);
} }
[Fact] [Fact]
@@ -215,6 +334,7 @@ public sealed class SguProfileParserTests
<span id="ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP"> <span id="ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP">
<a href="mailto:alumna@lasalle.mx">ALUMNA@LASALLE.MX</a> <a href="mailto:alumna@lasalle.mx">ALUMNA@LASALLE.MX</a>
</span> </span>
<span id="ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP">F</span>
<span id="ctl00_contenedor_HistorialAlumno1_lblCURPAlumnoHP"> <span id="ctl00_contenedor_HistorialAlumno1_lblCURPAlumnoHP">
DATO-SENSIBLE-QUE-NO-DEBE-EXTRAERSE DATO-SENSIBLE-QUE-NO-DEBE-EXTRAERSE
</span> </span>
@@ -262,6 +382,7 @@ public sealed class SguProfileParserTests
Assert.Equal("Ciudad de México", profile.City); Assert.Equal("Ciudad de México", profile.City);
Assert.Equal("Ciudad de México", profile.State); Assert.Equal("Ciudad de México", profile.State);
Assert.Equal("08500", profile.PostalCode); Assert.Equal("08500", profile.PostalCode);
Assert.Equal(InstitutionalGender.Female, profile.Gender);
} }
[Fact] [Fact]
@@ -0,0 +1,34 @@
using SGU.AuthBroker.Core.Profiles;
using SGU.AuthBroker.Services;
using Xunit;
namespace SGU.AuthBroker.Tests;
public sealed class ActiveDirectorySynchronizerTests
{
[Fact]
public void GenderMetadataPreservesUnmanagedNotesAndReplacesItsManagedLine()
{
const string existing = " Responsable de laboratorio \r\n\r\nSGU-Gender: Male\r\nTurno vespertino";
string? updated = ActiveDirectorySynchronizer.MergeGenderMetadata(
existing,
InstitutionalGender.Female);
Assert.Equal(
" Responsable de laboratorio \r\n\r\nTurno vespertino\r\nSGU-Gender: Female",
updated);
}
[Fact]
public void GenderMetadataDoesNotTruncateAnExistingFullNotesField()
{
string existing = new('x', 1024);
string? updated = ActiveDirectorySynchronizer.MergeGenderMetadata(
existing,
InstitutionalGender.Male);
Assert.Null(updated);
}
}
@@ -31,9 +31,9 @@ public sealed class BrokerOptionsTests
} }
[Theory] [Theory]
[InlineData(InstitutionalRole.Student, "CN=SGU-Alumnos,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=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=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) public void DefaultRoleGroupMappingsMatchInstitutionalPrefixes(InstitutionalRole role, string expectedGroupDn)
{ {
ActiveDirectoryOptions options = new(); ActiveDirectoryOptions options = new();
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using SGU.AuthBroker.Core.Authentication; using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Identity; using SGU.AuthBroker.Core.Identity;
using SGU.AuthBroker.Core.Profiles;
using SGU.AuthBroker.Options; using SGU.AuthBroker.Options;
using SGU.AuthBroker.Services; using SGU.AuthBroker.Services;
using Xunit; using Xunit;
@@ -132,6 +133,10 @@ public sealed class NtlmCredentialValidatorTests
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" /> <input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" /> <input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" /> <input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" />
<select name="ctl00$contenedor$ddlsexo">
<option value="1">Masculino</option>
<option selected="selected" value="2">Femenino</option>
</select>
"""), """),
Response( Response(
HttpStatusCode.OK, HttpStatusCode.OK,
@@ -140,14 +145,24 @@ public sealed class NtlmCredentialValidatorTests
<input id="ctl00_contenedor_txtNoExt" value="15" /> <input id="ctl00_contenedor_txtNoExt" value="15" />
<input id="ctl00_contenedor_txtCP" value="01000" /> <input id="ctl00_contenedor_txtCP" value="01000" />
<select id="ctl00_contenedor_ddlEstado"> <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>
<select id="ctl00_contenedor_ddlLocalidad"> <select id="ctl00_contenedor_ddlLocalidad">
<option selected="selected">ÁLVARO OBREGÓN</option> <option selected="selected" value="0">Seleccione alguna localidad...</option>
</select> </select>
<select id="ctl00_contenedor_ddlColonia"> <select id="ctl00_contenedor_ddlColonia">
<option>FLORIDA</option> <option selected="selected" value="0,0">Seleccione alguna colonia...</option>
</select> </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); NtlmCredentialValidator validator = CreateValidator(handler);
@@ -168,15 +183,24 @@ public sealed class NtlmCredentialValidatorTests
Assert.Equal("Álvaro Obregón", result.Profile.City); Assert.Equal("Álvaro Obregón", result.Profile.City);
Assert.Equal("Ciudad de México", result.Profile.State); Assert.Equal("Ciudad de México", result.Profile.State);
Assert.Equal("01000", result.Profile.PostalCode); Assert.Equal("01000", result.Profile.PostalCode);
Assert.Equal(InstitutionalGender.Female, result.Profile.Gender);
Assert.Equal( Assert.Equal(
[ [
"/psulsa/", "/psulsa/",
"/psulsa/", "/psulsa/",
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx", "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
"/psulsa/gadmon/capitalhumano/datos/personales.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); 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] [Fact]
@@ -224,6 +248,10 @@ public sealed class NtlmCredentialValidatorTests
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" /> <input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" /> <input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" /> <input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" />
<select id="ctl00_contenedor_ddlsexo">
<option selected="selected" value="1">Masculino</option>
<option value="2">Femenino</option>
</select>
"""), """),
Response( Response(
HttpStatusCode.OK, HttpStatusCode.OK,
@@ -232,14 +260,24 @@ public sealed class NtlmCredentialValidatorTests
<input id="ctl00_contenedor_txtNoExt" value="15" /> <input id="ctl00_contenedor_txtNoExt" value="15" />
<input id="ctl00_contenedor_txtCP" value="01000" /> <input id="ctl00_contenedor_txtCP" value="01000" />
<select id="ctl00_contenedor_ddlEstado"> <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>
<select id="ctl00_contenedor_ddlLocalidad"> <select id="ctl00_contenedor_ddlLocalidad">
<option selected="selected">ÁLVARO OBREGÓN</option> <option selected="selected" value="0">Seleccione alguna localidad...</option>
</select> </select>
<select id="ctl00_contenedor_ddlColonia"> <select id="ctl00_contenedor_ddlColonia">
<option selected="selected">FLORIDA</option> <option selected="selected" value="0,0">Seleccione alguna colonia...</option>
</select> </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); NtlmCredentialValidator validator = CreateValidator(handler);
@@ -262,6 +300,7 @@ public sealed class NtlmCredentialValidatorTests
Assert.Equal("Álvaro Obregón", result.Profile.City); Assert.Equal("Álvaro Obregón", result.Profile.City);
Assert.Equal("Ciudad de México", result.Profile.State); Assert.Equal("Ciudad de México", result.Profile.State);
Assert.Equal("01000", result.Profile.PostalCode); Assert.Equal("01000", result.Profile.PostalCode);
Assert.Equal(InstitutionalGender.Male, result.Profile.Gender);
Assert.Equal( Assert.Equal(
[ [
"/psulsa/", "/psulsa/",
@@ -269,7 +308,10 @@ public sealed class NtlmCredentialValidatorTests
"/psulsa/menu.aspx", "/psulsa/menu.aspx",
"/psulsa/gadmon/nomina/consultanomina.aspx", "/psulsa/gadmon/nomina/consultanomina.aspx",
"/psulsa/gadmon/capitalhumano/datos/personales.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); handler.RequestPaths);
} }
@@ -352,18 +394,29 @@ public sealed class NtlmCredentialValidatorTests
Content = new StringContent(content) 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 sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
{ {
private readonly Queue<HttpResponseMessage> responses = new(responses); private readonly Queue<HttpResponseMessage> responses = new(responses);
public List<string> RequestPaths { get; } = []; public List<string> RequestPaths { get; } = [];
public List<string?> RequestBodies { get; } = [];
protected override Task<HttpResponseMessage> SendAsync( protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, HttpRequestMessage request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
RequestPaths.Add(request.RequestUri!.AbsolutePath); RequestPaths.Add(request.RequestUri!.AbsolutePath);
RequestBodies.Add(request.Content is null
? null
: request.Content.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult());
if (responses.Count == 0) if (responses.Count == 0)
{ {
throw new InvalidOperationException("The validator sent more requests than expected."); throw new InvalidOperationException("The validator sent more requests than expected.");