Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74235ec6f8 | ||
|
|
0b510082d4 | ||
|
|
5e62a65316 | ||
|
|
1332f546fa | ||
|
|
14c919b380 | ||
|
|
a6bd625e4e | ||
|
|
8baa47fe1e | ||
|
|
a24c25a3fb | ||
|
|
991fc70600 | ||
|
|
93871b62b0 | ||
|
|
1fe2006404 | ||
|
|
7a4f599f55 |
@@ -9,7 +9,7 @@ source and adds an SGU-specific provider, an mTLS-protected broker, Active
|
||||
Directory synchronization, deployment scripts, and tests.
|
||||
|
||||
Ready-to-run bootstrap packages are published on the
|
||||
[releases page](https://github.lci.ulsa.mx/alexrg/SGU-CredentialProvider/releases).
|
||||
[releases page](https://gitea.lci.ulsa.mx/alexrg/SGU-CredentialProvider/releases).
|
||||
|
||||
## Authentication contract
|
||||
|
||||
@@ -44,6 +44,13 @@ skips that optional field. Missing or changed presentation HTML never blocks
|
||||
authentication or password synchronization after the lightweight NTLM root has
|
||||
accepted the credential.
|
||||
|
||||
For administrative staff and professors, the location page is enriched with its
|
||||
ASP.NET PageMethods responses. `GetDireccion` supplies the saved state,
|
||||
municipality and neighborhood identifiers; `GetLocalidadListado` resolves the
|
||||
municipality name, and `GetColoniasListado` validates or supplies the
|
||||
neighborhood name. This avoids reading the temporary `Seleccione...` values
|
||||
visible while the browser populates those controls asynchronously.
|
||||
|
||||
Operational documentation:
|
||||
|
||||
- [One-command server recovery and client enrollment](docs/bootstrap-recovery.md)
|
||||
@@ -55,11 +62,11 @@ Operational documentation:
|
||||
- [Domain monitoring, usage reports, and six-month retention](docs/monitoring.md)
|
||||
- [Decision: do not persist password verifiers in Redis](docs/decisions/0001-no-password-cache.md)
|
||||
|
||||
| Prefix | Role | Default OU |
|
||||
|---|---|---|
|
||||
| `DO` | Professor / docente | `OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
|
||||
| `AL` | Student / alumno | `OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
|
||||
| `AD` | Administrative | `OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
|
||||
| Prefix | Role | Default OU | Security group in the same OU |
|
||||
|---|---|---|---|
|
||||
| `DO` | Professor / docente | `OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` | `SGU-Docentes` |
|
||||
| `AL` | Student / alumno | `OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` | `SGU-Alumnos` |
|
||||
| `AD` | Administrative | `OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` | `SGU-Administrativos` |
|
||||
|
||||
If the broker or institutional NTLM authority is unavailable, the provider
|
||||
submits the unchanged credentials to Windows for normal AD/cached-domain
|
||||
@@ -97,17 +104,35 @@ the latest .NET 10 x64 runtime. The broker is published self-contained.
|
||||
Follow [docs/lab-runbook.md](docs/lab-runbook.md). Review
|
||||
[docs/security.md](docs/security.md) before production deployment and
|
||||
[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
|
||||
supported recovery path if a third-party provider fails to load.
|
||||
|
||||
For a clean machine, the supported entry points are the release packages:
|
||||
For a clean machine, choose the release package that matches the workstation:
|
||||
|
||||
- `sgu-windows10-legacy-client-bootstrap-VERSION.zip` for Windows 10;
|
||||
- `sgu-windows11-client-bootstrap-VERSION.zip` for Windows 11, including the
|
||||
modern Azure P2S/pre-logon flow.
|
||||
|
||||
Both use the same direct-lab entry point:
|
||||
|
||||
```bat
|
||||
Start-SguServerBootstrap.cmd 192.168.50.10
|
||||
Start-SguClientEnrollment.cmd 192.168.50.10
|
||||
Start-SguClientEnrollment.cmd 192.168.50.10 192.168.50.11
|
||||
```
|
||||
|
||||
El segundo argumento es la IP fija, única, del cliente en la red privada. Si se
|
||||
omite y ese adaptador todavía usa una dirección `169.254.x.x`, el enrolador la
|
||||
solicita de forma interactiva. En equipos con dos NIC selecciona el adaptador
|
||||
sin puerta de enlace y conserva el `Default Switch` para Internet.
|
||||
|
||||
El manifiesto identifica el perfil `Windows10Legacy` o `Windows11Modern` y el
|
||||
bootstrap valida el build antes de hacer cambios. Las correcciones comunes se
|
||||
mantienen en ambos; Windows 11 conserva además sus puntos de entrada modernos.
|
||||
|
||||
Linux clients are enrolled through their native PAM/SSSD stack instead of the
|
||||
Windows Credential Provider:
|
||||
|
||||
|
||||
+23
-3
@@ -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
|
||||
page for `AL`, and the portal menu as a conservative base for `DO`. After the
|
||||
incident page confirms an `AD` employee number, two additional GETs in the same
|
||||
in-memory session read the structured name from `datos/personales.aspx` and the
|
||||
address from `datos/ubicacion.aspx`. Docentes request
|
||||
in-memory session read the structured name and selected sex from
|
||||
`datos/personales.aspx` and the address inputs from `datos/ubicacion.aspx`. The
|
||||
broker then calls the location page's `GetDireccion`, `GetLocalidadListado`, and
|
||||
`GetColoniasListado` methods to correlate the saved state, municipality, and
|
||||
neighborhood identifiers instead of reading transient `Seleccione...` options.
|
||||
Docentes request
|
||||
`nomina/consultanomina.aspx` for a matching employee number, email, employee
|
||||
type and job title, then attempt the same two shared staff modules without
|
||||
requiring any optional route to exist. A supplemental
|
||||
@@ -61,7 +65,9 @@ passes the submitted password directly to ADSI `SetPassword`.
|
||||
When the authenticated HTML exposes recognized stable IDs, the broker also
|
||||
updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`,
|
||||
`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
|
||||
trusted. Administrative personal and location pages are accepted only after
|
||||
that incident-page match. Docente payroll metadata must match the requested
|
||||
@@ -72,6 +78,17 @@ is deliberately left unset because the verified page does not expose it.
|
||||
Missing metadata does not clear existing AD values and never changes the
|
||||
password outcome.
|
||||
|
||||
Every synchronization also enforces one idempotent security-group membership
|
||||
from the classified institutional prefix: `AL` to `SGU-Alumnos`, `AD` to
|
||||
`SGU-Administrativos`, and `DO` to `SGU-Docentes`. Each role group is stored
|
||||
inside its corresponding user OU. During an upgrade,
|
||||
the bootstrap moves a legacy group from the `Usuarios-SGU` root while preserving
|
||||
its SID and memberships instead of creating a duplicate. Membership enforcement
|
||||
happens synchronously inside the broker before the institutional password is written to AD. A missing
|
||||
or inaccessible role group therefore fails provisioning instead of leaving a
|
||||
new usable account without its authorization classification. Existing accounts
|
||||
are repaired automatically on their next successful SGU authentication.
|
||||
|
||||
Human-readable SGU values are decoded with BOM/header/meta detection, strict
|
||||
UTF-8 validation, and a Windows-1252 fallback for the legacy portal. Names and
|
||||
titles are normalized with Spanish-aware casing; particles such as `de`, `del`
|
||||
@@ -110,6 +127,9 @@ settings for managed clients.
|
||||
|
||||
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
|
||||
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
|
||||
immediate parent OU, then composes those values over the bundled dark-blue
|
||||
background with the bundled Indivisa fonts. Missing directory attributes degrade
|
||||
|
||||
@@ -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-windows11-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)
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
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
|
||||
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`.
|
||||
2. Abrir el directorio extraído.
|
||||
3. Ejecutar, indicando la IP fija que tendrá el controlador:
|
||||
@@ -91,13 +96,29 @@ Se admiten Pro, Enterprise y Education. Windows Home no puede unirse a Active
|
||||
Directory local ni actuar como host RDP; el bootstrap lo detecta antes de cambiar
|
||||
el equipo y explica que se debe actualizar la edición.
|
||||
|
||||
1. Descargar y extraer `sgu-client-bootstrap-VERSION.zip`.
|
||||
1. Descargar y extraer el paquete correspondiente:
|
||||
`sgu-windows10-legacy-client-bootstrap-VERSION.zip` o
|
||||
`sgu-windows11-client-bootstrap-VERSION.zip`.
|
||||
2. Ejecutar con la IP fija actual del controlador de dominio:
|
||||
|
||||
```bat
|
||||
Start-SguClientEnrollment.cmd 192.168.50.10
|
||||
Start-SguClientEnrollment.cmd 192.168.50.10 192.168.50.11
|
||||
```
|
||||
|
||||
El primer argumento es el controlador de dominio y el segundo es una dirección
|
||||
IPv4 fija, libre y exclusiva del cliente en la red privada. Si se omite la IP
|
||||
del cliente, el bootstrap la solicita cuando el adaptador sólo tiene APIPA
|
||||
(`169.254.x.x`). En una VM con Internet por `Default Switch` y otra NIC para
|
||||
`Laboratorio AD`, el bootstrap elige la NIC sin puerta de enlace y no cambia la
|
||||
ruta predeterminada. Si falla, la ventana elevada permanece abierta y el mismo
|
||||
error queda en `C:\ProgramData\SGU\Bootstrap\Client\latest-error.log`.
|
||||
|
||||
Cada manifiesto fija su perfil y evita cruzar paquetes: Windows 10 utiliza
|
||||
`Windows10Legacy` (build menor a 22000) y Windows 11 `Windows11Modern` (build
|
||||
22000 o posterior). El ZIP moderno conserva tanto el enrolamiento directo como
|
||||
Azure P2S/pre-logon; el ZIP legado contiene el flujo directo. El código común y
|
||||
las garantías de seguridad son idénticos.
|
||||
|
||||
Después de UAC, se solicita interactivamente la credencial autorizada para unir
|
||||
equipos. La contraseña existe sólo en memoria. El bootstrap:
|
||||
|
||||
@@ -123,6 +144,8 @@ Para elegir adaptador o nombre del equipo explícitamente:
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass `
|
||||
-File .\Invoke-SguClientBootstrap.ps1 `
|
||||
-DomainControllerIPv4Address 192.168.50.10 `
|
||||
-ClientIPv4Address 192.168.50.11 `
|
||||
-ClientPrefixLength 24 `
|
||||
-NetworkInterfaceAlias 'Ethernet' `
|
||||
-NewComputerName 'LCI-101'
|
||||
```
|
||||
@@ -186,4 +209,4 @@ la línea de comandos. Para empaquetar recursos institucionales adicionales:
|
||||
-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.
|
||||
|
||||
+31
-10
@@ -1,10 +1,14 @@
|
||||
# Enrolamiento obligatorio de clientes SGU
|
||||
|
||||
Para una instalación limpia de Windows se prefiere el único punto de entrada
|
||||
empaquetado:
|
||||
Para una instalación limpia se selecciona primero el ZIP correspondiente:
|
||||
|
||||
- `sgu-windows10-legacy-client-bootstrap-VERSION.zip` para Windows 10;
|
||||
- `sgu-windows11-client-bootstrap-VERSION.zip` para Windows 11.
|
||||
|
||||
Ambos conservan el punto de entrada directo:
|
||||
|
||||
```bat
|
||||
Start-SguClientEnrollment.cmd 192.168.50.10
|
||||
Start-SguClientEnrollment.cmd 192.168.50.10 192.168.50.11
|
||||
```
|
||||
|
||||
Este comando realiza el intercambio de certificados descrito abajo sin mover
|
||||
@@ -12,6 +16,20 @@ una clave privada y luego ejecuta la transacción proveedor-primero. Las
|
||||
instrucciones completas están en
|
||||
[`bootstrap-recovery.md`](bootstrap-recovery.md).
|
||||
|
||||
El primer argumento es la IP fija del controlador; el segundo es una IP fija y
|
||||
única para el cliente en la misma subred. Si el segundo se omite y la NIC
|
||||
privada no tiene una IP válida, se solicita en pantalla. El bootstrap prefiere
|
||||
la única NIC activa sin puerta de enlace para no reemplazar el adaptador de
|
||||
Internet. Ante cualquier error conserva la ventana y escribe el diagnóstico en
|
||||
`C:\ProgramData\SGU\Bootstrap\Client\latest-error.log`.
|
||||
|
||||
El manifiesto contiene el perfil `Windows10Legacy` o `Windows11Modern` y el
|
||||
bootstrap rechaza un ZIP que no corresponda al build instalado. El paquete de
|
||||
Windows 11 conserva además `Start-SguAzureClientEnrollment.cmd` y el instalador
|
||||
P2S de equipo; el legado de Windows 10 se limita al transporte directo del
|
||||
laboratorio. Credential Provider, mTLS, cuenta `alumno`, RustDesk, monitorización
|
||||
y autorreparación siguen saliendo de la misma base de código.
|
||||
|
||||
El flujo administrado instala y valida el Credential Provider **antes** de
|
||||
ejecutar `Add-Computer`. La pertenencia al dominio es el último cambio; si falta
|
||||
el runtime, un certificado, el registro COM, la directiva predeterminada o la
|
||||
@@ -72,11 +90,13 @@ Orden de la transacción:
|
||||
1. instala .NET y los binarios versionados;
|
||||
2. registra COM, configura SGU como proveedor predeterminado y oculta el
|
||||
último usuario que cerró sesión;
|
||||
3. instala el guard de autorreparación;
|
||||
4. exige health mTLS del broker y ejecuta las comprobaciones locales;
|
||||
5. configura DNS del dominio;
|
||||
6. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` y reinicia;
|
||||
7. al arrancar, el guard habilita RDP, NLA y WinRM y comprueba el estado final.
|
||||
3. crea o actualiza la cuenta local estándar `alumno` con la contraseña
|
||||
`ingenieria`, la habilita y garantiza que no pertenezca a Administradores;
|
||||
4. instala el guard de autorreparación;
|
||||
5. exige health mTLS del broker y ejecuta las comprobaciones locales;
|
||||
6. configura DNS del dominio;
|
||||
7. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` y reinicia;
|
||||
8. al arrancar, el guard habilita RDP, NLA y WinRM y comprueba el estado final.
|
||||
|
||||
La directiva de Windows **Assign a default credential provider** selecciona SGU
|
||||
por defecto. El instalador también habilita **Interactive logon: Don't display
|
||||
@@ -138,8 +158,9 @@ Start-ScheduledTask -TaskName SGU-CredentialProvider-EnrollmentGuard
|
||||
|
||||
Un resultado válido exige simultáneamente binario y registro COM, configuración,
|
||||
certificados, .NET 10, proveedor SGU predeterminado, último usuario oculto,
|
||||
enumeración local deshabilitada y proveedor de contraseña de Microsoft
|
||||
preservado. El script de reparación se encuentra en
|
||||
enumeración local deshabilitada, cuenta local estándar `alumno` habilitada y
|
||||
fuera del grupo Administradores, y proveedor de contraseña de Microsoft
|
||||
preservado. El guard recrea o corrige esa cuenta de forma idempotente. El script de reparación se encuentra en
|
||||
`C:\ProgramData\SGU\Enrollment` con ACL exclusiva para `SYSTEM` y
|
||||
administradores.
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ Get-Service SGUAuthBroker
|
||||
Get-NetTCPConnection -LocalPort 8443 -State Listen
|
||||
sc.exe qfailure SGUAuthBroker
|
||||
Get-ADOrganizationalUnit -Filter * -SearchBase 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx'
|
||||
Get-ADGroup -Filter 'SamAccountName -like "SGU-*"' -SearchBase 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx'
|
||||
```
|
||||
|
||||
## 4. Broker preflight from Windows 10
|
||||
|
||||
+2
-1
@@ -29,7 +29,8 @@ rol, `TraceId`, resultado y tiempo total. Los Event ID estables distinguen:
|
||||
IDs admitidos; `1202` timeout; `1203` excepción; `1204` página opcional no
|
||||
disponible;
|
||||
- `1300` fallo de sincronización AD; `1301` metadatos opcionales no aplicados;
|
||||
`1302` membresía RDP opcional no aplicada.
|
||||
`1302` membresía RDP opcional no aplicada; `1303` cuenta agregada a su grupo
|
||||
institucional de Alumnos, Administrativos o Docentes.
|
||||
|
||||
No se almacena HTML, contraseña, hash de contraseña ni contenido de la
|
||||
respuesta SGU.
|
||||
|
||||
+17
-5
@@ -1,5 +1,14 @@
|
||||
# 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
|
||||
|
||||
- The Credential Provider receives the password in Lithnet's secure password
|
||||
@@ -33,15 +42,18 @@
|
||||
- Administrative enrichment first verifies the employee number and reads
|
||||
employee type/status, email, job title, and department from the incident
|
||||
overview. Only after that match, it reads given names and paternal/maternal
|
||||
surnames from the personal page plus street, exterior/interior number,
|
||||
neighborhood, locality, state, and postal code from the location page.
|
||||
- Administrative birth date, RFC, CURP, sex, blood type, marital status,
|
||||
surnames and the normalized `Male`/`Female` value from the personal page plus
|
||||
street, exterior/interior number, neighborhood, locality, state, and postal
|
||||
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
|
||||
fields are ignored.
|
||||
- Student enrichment reads only the matching student number, given names,
|
||||
paternal/maternal surnames, email, career, street, neighborhood,
|
||||
city/municipality, state, and postal code from known element IDs.
|
||||
- Student CURP, birth date, sex, blood type, marital status, telephone, mobile,
|
||||
city/municipality, state, postal code, and normalized sex from known element
|
||||
IDs.
|
||||
- Student CURP, birth date, blood type, marital status, telephone, mobile,
|
||||
guardian, medical, financial, and academic-history values are ignored.
|
||||
- Professor enrichment keeps the menu display name as its base. From the payroll
|
||||
consultation header it reads only a matching employee number, name, email,
|
||||
|
||||
@@ -16,14 +16,20 @@ El generador consulta Active Directory con la identidad ya autenticada y sin
|
||||
guardar credenciales. Obtiene:
|
||||
|
||||
- `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.
|
||||
- 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.`
|
||||
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.
|
||||
Los errores de generación se registran en
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
@@ -31,6 +31,9 @@ param(
|
||||
[string]$BaseDn = 'DC=lci,DC=lasalle,DC=mx',
|
||||
[string]$DomainNetbios = 'LCI',
|
||||
[string]$UpnSuffix = 'lci.lasalle.mx',
|
||||
[string]$ProfessorGroupDn = '',
|
||||
[string]$StudentGroupDn = '',
|
||||
[string]$AdministrativeGroupDn = '',
|
||||
[string]$RemoteDesktopGroupDn = '',
|
||||
[ValidateLength(1, 64)]
|
||||
[string]$DefaultCompany = 'La Salle',
|
||||
@@ -72,10 +75,27 @@ if (-not $serverCertificate.Verify()) {
|
||||
throw 'The HTTPS server certificate chain is not trusted or is outside its validity period. Import the issuing CA chain; for a self-signed lab certificate, trust its public .cer in LocalMachine\Root.'
|
||||
}
|
||||
|
||||
Import-Module ActiveDirectory -ErrorAction Stop
|
||||
|
||||
function ConvertTo-LdapFilterValue {
|
||||
param([Parameter(Mandatory)][string]$Value)
|
||||
|
||||
return $Value.Replace('\', '\5c').Replace('*', '\2a').Replace('(', '\28').Replace(')', '\29').Replace(([string][char]0), '\00')
|
||||
}
|
||||
|
||||
$usersOuName = 'Usuarios-SGU'
|
||||
$usersOuDn = "OU=$usersOuName,$BaseDn"
|
||||
if ([string]::IsNullOrWhiteSpace($ProfessorGroupDn)) {
|
||||
$ProfessorGroupDn = "CN=SGU-Docentes,OU=Docentes,$usersOuDn"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($StudentGroupDn)) {
|
||||
$StudentGroupDn = "CN=SGU-Alumnos,OU=Alumnos,$usersOuDn"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($AdministrativeGroupDn)) {
|
||||
$AdministrativeGroupDn = "CN=SGU-Administrativos,OU=Administrativos,$usersOuDn"
|
||||
}
|
||||
|
||||
if ($CreateMissingOus) {
|
||||
Import-Module ActiveDirectory -ErrorAction Stop
|
||||
$usersOuName = 'Usuarios-SGU'
|
||||
$usersOuDn = "OU=$usersOuName,$BaseDn"
|
||||
if (-not (Get-ADOrganizationalUnit -LDAPFilter "(ou=$usersOuName)" -SearchBase $BaseDn -SearchScope OneLevel -Server $LdapHost -ErrorAction SilentlyContinue)) {
|
||||
New-ADOrganizationalUnit -Name $usersOuName -Path $BaseDn -ProtectedFromAccidentalDeletion $true -Server $LdapHost | Out-Null
|
||||
}
|
||||
@@ -117,8 +137,63 @@ if ($CreateMissingOus) {
|
||||
}
|
||||
}
|
||||
|
||||
$roleGroupDefinitions = @(
|
||||
[pscustomobject]@{ Role = 'Professor'; Dn = $ProfessorGroupDn; Description = 'SGU accounts with the DO institutional prefix.' }
|
||||
[pscustomobject]@{ Role = 'Student'; Dn = $StudentGroupDn; Description = 'SGU accounts with the AL institutional prefix.' }
|
||||
[pscustomobject]@{ Role = 'Administrative'; Dn = $AdministrativeGroupDn; Description = 'SGU accounts with the AD institutional prefix.' }
|
||||
)
|
||||
foreach ($definition in $roleGroupDefinitions) {
|
||||
if (-not $definition.Dn.EndsWith(",$BaseDn", [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "$($definition.Role)GroupDn must identify a security group beneath BaseDn."
|
||||
}
|
||||
|
||||
try {
|
||||
$roleGroup = Get-ADGroup -Identity $definition.Dn -Server $LdapHost -ErrorAction Stop
|
||||
}
|
||||
catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] {
|
||||
$roleGroup = $null
|
||||
}
|
||||
if (-not $roleGroup -and $CreateMissingOus) {
|
||||
$groupDnMatch = [regex]::Match($definition.Dn, '^CN=(?<Name>[^,]+),(?<Path>.+)$', [Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||
if (-not $groupDnMatch.Success) {
|
||||
throw "$($definition.Role)GroupDn must start with a simple CN component."
|
||||
}
|
||||
$groupName = $groupDnMatch.Groups['Name'].Value
|
||||
$groupPath = $groupDnMatch.Groups['Path'].Value
|
||||
if ($groupName.Length -gt 20) {
|
||||
throw "$($definition.Role) group name exceeds the 20-character sAMAccountName limit."
|
||||
}
|
||||
|
||||
$matchingGroups = @(Get-ADGroup `
|
||||
-LDAPFilter "(sAMAccountName=$(ConvertTo-LdapFilterValue -Value $groupName))" `
|
||||
-SearchBase $BaseDn -SearchScope Subtree -Server $LdapHost -ErrorAction Stop)
|
||||
if ($matchingGroups.Count -gt 1) {
|
||||
throw "More than one Active Directory group uses sAMAccountName $groupName; the bootstrap cannot select one safely."
|
||||
}
|
||||
if ($matchingGroups.Count -eq 1) {
|
||||
if ($matchingGroups[0].GroupCategory -ne 'Security') {
|
||||
throw "$($definition.Role)GroupDn must identify a security group."
|
||||
}
|
||||
Move-ADObject -Identity $matchingGroups[0].DistinguishedName `
|
||||
-TargetPath $groupPath -Server $LdapHost -Confirm:$false -ErrorAction Stop
|
||||
}
|
||||
else {
|
||||
New-ADGroup -Name $groupName -SamAccountName $groupName `
|
||||
-GroupCategory Security -GroupScope Global `
|
||||
-Path $groupPath `
|
||||
-Description $definition.Description -Server $LdapHost | Out-Null
|
||||
}
|
||||
$roleGroup = Get-ADGroup -Identity $definition.Dn -Server $LdapHost -ErrorAction Stop
|
||||
}
|
||||
if (-not $roleGroup) {
|
||||
throw "The required $($definition.Role) security group does not exist: $($definition.Dn)"
|
||||
}
|
||||
if ($roleGroup.GroupCategory -ne 'Security') {
|
||||
throw "$($definition.Role)GroupDn must identify a security group."
|
||||
}
|
||||
}
|
||||
|
||||
if ($RemoteDesktopGroupDn) {
|
||||
Import-Module ActiveDirectory -ErrorAction Stop
|
||||
$remoteDesktopGroup = Get-ADGroup -Identity $RemoteDesktopGroupDn -Server $LdapHost -ErrorAction Stop
|
||||
if ($remoteDesktopGroup.GroupCategory -ne 'Security' -or
|
||||
-not $remoteDesktopGroup.DistinguishedName.EndsWith(",$BaseDn", [StringComparison]::OrdinalIgnoreCase)) {
|
||||
@@ -186,6 +261,9 @@ $productionSettings = @{
|
||||
ProfessorOuDn = "OU=Docentes,OU=Usuarios-SGU,$BaseDn"
|
||||
StudentOuDn = "OU=Alumnos,OU=Usuarios-SGU,$BaseDn"
|
||||
AdministrativeOuDn = "OU=Administrativos,OU=Usuarios-SGU,$BaseDn"
|
||||
ProfessorGroupDn = $ProfessorGroupDn
|
||||
StudentGroupDn = $StudentGroupDn
|
||||
AdministrativeGroupDn = $AdministrativeGroupDn
|
||||
RemoteDesktopGroupDn = $RemoteDesktopGroupDn
|
||||
DefaultCompany = $DefaultCompany
|
||||
CreateMissingOus = [bool]$CreateMissingOus
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ param(
|
||||
[string]$NewComputerName,
|
||||
[string]$NetworkInterfaceAlias = 'Ethernet',
|
||||
[string[]]$DomainDnsServerAddresses = @('192.168.50.10'),
|
||||
[ValidateSet('Direct', 'AzureP2S')]
|
||||
[string]$ConnectivityMode = 'Direct',
|
||||
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
|
||||
[string]$DotNetRuntimeInstallerPath,
|
||||
[string]$RustDeskServerAddress,
|
||||
@@ -39,6 +41,7 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra
|
||||
foreach ($scriptName in @(
|
||||
'Install-CredentialProvider.ps1',
|
||||
'Install-SguEnrollmentGuard.ps1',
|
||||
'Set-SguStandardLocalUser.ps1',
|
||||
'Test-SguClientEnrollment.ps1',
|
||||
'Repair-SguClientEnrollment.ps1',
|
||||
'Enable-LabRemoteAccess.ps1',
|
||||
@@ -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
|
||||
# domain. Point at AD DNS first so the provider-first health check works on
|
||||
# a completely clean Windows installation.
|
||||
if ($ConnectivityMode -eq 'Direct') {
|
||||
Set-DnsClientServerAddress `
|
||||
-InterfaceAlias $NetworkInterfaceAlias `
|
||||
-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
|
||||
|
||||
& (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 `
|
||||
-ServerPublicKey $RustDeskServerPublicKey
|
||||
}
|
||||
$localStudentUser = & (Join-Path $PSScriptRoot 'Set-SguStandardLocalUser.ps1')
|
||||
& (Join-Path $PSScriptRoot 'Install-SguEnrollmentGuard.ps1') @guardParams | Out-Null
|
||||
|
||||
$testParameters = @{ RequireBrokerHealth = $true }
|
||||
@@ -128,6 +144,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
|
||||
$postJoinParameters.RustDeskServerAddress = $RustDeskServerAddress
|
||||
}
|
||||
$postJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @postJoinParameters
|
||||
$postJoin | Add-Member -NotePropertyName StandardLocalUser -NotePropertyValue $localStudentUser
|
||||
$postJoin | Add-Member -NotePropertyName RustDesk -NotePropertyValue $rustDeskResult
|
||||
return $postJoin
|
||||
}
|
||||
@@ -159,7 +176,9 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
|
||||
[pscustomobject]@{
|
||||
ComputerName = if ($NewComputerName) { $NewComputerName } else { $env:COMPUTERNAME }
|
||||
DomainName = $DomainName
|
||||
ConnectivityMode = $ConnectivityMode
|
||||
ProviderValidatedBeforeJoin = $true
|
||||
StandardLocalUser = $localStudentUser
|
||||
RustDesk = $rustDeskResult
|
||||
RestartRequired = [bool]$SkipRestart
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ $eventNames = @{
|
||||
1300 = 'DirectorySynchronizationFailure'
|
||||
1301 = 'DirectoryOptionalMetadataFailure'
|
||||
1302 = 'DirectoryGroupMembershipFailure'
|
||||
1303 = 'DirectoryRoleGroupMembershipAdded'
|
||||
}
|
||||
|
||||
# Keep these reads unfiltered. Besides making archived and current logs behave
|
||||
|
||||
@@ -6,6 +6,9 @@ param(
|
||||
[int]$PrefixLength = 24,
|
||||
[string]$NetworkInterfaceAlias,
|
||||
[ipaddress]$DefaultGateway,
|
||||
[ValidateSet('GuestStatic', 'PlatformManaged')]
|
||||
[string]$NetworkConfigurationMode = 'GuestStatic',
|
||||
[string[]]$TrustedClientNetworks = @(),
|
||||
[ipaddress[]]$DnsForwarders = @(),
|
||||
[string]$DomainName = 'lci.lasalle.mx',
|
||||
[string]$DomainNetbios = 'LCI',
|
||||
@@ -69,6 +72,67 @@ function Get-DomainBaseDn {
|
||||
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 {
|
||||
param([string]$RequestedAlias)
|
||||
|
||||
@@ -147,6 +211,27 @@ function Set-StaticDomainAddress {
|
||||
-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 {
|
||||
param([Parameter(Mandatory)][string]$ScriptPath)
|
||||
|
||||
@@ -310,6 +395,8 @@ if ($Resume -or (-not $ServerIPv4Address -and $existingState)) {
|
||||
$PrefixLength = [int]$existingState.PrefixLength
|
||||
$NetworkInterfaceAlias = [string]$existingState.NetworkInterfaceAlias
|
||||
$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]$_ })
|
||||
$DomainName = [string]$existingState.DomainName
|
||||
$DomainNetbios = [string]$existingState.DomainNetbios
|
||||
@@ -321,6 +408,16 @@ if ($Resume -or (-not $ServerIPv4Address -and $existingState)) {
|
||||
if (-not $ServerIPv4Address) {
|
||||
$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
|
||||
if (-not $Resume) {
|
||||
@@ -383,6 +480,8 @@ if (-not $existingState) {
|
||||
PrefixLength = $PrefixLength
|
||||
NetworkInterfaceAlias = $NetworkInterfaceAlias
|
||||
DefaultGateway = if ($DefaultGateway) { $DefaultGateway.IPAddressToString } else { $null }
|
||||
NetworkConfigurationMode = $NetworkConfigurationMode
|
||||
TrustedClientNetworks = $TrustedClientNetworks
|
||||
DnsForwarders = @($DnsForwarders | ForEach-Object IPAddressToString)
|
||||
DomainName = $DomainName
|
||||
DomainNetbios = $DomainNetbios
|
||||
@@ -396,9 +495,16 @@ if (-not $existingState) {
|
||||
[Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
|
||||
Write-BootstrapLog "Configuring $NetworkInterfaceAlias as $ServerIPv4Address/$PrefixLength."
|
||||
Set-StaticDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
|
||||
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."
|
||||
Set-StaticDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
|
||||
-Address $ServerIPv4Address -NetworkPrefixLength $PrefixLength -Gateway $DefaultGateway
|
||||
}
|
||||
|
||||
$computer = Get-CimInstance Win32_ComputerSystem
|
||||
if (-not $computer.PartOfDomain) {
|
||||
@@ -472,8 +578,17 @@ Wait-ActiveDirectoryReady -ExpectedBaseDn $baseDn
|
||||
$domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias `
|
||||
-ErrorAction SilentlyContinue
|
||||
if (-not $domainProfile -or $domainProfile.NetworkCategory -ne 'DomainAuthenticated') {
|
||||
if ($NetworkConfigurationMode -eq 'GuestStatic') {
|
||||
Write-BootstrapLog "Refreshing $NetworkInterfaceAlias so Windows detects the domain network profile."
|
||||
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++) {
|
||||
Start-Sleep -Seconds 2
|
||||
$domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias `
|
||||
@@ -577,7 +692,7 @@ if (Test-Path -LiteralPath $brokerConfigurationPath -PathType Leaf) {
|
||||
-RemoteDesktopGroupDn $remoteDesktopGroup.DistinguishedName `
|
||||
-DefaultCompany 'La Salle' `
|
||||
-FirewallLocalAddress $ServerIPv4Address `
|
||||
-FirewallRemoteAddress "$($ServerIPv4Address.IPAddressToString)/$PrefixLength" `
|
||||
-FirewallRemoteAddress $allowedRemoteAddresses `
|
||||
-CreateMissingOus `
|
||||
-DisableCertificateRevocationCheckForLab | Out-Null
|
||||
|
||||
@@ -591,9 +706,8 @@ foreach ($hostRecord in $hostRecords) {
|
||||
}
|
||||
}
|
||||
|
||||
$privateSubnet = "$($ServerIPv4Address.IPAddressToString)/$PrefixLength"
|
||||
& (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') `
|
||||
-AllowedRemoteAddress $privateSubnet | Out-Null
|
||||
-AllowedRemoteAddress $allowedRemoteAddresses | Out-Null
|
||||
|
||||
$contentPath = Join-Path $bootstrapRoot 'payload\server-content\Packages'
|
||||
if (Test-Path -LiteralPath $contentPath -PathType Container) {
|
||||
@@ -613,7 +727,7 @@ if (-not $packageFirewallRule) {
|
||||
-Protocol TCP `
|
||||
-LocalPort 445 `
|
||||
-LocalAddress $ServerIPv4Address.IPAddressToString `
|
||||
-RemoteAddress $privateSubnet `
|
||||
-RemoteAddress $allowedRemoteAddresses `
|
||||
-Profile Any | Out-Null
|
||||
}
|
||||
else {
|
||||
@@ -621,7 +735,7 @@ else {
|
||||
$packageFirewallRule | Get-NetFirewallAddressFilter |
|
||||
Set-NetFirewallAddressFilter `
|
||||
-LocalAddress $ServerIPv4Address.IPAddressToString `
|
||||
-RemoteAddress $privateSubnet | Out-Null
|
||||
-RemoteAddress $allowedRemoteAddresses | Out-Null
|
||||
}
|
||||
|
||||
$collectorFqdn = "$env:COMPUTERNAME.$DomainName"
|
||||
@@ -642,7 +756,7 @@ $userPolicyParameters = @{
|
||||
|
||||
$rustDeskServer = & (Join-Path $scriptsRoot 'Install-SguRustDeskServer.ps1') `
|
||||
-ServerAddress $rustDeskDnsName `
|
||||
-FirewallRemoteAddress $privateSubnet
|
||||
-FirewallRemoteAddress $allowedRemoteAddresses
|
||||
$rustDeskManagementRoot = Join-Path $env:ProgramData 'SGU\RustDesk'
|
||||
New-Item -ItemType Directory -Path $rustDeskManagementRoot -Force | Out-Null
|
||||
foreach ($scriptName in @(
|
||||
@@ -680,6 +794,9 @@ $validation = [ordered]@{
|
||||
ComputerName = $env:COMPUTERNAME
|
||||
DomainName = $DomainName
|
||||
ServerIPv4Address = $ServerIPv4Address.IPAddressToString
|
||||
NetworkConfigurationMode = $NetworkConfigurationMode
|
||||
TrustedClientNetworks = $TrustedClientNetworks
|
||||
AllowedRemoteAddresses = $allowedRemoteAddresses
|
||||
BrokerDnsName = $brokerDnsName
|
||||
BrokerCertificateThumbprint = $serverCertificate.Thumbprint
|
||||
BrokerService = (Get-Service SGUAuthBroker).Status.ToString()
|
||||
@@ -714,7 +831,8 @@ if ($validation.BrokerService -ne 'Running' -or
|
||||
-not $validation.RustDeskHbbsListening -or
|
||||
-not $validation.RustDeskHbbrListening -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.'
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ $sourceScripts = @(
|
||||
'Enable-LabRemoteAccess.ps1',
|
||||
'Enable-SguClientMonitoring.ps1',
|
||||
'Install-SguRustDeskClient.ps1',
|
||||
'Set-SguStandardLocalUser.ps1',
|
||||
'Test-SguClientEnrollment.ps1',
|
||||
'Repair-SguClientEnrollment.ps1'
|
||||
)
|
||||
|
||||
@@ -6,7 +6,8 @@ param(
|
||||
|
||||
[string]$InstallRoot = "$env:ProgramFiles\SGU\RustDeskServer",
|
||||
[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',
|
||||
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
||||
[string]$ExpectedSha256 = 'B865A3A62FC8755B45480C508F1C4871C3338590408DDA8C58C7E9C373B7ADB0'
|
||||
|
||||
@@ -3,11 +3,25 @@
|
||||
param(
|
||||
[ipaddress]$DomainControllerIPv4Address,
|
||||
[string]$NetworkInterfaceAlias,
|
||||
[ipaddress]$ClientIPv4Address,
|
||||
[ValidateRange(1, 32)]
|
||||
[int]$ClientPrefixLength = 24,
|
||||
[PSCredential]$DomainCredential,
|
||||
[string]$DomainName = 'lci.lasalle.mx',
|
||||
[string]$DomainNetbios = 'LCI',
|
||||
[string]$ComputerOuDn,
|
||||
[string]$NewComputerName,
|
||||
[ValidateSet('Auto', 'Windows10Legacy', 'Windows11Modern')]
|
||||
[string]$CompatibilityProfile = 'Auto',
|
||||
[ValidateSet('Direct', 'AzureP2S')]
|
||||
[string]$ConnectivityMode = 'Direct',
|
||||
[string]$VpnConnectionName = 'SGU Azure P2S',
|
||||
[string]$VpnProfilePackagePath,
|
||||
[string]$VpnClientCertificatePfxPath,
|
||||
[securestring]$VpnClientCertificatePfxPassword,
|
||||
[string]$VpnClientRootCertificatePath,
|
||||
[string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'),
|
||||
[switch]$PauseOnError,
|
||||
[switch]$SkipRestart
|
||||
)
|
||||
|
||||
@@ -16,6 +30,35 @@ $brokerRecordName = 'sgu-auth'
|
||||
$brokerDnsName = "$brokerRecordName.$DomainName"
|
||||
$brokerEndpoint = "https://${brokerDnsName}:8443/v1/authenticate"
|
||||
$temporaryRoot = Join-Path $env:ProgramData ("SGU\Bootstrap\Client-" + [Guid]::NewGuid().ToString('N'))
|
||||
$bootstrapLogRoot = Join-Path $env:ProgramData 'SGU\Bootstrap\Client'
|
||||
$bootstrapErrorLog = Join-Path $bootstrapLogRoot 'latest-error.log'
|
||||
|
||||
trap {
|
||||
$failure = $_
|
||||
$failureText = @(
|
||||
"SGU client enrollment failed at $((Get-Date).ToString('s')).",
|
||||
'',
|
||||
$failure.Exception.Message,
|
||||
'',
|
||||
$failure.ScriptStackTrace
|
||||
) -join [Environment]::NewLine
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $bootstrapLogRoot -Force | Out-Null
|
||||
[IO.File]::WriteAllText($bootstrapErrorLog, $failureText, [Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
catch {
|
||||
# Keep the original enrollment error when diagnostics cannot be written.
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host 'SGU client enrollment did not complete.' -ForegroundColor Red
|
||||
Write-Host $failure.Exception.Message -ForegroundColor Red
|
||||
Write-Host "Diagnostic log: $bootstrapErrorLog" -ForegroundColor Yellow
|
||||
if ($PauseOnError -and [Environment]::UserInteractive) {
|
||||
Read-Host 'Press ENTER to close this window' | Out-Null
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Assert-Administrator {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
@@ -44,6 +87,7 @@ function Assert-PackageManifest {
|
||||
throw "Bootstrap package integrity check failed: $($entry.Path)"
|
||||
}
|
||||
}
|
||||
return $manifest
|
||||
}
|
||||
|
||||
function Resolve-ClientInterfaceAlias {
|
||||
@@ -54,21 +98,133 @@ function Resolve-ClientInterfaceAlias {
|
||||
return $RequestedAlias
|
||||
}
|
||||
|
||||
$defaultRoute = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' `
|
||||
-ErrorAction SilentlyContinue |
|
||||
Sort-Object RouteMetric,InterfaceMetric |
|
||||
Select-Object -First 1
|
||||
if ($defaultRoute) {
|
||||
return [string](Get-NetAdapter -InterfaceIndex $defaultRoute.InterfaceIndex).Name
|
||||
}
|
||||
|
||||
$upAdapters = @(Get-NetAdapter | Where-Object Status -eq 'Up')
|
||||
$withoutDefaultGateway = @($upAdapters | Where-Object {
|
||||
-not (Get-NetIPConfiguration -InterfaceIndex $_.ifIndex).IPv4DefaultGateway
|
||||
})
|
||||
if ($withoutDefaultGateway.Count -eq 1) {
|
||||
return [string]$withoutDefaultGateway[0].Name
|
||||
}
|
||||
if ($upAdapters.Count -eq 1) {
|
||||
return [string]$upAdapters[0].Name
|
||||
}
|
||||
|
||||
$aliases = ($upAdapters.Name | Sort-Object) -join ', '
|
||||
throw "Could not select a network adapter. Re-run with -NetworkInterfaceAlias. Available adapters: $aliases"
|
||||
throw "Could not select the private domain adapter unambiguously. Re-run with -NetworkInterfaceAlias. Available adapters: $aliases"
|
||||
}
|
||||
|
||||
function Test-IPv4AddressesSharePrefix {
|
||||
param(
|
||||
[Parameter(Mandatory)][ipaddress]$FirstAddress,
|
||||
[Parameter(Mandatory)][ipaddress]$SecondAddress,
|
||||
[Parameter(Mandatory)][ValidateRange(1, 32)][int]$PrefixLength
|
||||
)
|
||||
|
||||
if ($FirstAddress.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or
|
||||
$SecondAddress.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$firstBytes = $FirstAddress.GetAddressBytes()
|
||||
$secondBytes = $SecondAddress.GetAddressBytes()
|
||||
$remainingBits = $PrefixLength
|
||||
for ($index = 0; $index -lt 4; $index++) {
|
||||
$bits = [Math]::Min(8, $remainingBits)
|
||||
$mask = if ($bits -eq 0) {
|
||||
0
|
||||
}
|
||||
elseif ($bits -eq 8) {
|
||||
255
|
||||
}
|
||||
else {
|
||||
256 - [int][Math]::Pow(2, 8 - $bits)
|
||||
}
|
||||
if (($firstBytes[$index] -band $mask) -ne ($secondBytes[$index] -band $mask)) {
|
||||
return $false
|
||||
}
|
||||
$remainingBits -= $bits
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Assert-UsableClientIPv4Address {
|
||||
param(
|
||||
[Parameter(Mandatory)][ipaddress]$Address,
|
||||
[Parameter(Mandatory)][ipaddress]$DomainControllerAddress,
|
||||
[Parameter(Mandatory)][ValidateRange(1, 32)][int]$PrefixLength
|
||||
)
|
||||
|
||||
if ($Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
|
||||
throw "The SGU client address '$Address' must be IPv4."
|
||||
}
|
||||
if ($Address.IPAddressToString -eq $DomainControllerAddress.IPAddressToString) {
|
||||
throw 'The SGU client and domain controller cannot use the same IPv4 address.'
|
||||
}
|
||||
if ($Address.IPAddressToString -match '^(0\.|127\.|169\.254\.|22[4-9]\.|23\d\.)') {
|
||||
throw "The SGU client address '$Address' is not usable on the private domain network."
|
||||
}
|
||||
if (-not (Test-IPv4AddressesSharePrefix -FirstAddress $Address `
|
||||
-SecondAddress $DomainControllerAddress -PrefixLength $PrefixLength)) {
|
||||
throw "The SGU client address '$Address/$PrefixLength' is not on the same network as domain controller $DomainControllerAddress."
|
||||
}
|
||||
}
|
||||
|
||||
function Set-ClientDomainAddress {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$InterfaceAlias,
|
||||
[Parameter(Mandatory)][ipaddress]$DomainControllerAddress,
|
||||
[ipaddress]$RequestedAddress,
|
||||
[Parameter(Mandatory)][ValidateRange(1, 32)][int]$PrefixLength
|
||||
)
|
||||
|
||||
$adapter = Get-NetAdapter -Name $InterfaceAlias -ErrorAction Stop
|
||||
$matchingAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
|
||||
-ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
$_.AddressState -eq 'Preferred' -and
|
||||
$_.IPAddress -notmatch '^(127\.|169\.254\.)' -and
|
||||
(Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]$_.IPAddress) `
|
||||
-SecondAddress $DomainControllerAddress -PrefixLength $PrefixLength)
|
||||
} |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $RequestedAddress -and $matchingAddress) {
|
||||
return [ipaddress]$matchingAddress.IPAddress
|
||||
}
|
||||
if (-not $RequestedAddress) {
|
||||
$RequestedAddress = [ipaddress](Read-Host "Fixed IPv4 address for this SGU client on '$InterfaceAlias'")
|
||||
}
|
||||
Assert-UsableClientIPv4Address -Address $RequestedAddress `
|
||||
-DomainControllerAddress $DomainControllerAddress -PrefixLength $PrefixLength
|
||||
|
||||
Set-NetIPInterface -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -Dhcp Disabled
|
||||
$existingAddresses = @(Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
|
||||
-ErrorAction SilentlyContinue | Where-Object PrefixOrigin -ne 'WellKnown')
|
||||
foreach ($existingAddress in $existingAddresses) {
|
||||
if ($existingAddress.IPAddress -ne $RequestedAddress.IPAddressToString -or
|
||||
[int]$existingAddress.PrefixLength -ne $PrefixLength) {
|
||||
Remove-NetIPAddress -InputObject $existingAddress -Confirm:$false
|
||||
}
|
||||
}
|
||||
if (-not (Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
|
||||
-IPAddress $RequestedAddress.IPAddressToString -ErrorAction SilentlyContinue)) {
|
||||
New-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
|
||||
-IPAddress $RequestedAddress.IPAddressToString -PrefixLength $PrefixLength | Out-Null
|
||||
}
|
||||
|
||||
$addressReadyDeadline = (Get-Date).AddSeconds(20)
|
||||
do {
|
||||
$configuredAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex `
|
||||
-AddressFamily IPv4 -IPAddress $RequestedAddress.IPAddressToString `
|
||||
-ErrorAction SilentlyContinue
|
||||
if ($configuredAddress -and $configuredAddress.AddressState -eq 'Preferred') {
|
||||
return $RequestedAddress
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
} while ((Get-Date) -lt $addressReadyDeadline)
|
||||
|
||||
$observedState = if ($configuredAddress) { $configuredAddress.AddressState } else { 'Missing' }
|
||||
throw "The SGU client address '$RequestedAddress' did not become ready on '$InterfaceAlias' within 20 seconds. Observed state: $observedState."
|
||||
}
|
||||
|
||||
function Test-TcpPort {
|
||||
@@ -95,6 +251,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
|
||||
$operatingSystem = Get-CimInstance Win32_OperatingSystem
|
||||
if ([int]$operatingSystem.ProductType -ne 1) {
|
||||
@@ -115,7 +312,38 @@ if (-not $ComputerOuDn) {
|
||||
}
|
||||
|
||||
$packageRoot = $PSScriptRoot
|
||||
Assert-PackageManifest -PackageRoot $packageRoot
|
||||
$packageManifest = Assert-PackageManifest -PackageRoot $packageRoot
|
||||
$manifestProfile = if ($packageManifest.PSObject.Properties['CompatibilityProfile']) {
|
||||
[string]$packageManifest.CompatibilityProfile
|
||||
}
|
||||
else {
|
||||
'Auto'
|
||||
}
|
||||
if ($CompatibilityProfile -ne 'Auto' -and $manifestProfile -ne 'Auto' -and
|
||||
$CompatibilityProfile -ne $manifestProfile) {
|
||||
throw "The requested compatibility profile '$CompatibilityProfile' does not match package profile '$manifestProfile'."
|
||||
}
|
||||
if ($CompatibilityProfile -eq 'Auto') {
|
||||
$CompatibilityProfile = $manifestProfile
|
||||
}
|
||||
$windowsBuild = [int]$operatingSystem.BuildNumber
|
||||
if ($CompatibilityProfile -eq 'Auto') {
|
||||
$CompatibilityProfile = if ($windowsBuild -lt 22000) {
|
||||
'Windows10Legacy'
|
||||
}
|
||||
else {
|
||||
'Windows11Modern'
|
||||
}
|
||||
}
|
||||
if ($CompatibilityProfile -eq 'Windows10Legacy' -and $windowsBuild -ge 22000) {
|
||||
throw "The Windows 10 legacy package cannot enroll Windows build $windowsBuild. Use the Windows 11 modern client package."
|
||||
}
|
||||
if ($CompatibilityProfile -eq 'Windows11Modern' -and $windowsBuild -lt 22000) {
|
||||
throw "The Windows 11 modern package cannot enroll Windows build $windowsBuild. Use the Windows 10 legacy client package."
|
||||
}
|
||||
if ($CompatibilityProfile -eq 'Windows10Legacy' -and $ConnectivityMode -eq 'AzureP2S') {
|
||||
throw 'Azure P2S pre-logon enrollment belongs to the Windows 11 modern package. Use Direct connectivity for the Windows 10 legacy package.'
|
||||
}
|
||||
$scriptsRoot = Join-Path $packageRoot 'payload\scripts'
|
||||
$providerPublishPath = Join-Path $packageRoot 'payload\credential-provider'
|
||||
$runtimeInstaller = Get-ChildItem (Join-Path $packageRoot 'payload\prerequisites') `
|
||||
@@ -135,12 +363,58 @@ if (-not $runtimeInstaller) {
|
||||
throw 'The offline Microsoft .NET 10 x64 runtime installer is missing from the client package.'
|
||||
}
|
||||
|
||||
$NetworkInterfaceAlias = Resolve-ClientInterfaceAlias -RequestedAlias $NetworkInterfaceAlias
|
||||
Set-DnsClientServerAddress -InterfaceAlias $NetworkInterfaceAlias `
|
||||
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
|
||||
$ClientIPv4Address = Set-ClientDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
|
||||
-DomainControllerAddress $DomainControllerIPv4Address `
|
||||
-RequestedAddress $ClientIPv4Address -PrefixLength $ClientPrefixLength
|
||||
Set-DnsClientServerAddress -InterfaceAlias $NetworkInterfaceAlias `
|
||||
-ServerAddresses $DomainControllerIPv4Address.IPAddressToString
|
||||
}
|
||||
|
||||
if (-not (Test-TcpPort -Address $DomainControllerIPv4Address -Port 5985)) {
|
||||
throw "The domain controller at $DomainControllerIPv4Address is not accepting WinRM on TCP 5985. Run the server bootstrap first and verify the selected IP."
|
||||
if (-not (Wait-TcpPort -Address $DomainControllerIPv4Address -Port 5985 -TimeoutSeconds 20)) {
|
||||
throw "The domain controller at $DomainControllerIPv4Address did not accept WinRM on TCP 5985 after 20 seconds. Run the server bootstrap first and verify the selected IP."
|
||||
}
|
||||
|
||||
if (-not $DomainCredential) {
|
||||
@@ -292,6 +566,7 @@ try {
|
||||
ComputerOuDn = $ComputerOuDn
|
||||
NetworkInterfaceAlias = $NetworkInterfaceAlias
|
||||
DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString)
|
||||
ConnectivityMode = $ConnectivityMode
|
||||
RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP"
|
||||
DotNetRuntimeInstallerPath = $runtimeInstaller.FullName
|
||||
RustDeskServerAddress = $serverIdentity.RustDeskServerAddress
|
||||
@@ -352,10 +627,12 @@ finally {
|
||||
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $priorTrustedHosts -Force
|
||||
}
|
||||
if (-not $winRmWasRunning) {
|
||||
Stop-Service WinRM -Force -ErrorAction SilentlyContinue
|
||||
Stop-Service WinRM -Force -NoWait -WarningAction SilentlyContinue `
|
||||
-ErrorAction SilentlyContinue
|
||||
}
|
||||
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
$DomainCredential = $null
|
||||
$VpnClientCertificatePfxPassword = $null
|
||||
}
|
||||
|
||||
if ($SkipRestart) {
|
||||
@@ -365,6 +642,9 @@ if ($SkipRestart) {
|
||||
ProviderInstalled = $true
|
||||
ClientCertificateRegistered = $true
|
||||
BrokerEndpoint = $brokerEndpoint
|
||||
ConnectivityMode = $ConnectivityMode
|
||||
CompatibilityProfile = $CompatibilityProfile
|
||||
VpnConnectionName = if ($ConnectivityMode -eq 'AzureP2S') { $VpnConnectionName } else { $null }
|
||||
RestartRequired = $true
|
||||
RustDesk = if ($result) { $result.RustDesk } else { $null }
|
||||
EnrollmentResult = $result
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -33,7 +33,10 @@ function Write-PackageManifest {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$PackageRoot,
|
||||
[Parameter(Mandatory)][string]$PackageVersion,
|
||||
[Parameter(Mandatory)][string]$PackageKind
|
||||
[Parameter(Mandatory)][string]$PackageKind,
|
||||
[ValidateSet('Windows10Legacy', 'Windows11Modern')]
|
||||
[string]$CompatibilityProfile,
|
||||
[string]$TargetOperatingSystem
|
||||
)
|
||||
|
||||
$resolvedPackageRoot = (Resolve-Path -LiteralPath $PackageRoot).Path.TrimEnd('\')
|
||||
@@ -48,13 +51,19 @@ function Write-PackageManifest {
|
||||
}
|
||||
})
|
||||
$manifest = [ordered]@{
|
||||
SchemaVersion = 1
|
||||
SchemaVersion = 2
|
||||
Product = 'SGU Credential Provider'
|
||||
PackageKind = $PackageKind
|
||||
Version = $PackageVersion
|
||||
CreatedAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||
Files = $files
|
||||
}
|
||||
if ($CompatibilityProfile) {
|
||||
$manifest['CompatibilityProfile'] = $CompatibilityProfile
|
||||
}
|
||||
if ($TargetOperatingSystem) {
|
||||
$manifest['TargetOperatingSystem'] = $TargetOperatingSystem
|
||||
}
|
||||
[IO.File]::WriteAllText(
|
||||
(Join-Path $resolvedPackageRoot 'package-manifest.json'),
|
||||
($manifest | ConvertTo-Json -Depth 6),
|
||||
@@ -78,19 +87,28 @@ if (-not $runtimeInstaller) {
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $resolvedOutputRoot -Force | Out-Null
|
||||
$clientRoot = Join-Path $resolvedOutputRoot "sgu-client-bootstrap-$Version"
|
||||
$windows11ClientRoot = Join-Path $resolvedOutputRoot "sgu-windows11-client-bootstrap-$Version"
|
||||
$windows10ClientRoot = Join-Path $resolvedOutputRoot "sgu-windows10-legacy-client-bootstrap-$Version"
|
||||
$clientRoot = $windows11ClientRoot
|
||||
$serverRoot = Join-Path $resolvedOutputRoot "sgu-server-bootstrap-$Version"
|
||||
$linuxClientRoot = Join-Path $resolvedOutputRoot "sgu-linux-client-bootstrap-$Version"
|
||||
$clientZip = "$clientRoot.zip"
|
||||
$azureRoot = Join-Path $resolvedOutputRoot "sgu-azure-infrastructure-$Version"
|
||||
$windows11ClientZip = "$windows11ClientRoot.zip"
|
||||
$windows10ClientZip = "$windows10ClientRoot.zip"
|
||||
$serverZip = "$serverRoot.zip"
|
||||
$linuxClientZip = "$linuxClientRoot.zip"
|
||||
foreach ($target in @($clientRoot,$serverRoot,$linuxClientRoot,$clientZip,$serverZip,$linuxClientZip)) {
|
||||
$azureZip = "$azureRoot.zip"
|
||||
foreach ($target in @(
|
||||
$windows11ClientRoot,$windows10ClientRoot,$serverRoot,$linuxClientRoot,$azureRoot,
|
||||
$windows11ClientZip,$windows10ClientZip,$serverZip,$linuxClientZip,$azureZip)) {
|
||||
if (Test-Path -LiteralPath $target) {
|
||||
throw "Release target already exists: $target"
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $clientRoot,$serverRoot,$linuxClientRoot -Force | Out-Null
|
||||
New-Item -ItemType Directory `
|
||||
-Path $windows11ClientRoot,$windows10ClientRoot,$serverRoot,$linuxClientRoot,$azureRoot `
|
||||
-Force | Out-Null
|
||||
$welcomeFontNames = @(
|
||||
'IndivisaTextSans-Regular.otf',
|
||||
'IndivisaTextSans-Bold.otf',
|
||||
@@ -112,6 +130,7 @@ $clientScripts = @(
|
||||
'Install-SguRustDeskClient.ps1',
|
||||
'Register-SguClientCertificate.ps1',
|
||||
'Repair-SguClientEnrollment.ps1',
|
||||
'Set-SguStandardLocalUser.ps1',
|
||||
'Test-SguClientEnrollment.ps1'
|
||||
)
|
||||
foreach ($scriptName in $clientScripts) {
|
||||
@@ -134,8 +153,26 @@ foreach ($fontName in $welcomeFontNames) {
|
||||
}
|
||||
Copy-RequiredFile -Source $runtimeInstaller.FullName `
|
||||
-Destination (Join-Path $clientRoot "payload\prerequisites\$($runtimeInstaller.Name)")
|
||||
Write-PackageManifest -PackageRoot $clientRoot -PackageVersion $Version -PackageKind Client
|
||||
Compress-Archive -Path (Join-Path $clientRoot '*') -DestinationPath $clientZip `
|
||||
|
||||
# Both Windows packages share the provider and enrollment implementation. The
|
||||
# Windows 10 artifact freezes the direct-network compatibility surface, while
|
||||
# the Windows 11 artifact adds the modern Azure P2S/pre-logon entry point.
|
||||
Copy-Item -Path (Join-Path $windows11ClientRoot '*') `
|
||||
-Destination $windows10ClientRoot -Recurse -Force
|
||||
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguAzureClientEnrollment.cmd') `
|
||||
-Destination (Join-Path $windows11ClientRoot 'Start-SguAzureClientEnrollment.cmd')
|
||||
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Install-SguAzureP2sClient.ps1') `
|
||||
-Destination (Join-Path $windows11ClientRoot 'Install-SguAzureP2sClient.ps1')
|
||||
|
||||
Write-PackageManifest -PackageRoot $windows10ClientRoot -PackageVersion $Version `
|
||||
-PackageKind WindowsClient -CompatibilityProfile Windows10Legacy `
|
||||
-TargetOperatingSystem 'Windows 10 Pro, Enterprise, or Education (build below 22000)'
|
||||
Write-PackageManifest -PackageRoot $windows11ClientRoot -PackageVersion $Version `
|
||||
-PackageKind WindowsClient -CompatibilityProfile Windows11Modern `
|
||||
-TargetOperatingSystem 'Windows 11 Pro, Enterprise, or Education (build 22000 or later)'
|
||||
Compress-Archive -Path (Join-Path $windows10ClientRoot '*') -DestinationPath $windows10ClientZip `
|
||||
-CompressionLevel Optimal
|
||||
Compress-Archive -Path (Join-Path $windows11ClientRoot '*') -DestinationPath $windows11ClientZip `
|
||||
-CompressionLevel Optimal
|
||||
|
||||
# Linux clients use their native PAM/SSSD sign-in stack rather than the Windows
|
||||
@@ -163,6 +200,8 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Initialize-SguDomainControll
|
||||
-Destination (Join-Path $serverRoot 'Initialize-SguDomainController.ps1')
|
||||
Copy-RequiredFile -Source (Join-Path $PSScriptRoot '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 = @(
|
||||
'Deploy-AuthBroker.ps1',
|
||||
'Enable-SguServerRemoteManagement.ps1',
|
||||
@@ -211,22 +250,49 @@ Write-PackageManifest -PackageRoot $serverRoot -PackageVersion $Version -Package
|
||||
Compress-Archive -Path (Join-Path $serverRoot '*') -DestinationPath $serverZip `
|
||||
-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 = @(
|
||||
("{0} {1}" -f (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash, (Split-Path $clientZip -Leaf))
|
||||
("{0} {1}" -f (Get-FileHash -LiteralPath $windows10ClientZip -Algorithm SHA256).Hash, (Split-Path $windows10ClientZip -Leaf))
|
||||
("{0} {1}" -f (Get-FileHash -LiteralPath $windows11ClientZip -Algorithm SHA256).Hash, (Split-Path $windows11ClientZip -Leaf))
|
||||
("{0} {1}" -f (Get-FileHash -LiteralPath $serverZip -Algorithm SHA256).Hash, (Split-Path $serverZip -Leaf))
|
||||
("{0} {1}" -f (Get-FileHash -LiteralPath $linuxClientZip -Algorithm SHA256).Hash, (Split-Path $linuxClientZip -Leaf))
|
||||
("{0} {1}" -f (Get-FileHash -LiteralPath $azureZip -Algorithm SHA256).Hash, (Split-Path $azureZip -Leaf))
|
||||
)
|
||||
$checksumsPath = Join-Path $resolvedOutputRoot "SHA256SUMS-$Version.txt"
|
||||
[IO.File]::WriteAllLines($checksumsPath, $checksums, [Text.UTF8Encoding]::new($false))
|
||||
|
||||
[pscustomobject]@{
|
||||
Version = $Version
|
||||
ClientPackage = $clientZip
|
||||
ClientSha256 = (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash
|
||||
Windows10LegacyClientPackage = $windows10ClientZip
|
||||
Windows10LegacyClientSha256 = (Get-FileHash -LiteralPath $windows10ClientZip -Algorithm SHA256).Hash
|
||||
Windows11ClientPackage = $windows11ClientZip
|
||||
Windows11ClientSha256 = (Get-FileHash -LiteralPath $windows11ClientZip -Algorithm SHA256).Hash
|
||||
LinuxClientPackage = $linuxClientZip
|
||||
LinuxClientSha256 = (Get-FileHash -LiteralPath $linuxClientZip -Algorithm SHA256).Hash
|
||||
ServerPackage = $serverZip
|
||||
ServerSha256 = (Get-FileHash -LiteralPath $serverZip -Algorithm SHA256).Hash
|
||||
AzureInfrastructurePackage = $azureZip
|
||||
AzureInfrastructureSha256 = (Get-FileHash -LiteralPath $azureZip -Algorithm SHA256).Hash
|
||||
Checksums = $checksumsPath
|
||||
RuntimeInstaller = $runtimeInstaller.Name
|
||||
}
|
||||
|
||||
@@ -15,9 +15,11 @@ param(
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$tagName = "v$Version"
|
||||
$assetPaths = @(
|
||||
(Join-Path $ReleaseDirectory "sgu-client-bootstrap-$Version.zip"),
|
||||
(Join-Path $ReleaseDirectory "sgu-windows10-legacy-client-bootstrap-$Version.zip"),
|
||||
(Join-Path $ReleaseDirectory "sgu-windows11-client-bootstrap-$Version.zip"),
|
||||
(Join-Path $ReleaseDirectory "sgu-server-bootstrap-$Version.zip"),
|
||||
(Join-Path $ReleaseDirectory "sgu-linux-client-bootstrap-$Version.zip"),
|
||||
(Join-Path $ReleaseDirectory "sgu-azure-infrastructure-$Version.zip"),
|
||||
(Join-Path $ReleaseDirectory "SHA256SUMS-$Version.txt")
|
||||
)
|
||||
foreach ($assetPath in $assetPaths) {
|
||||
@@ -27,7 +29,9 @@ foreach ($assetPath in $assetPaths) {
|
||||
}
|
||||
|
||||
$token = $env:GITEA_TOKEN
|
||||
if (-not $token) {
|
||||
$authorizationScheme = 'token'
|
||||
$authorizationParameter = $token
|
||||
if (-not $authorizationParameter) {
|
||||
$credentialInput = "protocol=$($GiteaBaseUri.Scheme)`nhost=$($GiteaBaseUri.Host)`n`n"
|
||||
$credentialOutput = $credentialInput | & git credential fill
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
@@ -40,17 +44,25 @@ if (-not $token) {
|
||||
$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
|
||||
$handler = [Net.Http.HttpClientHandler]::new()
|
||||
$client = [Net.Http.HttpClient]::new($handler)
|
||||
$client.BaseAddress = [uri]($GiteaBaseUri.AbsoluteUri.TrimEnd('/') + '/')
|
||||
$client.DefaultRequestHeaders.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new('token', $token)
|
||||
$client.DefaultRequestHeaders.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new(
|
||||
$authorizationScheme,
|
||||
$authorizationParameter)
|
||||
$client.DefaultRequestHeaders.UserAgent.ParseAdd('SGU-CredentialProvider-Release/1.0')
|
||||
|
||||
function Invoke-GiteaJson {
|
||||
@@ -100,8 +112,20 @@ Bootstrap reproducible para el laboratorio SGU.
|
||||
|
||||
- **Advertencia:** el bootstrap de servidor crea un bosque nuevo. No restaura los SID, contraseñas ni relaciones de confianza del bosque anterior; para conservarlos se requiere una recuperación de bosque desde una copia de estado del sistema.
|
||||
- `sgu-server-bootstrap-$Version.zip`: crea el bosque AD/DNS, OUs, grupo RDP, GPO, recurso `Packages`, broker mTLS y administración remota; se reanuda solo después del reinicio.
|
||||
- `sgu-client-bootstrap-$Version.zip`: registra un certificado mTLS único, instala y valida el Credential Provider antes de unir el equipo al dominio, habilita RDP/WinRM y se repara al arranque.
|
||||
- `sgu-windows10-legacy-client-bootstrap-$Version.zip`: perfil directo para Windows 10 de laboratorio, con las correcciones de NIC privada, límites de cuentas locales y compatibilidad de sus APIs heredadas.
|
||||
- `sgu-windows11-client-bootstrap-$Version.zip`: perfil completo para Windows 11; conserva el enrolamiento directo y añade Azure P2S con certificado de máquina y entrada previa al inicio de sesión.
|
||||
- Ambos clientes comparten los mismos binarios, seguridad mTLS, Credential Provider, cuenta estándar, RustDesk, supervisión y autorreparación; el manifiesto impide ejecutar accidentalmente el paquete de la otra versión de Windows.
|
||||
- En clientes Hyper-V con dos NIC, el bootstrap selecciona la red privada sin puerta de enlace, solicita o acepta la IP fija del cliente, espera a que la dirección y WinRM estén disponibles y conserva en pantalla y archivo cualquier error de enrolamiento.
|
||||
- `sgu-linux-client-bootstrap-$Version.zip`: une clientes Debian/Ubuntu o RHEL/Fedora/Rocky/AlmaLinux con realmd, Kerberos y SSSD. Solicita interactivamente la contraseña de unión y no instala el Credential Provider de Windows.
|
||||
- `sgu-azure-infrastructure-$Version.zip`: despliega mediante Bicep una VM Windows Server 2025, red privada, IP pública protegida por NSG y Azure VPN Gateway P2S; también genera certificados por equipo y descarga el perfil de cliente.
|
||||
- El bootstrap Azure conserva la IP privada administrada por la NIC de Azure, autoriza el pool P2S en los firewalls SGU y nunca publica LDAP, Kerberos, SMB, RPC, WinRM ni el Auth Broker directamente a Internet.
|
||||
- Los Windows 11 Pro pueden instalar un perfil IKEv2 de todos los usuarios con certificado de máquina, DNS dividido para `lci.lasalle.mx` y ejecutarlo desde la pantalla de inicio de sesión antes de autenticar una cuenta de dominio nueva.
|
||||
- El Auth Broker clasifica sin tareas programadas cada cuenta autenticada: `AL` se agrega a `SGU-Alumnos`, `AD` a `SGU-Administrativos` y `DO` a `SGU-Docentes`; el bootstrap crea cada grupo dentro de la OU de su rol y migra idempotentemente cualquier grupo heredado sin cambiar su SID.
|
||||
- El Auth Broker resuelve la dirección guardada de administrativos y docentes mediante `GetDireccion`, `GetLocalidadListado` y `GetColoniasListado`, evitando conservar los valores transitorios `Seleccione...` de los controles dinámicos de SGU.
|
||||
- El enrolamiento y la reparación de clientes Windows crean y verifican idempotentemente la cuenta local estándar `alumno`, sin pertenencia al grupo de administradores.
|
||||
- La descripción de la cuenta local administrada respeta el límite de 48 caracteres de Windows 10 Enterprise.
|
||||
- La validación de expiración de contraseña usa el indicador de cuenta compatible con Windows 10 y 11, en lugar de una propiedad que Windows 10 no expone.
|
||||
- El enriquecimiento obtiene el sexo de los módulos SGU de personal/alumnos, lo conserva como la línea administrada `SGU-Gender: Male|Female` en Notas de AD y adapta el fondo de Windows/Linux; cuando falta utiliza redacción neutral.
|
||||
- El servidor configura WEF/WEC para registrar sesiones y fallos, inventariar el estado alcanzable de las máquinas cada cinco minutos y conservar durante 183 días tanto esos eventos como el diagnóstico estructurado del Auth Broker.
|
||||
- Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host.
|
||||
|
||||
@@ -154,6 +178,8 @@ Las contraseñas se solicitan de forma interactiva y no se escriben en archivos
|
||||
}
|
||||
finally {
|
||||
$token = $null
|
||||
$authorizationParameter = $null
|
||||
$credentialValues = $null
|
||||
$client.Dispose()
|
||||
$handler.Dispose()
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ $installScript = Join-Path $enrollmentRoot 'Install-CredentialProvider.ps1'
|
||||
$remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1'
|
||||
$monitoringScript = Join-Path $enrollmentRoot 'Enable-SguClientMonitoring.ps1'
|
||||
$rustDeskScript = Join-Path $enrollmentRoot 'Install-SguRustDeskClient.ps1'
|
||||
$localUserScript = Join-Path $enrollmentRoot 'Set-SguStandardLocalUser.ps1'
|
||||
|
||||
$before = & $testScript
|
||||
if (-not $before.IsValid) {
|
||||
& $localUserScript | Out-Null
|
||||
$installParams = @{
|
||||
PublishPath = [string]$configuration.PublishPath
|
||||
BrokerEndpoint = [string]$configuration.BrokerEndpoint
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#Requires -Version 5.1
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$userName = 'alumno'
|
||||
$plainTextPassword = 'ingenieria'
|
||||
$description = 'Cuenta local estandar SGU para recuperacion'
|
||||
$passwordNeverExpiresFlag = 0x10000
|
||||
|
||||
function Get-LocalUserFlags {
|
||||
param([Parameter(Mandatory)][string]$Name)
|
||||
|
||||
$directoryEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$Name,user")
|
||||
return [int]$directoryEntry.InvokeGet('UserFlags')
|
||||
}
|
||||
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw 'Run this script from an elevated Windows PowerShell session.'
|
||||
}
|
||||
|
||||
if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Create or update standard local user $userName")) {
|
||||
return
|
||||
}
|
||||
|
||||
$securePassword = ConvertTo-SecureString $plainTextPassword -AsPlainText -Force
|
||||
try {
|
||||
$user = Get-LocalUser -Name $userName -ErrorAction SilentlyContinue
|
||||
if ($user -and $user.SID.Value.EndsWith('-500', [StringComparison]::Ordinal)) {
|
||||
throw "The local account '$userName' is the built-in Administrator account and cannot be converted to a standard user."
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Set-LocalUser -Name $userName `
|
||||
-Password $securePassword `
|
||||
-PasswordNeverExpires $true `
|
||||
-Description $description
|
||||
if (-not $user.Enabled) {
|
||||
Enable-LocalUser -Name $userName
|
||||
}
|
||||
}
|
||||
else {
|
||||
New-LocalUser -Name $userName `
|
||||
-Password $securePassword `
|
||||
-PasswordNeverExpires `
|
||||
-Description $description | Out-Null
|
||||
}
|
||||
|
||||
# Windows 10's Get-LocalUser object has PasswordExpires but does not expose
|
||||
# PasswordNeverExpires. Enforce and verify the underlying UF_DONT_EXPIRE_PASSWD
|
||||
# flag so the result is consistent across Windows 10 and Windows 11.
|
||||
$directoryEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$userName,user")
|
||||
$userFlags = [int]$directoryEntry.InvokeGet('UserFlags')
|
||||
if (($userFlags -band $passwordNeverExpiresFlag) -eq 0) {
|
||||
$directoryEntry.InvokeSet('UserFlags', ($userFlags -bor $passwordNeverExpiresFlag))
|
||||
$directoryEntry.CommitChanges()
|
||||
}
|
||||
|
||||
$user = Get-LocalUser -Name $userName -ErrorAction Stop
|
||||
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
||||
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
|
||||
$administratorsGroup = Get-LocalGroup -SID $administratorsSid -ErrorAction Stop
|
||||
$usersGroup = Get-LocalGroup -SID $usersSid -ErrorAction Stop
|
||||
$administratorMembers = @(Get-LocalGroupMember -Group $administratorsGroup -ErrorAction Stop)
|
||||
if ($administratorMembers.SID.Value -contains $user.SID.Value) {
|
||||
Remove-LocalGroupMember -Group $administratorsGroup -Member $user -Confirm:$false
|
||||
}
|
||||
|
||||
$standardMembers = @(Get-LocalGroupMember -Group $usersGroup -ErrorAction Stop)
|
||||
if ($standardMembers.SID.Value -notcontains $user.SID.Value) {
|
||||
Add-LocalGroupMember -Group $usersGroup -Member $user
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$securePassword = $null
|
||||
}
|
||||
|
||||
$verifiedUser = Get-LocalUser -Name $userName -ErrorAction Stop
|
||||
$verifiedAdministratorsGroup = Get-LocalGroup `
|
||||
-SID ([Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')) `
|
||||
-ErrorAction Stop
|
||||
$verifiedUsersGroup = Get-LocalGroup `
|
||||
-SID ([Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')) `
|
||||
-ErrorAction Stop
|
||||
$verifiedAdministrators = @(Get-LocalGroupMember -Group $verifiedAdministratorsGroup -ErrorAction Stop)
|
||||
$verifiedUsers = @(Get-LocalGroupMember -Group $verifiedUsersGroup -ErrorAction Stop)
|
||||
if (@($verifiedAdministrators).SID.Value -contains $verifiedUser.SID.Value) {
|
||||
throw "The local account '$userName' still belongs to the local Administrators group."
|
||||
}
|
||||
if ($verifiedUsers.SID.Value -notcontains $verifiedUser.SID.Value) {
|
||||
throw "The local account '$userName' does not belong to the local Users group."
|
||||
}
|
||||
$verifiedPasswordNeverExpires =
|
||||
((Get-LocalUserFlags -Name $userName) -band $passwordNeverExpiresFlag) -ne 0
|
||||
if (-not $verifiedPasswordNeverExpires) {
|
||||
throw "The local account '$userName' password is not configured to never expire."
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
UserName = $verifiedUser.Name
|
||||
Enabled = $verifiedUser.Enabled
|
||||
IsAdministrator = $false
|
||||
IsStandardUser = $true
|
||||
PasswordNeverExpires = $verifiedPasswordNeverExpires
|
||||
}
|
||||
@@ -8,6 +8,8 @@ param(
|
||||
[string]$ComputerName = $env:COMPUTERNAME,
|
||||
[string]$Location,
|
||||
[string]$OrganizationalUnit,
|
||||
[ValidateSet('Male', 'Female')]
|
||||
[string]$Gender,
|
||||
[ValidateRange(640, 16384)]
|
||||
[int]$CanvasWidth,
|
||||
[ValidateRange(480, 16384)]
|
||||
@@ -67,6 +69,22 @@ function Get-ImmediateOrganizationalUnit {
|
||||
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 {
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$UserName,
|
||||
@@ -93,6 +111,7 @@ function Get-DirectoryWelcomeMetadata {
|
||||
$userSearcher.Filter = '(&(objectCategory=person)(objectClass=user)(sAMAccountName={0}))' -f `
|
||||
(ConvertTo-LdapFilterValue -Value $UserName)
|
||||
[void]$userSearcher.PropertiesToLoad.Add('displayName')
|
||||
[void]$userSearcher.PropertiesToLoad.Add('info')
|
||||
$userResult = $userSearcher.FindOne()
|
||||
$directoryDisplayName = if ($userResult -and $userResult.Properties['displayname'].Count) {
|
||||
[string]$userResult.Properties['displayname'][0]
|
||||
@@ -100,6 +119,12 @@ function Get-DirectoryWelcomeMetadata {
|
||||
else {
|
||||
$null
|
||||
}
|
||||
$directoryGender = if ($userResult -and $userResult.Properties['info'].Count) {
|
||||
Get-SguGenderFromInfo -Info ([string]$userResult.Properties['info'][0])
|
||||
}
|
||||
else {
|
||||
$null
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$userSearcher.Dispose()
|
||||
@@ -136,6 +161,7 @@ function Get-DirectoryWelcomeMetadata {
|
||||
|
||||
[pscustomobject]@{
|
||||
DisplayName = $directoryDisplayName
|
||||
Gender = $directoryGender
|
||||
Location = $directoryLocation
|
||||
OrganizationalUnit = Get-ImmediateOrganizationalUnit -DistinguishedName $computerDn
|
||||
}
|
||||
@@ -156,11 +182,20 @@ function Get-SpanishArticle {
|
||||
function Get-WelcomeLocationText {
|
||||
param(
|
||||
[string]$Room,
|
||||
[string]$OuName
|
||||
[string]$OuName,
|
||||
[string]$Gender
|
||||
)
|
||||
|
||||
$located = 'Est{0}s ubicado en' -f [char]0x00E1
|
||||
$engineeringLab = 'Bienvenido al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED
|
||||
$located = switch ($Gender) {
|
||||
'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 }
|
||||
$OuName = if ($OuName) { $OuName.Trim() } else { $null }
|
||||
|
||||
@@ -184,6 +219,16 @@ function Get-WelcomeLocationText {
|
||||
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 {
|
||||
param(
|
||||
[Parameter(Mandatory)][string[]]$Candidates,
|
||||
@@ -270,10 +315,15 @@ if (-not $DisplayName) {
|
||||
if (-not $PSBoundParameters.ContainsKey('Location') -and $metadata) {
|
||||
$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) {
|
||||
$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) {
|
||||
try {
|
||||
@@ -366,7 +416,7 @@ try {
|
||||
$format.Trimming = [Drawing.StringTrimming]::EllipsisWord
|
||||
try {
|
||||
$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
|
||||
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
|
||||
@@ -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]@{
|
||||
DisplayName = $DisplayName
|
||||
ComputerName = $ComputerName
|
||||
Location = $Location
|
||||
OrganizationalUnit = $OrganizationalUnit
|
||||
Gender = $Gender
|
||||
WelcomeHeading = $welcomeHeading
|
||||
LocationText = $locationText
|
||||
OutputPath = $OutputPath
|
||||
Applied = -not $SkipApply
|
||||
|
||||
@@ -53,6 +53,7 @@ computer_name=${computer_name^^}
|
||||
location=''
|
||||
distinguished_name=''
|
||||
organizational_unit=''
|
||||
gender=''
|
||||
|
||||
read_ldif_value() {
|
||||
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 \
|
||||
-H "ldap://${ldap_server}" -b "$BASE_DN" \
|
||||
"(&(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")
|
||||
[[ -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
|
||||
else
|
||||
log_message 'WARN AD metadata query skipped because Kerberos or LDAP session data was unavailable.'
|
||||
@@ -136,6 +146,24 @@ with_article() {
|
||||
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
|
||||
room_phrase=$(with_article "$location")
|
||||
ou_article=$(article_for "$organizational_unit")
|
||||
@@ -146,13 +174,13 @@ if [[ -n $location && -n $organizational_unit ]]; then
|
||||
else
|
||||
ou_phrase="de ${organizational_unit}"
|
||||
fi
|
||||
location_text="Estás ubicado en ${room_phrase} ${ou_phrase}."
|
||||
location_text="${located_text} ${room_phrase} ${ou_phrase}."
|
||||
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
|
||||
location_text="Estás ubicado en $(with_article "$organizational_unit")."
|
||||
location_text="${located_text} $(with_article "$organizational_unit")."
|
||||
else
|
||||
location_text='Bienvenido al Laboratorio de Cómputo de Ingeniería.'
|
||||
location_text=$engineering_lab_text
|
||||
fi
|
||||
|
||||
width=1600
|
||||
@@ -210,7 +238,7 @@ if ! "${image_command[@]}" "$BASE_IMAGE" \
|
||||
-gravity center \
|
||||
-font "$sans_font" -weight 700 -style Normal -pointsize "$welcome_size" \
|
||||
-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" \
|
||||
-fill white -annotate "+0-$(( 22 * scale / 100 ))" "$display_name" \
|
||||
-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
|
||||
|
||||
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
|
||||
log_message 'WARN Wallpaper rendered, but no supported desktop background API was found.'
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
@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 + '"'),'-PauseOnError','-ConnectivityMode','AzureP2S'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',('"' + $env:SGU_BOOTSTRAP_IP + '"')) }; if ($env:SGU_VPN_PACKAGE) { $arguments += @('-VpnProfilePackagePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PACKAGE) + '"')) }; if ($env:SGU_VPN_PFX) { $arguments += @('-VpnClientCertificatePfxPath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PFX) + '"')) }; if ($env:SGU_VPN_ROOT) { $arguments += @('-VpnClientRootCertificatePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_ROOT) + '"')) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode"
|
||||
set "SGU_EXIT_CODE=%errorlevel%"
|
||||
if not "%SGU_EXIT_CODE%"=="0" (
|
||||
echo.
|
||||
echo SGU Windows 11 Azure enrollment did not complete. Review:
|
||||
echo C:\ProgramData\SGU\Bootstrap\Client\latest-error.log
|
||||
pause
|
||||
)
|
||||
exit /b %SGU_EXIT_CODE%
|
||||
@@ -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%
|
||||
@@ -1,5 +1,14 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set "SGU_BOOTSTRAP_IP=%~1"
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"')); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',$env:SGU_BOOTSTRAP_IP) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode"
|
||||
exit /b %errorlevel%
|
||||
set "SGU_CLIENT_IP=%~2"
|
||||
set "SGU_NETWORK_ALIAS=%~3"
|
||||
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"'),'-PauseOnError'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',$env:SGU_BOOTSTRAP_IP) }; if ($env:SGU_CLIENT_IP) { $arguments += @('-ClientIPv4Address',$env:SGU_CLIENT_IP) }; if ($env:SGU_NETWORK_ALIAS) { $arguments += @('-NetworkInterfaceAlias',('"' + $env:SGU_NETWORK_ALIAS + '"')) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode"
|
||||
set "SGU_EXIT_CODE=%errorlevel%"
|
||||
if not "%SGU_EXIT_CODE%"=="0" (
|
||||
echo.
|
||||
echo SGU client enrollment did not complete. Review the elevated window or:
|
||||
echo C:\ProgramData\SGU\Bootstrap\Client\latest-error.log
|
||||
pause
|
||||
)
|
||||
exit /b %SGU_EXIT_CODE%
|
||||
|
||||
@@ -19,6 +19,8 @@ $defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
|
||||
$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
|
||||
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
|
||||
$issues = [Collections.Generic.List[string]]::new()
|
||||
$standardLocalUserName = 'alumno'
|
||||
$passwordNeverExpiresFlag = 0x10000
|
||||
|
||||
$computer = Get-CimInstance Win32_ComputerSystem
|
||||
if ($RequireDomainJoined -and -not $computer.PartOfDomain) {
|
||||
@@ -87,6 +89,50 @@ if (-not $passwordProviderPreserved) {
|
||||
$issues.Add('The built-in Microsoft password provider registration is missing.')
|
||||
}
|
||||
|
||||
$standardLocalUser = Get-LocalUser -Name $standardLocalUserName -ErrorAction SilentlyContinue
|
||||
$standardLocalUserPresent = [bool]$standardLocalUser
|
||||
$standardLocalUserEnabled = $standardLocalUserPresent -and $standardLocalUser.Enabled
|
||||
$standardLocalUserIsAdministrator = $false
|
||||
$standardLocalUserInUsersGroup = $false
|
||||
$standardLocalUserPasswordNeverExpires = $false
|
||||
if ($standardLocalUserPresent) {
|
||||
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
||||
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
|
||||
$administratorsGroup = Get-LocalGroup -SID $administratorsSid -ErrorAction Stop
|
||||
$usersGroup = Get-LocalGroup -SID $usersSid -ErrorAction Stop
|
||||
$administratorMembers = @(Get-LocalGroupMember -Group $administratorsGroup -ErrorAction Stop)
|
||||
$standardMembers = @(Get-LocalGroupMember -Group $usersGroup -ErrorAction Stop)
|
||||
$standardLocalUserIsAdministrator =
|
||||
$administratorMembers.SID.Value -contains $standardLocalUser.SID.Value
|
||||
$standardLocalUserInUsersGroup =
|
||||
$standardMembers.SID.Value -contains $standardLocalUser.SID.Value
|
||||
try {
|
||||
$directoryEntry = [ADSI]("WinNT://$env:COMPUTERNAME/$standardLocalUserName,user")
|
||||
$userFlags = [int]$directoryEntry.InvokeGet('UserFlags')
|
||||
$standardLocalUserPasswordNeverExpires =
|
||||
($userFlags -band $passwordNeverExpiresFlag) -ne 0
|
||||
}
|
||||
catch {
|
||||
# Report the account as invalid when Windows cannot read its flags.
|
||||
$standardLocalUserPasswordNeverExpires = $false
|
||||
}
|
||||
}
|
||||
if (-not $standardLocalUserPresent) {
|
||||
$issues.Add("The required standard local user '$standardLocalUserName' is missing.")
|
||||
}
|
||||
elseif (-not $standardLocalUserEnabled) {
|
||||
$issues.Add("The required standard local user '$standardLocalUserName' is disabled.")
|
||||
}
|
||||
elseif ($standardLocalUserIsAdministrator) {
|
||||
$issues.Add("The required standard local user '$standardLocalUserName' belongs to the local Administrators group.")
|
||||
}
|
||||
elseif (-not $standardLocalUserInUsersGroup) {
|
||||
$issues.Add("The required standard local user '$standardLocalUserName' does not belong to the local Users group.")
|
||||
}
|
||||
elseif (-not $standardLocalUserPasswordNeverExpires) {
|
||||
$issues.Add("The required standard local user '$standardLocalUserName' password is not configured to never expire.")
|
||||
}
|
||||
|
||||
$settings = $null
|
||||
try {
|
||||
$settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json
|
||||
@@ -207,6 +253,11 @@ $result = [pscustomobject]@{
|
||||
LastSignedInUserHidden = $lastSignedInUserHidden
|
||||
LocalUserEnumerationDisabled = $localUserEnumerationDisabled
|
||||
PasswordProviderPreserved = $passwordProviderPreserved
|
||||
StandardLocalUserPresent = $standardLocalUserPresent
|
||||
StandardLocalUserEnabled = $standardLocalUserEnabled
|
||||
StandardLocalUserIsAdministrator = $standardLocalUserIsAdministrator
|
||||
StandardLocalUserInUsersGroup = $standardLocalUserInUsersGroup
|
||||
StandardLocalUserPasswordNeverExpires = $standardLocalUserPasswordNeverExpires
|
||||
SettingsPresent = [bool]$settings
|
||||
ClientCertificatePresent = [bool]$clientCertificatePresent
|
||||
ServerCertificateTrusted = $serverCertificateTrusted
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SGU.AuthBroker.Core.Profiles;
|
||||
|
||||
public enum InstitutionalGender
|
||||
{
|
||||
Male,
|
||||
Female
|
||||
}
|
||||
@@ -12,7 +12,8 @@ public sealed record InstitutionalProfile(
|
||||
string? StreetAddress = null,
|
||||
string? City = null,
|
||||
string? State = null,
|
||||
string? PostalCode = null)
|
||||
string? PostalCode = null,
|
||||
InstitutionalGender? Gender = null)
|
||||
{
|
||||
public bool HasValues =>
|
||||
EmployeeNumber is not null ||
|
||||
@@ -26,7 +27,8 @@ public sealed record InstitutionalProfile(
|
||||
StreetAddress is not null ||
|
||||
City is not null ||
|
||||
State is not null ||
|
||||
PostalCode is not null;
|
||||
PostalCode is not null ||
|
||||
Gender is not null;
|
||||
|
||||
public InstitutionalProfile Overlay(InstitutionalProfile? values) =>
|
||||
values is null
|
||||
@@ -44,6 +46,7 @@ public sealed record InstitutionalProfile(
|
||||
StreetAddress = values.StreetAddress ?? StreetAddress,
|
||||
City = values.City ?? City,
|
||||
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.Mail;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SGU.AuthBroker.Core.Profiles;
|
||||
|
||||
@@ -14,6 +15,8 @@ public static class SguProfileParser
|
||||
private const string AdministrativeGivenNameId = "ctl00_contenedor_txtNombre";
|
||||
private const string AdministrativePaternalSurnameId = "ctl00_contenedor_txtApaterno";
|
||||
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 AdministrativeExteriorNumberId = "ctl00_contenedor_txtNoExt";
|
||||
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 StudentMunicipalityId = "ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP";
|
||||
private const string StudentPostalCodeId = "ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP";
|
||||
private const string StudentGenderId = "ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP";
|
||||
|
||||
public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber)
|
||||
=> ParseStaffHeader(html, expectedEmployeeNumber);
|
||||
@@ -91,11 +95,22 @@ public static class SguProfileParser
|
||||
InstitutionalProfile profile = new(
|
||||
DisplayName: displayName,
|
||||
GivenName: givenName,
|
||||
Surname: surname);
|
||||
Surname: surname,
|
||||
Gender: ParseStaffGender(ExtractSelectedOptionValue(
|
||||
html,
|
||||
AdministrativeGenderId,
|
||||
AdministrativeGenderName)));
|
||||
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);
|
||||
|
||||
@@ -105,18 +120,79 @@ public static class SguProfileParser
|
||||
string? interiorNumber = NormalizeAddressUnit(
|
||||
ExtractInputValue(html, AdministrativeInteriorNumberId));
|
||||
string? streetLine = BuildAdministrativeStreetLine(street, exteriorNumber, interiorNumber);
|
||||
string? neighborhood = NormalizeTitle(
|
||||
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId),
|
||||
256);
|
||||
string? neighborhood = NormalizeTitle(FirstNonEmpty(
|
||||
selection?.NeighborhoodName,
|
||||
ResolveNeighborhoodName(neighborhoodsJson, selection),
|
||||
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId)), 256);
|
||||
string? city = NormalizeTitle(FirstNonEmpty(
|
||||
selection?.MunicipalityName,
|
||||
ResolveLocalityName(localitiesJson, selection),
|
||||
ExtractSelectedOptionText(html, AdministrativeCityId)), 128);
|
||||
string? state = NormalizeTitle(FirstNonEmpty(
|
||||
selection?.StateName,
|
||||
ExtractOptionTextByValue(html, AdministrativeStateId, selection?.StateId),
|
||||
ExtractSelectedOptionText(html, AdministrativeStateId)), 128);
|
||||
string? postalCode = NormalizePostalCode(FirstNonEmpty(
|
||||
selection?.PostalCode,
|
||||
ExtractInputValue(html, AdministrativePostalCodeId)));
|
||||
|
||||
InstitutionalProfile profile = new(
|
||||
StreetAddress: BuildStreetAddress(streetLine, neighborhood, null, null),
|
||||
City: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeCityId), 128),
|
||||
State: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeStateId), 128),
|
||||
PostalCode: NormalizePostalCode(ExtractInputValue(html, AdministrativePostalCodeId)));
|
||||
City: city,
|
||||
State: state,
|
||||
PostalCode: postalCode);
|
||||
return profile.HasValues ? profile : null;
|
||||
}
|
||||
|
||||
public static SguAdministrativeLocationSelection? ParseAdministrativeLocationSelection(
|
||||
string json,
|
||||
string? expectedPostalCode)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(json);
|
||||
|
||||
string? expected = NormalizePostalCode(expectedPostalCode);
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(json);
|
||||
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (JsonElement value in values.EnumerateArray())
|
||||
{
|
||||
string? postalCode = NormalizePostalCode(GetJsonString(value, "p_Cp"));
|
||||
if (expected is not null &&
|
||||
!string.Equals(postalCode, expected, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
SguAdministrativeLocationSelection selection = new(
|
||||
StateId: NormalizeCatalogId(GetJsonString(value, "p_IdEstado")),
|
||||
MunicipalityId: NormalizeCatalogId(GetJsonString(value, "p_IdMunicipio")),
|
||||
NeighborhoodId: NormalizeCatalogId(GetJsonString(value, "p_IdCP")),
|
||||
StateName: Limit(GetJsonString(value, "p_NombreEstado"), 128),
|
||||
MunicipalityName: Limit(GetJsonString(value, "p_NombreMunicipio"), 128),
|
||||
NeighborhoodName: Limit(FirstNonEmpty(
|
||||
GetJsonString(value, "p_NombreColonia"),
|
||||
GetJsonString(value, "p_NombreAsentamiento"),
|
||||
GetJsonString(value, "p_Nombre")), 256),
|
||||
PostalCode: postalCode);
|
||||
if (selection.HasValues)
|
||||
{
|
||||
return selection;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(html);
|
||||
@@ -151,7 +227,8 @@ public static class SguProfileParser
|
||||
StreetAddress: streetAddress,
|
||||
City: city ?? municipality,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -292,11 +369,248 @@ public static class SguProfileParser
|
||||
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)
|
||||
=> FindOpeningTagByAttribute(html, tagName, "id", id);
|
||||
|
||||
private static string? FindOpeningTagByAttribute(
|
||||
string html,
|
||||
string tagName,
|
||||
string attributeName,
|
||||
string attributeValue)
|
||||
{
|
||||
foreach (char quote in new[] { '"', '\'' })
|
||||
{
|
||||
string marker = $"id={quote}{id}{quote}";
|
||||
string marker = $"{attributeName}={quote}{attributeValue}{quote}";
|
||||
int searchFrom = 0;
|
||||
while (searchFrom < html.Length)
|
||||
{
|
||||
@@ -324,6 +638,20 @@ public static class SguProfileParser
|
||||
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)
|
||||
{
|
||||
foreach (char quote in new[] { '"', '\'' })
|
||||
|
||||
@@ -21,4 +21,5 @@ internal static class BrokerEventIds
|
||||
internal static readonly EventId DirectorySynchronizationFailure = new(1300, nameof(DirectorySynchronizationFailure));
|
||||
internal static readonly EventId DirectoryOptionalMetadataFailure = new(1301, nameof(DirectoryOptionalMetadataFailure));
|
||||
internal static readonly EventId DirectoryGroupMembershipFailure = new(1302, nameof(DirectoryGroupMembershipFailure));
|
||||
internal static readonly EventId DirectoryRoleGroupMembershipAdded = new(1303, nameof(DirectoryRoleGroupMembershipAdded));
|
||||
}
|
||||
|
||||
@@ -87,6 +87,14 @@ public sealed class BrokerOptions
|
||||
{
|
||||
throw new InvalidOperationException($"The OU mapping for {role} must be beneath BaseDn.");
|
||||
}
|
||||
|
||||
string groupDn = Directory.GetGroupDn(role);
|
||||
if (string.IsNullOrWhiteSpace(groupDn) ||
|
||||
!groupDn.StartsWith("CN=", StringComparison.OrdinalIgnoreCase) ||
|
||||
!groupDn.EndsWith($",{Directory.BaseDn}", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"The security-group mapping for {role} must identify a group beneath BaseDn.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Directory.RemoteDesktopGroupDn) &&
|
||||
@@ -168,6 +176,12 @@ public sealed class ActiveDirectoryOptions
|
||||
|
||||
public string AdministrativeOuDn { get; init; } = "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||
|
||||
public string ProfessorGroupDn { get; init; } = "CN=SGU-Docentes,OU=Docentes,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=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||
|
||||
public string RemoteDesktopGroupDn { get; init; } = string.Empty;
|
||||
|
||||
public string DefaultCompany { get; init; } = "La Salle";
|
||||
@@ -181,4 +195,12 @@ public sealed class ActiveDirectoryOptions
|
||||
InstitutionalRole.Administrative => AdministrativeOuDn,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(role), role, null)
|
||||
};
|
||||
|
||||
public string GetGroupDn(InstitutionalRole role) => role switch
|
||||
{
|
||||
InstitutionalRole.Professor => ProfessorGroupDn,
|
||||
InstitutionalRole.Student => StudentGroupDn,
|
||||
InstitutionalRole.Administrative => AdministrativeGroupDn,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(role), role, null)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ public sealed class ActiveDirectorySynchronizer(
|
||||
BrokerOptions options,
|
||||
ILogger<ActiveDirectorySynchronizer> logger) : IActiveDirectorySynchronizer
|
||||
{
|
||||
private const string GenderMetadataPrefix = "SGU-Gender:";
|
||||
private const int InfoAttributeMaximumLength = 1024;
|
||||
private const int AccountDisabled = 0x0002;
|
||||
private const int NormalAccount = 0x0200;
|
||||
private static readonly AuthenticationTypes BindFlags =
|
||||
@@ -109,6 +111,12 @@ public sealed class ActiveDirectorySynchronizer(
|
||||
user.CommitChanges();
|
||||
}
|
||||
|
||||
// Role membership is part of account provisioning, not optional
|
||||
// enrichment. Do it before changing the password so a missing or
|
||||
// inaccessible authorization group cannot leave a newly usable
|
||||
// account without its required classification.
|
||||
EnsureRoleGroupMembership(user, identity);
|
||||
|
||||
// The exact institutional password received by the broker is passed to AD.
|
||||
// It is not derived, transformed, written to disk, or included in logs.
|
||||
user.Invoke("SetPassword", [password]);
|
||||
@@ -158,6 +166,7 @@ public sealed class ActiveDirectorySynchronizer(
|
||||
SetOptionalProperty(user, "l", profile.City);
|
||||
SetOptionalProperty(user, "st", profile.State);
|
||||
SetOptionalProperty(user, "postalCode", profile.PostalCode);
|
||||
SetGenderMetadata(user, profile.Gender, identity.UserName, logger);
|
||||
if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal))
|
||||
{
|
||||
SetOptionalProperty(user, "employeeID", profile.EmployeeNumber);
|
||||
@@ -195,6 +204,83 @@ 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)
|
||||
{
|
||||
user.RefreshCache(["distinguishedName"]);
|
||||
string? userDn = Convert.ToString(user.Properties["distinguishedName"].Value);
|
||||
if (string.IsNullOrWhiteSpace(userDn))
|
||||
{
|
||||
throw new InvalidOperationException($"Active Directory did not return a distinguished name for {identity.UserName}.");
|
||||
}
|
||||
|
||||
string groupDn = options.GetGroupDn(identity.Role);
|
||||
using DirectoryEntry group = Bind(groupDn);
|
||||
_ = group.NativeObject;
|
||||
if (group.Properties["member"].Contains(userDn))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
group.Properties["member"].Add(userDn);
|
||||
group.CommitChanges();
|
||||
logger.LogInformation(
|
||||
BrokerEventIds.DirectoryRoleGroupMembershipAdded,
|
||||
"Added {InstitutionalUser} with role {Role} to Active Directory security group {GroupDn}.",
|
||||
identity.UserName,
|
||||
identity.Role,
|
||||
groupDn);
|
||||
}
|
||||
|
||||
private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user, string institutionalUser)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using SGU.AuthBroker.Core.Authentication;
|
||||
using SGU.AuthBroker.Core.Identity;
|
||||
using SGU.AuthBroker.Core.Profiles;
|
||||
@@ -431,9 +433,6 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
pages.Add((
|
||||
options.AdministrativePersonalProfilePath,
|
||||
SguProfileParser.ParseAdministrativePersonal));
|
||||
pages.Add((
|
||||
options.AdministrativeLocationProfilePath,
|
||||
SguProfileParser.ParseAdministrativeLocation));
|
||||
|
||||
foreach ((string path, Func<string, InstitutionalProfile?> parser) in pages)
|
||||
{
|
||||
@@ -474,7 +473,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
"SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||
identity.Role,
|
||||
elapsed.ElapsedMilliseconds);
|
||||
break;
|
||||
return profile;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -487,6 +486,142 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
}
|
||||
}
|
||||
|
||||
return await TryEnrichStaffLocationAsync(
|
||||
client,
|
||||
profile,
|
||||
identity,
|
||||
allowedHosts,
|
||||
timeoutToken,
|
||||
requestCancellationToken,
|
||||
elapsed).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<InstitutionalProfile> TryEnrichStaffLocationAsync(
|
||||
HttpClient client,
|
||||
InstitutionalProfile profile,
|
||||
UserIdentity identity,
|
||||
HashSet<string> allowedHosts,
|
||||
CancellationToken timeoutToken,
|
||||
CancellationToken requestCancellationToken,
|
||||
Stopwatch elapsed)
|
||||
{
|
||||
string path = options.AdministrativeLocationProfilePath;
|
||||
Uri locationPageUri = GetProfileUri(path);
|
||||
try
|
||||
{
|
||||
string? html = await TryFetchAdditionalProfilePageAsync(
|
||||
client,
|
||||
locationPageUri,
|
||||
allowedHosts,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
if (html is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.ProfilePageUnavailable,
|
||||
"Optional SGU profile page {Path} did not return usable HTML for role {Role}; preserving fields already collected.",
|
||||
path,
|
||||
identity.Role);
|
||||
return profile;
|
||||
}
|
||||
|
||||
InstitutionalProfile? staticLocation = SguProfileParser.ParseAdministrativeLocation(html);
|
||||
if (staticLocation is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.ProfileHtmlUnexpected,
|
||||
"Optional SGU profile page {Path} returned HTML without its supported field IDs for role {Role}; preserving fields already collected.",
|
||||
path,
|
||||
identity.Role);
|
||||
return profile;
|
||||
}
|
||||
|
||||
profile = profile.Overlay(staticLocation);
|
||||
if (string.IsNullOrWhiteSpace(staticLocation.PostalCode))
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
|
||||
string? directionJson = await TryPostProfilePageMethodAsync(
|
||||
client,
|
||||
GetAdministrativeLocationMethodUri("GetDireccion"),
|
||||
locationPageUri,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["CodigoPostal"] = staticLocation.PostalCode
|
||||
},
|
||||
allowedHosts,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
if (directionJson is null)
|
||||
{
|
||||
return profile;
|
||||
}
|
||||
|
||||
SguAdministrativeLocationSelection? selection =
|
||||
SguProfileParser.ParseAdministrativeLocationSelection(
|
||||
directionJson,
|
||||
staticLocation.PostalCode);
|
||||
if (selection is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.ProfileHtmlUnexpected,
|
||||
"SGU location method GetDireccion returned an unexpected payload for role {Role}; preserving the static address fields.",
|
||||
identity.Role);
|
||||
return profile;
|
||||
}
|
||||
|
||||
string? localitiesJson = null;
|
||||
if (!string.IsNullOrWhiteSpace(selection.StateId))
|
||||
{
|
||||
localitiesJson = await TryPostProfilePageMethodAsync(
|
||||
client,
|
||||
GetAdministrativeLocationMethodUri("GetLocalidadListado"),
|
||||
locationPageUri,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["pIdEstado"] = selection.StateId
|
||||
},
|
||||
allowedHosts,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
string? neighborhoodsJson = await TryPostProfilePageMethodAsync(
|
||||
client,
|
||||
GetAdministrativeLocationMethodUri("GetColoniasListado"),
|
||||
locationPageUri,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["pIdEstado"] = string.Empty,
|
||||
["pLocalidad"] = string.Empty,
|
||||
["CodigoPostal"] = selection.PostalCode ?? staticLocation.PostalCode
|
||||
},
|
||||
allowedHosts,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
|
||||
InstitutionalProfile? resolvedLocation = SguProfileParser.ParseAdministrativeLocation(
|
||||
html,
|
||||
selection,
|
||||
localitiesJson,
|
||||
neighborhoodsJson);
|
||||
return profile.Overlay(resolvedLocation);
|
||||
}
|
||||
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.ProfileEnrichmentTimeout,
|
||||
"SGU optional staff location enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||
identity.Role,
|
||||
elapsed.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.ProfileEnrichmentFailure,
|
||||
exception,
|
||||
"SGU optional staff location enrichment failed for role {Role} after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||
identity.Role,
|
||||
elapsed.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
@@ -545,6 +680,46 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string?> TryPostProfilePageMethodAsync(
|
||||
HttpClient client,
|
||||
Uri requestedUri,
|
||||
Uri referrerUri,
|
||||
IReadOnlyDictionary<string, string> payload,
|
||||
HashSet<string> allowedHosts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsAllowedHttpsUri(requestedUri, allowedHosts) ||
|
||||
!IsAllowedHttpsUri(referrerUri, allowedHosts))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Post, requestedUri);
|
||||
request.Headers.Referrer = referrerUri;
|
||||
request.Content = new StringContent(
|
||||
JsonSerializer.Serialize(payload),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
using HttpResponseMessage response = await client
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
int statusCode = (int)response.StatusCode;
|
||||
if (statusCode is >= 200 and < 300)
|
||||
{
|
||||
return await ReadLimitedStringAsync(
|
||||
response.Content,
|
||||
options.MaxProfileBytes,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
BrokerEventIds.ProfilePageUnavailable,
|
||||
"Optional SGU profile method {Path} returned HTTP {StatusCode}.",
|
||||
requestedUri.AbsolutePath,
|
||||
statusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void AddCredential(
|
||||
Uri uri,
|
||||
CredentialCache credentialCache,
|
||||
@@ -658,6 +833,12 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
private Uri GetProfileUri(string path) =>
|
||||
new(new Uri(options.Endpoint, UriKind.Absolute), path);
|
||||
|
||||
private Uri GetAdministrativeLocationMethodUri(string methodName)
|
||||
{
|
||||
Uri pageUri = GetProfileUri(options.AdministrativeLocationProfilePath);
|
||||
return new Uri($"{pageUri.GetLeftPart(UriPartial.Path).TrimEnd('/')}/{methodName}");
|
||||
}
|
||||
|
||||
private async Task<InstitutionalProfile?> TryReadProfileAsync(
|
||||
HttpResponseMessage response,
|
||||
UserIdentity identity,
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
"ProfessorOuDn": "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||
"StudentOuDn": "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||
"AdministrativeOuDn": "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||
"ProfessorGroupDn": "CN=SGU-Docentes,OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||
"StudentGroupDn": "CN=SGU-Alumnos,OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||
"AdministrativeGroupDn": "CN=SGU-Administrativos,OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||
"RemoteDesktopGroupDn": "",
|
||||
"DefaultCompany": "La Salle",
|
||||
"CreateMissingOus": false
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
$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
|
||||
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
|
||||
'CompatibilityProfile') | Should Be $true
|
||||
}
|
||||
|
||||
It 'accepts an explicit static IPv4 address for a private Windows adapter' {
|
||||
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
|
||||
'ClientIPv4Address') | Should Be $true
|
||||
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
|
||||
'ClientPrefixLength') | Should Be $true
|
||||
}
|
||||
|
||||
It 'matches a client and domain controller within the requested prefix' {
|
||||
Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]'192.168.50.11') `
|
||||
-SecondAddress ([ipaddress]'192.168.50.10') -PrefixLength 24 |
|
||||
Should Be $true
|
||||
Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]'192.168.51.11') `
|
||||
-SecondAddress ([ipaddress]'192.168.50.10') -PrefixLength 24 |
|
||||
Should Be $false
|
||||
Test-IPv4AddressesSharePrefix -FirstAddress ([ipaddress]'10.77.15.20') `
|
||||
-SecondAddress ([ipaddress]'10.77.0.4') -PrefixLength 16 |
|
||||
Should Be $true
|
||||
}
|
||||
|
||||
It 'prefers the private adapter instead of the Internet default route' {
|
||||
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
|
||||
$source | Should Match '\$withoutDefaultGateway\.Count -eq 1'
|
||||
$source | Should Not Match "Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0\.0\.0\.0/0'"
|
||||
}
|
||||
|
||||
It 'waits for the new address and WinRM route to stabilize' {
|
||||
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
|
||||
$source | Should Match "AddressState -eq 'Preferred'"
|
||||
$source | Should Match 'function Wait-TcpPort'
|
||||
$source | Should Match 'Wait-TcpPort -Address \$DomainControllerIPv4Address -Port 5985'
|
||||
}
|
||||
|
||||
It 'keeps legacy and modern Windows package profiles isolated by build' {
|
||||
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
|
||||
$source.Contains("if (`$CompatibilityProfile -eq 'Windows10Legacy' -and `$windowsBuild -ge 22000)") |
|
||||
Should Be $true
|
||||
$source.Contains("if (`$CompatibilityProfile -eq 'Windows11Modern' -and `$windowsBuild -lt 22000)") |
|
||||
Should Be $true
|
||||
$source.Contains("if (`$CompatibilityProfile -eq 'Windows10Legacy' -and `$ConnectivityMode -eq 'AzureP2S')") |
|
||||
Should Be $true
|
||||
}
|
||||
|
||||
It 'uses an all-user machine-certificate VPN profile' {
|
||||
$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'"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$localUserScriptPath = Join-Path $repositoryRoot 'scripts\Set-SguStandardLocalUser.ps1'
|
||||
$enrollmentTestScriptPath = Join-Path $repositoryRoot 'scripts\Test-SguClientEnrollment.ps1'
|
||||
$packageScriptPath = Join-Path $repositoryRoot 'scripts\New-SguBootstrapPackages.ps1'
|
||||
$releaseScriptPath = Join-Path $repositoryRoot 'scripts\Publish-GiteaRelease.ps1'
|
||||
$azureLauncherPath = Join-Path $repositoryRoot 'scripts\Start-SguAzureClientEnrollment.cmd'
|
||||
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$scriptAst = [Management.Automation.Language.Parser]::ParseFile(
|
||||
$localUserScriptPath,
|
||||
[ref]$tokens,
|
||||
[ref]$parseErrors)
|
||||
if ($parseErrors.Count -gt 0) {
|
||||
throw ($parseErrors -join [Environment]::NewLine)
|
||||
}
|
||||
|
||||
$descriptionAssignment = $scriptAst.Find({
|
||||
param($node)
|
||||
$node -is [Management.Automation.Language.AssignmentStatementAst] -and
|
||||
$node.Left.Extent.Text -eq '$description'
|
||||
}, $true)
|
||||
$description = $descriptionAssignment.Right.Extent.Text.Trim("'")
|
||||
|
||||
Describe 'SGU Windows client enrollment scripts' {
|
||||
It 'keeps the local-user description within the Windows 10 limit' {
|
||||
($description.Length -le 48) | Should Be $true
|
||||
}
|
||||
|
||||
It 'declares the managed local student account' {
|
||||
$source = Get-Content -LiteralPath $localUserScriptPath -Raw
|
||||
$source | Should Match "\$userName = 'alumno'"
|
||||
$source | Should Match "\$plainTextPassword = 'ingenieria'"
|
||||
}
|
||||
|
||||
It 'uses the cross-version Windows account flag for password expiration' {
|
||||
$localUserSource = Get-Content -LiteralPath $localUserScriptPath -Raw
|
||||
$enrollmentTestSource = Get-Content -LiteralPath $enrollmentTestScriptPath -Raw
|
||||
$localUserSource | Should Match '\$passwordNeverExpiresFlag = 0x10000'
|
||||
$enrollmentTestSource | Should Match '\$passwordNeverExpiresFlag = 0x10000'
|
||||
$localUserSource | Should Not Match '\$verifiedUser\.PasswordNeverExpires'
|
||||
$enrollmentTestSource | Should Not Match '\$standardLocalUser\.PasswordNeverExpires'
|
||||
}
|
||||
|
||||
It 'publishes separate legacy Windows 10 and modern Windows 11 artifacts' {
|
||||
$packageSource = Get-Content -LiteralPath $packageScriptPath -Raw
|
||||
$releaseSource = Get-Content -LiteralPath $releaseScriptPath -Raw
|
||||
$packageSource | Should Match 'sgu-windows10-legacy-client-bootstrap-\$Version'
|
||||
$packageSource | Should Match 'sgu-windows11-client-bootstrap-\$Version'
|
||||
$packageSource | Should Match '-CompatibilityProfile Windows10Legacy'
|
||||
$packageSource | Should Match '-CompatibilityProfile Windows11Modern'
|
||||
$releaseSource | Should Match 'sgu-windows10-legacy-client-bootstrap-\$Version\.zip'
|
||||
$releaseSource | Should Match 'sgu-windows11-client-bootstrap-\$Version\.zip'
|
||||
}
|
||||
|
||||
It 'keeps Azure P2S in the modern Windows 11 artifact' {
|
||||
$packageSource = Get-Content -LiteralPath $packageScriptPath -Raw
|
||||
$azureLauncher = Get-Content -LiteralPath $azureLauncherPath -Raw
|
||||
$packageSource.Contains("Join-Path `$windows11ClientRoot 'Start-SguAzureClientEnrollment.cmd'") |
|
||||
Should Be $true
|
||||
$packageSource.Contains("Join-Path `$windows10ClientRoot 'Start-SguAzureClientEnrollment.cmd'") |
|
||||
Should Be $false
|
||||
$azureLauncher | Should Match '-PauseOnError'
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,64 @@ public sealed class SguProfileParserTests
|
||||
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]
|
||||
public void ParsesAdministrativeAddressFromInputsAndSelectedOptions()
|
||||
{
|
||||
@@ -170,6 +228,65 @@ public sealed class SguProfileParserTests
|
||||
Assert.Null(profile.Email);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvesAdministrativeAddressFromPageMethodIdentifiers()
|
||||
{
|
||||
const string html = """
|
||||
<html><body>
|
||||
<input id='ctl00_contenedor_txtCalle' value='RETORNO 1, SUR 16' />
|
||||
<input id='ctl00_contenedor_txtNoExt' value='74' />
|
||||
<input id='ctl00_contenedor_txtNoInt' value='' />
|
||||
<input id='ctl00_contenedor_txtCP' value='08500' />
|
||||
<select id='ctl00_contenedor_ddlEstado'>
|
||||
<option selected='selected' value='0'>Seleccione...</option>
|
||||
<option value='09'>CIUDAD DE MÉXICO</option>
|
||||
</select>
|
||||
<select id='ctl00_contenedor_ddlLocalidad'>
|
||||
<option selected='selected' value='0'>Seleccione alguna localidad...</option>
|
||||
</select>
|
||||
<select id='ctl00_contenedor_ddlColonia'>
|
||||
<option selected='selected' value='0,0'>Seleccione alguna colonia...</option>
|
||||
</select>
|
||||
</body></html>
|
||||
""";
|
||||
const string directionJson = """
|
||||
{"d":[{"p_IdCP":"091263","p_IdEstado":"09","p_NombreEstado":"","p_IdMunicipio":"006","p_NombreMunicipio":"","p_NombreColonia":"","p_Cp":"08500"}]}
|
||||
""";
|
||||
const string localitiesJson = """
|
||||
{"d":[{"ID_Estado":"09","Id_Municipio":"002","Nombre":"AZCAPOTZALCO"},{"ID_Estado":"09","Id_Municipio":"006","Nombre":"IZTACALCO"}]}
|
||||
""";
|
||||
const string neighborhoodsJson = """
|
||||
{"d":[{"p_IdCP":"091263","p_Nombre":"AGRÍCOLA ORIENTAL","p_Cp":"08500"}]}
|
||||
""";
|
||||
|
||||
SguAdministrativeLocationSelection? selection =
|
||||
SguProfileParser.ParseAdministrativeLocationSelection(directionJson, "08500");
|
||||
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativeLocation(
|
||||
html,
|
||||
selection,
|
||||
localitiesJson,
|
||||
neighborhoodsJson);
|
||||
|
||||
Assert.NotNull(selection);
|
||||
Assert.Equal("09", selection.StateId);
|
||||
Assert.Equal("006", selection.MunicipalityId);
|
||||
Assert.Equal("091263", selection.NeighborhoodId);
|
||||
Assert.NotNull(profile);
|
||||
Assert.Equal("Retorno 1, Sur 16 74\r\nAgrícola Oriental", profile.StreetAddress);
|
||||
Assert.Equal("Iztacalco", profile.City);
|
||||
Assert.Equal("Ciudad de México", profile.State);
|
||||
Assert.Equal("08500", profile.PostalCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-json")]
|
||||
[InlineData("{\"d\":{}}")]
|
||||
[InlineData("{\"d\":[]}")]
|
||||
public void RejectsUnexpectedAdministrativeLocationPayloads(string json)
|
||||
{
|
||||
Assert.Null(SguProfileParser.ParseAdministrativeLocationSelection(json, "08500"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdministrativePagesOverlayTheVerifiedIncidentsProfile()
|
||||
{
|
||||
@@ -182,7 +299,8 @@ public sealed class SguProfileParserTests
|
||||
InstitutionalProfile personal = new(
|
||||
DisplayName: "María del Carmen de la Fuente",
|
||||
GivenName: "María del Carmen",
|
||||
Surname: "de la Fuente");
|
||||
Surname: "de la Fuente",
|
||||
Gender: InstitutionalGender.Female);
|
||||
InstitutionalProfile location = new(
|
||||
StreetAddress: "Calle Uno 10",
|
||||
City: "Ciudad de México",
|
||||
@@ -200,6 +318,7 @@ public sealed class SguProfileParserTests
|
||||
Assert.Equal("Ingeniería", combined.Department);
|
||||
Assert.Equal("Calle Uno 10", combined.StreetAddress);
|
||||
Assert.Equal("01000", combined.PostalCode);
|
||||
Assert.Equal(InstitutionalGender.Female, combined.Gender);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -215,6 +334,7 @@ public sealed class SguProfileParserTests
|
||||
<span id="ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP">
|
||||
<a href="mailto:alumna@lasalle.mx">ALUMNA@LASALLE.MX</a>
|
||||
</span>
|
||||
<span id="ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP">F</span>
|
||||
<span id="ctl00_contenedor_HistorialAlumno1_lblCURPAlumnoHP">
|
||||
DATO-SENSIBLE-QUE-NO-DEBE-EXTRAERSE
|
||||
</span>
|
||||
@@ -262,6 +382,7 @@ public sealed class SguProfileParserTests
|
||||
Assert.Equal("Ciudad de México", profile.City);
|
||||
Assert.Equal("Ciudad de México", profile.State);
|
||||
Assert.Equal("08500", profile.PostalCode);
|
||||
Assert.Equal(InstitutionalGender.Female, profile.Gender);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using SGU.AuthBroker.Options;
|
||||
using SGU.AuthBroker.Core.Identity;
|
||||
using Xunit;
|
||||
|
||||
namespace SGU.AuthBroker.Tests;
|
||||
@@ -28,4 +29,31 @@ public sealed class BrokerOptionsTests
|
||||
|
||||
Assert.Contains("thumbprint", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(InstitutionalRole.Student, "CN=SGU-Alumnos,OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
|
||||
[InlineData(InstitutionalRole.Administrative, "CN=SGU-Administrativos,OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
|
||||
[InlineData(InstitutionalRole.Professor, "CN=SGU-Docentes,OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
|
||||
public void DefaultRoleGroupMappingsMatchInstitutionalPrefixes(InstitutionalRole role, string expectedGroupDn)
|
||||
{
|
||||
ActiveDirectoryOptions options = new();
|
||||
|
||||
Assert.Equal(expectedGroupDn, options.GetGroupDn(role));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateRejectsARoleGroupOutsideTheConfiguredDirectoryBase()
|
||||
{
|
||||
BrokerOptions options = new()
|
||||
{
|
||||
Directory = new ActiveDirectoryOptions
|
||||
{
|
||||
StudentGroupDn = "CN=SGU-Alumnos,DC=example,DC=invalid"
|
||||
}
|
||||
};
|
||||
|
||||
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(options.Validate);
|
||||
|
||||
Assert.Contains("security-group", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using SGU.AuthBroker.Core.Authentication;
|
||||
using SGU.AuthBroker.Core.Identity;
|
||||
using SGU.AuthBroker.Core.Profiles;
|
||||
using SGU.AuthBroker.Options;
|
||||
using SGU.AuthBroker.Services;
|
||||
using Xunit;
|
||||
@@ -132,6 +133,10 @@ public sealed class NtlmCredentialValidatorTests
|
||||
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
|
||||
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
|
||||
<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(
|
||||
HttpStatusCode.OK,
|
||||
@@ -140,14 +145,24 @@ public sealed class NtlmCredentialValidatorTests
|
||||
<input id="ctl00_contenedor_txtNoExt" value="15" />
|
||||
<input id="ctl00_contenedor_txtCP" value="01000" />
|
||||
<select id="ctl00_contenedor_ddlEstado">
|
||||
<option selected="selected">CIUDAD DE MÉXICO</option>
|
||||
<option selected="selected" value="0">Seleccione...</option>
|
||||
<option value="09">CIUDAD DE MÉXICO</option>
|
||||
</select>
|
||||
<select id="ctl00_contenedor_ddlLocalidad">
|
||||
<option selected="selected">ÁLVARO OBREGÓN</option>
|
||||
<option selected="selected" value="0">Seleccione alguna localidad...</option>
|
||||
</select>
|
||||
<select id="ctl00_contenedor_ddlColonia">
|
||||
<option>FLORIDA</option>
|
||||
<option selected="selected" value="0,0">Seleccione alguna colonia...</option>
|
||||
</select>
|
||||
"""),
|
||||
JsonResponse("""
|
||||
{"d":[{"p_IdCP":"090001","p_IdEstado":"09","p_NombreEstado":"","p_IdMunicipio":"010","p_NombreMunicipio":"","p_NombreColonia":"FLORIDA","p_Cp":"01000"}]}
|
||||
"""),
|
||||
JsonResponse("""
|
||||
{"d":[{"ID_Estado":"09","Id_Municipio":"010","Nombre":"ÁLVARO OBREGÓN"}]}
|
||||
"""),
|
||||
JsonResponse("""
|
||||
{"d":[{"p_IdCP":"090001","p_Nombre":"FLORIDA","p_Cp":"01000"}]}
|
||||
"""));
|
||||
NtlmCredentialValidator validator = CreateValidator(handler);
|
||||
|
||||
@@ -168,15 +183,24 @@ public sealed class NtlmCredentialValidatorTests
|
||||
Assert.Equal("Álvaro Obregón", result.Profile.City);
|
||||
Assert.Equal("Ciudad de México", result.Profile.State);
|
||||
Assert.Equal("01000", result.Profile.PostalCode);
|
||||
Assert.Equal(InstitutionalGender.Female, result.Profile.Gender);
|
||||
Assert.Equal(
|
||||
[
|
||||
"/psulsa/",
|
||||
"/psulsa/",
|
||||
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
|
||||
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx"
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetDireccion",
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetLocalidadListado",
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetColoniasListado"
|
||||
],
|
||||
handler.RequestPaths);
|
||||
Assert.Equal("{\"CodigoPostal\":\"01000\"}", handler.RequestBodies[5]);
|
||||
Assert.Equal("{\"pIdEstado\":\"09\"}", handler.RequestBodies[6]);
|
||||
Assert.Equal(
|
||||
"{\"pIdEstado\":\"\",\"pLocalidad\":\"\",\"CodigoPostal\":\"01000\"}",
|
||||
handler.RequestBodies[7]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -224,6 +248,10 @@ public sealed class NtlmCredentialValidatorTests
|
||||
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
|
||||
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
|
||||
<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(
|
||||
HttpStatusCode.OK,
|
||||
@@ -232,14 +260,24 @@ public sealed class NtlmCredentialValidatorTests
|
||||
<input id="ctl00_contenedor_txtNoExt" value="15" />
|
||||
<input id="ctl00_contenedor_txtCP" value="01000" />
|
||||
<select id="ctl00_contenedor_ddlEstado">
|
||||
<option selected="selected">CIUDAD DE MÉXICO</option>
|
||||
<option selected="selected" value="0">Seleccione...</option>
|
||||
<option value="09">CIUDAD DE MÉXICO</option>
|
||||
</select>
|
||||
<select id="ctl00_contenedor_ddlLocalidad">
|
||||
<option selected="selected">ÁLVARO OBREGÓN</option>
|
||||
<option selected="selected" value="0">Seleccione alguna localidad...</option>
|
||||
</select>
|
||||
<select id="ctl00_contenedor_ddlColonia">
|
||||
<option selected="selected">FLORIDA</option>
|
||||
<option selected="selected" value="0,0">Seleccione alguna colonia...</option>
|
||||
</select>
|
||||
"""),
|
||||
JsonResponse("""
|
||||
{"d":[{"p_IdCP":"090001","p_IdEstado":"09","p_NombreEstado":"","p_IdMunicipio":"010","p_NombreMunicipio":"","p_NombreColonia":"FLORIDA","p_Cp":"01000"}]}
|
||||
"""),
|
||||
JsonResponse("""
|
||||
{"d":[{"ID_Estado":"09","Id_Municipio":"010","Nombre":"ÁLVARO OBREGÓN"}]}
|
||||
"""),
|
||||
JsonResponse("""
|
||||
{"d":[{"p_IdCP":"090001","p_Nombre":"FLORIDA","p_Cp":"01000"}]}
|
||||
"""));
|
||||
NtlmCredentialValidator validator = CreateValidator(handler);
|
||||
|
||||
@@ -262,6 +300,7 @@ public sealed class NtlmCredentialValidatorTests
|
||||
Assert.Equal("Álvaro Obregón", result.Profile.City);
|
||||
Assert.Equal("Ciudad de México", result.Profile.State);
|
||||
Assert.Equal("01000", result.Profile.PostalCode);
|
||||
Assert.Equal(InstitutionalGender.Male, result.Profile.Gender);
|
||||
Assert.Equal(
|
||||
[
|
||||
"/psulsa/",
|
||||
@@ -269,7 +308,10 @@ public sealed class NtlmCredentialValidatorTests
|
||||
"/psulsa/menu.aspx",
|
||||
"/psulsa/gadmon/nomina/consultanomina.aspx",
|
||||
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx"
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetDireccion",
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetLocalidadListado",
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetColoniasListado"
|
||||
],
|
||||
handler.RequestPaths);
|
||||
}
|
||||
@@ -352,18 +394,29 @@ public sealed class NtlmCredentialValidatorTests
|
||||
Content = new StringContent(content)
|
||||
};
|
||||
|
||||
private static HttpResponseMessage JsonResponse(string content) =>
|
||||
new(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(content, null, "application/json")
|
||||
};
|
||||
|
||||
private sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
||||
{
|
||||
private readonly Queue<HttpResponseMessage> responses = new(responses);
|
||||
|
||||
public List<string> RequestPaths { get; } = [];
|
||||
|
||||
public List<string?> RequestBodies { get; } = [];
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
RequestPaths.Add(request.RequestUri!.AbsolutePath);
|
||||
RequestBodies.Add(request.Content is null
|
||||
? null
|
||||
: request.Content.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult());
|
||||
if (responses.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("The validator sent more requests than expected.");
|
||||
|
||||
Reference in New Issue
Block a user