Compare commits

..
35 Commits
Author SHA1 Message Date
alexrg 520b4be955 Automate direct domain enrollment across Windows versions 2026-09-11 17:34:19 -06:00
alexrg 7f8a9eed4e Unify Windows client bootstrap and discover network paths 2026-09-10 08:13:46 -06:00
alexrg 74235ec6f8 Split Windows client bootstrap profiles 2026-09-09 11:04:01 -06:00
alexrg 0b510082d4 Validate local password expiration on Windows 10 2026-09-09 10:31:53 -06:00
alexrg 5e62a65316 Honor Windows local user description limit 2026-09-09 10:23:03 -06:00
alexrg 1332f546fa Wait for Windows enrollment network readiness 2026-09-09 10:19:36 -06:00
alexrg 14c919b380 Configure private IPv4 during Windows enrollment 2026-09-09 10:05:59 -06:00
alexrg a6bd625e4e Resolve SGU staff addresses and provision local student user 2026-09-09 09:48:06 -06:00
alexrg 8baa47fe1e Place SGU role groups in their user OUs 2026-09-08 16:12:58 -06:00
alexrg a24c25a3fb Prepare Azure P2S domain deployment 2026-09-08 15:32:07 -06:00
alexrg 991fc70600 Support stored Gitea credentials for releases 2026-09-08 12:16:33 -06:00
alexrg 93871b62b0 Adapt welcome wallpaper to SGU gender 2026-09-08 12:07:43 -06:00
alexrg 1fe2006404 Handle absent AD role groups during deployment 2026-09-08 11:37:35 -06:00
alexrg 7a4f599f55 Classify SGU accounts into AD role groups 2026-09-08 11:33:02 -06:00
alexrg 0a2dbbeb93 Detect Ubuntu GDM configuration path 2026-09-08 11:02:39 -06:00
alexrg f87495f21c Use X11 greeter for Linux RustDesk access 2026-09-08 10:58:37 -06:00
alexrg 5f5772f8cd Make Linux enrollment resilient to package update locks 2026-09-08 10:25:13 -06:00
alexrg a2ac027dc3 Wait for Linux RustDesk service before setting password 2026-09-08 10:02:30 -06:00
alexrg bb6bcf3e85 Allow unattended Linux RustDesk connections 2026-09-08 09:46:30 -06:00
alexrg db395c9215 Fix Linux RustDesk permanent password handling 2026-09-08 08:46:41 -06:00
alexrg ef9be0f897 Add managed RustDesk enrollment for Linux clients 2026-09-08 08:27:11 -06:00
alexrg 675db6bc1e Add self-hosted RustDesk bootstrap management 2026-09-07 17:29:25 -06:00
alexrg b3ec649199 Add temporary user examples directory to .gitignore 2026-09-07 16:30:28 -06:00
alexrg 9f32ed2cb1 Improve domain enrollment and desktop personalization 2026-09-07 15:07:04 -06:00
alexrg c737bd3192 Add Linux Active Directory enrollment bootstrap 2026-09-07 09:42:24 -06:00
alexrg dcbf5e87e3 Add six-month domain and broker monitoring 2026-09-04 16:57:34 -06:00
alexrg f2a40f051b Use La Salle Mexico logo for provider tile 2026-09-04 15:38:35 -06:00
alexrg 17c8960cdd Improve credential provider mascot fidelity 2026-09-04 15:29:31 -06:00
alexrg b5f526e244 Brand credential provider with La Salle mascot 2026-09-04 15:20:05 -06:00
alexrg b3e40fabb5 Deploy branded default account picture 2026-09-04 15:09:19 -06:00
alexrg a850b56a02 Enforce logon presentation through domain policy 2026-09-04 14:53:51 -06:00
alexrg ac531db05e Apply dark theme through user policy 2026-09-04 10:11:32 -06:00
alexrg c8572eb8d4 Fix localized enrollment recovery 2026-09-04 09:43:45 -06:00
alexrg 57572c5567 Fix fresh domain controller bootstrap 2026-09-03 17:26:05 -06:00
alexrg 1d7c312a67 Clarify new forest recovery semantics 2026-09-03 16:35:39 -06:00
86 changed files with 9529 additions and 294 deletions
+3
View File
@@ -348,3 +348,6 @@ MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Ejemplos de Usuarios
tmp/
+48 -6
View File
@@ -8,6 +8,9 @@ The repository starts from the current
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://gitea.lci.ulsa.mx/alexrg/SGU-CredentialProvider/releases).
## Authentication contract
1. The Windows tile collects a `DO`, `AL`, or `AD` institutional key and a password.
@@ -41,19 +44,29 @@ 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)
- [Broker location, health, timeout, and recovery](docs/broker-operations.md)
- [Windows domain join and remote-access onboarding](docs/windows-client-onboarding.md)
- [Required Credential Provider client enrollment](docs/client-enrollment.md)
- [Linux client enrollment with realmd and SSSD](docs/linux-client-enrollment.md)
- [Self-hosted RustDesk server and managed Windows remote access](docs/rustdesk-operations.md)
- [Domain monitoring, usage reports, and six-month retention](docs/monitoring.md)
- [Decision: do not persist password verifiers in Redis](docs/decisions/0001-no-password-cache.md)
| 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
@@ -91,17 +104,46 @@ 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, use
[docs/azure-vpn-deployment.md](docs/azure-vpn-deployment.md). It supports an
optional Azure P2S gateway or direct enrollment restricted to explicit public
source CIDRs.
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 Windows 10 or Windows 11 workstation, use the unified package:
- `sgu-windows-client-bootstrap-VERSION.zip` (x64 Pro, Enterprise or Education),
including optional Azure P2S setup for both versions.
Double-click `Start-SguClientEnrollment.cmd` and enter the server IP and domain
credentials, or provide the server IP on the command line:
```bat
Start-SguServerBootstrap.cmd 192.168.50.10
Start-SguClientEnrollment.cmd 192.168.50.10
```
El bootstrap prueba interfaces y rutas hacia el servidor, incluyendo VPN ya
conectadas, sin pedir la IP del cliente ni exigir la misma subred. Conserva DHCP
y el DNS de Internet; descubre el dominio autenticado y configura DNS sólo para
ese dominio. Si recibe una IP pública, configura DoH y los nombres necesarios de
AD después de autenticar al servidor. El segmento público del cliente debe estar
autorizado en el servidor y su firewall perimetral. Los casos sin DHCP o sin ruta
muestran un diagnóstico.
Ver [client-enrollment.md](docs/client-enrollment.md) para requisitos y parámetros
avanzados de IP estática.
Linux clients are enrolled through their native PAM/SSSD stack instead of the
Windows Credential Provider:
```bash
sudo bash ./Enroll-SguLinuxDomainClient.sh \
--domain-controller 192.168.50.10 \
--enable-hyperv-enhanced-session
```
The server command creates a new forest and resumes by itself after its required
restart. The client command registers a unique non-exportable mTLS certificate,
installs and validates SGU before domain join, then enables the managed remote
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+60 -12
View File
@@ -23,13 +23,19 @@ is forced because NTLM authentication is connection-bound.
The authoritative logical GET is sent to `/psulsa/`, a lightweight route that
returns the NTLM challenge without waiting for the slow application pages. A
`401` or `403` rejects the credential; an allowed `2xx` or `3xx` proves that IIS
accepted it. The broker then makes a separately bounded, best-effort GET to the
administrative incident overview for `AD`, the student information page for
`AL`, or the portal menu 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 keep the menu name as a base and attempt those
same two shared staff modules without requiring them to exist. A supplemental
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 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
404, changed/missing element ID, other failure, or timeout preserves fields
already collected, and a profile timeout does not invalidate an already
authenticated credential. NTLM may still require its normal
@@ -59,16 +65,30 @@ 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. A docente's supplemental fields remain tied to the
fresh NTLM-authenticated portal session and are optional; the menu display name
remains usable if neither shared page is available. Student faculty/department
that incident-page match. Docente payroll metadata must match the requested
six-digit number; all docente supplemental fields remain tied to the fresh
NTLM-authenticated portal session and are optional. The menu display name
remains usable if the payroll or shared staff pages are unavailable. Student faculty/department
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`
@@ -85,7 +105,10 @@ The generic SGU credential is rendered as a dedicated branded tile instead of
being grouped below the anonymous **Other user** tile. Machine policy assigns
the SGU CLSID as the default provider, hides the last signed-in identity, and
disables local-user enumeration while retaining the built-in Microsoft password
provider and its **Other user** recovery path. It enumerates one
provider and its **Other user** recovery path. The computer GPO also applies
Windows' native default account picture to named Windows accounts; client
enrollment installs the La Salle mascot bitmap in Windows' standard account-picture
location before that GPO takes effect. It enumerates one
`CPFT_TILE_IMAGE` and places the `CPFT_LARGE_TEXT` heading immediately after it
with `CPFS_DISPLAY_IN_SELECTED_TILE`. LogonUI owns field typography and vertical
tile order: on Windows 10 and 11, the account-name title used by **Other user**
@@ -102,6 +125,31 @@ to `OU=Laboratorio`; it suppresses first-logon/privacy/diagnostic prompts,
disables location, and enforces always-on display, sleep, and hibernation
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
to deterministic text and never block the interactive session.
The domain controller is also the source-initiated Windows Event Collector for
managed laboratory computers. Kerberos-authenticated WEF sends only selected
logon/logoff, failed-logon, reconnect/disconnect, and operating-system power
events to `ForwardedEvents`. Daily EVTX archives are retained for 183 days, and
a five-minute server-side inventory records WinRM reachability and AD last-logon
metadata. Session-duration reports correlate Windows logon IDs; no password or
SGU HTTP payload is included in this monitoring path.
Broker diagnostics use the dedicated `SGU Auth Broker` Windows log with stable
event IDs for authorization outcomes, SGU network/timeout failures, unexpected
profile HTML, partial enrichment, and AD synchronization warnings. The same
daily maintenance task archives that log for 183 days. Messages identify the
institutional user and role but never include passwords, password verifiers, or
raw SGU HTML.
Per-user synchronization is serialized inside the broker to prevent concurrent
create/reset races. Production deployments should run the broker as a gMSA with
delegated create-user, move-user, write-property, enable-account, and reset-password
@@ -0,0 +1,136 @@
# Despliegue SGU en Azure y enrolamiento de Windows11-002
Fecha: 2026-09-10. Suscripción `1254ca0e-3950-4711-8b4b-e33b4677d950`.
## Infraestructura y bosque
| Componente | Configuración comprobada |
| --- | --- |
| Grupo de recursos / región | `rg-sgu-lab` / `centralus` |
| VM Azure / nombre Windows | `sgu-lab-dc` / `SGU-DC01` |
| Sistema y tamaño | Windows Server 2025 Azure Edition / `Standard_D2s_v5` |
| Dirección del controlador de dominio | `10.77.0.4` |
| Bosque / dominio / NetBIOS | `lci.lasalle.mx` / `lci.lasalle.mx` / `LCI` |
| SID del dominio Azure | `S-1-5-21-2324484875-464590158-1758545597` |
| VNet / pool P2S | `10.77.0.0/16` / `172.30.0.0/24` |
| Gateway | `sgu-lab-vpngw`, `VpnGw1AZ`, estado `Succeeded` |
| Protocolos configurados | IKEv2 y OpenVPN; prueba real con IKEv2 |
| Cliente Hyper-V / nombre Windows | `Windows11-002` / `DESKTOP-LM7D7OM` |
Se creó un bosque nuevo en Azure. Tiene el mismo nombre DNS que el bosque del
laboratorio local, pero una identidad distinta; no es una réplica ni una
migración de sus usuarios. El cliente de esta prueba consulta el bosque Azure
mediante una regla NRPT para `.lci.lasalle.mx`.
La promoción y la configuración SGU terminaron a las `22:10:36Z`. Se comprobó
`bootstrap-complete.json`, los servicios AD DS, DNS, ADWS, Netlogon y SGUAuthBroker,
los registros SRV y las pruebas dcdiag Connectivity, Advertising, SysVolCheck,
NetLogons y Services, todas con resultado satisfactorio. RustDesk, WinRM,
escritorio remoto y el colector de eventos quedaron configurados. Los puertos
administrativos y de AD no están abiertos a Internet.
## Enrolamiento y VPN
El cliente usa Windows 11 Enterprise LTSC x64, build 26100. Se creó el checkpoint
`Before-SGU-Azure-Enrollment-20260910` antes de modificarlo.
Se instaló un certificado de máquina y el perfil nativo `SGU Azure P2S`. Con el
túnel conectado, el bootstrap recibió la IP `10.77.0.4` y una credencial de dominio;
descubrió automáticamente dominio, NetBIOS, OU y la interfaz VPN `172.30.0.2`.
No recibió una IP del cliente ni una interfaz elegida manualmente.
El objeto `DESKTOP-LM7D7OM` quedó habilitado en
`OU=Laboratorio,DC=lci,DC=lasalle,DC=mx`. El proveedor SGU y sus certificados mTLS
quedaron instalados. La validación con dominio, broker, acceso remoto y RustDesk
exigidos devolvió `IsValid=True`, `Issues=[]`, `BrokerHealth=ok`,
`RemoteAccessReady=True` y `RustDeskReady=True`. El guard de enrolamiento terminó
con código 0.
Para este cliente Enterprise se instaló también `SGU Azure Device`, un perfil
VPNv2 de dispositivo bajo SYSTEM, con IKEv2, certificado de máquina, Always On y
ruta dividida `10.77.0.0/16`. El perfil manual permanece disponible. La NIC de
Internet conserva DHCP y DNS `172.18.176.1`; el túnel utiliza la dirección
`172.30.0.2` y la red del dominio.
La primera prueba de arranque del túnel detectó Netlogon 5719 y
`ERROR_NO_LOGON_SERVERS`: la red VPN estaba disponible después de que Netlogon
intentara localizar el dominio. Reiniciar únicamente Netlogon recuperó el canal
seguro sin restablecer la contraseña de máquina. Se probaron
`ExpectedDialupDelay=60` y `NegativeCachePeriod=3`, siguiendo la guía de Microsoft para
[conectividad de dominio tardía al arrancar](https://learn.microsoft.com/en-us/troubleshoot/windows-server/group-policy/netlogon-event-id-5719-or-group-policy-event-1129).
No resolvieron por sí solos esta VM; se devolvieron a sus valores predeterminados
0 y 45, respectivamente.
Se instaló `SGU-Azure-DomainConnectivity`, una tarea SYSTEM de arranque con
demora de 30 segundos y reintentos. Ejecuta
`scripts/Repair-SguAzureDomainConnectivity.ps1`: espera al perfil VPN y a LDAP
del controlador, comprueba el canal seguro y reinicia Netlogon únicamente si
es necesario. Después vuelve a ejecutar el guard SGU, espera su resultado y
reintenta fallos transitorios de resolución de grupos del dominio. No guarda credenciales
ni restablece automáticamente la contraseña de la cuenta de equipo. El
resultado se registra en
`C:\ProgramData\SGU\Enrollment\azure-domain-connectivity.json`.
La verificación final se realizó a las **17:00:55 UTC-6**, después del arranque
de las **16:57:35 UTC-6**, sin sesión interactiva (`UserName=null`):
- `SGU Azure Device=Connected`, `172.30.0.2`, red `DomainAuthenticated`.
- `Test-ComputerSecureChannel=True`; DC localizado en `10.77.0.4`.
- `IsValid=True`, sin incidencias; broker, acceso remoto y RustDesk correctos.
- `SGU-Azure-DomainConnectivity` terminó con código 0 y registró la recuperación
de Netlogon y `EnrollmentGuardResult=0`.
- `SGU-CredentialProvider-EnrollmentGuard` terminó con código 0.
- La captura muestra el acceso institucional SGU en la pantalla de inicio de
sesión. Se obtuvo directamente de Hyper-V, sin usar el escritorio del host.
En este arranque, la recuperación completa de dominio y guard terminó unos
dos minutos y medio después del inicio de Windows. No se comprobó un inicio de
sesión interactivo con un usuario institucional del nuevo bosque; se validaron
la unión, la confianza de máquina, los servicios y la salud mTLS.
## Correcciones y versiones usadas
- La plantilla Azure usa `VpnGw1AZ`, IP de gateway con zonas 1/2/3 y OpenVPN
como alternativa a IKEv2. Azure rechazó las opciones anteriores VpnGw1/SSTP.
- El disco del controlador tiene caché `None` para las escrituras de AD DS.
- El bootstrap del servidor omite la VF de Accelerated Networking que figura
activa sin una interfaz IPv4; configura el adaptador que realmente tiene IP.
La prueba de regresión cubre ese caso.
- Servidor: paquete local `0.5.2-azure.2`, SHA-256
`BE29411A1A1C0FE0BB2BB7DB0418EF40881C98A721B42D1F52D54E22487077AC`.
- Cliente: paquete local `0.5.2-azure.1`, con la corrección del saludo sin género.
Las diferencias posteriores de `.2` corresponden al servidor.
Estos paquetes de validación no reemplazan el release 0.5.1 publicado en Gitea.
La configuración del device tunnel y de su tarea de recuperación se aplicó a
esta VM; no está integrada como opción automática en el instalador publicado.
## Evidencias y acceso administrativo
Las evidencias están en `artifacts/azure-deployment-20260910/`, excluido de Git:
- `server-verification.json`: bootstrap del servidor y dcdiag.
- `azure-computer-verification.json`: objeto de equipo en el bosque Azure.
- `client-enrollment-result.json`: resultado original de unión.
- `client-validation-before-final-reboot.json`: validación completa antes del reinicio.
- `client-postboot-verification.json`: comprobación posterior del arranque,
incluyendo canal seguro, VPN, usuario interactivo y resultados de las tareas.
- `windows11-azure-login.jpg`: captura directa del framebuffer de Hyper-V.
- `enable-device-tunnel.ps1`: XML y comandos usados para el túnel de esta VM.
- `state.json`: inventario y estado de la operación.
La cuenta administrativa del bosque es `LCI\azureadmin`. Su contraseña generada
está protegida con DPAPI en `credentials.clixml`, dentro de ese directorio del
host, para el usuario que ejecutó el despliegue. No se guardó en este documento.
La contraseña DSRM se generó en la VM Azure y se conserva protegida para SYSTEM
en `C:\ProgramData\SGU\Secrets\dsrm-password.clixml`.
La revisión automática rechazó la limpieza de la cuenta de almacenamiento
temporal `sgustagea8e952c02421` y su rol, y después la limpieza de archivos y de
la tarea temporal del cliente, sin indicar un motivo específico. No se
eliminaron. El PFX del cliente permanece cifrado y bajo una ACL restringida a
Administradores/SYSTEM; la tarea instaladora del túnel no tiene disparador
recurrente. Esta limpieza queda pendiente.
OpenVPN y otras configuraciones de VPN no se probaron. Esta validación no
extiende el soporte Always On device tunnel a ediciones Windows Pro.
+225
View File
@@ -0,0 +1,225 @@
# Active Directory SGU en Azure: VPN opcional o enrolamiento directo
La misma plantilla despliega Active Directory en Windows Server 2025 y permite
elegir entre Azure VPN Gateway Point-to-Site (P2S) o acceso público directo. El
modo directo restringe AD, WinRM, Auth Broker y RustDesk a los CIDR públicos
indicados; el cliente configura DoH y la resolución del dominio automáticamente.
La lista pública vacía no expone esos servicios.
La plantilla crea:
- VNet `10.77.0.0/16`, subnet del DC `10.77.0.0/24` y, si se solicita, `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 opcional `VpnGw1AZ` con IKEv2/OpenVPN 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 privados deben ser RFC1918 y no deben solaparse con las redes usadas
por Hyper-V, el `Default Switch`, Wi-Fi o Ethernet locales. Los prefijos de
enrolamiento directo deben ser CIDR IPv4 públicos explícitos.
## 1. Elegir el modo de conectividad
Para P2S, crear la autoridad y el certificado de cada cliente:
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.
Para acceso directo no se necesita certificado P2S. Se necesita conocer el
segmento público de salida del laboratorio; por ejemplo, la IP
`200.13.89.183` pertenece a `200.13.89.0/24`.
## 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
```
Sin VPN y autorizando un laboratorio completo:
```powershell
$azure = .\scripts\Deploy-SguAzureInfrastructure.ps1 `
-SubscriptionId '00000000-0000-0000-0000-000000000000' `
-ResourceGroupName 'rg-sgu-lab' `
-Location 'centralus' `
-AdministratorUsername 'azureadmin' `
-DeployVpnGateway $false `
-PublicEnrollmentSourceAddressPrefixes '200.13.89.0/24' `
-AdministratorSourceAddressPrefix '200.13.89.0/24'
```
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; omitirlo reduce tiempo y costo. El comando espera hasta que Azure
entregue un resultado final.
## 3. Conectarse al servidor
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.
En modo directo, use RDP contra `$azure.DomainControllerPublicIp` desde un origen
incluido en `AdministratorSourceAddressPrefix`. RDP y enrolamiento tienen listas
separadas para poder retirar RDP sin interrumpir los clientes.
## 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` cuando exista VPN y los CIDR directos en
`PublicEnrollmentNetworks` cuando se hayan habilitado.
## 5. 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 10/11 de Hyper-V:
- `sgu-windows-client-bootstrap-VERSION.zip` extraído (Windows 10/11 x64);
- `$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.
Para el modo directo, cada Windows 10/11 usa el lanzador normal y la IP pública;
no necesita perfil ni certificado VPN:
```bat
Start-SguClientEnrollment.cmd 20.9.81.130
```
El bootstrap pide la cuenta de dominio, prueba todas las interfaces con ruta,
descubre el bosque por WinRM, instala el certificado público DoH, configura NRPT
y los nombres del DC/broker, registra mTLS y une la máquina. No pide una IP del
cliente ni una interfaz.
Si la red local bloquea IKEv2 (UDP 500/4500), se puede generar un perfil
OpenVPN sobre TCP 443 para Azure VPN Client. Ese fallback requiere instalar
y configurar el cliente correspondiente; el bootstrap instala el perfil nativo
IKEv2. Azure ya no admite SSTP al crear este gateway.
La prueba real con `Windows11-002` y un bosque en Azure está documentada en
[la validación del despliegue del 10 de septiembre de 2026](azure-deployment-validation-2026-09-10.md).
Incluye un device tunnel para Enterprise y recuperación de Netlogon cuando el
túnel tarda en estar disponible al arrancar. Son configuraciones adicionales
aplicadas a esa VM; el instalador publicado crea el perfil manual anterior.
Windows 10/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 o usando el acceso directo:
```powershell
Get-VpnConnection -Name 'SGU Azure P2S' -AllUserConnection
Get-DnsClientNrptRule | Where-Object DisplayName -like 'SGU Azure P2S*'
Test-NetConnection 20.9.81.130 -Port 5985
Resolve-DnsName _ldap._tcp.dc._msdcs.lci.lasalle.mx -Type SRV
nltest.exe /dsgetdc:lci.lasalle.mx
```
## Alcance de red y referencias
P2S mantiene los puertos de AD dentro del túnel. El modo directo abre el conjunto
necesario para la unión sólo desde `publicEnrollmentSourceAddressPrefixes` y
replica la misma lista en Windows Firewall mediante `PublicEnrollmentNetworks`.
Prefiera `/32` si la salida es estable; use `/24` únicamente cuando deba admitir
todo el segmento. Retire el CIDR cuando termine la prueba si ya no se requiere.
- [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)
+101 -15
View File
@@ -1,6 +1,6 @@
# Recuperación desde cero y alta en una sola ejecución
Los releases entregan dos 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, con VPN Gateway opcional o enrolamiento público directo, 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:
@@ -52,16 +57,38 @@ El proceso crea o configura de forma idempotente:
- GPO de experiencia del equipo y restricciones de sesión SGU;
- certificado de servidor no exportable y broker mTLS en TCP 8443;
- recurso `\\SERVIDOR\Packages`, con lectura para Domain Computers;
- RDP con NLA, WinRM/PowerShell Remoting y reglas administrativas sólo en el
perfil Domain;
- RDP con NLA, WinRM/PowerShell Remoting y reglas administrativas limitadas a
la subred privada indicada, incluso si Windows tarda en reconocer el perfil
Domain después de la promoción;
- servidor RustDesk OSS autoalojado (`hbbs` y `hbbr`) y su cliente administrado
en el propio DC, con puertos de administración limitados a la subred privada;
- pantalla, suspensión e hibernación en Nunca.
En un servidor con dos NIC, el bootstrap desactiva el registro DNS de la NIC de
Internet y obliga al servicio DNS a escuchar y publicar únicamente la IP fija
privada. También vuelve a iniciar brevemente esa NIC privada si Windows Server
2025 todavía la clasifica como Public al terminar la promoción. La salida HTTPS
continúa por la NIC que tenga el gateway predeterminado.
Para permitir clientes que llegan directamente desde un segmento público, use
`-PublicEnrollmentNetworks` al preparar el servidor. El parámetro valida y
normaliza cada CIDR y limita a esos orígenes los puertos de AD, DoH, WinRM,
broker y RustDesk:
```powershell
.\Initialize-SguDomainController.ps1 `
-ServerIPv4Address 10.77.0.4 `
-NetworkConfigurationMode PlatformManaged `
-PublicEnrollmentNetworks 200.13.89.0/24
```
El broker arranca con una lista de clientes vacía. Eso no abre el servicio: mTLS
rechaza todos los certificados hasta que el primer cliente registra el suyo.
Los archivos opcionales colocados en `payload\server-content\Packages` al crear
el release se copian al recurso compartido. Si allí existe `wallpaper.jpg`,
`wallpaper.jpeg`, `wallpaper.png` o `wallpaper.bmp`, la GPO de usuarios lo aplica
automáticamente como fondo con ajuste Fill.
el release se copian al recurso compartido. El paquete siempre incluye
`welcome-wallpaper`: fondo azul, fuentes Indivisa y generador de respaldo para
reparación o actualización de clientes. La GPO de equipos inicia la copia local
del generador en cada sesión; ya no se impone un único fondo estático por usuario.
Estado y diagnóstico:
@@ -81,28 +108,55 @@ 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 `sgu-windows-client-bootstrap-VERSION.zip`, común para
Windows 10 y Windows 11 x64 Pro, Enterprise o Education.
2. Ejecutar con la IP fija actual del controlador de dominio:
```bat
Start-SguClientEnrollment.cmd 192.168.50.10
```
También puede hacerse doble clic y escribir la IP del controlador. El cliente
conserva sus IP/DHCP y prueba WinRM por las interfaces con rutas disponibles,
incluyendo VPN y redes enrutadas. No requiere compartir subred con el servidor.
Si una NIC sólo tiene APIPA, necesita DHCP o una IP asignada por el administrador;
el bootstrap no inventa direcciones. Si falla, la ventana permanece abierta y
el error queda en `C:\ProgramData\SGU\Bootstrap\Client\latest-error.log`.
El manifiesto usa `Auto` y ambos Windows ejecutan el mismo código. Azure P2S está
incluido para ambos; otras VPN ya conectadas usan el lanzador habitual.
También puede proporcionarse directamente la IP pública del DC. Tras autenticar
WinRM, el bootstrap configura DoH y resolución dividida, valida el SRV de AD y
continúa sin pedir la IP del cliente. El segmento de salida del laboratorio debe
estar en la lista `PublicEnrollmentNetworks` del servidor y en el NSG/firewall
perimetral; por ejemplo, `200.13.89.0/24` cubre las salidas `.1` a `.254`.
Después de UAC, se solicita interactivamente la credencial autorizada para unir
equipos. La contraseña existe sólo en memoria. El bootstrap:
1. apunta el DNS del adaptador al IP proporcionado;
2. abre una sesión WinRM autenticada con el DC y verifica que pertenece al
dominio esperado;
1. selecciona una interfaz con conectividad comprobada al servidor;
2. abre una sesión WinRM autenticada con el DC, descubre el dominio y configura
DNS mediante NRPT sólo para ese dominio; cuando la IP es pública, además
configura y valida automáticamente DoH, conservando el DNS de Internet;
3. crea en el cliente un certificado mTLS RSA-3072 no exportable y envía sólo su
parte pública al broker;
4. recupera por esa sesión autenticada el certificado público del broker;
5. instala el runtime .NET 10 offline y el Credential Provider;
6. valida binarios, registro COM, certificados y salud del broker;
7. instala el guardián de reparación al arranque;
8. sólo entonces ejecuta `Add-Computer` dentro de `OU=Laboratorio` y reinicia;
8. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` si existe, o en el
contenedor de equipos predeterminado del dominio, y reinicia;
9. al arrancar, activa RDP/NLA, WinRM y las reglas Domain, y vuelve a validar el
enrolamiento.
10. instala RustDesk desde el MSI oficial comprobado, lo apunta al servidor
`rustdesk.lci.lasalle.mx` y registra el ID del dispositivo en el inventario
protegido del DC.
Cuando la IP del DC es pública, el paso 2 crea o reutiliza DoH en el servidor,
recupera su certificado público, configura el cliente y valida el registro SRV
antes de continuar. El mismo doble clic funciona en LAN, una VPN ya conectada o
Internet directo; el operador sólo proporciona IP del DC y credenciales.
Para elegir adaptador o nombre del equipo explícitamente:
@@ -110,6 +164,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'
```
@@ -118,19 +174,49 @@ La IP del argumento es siempre la IP fija **actual del servidor**, no una IP que
queda compilada en el Credential Provider. El proveedor usa después el nombre
DNS `sgu-auth.lci.lasalle.mx`, que el bootstrap del servidor actualiza.
La administración gráfica autoalojada se documenta en
[rustdesk-operations.md](rustdesk-operations.md). Durante la primera instalación
el servidor y los clientes necesitan salida HTTPS para obtener los instaladores
RustDesk verificados; el tráfico de soporte posterior permanece dentro de la
subred privada del laboratorio.
Un administrador del dominio todavía puede ignorar deliberadamente este flujo y
ejecutar `Add-Computer` a mano; ninguna GPO puede impedir a un administrador del
bosque modificar el dominio. Para la operación soportada, el script aplica una
transacción proveedor-primero y se niega a unir un equipo que no haya pasado las
validaciones.
## Cliente Linux nuevo
El ZIP `sgu-linux-client-bootstrap-VERSION.zip` usa el mecanismo nativo de
Linux: `realmd`, Kerberos, SSSD, NSS y PAM. No distribuye ni instala el
Credential Provider de Windows.
En una VM con Internet por `Default Switch` y una NIC privada conectada a
`Laboratorio AD`, ejecutar como administrador:
```bash
sudo bash ./Enroll-SguLinuxDomainClient.sh \
--domain-controller 192.168.50.10 \
--domain-interface eth0 \
--domain-address 192.168.50.12/24 \
--enable-ssh
```
La contraseña de la cuenta autorizada para la unión se solicita de forma
interactiva por `realmd`; no se incluye en la línea de comandos. El script
configura DNS de AD en la NIC privada, preserva la ruta de Internet, crea la
cuenta de equipo en `OU=Laboratorio`, habilita creación de directorio personal
mediante PAM y valida la cuenta de equipo con `adcli testjoin`. La documentación
de operación completa está en [linux-client-enrollment.md](linux-client-enrollment.md).
## Crear y publicar un release
Desde el repositorio y con el SDK fijado en `global.json`:
```powershell
.\scripts\New-SguBootstrapPackages.ps1 -Version 0.1.0
.\scripts\Publish-GiteaRelease.ps1 -Version 0.1.0
.\scripts\New-SguBootstrapPackages.ps1 -Version 0.1.1
.\scripts\Publish-GiteaRelease.ps1 -Version 0.1.1
```
El segundo comando usa `GITEA_TOKEN` sólo en memoria o, si no está definido,
@@ -139,8 +225,8 @@ la línea de comandos. Para empaquetar recursos institucionales adicionales:
```powershell
.\scripts\New-SguBootstrapPackages.ps1 `
-Version 0.1.0 `
-Version 0.1.1 `
-ServerContentPath C:\Preparacion\Packages
```
`SHA256SUMS-VERSION.txt` permite comprobar ambos ZIP antes de usarlos.
`SHA256SUMS-VERSION.txt` permite comprobar los cuatro ZIP antes de usarlos.
+3 -2
View File
@@ -51,8 +51,9 @@ Eso es comportamiento esperado, no una caída del servicio.
`/psulsa/`. El enriquecimiento usa el límite total independiente
`ProfileTimeoutSeconds` —**90 segundos** en la configuración del laboratorio—
y conserva los campos que alcance a obtener si una página de personal se
retrasa, no existe o cambia sus IDs. Esto incluye los módulos opcionales de
nombre y ubicación para docentes. El Credential Provider mantiene su propio límite de **90
retrasa, no existe o cambia sus IDs. Para docentes esto incluye consulta de
nómina —clave, nombre, correo, tipo y puesto— más los módulos opcionales de
nombre y ubicación. El Credential Provider mantiene su propio límite de **90
segundos**: si SGU excede ese presupuesto, Windows continúa por el fallback
normal de AD o credenciales de dominio en caché.
- El instalador configura recuperación del servicio con reinicios a los 5, 15
+86 -9
View File
@@ -1,7 +1,12 @@
# Enrolamiento obligatorio de clientes SGU
Para una instalación limpia de Windows se prefiere el único punto de entrada
empaquetado:
Para Windows 10 y Windows 11 se usa un solo paquete:
- `sgu-windows-client-bootstrap-VERSION.zip` (x64, Pro, Enterprise o Education).
Extraer el ZIP y hacer doble clic en `Start-SguClientEnrollment.cmd`. Aceptar
UAC, introducir la IP del controlador y la cuenta del dominio con su contraseña.
También se puede indicar el servidor desde consola:
```bat
Start-SguClientEnrollment.cmd 192.168.50.10
@@ -12,6 +17,69 @@ una clave privada y luego ejecuta la transacción proveedor-primero. Las
instrucciones completas están en
[`bootstrap-recovery.md`](bootstrap-recovery.md).
El único dato de red necesario es la IPv4 del controlador. El bootstrap prueba
WinRM desde las direcciones de las interfaces conectadas, incluyendo túneles
VPN, empezando por la ruta elegida por Windows. Si ésta falla, prueba las demás
rutas disponibles. Cliente y servidor pueden estar en subredes distintas.
Si la red todavía está inicializando, reintenta el descubrimiento durante
20 segundos antes de informar el último diagnóstico.
Conserva DHCP, direcciones y puertas de enlace. Sólo si Windows elige otra
interfaz agrega una ruta persistente `/32` hacia ese servidor por la interfaz
comprobada; no modifica la ruta de Internet. Ante cualquier error conserva la
ventana y escribe el diagnóstico en
`C:\ProgramData\SGU\Bootstrap\Client\latest-error.log`.
El dominio DNS, NetBIOS y contenedor de equipos se descubren en la sesión
autenticada. Usa `OU=Laboratorio` si existe en la raíz del dominio y, en caso
contrario, el contenedor de equipos configurado en AD. Los parámetros
`-DomainName`, `-DomainNetbios` y `-ComputerOuDn` permiten validarlos o elegirlos
explícitamente. Para otra cuenta, editar el usuario sugerido como
`DOMINIO\usuario` o `usuario@dominio`. No se guardan contraseñas.
Si la IPv4 proporcionada es pública, el mismo flujo configura automáticamente
la conectividad directa. Después de autenticar WinRM, el servidor crea o reutiliza
un certificado DoH, publica DNS cifrado en TCP 443 y devuelve únicamente su
certificado público. El cliente lo confía, registra el servidor DoH, agrega la
regla NRPT y fija localmente los nombres del DC, dominio, broker y RustDesk a esa
IP. A continuación comprueba el registro SRV de AD y continúa con la unión. El
operador sigue introduciendo solamente IP del DC, usuario y contraseña.
El servidor o firewall perimetral debe autorizar previamente el segmento público
del laboratorio. En el bootstrap del servidor se hace con
`-PublicEnrollmentNetworks 200.13.89.0/24`; en Azure, con
`-PublicEnrollmentSourceAddressPrefixes 200.13.89.0/24`. La lista vacía no abre
puertos. Este modo requiere Windows 11 o una versión de Windows 10 que exponga
los cmdlets DNS-over-HTTPS; en equipos anteriores funciona si la red ya permite
DNS tradicional hacia el DC.
El DNS se configura mediante una regla NRPT para el dominio descubierto,
conservando los servidores DNS de los adaptadores y la resolución de Internet.
Las políticas DNS/VPN corporativas deben permitir resolver ese dominio.
El servidor necesita el bootstrap SGU (AD/DNS, WinRM, Auth Broker y RustDesk):
este paquete no es un enrolador genérico de Microsoft Entra ID ni instala SGU
en un directorio ajeno automáticamente. Se comprueban TCP 53, 88, 135, 389, 445,
5985 y 8443; la unión también requiere DNS/Kerberos por UDP y RPC dinámico
según la configuración de AD. Estas pruebas no sustituyen la unión real.
Una VPN de cualquier proveedor ya conectada utiliza el mismo lanzador. Para
instalar la VPN Azure IKEv2 se incluyen `Start-SguAzureClientEnrollment.cmd` e
`Install-SguAzureP2sClient.ps1` en el mismo ZIP para ambos Windows; la primera
instalación requiere perfil y certificados. La conectividad antes del inicio
de sesión depende de la VPN y sus políticas, no se puede deducir de la IP del DC.
Una interfaz con sólo APIPA (`169.254.x.x`), sin DHCP o sin ruta necesita que
el administrador configure la red o conecte la VPN. El bootstrap no inventa una
IP libre ni una puerta de enlace. Para una LAN estática se mantienen los
parámetros avanzados `-ClientIPv4Address`, `-ClientPrefixLength` y
`-NetworkInterfaceAlias`; la IP y la interfaz deben indicarse juntas.
El manifiesto usa `CompatibilityProfile: Auto`. No hay restricciones de VPN
por Windows 10/11; los nombres antiguos de perfil se aceptan como parámetros
obsoletos. Se valida x64, edición con unión a AD y Windows 10 desde 1607 o
Windows 11 (el runtime incluido es .NET 10; usar una edición/build compatible
con su ciclo de soporte). Credential Provider, mTLS, cuenta `alumno`, RustDesk,
monitorización y autorreparación conservan la misma implementación.
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 +140,14 @@ 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
inicial `ingenieria` sólo al crearla; si ya existe conserva su contraseña,
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
@@ -112,6 +183,11 @@ pantalla, suspensión, hibernación y suspensión híbrida, conectado a corrient
batería. El guard de enrolamiento vuelve a aplicar `powercfg /hibernate off` y
los tiempos en cero al inicio y diariamente.
Esa GPO también ejecuta el generador local del fondo de bienvenida y aplica el
fondo azul base a la pantalla de bloqueo. El fondo individual se crea al abrir la
sesión con el nombre del usuario y `location`/OU del equipo; véase
[welcome-wallpaper.md](welcome-wallpaper.md).
`HideEULAPage` no forma parte de esta GPO: es una opción de archivo Unattend para
la fase OOBE y Microsoft la reserva para pruebas de OEM/System Builder. La GPO
usa las alternativas soportadas `DisablePrivacyExperience=1` y
@@ -133,8 +209,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.
+1
View File
@@ -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
+122
View File
@@ -0,0 +1,122 @@
# Enrolamiento de clientes Linux
El enrolador Linux incorpora una estación Debian/Ubuntu o RHEL/Fedora/Rocky/AlmaLinux a `lci.lasalle.mx` mediante `realmd`, `adcli`, Kerberos y SSSD. No instala el Credential Provider de Windows: Linux conserva su propio inicio de sesión PAM/SSSD. También instala RustDesk, lo dirige al servidor RustDesk autoalojado y registra el ID y la contraseña de acceso desatendido en el inventario protegido del controlador de dominio.
La contraseña de la cuenta autorizada para unir equipos se solicita directamente por `realmd`. Nunca se acepta como argumento, ni se escribe en archivos, registros o la línea de comandos.
## Requisitos de red
El equipo debe alcanzar por una interfaz privada al controlador de dominio, DNS y Kerberos. En el laboratorio:
| Servicio | Destino |
| --- | --- |
| Controlador y DNS | `192.168.50.10` |
| Dominio | `lci.lasalle.mx` |
| OU de equipos | `OU=Laboratorio,DC=lci,DC=lasalle,DC=mx` |
Para una VM Hyper-V con dos NIC, mantén el Internet en `Default Switch` y conecta una segunda NIC a `Laboratorio AD`. El enrolador puede configurar la segunda NIC sin modificar la ruta predeterminada:
```bash
sudo bash ./Enroll-SguLinuxDomainClient.sh \
--domain-controller 192.168.50.10 \
--domain-interface eth0 \
--domain-address 192.168.50.12/24 \
--enable-ssh \
--enable-hyperv-enhanced-session
```
`--domain-interface` y `--domain-address` son opcionales como pareja. Si ya se aprovisionó la red privada mediante cloud-init, DHCP o gestión de configuración, omítelos y conserva únicamente `--domain-controller`.
El script se niega a reconfigurar una interfaz que posea la ruta predeterminada; así no deja a la máquina sin salida a Internet al agregar AD.
En Ubuntu con GNOME, cierra la sesión gráfica de **Sesión básica** antes de
entrar con el mismo usuario mediante **Sesión mejorada**. GNOME no admite dos
escritorios simultáneos del mismo usuario; intentar conservar ambos produce una
pantalla negra aunque XRDP haya autenticado correctamente. El inicio automático
de sesión de GDM también debe permanecer deshabilitado.
## Qué instala y configura
1. Instala `realmd`, `adcli`, SSSD, Kerberos y los módulos NSS/PAM adecuados para la familia de distribución.
2. Comprueba el registro DNS SRV de Active Directory y sincronización de hora ya existente.
3. Establece el nombre de host `NOMBRE.lci.lasalle.mx` antes de crear la cuenta de equipo.
4. Une el equipo con `adcli` en `OU=Laboratorio`.
5. Activa SSSD, creación de directorio personal mediante PAM y valida la contraseña de la cuenta de equipo con `adcli testjoin`. En distribuciones que habilitan los respondedores NSS/PAM de SSSD tanto en `sssd.conf` como mediante sockets de systemd, desactiva los sockets duplicados para evitar una colisión al arrancar.
6. Cuando se proporcionó la NIC privada, activa actualizaciones DNS dinámicas de SSSD en esa interfaz.
7. Con `--enable-ssh`, instala y habilita OpenSSH y abre únicamente el servicio SSH cuando el firewall local ya está activo.
8. Registra `lightdm` y `cinnamon-screensaver` como inicios interactivos ante las políticas GPO de SSSD. En equipos con LightDM oculta la lista de cuentas y conserva únicamente el ingreso manual: usuario y contraseña, necesario para el primer acceso de un usuario del dominio.
9. Con `--enable-hyperv-enhanced-session`, configura XRDP sobre Hyper-V sockets para que VMConnect pueda usar **Sesión mejorada**, repara certificados incompletos, registra `xrdp-sesman` en el mismo mapa interactivo y valida ambos servicios XRDP.
10. Instala el fondo azul, las fuentes Indivisa y un autoinicio compatible con Cinnamon, GNOME y XFCE. En cada sesión gráfica genera el saludo con el nombre del usuario y la ubicación/OU del equipo obtenidas de AD.
11. Instala RustDesk 1.4.9 desde el paquete oficial comprobado, configura exclusivamente el servidor institucional y crea una contraseña aleatoria de acceso desatendido. También fuerza el greeter de GDM o SDDM a usar X11, porque RustDesk no admite controlar la pantalla de acceso bajo Wayland; LightDM ya usa X11. La contraseña no se muestra en Linux: viaja cifrada con la clave pública del controlador y éste la conserva mediante su inventario protegido. Si el enrolador cambia el backend gráfico, reinicia el equipo al finalizar para activarlo.
El objeto de equipo aparece como `NOMBRE` en `OU=Laboratorio`. SSSD registra su registro A cuando la actualización DNS dinámica está activada.
## Inicio de sesión de dominio
Después de la unión se acepta directamente la clave institucional corta:
```text
al201428
```
El formato UPN explícito también permanece disponible:
```text
usuario@lci.lasalle.mx
```
En Linux Mint aparece únicamente el ingreso manual. Escribe la clave corta y
su contraseña; no se muestra una lista ni mosaicos de cuentas locales o del
dominio.
La primera sesión crea `/home/usuario@lci.lasalle.mx`. El valor predeterminado de SSSD conserva credenciales para desconexiones breves de la red; las contraseñas no son administradas ni almacenadas por el Auth Broker.
Para limitar quién puede iniciar sesión, incluye un grupo de AD:
```bash
sudo bash ./Enroll-SguLinuxDomainClient.sh \
--domain-controller 192.168.50.10 \
--allow-group 'SG-Laboratorio-Linux-Users'
```
Ese modo ejecuta `realm deny --all` seguido de `realm permit --groups`; crea y administra el grupo antes de usarlo.
## Verificación y salida controlada
```bash
realm list
sudo adcli testjoin --domain=lci.lasalle.mx
getent passwd 'usuario@lci.lasalle.mx'
sudo sssctl domain-status lci.lasalle.mx
sudo sssctl user-checks usuario -a acct -s lightdm
sudo sssctl user-checks usuario -a acct -s cinnamon-screensaver
systemctl is-active rustdesk
sudo cat /var/lib/sgu/rustdesk/device.json
```
En el controlador de dominio, el mismo inventario protegido usado por Windows
muestra el ID de un cliente Linux y, únicamente bajo solicitud explícita de un
administrador, su contraseña de RustDesk:
```powershell
& C:\ProgramData\SGU\RustDesk\Get-SguRustDeskDevice.ps1
& C:\ProgramData\SGU\RustDesk\Get-SguRustDeskDevice.ps1 `
-ComputerName ALEX-LMINT -RevealPassword
```
Usa `--disable-rustdesk` sólo cuando una estación deba quedar expresamente sin
soporte remoto. `--rustdesk-registration-share` permite especificar el UNC del
controlador cuando una topología de DNS no puede resolver automáticamente el
controlador de dominio.
Para sacar un equipo del dominio de forma explícita:
```bash
sudo realm leave lci.lasalle.mx
```
Esta última acción elimina la relación de confianza local; debe ejecutarse sólo durante baja o reconstrucción del equipo.
La personalización gráfica es deliberadamente opcional: si ImageMagick, LDAP o
la API del escritorio fallan, no revierte la unión ni impide iniciar sesión. Consulta
los detalles y las reglas de degradación en [welcome-wallpaper.md](welcome-wallpaper.md).
+119
View File
@@ -0,0 +1,119 @@
# Monitoreo y registros de uso
El dominio usa **Windows Event Forwarding (WEF)** y el servicio nativo
**Windows Event Collector (Wecsvc)**. No instala un agente de telemetría y no
registra contraseñas, contenido de escritorio ni páginas visitadas.
## Datos recopilados
La suscripción `SGU-Lab-Monitoring` recibe desde los equipos de
`OU=Laboratorio`:
- inicios de sesión correctos y fallidos (`4624` y `4625`);
- cierre de sesión, desconexión y reconexión (`4634`, `4647`, `4778`, `4779`);
- arranque, apagado, reinicio y apagado inesperado (`12`, `13`, `41`, `1074`,
`6005`, `6006`, `6008`).
Con el identificador de sesión de Windows se obtiene quién usó qué equipo,
hora de entrada, hora de salida y duración. Los eventos fallidos conservan el
estado de Windows, pero nunca la contraseña introducida.
El broker escribe un registro separado de Windows llamado `SGU Auth Broker`.
Cada solicitud válida queda correlacionada mediante usuario institucional,
rol, `TraceId`, resultado y tiempo total. Los Event ID estables distinguen:
- `1000` autorización completada; `1001` credenciales rechazadas; `1002`
servicio no disponible; `1003` solicitud inválida;
- `1100` SGU aceptó la autenticación; `1101` timeout; `1102` fallo de red/DNS;
- `1200` enriquecimiento completado y cantidad de campos; `1201` HTML sin los
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; `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.
## Retención
`ForwardedEvents` tiene un límite de 512 MB. La tarea
`SGU-Monitoring-Retention` lo archiva diariamente en:
```text
C:\ProgramData\SGU\Monitoring\Archive
```
El mismo ciclo archiva `SGU Auth Broker` en `Archive\Broker`. Solo elimina
archivos `.evtx` cuya antigüedad supera **183 días**. La tarea
`SGU-Monitoring-Inventory` actualiza cada cinco minutos el inventario en:
```text
C:\ProgramData\SGU\Monitoring\Reports\machine-status.json
C:\ProgramData\SGU\Monitoring\Reports\machine-status.csv
```
`Encendida` significa que WinRM respondió en TCP 5985. `Apagada o inaccesible`
también puede significar que el equipo está arrancando, perdió la red o tiene
el puerto bloqueado; Active Directory por sí solo no conoce el estado eléctrico
instantáneo de un PC.
## Consultas
En el servidor, como administrador:
```powershell
# Estado actual de las máquinas
Get-Content 'C:\ProgramData\SGU\Monitoring\Reports\machine-status.json' -Raw |
ConvertFrom-Json | Format-Table ComputerName,Status,IPv4Address,LastDomainLogon
# Uso de los últimos seis meses
& 'C:\ProgramData\SGU\Monitoring\Get-SguUsageReport.ps1' |
Format-Table User,Computer,StartedAt,EndedAt,DurationMinutes,Result
# Un usuario o una máquina
& 'C:\ProgramData\SGU\Monitoring\Get-SguUsageReport.ps1' -UserName AL201428
& 'C:\ProgramData\SGU\Monitoring\Get-SguUsageReport.ps1' -ComputerName LCI-W11-01
# Diagnóstico del broker; admite -UserName, -Level, -EventId y -Text
& 'C:\ProgramData\SGU\Monitoring\Get-SguBrokerLog.ps1' -UserName AL201428 |
Format-Table TimeCreated,Level,EventId,EventName,Message -Wrap
& 'C:\ProgramData\SGU\Monitoring\Get-SguBrokerLog.ps1' -Level Warning
# Exportación
& 'C:\ProgramData\SGU\Monitoring\Get-SguUsageReport.ps1' `
-OutputCsv 'C:\ProgramData\SGU\Monitoring\Reports\usage.csv'
```
Para revisar la salud del colector:
```powershell
Get-Service Wecsvc
wecutil enum-subscription
wecutil get-subscriptionruntimestatus SGU-Lab-Monitoring
Get-WinEvent -LogName ForwardedEvents -MaxEvents 20
Get-WinEvent -LogName 'SGU Auth Broker' -MaxEvents 20
Get-ScheduledTask -TaskName 'SGU-Monitoring-*'
```
> **Windows Server 2025:** no consultes `ForwardedEvents` mediante
> `-FilterHashtable` ni XPath. Algunas compilaciones tienen una regresión que
> termina el servicio Windows Event Log (`wevtsvc.dll`, `0xc0000420`). El
> reporte incluido lee el canal sin consulta estructurada y aplica los filtros
> en memoria.
El bootstrap del servidor crea el colector, la suscripción y las tareas. El GPO
`SGU - Windows client experience` publica el Subscription Manager por FQDN. El
bootstrap y la reparación diaria del cliente habilitan las subcategorías de
auditoría y el acceso de `NETWORK SERVICE` al registro Security.
La suscripción usa `ContentFormat=Events`: conserva el XML original que emplea
el reporte y evita que cada cliente tenga que renderizar texto localizado antes
de enviarlo.
Referencias de Microsoft:
- <https://learn.microsoft.com/en-us/windows/win32/wec/setting-up-a-source-initiated-subscription>
- <https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/wecutil>
- <https://learn.microsoft.com/en-us/windows/security/threat-protection/auditing/event-4624>
- <https://learn.microsoft.com/en-us/windows/security/threat-protection/auditing/event-4634>
+97
View File
@@ -0,0 +1,97 @@
# RustDesk autoalojado y acceso remoto administrado
El bootstrap del controlador de dominio instala un servidor RustDesk OSS
autoalojado y el bootstrap de cada cliente Windows inscrito instala el agente
RustDesk administrado. Esto permite administrar el propio controlador y cada
cliente del laboratorio sin depender de los servidores públicos de RustDesk.
## Componentes y red
El controlador inicia ambos componentes bajo `SYSTEM` mediante las tareas
programadas `SGU-RustDesk-hbbs` y `SGU-RustDesk-hbbr`:
| Componente | Función | Puerto entrante |
| --- | --- | --- |
| `hbbs` | ID/rendezvous y prueba NAT | TCP 21115-21116 y UDP 21116 |
| `hbbr` | Relay para sesiones que no pueden ser directas | TCP 21117 |
Las reglas se llaman **SGU RustDesk** y sólo aceptan la subred privada que se
indicó al bootstrap del servidor. No se habilitan el servidor web ni los puertos
21118/21119. Los clientes y el servidor necesitan salida HTTPS a GitHub sólo
durante una primera instalación o actualización, para descargar el binario
verificado por SHA-256.
El nombre interno usado por los clientes es `rustdesk.lci.lasalle.mx`; el
bootstrap del DC mantiene su registro A en DNS apuntando a la IP fija del
controlador.
## Alta automática de un equipo Windows
Al finalizar `Invoke-SguClientBootstrap.ps1`, antes de considerar válido el
enrolamiento, el flujo:
1. lee la clave pública del servidor a través de la sesión autenticada con el
DC;
2. instala RustDesk desde el MSI oficial, comprobando el SHA-256 fijado;
3. instala y arranca el servicio `RustDesk` como automático;
4. configura ID, relay y clave pública del servidor privado en el contexto del
servicio y para perfiles nuevos;
5. crea una contraseña única de acceso desatendido, cifrada con DPAPI local y
protegida por ACL para `SYSTEM` y administradores locales;
6. registra únicamente el ID y la contraseña cifrada en el inventario del DC.
La contraseña no se imprime, no se pone en el manifiesto y no se añade a los
logs. El inventario del servidor usa DPAPI de la máquina y está limitado por ACL
a `SYSTEM` y administradores del servidor.
El mismo flujo se aplica al DC, por lo que también se puede administrar de forma
remota. Reejecutar el bootstrap mantiene el ID y la contraseña existentes, y
vuelve a validar configuración, tareas, servicio y conectividad sin crear otro
registro.
## Verificación y operación
En el DC, como administrador:
```powershell
Get-ScheduledTask SGU-RustDesk-hbbs,SGU-RustDesk-hbbr |
Select-Object TaskName,State
Get-NetTCPConnection -State Listen -LocalPort 21116,21117
Get-Content C:\ProgramData\SGU\RustDesk\server.json
& C:\ProgramData\SGU\RustDesk\Get-SguRustDeskDevice.ps1
```
El último comando muestra los nombres, IDs y fecha de alta, sin contraseñas. Si
un administrador necesita recuperar una contraseña para conectarse desde el
cliente controlador de RustDesk, puede hacerlo explícitamente en la consola del
DC:
```powershell
& C:\ProgramData\SGU\RustDesk\Get-SguRustDeskDevice.ps1 `
-ComputerName LCI-01 -RevealPassword
```
Trata esa salida como una credencial administrativa: no la pegues en tickets,
capturas ni registros. En RustDesk, conecta usando el ID inventariado y el modo
de autenticación por contraseña permanente.
En un cliente, los indicadores locales son:
```powershell
Get-Service RustDesk
Get-Content C:\ProgramData\SGU\RustDesk\Client\device.json
Test-NetConnection rustdesk.lci.lasalle.mx -Port 21116
```
Si un agente deja de funcionar, se puede repetir el bootstrap del cliente. El
guardián de enrolamiento también repara la configuración de RustDesk al inicio
cuando la información del servidor sigue presente en su estado de enrolamiento.
## Límites operativos
Este alcance automatiza el cliente Windows entregado por
`Invoke-SguClientBootstrap.ps1`. El bootstrap Linux conserva su inicio PAM/SSSD
independiente y usa un flujo propio de inventario: autentica con la cuenta de
equipo Kerberos, cifra la contraseña de RustDesk para el controlador y recibe
su confirmación desde la cola protegida. No reutiliza ni expone contraseñas de
Windows.
+28 -12
View File
@@ -1,5 +1,14 @@
# Security model
## Public Azure deployment
The Azure topology supports certificate-authenticated P2S or direct enrollment.
P2S keeps AD services inside the VNet. Direct enrollment exposes the required
AD, DNS/DoH, WinRM, broker and RustDesk ports only to explicit public IPv4 CIDRs;
an empty allowlist exposes none of them. Azure NSG and Windows Firewall enforce
the same source list. RDP uses a separate allowlist. See
[azure-vpn-deployment.md](azure-vpn-deployment.md).
## Password handling
- The Credential Provider receives the password in Lithnet's secure password
@@ -33,26 +42,33 @@
- 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 and optionally
reads only the same name and postal-address element IDs used by staff pages.
A missing professor route or element never makes authentication fail.
- Incident details, calendars, photographs, manager names, and manager positions
are deliberately 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,
employee type/status, job title, and the optional department field. It then
optionally reads the same structured-name and postal-address element IDs used
by staff pages. A missing professor route or element never makes authentication
fail.
- Payroll/receipt contents, incident details, calendars, photographs, manager
names, and manager positions are deliberately ignored.
- The employee or student number must match the authenticated `AD` or `AL` key
before role-specific metadata is synchronized. The two supplemental
administrative pages are never requested unless the incident page supplied
the matching employee number. Professor supplemental data comes from the
same fresh, request-scoped NTLM session as its menu fallback.
the matching employee number. Professor payroll metadata independently
requires the matching six-digit number, and every supplemental request uses
the same fresh, request-scoped NTLM session as its menu fallback.
- If SGU changes its HTML, authentication and exact-password synchronization
continue without enrichment; existing AD metadata is not erased.
- Slow profile pages cannot change an accepted credential into a rejection. The
+171
View File
@@ -0,0 +1,171 @@
# Validación del bootstrap Windows unificado
Fecha: 2026-09-10. Paquete: 0.5.1.
## Comprobaciones locales
- Publicación Release del Auth Broker y del Credential Provider completada.
- Pruebas Pester ejecutadas en Windows PowerShell 5.1: selección de rutas,
dos interfaces, VPN en otra subred, preferencia por la ruta de Windows,
restricción explícita de interfaz, APIPA, falta de ruta, prefijo más específico,
ruta de host y conflictos, DNS limitado al dominio, reintentos y compatibilidad.
- Prueba TCP real con socket ligado a una IP e interfaz y servicio cerrado.
- Reenrolamiento: se conserva la contraseña de `alumno` si la cuenta ya existe,
para no provocar rechazos de historial/complejidad tras aplicar las políticas
del dominio; se mantienen las verificaciones de permisos de usuario estándar.
- El empaquetador genera un solo ZIP Windows con los lanzadores habitual y Azure,
el instalador VPN, el runtime offline y el manifiesto SHA-256.
## Prueba real en Hyper-V: Windows 11
Servidor: VM `Windows Server`, dominio `lci.lasalle.mx`, DC `192.168.50.10`.
Cliente: VM `Windows11-002`, Windows 11 Enterprise LTSC, build 26100,
nombre de equipo `DESKTOP-LM7D7OM`, inicialmente en WORKGROUP.
Antes de la prueba se creó el checkpoint
`Before SGU unified enrollment 2026-09-10`. El cliente sólo tenía conexión al
`Default Switch`; se añadió la tarjeta `SGU AD Test` al switch `Laboratorio AD`
y se configuró administrativamente `192.168.50.202/24` sin puerta de enlace.
Esta preparación de la red del laboratorio es independiente del bootstrap:
el enrolador no asignó esa dirección y no recibió parámetros de IP del cliente,
interfaz, dominio, NetBIOS ni OU.
Se ejecutó el paquete con la IP del DC, una credencial en memoria y
`-SkipRestart` para inspeccionar el resultado; después se reinició el cliente.
Resultados comprobados:
- Selección automática de `Laboratorio AD` y descubrimiento autenticado de
`lci.lasalle.mx`, `LCI` y `OU=Laboratorio`.
- Proveedor y certificados instalados; salud mTLS verificada antes de la unión.
- Unión al dominio completada y `Test-ComputerSecureChannel` verdadero después
del reinicio.
- `Test-SguClientEnrollment.ps1` con exigencia de dominio, broker, acceso remoto
y RustDesk: `IsValid=True`, sin incidencias, después del guard de arranque.
- Interfaz privada `DomainAuthenticated`; interfaz de Internet `Public`, con
DHCP y su servidor DNS originales. Resolución pública y HTTPS comprobados
contra `www.microsoft.com` (HTTP 200).
## Prueba real en Hyper-V: Windows 10
Fecha: 2026-09-10. Cliente: VM `Windows10-001`, Windows 10 Enterprise LTSC
x64, build 19044, nombre de equipo `DESKTOP-HDKRD5V`, inicialmente en WORKGROUP.
Se usó el mismo ZIP 0.5.1 publicado en Gitea, sin modificar sus scripts ni
binarios. SHA-256 del ZIP:
```text
4DBE45697D74110F65C6D7825A593121B19C53B5F286F0DBC00068F3AFE45FB0
```
Se guardó el checkpoint `Before SGU Windows10 validation 2026-09-10`.
La VM ya tenía dos tarjetas: `Ethernet` en `Default Switch`, con DHCP y DNS
`172.18.176.1`, y `Ethernet 2` en `Laboratorio AD`, con dirección APIPA.
Primero se ejecutó el bootstrap sin preparar la IP privada. Reintentó la
conexión, diagnosticó que `Ethernet 2` no tenía una IPv4 utilizable y que la ruta
de Internet no alcanzaba WinRM del servidor. No solicitó una IP del cliente,
no modificó sus direcciones y mantuvo el equipo en WORKGROUP.
Para la prueba positiva se configuró administrativamente
`192.168.50.203/24` en `Ethernet 2`, sin puerta de enlace, y se esperó a que
Windows confirmara la dirección como `Preferred`. Esta preparación corresponde
a la red de laboratorio sin DHCP; no la realizó el bootstrap. Se ejecutó de
nuevo el ZIP con sólo la IP del DC, una credencial en memoria y `-SkipRestart`,
sin parámetros de interfaz, IP del cliente, dominio, NetBIOS ni OU.
Resultados:
- Selección automática de `Ethernet 2`, descubrimiento de `lci.lasalle.mx`,
`LCI` y `OU=Laboratorio`, y unión al dominio completada.
- Proveedor y certificados instalados; proveedor validado antes de la unión.
- Tras reiniciar, `Test-ComputerSecureChannel=True` y tarea
`SGU-CredentialProvider-EnrollmentGuard` finalizada con `LastTaskResult=0`.
- Validación con dominio, salud del broker, acceso remoto y RustDesk exigidos:
`IsValid=True`, `Issues={}`, `BrokerHealth=ok`, `RemoteAccessReady=True` y
`RustDeskReady=True`.
- Runtime .NET y binarios presentes; proveedor de contraseña de Windows
conservado. Cuenta `alumno` presente, sin permisos de administrador y con
expiración de contraseña deshabilitada.
- `Ethernet 2` quedó como `DomainAuthenticated`; `Ethernet` permaneció como
`Public`, conservando su DHCP y DNS original. HTTPS hacia
`https://www.microsoft.com` respondió HTTP 200.
No fue necesario corregir el bootstrap para esta prueba. La VM quedó encendida
y enrolada, con el ZIP extraído en sus Descargas y el checkpoint previo disponible.
### Comprobación posterior del escritorio
La validación anterior comprobaba el enrolamiento, pero no el fondo visible
en una sesión de usuario. Al revisar la sesión de `Windows10-001`, el fondo
seguía siendo el predeterminado de Windows. El registro del generador mostró
un fallo de validación al asignar el género vacío devuelto por AD al parámetro
`Gender`, cuyo `ValidateSet` sólo permite `Male` o `Female`.
Se corrigió `Set-SguWelcomeWallpaper.ps1` para mantener el saludo neutral cuando
el dato no está disponible. Se actualizaron el generador instalado y su copia
en el paquete de autorreparación, y se ejecutó en el contexto de la sesión
interactiva existente, sin cerrar sesión ni solicitar otra contraseña.
El registro terminó con `OK`, la configuración del usuario apuntó al JPEG
generado y se verificó visualmente el fondo institucional con nombre y saludo.
La tarea temporal utilizada para actualizar la sesión se retiró al finalizar;
la ejecución habitual al iniciar sesión sigue a cargo de la GPO.
Se agregaron cuatro pruebas de renderizado JPEG: género ausente, vacío o
desconocido, valores reconocidos y prioridad de un valor explícito. Todas
pasaron en Windows PowerShell 5.1. Esta corrección posterior está en el código
y en la VM; el ZIP publicado como 0.5.1 no se modificó.
## Prueba real de enrolamiento público directo: Windows 10
Fecha: 2026-09-11. Se repitió el enrolamiento de `Windows10-001` contra el DC
Azure `20.9.81.130`, sin perfil ni interfaz VPN. El cliente conservó sus dos NIC
y seleccionó por sí solo `Ethernet` con DHCP (`172.18.183.201`), porque era la
ruta que alcanzaba WinRM. El segmento público de salida autorizado fue
`200.13.89.0/24`.
La VM aún nombraba `lci.lasalle.mx`, pero su canal seguro pertenecía al bosque
anterior y estaba roto. El flujo creó o reutilizó la cuenta de equipo en la OU
descubierta, restableció la contraseña de máquina contra
`SGU-DC01.lci.lasalle.mx`, reinició Netlogon y conservó el equipo unido. También
toleró SID huérfanos del bosque anterior al comprobar los grupos locales.
Windows 10 Enterprise LTSC build 19044 no expone los cmdlets DoH. El bootstrap
lo detectó y usó DNS tradicional hacia la misma IP pública, limitado por NSG y
Windows Firewall al CIDR permitido. Después de un reinicio real se comprobó:
- `Test-ComputerSecureChannel=True` y resolución SRV del DC;
- `IsValid=True`, sin incidencias;
- `BrokerHealth=ok`, `RemoteAccessReady=True` y `RustDeskReady=True`;
- ningún perfil VPN instalado;
- paquete final `0.5.9`, SHA-256 del ZIP de Windows:
`E7AF77252E444FB7EBACF3C90005D322EE19F76DD9387AC5782C57EA9EE617B7`.
## Prueba real con Azure VPN
El 2026-09-10 se desplegó `sgu-lab-dc` en Azure Central US, se creó el bosque
`lci.lasalle.mx` y se enroló `Windows11-002` mediante un gateway real
`VpnGw1AZ`. El controlador tiene IP `10.77.0.4` y el cliente obtuvo
`172.30.0.2` por IKEv2 con certificado de máquina. El bootstrap descubrió
automáticamente la interfaz VPN, el dominio y la OU a partir de la IP del DC
y la credencial administrativa.
Se comprobaron el objeto de equipo en `OU=Laboratorio`, el canal seguro y la
validación SGU completa, incluyendo salud mTLS, acceso remoto y RustDesk.
Para Enterprise se configuró un device tunnel y una recuperación de Netlogon
para la conectividad tardía al arrancar. El bosque Azure es independiente del
bosque local con el mismo nombre.
La infraestructura, versiones de paquetes, ajustes adicionales y evidencias
están en [el informe de Azure](azure-deployment-validation-2026-09-10.md).
Se usaron paquetes locales de validación `0.5.2-azure.*`; el release publicado
0.5.1 no se reemplazó durante este despliegue.
## Alcance pendiente
OpenVPN y otras VPN requieren validación en sus redes reales. Las pruebas
Windows realizadas cubren Enterprise LTSC x64, builds 19044 y 26100; no todas
las ediciones ni builds. Azure se probó con Windows 11; la prueba de Windows 10
descrita arriba corresponde a la LAN.
El servidor debe tener SGU preparado. La IP de un DC no permite crear una VPN,
adivinar una IP libre ni sustituir permisos, DHCP o políticas de firewall.
+68
View File
@@ -0,0 +1,68 @@
# Fondo de bienvenida personalizado
El enrolamiento instala un fondo base azul, las familias `Indivisa Text Sans` y
`Indivisa Text Serif`, y un generador local. La GPO de equipos
`SGU - Windows client experience` ejecuta el generador al abrir cada sesión y
mantiene el fondo base en la pantalla de bloqueo.
Windows no conoce todavía la identidad que se autenticará mientras muestra la
pantalla previa al inicio de sesión. Por ello, esa pantalla utiliza el fondo base
sin datos personales y la composición individual se genera inmediatamente
después de autenticar, antes de que el usuario empiece a trabajar en el escritorio.
## Datos y degradación controlada
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 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: 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
`%LOCALAPPDATA%\SGU\Logs\welcome-wallpaper.log`.
## Windows
El paquete de cliente copia los recursos a `C:\ProgramData\SGU\Branding`. La
GPO crea el valor de equipo `SGUWelcomeWallpaper` bajo
`HKLM\Software\Microsoft\Windows\CurrentVersion\Run`; por tanto, se ejecuta en
el contexto de cada usuario y puede leer sus datos de AD. El resultado se guarda
en `%LOCALAPPDATA%\SGU\Wallpapers` y se aplica con la API nativa de Windows.
La antigua directiva estática de escritorio se elimina para que no sobrescriba
el archivo individual. La personalización sigue estando gobernada por dominio:
el comando de inicio y la pantalla de bloqueo pertenecen a la GPO de equipos.
## Linux
El paquete Linux instala el generador en
`/usr/local/lib/sgu-welcome-wallpaper` y registra
`/etc/xdg/autostart/sgu-welcome-wallpaper.desktop`. Utiliza el ticket Kerberos
creado por SSSD para consultar el objeto de equipo mediante LDAP; nunca contiene
una contraseña de enlace.
Se admiten Cinnamon, GNOME y XFCE. La composición requiere ImageMagick; si la
dependencia o la API del escritorio no está disponible, el enrolamiento y el
inicio de sesión continúan normalmente y se escribe un diagnóstico en
`~/.local/state/sgu/welcome-wallpaper.log`.
## Tipografía
Los archivos OTF necesarios viajan dentro de cada paquete y se cargan en memoria
para renderizar el fondo; no se sustituyen fuentes del sistema. Se usa Sans en el
saludo y la ubicación, y Serif Bold Italic en el nombre. Si los archivos no
pueden cargarse, Windows usa Segoe UI/Georgia y Linux usa DejaVu Sans/Serif.
+6
View File
@@ -129,6 +129,12 @@ configuración siempre activa y evita las experiencias iniciales de privacidad,
telemetría, ubicación y **Hi / Preparing Windows** antes de que un usuario SGU
entre por primera vez.
La misma GPO configura el fondo azul de bloqueo y ejecuta el generador local al
abrir cada sesión. El generador usa el `displayName` del usuario, la propiedad
`location` del equipo y su OU padre inmediata para crear el fondo individual.
Consulta [welcome-wallpaper.md](welcome-wallpaper.md) para conocer los fallbacks
y la ubicación de los diagnósticos.
Microsoft documenta este derecho en:
<https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-userrights#allowlogonthroughremotedesktop>
y PowerShell Remoting en:
+406
View File
@@ -0,0 +1,406 @@
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('Deploy the optional Azure Point-to-Site VPN Gateway. Direct public enrollment does not require it.')
param deployVpnGateway bool = true
@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('Public IPv4 CIDRs allowed to enroll clients directly without VPN. Leave empty to expose no AD enrollment ports.')
param publicEnrollmentSourceAddressPrefixes array = []
@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(deployVpnGateway ? [
{
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(publicEnrollmentSourceAddressPrefixes) ? [] : [
{
name: 'Allow-Direct-AD-TCP'
properties: {
priority: 110
access: 'Allow'
direction: 'Inbound'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRanges: [
'53'
'88'
'135'
'389'
'443'
'445'
'464'
'636'
'3268'
'3269'
'21115-21117'
'49152-65535'
]
sourceAddressPrefixes: publicEnrollmentSourceAddressPrefixes
destinationAddressPrefix: domainControllerPrivateIp
description: 'AD, DoH, and RustDesk TCP access for explicitly authorized public enrollment networks.'
}
}
{
name: 'Allow-Direct-AD-UDP'
properties: {
priority: 120
access: 'Allow'
direction: 'Inbound'
protocol: 'Udp'
sourcePortRange: '*'
destinationPortRanges: [
'53'
'88'
'123'
'389'
'464'
'21116'
]
sourceAddressPrefixes: publicEnrollmentSourceAddressPrefixes
destinationAddressPrefix: domainControllerPrivateIp
description: 'AD, time, and RustDesk UDP access for explicitly authorized public enrollment networks.'
}
}
{
name: 'Allow-Direct-SGU-Enrollment-TCP'
properties: {
priority: 130
access: 'Allow'
direction: 'Inbound'
protocol: 'Tcp'
sourcePortRange: '*'
destinationPortRanges: [
'5985'
'8443'
]
sourceAddressPrefixes: publicEnrollmentSourceAddressPrefixes
destinationAddressPrefix: domainControllerPrivateIp
description: 'WinRM discovery and SGU broker access for direct enrollment.'
}
}
], empty(administratorSourceAddressPrefix) ? [] : [
{
name: 'Allow-RDP-from-administrator'
properties: {
priority: 140
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: concat([
{
name: domainControllerSubnetName
properties: {
addressPrefix: domainControllerSubnetPrefix
networkSecurityGroup: {
id: networkSecurityGroup.id
}
}
}
], deployVpnGateway ? [
{
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' = if (deployVpnGateway) {
name: gatewayPublicIpName
location: location
zones: [
'1'
'2'
'3'
]
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'
// AD DS requires durable writes; the bootstrap stores NTDS on this disk.
caching: 'None'
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' = if (deployVpnGateway) {
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: 'VpnGw1AZ'
tier: 'VpnGw1AZ'
}
vpnClientConfiguration: {
vpnClientAddressPool: {
addressPrefixes: [
vpnClientAddressPoolPrefix
]
}
vpnClientProtocols: [
'IkeV2'
'OpenVPN'
]
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 = deployVpnGateway ? virtualNetworkGateway.name : ''
output vpnClientAddressPoolPrefix string = vpnClientAddressPoolPrefix
output serverBootstrapArguments array = concat([
'-ServerIPv4Address'
domainControllerPrivateIp
'-PrefixLength'
last(split(domainControllerSubnetPrefix, '/'))
'-NetworkConfigurationMode'
'PlatformManaged'
'-DnsForwarders'
'168.63.129.16'
], deployVpnGateway ? [
'-TrustedClientNetworks'
vpnClientAddressPoolPrefix
] : [], empty(publicEnrollmentSourceAddressPrefixes) ? [] : concat([
'-PublicEnrollmentNetworks'
], publicEnrollmentSourceAddressPrefixes))
+139 -6
View File
@@ -22,6 +22,8 @@ param(
[ValidatePattern('^/')]
[string]$StudentProfilePath = '/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx',
[ValidatePattern('^/')]
[string]$ProfessorPayrollProfilePath = '/psulsa/gadmon/nomina/consultanomina.aspx',
[ValidatePattern('^/')]
[string]$MenuProfilePath = '/psulsa/menu.aspx',
[ValidateRange(32768, 2097152)]
[int]$MaxProfileBytes = 524288,
@@ -29,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',
@@ -36,6 +41,9 @@ param(
[int]$NtlmTimeoutSeconds = 20,
[ValidateRange(2, 90)]
[int]$ProfileTimeoutSeconds = 90,
[ValidateNotNullOrEmpty()]
[string[]]$FirewallRemoteAddress = @('LocalSubnet'),
[ipaddress]$FirewallLocalAddress,
[switch]$CreateMissingOus,
[switch]$DisableCertificateRevocationCheckForLab
)
@@ -43,6 +51,8 @@ param(
$ErrorActionPreference = 'Stop'
$serviceName = 'SGUAuthBroker'
$installPath = Join-Path $env:ProgramFiles 'SGU\AuthBroker'
$brokerEventLogName = 'SGU Auth Broker'
$brokerEventSource = 'SGU.AuthBroker.Operational'
$normalizedClientThumbprints = @($AllowedClientThumbprints | ForEach-Object { $_ -replace ' ', '' })
if ($normalizedClientThumbprints.Where({ $_.Length -ne 40 }).Count -gt 0) {
throw 'Client certificate thumbprints must contain exactly 40 hexadecimal characters.'
@@ -65,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.'
}
if ($CreateMissingOus) {
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) {
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
}
@@ -110,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)) {
@@ -126,6 +208,14 @@ foreach ($file in @('SGU.AuthBroker.exe', 'SGU.AuthBroker.dll', 'appsettings.jso
}
$productionSettings = @{
Logging = @{
EventLog = @{
LogLevel = @{
Default = 'Information'
'Microsoft.AspNetCore' = 'Warning'
}
}
}
Kestrel = @{
Endpoints = @{
Https = @{
@@ -140,6 +230,9 @@ $productionSettings = @{
}
}
Broker = @{
Diagnostics = @{
UseDedicatedEventLog = $true
}
Tls = @{
AllowedClientThumbprints = $normalizedClientThumbprints
CheckCertificateRevocation = -not $DisableCertificateRevocationCheckForLab
@@ -155,6 +248,7 @@ $productionSettings = @{
AdministrativePersonalProfilePath = $AdministrativePersonalProfilePath
AdministrativeLocationProfilePath = $AdministrativeLocationProfilePath
StudentProfilePath = $StudentProfilePath
ProfessorPayrollProfilePath = $ProfessorPayrollProfilePath
MenuProfilePath = $MenuProfilePath
MaxProfileBytes = $MaxProfileBytes
AllowedRedirectHosts = $AllowedNtlmRedirectHosts
@@ -167,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
@@ -195,6 +292,18 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker
$settingsJson,
$utf8WithoutBom)
if ([Diagnostics.EventLog]::SourceExists($brokerEventSource)) {
$registeredLog = [Diagnostics.EventLog]::LogNameFromSourceName($brokerEventSource, '.')
if (-not $registeredLog.Equals($brokerEventLogName, [StringComparison]::OrdinalIgnoreCase)) {
throw "Event source $brokerEventSource is already registered to $registeredLog."
}
}
else {
New-EventLog -LogName $brokerEventLogName -Source $brokerEventSource
}
Limit-EventLog -LogName $brokerEventLogName -MaximumSize 268435456 `
-OverflowAction OverwriteAsNeeded
if (-not (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)) {
New-Service -Name $serviceName `
-DisplayName 'SGU Authentication Broker' `
@@ -215,12 +324,36 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker
throw 'Could not enable recovery for non-crash SGUAuthBroker failures.'
}
if (-not (Get-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' -ErrorAction SilentlyContinue)) {
New-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' `
-Direction Inbound -Action Allow -Protocol TCP -LocalPort 8443 -Profile Domain | Out-Null
$firewallRule = Get-NetFirewallRule `
-DisplayName 'SGU Authentication Broker (mTLS)' `
-ErrorAction SilentlyContinue
if (-not $firewallRule) {
$firewallParameters = @{
DisplayName = 'SGU Authentication Broker (mTLS)'
Direction = 'Inbound'
Action = 'Allow'
Protocol = 'TCP'
LocalPort = 8443
Profile = 'Any'
RemoteAddress = $FirewallRemoteAddress
}
if ($FirewallLocalAddress) {
$firewallParameters.LocalAddress = $FirewallLocalAddress.IPAddressToString
}
$firewallRule = New-NetFirewallRule @firewallParameters
}
else {
$firewallRule | Set-NetFirewallRule -Enabled True -Profile Any
$addressParameters = @{ RemoteAddress = $FirewallRemoteAddress }
if ($FirewallLocalAddress) {
$addressParameters.LocalAddress = $FirewallLocalAddress.IPAddressToString
}
$firewallRule | Get-NetFirewallAddressFilter |
Set-NetFirewallAddressFilter @addressParameters | Out-Null
}
Start-Service -Name $serviceName
}
Get-Service -Name $serviceName | Select-Object Name, Status, StartType
Get-Service -Name $serviceName | Select-Object Name, Status, StartType,
@{ Name = 'EventLog'; Expression = { $brokerEventLogName } }
+156
View File
@@ -0,0 +1,156 @@
#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,
[string]$P2sRootCertificatePath,
[bool]$DeployVpnGateway = $true,
[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[]]$PublicEnrollmentSourceAddressPrefixes = @(),
[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 $AdministratorPassword) {
$AdministratorPassword = Read-Host 'Password for the local Azure VM administrator' -AsSecureString
}
$rootCertificateData = ''
if ($DeployVpnGateway) {
if (-not $P2sRootCertificatePath -or
-not (Test-Path -LiteralPath $P2sRootCertificatePath -PathType Leaf)) {
throw 'P2sRootCertificatePath is required when DeployVpnGateway is true.'
}
$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."
}
$deploymentDescription = if ($DeployVpnGateway) {
'Create Azure VNet, Windows Server 2025 VM, public IP, and P2S VPN Gateway'
}
else {
'Create Azure VNet, Windows Server 2025 VM, and public IP for direct enrollment'
}
if ($PSCmdlet.ShouldProcess("$ResourceGroupName in $Location", $deploymentDescription)) {
& 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 }
deployVpnGateway = @{ value = $DeployVpnGateway }
p2sRootCertificateData = @{ value = $rootCertificateData }
publicEnrollmentSourceAddressPrefixes = @{ value = @($PublicEnrollmentSourceAddressPrefixes) }
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
DeployVpnGateway = $DeployVpnGateway
PublicEnrollmentSourceAddressPrefixes = @($PublicEnrollmentSourceAddressPrefixes)
ServerBootstrapArguments = $values.serverBootstrapArguments
}
}
+39 -13
View File
@@ -18,8 +18,40 @@ if (-not $computer.PartOfDomain) {
$remoteDesktopUsersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-555')
$remoteDesktopUsersGroup = ($remoteDesktopUsersSid.Translate([Security.Principal.NTAccount]).Value -split '\\', 2)[1]
$remoteDesktopPrincipalSid = ([Security.Principal.NTAccount]::new($RemoteDesktopPrincipal)).Translate(
[Security.Principal.SecurityIdentifier])
function Get-LocalGroupMemberSid {
param([Parameter(Mandatory)][string]$Name)
$group = [ADSI]("WinNT://$env:COMPUTERNAME/$Name,group")
foreach ($member in @($group.psbase.Invoke('Members'))) {
try {
$sidBytes = $member.GetType().InvokeMember('objectSid',
[Reflection.BindingFlags]::GetProperty, $null, $member, $null)
if ($sidBytes) {
([Security.Principal.SecurityIdentifier]::new([byte[]]$sidBytes, 0)).Value
}
}
catch { }
}
}
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enable RDP and grant $RemoteDesktopPrincipal access")) {
function Invoke-PowerCfgBestEffort {
param([Parameter(Mandatory)][string[]]$Arguments)
# Start-Process keeps powercfg's policy-override diagnostic on its own
# stderr stream. In PowerShell 7, directly invoking that native command
# turns stderr into a terminating ErrorRecord under $ErrorActionPreference
# = 'Stop', which previously aborted this unrelated remediation work.
$process = Start-Process -FilePath "$env:SystemRoot\System32\powercfg.exe" `
-ArgumentList $Arguments -Wait -PassThru -WindowStyle Hidden
if ($process.ExitCode -ne 0) {
Write-Warning "powercfg $($Arguments -join ' ') returned exit code $($process.ExitCode); continuing enrollment repair."
}
}
foreach ($powerChange in @(
@('monitor-timeout-ac', '0'),
@('monitor-timeout-dc', '0'),
@@ -27,15 +59,9 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enable RDP and grant $RemoteDesk
@('standby-timeout-dc', '0'),
@('hibernate-timeout-ac', '0'),
@('hibernate-timeout-dc', '0'))) {
& powercfg.exe /change $powerChange[0] $powerChange[1]
if ($LASTEXITCODE -ne 0) {
throw "powercfg /change $($powerChange[0]) failed with exit code $LASTEXITCODE."
}
}
& powercfg.exe /hibernate off
if ($LASTEXITCODE -ne 0) {
throw "powercfg /hibernate off failed with exit code $LASTEXITCODE."
Invoke-PowerCfgBestEffort -Arguments @('/change', $powerChange[0], $powerChange[1])
}
Invoke-PowerCfgBestEffort -Arguments @('/hibernate', 'off')
Set-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' `
-Name fDenyTSConnections -Type DWord -Value 0
@@ -49,9 +75,9 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enable RDP and grant $RemoteDesk
-ErrorAction SilentlyContinue |
Set-NetFirewallRule -Enabled True -Profile Domain
$existingMembers = @(Get-LocalGroupMember -Group $remoteDesktopUsersGroup -ErrorAction SilentlyContinue)
if ($existingMembers.Name -notcontains $RemoteDesktopPrincipal) {
Add-LocalGroupMember -Group $remoteDesktopUsersGroup -Member $RemoteDesktopPrincipal
$existingMembers = @(Get-LocalGroupMemberSid -Name $remoteDesktopUsersGroup)
if ($existingMembers -notcontains $remoteDesktopPrincipalSid.Value) {
Add-LocalGroupMember -Group $remoteDesktopUsersGroup -Member $remoteDesktopPrincipalSid.Value
}
# Use Windows PowerShell so both the inbox and compatible remoting endpoints
@@ -89,7 +115,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enable RDP and grant $RemoteDesk
}
}
$rdpMembers = @(Get-LocalGroupMember -Group $remoteDesktopUsersGroup -ErrorAction SilentlyContinue)
$rdpMembers = @(Get-LocalGroupMemberSid -Name $remoteDesktopUsersGroup)
[pscustomobject]@{
ComputerName = $env:COMPUTERNAME
Domain = $computer.Domain
@@ -100,7 +126,7 @@ $rdpMembers = @(Get-LocalGroupMember -Group $remoteDesktopUsersGroup -ErrorActio
'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' `
-Name UserAuthentication) -eq 1
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
PrincipalIsAuthorized = $rdpMembers.Name -contains $RemoteDesktopPrincipal
PrincipalIsAuthorized = $rdpMembers -contains $remoteDesktopPrincipalSid.Value
TermService = (Get-Service TermService).Status
WinRM = (Get-Service WinRM).Status
FirewallProfile = 'Domain'
+78
View File
@@ -0,0 +1,78 @@
[CmdletBinding(SupportsShouldProcess)]
param()
$ErrorActionPreference = 'Stop'
function Get-LocalGroupMemberSid {
param([Parameter(Mandatory)][string]$Name)
$group = [ADSI]("WinNT://$env:COMPUTERNAME/$Name,group")
foreach ($member in @($group.psbase.Invoke('Members'))) {
try {
$sidBytes = $member.GetType().InvokeMember('objectSid',
[Reflection.BindingFlags]::GetProperty, $null, $member, $null)
if ($sidBytes) {
([Security.Principal.SecurityIdentifier]::new([byte[]]$sidBytes, 0)).Value
}
}
catch { }
}
}
$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.'
}
# Use invariant audit subcategory GUIDs so this works on English and Spanish
# installations. Logon, logoff, and other logon/logoff events provide the
# session identifiers required to correlate usage centrally.
$auditSubcategories = @(
'{0CCE9215-69AE-11D9-BED3-505054503030}', # Logon
'{0CCE9216-69AE-11D9-BED3-505054503030}', # Logoff
'{0CCE921C-69AE-11D9-BED3-505054503030}' # Other Logon/Logoff Events
)
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable SGU session auditing and event forwarding prerequisites')) {
foreach ($subcategory in $auditSubcategories) {
& auditpol.exe /set "/subcategory:$subcategory" /success:enable /failure:enable | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "auditpol failed for subcategory $subcategory with exit code $LASTEXITCODE."
}
}
# Security events are read by the Windows Event Forwarding plug-in under
# NETWORK SERVICE. Resolve both principals by SID for localized Windows.
$eventLogReadersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-573')
$networkServiceSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-20')
$eventLogReadersGroup = ($eventLogReadersSid.Translate(
[Security.Principal.NTAccount]).Value -split '\\', 2)[1]
$members = @(Get-LocalGroupMemberSid -Name $eventLogReadersGroup)
$eventLogReaderMembershipChanged = $false
if ($members -notcontains $networkServiceSid.Value) {
Add-LocalGroupMember -SID $eventLogReadersSid -Member $networkServiceSid.Value
$eventLogReaderMembershipChanged = $true
}
Set-Service WinRM -StartupType Automatic
if ((Get-Service WinRM).Status -ne 'Running') {
Start-Service WinRM
}
elseif ($eventLogReaderMembershipChanged) {
Restart-Service WinRM -Force
}
& wevtutil.exe set-log Security /maxsize:268435456 /retention:false /autobackup:false
if ($LASTEXITCODE -ne 0) {
throw "wevtutil failed to configure the local Security log with exit code $LASTEXITCODE."
}
}
[pscustomobject]@{
ComputerName = $env:COMPUTERNAME
WinRM = (Get-Service WinRM).Status.ToString()
SecurityLogMaximumBytes = (Get-WinEvent -ListLog Security).MaximumSizeInBytes
AuditSubcategories = $auditSubcategories
EventForwardingPolicy = Test-Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\EventLog\EventForwarding\SubscriptionManager'
}
+23 -10
View File
@@ -1,5 +1,8 @@
[CmdletBinding(SupportsShouldProcess)]
param()
param(
[ValidateNotNullOrEmpty()]
[string[]]$AllowedRemoteAddress = @('LocalSubnet')
)
$ErrorActionPreference = 'Stop'
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
@@ -38,9 +41,12 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable secure administrative RDP
Set-Service -Name TermService -StartupType Automatic
Start-Service -Name TermService
Get-NetFirewallRule -Name 'RemoteDesktop-UserMode-In-TCP','RemoteDesktop-UserMode-In-UDP' `
-ErrorAction SilentlyContinue |
Set-NetFirewallRule -Enabled True -Profile Domain
$remoteDesktopRules = @(Get-NetFirewallRule `
-Name 'RemoteDesktop-UserMode-In-TCP','RemoteDesktop-UserMode-In-UDP' `
-ErrorAction SilentlyContinue)
$remoteDesktopRules | Set-NetFirewallRule -Enabled True -Profile Any
$remoteDesktopRules | Get-NetFirewallAddressFilter |
Set-NetFirewallAddressFilter -RemoteAddress $AllowedRemoteAddress | Out-Null
$enableRemoting = Start-Process `
-FilePath "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" `
@@ -57,9 +63,12 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable secure administrative RDP
Set-Service -Name WinRM -StartupType Automatic
Start-Service -Name WinRM
Get-NetFirewallRule -Name 'WINRM-HTTP-In-TCP','WINRM-HTTP-In-TCP-NoScope' `
-ErrorAction SilentlyContinue |
Set-NetFirewallRule -Enabled True -Profile Domain
$winRmRules = @(Get-NetFirewallRule `
-Name 'WINRM-HTTP-In-TCP','WINRM-HTTP-In-TCP-NoScope' `
-ErrorAction SilentlyContinue)
$winRmRules | Set-NetFirewallRule -Enabled True -Profile Any
$winRmRules | Get-NetFirewallAddressFilter |
Set-NetFirewallAddressFilter -RemoteAddress $AllowedRemoteAddress | Out-Null
Get-NetFirewallRule -Name 'WINRM-HTTP-In-TCP-PUBLIC' -ErrorAction SilentlyContinue |
Disable-NetFirewallRule
@@ -74,8 +83,11 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable secure administrative RDP
'WMI-WINMGMT-In-TCP',
'WMI-ASYNC-In-TCP'
)
Get-NetFirewallRule -Name $administrativeRules -ErrorAction SilentlyContinue |
Set-NetFirewallRule -Enabled True -Profile Domain
$enabledAdministrativeRules = @(Get-NetFirewallRule `
-Name $administrativeRules -ErrorAction SilentlyContinue)
$enabledAdministrativeRules | Set-NetFirewallRule -Enabled True -Profile Any
$enabledAdministrativeRules | Get-NetFirewallAddressFilter |
Set-NetFirewallAddressFilter -RemoteAddress $AllowedRemoteAddress | Out-Null
}
[pscustomobject]@{
@@ -88,7 +100,8 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable secure administrative RDP
-Name UserAuthentication) -eq 1
TermService = (Get-Service TermService).Status
WinRM = (Get-Service WinRM).Status
FirewallProfile = 'Domain'
FirewallProfile = 'Any'
AllowedRemoteAddress = $AllowedRemoteAddress
AdministrativeAccessOnly = $true
AlwaysOnPowerPolicyApplied = $true
}
+91 -8
View File
@@ -18,12 +18,18 @@ param(
[PSCredential]$DomainCredential,
[string]$DomainName = 'lci.lasalle.mx',
[string]$DomainNetbios = 'LCI',
[string]$DomainControllerDnsName,
[string]$ComputerOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
[string]$NewComputerName,
[string]$NetworkInterfaceAlias = 'Ethernet',
[string[]]$DomainDnsServerAddresses = @('192.168.50.10'),
[switch]$DomainDnsConfigured,
[ValidateSet('Direct', 'AzureP2S')]
[string]$ConnectivityMode = 'Direct',
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
[string]$DotNetRuntimeInstallerPath,
[string]$RustDeskServerAddress,
[string]$RustDeskServerPublicKey,
[switch]$SkipRestart
)
@@ -37,9 +43,12 @@ 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')) {
'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1',
'Install-SguRustDeskClient.ps1')) {
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot $scriptName) -PathType Leaf)) {
throw "$scriptName must be beside Enroll-SguDomainClient.ps1."
}
@@ -49,6 +58,31 @@ $computer = Get-CimInstance Win32_ComputerSystem
if ($computer.PartOfDomain -and $computer.Domain -ne $DomainName) {
throw "The computer is already joined to the unexpected domain $($computer.Domain)."
}
$domainMembershipHealthy = $false
if ($computer.PartOfDomain) {
try {
$domainMembershipHealthy = [bool](Test-ComputerSecureChannel -ErrorAction Stop)
}
catch {
$domainMembershipHealthy = $false
}
}
if ($computer.PartOfDomain -and -not $domainMembershipHealthy) {
if (-not $DomainCredential) {
$DomainCredential = Get-Credential `
-UserName "$DomainNetbios\Administrator" `
-Message "Credential permitted to repair this computer in $DomainName"
}
$repairServer = if ($DomainControllerDnsName) { $DomainControllerDnsName } else { $DomainName }
Write-Warning "The computer names $DomainName but its secure channel is broken. Repairing it against $repairServer."
Reset-ComputerMachinePassword -Server $repairServer -Credential $DomainCredential -ErrorAction Stop
Restart-Service Netlogon -Force
Start-Sleep -Seconds 2
$domainMembershipHealthy = [bool](Test-ComputerSecureChannel -ErrorAction Stop)
if (-not $domainMembershipHealthy) {
throw "The secure channel to $DomainName remained invalid after repair."
}
}
$installParams = @{
PublishPath = $PublishPath
@@ -72,34 +106,80 @@ $guardParams = @{
TimeoutSeconds = 90
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
DotNetRuntimeInstallerPath = $DotNetRuntimeInstallerPath
RustDeskServerAddress = $RustDeskServerAddress
RustDeskServerPublicKey = $RustDeskServerPublicKey
}
if ([string]::IsNullOrWhiteSpace($RustDeskServerAddress) -xor
[string]::IsNullOrWhiteSpace($RustDeskServerPublicKey)) {
throw 'RustDeskServerAddress and RustDeskServerPublicKey must be supplied together.'
}
$rustDeskResult = $null
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before joining the domain')) {
# 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 ($DomainDnsConfigured) {
# The bootstrap configured domain-scoped NRPT, preserving Internet DNS.
}
elseif ($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
if ($RustDeskServerAddress) {
$rustDeskResult = & (Join-Path $PSScriptRoot 'Install-SguRustDeskClient.ps1') `
-ServerAddress $RustDeskServerAddress `
-ServerPublicKey $RustDeskServerPublicKey
}
$localStudentUser = & (Join-Path $PSScriptRoot 'Set-SguStandardLocalUser.ps1')
& (Join-Path $PSScriptRoot 'Install-SguEnrollmentGuard.ps1') @guardParams | Out-Null
$preJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') `
-RequireBrokerHealth
$testParameters = @{ RequireBrokerHealth = $true }
if ($RustDeskServerAddress) {
$testParameters.RequireRustDesk = $true
$testParameters.RustDeskServerAddress = $RustDeskServerAddress
}
$preJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @testParameters
if (-not $preJoin.IsValid) {
throw "Domain join refused because SGU enrollment is invalid: $($preJoin.Issues -join ' ')"
}
if ($computer.PartOfDomain) {
if ($computer.PartOfDomain -and $domainMembershipHealthy) {
& (Join-Path $PSScriptRoot 'Enable-LabRemoteAccess.ps1') `
-RemoteDesktopPrincipal $RemoteDesktopPrincipal `
-EnableAdministrativeFirewallGroups | Out-Null
return & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') `
-RequireDomainJoined `
-RequireRemoteAccess `
-RemoteDesktopPrincipal $RemoteDesktopPrincipal
& (Join-Path $PSScriptRoot 'Enable-SguClientMonitoring.ps1') | Out-Null
$postJoinParameters = @{
RequireDomainJoined = $true
RequireRemoteAccess = $true
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
}
if ($RustDeskServerAddress) {
$postJoinParameters.RequireRustDesk = $true
$postJoinParameters.RustDeskServerAddress = $RustDeskServerAddress
}
$postJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @postJoinParameters
if (-not $postJoin.IsValid) {
throw "SGU validation failed on the joined computer: $($postJoin.Issues -join ' ')"
}
$postJoin | Add-Member -NotePropertyName StandardLocalUser -NotePropertyValue $localStudentUser
$postJoin | Add-Member -NotePropertyName RustDesk -NotePropertyValue $rustDeskResult
return $postJoin
}
if (-not $DomainCredential) {
@@ -129,6 +209,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
}
+513
View File
@@ -0,0 +1,513 @@
#!/usr/bin/env bash
# Enroll-SguLinuxDomainClient.sh
#
# Idempotently joins a Debian/Ubuntu or RHEL-family Linux workstation to the
# SGU Active Directory laboratory. The join password is always requested by
# realmd; this script never accepts, logs, or stores it.
set -Eeuo pipefail
IFS=$'\n\t'
SCRIPT_DIRECTORY=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
DOMAIN_NAME='lci.lasalle.mx'
DOMAIN_CONTROLLER=''
DOMAIN_DNS=''
COMPUTER_OU='OU=Laboratorio,DC=lci,DC=lasalle,DC=mx'
JOIN_USER='Administrator'
DOMAIN_INTERFACE=''
DOMAIN_ADDRESS=''
COMPUTER_NAME=''
ALLOW_GROUP=''
ENABLE_SSH=false
ENABLE_HYPERV_ENHANCED_SESSION=false
ENABLE_RUSTDESK=true
RUSTDESK_REGISTRATION_SHARE=''
usage() {
cat <<'EOF'
Usage:
sudo ./Enroll-SguLinuxDomainClient.sh --domain-controller <IPv4-or-FQDN> [options]
Required:
--domain-controller VALUE Fixed IPv4 address or DNS name of the AD controller.
Options:
--domain-name VALUE AD DNS domain (default: lci.lasalle.mx).
--domain-dns VALUE DNS server for the AD network (default: domain controller).
--computer-ou DN Destination computer OU.
--join-user USER AD account permitted to join computers (default: Administrator).
--computer-name NAME NetBIOS host name; its FQDN becomes NAME.DOMAIN.
--domain-interface IFACE Private NIC connected to the AD network.
--domain-address CIDR Static IPv4 address for --domain-interface, e.g. 192.168.50.12/24.
--allow-group GROUP Restrict Linux sign-in to this AD group after joining.
--enable-ssh Install, enable, and (when active) permit OpenSSH in the local firewall.
--enable-hyperv-enhanced-session
Install and configure XRDP over Hyper-V sockets for VMConnect.
--disable-rustdesk Do not install the managed RustDesk remote-support client.
--rustdesk-registration-share UNC
Override the protected controller SMB enrollment share.
--help Show this help.
Network safety:
--domain-interface and --domain-address must be supplied together. The selected
interface must not own the default route, so the command cannot replace the
Internet route while attaching a private AD NIC.
The AD password is requested interactively by realmd. It is never accepted as an
argument or written to a file, log, or command line.
EOF
}
fail() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
need_command() {
command -v "$1" >/dev/null 2>&1 || fail "Required command is unavailable: $1"
}
packages_are_installed() {
local package_name
for package_name in "$@"; do
dpkg-query -W -f='${db:Status-Status}' "$package_name" 2>/dev/null | grep -Fxq 'installed' \
|| return 1
done
}
apt_get_with_retry() {
local attempt
for attempt in $(seq 1 60); do
if apt-get "$@"; then
return 0
fi
if fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock \
>/dev/null 2>&1; then
printf 'Waiting for another package operation before retrying apt-get %s.\n' "$1" >&2
sleep 5
continue
fi
fail "apt-get $1 failed for a reason other than a temporary package lock."
done
fail 'Timed out waiting for another package operation to finish.'
}
while (($#)); do
case "$1" in
--domain-controller) DOMAIN_CONTROLLER=${2:?Missing value for --domain-controller}; shift 2 ;;
--domain-name) DOMAIN_NAME=${2:?Missing value for --domain-name}; shift 2 ;;
--domain-dns) DOMAIN_DNS=${2:?Missing value for --domain-dns}; shift 2 ;;
--computer-ou) COMPUTER_OU=${2:?Missing value for --computer-ou}; shift 2 ;;
--join-user) JOIN_USER=${2:?Missing value for --join-user}; shift 2 ;;
--computer-name) COMPUTER_NAME=${2:?Missing value for --computer-name}; shift 2 ;;
--domain-interface) DOMAIN_INTERFACE=${2:?Missing value for --domain-interface}; shift 2 ;;
--domain-address) DOMAIN_ADDRESS=${2:?Missing value for --domain-address}; shift 2 ;;
--allow-group) ALLOW_GROUP=${2:?Missing value for --allow-group}; shift 2 ;;
--enable-ssh) ENABLE_SSH=true; shift ;;
--enable-hyperv-enhanced-session) ENABLE_HYPERV_ENHANCED_SESSION=true; shift ;;
--disable-rustdesk) ENABLE_RUSTDESK=false; shift ;;
--rustdesk-registration-share) RUSTDESK_REGISTRATION_SHARE=${2:?Missing value for --rustdesk-registration-share}; shift 2 ;;
--help|-h) usage; exit 0 ;;
*) fail "Unknown argument: $1. Use --help for usage." ;;
esac
done
[[ ${EUID} -eq 0 ]] || fail 'Run this command with sudo or as root.'
[[ -n $DOMAIN_CONTROLLER ]] || fail '--domain-controller is required.'
if [[ -z $DOMAIN_DNS ]]; then
DOMAIN_DNS=$DOMAIN_CONTROLLER
fi
if [[ -n $DOMAIN_INTERFACE || -n $DOMAIN_ADDRESS ]]; then
[[ -n $DOMAIN_INTERFACE && -n $DOMAIN_ADDRESS ]] || \
fail '--domain-interface and --domain-address must be supplied together.'
fi
if [[ -z $COMPUTER_NAME ]]; then
COMPUTER_NAME=$(hostname -s)
fi
COMPUTER_NAME=${COMPUTER_NAME^^}
HOST_FQDN="${COMPUTER_NAME,,}.${DOMAIN_NAME,,}"
install_prerequisites() {
local -a packages=()
if command -v apt-get >/dev/null 2>&1; then
packages=(realmd sssd sssd-tools adcli libnss-sss libpam-sss krb5-user packagekit samba-common-bin)
if [[ $ENABLE_SSH == true ]]; then
packages+=(openssh-server)
fi
if [[ $ENABLE_HYPERV_ENHANCED_SESSION == true ]]; then
packages+=(xrdp xorgxrdp ssl-cert)
# XRDP's Debian post-install script cannot replace a dangling
# certificate symlink left by an interrupted/older installation.
# Remove only dangling links so dpkg can recreate them safely.
local xrdp_link
for xrdp_link in /etc/xrdp/cert.pem /etc/xrdp/key.pem; do
if [[ -L $xrdp_link && ! -e $xrdp_link ]]; then
rm -f -- "$xrdp_link"
fi
done
fi
export DEBIAN_FRONTEND=noninteractive
if ! packages_are_installed "${packages[@]}"; then
apt_get_with_retry update
apt_get_with_retry install -y "${packages[@]}"
fi
# `pam-auth-update` takes the debconf database lock even when its
# profile is already enabled. Avoid that unnecessary package-manager
# dependency on repeat enrollment runs.
if ! grep -Eq '^[[:space:]]*[^#].*pam_mkhomedir\.so' /etc/pam.d/common-session; then
pam-auth-update --enable mkhomedir --force
fi
return
fi
if command -v dnf >/dev/null 2>&1; then
packages=(realmd sssd sssd-tools adcli oddjob oddjob-mkhomedir samba-common-tools krb5-workstation)
if [[ $ENABLE_SSH == true ]]; then
packages+=(openssh-server)
fi
dnf install -y "${packages[@]}"
authselect select sssd with-mkhomedir --force
return
fi
fail 'Supported package managers are apt-get (Debian/Ubuntu) and dnf (RHEL/Fedora/Rocky/AlmaLinux).'
}
configure_private_ad_interface() {
[[ -n $DOMAIN_INTERFACE ]] || return 0
need_command nmcli
ip link show "$DOMAIN_INTERFACE" >/dev/null 2>&1 || \
fail "Network interface does not exist: $DOMAIN_INTERFACE"
local default_interface
default_interface=$(ip route show default | awk 'NR == 1 { print $5 }')
if [[ $default_interface == "$DOMAIN_INTERFACE" ]]; then
fail "Refusing to reconfigure $DOMAIN_INTERFACE because it owns the default route. Use the private AD NIC."
fi
local connection_name="SGU-Lab-AD-${DOMAIN_INTERFACE}"
if ! nmcli -t -f NAME connection show | grep -Fxq "$connection_name"; then
nmcli connection add type ethernet ifname "$DOMAIN_INTERFACE" con-name "$connection_name"
fi
nmcli connection modify "$connection_name" \
connection.autoconnect yes \
ipv4.method manual \
ipv4.addresses "$DOMAIN_ADDRESS" \
ipv4.dns "$DOMAIN_DNS" \
ipv4.dns-search "$DOMAIN_NAME" \
ipv4.never-default yes \
ipv6.method ignore
nmcli connection up "$connection_name"
}
enable_sssd_dyndns() {
[[ -n $DOMAIN_INTERFACE ]] || return 0
local configuration_directory='/etc/sssd/conf.d'
local configuration_path="${configuration_directory}/90-sgu-dyndns.conf"
local temporary_path
temporary_path=$(mktemp)
printf '%s\n' \
"[domain/${DOMAIN_NAME,,}]" \
"ad_hostname = ${HOST_FQDN}" \
'dyndns_update = True' \
'dyndns_update_ptr = True' \
"dyndns_iface = ${DOMAIN_INTERFACE}" \
'dyndns_refresh_interval = 43200' >"$temporary_path"
install -d -o root -g root -m 700 "$configuration_directory"
install -o root -g root -m 600 "$temporary_path" "$configuration_path"
rm -f "$temporary_path"
}
enable_short_domain_login_names() {
local configuration_path='/etc/sssd/sssd.conf'
[[ -f $configuration_path ]] || return 0
# Institutional account names (AL/AD/DO) are unique in this lab and are
# the identifiers users already know. Keep UPN logins valid while also
# allowing the short form in PAM applications such as XRDP/VMConnect.
if grep -Eq '^[[:space:]]*use_fully_qualified_names[[:space:]]*=' "$configuration_path"; then
sed -Ei 's/^[[:space:]]*use_fully_qualified_names[[:space:]]*=.*/use_fully_qualified_names = False/' \
"$configuration_path"
else
sed -Ei "/^\[domain\/${DOMAIN_NAME//./\\.}\]$/a use_fully_qualified_names = False" \
"$configuration_path"
fi
chmod 600 "$configuration_path"
}
configure_sssd_responder_mode() {
local configuration_path='/etc/sssd/sssd.conf'
[[ -f $configuration_path ]] || return 0
# realmd writes a persistent responder list, while recent Debian-family
# packages can enable the same NSS/PAM responders through systemd sockets.
# Running both modes makes the sockets fail at boot and can leave graphical
# PAM clients unable to contact SSSD reliably. Keep realmd's persistent
# responders and disable only the duplicate socket units when they exist.
local unit
for unit in sssd-nss.socket sssd-pam.socket sssd-pam-priv.socket; do
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q "^${unit}"; then
systemctl disable --now "$unit" >/dev/null 2>&1 || true
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
fi
done
}
configure_graphical_domain_login() {
local sssd_configuration_directory='/etc/sssd/conf.d'
local temporary_sssd_configuration
local interactive_services='+lightdm,+cinnamon-screensaver'
if [[ $ENABLE_HYPERV_ENHANCED_SESSION == true ]]; then
interactive_services+=',+xrdp-sesman'
fi
temporary_sssd_configuration=$(mktemp)
printf '%s\n' \
"[domain/${DOMAIN_NAME,,}]" \
"ad_gpo_map_interactive = ${interactive_services}" >"$temporary_sssd_configuration"
install -d -o root -g root -m 700 "$sssd_configuration_directory"
install -o root -g root -m 600 "$temporary_sssd_configuration" \
"${sssd_configuration_directory}/91-sgu-graphical-login.conf"
rm -f "$temporary_sssd_configuration"
rm -f "${sssd_configuration_directory}/91-sgu-xrdp.conf"
# Do not disclose a list of local/domain accounts at the console. Slick
# Greeter still provides the explicit manual prompt needed for a first AD
# sign-in (AL/AD/DO identifier and password).
if [[ -d /etc/lightdm/lightdm.conf.d ]]; then
local temporary_lightdm_configuration
temporary_lightdm_configuration=$(mktemp)
printf '%s\n' \
'[Seat:*]' \
'greeter-show-manual-login=true' \
'greeter-hide-users=true' >"$temporary_lightdm_configuration"
install -o root -g root -m 644 "$temporary_lightdm_configuration" \
'/etc/lightdm/lightdm.conf.d/91-sgu-domain-login.conf'
rm -f "$temporary_lightdm_configuration"
fi
}
enable_ssh() {
[[ $ENABLE_SSH == true ]] || return 0
local service_name='sshd'
if systemctl list-unit-files ssh.service >/dev/null 2>&1; then
service_name='ssh'
fi
systemctl enable --now "$service_name"
if command -v ufw >/dev/null 2>&1 && ufw status | grep -q '^Status: active'; then
ufw allow OpenSSH
elif command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld; then
firewall-cmd --permanent --add-service=ssh
firewall-cmd --reload
fi
}
configure_hyperv_enhanced_session() {
[[ $ENABLE_HYPERV_ENHANCED_SESSION == true ]] || return 0
command -v xrdp >/dev/null 2>&1 || {
printf 'WARNING: XRDP is unavailable; Hyper-V Enhanced Session was not enabled.\n' >&2
return 0
}
local xrdp_configuration='/etc/xrdp/xrdp.ini'
[[ -f $xrdp_configuration ]] || {
printf 'WARNING: %s is missing; Hyper-V Enhanced Session was not enabled.\n' "$xrdp_configuration" >&2
return 0
}
# VMConnect uses AF_VSOCK rather than TCP. Only change the first occurrence,
# which belongs to [Globals]; later port entries describe XRDP backends.
sed -Ei '0,/^port=.*/s|^port=.*|port=vsock://-1:3389|' "$xrdp_configuration"
if grep -q '^use_vsock=' "$xrdp_configuration"; then
sed -Ei '0,/^use_vsock=.*/s|^use_vsock=.*|use_vsock=true|' "$xrdp_configuration"
else
sed -Ei '/^port=vsock:\/\/-1:3389/a use_vsock=true' "$xrdp_configuration"
fi
sed -Ei '0,/^security_layer=.*/s|^security_layer=.*|security_layer=rdp|' "$xrdp_configuration"
sed -Ei '0,/^crypt_level=.*/s|^crypt_level=.*|crypt_level=none|' "$xrdp_configuration"
# A clean Ubuntu installation can contain XRDP symlinks before the
# snake-oil certificate has actually been generated.
if [[ ! -s /etc/ssl/certs/ssl-cert-snakeoil.pem || \
! -s /etc/ssl/private/ssl-cert-snakeoil.key ]]; then
if command -v make-ssl-cert >/dev/null 2>&1; then
make-ssl-cert generate-default-snakeoil --force-overwrite
else
printf 'WARNING: make-ssl-cert is unavailable; XRDP certificate generation was skipped.\n' >&2
fi
fi
usermod -aG ssl-cert xrdp
# xrdp-sesman (root) and xrdp (the xrdp account) share /run/xrdp. Give the
# directory the shared group/mode so the second service can create its PID
# file instead of timing out while VMConnect remains at "Connecting".
local override_directory='/etc/systemd/system/xrdp-sesman.service.d'
local temporary_override
temporary_override=$(mktemp)
printf '%s\n' \
'[Service]' \
'Group=xrdp' \
'RuntimeDirectory=xrdp' \
'RuntimeDirectoryMode=0775' >"$temporary_override"
install -d -o root -g root -m 755 "$override_directory"
install -o root -g root -m 644 "$temporary_override" \
"${override_directory}/sgu-runtime.conf"
rm -f "$temporary_override"
systemctl daemon-reload
systemctl enable xrdp xrdp-sesman
systemctl restart xrdp
systemctl is-active --quiet xrdp
systemctl is-active --quiet xrdp-sesman
}
install_welcome_wallpaper() {
local source_directory="${SCRIPT_DIRECTORY}/welcome-wallpaper"
local source_script="${source_directory}/Set-SguWelcomeWallpaper.sh"
local source_image="${source_directory}/darkblue.jpg"
local install_directory='/usr/local/lib/sgu-welcome-wallpaper'
local configuration_directory='/etc/sgu'
local autostart_directory='/etc/xdg/autostart'
if [[ ! -r $source_script || ! -r $source_image ]]; then
printf 'WARNING: Welcome wallpaper assets are absent; domain enrollment will continue without desktop branding.\n' >&2
return 0
fi
# Desktop branding is optional and must never invalidate an otherwise valid
# domain join. Install its distribution-specific dependencies best-effort.
if command -v apt-get >/dev/null 2>&1; then
if ! apt_get_with_retry install -y imagemagick ldap-utils fontconfig; then
printf 'WARNING: Could not install welcome wallpaper dependencies; enrollment remains valid.\n' >&2
return 0
fi
elif command -v dnf >/dev/null 2>&1; then
if ! dnf install -y ImageMagick openldap-clients fontconfig; then
printf 'WARNING: Could not install welcome wallpaper dependencies; enrollment remains valid.\n' >&2
return 0
fi
fi
install -d -o root -g root -m 755 "$install_directory" "$configuration_directory" "$autostart_directory"
install -o root -g root -m 755 "$source_script" "${install_directory}/Set-SguWelcomeWallpaper.sh"
install -o root -g root -m 644 "$source_image" "${install_directory}/darkblue.jpg"
if compgen -G "${source_directory}/fonts/*.[ot]tf" >/dev/null; then
install -d -o root -g root -m 755 "${install_directory}/fonts"
install -o root -g root -m 644 "${source_directory}"/fonts/*.[ot]tf "${install_directory}/fonts/"
fi
local base_dn=''
local component
IFS='.' read -ra domain_components <<<"$DOMAIN_NAME"
for component in "${domain_components[@]}"; do
if [[ -n $base_dn ]]; then
base_dn+=','
fi
base_dn+="DC=${component}"
done
local temporary_configuration
temporary_configuration=$(mktemp)
printf 'DOMAIN_CONTROLLER=%q\nDOMAIN_NAME=%q\nBASE_DN=%q\n' \
"$DOMAIN_CONTROLLER" "$DOMAIN_NAME" "$base_dn" >"$temporary_configuration"
install -o root -g root -m 644 "$temporary_configuration" \
"${configuration_directory}/welcome-wallpaper.conf"
rm -f "$temporary_configuration"
local temporary_autostart
temporary_autostart=$(mktemp)
cat >"$temporary_autostart" <<'EOF'
[Desktop Entry]
Type=Application
Name=SGU welcome wallpaper
Comment=Generate a personalized La Salle laboratory welcome wallpaper
Exec=/usr/local/lib/sgu-welcome-wallpaper/Set-SguWelcomeWallpaper.sh
Terminal=false
NoDisplay=true
X-GNOME-Autostart-enabled=true
X-Cinnamon-Autostart-enabled=true
EOF
install -o root -g root -m 644 "$temporary_autostart" \
"${autostart_directory}/sgu-welcome-wallpaper.desktop"
rm -f "$temporary_autostart"
}
install_managed_rustdesk() {
[[ $ENABLE_RUSTDESK == true ]] || return 0
local installer="${SCRIPT_DIRECTORY}/Install-SguLinuxRustDeskClient.sh"
if [[ ! -r $installer ]]; then
fail 'The managed Linux RustDesk installer is missing from this bootstrap package.'
fi
local -a parameters=(--domain-name "$DOMAIN_NAME")
if [[ -n $RUSTDESK_REGISTRATION_SHARE ]]; then
parameters+=(--registration-share "$RUSTDESK_REGISTRATION_SHARE")
fi
bash "$installer" "${parameters[@]}"
}
verify_domain_connectivity() {
need_command getent
getent ahostsv4 "$DOMAIN_CONTROLLER" >/dev/null || \
fail "Could not resolve the domain controller: $DOMAIN_CONTROLLER"
if command -v resolvectl >/dev/null 2>&1; then
resolvectl query --type=SRV "_ldap._tcp.dc._msdcs.${DOMAIN_NAME}" >/dev/null || \
fail "AD DNS does not provide _ldap._tcp.dc._msdcs.${DOMAIN_NAME}."
fi
}
configure_private_ad_interface
install_prerequisites
verify_domain_connectivity
# Establish a canonical host name before adcli creates or refreshes the
# computer object, SPNs, and keytab entries.
hostnamectl set-hostname "$HOST_FQDN"
if realm list --name-only 2>/dev/null | grep -Fxqi "$DOMAIN_NAME"; then
printf 'Computer is already joined to %s; validating and refreshing configuration.\n' "$DOMAIN_NAME"
else
realm discover "$DOMAIN_NAME" >/dev/null
printf 'Joining %s. realmd will request the password for %s interactively.\n' "$DOMAIN_NAME" "$JOIN_USER"
realm join \
--membership-software=adcli \
--client-software=sssd \
--computer-ou="$COMPUTER_OU" \
--user="$JOIN_USER" \
"$DOMAIN_NAME"
fi
enable_sssd_dyndns
enable_short_domain_login_names
configure_sssd_responder_mode
configure_graphical_domain_login
systemctl enable --now sssd
sssctl config-check
systemctl restart sssd
adcli update --domain="$DOMAIN_NAME" --host-fqdn="$HOST_FQDN" --computer-name="$COMPUTER_NAME"
adcli testjoin --domain="$DOMAIN_NAME"
if [[ -n $ALLOW_GROUP ]]; then
realm deny --all
realm permit --groups "$ALLOW_GROUP"
fi
enable_ssh
configure_hyperv_enhanced_session
install_welcome_wallpaper
install_managed_rustdesk
printf '\nLinux enrollment completed.\n'
printf ' Host: %s\n' "$HOST_FQDN"
printf ' Domain: %s\n' "$DOMAIN_NAME"
printf ' OU: %s\n' "$COMPUTER_OU"
printf ' Login format: %%U@%s\n' "$DOMAIN_NAME"
printf ' Welcome wallpaper: generated at each graphical sign-in when the desktop is supported.\n'
if [[ $ENABLE_RUSTDESK == true ]]; then
printf ' RustDesk: configured and registered in the controller inventory.\n'
fi
realm list
+48
View File
@@ -0,0 +1,48 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][string]$SubscriptionId,
[Parameter(Mandatory)][string]$ResourceGroupName,
[Parameter(Mandatory)][string]$VpnGatewayName,
[string]$OutputPath = (Join-Path $PSScriptRoot '..\artifacts\azure-p2s\sgu-azure-vpn-client.zip')
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
throw 'Azure CLI is required.'
}
& az account set --subscription $SubscriptionId --only-show-errors
if ($LASTEXITCODE -ne 0) {
throw "Could not select Azure subscription $SubscriptionId."
}
if ($PSCmdlet.ShouldProcess($OutputPath, 'Generate and download the Azure P2S client package')) {
$downloadUriText = & az network vnet-gateway vpn-client generate `
--resource-group $ResourceGroupName `
--name $VpnGatewayName `
--processor-architecture Amd64 `
--authentication-method EAPTLS `
--only-show-errors `
--output tsv
$downloadUriText = ($downloadUriText -join '').Trim()
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($downloadUriText)) {
throw 'Azure did not generate a P2S client package URL.'
}
$downloadUri = $null
if (-not [uri]::TryCreate($downloadUriText, [UriKind]::Absolute, [ref]$downloadUri) -or
$downloadUri.Scheme -ne 'https') {
throw 'Azure returned an invalid VPN client package URL.'
}
$resolvedOutputPath = [IO.Path]::GetFullPath($OutputPath)
New-Item -ItemType Directory -Path (Split-Path $resolvedOutputPath -Parent) -Force | Out-Null
Invoke-WebRequest -Uri $downloadUri -OutFile $resolvedOutputPath -UseBasicParsing
if ((Get-Item -LiteralPath $resolvedOutputPath).Length -lt 1024) {
throw 'The downloaded VPN client package is unexpectedly small.'
}
[pscustomobject]@{
PackagePath = $resolvedOutputPath
Sha256 = (Get-FileHash -LiteralPath $resolvedOutputPath -Algorithm SHA256).Hash
VpnGatewayName = $VpnGatewayName
}
}
+82
View File
@@ -0,0 +1,82 @@
#Requires -Version 5.1
[CmdletBinding()]
param(
[datetime]$Since = (Get-Date).AddDays(-183),
[datetime]$Until = (Get-Date),
[string]$UserName,
[ValidateSet('Critical','Error','Warning','Information','Verbose')]
[string]$Level,
[int[]]$EventId,
[string]$Text,
[string]$MonitoringRoot = 'C:\ProgramData\SGU\Monitoring',
[string]$BrokerEventLogName = 'SGU Auth Broker',
[string]$OutputCsv
)
$ErrorActionPreference = 'Stop'
$events = [Collections.Generic.List[object]]::new()
$eventNames = @{
900 = 'BrokerStarted'
1000 = 'AuthenticationAuthorized'
1001 = 'AuthenticationRejected'
1002 = 'AuthenticationUnavailable'
1003 = 'AuthenticationInvalidRequest'
1100 = 'SguAuthenticationAccepted'
1101 = 'SguAuthenticationTimeout'
1102 = 'SguAuthenticationNetworkFailure'
1200 = 'ProfileEnrichmentCompleted'
1201 = 'ProfileHtmlUnexpected'
1202 = 'ProfileEnrichmentTimeout'
1203 = 'ProfileEnrichmentFailure'
1204 = 'ProfilePageUnavailable'
1300 = 'DirectorySynchronizationFailure'
1301 = 'DirectoryOptionalMetadataFailure'
1302 = 'DirectoryGroupMembershipFailure'
1303 = 'DirectoryRoleGroupMembershipAdded'
}
# Keep these reads unfiltered. Besides making archived and current logs behave
# identically, this avoids the Windows Server 2025 ForwardedEvents query defect.
if (Get-WinEvent -ListLog $BrokerEventLogName -ErrorAction SilentlyContinue) {
Get-WinEvent -LogName $BrokerEventLogName -ErrorAction SilentlyContinue |
Where-Object { $_.TimeCreated -ge $Since -and $_.TimeCreated -le $Until } |
ForEach-Object { $events.Add($_) }
}
$brokerArchiveRoot = Join-Path $MonitoringRoot 'Archive\Broker'
Get-ChildItem -LiteralPath $brokerArchiveRoot -Filter '*.evtx' -File -ErrorAction SilentlyContinue |
Where-Object LastWriteTime -ge $Since.AddDays(-1) |
ForEach-Object {
try {
Get-WinEvent -Path $_.FullName -Oldest -ErrorAction Stop |
Where-Object { $_.TimeCreated -ge $Since -and $_.TimeCreated -le $Until } |
ForEach-Object { $events.Add($_) }
}
catch {
Write-Warning "Could not read broker archive $($_.FullName): $($_.Exception.Message)"
}
}
$result = @($events | Where-Object {
(-not $UserName -or $_.Message -like "*$UserName*") -and
(-not $Level -or $_.LevelDisplayName -eq $Level) -and
(-not $EventId -or $_.Id -in $EventId) -and
(-not $Text -or $_.Message -like "*$Text*")
} | Sort-Object TimeCreated -Descending | ForEach-Object {
[pscustomobject]@{
TimeCreated = $_.TimeCreated
Level = $_.LevelDisplayName
EventId = $_.Id
EventName = $eventNames[[int]$_.Id]
Provider = $_.ProviderName
Message = $_.Message
}
})
if ($OutputCsv) {
$resolvedOutput = [IO.Path]::GetFullPath($OutputCsv)
New-Item -ItemType Directory -Path (Split-Path $resolvedOutput -Parent) -Force | Out-Null
$result | Export-Csv -LiteralPath $resolvedOutput -NoTypeInformation -Encoding UTF8
}
$result
+71
View File
@@ -0,0 +1,71 @@
[CmdletBinding()]
param(
[string]$ComputerName,
[switch]$RevealPassword,
[string]$InventoryRoot = "$env:ProgramData\SGU\RustDesk\Devices"
)
$ErrorActionPreference = 'Stop'
function Assert-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Only a local administrator can read the RustDesk device inventory.'
}
}
function Initialize-DataProtection {
if (-not ('SguRustDeskDataProtection' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
public static class SguRustDeskDataProtection {
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct DataBlob { public int cbData; public IntPtr pbData; }
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CryptProtectData(ref DataBlob input, string description, IntPtr entropy, IntPtr reserved, IntPtr prompt, int flags, out DataBlob output);
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CryptUnprotectData(ref DataBlob input, IntPtr description, IntPtr entropy, IntPtr reserved, IntPtr prompt, int flags, out DataBlob output);
[DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr LocalFree(IntPtr memory);
private const int CryptProtectLocalMachine = 0x4;
private static DataBlob ToBlob(byte[] value) { var blob = new DataBlob { cbData = value.Length, pbData = IntPtr.Zero }; if (value.Length > 0) { blob.pbData = Marshal.AllocHGlobal(value.Length); Marshal.Copy(value, 0, blob.pbData, value.Length); } return blob; }
private static byte[] FromBlob(DataBlob blob) { var value = new byte[blob.cbData]; if (blob.cbData > 0) Marshal.Copy(blob.pbData, value, 0, blob.cbData); return value; }
public static byte[] Protect(byte[] value) { var input = ToBlob(value); var output = new DataBlob(); try { if (!CryptProtectData(ref input, null, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, CryptProtectLocalMachine, out output)) throw new Win32Exception(Marshal.GetLastWin32Error()); return FromBlob(output); } finally { if (input.pbData != IntPtr.Zero) Marshal.FreeHGlobal(input.pbData); if (output.pbData != IntPtr.Zero) LocalFree(output.pbData); } }
public static byte[] Unprotect(byte[] value) { var input = ToBlob(value); var output = new DataBlob(); try { if (!CryptUnprotectData(ref input, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, 0, out output)) throw new Win32Exception(Marshal.GetLastWin32Error()); return FromBlob(output); } finally { if (input.pbData != IntPtr.Zero) Marshal.FreeHGlobal(input.pbData); if (output.pbData != IntPtr.Zero) LocalFree(output.pbData); } }
}
'@ -ErrorAction Stop
}
}
Assert-Administrator
Initialize-DataProtection
if (-not (Test-Path -LiteralPath $InventoryRoot -PathType Container)) {
return @()
}
$entries = @(Get-ChildItem -LiteralPath $InventoryRoot -Filter '*.json' -File |
ForEach-Object {
$metadata = Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json
if ($ComputerName -and -not $metadata.ComputerName.Equals($ComputerName, [StringComparison]::OrdinalIgnoreCase)) {
return
}
$result = [ordered]@{
ComputerName = [string]$metadata.ComputerName
RustDeskId = [string]$metadata.RustDeskId
RegisteredAt = [datetime]$metadata.RegisteredAt
}
if ($RevealPassword) {
$secretPath = [string]$metadata.SecretPath
if (-not (Test-Path -LiteralPath $secretPath -PathType Leaf)) {
throw "The protected RustDesk credential for $($metadata.ComputerName) is missing."
}
$result.AccessPassword = [Text.Encoding]::UTF8.GetString(
[SguRustDeskDataProtection]::Unprotect(
[IO.File]::ReadAllBytes($secretPath)))
}
[pscustomobject]$result
})
$entries | Sort-Object ComputerName
+176
View File
@@ -0,0 +1,176 @@
#Requires -Version 5.1
[CmdletBinding()]
param(
[datetime]$Since = (Get-Date).AddDays(-183),
[datetime]$Until = (Get-Date),
[string]$UserName,
[string]$ComputerName,
[string]$MonitoringRoot = 'C:\ProgramData\SGU\Monitoring',
[string]$OutputCsv
)
$ErrorActionPreference = 'Stop'
$eventIds = @(4624,4625,4634,4647,4778,4779,6005,6006,6008)
$events = [Collections.Generic.List[object]]::new()
try {
# Windows Server 2025 can crash the Windows Event Log service when a
# structured query is evaluated against ForwardedEvents (wevtsvc.dll,
# exception 0xc0000420). Read the channel without a server-side query and
# apply every predicate in this process instead.
Get-WinEvent -LogName 'ForwardedEvents' -ErrorAction Stop |
Where-Object {
$_.Id -in $eventIds -and
$_.TimeCreated -ge $Since -and
$_.TimeCreated -le $Until
} |
ForEach-Object { $events.Add($_) }
}
catch [System.Exception] {
if ($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*') {
Write-Verbose $_.Exception.Message
}
}
$archiveRoot = Join-Path $MonitoringRoot 'Archive'
Get-ChildItem -LiteralPath $archiveRoot -Filter '*.evtx' -File -ErrorAction SilentlyContinue |
Where-Object LastWriteTime -ge $Since.AddDays(-1) |
ForEach-Object {
try {
Get-WinEvent -Path $_.FullName -Oldest -ErrorAction Stop |
Where-Object { $_.Id -in $eventIds -and $_.TimeCreated -ge $Since -and $_.TimeCreated -le $Until } |
ForEach-Object { $events.Add($_) }
}
catch {
Write-Warning "Could not read archive $($_.FullName): $($_.Exception.Message)"
}
}
function Get-EventData {
param([Parameter(Mandatory)]$EventRecord)
$xml = [xml]$EventRecord.ToXml()
$data = @{}
foreach ($item in @($xml.Event.EventData.Data)) {
if ($item.Name) {
$data[[string]$item.Name] = [string]$item.'#text'
}
}
[pscustomobject]@{
Computer = [string]$xml.Event.System.Computer
Data = $data
}
}
$openSessions = @{}
$rows = [Collections.Generic.List[object]]::new()
$ignoredUsers = @('ANONYMOUS LOGON','DWM-1','DWM-2','DWM-3','LOCAL SERVICE','NETWORK SERVICE','SYSTEM','UMFD-0','UMFD-1','UMFD-2','UMFD-3')
foreach ($eventRecord in @($events | Sort-Object TimeCreated,RecordId)) {
$parsed = Get-EventData -EventRecord $eventRecord
$machine = ($parsed.Computer -split '\.')[0].ToUpperInvariant()
$data = $parsed.Data
if ($eventRecord.Id -in 6005,6006,6008) {
foreach ($key in @($openSessions.Keys | Where-Object { $_ -like "$machine|*" })) {
$session = $openSessions[$key]
$rows.Add([pscustomobject]@{
User = $session.User
Computer = $machine
StartedAt = $session.StartedAt
EndedAt = $eventRecord.TimeCreated
Duration = $eventRecord.TimeCreated - $session.StartedAt
DurationMinutes = [math]::Round(($eventRecord.TimeCreated - $session.StartedAt).TotalMinutes, 2)
LogonType = $session.LogonType
Result = 'Interrumpida por apagado o reinicio'
FailureStatus = $null
})
$openSessions.Remove($key)
}
continue
}
if ($eventRecord.Id -eq 4625) {
$failedUser = [string]$data.TargetUserName
if ($failedUser -and $failedUser -notlike '*$' -and $failedUser.ToUpperInvariant() -notin $ignoredUsers) {
$rows.Add([pscustomobject]@{
User = if ($data.TargetDomainName) { "$($data.TargetDomainName)\$failedUser" } else { $failedUser }
Computer = $machine
StartedAt = $eventRecord.TimeCreated
EndedAt = $eventRecord.TimeCreated
Duration = [timespan]::Zero
DurationMinutes = 0
LogonType = [string]$data.LogonType
Result = 'Fallida'
FailureStatus = "$($data.Status)/$($data.SubStatus)"
})
}
continue
}
if ($eventRecord.Id -eq 4624) {
$logonType = [string]$data.LogonType
$targetUser = [string]$data.TargetUserName
if ($logonType -notin @('2','10','11') -or -not $targetUser -or $targetUser -like '*$' -or
$targetUser.ToUpperInvariant() -in $ignoredUsers) {
continue
}
$logonId = [string]$data.TargetLogonId
$key = "$machine|$logonId"
$openSessions[$key] = [pscustomobject]@{
User = if ($data.TargetDomainName) { "$($data.TargetDomainName)\$targetUser" } else { $targetUser }
StartedAt = $eventRecord.TimeCreated
LogonType = $logonType
}
continue
}
if ($eventRecord.Id -in 4634,4647) {
$logonId = if ($eventRecord.Id -eq 4634) { [string]$data.TargetLogonId } else { [string]$data.SubjectLogonId }
$key = "$machine|$logonId"
if ($openSessions.ContainsKey($key)) {
$session = $openSessions[$key]
$rows.Add([pscustomobject]@{
User = $session.User
Computer = $machine
StartedAt = $session.StartedAt
EndedAt = $eventRecord.TimeCreated
Duration = $eventRecord.TimeCreated - $session.StartedAt
DurationMinutes = [math]::Round(($eventRecord.TimeCreated - $session.StartedAt).TotalMinutes, 2)
LogonType = $session.LogonType
Result = 'Completada'
FailureStatus = $null
})
$openSessions.Remove($key)
}
}
}
foreach ($key in $openSessions.Keys) {
$session = $openSessions[$key]
$machine = ($key -split '\|', 2)[0]
$rows.Add([pscustomobject]@{
User = $session.User
Computer = $machine
StartedAt = $session.StartedAt
EndedAt = $null
Duration = $Until - $session.StartedAt
DurationMinutes = [math]::Round(($Until - $session.StartedAt).TotalMinutes, 2)
LogonType = $session.LogonType
Result = 'Sesión posiblemente activa'
FailureStatus = $null
})
}
$result = @($rows | Where-Object {
(-not $UserName -or $_.User -like "*$UserName*") -and
(-not $ComputerName -or $_.Computer -like "*$ComputerName*")
} | Sort-Object StartedAt -Descending)
if ($OutputCsv) {
$resolvedOutput = [IO.Path]::GetFullPath($OutputCsv)
New-Item -ItemType Directory -Path (Split-Path $resolvedOutput -Parent) -Force | Out-Null
$result | Export-Csv -LiteralPath $resolvedOutput -NoTypeInformation -Encoding UTF8
}
$result
+433 -22
View File
@@ -6,10 +6,15 @@ param(
[int]$PrefixLength = 24,
[string]$NetworkInterfaceAlias,
[ipaddress]$DefaultGateway,
[ValidateSet('GuestStatic', 'PlatformManaged')]
[string]$NetworkConfigurationMode = 'GuestStatic',
[string[]]$TrustedClientNetworks = @(),
[string[]]$PublicEnrollmentNetworks = @(),
[ipaddress[]]$DnsForwarders = @(),
[string]$DomainName = 'lci.lasalle.mx',
[string]$DomainNetbios = 'LCI',
[string]$BrokerRecordName = 'sgu-auth',
[string]$RustDeskRecordName = 'rustdesk',
[string]$PackageSharePath = 'C:\Packages',
[securestring]$SafeModeAdministratorPassword,
[switch]$SkipRestart,
@@ -68,6 +73,135 @@ 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 ConvertTo-PublicNetworkCidr {
param([Parameter(Mandatory)][string]$Cidr)
if ($Cidr -notmatch '^([^/]+)/(\d{1,2})$') {
throw "Public enrollment network '$Cidr' must use IPv4 CIDR notation, for example 203.0.113.0/24."
}
$address = $null
if (-not [ipaddress]::TryParse($Matches[1], [ref]$address) -or
$address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
throw "Public enrollment network '$Cidr' is not a valid IPv4 network."
}
$networkPrefixLength = [int]$Matches[2]
if ($networkPrefixLength -lt 1 -or $networkPrefixLength -gt 32) {
throw "Public enrollment network '$Cidr' has an invalid prefix length."
}
if (Test-PrivateIPv4Address -Address $address) {
throw "Public enrollment network '$Cidr' is private RFC1918 space. Use -TrustedClientNetworks for LAN or VPN ranges."
}
$bytes = $address.GetAddressBytes()
if ($bytes[0] -in @(0, 127) -or
($bytes[0] -eq 169 -and $bytes[1] -eq 254) -or
$bytes[0] -ge 224) {
throw "Public enrollment network '$Cidr' is not usable unicast IPv4 space."
}
return ConvertTo-NetworkCidr -Address $address -NetworkPrefixLength $networkPrefixLength
}
function Set-SguPublicEnrollmentFirewall {
param(
[Parameter(Mandatory)][ipaddress]$LocalAddress,
[Parameter(Mandatory)][string[]]$RemoteAddress
)
if ($RemoteAddress.Count -eq 0) { return }
$definitions = @(
@{ Name = 'SGU Public Enrollment TCP'; Protocol = 'TCP';
Port = @('53','88','135','389','443','445','464','636','3268','3269','5985','8443','21115-21117','49152-65535') },
@{ Name = 'SGU Public Enrollment UDP'; Protocol = 'UDP';
Port = @('53','88','123','389','464','21116') }
)
foreach ($definition in $definitions) {
$rule = Get-NetFirewallRule -DisplayName $definition.Name -ErrorAction SilentlyContinue
if (-not $rule) {
New-NetFirewallRule -DisplayName $definition.Name -Direction Inbound -Action Allow `
-Protocol $definition.Protocol -LocalPort $definition.Port `
-LocalAddress $LocalAddress.IPAddressToString -RemoteAddress $RemoteAddress `
-Profile Any | Out-Null
}
else {
$rule | Set-NetFirewallRule -Enabled True -Action Allow -Profile Any | Out-Null
$rule | Get-NetFirewallPortFilter | Set-NetFirewallPortFilter `
-Protocol $definition.Protocol -LocalPort $definition.Port | Out-Null
$rule | Get-NetFirewallAddressFilter | Set-NetFirewallAddressFilter `
-LocalAddress $LocalAddress.IPAddressToString -RemoteAddress $RemoteAddress | Out-Null
}
}
}
function Get-ActiveIPv4Adapters {
# Accelerated Networking exposes an Up VF without an IP stack. Configure
# the synthetic adapter that owns IPv4, never the underlying VF.
Get-NetAdapter | Where-Object {
$_.Status -eq 'Up' -and
(Get-NetIPInterface -InterfaceIndex $_.ifIndex -AddressFamily IPv4 `
-ErrorAction SilentlyContinue | Where-Object ConnectionState -eq 'Connected')
}
}
function Resolve-PrivateInterfaceAlias {
param([string]$RequestedAlias)
@@ -76,7 +210,7 @@ function Resolve-PrivateInterfaceAlias {
return $RequestedAlias
}
$upAdapters = @(Get-NetAdapter | Where-Object Status -eq 'Up')
$upAdapters = @(Get-ActiveIPv4Adapters)
$withoutGateway = @($upAdapters | Where-Object {
-not (Get-NetIPConfiguration -InterfaceIndex $_.ifIndex).IPv4DefaultGateway
})
@@ -146,6 +280,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)
@@ -171,8 +326,10 @@ function Ensure-OrganizationalUnit {
)
$distinguishedName = "OU=$Name,$Path"
$existing = Get-ADOrganizationalUnit -Identity $distinguishedName -Server $Server `
-ErrorAction SilentlyContinue
$escapedName = $Name.Replace('\', '\5c').Replace('*', '\2a').Replace('(', '\28').Replace(')', '\29')
$existing = Get-ADOrganizationalUnit -LDAPFilter "(ou=$escapedName)" `
-SearchBase $Path -SearchScope OneLevel -Server $Server -ErrorAction Stop |
Select-Object -First 1
if (-not $existing) {
New-ADOrganizationalUnit -Name $Name -Path $Path `
-ProtectedFromAccidentalDeletion $true -Server $Server | Out-Null
@@ -180,6 +337,57 @@ function Ensure-OrganizationalUnit {
return $distinguishedName
}
function Wait-ActiveDirectoryReady {
param(
[Parameter(Mandatory)][string]$ExpectedBaseDn,
[ValidateRange(1, 120)][int]$Attempts = 36,
[ValidateRange(1, 30)][int]$DelaySeconds = 5
)
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
try {
$rootDse = Get-ADRootDSE -Server localhost -ErrorAction Stop
if ($rootDse.DefaultNamingContext -eq $ExpectedBaseDn) {
return
}
}
catch {
if ($attempt -eq $Attempts) {
throw
}
}
Start-Sleep -Seconds $DelaySeconds
}
throw "Active Directory did not publish $ExpectedBaseDn before the readiness timeout."
}
function Wait-DnsZoneReady {
param(
[Parameter(Mandatory)][string]$ZoneName,
[Parameter(Mandatory)][ipaddress]$DnsServer,
[ValidateRange(1, 120)][int]$Attempts = 30,
[ValidateRange(1, 30)][int]$DelaySeconds = 2
)
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
try {
$soa = @(Resolve-DnsName $ZoneName -Type SOA -DnsOnly `
-Server $DnsServer.IPAddressToString -ErrorAction Stop |
Where-Object Type -eq SOA)
if ($soa.Count -gt 0) {
return
}
}
catch {
if ($attempt -eq $Attempts) {
throw
}
}
Start-Sleep -Seconds $DelaySeconds
}
throw "DNS did not load the $ZoneName zone before the readiness timeout."
}
function Set-PackageShare {
param(
[Parameter(Mandatory)][string]$Path,
@@ -234,6 +442,10 @@ function Set-PackageShare {
}
Assert-Administrator
trap {
Write-BootstrapLog ("ERROR: " + $_.Exception.Message)
throw
}
$operatingSystem = Get-CimInstance Win32_OperatingSystem
if ([int]$operatingSystem.ProductType -eq 1) {
throw 'The domain controller bootstrap requires Windows Server, not a Windows client edition.'
@@ -252,16 +464,33 @@ 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 { @() }
$PublicEnrollmentNetworks = if ($existingState.PublicEnrollmentNetworks) { @($existingState.PublicEnrollmentNetworks | ForEach-Object { [string]$_ }) } else { @() }
$DnsForwarders = @($existingState.DnsForwarders | ForEach-Object { [ipaddress][string]$_ })
$DomainName = [string]$existingState.DomainName
$DomainNetbios = [string]$existingState.DomainNetbios
$BrokerRecordName = [string]$existingState.BrokerRecordName
$RustDeskRecordName = if ($existingState.RustDeskRecordName) { [string]$existingState.RustDeskRecordName } else { $RustDeskRecordName }
$PackageSharePath = [string]$existingState.PackageSharePath
}
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)
$PublicEnrollmentNetworks = @($PublicEnrollmentNetworks |
ForEach-Object { ConvertTo-PublicNetworkCidr -Cidr $_ } |
Select-Object -Unique)
$allowedRemoteAddresses = @($domainSubnet) + $TrustedClientNetworks + $PublicEnrollmentNetworks
$sourceRoot = $PSScriptRoot
if (-not $Resume) {
@@ -279,6 +508,7 @@ else {
$NetworkInterfaceAlias = Resolve-PrivateInterfaceAlias -RequestedAlias $NetworkInterfaceAlias
$baseDn = Get-DomainBaseDn -DnsDomainName $DomainName
$brokerDnsName = "$BrokerRecordName.$DomainName"
$rustDeskDnsName = "$RustDeskRecordName.$DomainName"
$stagedScriptPath = Join-Path $bootstrapRoot 'Initialize-SguDomainController.ps1'
$scriptsRoot = Join-Path $bootstrapRoot 'payload\scripts'
$brokerPublishPath = Join-Path $bootstrapRoot 'payload\broker'
@@ -291,6 +521,16 @@ foreach ($requiredPath in @(
(Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1'),
(Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1'),
(Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1'),
(Join-Path $scriptsRoot 'Install-SguDomainMonitoring.ps1'),
(Join-Path $scriptsRoot 'Install-SguRustDeskClient.ps1'),
(Join-Path $scriptsRoot 'Install-SguRustDeskLinuxEnrollment.ps1'),
(Join-Path $scriptsRoot 'Install-SguRustDeskServer.ps1'),
(Join-Path $scriptsRoot 'Invoke-SguRustDeskLinuxRegistrationProcessor.ps1'),
(Join-Path $scriptsRoot 'Invoke-SguMonitoringMaintenance.ps1'),
(Join-Path $scriptsRoot 'Get-SguRustDeskDevice.ps1'),
(Join-Path $scriptsRoot 'Get-SguUsageReport.ps1'),
(Join-Path $scriptsRoot 'Get-SguBrokerLog.ps1'),
(Join-Path $scriptsRoot 'Register-SguRustDeskDevice.ps1'),
(Join-Path $brokerPublishPath 'SGU.AuthBroker.exe'))) {
if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
throw "The server bootstrap package is incomplete: $requiredPath"
@@ -313,10 +553,14 @@ if (-not $existingState) {
PrefixLength = $PrefixLength
NetworkInterfaceAlias = $NetworkInterfaceAlias
DefaultGateway = if ($DefaultGateway) { $DefaultGateway.IPAddressToString } else { $null }
NetworkConfigurationMode = $NetworkConfigurationMode
TrustedClientNetworks = $TrustedClientNetworks
PublicEnrollmentNetworks = $PublicEnrollmentNetworks
DnsForwarders = @($DnsForwarders | ForEach-Object IPAddressToString)
DomainName = $DomainName
DomainNetbios = $DomainNetbios
BrokerRecordName = $BrokerRecordName
RustDeskRecordName = $RustDeskRecordName
PackageSharePath = $PackageSharePath
}
[IO.File]::WriteAllText(
@@ -325,9 +569,16 @@ if (-not $existingState) {
[Text.UTF8Encoding]::new($false))
}
if ($NetworkConfigurationMode -eq 'PlatformManaged') {
Write-BootstrapLog "Validating platform-managed address $ServerIPv4Address/$PrefixLength on $NetworkInterfaceAlias without changing DHCP, routes, or the Azure NIC."
Assert-PlatformManagedDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
-Address $ServerIPv4Address -NetworkPrefixLength $PrefixLength
}
else {
Write-BootstrapLog "Configuring $NetworkInterfaceAlias as $ServerIPv4Address/$PrefixLength."
Set-StaticDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
-Address $ServerIPv4Address -NetworkPrefixLength $PrefixLength -Gateway $DefaultGateway
}
$computer = Get-CimInstance Win32_ComputerSystem
if (-not $computer.PartOfDomain) {
@@ -379,24 +630,84 @@ if (-not $computer.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreC
}
Write-BootstrapLog 'Finalizing Active Directory, DNS, policies, broker, shares, and remote management.'
Import-Module ActiveDirectory -ErrorAction Stop
$domain = Get-ADDomain -Identity $DomainName -Server $env:COMPUTERNAME
$laboratoryOuDn = Ensure-OrganizationalUnit -Name 'Laboratorio' -Path $baseDn -Server $env:COMPUTERNAME
$usersOuDn = Ensure-OrganizationalUnit -Name 'Usuarios-SGU' -Path $baseDn -Server $env:COMPUTERNAME
# Once the machine is a DC, every active adapter must query the local DNS
# service. Only the private domain adapter may publish its address in the AD
# zone; otherwise clients can receive the DHCP/NAT address of the Internet NIC.
Get-ActiveIPv4Adapters | ForEach-Object {
Set-DnsClientServerAddress -InterfaceIndex $_.ifIndex `
-ServerAddresses $ServerIPv4Address.IPAddressToString
Set-DnsClient -InterfaceIndex $_.ifIndex `
-RegisterThisConnectionsAddress:($_.Name -eq $NetworkInterfaceAlias)
}
Clear-DnsClientCache
Register-DnsClient
Import-Module ActiveDirectory -ErrorAction Stop
Wait-ActiveDirectoryReady -ExpectedBaseDn $baseDn
# A newly promoted Windows Server 2025 DC can retain the Public firewall
# profile because network identification ran before local DNS and LDAP were
# ready. A private-adapter bounce triggers the supported domain-detection path.
$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 `
-ErrorAction SilentlyContinue
if ($domainProfile -and $domainProfile.NetworkCategory -eq 'DomainAuthenticated') {
break
}
}
}
# Apply the single-address DNS listener only after any adapter refresh. That
# avoids transient DNS socket errors while the private address is momentarily
# unavailable, while still preventing the Internet/NAT address from being
# published once finalization completes.
New-ItemProperty `
-Path 'HKLM:\SYSTEM\CurrentControlSet\Services\DNS\Parameters' `
-Name PublishAddresses `
-PropertyType String `
-Value $ServerIPv4Address.IPAddressToString `
-Force | Out-Null
$dnsServerSetting = Get-DnsServerSetting -All -WarningAction SilentlyContinue
$dnsServerSetting.ListeningIPAddress = @($ServerIPv4Address)
Set-DnsServerSetting -InputObject $dnsServerSetting -WarningAction SilentlyContinue | Out-Null
Restart-Service DNS -Force
Wait-DnsZoneReady -ZoneName $DomainName -DnsServer $ServerIPv4Address
$adServer = 'localhost'
$domain = Get-ADDomain -Identity $DomainName -Server $adServer
$laboratoryOuDn = Ensure-OrganizationalUnit -Name 'Laboratorio' -Path $baseDn -Server $adServer
$usersOuDn = Ensure-OrganizationalUnit -Name 'Usuarios-SGU' -Path $baseDn -Server $adServer
foreach ($ouName in @('Docentes', 'Alumnos', 'Administrativos')) {
Ensure-OrganizationalUnit -Name $ouName -Path $usersOuDn -Server $env:COMPUTERNAME | Out-Null
Ensure-OrganizationalUnit -Name $ouName -Path $usersOuDn -Server $adServer | Out-Null
}
$remoteDesktopGroupName = 'SG-Laboratorio-Usuarios-RDP'
$remoteDesktopGroup = Get-ADGroup -Identity $remoteDesktopGroupName -Server $env:COMPUTERNAME `
$remoteDesktopGroup = Get-ADGroup -LDAPFilter "(sAMAccountName=$remoteDesktopGroupName)" `
-SearchBase $baseDn -SearchScope Subtree -Server $adServer `
-ErrorAction SilentlyContinue
if (-not $remoteDesktopGroup) {
New-ADGroup -Name $remoteDesktopGroupName -SamAccountName $remoteDesktopGroupName `
-GroupCategory Security -GroupScope Global -Path $laboratoryOuDn `
-Description 'SGU users permitted to use Remote Desktop on laboratory clients.' `
-Server $env:COMPUTERNAME | Out-Null
$remoteDesktopGroup = Get-ADGroup -Identity $remoteDesktopGroupName -Server $env:COMPUTERNAME
-Server $adServer | Out-Null
$remoteDesktopGroup = Get-ADGroup -LDAPFilter "(sAMAccountName=$remoteDesktopGroupName)" `
-SearchBase $laboratoryOuDn -SearchScope OneLevel -Server $adServer
}
& (Join-Path $scriptsRoot 'Set-LabBrokerDns.ps1') `
@@ -404,6 +715,10 @@ if (-not $remoteDesktopGroup) {
-RecordName $BrokerRecordName `
-IPv4Address $ServerIPv4Address `
-ExternalForwarders $DnsForwarders | Out-Null
& (Join-Path $scriptsRoot 'Set-LabBrokerDns.ps1') `
-ZoneName $DomainName `
-RecordName $RustDeskRecordName `
-IPv4Address $ServerIPv4Address | Out-Null
$certificateDirectory = Join-Path $bootstrapRoot 'certificates'
$serverCertificate = Get-ChildItem Cert:\LocalMachine\My |
@@ -444,16 +759,31 @@ if (Test-Path -LiteralPath $brokerConfigurationPath -PathType Leaf) {
-PublishPath $brokerPublishPath `
-ServerCertificateSubject $brokerDnsName `
-AllowedClientThumbprints $allowedClientThumbprints `
-LdapHost $env:COMPUTERNAME `
-LdapHost $adServer `
-BaseDn $baseDn `
-DomainNetbios $DomainNetbios `
-UpnSuffix $DomainName `
-RemoteDesktopGroupDn $remoteDesktopGroup.DistinguishedName `
-DefaultCompany 'La Salle' `
-FirewallLocalAddress $ServerIPv4Address `
-FirewallRemoteAddress $allowedRemoteAddresses `
-CreateMissingOus `
-DisableCertificateRevocationCheckForLab | Out-Null
& (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') | Out-Null
# Remove stale A records registered by any non-domain/NAT adapter before its
# dynamic DNS registration was disabled.
$hostRecords = @(Get-DnsServerResourceRecord -ZoneName $DomainName `
-Name $env:COMPUTERNAME -RRType A -ErrorAction SilentlyContinue)
foreach ($hostRecord in $hostRecords) {
if ($hostRecord.RecordData.IPv4Address.IPAddressToString -ne $ServerIPv4Address.IPAddressToString) {
Remove-DnsServerResourceRecord -ZoneName $DomainName -InputObject $hostRecord -Force
}
}
& (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') `
-AllowedRemoteAddress $allowedRemoteAddresses | Out-Null
Set-SguPublicEnrollmentFirewall -LocalAddress $ServerIPv4Address `
-RemoteAddress $PublicEnrollmentNetworks
$contentPath = Join-Path $bootstrapRoot 'payload\server-content\Packages'
if (Test-Path -LiteralPath $contentPath -PathType Container) {
@@ -463,42 +793,123 @@ if (Test-Path -LiteralPath $contentPath -PathType Container) {
Set-PackageShare -Path $PackageSharePath -NetbiosName $DomainNetbios `
-DomainSid $domain.DomainSID.Value
$packageFirewallRule = Get-NetFirewallRule -DisplayName 'SGU Bootstrap Packages (SMB)' `
-ErrorAction SilentlyContinue
if (-not $packageFirewallRule) {
New-NetFirewallRule `
-DisplayName 'SGU Bootstrap Packages (SMB)' `
-Direction Inbound `
-Action Allow `
-Protocol TCP `
-LocalPort 445 `
-LocalAddress $ServerIPv4Address.IPAddressToString `
-RemoteAddress $allowedRemoteAddresses `
-Profile Any | Out-Null
}
else {
$packageFirewallRule | Set-NetFirewallRule -Enabled True -Profile Any
$packageFirewallRule | Get-NetFirewallAddressFilter |
Set-NetFirewallAddressFilter `
-LocalAddress $ServerIPv4Address.IPAddressToString `
-RemoteAddress $allowedRemoteAddresses | Out-Null
}
$collectorFqdn = "$env:COMPUTERNAME.$DomainName"
& (Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1') `
-TargetOuDn $laboratoryOuDn -DomainController $env:COMPUTERNAME | Out-Null
-TargetOuDn $laboratoryOuDn `
-DomainController $env:COMPUTERNAME `
-EventCollectorFqdn $collectorFqdn | Out-Null
& (Join-Path $scriptsRoot 'Install-SguDomainMonitoring.ps1') `
-CollectorFqdn $collectorFqdn `
-ComputerOuDn $laboratoryOuDn `
-RetentionDays 183 | Out-Null
$userPolicyParameters = @{
TargetOuDn = $usersOuDn
DomainController = $env:COMPUTERNAME
}
$wallpaper = Get-ChildItem -LiteralPath $PackageSharePath -File -ErrorAction SilentlyContinue |
Where-Object { $_.BaseName -eq 'wallpaper' -and $_.Extension -in @('.jpg','.jpeg','.png','.bmp') } |
Sort-Object Name |
Select-Object -First 1
if ($wallpaper) {
$userPolicyParameters.WallpaperPath = "\\$env:COMPUTERNAME\Packages\$($wallpaper.Name)"
ClearManagedWallpaper = $true
}
& (Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1') @userPolicyParameters | Out-Null
$rustDeskServer = & (Join-Path $scriptsRoot 'Install-SguRustDeskServer.ps1') `
-ServerAddress $rustDeskDnsName `
-FirewallRemoteAddress $allowedRemoteAddresses
$rustDeskManagementRoot = Join-Path $env:ProgramData 'SGU\RustDesk'
New-Item -ItemType Directory -Path $rustDeskManagementRoot -Force | Out-Null
foreach ($scriptName in @(
'Register-SguRustDeskDevice.ps1',
'Get-SguRustDeskDevice.ps1',
'Install-SguRustDeskLinuxEnrollment.ps1',
'Invoke-SguRustDeskLinuxRegistrationProcessor.ps1')) {
Copy-Item -LiteralPath (Join-Path $scriptsRoot $scriptName) `
-Destination (Join-Path $rustDeskManagementRoot $scriptName) -Force
}
$rustDeskLinuxEnrollment = & (Join-Path $rustDeskManagementRoot 'Install-SguRustDeskLinuxEnrollment.ps1') `
-DomainName $DomainName `
-ServerAddress $rustDeskDnsName `
-ServerPublicKey $rustDeskServer.PublicKey `
-ProcessorScriptPath (Join-Path $rustDeskManagementRoot 'Invoke-SguRustDeskLinuxRegistrationProcessor.ps1')
$rustDeskServerClient = & (Join-Path $scriptsRoot 'Install-SguRustDeskClient.ps1') `
-ServerAddress $rustDeskDnsName `
-ServerPublicKey $rustDeskServer.PublicKey
$rustDeskPasswordPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR(
$rustDeskServerClient.AccessPassword)
try {
$rustDeskPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($rustDeskPasswordPointer)
& (Join-Path $rustDeskManagementRoot 'Register-SguRustDeskDevice.ps1') `
-ComputerName $env:COMPUTERNAME `
-RustDeskId $rustDeskServerClient.RustDeskId `
-AccessPassword $rustDeskPassword | Out-Null
}
finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($rustDeskPasswordPointer)
$rustDeskPassword = $null
}
$validation = [ordered]@{
CompletedAt = (Get-Date).ToString('o')
ComputerName = $env:COMPUTERNAME
DomainName = $DomainName
ServerIPv4Address = $ServerIPv4Address.IPAddressToString
NetworkConfigurationMode = $NetworkConfigurationMode
TrustedClientNetworks = $TrustedClientNetworks
PublicEnrollmentNetworks = $PublicEnrollmentNetworks
AllowedRemoteAddresses = $allowedRemoteAddresses
BrokerDnsName = $brokerDnsName
BrokerCertificateThumbprint = $serverCertificate.Thumbprint
BrokerService = (Get-Service SGUAuthBroker).Status.ToString()
BrokerPortListening = [bool](Get-NetTCPConnection -LocalPort 8443 -State Listen -ErrorAction SilentlyContinue)
WinRM = (Get-Service WinRM).Status.ToString()
RemoteDesktop = (Get-Service TermService).Status.ToString()
RustDeskServerAddress = $rustDeskServer.ServerAddress
RustDeskHbbsTask = $rustDeskServer.HbbsTask
RustDeskHbbrTask = $rustDeskServer.HbbrTask
RustDeskHbbsListening = $rustDeskServer.HbbsListening
RustDeskHbbrListening = $rustDeskServer.HbbrListening
RustDeskLinuxRegistrationTask = (Get-ScheduledTask -TaskName $rustDeskLinuxEnrollment.RegistrationTask).State.ToString()
RustDeskServerClientId = $rustDeskServerClient.RustDeskId
EventCollector = (Get-Service Wecsvc).Status.ToString()
EventSubscription = @(& wecutil.exe enum-subscription) -contains 'SGU-Lab-Monitoring'
MonitoringRetentionDays = 183
PackageShare = "\\$env:COMPUTERNAME\Packages"
LaboratoryOu = $laboratoryOuDn
UsersOu = $usersOuDn
RemoteDesktopGroup = $remoteDesktopGroup.DistinguishedName
DomainNetworkProfile = [string](Get-NetConnectionProfile `
-InterfaceAlias $NetworkInterfaceAlias -ErrorAction SilentlyContinue).NetworkCategory
}
if ($validation.BrokerService -ne 'Running' -or
-not $validation.BrokerPortListening -or
$validation.WinRM -ne 'Running' -or
$validation.RemoteDesktop -ne 'Running') {
$validation.RemoteDesktop -ne 'Running' -or
$validation.RustDeskHbbsTask -ne 'Running' -or
$validation.RustDeskHbbrTask -ne 'Running' -or
$validation.RustDeskLinuxRegistrationTask -notin @('Ready', 'Running') -or
-not $validation.RustDeskHbbsListening -or
-not $validation.RustDeskHbbrListening -or
$validation.EventCollector -ne 'Running' -or
-not $validation.EventSubscription -or
$validation.DomainNetworkProfile -ne 'DomainAuthenticated') {
throw 'Server finalization did not pass service validation. Review bootstrap.log and re-run the bootstrap.'
}
+103 -2
View File
@@ -35,6 +35,12 @@ $providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authent
$classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId\InprocServer32"
$defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
$accountPictureSourcePath = Join-Path $PublishPath 'branding\user.png'
$accountPictureDirectory = Join-Path $env:ProgramData 'Microsoft\User Account Pictures'
$welcomeWallpaperSourcePath = Join-Path $PublishPath 'branding\darkblue.jpg'
$welcomeWallpaperScriptSourcePath = Join-Path $PublishPath 'branding\Set-SguWelcomeWallpaper.ps1'
$welcomeFontsSourcePath = Join-Path $PublishPath 'branding\fonts'
$welcomeWallpaperDirectory = Join-Path $env:ProgramData 'SGU\Branding'
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
@@ -56,6 +62,89 @@ function Test-DotNet10Runtime {
return $false
}
function Install-DefaultAccountPicture {
param([Parameter(Mandatory)][string]$SourcePath)
if (-not (Test-Path -LiteralPath $SourcePath -PathType Leaf)) {
return $false
}
Add-Type -AssemblyName System.Drawing
New-Item -ItemType Directory -Path $accountPictureDirectory -Force | Out-Null
function Save-AccountPicture {
param(
[Parameter(Mandatory)][Drawing.Image]$Image,
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][Drawing.Imaging.ImageFormat]$Format
)
$stream = [IO.MemoryStream]::new()
try {
$Image.Save($stream, $Format)
[IO.File]::WriteAllBytes($Path, $stream.ToArray())
}
finally {
$stream.Dispose()
}
}
$source = [Drawing.Image]::FromFile($SourcePath)
try {
foreach ($size in @(192, 48, 40, 32)) {
$bitmap = [Drawing.Bitmap]::new($size, $size)
try {
$graphics = [Drawing.Graphics]::FromImage($bitmap)
try {
$graphics.Clear([Drawing.Color]::Transparent)
$graphics.InterpolationMode = [Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$graphics.DrawImage($source, [Drawing.Rectangle]::new(0, 0, $size, $size))
Save-AccountPicture -Image $bitmap `
-Path (Join-Path $accountPictureDirectory "user-$size.png") `
-Format ([Drawing.Imaging.ImageFormat]::Png)
}
finally {
$graphics.Dispose()
}
}
finally {
$bitmap.Dispose()
}
}
Save-AccountPicture -Image $source `
-Path (Join-Path $accountPictureDirectory 'user.png') `
-Format ([Drawing.Imaging.ImageFormat]::Png)
Save-AccountPicture -Image $source `
-Path (Join-Path $accountPictureDirectory 'user.bmp') `
-Format ([Drawing.Imaging.ImageFormat]::Bmp)
}
finally {
$source.Dispose()
}
return $true
}
function Install-WelcomeWallpaperAssets {
if (-not (Test-Path -LiteralPath $welcomeWallpaperSourcePath -PathType Leaf) -or
-not (Test-Path -LiteralPath $welcomeWallpaperScriptSourcePath -PathType Leaf)) {
return $false
}
New-Item -ItemType Directory -Path $welcomeWallpaperDirectory -Force | Out-Null
Copy-Item -LiteralPath $welcomeWallpaperSourcePath `
-Destination (Join-Path $welcomeWallpaperDirectory 'darkblue.jpg') -Force
Copy-Item -LiteralPath $welcomeWallpaperScriptSourcePath `
-Destination (Join-Path $welcomeWallpaperDirectory 'Set-SguWelcomeWallpaper.ps1') -Force
if (Test-Path -LiteralPath $welcomeFontsSourcePath -PathType Container) {
$fontDestination = Join-Path $welcomeWallpaperDirectory 'fonts'
New-Item -ItemType Directory -Path $fontDestination -Force | Out-Null
Copy-Item -Path (Join-Path $welcomeFontsSourcePath '*') -Destination $fontDestination -Force
}
return $true
}
if (-not (Test-DotNet10Runtime)) {
if (-not $InstallDotNetRuntime) {
throw 'Microsoft .NET 10 x64 runtime is required. Re-run with -InstallDotNetRuntime or install it first.'
@@ -166,6 +255,11 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credenti
[IO.File]::WriteAllText($completeMarker, $packageHash, [Text.UTF8Encoding]::new($false))
}
# The domain GPO selects the Windows default account picture. Install its
# branded bitmap during enrollment so no per-machine manual setup is needed.
Install-DefaultAccountPicture -SourcePath $accountPictureSourcePath | Out-Null
Install-WelcomeWallpaperAssets | Out-Null
New-Item -ItemType Directory -Path (Split-Path $settingsPath -Parent) -Force | Out-Null
$settingsJson = @{
BrokerEndpoint = $BrokerEndpoint
@@ -179,10 +273,14 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credenti
$acl = Get-Acl -LiteralPath (Split-Path $settingsPath -Parent)
$acl.SetAccessRuleProtection($true, $false)
# Resolve built-in identities by SID instead of localized display names.
# "BUILTIN\Administrators" is not resolvable on every non-English client.
$systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18')
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
'SYSTEM', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
$systemSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
'BUILTIN\Administrators', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
$administratorsSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
Set-Acl -LiteralPath (Split-Path $settingsPath -Parent) -AclObject $acl
New-Item -Path $classRegistryPath -Force | Out-Null
@@ -250,4 +348,7 @@ catch {
-LiteralPath $defaultProviderPolicyPath `
-Name EnumerateLocalUsers) -eq 0
SystemPasswordProviderPreserved = $true
WelcomeWallpaperAssetsInstalled =
(Test-Path -LiteralPath (Join-Path $welcomeWallpaperDirectory 'darkblue.jpg') -PathType Leaf) -and
(Test-Path -LiteralPath (Join-Path $welcomeWallpaperDirectory 'Set-SguWelcomeWallpaper.ps1') -PathType Leaf)
}
+127
View File
@@ -0,0 +1,127 @@
#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
}
$dnsParameters = @{}
if ($DomainName) { $dnsParameters.DnsSuffix = $DomainName }
Add-VpnConnection @dnsParameters `
-Name $ConnectionName `
-ServerAddress $vpnServer `
-TunnelType Ikev2 `
-AuthenticationMethod MachineCertificate `
-MachineCertificateIssuerFilter $clientRoot `
-MachineCertificateEKUFilter '1.3.6.1.5.5.7.3.2' `
-EncryptionLevel Required `
-SplitTunneling `
-AllUserConnection `
-Force | Out-Null
foreach ($prefix in $AzureNetworkPrefixes) {
Add-VpnConnectionRoute -ConnectionName $ConnectionName `
-DestinationPrefix $prefix -AllUserConnection -PassThru | Out-Null
}
# The unified bootstrap can discover the domain after connecting.
if ($DomainName) {
$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 configure the Azure-generated OpenVPN profile in Azure VPN Client 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
}
}
+147
View File
@@ -0,0 +1,147 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess)]
param(
[string]$CollectorFqdn = "$env:COMPUTERNAME.$env:USERDNSDOMAIN",
[string]$ComputerOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
[string]$MonitoringRoot = 'C:\ProgramData\SGU\Monitoring',
[ValidateRange(30, 730)]
[int]$RetentionDays = 183
)
$ErrorActionPreference = 'Stop'
$subscriptionId = 'SGU-Lab-Monitoring'
$maintenanceScriptName = 'Invoke-SguMonitoringMaintenance.ps1'
$reportScriptName = 'Get-SguUsageReport.ps1'
$brokerReportScriptName = 'Get-SguBrokerLog.ps1'
$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 on the domain event collector.'
}
Import-Module ActiveDirectory -ErrorAction Stop
Get-ADOrganizationalUnit -Identity $ComputerOuDn -ErrorAction Stop | Out-Null
foreach ($requiredScript in $maintenanceScriptName,$reportScriptName,$brokerReportScriptName) {
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot $requiredScript) -PathType Leaf)) {
throw "$requiredScript must be beside Install-SguDomainMonitoring.ps1."
}
}
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install the SGU domain monitoring collector')) {
Set-Service EventLog -StartupType Automatic
if ((Get-Service EventLog).Status -ne 'Running') {
Start-Service EventLog
}
& wecutil.exe quick-config /quiet
if ($LASTEXITCODE -ne 0) {
throw "wecutil quick-config failed with exit code $LASTEXITCODE."
}
Set-Service Wecsvc -StartupType Automatic
Start-Service Wecsvc
& wevtutil.exe set-log ForwardedEvents /enabled:true /maxsize:536870912 /retention:false /autobackup:false
if ($LASTEXITCODE -ne 0) {
throw "wevtutil failed to configure ForwardedEvents with exit code $LASTEXITCODE."
}
$query = @'
<QueryList>
<Query Id="0">
<Select Path="Security">*[System[(EventID=4624 or EventID=4625 or EventID=4634 or EventID=4647 or EventID=4778 or EventID=4779)]]</Select>
<Select Path="System">*[System[(EventID=12 or EventID=13 or EventID=41 or EventID=1074 or EventID=6005 or EventID=6006 or EventID=6008)]]</Select>
</Query>
</QueryList>
'@
$escapedQuery = [Security.SecurityElement]::Escape($query)
$subscriptionXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<Subscription xmlns="http://schemas.microsoft.com/2006/03/windows/events/subscription">
<SubscriptionId>$subscriptionId</SubscriptionId>
<SubscriptionType>SourceInitiated</SubscriptionType>
<Description>SGU interactive sessions, failures, reconnects, and workstation power state.</Description>
<Enabled>true</Enabled>
<Uri>http://schemas.microsoft.com/wbem/wsman/1/windows/EventLog</Uri>
<ConfigurationMode>Custom</ConfigurationMode>
<Delivery Mode="Push">
<Batching><MaxItems>5</MaxItems><MaxLatencyTime>30000</MaxLatencyTime></Batching>
<PushSettings><Heartbeat Interval="60000"/></PushSettings>
</Delivery>
<Query>$escapedQuery</Query>
<ReadExistingEvents>false</ReadExistingEvents>
<TransportName>HTTP</TransportName>
<ContentFormat>Events</ContentFormat>
<Locale Language="es-MX"/>
<LogFile>ForwardedEvents</LogFile>
<AllowedSourceDomainComputers>O:NSG:NSD:(A;;GA;;;DC)(A;;GA;;;NS)</AllowedSourceDomainComputers>
</Subscription>
"@
New-Item -ItemType Directory -Path $MonitoringRoot -Force | Out-Null
$subscriptionPath = Join-Path $MonitoringRoot 'SGU-Lab-Monitoring.xml'
[IO.File]::WriteAllText($subscriptionPath, $subscriptionXml, [Text.UTF8Encoding]::new($true))
$existingSubscriptions = @(& wecutil.exe enum-subscription 2>$null)
if ($existingSubscriptions -contains $subscriptionId) {
& wecutil.exe delete-subscription $subscriptionId
if ($LASTEXITCODE -ne 0) {
throw "Could not replace the existing $subscriptionId subscription."
}
}
& wecutil.exe create-subscription $subscriptionPath
if ($LASTEXITCODE -ne 0) {
throw "Could not create the $subscriptionId subscription."
}
foreach ($scriptName in $maintenanceScriptName,$reportScriptName,$brokerReportScriptName) {
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $scriptName) `
-Destination (Join-Path $MonitoringRoot $scriptName) -Force
}
$configuration = [ordered]@{
CollectorFqdn = $CollectorFqdn
ComputerOuDn = $ComputerOuDn
RetentionDays = $RetentionDays
SubscriptionId = $subscriptionId
}
[IO.File]::WriteAllText(
(Join-Path $MonitoringRoot 'monitoring.json'),
($configuration | ConvertTo-Json),
[Text.UTF8Encoding]::new($false))
$powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
$maintenanceScript = Join-Path $MonitoringRoot $maintenanceScriptName
$inventoryAction = New-ScheduledTaskAction -Execute $powerShell -Argument (
"-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$maintenanceScript`" " +
"-MonitoringRoot `"$MonitoringRoot`" -ComputerOuDn `"$ComputerOuDn`" -RetentionDays $RetentionDays -InventoryOnly")
$inventoryTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
-RepetitionInterval (New-TimeSpan -Minutes 5) `
-RepetitionDuration (New-TimeSpan -Days 3650)
$taskSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes 10) -RestartCount 2 `
-RestartInterval (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName 'SGU-Monitoring-Inventory' -Action $inventoryAction `
-Trigger $inventoryTrigger -Settings $taskSettings -User 'SYSTEM' -RunLevel Highest -Force | Out-Null
$retentionAction = New-ScheduledTaskAction -Execute $powerShell -Argument (
"-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$maintenanceScript`" " +
"-MonitoringRoot `"$MonitoringRoot`" -ComputerOuDn `"$ComputerOuDn`" -RetentionDays $RetentionDays")
$retentionTrigger = New-ScheduledTaskTrigger -Daily -At '12:10 AM'
Register-ScheduledTask -TaskName 'SGU-Monitoring-Retention' -Action $retentionAction `
-Trigger $retentionTrigger -Settings $taskSettings -User 'SYSTEM' -RunLevel Highest -Force | Out-Null
& $maintenanceScript -MonitoringRoot $MonitoringRoot -ComputerOuDn $ComputerOuDn `
-RetentionDays $RetentionDays -InventoryOnly | Out-Null
}
[pscustomobject]@{
Collector = $CollectorFqdn
CollectorService = (Get-Service Wecsvc).Status.ToString()
SubscriptionId = $subscriptionId
SubscriptionEnabled = @(& wecutil.exe enum-subscription) -contains $subscriptionId
RetentionDays = $RetentionDays
InventoryTask = (Get-ScheduledTask -TaskName 'SGU-Monitoring-Inventory').State
RetentionTask = (Get-ScheduledTask -TaskName 'SGU-Monitoring-Retention').State
MachineStatusPath = Join-Path $MonitoringRoot 'Reports\machine-status.json'
UsageReportCommand = "& '$MonitoringRoot\$reportScriptName'"
BrokerLogCommand = "& '$MonitoringRoot\$brokerReportScriptName'"
}
+21 -3
View File
@@ -19,7 +19,9 @@ param(
[ValidateRange(2, 90)]
[int]$TimeoutSeconds = 90,
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
[string]$DotNetRuntimeInstallerPath
[string]$DotNetRuntimeInstallerPath,
[string]$RustDeskServerAddress,
[string]$RustDeskServerPublicKey
)
$ErrorActionPreference = 'Stop'
@@ -28,6 +30,9 @@ $enrollmentRoot = Join-Path $env:ProgramData 'SGU\Enrollment'
$sourceScripts = @(
'Install-CredentialProvider.ps1',
'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1',
'Install-SguRustDeskClient.ps1',
'Set-SguStandardLocalUser.ps1',
'Test-SguClientEnrollment.ps1',
'Repair-SguClientEnrollment.ps1'
)
@@ -53,6 +58,10 @@ if ($DotNetRuntimeInstallerPath -and
-not (Test-Path -LiteralPath $DotNetRuntimeInstallerPath -PathType Leaf)) {
throw 'DotNetRuntimeInstallerPath does not exist.'
}
if ([string]::IsNullOrWhiteSpace($RustDeskServerAddress) -xor
[string]::IsNullOrWhiteSpace($RustDeskServerPublicKey)) {
throw 'RustDeskServerAddress and RustDeskServerPublicKey must be supplied together.'
}
if ($PSCmdlet.ShouldProcess($enrollmentRoot, 'Install the SGU enrollment repair guard')) {
New-Item -ItemType Directory -Path $enrollmentRoot -Force | Out-Null
@@ -71,8 +80,12 @@ if ($PSCmdlet.ShouldProcess($enrollmentRoot, 'Install the SGU enrollment repair
$runtimeDirectory = Join-Path $enrollmentRoot 'prerequisites'
New-Item -ItemType Directory -Path $runtimeDirectory -Force | Out-Null
$guardRuntimeInstaller = Join-Path $runtimeDirectory (Split-Path $DotNetRuntimeInstallerPath -Leaf)
$sourceRuntimeInstaller = [IO.Path]::GetFullPath($DotNetRuntimeInstallerPath)
$destinationRuntimeInstaller = [IO.Path]::GetFullPath($guardRuntimeInstaller)
if (-not $sourceRuntimeInstaller.Equals($destinationRuntimeInstaller, [StringComparison]::OrdinalIgnoreCase)) {
Copy-Item -LiteralPath $DotNetRuntimeInstallerPath -Destination $guardRuntimeInstaller -Force
}
}
$guardConfiguration = [ordered]@{
PublishPath = $guardPublishPath
@@ -83,6 +96,8 @@ if ($PSCmdlet.ShouldProcess($enrollmentRoot, 'Install the SGU enrollment repair
TimeoutSeconds = $TimeoutSeconds
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
DotNetRuntimeInstallerPath = $guardRuntimeInstaller
RustDeskServerAddress = $RustDeskServerAddress
RustDeskServerPublicKey = $RustDeskServerPublicKey
}
$configurationPath = Join-Path $enrollmentRoot 'enrollment.json'
[IO.File]::WriteAllText(
@@ -92,10 +107,13 @@ if ($PSCmdlet.ShouldProcess($enrollmentRoot, 'Install the SGU enrollment repair
$acl = Get-Acl -LiteralPath $enrollmentRoot
$acl.SetAccessRuleProtection($true, $false)
# Well-known SIDs are invariant across localized Windows installations.
$systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18')
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
'SYSTEM', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
$systemSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
'BUILTIN\Administrators', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
$administratorsSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
Set-Acl -LiteralPath $enrollmentRoot -AclObject $acl
$repairScript = Join-Path $enrollmentRoot 'Repair-SguClientEnrollment.ps1'
+427
View File
@@ -0,0 +1,427 @@
#!/usr/bin/env bash
# Install-SguLinuxRustDeskClient.sh
#
# Installs/configures a RustDesk client on an AD-joined Linux workstation and
# registers its randomly generated unattended-access credential with the
# protected inventory on the SGU domain controller. The credential is never
# emitted to stdout and is sent to the controller only in an RSA-OAEP envelope.
set -Eeuo pipefail
IFS=$'\n\t'
DOMAIN_NAME='lci.lasalle.mx'
REGISTRATION_SHARE=''
STATE_ROOT='/var/lib/sgu/rustdesk'
CLIENT_VERSION='1.4.9'
DOWNLOAD_URI='https://github.com/rustdesk/rustdesk/releases/download/1.4.9/rustdesk-1.4.9-x86_64.deb'
EXPECTED_SHA256='7244BA47C40E804172044BFBE659467C54CE46554C98E78C8C0406F1D612FDA3'
usage() {
cat <<'EOF'
Usage:
sudo ./Install-SguLinuxRustDeskClient.sh [options]
Options:
--domain-name VALUE AD DNS domain (default: lci.lasalle.mx).
--registration-share UNC SMB enrollment share. Defaults to the first
AD domain controller's SGU RustDesk share.
--state-root PATH Root-owned local RustDesk state directory.
--help Show this help.
The computer must already be joined to Active Directory. The script uses the
machine keytab to authenticate to the enrollment share, configures the
self-hosted RustDesk server, creates an unattended-access password, and waits
for the controller to confirm protected inventory registration.
EOF
}
fail() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
need_command() {
command -v "$1" >/dev/null 2>&1 || fail "Required command is unavailable: $1"
}
apt_get_with_retry() {
local attempt
for attempt in $(seq 1 60); do
if apt-get "$@"; then
return 0
fi
if fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock \
>/dev/null 2>&1; then
printf 'Waiting for another package operation before retrying apt-get %s.\n' "$1" >&2
sleep 5
continue
fi
fail "apt-get $1 failed for a reason other than a temporary package lock."
done
fail 'Timed out waiting for another package operation to finish.'
}
while (($#)); do
case "$1" in
--domain-name) DOMAIN_NAME=${2:?Missing value for --domain-name}; shift 2 ;;
--registration-share) REGISTRATION_SHARE=${2:?Missing value for --registration-share}; shift 2 ;;
--state-root) STATE_ROOT=${2:?Missing value for --state-root}; shift 2 ;;
--help|-h) usage; exit 0 ;;
*) fail "Unknown argument: $1. Use --help for usage." ;;
esac
done
[[ ${EUID} -eq 0 ]] || fail 'Run this command with sudo or as root.'
[[ -r /etc/krb5.keytab ]] || fail 'The AD machine keytab is missing. Join the computer to the domain first.'
install_prerequisites() {
if command -v apt-get >/dev/null 2>&1; then
export DEBIAN_FRONTEND=noninteractive
apt_get_with_retry update
apt_get_with_retry install -y curl openssl smbclient dnsutils
return
fi
if command -v dnf >/dev/null 2>&1; then
dnf install -y curl openssl samba-client bind-utils
return
fi
fail 'RustDesk enrollment supports apt-get (Debian/Ubuntu) and dnf (RHEL/Fedora/Rocky/AlmaLinux).'
}
resolve_registration_share() {
if [[ -n $REGISTRATION_SHARE ]]; then
return
fi
local controller
controller=$(host -t SRV "_ldap._tcp.dc._msdcs.${DOMAIN_NAME}" 2>/dev/null |
awk '/SRV record/ { print $NF; exit }' | sed 's/\.$//')
[[ -n $controller ]] || controller=$DOMAIN_NAME
REGISTRATION_SHARE="//${controller}/SGU-RustDesk-Enrollment$"
}
initialize_machine_kerberos() {
local principal
# adcli places the machine-account principal in the keytab. Prefer it to
# host/FQDN: some AD deployments retain the latter locally even when its
# SPN is not accepted by the KDC for an initial ticket request.
principal=$(klist -k /etc/krb5.keytab 2>/dev/null |
awk '$NF ~ /^[^/@]+\$@/ { print $NF; exit }')
if [[ -z $principal ]]; then
principal=$(klist -k /etc/krb5.keytab 2>/dev/null |
awk '$NF ~ /^host\// { print $NF; exit }')
fi
[[ -n $principal ]] || fail 'No host principal was found in /etc/krb5.keytab.'
KRB5CCNAME="FILE:${STATE_ROOT}/machine-krb5cc"
export KRB5CCNAME
rm -f -- "${KRB5CCNAME#FILE:}"
kinit -k -t /etc/krb5.keytab "$principal"
}
smb_get() {
local remote_name=$1
local local_path=$2
smbclient --use-kerberos=required -N "$REGISTRATION_SHARE" \
-c "get ${remote_name} ${local_path}" >/dev/null
}
smb_put() {
local local_path=$1
local remote_name=$2
smbclient --use-kerberos=required -N "$REGISTRATION_SHARE" \
-c "put ${local_path} ${remote_name}" >/dev/null
}
install_rustdesk() {
local installer_path="${STATE_ROOT}/rustdesk-${CLIENT_VERSION}-x86_64.deb"
local installed_version=''
if command -v rustdesk >/dev/null 2>&1; then
installed_version=$(rustdesk --version 2>/dev/null | head -n 1 || true)
fi
if [[ $installed_version != *"${CLIENT_VERSION}"* ]]; then
curl --fail --location --proto '=https' --tlsv1.2 \
--output "$installer_path" "$DOWNLOAD_URI"
local actual_hash
actual_hash=$(sha256sum "$installer_path" | awk '{ print toupper($1) }')
[[ $actual_hash == "$EXPECTED_SHA256" ]] || fail 'RustDesk package SHA-256 verification failed.'
if command -v apt-get >/dev/null 2>&1; then
dpkg -i "$installer_path" || apt_get_with_retry install -f -y
else
fail 'The pinned RustDesk package is currently provided as a Debian package only.'
fi
fi
need_command rustdesk
systemctl enable rustdesk
}
read_server_configuration() {
local configuration_path="${STATE_ROOT}/rustdesk-client.json"
smb_get 'rustdesk-client.json' "$configuration_path"
RUSTDESK_SERVER_ADDRESS=$(python3 - "$configuration_path" <<'PY'
import json
import sys
with open(sys.argv[1], encoding='utf-8') as source:
value = json.load(source)
address = value.get('ServerAddress', '')
key = value.get('ServerPublicKey', '')
if not isinstance(address, str) or not isinstance(key, str) or not address or not key:
raise SystemExit('The controller RustDesk configuration is incomplete.')
print(address)
PY
)
RUSTDESK_SERVER_PUBLIC_KEY=$(python3 - "$configuration_path" <<'PY'
import json
import sys
with open(sys.argv[1], encoding='utf-8') as source:
print(json.load(source)['ServerPublicKey'])
PY
)
}
configure_rustdesk() {
local configuration
configuration=$(cat <<EOF
rendezvous_server = '${RUSTDESK_SERVER_ADDRESS}:21116'
nat_type = 1
serial = 0
[options]
custom-rendezvous-server = '${RUSTDESK_SERVER_ADDRESS}:21116'
relay-server = '${RUSTDESK_SERVER_ADDRESS}:21117'
key = '${RUSTDESK_SERVER_PUBLIC_KEY}'
verification-method = 'use-permanent-password'
approve-mode = 'password'
EOF
)
# The service starts as root but RustDesk hands its graphical server to the
# LightDM session account. Configure both profiles; writing only root's
# profile leaves the greeter-side server using a temporary password.
install -d -o root -g root -m 700 /root/.config/rustdesk /etc/rustdesk
printf '%s\n' "$configuration" | install -o root -g root -m 600 /dev/stdin \
/root/.config/rustdesk/RustDesk2.toml
printf '%s\n' "$configuration" | install -o root -g root -m 644 /dev/stdin \
/etc/rustdesk/RustDesk2.toml
if id lightdm >/dev/null 2>&1; then
install -d -o lightdm -g lightdm -m 700 /var/lib/lightdm/.config/rustdesk
printf '%s\n' "$configuration" | install -o lightdm -g lightdm -m 600 /dev/stdin \
/var/lib/lightdm/.config/rustdesk/RustDesk2.toml
fi
systemctl restart rustdesk
systemctl is-active --quiet rustdesk || fail 'The RustDesk service did not start.'
wait_for_rustdesk_server
}
configure_x11_login_screen() {
local display_manager=''
local configuration_changed=false
local configuration_path=''
local temporary_configuration=''
if [[ -L /etc/systemd/system/display-manager.service ]]; then
display_manager=$(basename "$(readlink -f /etc/systemd/system/display-manager.service)")
fi
case "$display_manager" in
gdm3.service|gdm.service)
# Ubuntu exposes the unit as gdm.service on some releases while
# the package still reads /etc/gdm3/custom.conf. Prefer the
# distribution-specific directory instead of inferring it only
# from the unit name.
if [[ -d /etc/gdm3 || -f /etc/gdm3/custom.conf ]]; then
configuration_path='/etc/gdm3/custom.conf'
else
configuration_path='/etc/gdm/custom.conf'
fi
install -d -o root -g root -m 755 "$(dirname "$configuration_path")"
[[ -f $configuration_path ]] || printf '[daemon]\n' >"$configuration_path"
temporary_configuration=$(mktemp)
python3 - "$configuration_path" "$temporary_configuration" <<'PY'
import re
import sys
from pathlib import Path
source = Path(sys.argv[1])
destination = Path(sys.argv[2])
lines = source.read_text(encoding='utf-8').splitlines()
daemon_start = None
daemon_end = len(lines)
for index, line in enumerate(lines):
if re.match(r'^\s*\[daemon\]\s*$', line, re.IGNORECASE):
daemon_start = index
continue
if daemon_start is not None and index > daemon_start and re.match(r'^\s*\[[^]]+\]\s*$', line):
daemon_end = index
break
if daemon_start is None:
if lines and lines[-1]:
lines.append('')
lines.extend(['[daemon]', 'WaylandEnable=false'])
else:
setting = re.compile(r'^\s*[#;]?\s*WaylandEnable\s*=.*$', re.IGNORECASE)
for index in range(daemon_start + 1, daemon_end):
if setting.match(lines[index]):
lines[index] = 'WaylandEnable=false'
break
else:
lines.insert(daemon_end, 'WaylandEnable=false')
destination.write_text('\n'.join(lines) + '\n', encoding='utf-8')
PY
if ! cmp -s "$temporary_configuration" "$configuration_path"; then
install -o root -g root -m 644 "$temporary_configuration" "$configuration_path"
configuration_changed=true
fi
rm -f "$temporary_configuration"
;;
sddm.service)
configuration_path='/etc/sddm.conf.d/91-sgu-rustdesk-x11.conf'
install -d -o root -g root -m 755 "$(dirname "$configuration_path")"
temporary_configuration=$(mktemp)
printf '%s\n' '[General]' 'DisplayServer=x11' >"$temporary_configuration"
if ! cmp -s "$temporary_configuration" "$configuration_path"; then
install -o root -g root -m 644 "$temporary_configuration" "$configuration_path"
configuration_changed=true
fi
rm -f "$temporary_configuration"
;;
lightdm.service)
# LightDM's greeter already runs on X11, which RustDesk supports.
;;
*)
printf 'WARNING: Could not identify a supported display manager; RustDesk login-screen access may require X11 configuration.\n' >&2
;;
esac
if [[ $configuration_changed == true ]]; then
printf 'RustDesk login-screen support was configured for X11; reboot after enrollment to activate it.\n'
fi
}
wait_for_rustdesk_server() {
local attempt
local candidate_id
# `systemctl is-active` only confirms that the launcher is alive. On Linux
# it still needs to start the `--server` process for the greeter account.
# Calling `rustdesk --password` during that short window returns successfully
# but does not persist a password for the remote-access process.
for attempt in $(seq 1 20); do
if systemctl is-active --quiet rustdesk \
&& pgrep -f '/usr/share/rustdesk/rustdesk --server' >/dev/null 2>&1; then
candidate_id=$(rustdesk --get-id 2>/dev/null | tail -n 1 | tr -d '[:space:]')
if [[ $candidate_id =~ ^[0-9]+$ ]]; then
RUSTDESK_ID=$candidate_id
return
fi
fi
sleep 1
done
fail 'The RustDesk greeter-side server did not become ready within 20 seconds.'
}
set_access_password() {
local secret_path="${STATE_ROOT}/access.secret"
if [[ -r $secret_path ]] && [[ $(wc -c <"$secret_path") -le 32 ]]; then
ACCESS_PASSWORD=$(<"$secret_path")
else
# RustDesk's permanent-password UI is reliable with a short, printable
# credential. Earlier Linux enrollment generated 48 hexadecimal
# characters; rotate that legacy value to a 24-character password.
ACCESS_PASSWORD="Sgu-$(openssl rand -hex 10)"
umask 077
printf '%s' "$ACCESS_PASSWORD" >"$secret_path"
chmod 600 "$secret_path"
fi
local password_result
wait_for_rustdesk_server
password_result=$(rustdesk --password "$ACCESS_PASSWORD" 2>&1) \
|| fail "RustDesk rejected the permanent password update: $password_result"
[[ $password_result == *Done!* ]] \
|| fail "RustDesk did not acknowledge the permanent password update: $password_result"
rustdesk --option verification-method use-permanent-password >/dev/null
rustdesk --option approve-mode password >/dev/null
systemctl restart rustdesk
systemctl is-active --quiet rustdesk || fail 'The RustDesk service did not restart after setting its permanent password.'
wait_for_rustdesk_server
}
register_with_controller() {
local certificate_path="${STATE_ROOT}/registration-public.cer"
local public_key_path="${STATE_ROOT}/registration-public.pem"
local request_path="${STATE_ROOT}/registration.request"
local encrypted_request_path="${STATE_ROOT}/registration.request.enc"
local result_path="${STATE_ROOT}/registration.result.json"
local request_id
request_id=$(cat /proc/sys/kernel/random/uuid)
local computer_name
computer_name=$(hostname -s | tr '[:lower:]' '[:upper:]')
[[ $computer_name =~ ^[A-Z0-9][A-Z0-9-]{0,62}$ ]] || fail 'The Linux computer name is not valid for RustDesk inventory.'
smb_get 'registration-public.cer' "$certificate_path"
openssl x509 -inform DER -in "$certificate_path" -pubkey -noout >"$public_key_path"
chmod 600 "$public_key_path"
# AccessPassword is hexadecimal and the other values are constrained, so
# this compact JSON is safe to construct without echoing sensitive data.
printf '{"ComputerName":"%s","RustDeskId":"%s","AccessPassword":"%s","RequestId":"%s"}' \
"$computer_name" "$RUSTDESK_ID" "$ACCESS_PASSWORD" "$request_id" >"$request_path"
openssl pkeyutl -encrypt -pubin -inkey "$public_key_path" \
-pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 \
-in "$request_path" -out "$encrypted_request_path"
chmod 600 "$request_path" "$encrypted_request_path"
local remote_request="${computer_name}-${request_id}.request"
smb_put "$encrypted_request_path" "Requests/${remote_request}"
local attempt=0
while ((attempt < 18)); do
rm -f -- "$result_path"
if smb_get "Requests/${request_id}.result.json" "$result_path" 2>/dev/null; then
python3 - "$result_path" "$computer_name" "$RUSTDESK_ID" <<'PY'
import json
import sys
with open(sys.argv[1], encoding='utf-8') as source:
result = json.load(source)
if result.get('Status') != 'Registered':
raise SystemExit(result.get('Error', 'The controller rejected the RustDesk registration.'))
if result.get('ComputerName') != sys.argv[2] or result.get('RustDeskId') != sys.argv[3]:
raise SystemExit('The controller response did not match this computer or RustDesk ID.')
PY
rm -f -- "$request_path" "$encrypted_request_path" "$public_key_path" "$certificate_path" "$result_path"
return
fi
sleep 5
((attempt+=1))
done
fail 'RustDesk was configured locally, but the domain controller did not confirm inventory registration within 90 seconds.'
}
install -d -o root -g root -m 700 "$STATE_ROOT"
trap 'if [[ -n ${KRB5CCNAME:-} ]]; then rm -f -- "${KRB5CCNAME#FILE:}"; fi' EXIT
install_prerequisites
resolve_registration_share
initialize_machine_kerberos
install_rustdesk
read_server_configuration
configure_x11_login_screen
configure_rustdesk
set_access_password
register_with_controller
device_path="${STATE_ROOT}/device.json"
printf '{"ComputerName":"%s","RustDeskId":"%s","ServerAddress":"%s","ConfiguredAt":"%s"}\n' \
"$(hostname -s | tr '[:lower:]' '[:upper:]')" "$RUSTDESK_ID" "$RUSTDESK_SERVER_ADDRESS" \
"$(date --iso-8601=seconds)" >"$device_path"
chmod 600 "$device_path"
printf 'RustDesk enrollment completed. ID: %s\n' "$RUSTDESK_ID"
+356
View File
@@ -0,0 +1,356 @@
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9.-]*$')]
[string]$ServerAddress,
[Parameter(Mandatory)]
[ValidatePattern('^[A-Za-z0-9+/=]+$')]
[string]$ServerPublicKey,
[securestring]$AccessPassword,
[string]$InstallRoot = "$env:ProgramFiles\RustDesk",
[string]$StateRoot = "$env:ProgramData\SGU\RustDesk\Client",
[string]$ClientVersion = '1.4.9',
[uri]$DownloadUri = 'https://github.com/rustdesk/rustdesk/releases/download/1.4.9/rustdesk-1.4.9-x86_64.msi',
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
[string]$ExpectedSha256 = 'C87D2F4CEF2A5ACD6003B6507DCFBF5D5168A256DB082CD90B54D35193224AAA'
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$downloadRoot = Join-Path $env:ProgramData 'SGU\RustDesk\Downloads'
$installerPath = Join-Path $downloadRoot "rustdesk-$ClientVersion-x86_64.msi"
$installerLogPath = Join-Path $downloadRoot "rustdesk-$ClientVersion-install.log"
$secretPath = Join-Path $StateRoot 'access.secret'
$devicePath = Join-Path $StateRoot 'device.json'
function Assert-Administrator {
$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.'
}
}
function Initialize-DataProtection {
if (-not ('SguRustDeskDataProtection' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
public static class SguRustDeskDataProtection {
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct DataBlob { public int cbData; public IntPtr pbData; }
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CryptProtectData(ref DataBlob input, string description,
IntPtr optionalEntropy, IntPtr reserved, IntPtr prompt, int flags, out DataBlob output);
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CryptUnprotectData(ref DataBlob input, IntPtr description,
IntPtr optionalEntropy, IntPtr reserved, IntPtr prompt, int flags, out DataBlob output);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern IntPtr LocalFree(IntPtr memory);
private const int CryptProtectLocalMachine = 0x4;
private static DataBlob ToBlob(byte[] value) {
var blob = new DataBlob { cbData = value.Length, pbData = IntPtr.Zero };
if (value.Length > 0) {
blob.pbData = Marshal.AllocHGlobal(value.Length);
Marshal.Copy(value, 0, blob.pbData, value.Length);
}
return blob;
}
private static byte[] FromBlob(DataBlob blob) {
var value = new byte[blob.cbData];
if (blob.cbData > 0) Marshal.Copy(blob.pbData, value, 0, blob.cbData);
return value;
}
public static byte[] Protect(byte[] value) {
var input = ToBlob(value); var output = new DataBlob();
try {
if (!CryptProtectData(ref input, null, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero,
CryptProtectLocalMachine, out output)) {
throw new Win32Exception(Marshal.GetLastWin32Error());
}
return FromBlob(output);
} finally {
if (input.pbData != IntPtr.Zero) Marshal.FreeHGlobal(input.pbData);
if (output.pbData != IntPtr.Zero) LocalFree(output.pbData);
}
}
public static byte[] Unprotect(byte[] value) {
var input = ToBlob(value); var output = new DataBlob();
try {
if (!CryptUnprotectData(ref input, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero,
0, out output)) {
throw new Win32Exception(Marshal.GetLastWin32Error());
}
return FromBlob(output);
} finally {
if (input.pbData != IntPtr.Zero) Marshal.FreeHGlobal(input.pbData);
if (output.pbData != IntPtr.Zero) LocalFree(output.pbData);
}
}
}
'@ -ErrorAction Stop
}
}
function Set-PrivateDirectoryAcl {
param([Parameter(Mandatory)][string]$Path)
New-Item -ItemType Directory -Path $Path -Force | Out-Null
$acl = New-Object Security.AccessControl.DirectorySecurity
$acl.SetAccessRuleProtection($true, $false)
$inheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit'
$allow = [Security.AccessControl.AccessControlType]::Allow
foreach ($sid in @('S-1-5-18', 'S-1-5-32-544')) {
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
[Security.Principal.SecurityIdentifier]::new($sid),
[Security.AccessControl.FileSystemRights]::FullControl,
$inheritance,
[Security.AccessControl.PropagationFlags]::None,
$allow))
}
Set-Acl -LiteralPath $Path -AclObject $acl
}
function ConvertTo-PlainText {
param([Parameter(Mandatory)][securestring]$SecureString)
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString)
try {
return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
}
finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
}
}
function New-RandomAccessPassword {
$characters = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%*+-_'.ToCharArray()
$bytes = New-Object byte[] 24
$rng = [Security.Cryptography.RandomNumberGenerator]::Create()
try {
$rng.GetBytes($bytes)
}
finally {
$rng.Dispose()
}
$value = -join ($bytes | ForEach-Object { $characters[$_ % $characters.Length] })
return (ConvertTo-SecureString -String $value -AsPlainText -Force)
}
function Save-AccessPassword {
param([Parameter(Mandatory)][securestring]$Password)
$plainText = ConvertTo-PlainText -SecureString $Password
try {
$cipherText = [SguRustDeskDataProtection]::Protect(
[Text.Encoding]::UTF8.GetBytes($plainText))
[IO.File]::WriteAllBytes($secretPath, $cipherText)
}
finally {
$plainText = $null
}
}
function Get-SavedAccessPassword {
if (-not (Test-Path -LiteralPath $secretPath -PathType Leaf)) {
return $null
}
$plainText = [Text.Encoding]::UTF8.GetString(
[SguRustDeskDataProtection]::Unprotect(
[IO.File]::ReadAllBytes($secretPath)))
try {
return (ConvertTo-SecureString -String $plainText -AsPlainText -Force)
}
finally {
$plainText = $null
}
}
function Assert-FileHash {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$ExpectedHash
)
$actualHash = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
if (-not $actualHash.Equals($ExpectedHash, [StringComparison]::OrdinalIgnoreCase)) {
throw "SHA-256 verification failed for $Path."
}
}
function Test-TcpConnection {
param([Parameter(Mandatory)][string]$HostName, [Parameter(Mandatory)][int]$Port)
$client = [Net.Sockets.TcpClient]::new()
try {
$connect = $client.BeginConnect($HostName, $Port, $null, $null)
if (-not $connect.AsyncWaitHandle.WaitOne(5000)) {
return $false
}
$client.EndConnect($connect)
return $true
}
catch {
return $false
}
finally {
$client.Dispose()
}
}
Assert-Administrator
Initialize-DataProtection
if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and configure the managed RustDesk client')) {
return
}
Set-PrivateDirectoryAcl -Path $StateRoot
New-Item -ItemType Directory -Path $downloadRoot -Force | Out-Null
$rustDeskExecutable = Join-Path $InstallRoot 'RustDesk.exe'
$installedVersion = if (Test-Path -LiteralPath $rustDeskExecutable -PathType Leaf) {
[string](Get-Item -LiteralPath $rustDeskExecutable).VersionInfo.ProductVersion
}
else {
''
}
if (-not $installedVersion.StartsWith($ClientVersion, [StringComparison]::OrdinalIgnoreCase)) {
if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf) -or
(Get-FileHash -LiteralPath $installerPath -Algorithm SHA256).Hash -ne $ExpectedSha256) {
Invoke-WebRequest -Uri $DownloadUri -OutFile $installerPath -UseBasicParsing
}
Assert-FileHash -Path $installerPath -ExpectedHash $ExpectedSha256
# The vendor's MSI is the supported path for managed, silent Windows
# deployment. Unlike the GUI-oriented EXE it does not require an
# interactive desktop, which matters for startup/bootstrap execution.
$msiArguments = "/i `"$installerPath`" /qn /norestart " +
"INSTALLFOLDER=`"$InstallRoot`" CREATESTARTMENUSHORTCUTS=`"N`" " +
"CREATEDESKTOPSHORTCUTS=`"N`" INSTALLPRINTER=`"N`" /l*v `"$installerLogPath`""
$installer = Start-Process -FilePath (Join-Path $env:WINDIR 'System32\msiexec.exe') `
-ArgumentList $msiArguments -Wait -PassThru
if ($installer.ExitCode -notin @(0, 3010)) {
throw "RustDesk MSI installation failed with exit code $($installer.ExitCode). See $installerLogPath."
}
}
if (-not (Test-Path -LiteralPath $rustDeskExecutable -PathType Leaf)) {
throw "RustDesk installation did not create $rustDeskExecutable."
}
$rustDeskService = Get-Service -Name 'RustDesk' -ErrorAction SilentlyContinue
if (-not $rustDeskService) {
$serviceInstaller = Start-Process -FilePath $rustDeskExecutable -ArgumentList '--install-service' `
-Wait -PassThru
if ($serviceInstaller.ExitCode -ne 0) {
throw "RustDesk service installation failed with exit code $($serviceInstaller.ExitCode)."
}
Start-Sleep -Seconds 2
$rustDeskService = Get-Service -Name 'RustDesk' -ErrorAction SilentlyContinue
}
if (-not $rustDeskService) {
throw 'RustDesk did not register its Windows service.'
}
Set-Service -Name $rustDeskService.Name -StartupType Automatic
if ($rustDeskService.Status -ne 'Stopped') {
Stop-Service -Name $rustDeskService.Name -Force
$rustDeskService.WaitForStatus('Stopped', (New-TimeSpan -Seconds 20))
}
$rendezvousAddress = "$ServerAddress`:21116"
$relayAddress = "$ServerAddress`:21117"
$configuration = @"
rendezvous_server = '$rendezvousAddress'
nat_type = 1
serial = 0
[options]
custom-rendezvous-server = '$rendezvousAddress'
relay-server = '$relayAddress'
key = '$ServerPublicKey'
"@
$configurationPaths = @(
(Join-Path $env:ProgramData 'RustDesk\config\RustDesk2.toml'),
(Join-Path $env:WINDIR 'ServiceProfiles\LocalService\AppData\Roaming\RustDesk\config\RustDesk2.toml'),
(Join-Path $env:WINDIR 'System32\config\systemprofile\AppData\Roaming\RustDesk\config\RustDesk2.toml'),
(Join-Path $env:SystemDrive 'Users\Default\AppData\Roaming\RustDesk\config\RustDesk2.toml')
)
foreach ($configurationPath in $configurationPaths) {
New-Item -ItemType Directory -Path (Split-Path $configurationPath -Parent) -Force | Out-Null
[IO.File]::WriteAllText($configurationPath, $configuration, [Text.UTF8Encoding]::new($false))
}
$existingPassword = Get-SavedAccessPassword
if ($AccessPassword) {
$managedPassword = $AccessPassword
Save-AccessPassword -Password $managedPassword
$passwordWasGenerated = $false
}
elseif ($existingPassword) {
$managedPassword = $existingPassword
$passwordWasGenerated = $false
}
else {
$managedPassword = New-RandomAccessPassword
Save-AccessPassword -Password $managedPassword
$passwordWasGenerated = $true
}
Start-Service -Name $rustDeskService.Name
$rustDeskService = Get-Service -Name $rustDeskService.Name
$rustDeskService.WaitForStatus('Running', (New-TimeSpan -Seconds 20))
$plainPassword = ConvertTo-PlainText -SecureString $managedPassword
try {
# RustDesk on Windows only reliably treats its CLI output path as a command
# invocation when stdout is consumed. Without the pipeline it can attach
# to the GUI instance and leave a non-interactive bootstrap waiting.
$null = & $rustDeskExecutable --password $plainPassword | Out-String
if ($LASTEXITCODE -ne 0) {
throw "RustDesk could not set the managed access password (exit code $LASTEXITCODE)."
}
}
finally {
$plainPassword = $null
}
$rustDeskId = ((& $rustDeskExecutable --get-id | Out-String).Trim() -split "`r?`n" |
Select-Object -Last 1).Trim()
if ($rustDeskId -notmatch '^\d+$') {
throw "RustDesk returned an invalid device ID: $rustDeskId"
}
if (-not (Test-TcpConnection -HostName $ServerAddress -Port 21116)) {
throw "The RustDesk rendezvous server $rendezvousAddress is not reachable from this client."
}
$device = [ordered]@{
ComputerName = $env:COMPUTERNAME
RustDeskId = $rustDeskId
ServerAddress = $ServerAddress
ServerPublicKeySha256 = ([Security.Cryptography.SHA256]::Create().ComputeHash(
[Text.Encoding]::UTF8.GetBytes($ServerPublicKey)) | ForEach-Object ToString x2) -join ''
ConfiguredAt = (Get-Date).ToString('o')
}
[IO.File]::WriteAllText($devicePath, ($device | ConvertTo-Json), [Text.UTF8Encoding]::new($false))
[pscustomobject]@{
RustDeskId = $rustDeskId
ServerAddress = $ServerAddress
ServiceName = $rustDeskService.Name
ServiceStatus = (Get-Service -Name $rustDeskService.Name).Status.ToString()
RendezvousReachable = $true
AccessPassword = $managedPassword
AccessPasswordWasGenerated = $passwordWasGenerated
DevicePath = $devicePath
}
@@ -0,0 +1,128 @@
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9.-]*$')]
[string]$DomainName,
[Parameter(Mandatory)]
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9.-]*$')]
[string]$ServerAddress,
[Parameter(Mandatory)]
[ValidatePattern('^[A-Za-z0-9+/=]+$')]
[string]$ServerPublicKey,
[string]$RegistrationShareName = 'SGU-RustDesk-Enrollment$',
[string]$DataRoot = "$env:ProgramData\SGU\RustDesk\LinuxEnrollment",
[string]$ProcessorScriptPath = (Join-Path $PSScriptRoot 'Invoke-SguRustDeskLinuxRegistrationProcessor.ps1')
)
$ErrorActionPreference = 'Stop'
function Assert-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Only a local administrator can install Linux RustDesk enrollment.'
}
}
function Get-EnrollmentCertificate {
param([Parameter(Mandatory)][string]$FriendlyName)
$certificate = Get-ChildItem -Path Cert:\LocalMachine\My |
Where-Object FriendlyName -eq $FriendlyName |
Where-Object HasPrivateKey |
Select-Object -First 1
if (-not $certificate) {
$certificate = New-SelfSignedCertificate `
-Subject 'CN=SGU RustDesk Linux enrollment' `
-FriendlyName $FriendlyName `
-CertStoreLocation 'Cert:\LocalMachine\My' `
-KeyAlgorithm RSA `
-KeyLength 3072 `
-KeyUsage KeyEncipherment,DigitalSignature `
-NotAfter (Get-Date).AddYears(5)
}
return $certificate
}
function Set-EnrollmentDirectoryAcl {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$DomainNetbiosName
)
New-Item -ItemType Directory -Path $Path -Force | Out-Null
$arguments = @(
"`"$Path`"", '/inheritance:r',
'/grant:r', 'SYSTEM:(OI)(CI)(F)',
'BUILTIN\Administrators:(OI)(CI)(F)',
"$DomainNetbiosName\Domain Computers:(OI)(CI)(M)"
)
& icacls.exe @arguments | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Could not secure the Linux RustDesk enrollment directory $Path."
}
}
Assert-Administrator
Import-Module ActiveDirectory -ErrorAction Stop
if (-not (Test-Path -LiteralPath $ProcessorScriptPath -PathType Leaf)) {
throw "The Linux RustDesk registration processor is missing: $ProcessorScriptPath"
}
if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install the protected Linux RustDesk enrollment endpoint')) {
return
}
$domain = Get-ADDomain -Identity $DomainName
$publicRoot = Join-Path $DataRoot 'Public'
$requestsRoot = Join-Path $publicRoot 'Requests'
$archiveRoot = Join-Path $publicRoot 'Archive'
$rejectedRoot = Join-Path $publicRoot 'Rejected'
foreach ($path in @($DataRoot, $publicRoot, $requestsRoot, $archiveRoot, $rejectedRoot)) {
Set-EnrollmentDirectoryAcl -Path $path -DomainNetbiosName $domain.NetBIOSName
}
$certificate = Get-EnrollmentCertificate -FriendlyName 'SGU RustDesk Linux enrollment'
$publicCertificatePath = Join-Path $publicRoot 'registration-public.cer'
Export-Certificate -Cert $certificate -FilePath $publicCertificatePath -Force | Out-Null
$clientConfiguration = [ordered]@{
ServerAddress = $ServerAddress
ServerPublicKey = $ServerPublicKey
RegistrationShare = "\\$env:COMPUTERNAME\$RegistrationShareName"
UpdatedAt = (Get-Date).ToString('o')
}
[IO.File]::WriteAllText((Join-Path $publicRoot 'rustdesk-client.json'),
($clientConfiguration | ConvertTo-Json), [Text.UTF8Encoding]::new($false))
$share = Get-SmbShare -Name $RegistrationShareName -ErrorAction SilentlyContinue
if (-not $share) {
New-SmbShare -Name $RegistrationShareName -Path $publicRoot `
-FullAccess @('SYSTEM', 'BUILTIN\Administrators') `
-ChangeAccess "$($domain.NetBIOSName)\Domain Computers" | Out-Null
}
elseif ($share.Path -ne $publicRoot) {
throw "The existing SMB share $RegistrationShareName points to $($share.Path), not $publicRoot."
}
$installedProcessor = Join-Path $DataRoot 'Invoke-SguRustDeskLinuxRegistrationProcessor.ps1'
Copy-Item -LiteralPath $ProcessorScriptPath -Destination $installedProcessor -Force
$processorArguments = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$installedProcessor`" -DataRoot `"$DataRoot`" -CertificateThumbprint $($certificate.Thumbprint)"
$action = New-ScheduledTaskAction -Execute (Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe') `
-Argument $processorArguments
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
-RepetitionInterval (New-TimeSpan -Minutes 1) -RepetitionDuration (New-TimeSpan -Days 3650)
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName 'SGU-RustDesk-LinuxRegistration' -Action $action -Trigger $trigger `
-Principal $principal -Description 'Registers encrypted RustDesk credentials sent by domain-joined Linux computers.' -Force | Out-Null
New-NetFirewallRule -DisplayName 'SGU RustDesk Linux enrollment SMB' -Group 'SGU RustDesk' `
-Direction Inbound -Action Allow -Protocol TCP -LocalPort 445 -Profile Domain -ErrorAction SilentlyContinue | Out-Null
[pscustomobject]@{
RegistrationShare = "\\$env:COMPUTERNAME\$RegistrationShareName"
PublicCertificatePath = $publicCertificatePath
RegistrationTask = 'SGU-RustDesk-LinuxRegistration'
CertificateThumbprint = $certificate.Thumbprint
}
+241
View File
@@ -0,0 +1,241 @@
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9.-]*$')]
[string]$ServerAddress,
[string]$InstallRoot = "$env:ProgramFiles\SGU\RustDeskServer",
[string]$DataRoot = "$env:ProgramData\SGU\RustDesk\Server",
[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'
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$hbbsTaskName = 'SGU-RustDesk-hbbs'
$hbbrTaskName = 'SGU-RustDesk-hbbr'
$downloadRoot = Join-Path $env:ProgramData 'SGU\RustDesk\Downloads'
$archivePath = Join-Path $downloadRoot 'rustdesk-server-windows-x86_64-1.1.16.zip'
function Assert-Administrator {
$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.'
}
}
function Set-PrivateDirectoryAcl {
param([Parameter(Mandatory)][string]$Path)
New-Item -ItemType Directory -Path $Path -Force | Out-Null
$acl = New-Object Security.AccessControl.DirectorySecurity
$acl.SetAccessRuleProtection($true, $false)
$inheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit'
$allow = [Security.AccessControl.AccessControlType]::Allow
foreach ($sid in @('S-1-5-18', 'S-1-5-32-544')) {
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
[Security.Principal.SecurityIdentifier]::new($sid),
[Security.AccessControl.FileSystemRights]::FullControl,
$inheritance,
[Security.AccessControl.PropagationFlags]::None,
$allow))
}
Set-Acl -LiteralPath $Path -AclObject $acl
}
function Assert-FileHash {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$ExpectedHash
)
$actualHash = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
if (-not $actualHash.Equals($ExpectedHash, [StringComparison]::OrdinalIgnoreCase)) {
throw "SHA-256 verification failed for $Path."
}
}
function Copy-IfDifferent {
param(
[Parameter(Mandatory)][string]$Source,
[Parameter(Mandatory)][string]$Destination
)
if (-not (Test-Path -LiteralPath $Destination -PathType Leaf) -or
(Get-FileHash -LiteralPath $Source -Algorithm SHA256).Hash -ne
(Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash) {
Copy-Item -LiteralPath $Source -Destination $Destination -Force
return $true
}
return $false
}
function Set-RustDeskFirewallRule {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][ValidateSet('TCP', 'UDP')][string]$Protocol,
[Parameter(Mandatory)][string]$LocalPort
)
$rule = Get-NetFirewallRule -DisplayName $Name -ErrorAction SilentlyContinue
if (-not $rule) {
$rule = New-NetFirewallRule -DisplayName $Name -Group 'SGU RustDesk' `
-Direction Inbound -Action Allow -Protocol $Protocol -LocalPort $LocalPort `
-RemoteAddress $FirewallRemoteAddress -Profile Domain -Enabled True
}
else {
$rule | Set-NetFirewallRule -Enabled True -Profile Domain -Action Allow | Out-Null
$rule | Get-NetFirewallPortFilter | Set-NetFirewallPortFilter `
-Protocol $Protocol -LocalPort $LocalPort | Out-Null
$rule | Get-NetFirewallAddressFilter | Set-NetFirewallAddressFilter `
-RemoteAddress $FirewallRemoteAddress | Out-Null
}
}
function Stop-RustDeskTasks {
foreach ($taskName in @($hbbsTaskName, $hbbrTaskName)) {
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
if ($task -and $task.State -eq 'Running') {
Stop-ScheduledTask -TaskName $taskName
}
}
Start-Sleep -Seconds 1
}
function Register-RustDeskServerTask {
param(
[Parameter(Mandatory)][string]$TaskName,
[Parameter(Mandatory)][string]$Executable,
[string]$Arguments
)
# New-ScheduledTaskAction rejects an empty -Argument value. hbbr has no
# command-line arguments, whereas hbbs needs the relay endpoint, so add
# the parameter only when it is meaningful.
$actionParameters = @{
Execute = $Executable
WorkingDirectory = $DataRoot
}
if (-not [string]::IsNullOrWhiteSpace($Arguments)) {
$actionParameters.Argument = $Arguments
}
$action = New-ScheduledTaskAction @actionParameters
$trigger = New-ScheduledTaskTrigger -AtStartup
$trigger.Delay = 'PT30S'
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit ([TimeSpan]::Zero) `
-RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
-Settings $settings -User 'SYSTEM' -RunLevel Highest -Force | Out-Null
$registeredTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop
if ($registeredTask.State -ne 'Running') {
Start-ScheduledTask -TaskName $TaskName
}
}
function Wait-ForRustDeskServer {
for ($attempt = 1; $attempt -le 30; $attempt++) {
$hbbsListening = [bool](Get-NetTCPConnection -LocalPort 21116 -State Listen `
-ErrorAction SilentlyContinue)
$hbbrListening = [bool](Get-NetTCPConnection -LocalPort 21117 -State Listen `
-ErrorAction SilentlyContinue)
$publicKeyReady = Test-Path -LiteralPath (Join-Path $DataRoot 'id_ed25519.pub') -PathType Leaf
if ($hbbsListening -and $hbbrListening -and $publicKeyReady) {
return
}
Start-Sleep -Seconds 2
}
throw 'RustDesk hbbs/hbbr did not become ready within 60 seconds.'
}
Assert-Administrator
if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and configure the RustDesk OSS rendezvous and relay server')) {
return
}
Set-PrivateDirectoryAcl -Path $DataRoot
$managementRoot = Split-Path $DataRoot -Parent
Set-PrivateDirectoryAcl -Path $managementRoot
New-Item -ItemType Directory -Path $InstallRoot,$downloadRoot -Force | Out-Null
if (-not (Test-Path -LiteralPath $archivePath -PathType Leaf) -or
(Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash -ne $ExpectedSha256) {
Invoke-WebRequest -Uri $DownloadUri -OutFile $archivePath -UseBasicParsing
}
Assert-FileHash -Path $archivePath -ExpectedHash $ExpectedSha256
$stagingRoot = Join-Path $env:TEMP ('sgu-rustdesk-server-' + [Guid]::NewGuid().ToString('N'))
try {
Expand-Archive -LiteralPath $archivePath -DestinationPath $stagingRoot -Force
$payloadRoot = Join-Path $stagingRoot 'x86_64'
$sourceHbbs = Join-Path $payloadRoot 'hbbs.exe'
$sourceHbbr = Join-Path $payloadRoot 'hbbr.exe'
foreach ($required in @($sourceHbbs, $sourceHbbr)) {
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
throw "The verified RustDesk archive is missing $required."
}
}
$targetHbbs = Join-Path $InstallRoot 'hbbs.exe'
$targetHbbr = Join-Path $InstallRoot 'hbbr.exe'
$requiresBinaryUpdate =
-not (Test-Path -LiteralPath $targetHbbs) -or
-not (Test-Path -LiteralPath $targetHbbr) -or
(Get-FileHash -LiteralPath $sourceHbbs -Algorithm SHA256).Hash -ne
(Get-FileHash -LiteralPath $targetHbbs -Algorithm SHA256).Hash -or
(Get-FileHash -LiteralPath $sourceHbbr -Algorithm SHA256).Hash -ne
(Get-FileHash -LiteralPath $targetHbbr -Algorithm SHA256).Hash
if ($requiresBinaryUpdate) {
Stop-RustDeskTasks
Copy-IfDifferent -Source $sourceHbbs -Destination $targetHbbs | Out-Null
Copy-IfDifferent -Source $sourceHbbr -Destination $targetHbbr | Out-Null
}
}
finally {
if (Test-Path -LiteralPath $stagingRoot) {
Remove-Item -LiteralPath $stagingRoot -Recurse -Force
}
}
Set-RustDeskFirewallRule -Name 'SGU RustDesk hbbs (TCP)' -Protocol TCP -LocalPort '21115-21116'
Set-RustDeskFirewallRule -Name 'SGU RustDesk hbbr (TCP)' -Protocol TCP -LocalPort '21117'
Set-RustDeskFirewallRule -Name 'SGU RustDesk hbbs (UDP)' -Protocol UDP -LocalPort '21116'
Register-RustDeskServerTask -TaskName $hbbrTaskName -Executable (Join-Path $InstallRoot 'hbbr.exe')
Register-RustDeskServerTask -TaskName $hbbsTaskName -Executable (Join-Path $InstallRoot 'hbbs.exe') `
-Arguments "-r $ServerAddress`:21117"
Wait-ForRustDeskServer
$publicKey = (Get-Content -LiteralPath (Join-Path $DataRoot 'id_ed25519.pub') -Raw).Trim()
if ([string]::IsNullOrWhiteSpace($publicKey)) {
throw 'RustDesk generated an empty public key.'
}
$statusPath = Join-Path (Split-Path $DataRoot -Parent) 'server.json'
$status = [ordered]@{
ServerAddress = $ServerAddress
PublicKey = $publicKey
PublicKeySha256 = ([Security.Cryptography.SHA256]::Create().ComputeHash(
[Text.Encoding]::UTF8.GetBytes($publicKey)) | ForEach-Object ToString x2) -join ''
HbbsTaskName = $hbbsTaskName
HbbrTaskName = $hbbrTaskName
HbbsTcpPort = 21116
HbbrTcpPort = 21117
InstalledAt = (Get-Date).ToString('o')
}
[IO.File]::WriteAllText($statusPath, ($status | ConvertTo-Json), [Text.UTF8Encoding]::new($false))
[pscustomobject]@{
ServerAddress = $ServerAddress
PublicKey = $publicKey
PublicKeySha256 = $status.PublicKeySha256
HbbsTask = (Get-ScheduledTask -TaskName $hbbsTaskName).State.ToString()
HbbrTask = (Get-ScheduledTask -TaskName $hbbrTaskName).State.ToString()
HbbsListening = [bool](Get-NetTCPConnection -LocalPort 21116 -State Listen -ErrorAction SilentlyContinue)
HbbrListening = [bool](Get-NetTCPConnection -LocalPort 21117 -State Listen -ErrorAction SilentlyContinue)
StatusPath = $statusPath
}
+727 -40
View File
@@ -3,19 +3,60 @@
param(
[ipaddress]$DomainControllerIPv4Address,
[string]$NetworkInterfaceAlias,
[ipaddress]$ClientIPv4Address,
[ValidateRange(1, 32)]
[int]$ClientPrefixLength = 24,
[PSCredential]$DomainCredential,
[string]$DomainName = 'lci.lasalle.mx',
[string]$DomainNetbios = 'LCI',
[string]$DomainName,
[string]$DomainNetbios,
[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
)
$ErrorActionPreference = 'Stop'
$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,42 +85,235 @@ function Assert-PackageManifest {
throw "Bootstrap package integrity check failed: $($entry.Path)"
}
}
return $manifest
}
function Resolve-ClientInterfaceAlias {
param([string]$RequestedAlias)
param(
[string]$RequestedAlias,
[Parameter(Mandatory)][ipaddress]$DomainControllerAddress
)
if ($RequestedAlias) {
Get-NetAdapter -Name $RequestedAlias -ErrorAction Stop | Out-Null
return $RequestedAlias
# IP interfaces include tunnel/PPP adapters that Get-NetAdapter can omit.
$interfaces = @(Get-NetIPInterface -AddressFamily IPv4 | Where-Object {
$_.ConnectionState -eq 'Connected' -and
(-not $RequestedAlias -or $_.InterfaceAlias -eq $RequestedAlias)
})
$preferred = @(Find-NetRoute -RemoteIPAddress $DomainControllerAddress.IPAddressToString `
-ErrorAction SilentlyContinue | Where-Object { $_.PSObject.Properties['IPAddress'] })
$attempts = @()
$candidates = @(foreach ($interface in $interfaces) {
$addresses = @(Get-NetIPAddress -InterfaceIndex $interface.InterfaceIndex -AddressFamily IPv4 `
-ErrorAction SilentlyContinue | Where-Object {
$_.AddressState -eq 'Preferred' -and -not $_.SkipAsSource -and
$_.IPAddress -notmatch '^(0\.|127\.|169\.254\.)'
})
if ($addresses.Count -eq 0) {
$attempts += "$($interface.InterfaceAlias): no usable IPv4 address (check DHCP/static configuration)"
continue
}
$route = Get-NetRoute -InterfaceIndex $interface.InterfaceIndex -AddressFamily IPv4 `
-PolicyStore ActiveStore -ErrorAction SilentlyContinue | Where-Object {
$parts = $_.DestinationPrefix -split '/'
Test-IPv4AddressesSharePrefix -FirstAddress $DomainControllerAddress `
-SecondAddress ([ipaddress]$parts[0]) -PrefixLength ([int]$parts[1])
} | Sort-Object @{ Expression = { [int]($_.DestinationPrefix -split '/')[1] }; Descending = $true },
RouteMetric | Select-Object -First 1
if ($route) {
foreach ($address in $addresses) {
[pscustomobject]@{
InterfaceAlias = $interface.InterfaceAlias
InterfaceIndex = [int]$interface.InterfaceIndex
IPAddress = $address.IPAddress
NextHop = $route.NextHop
Preferred = @($preferred | Where-Object IPAddress -eq $address.IPAddress).Count -gt 0
PrefixLength = [int]($route.DestinationPrefix -split '/')[1]
Metric = [int]$route.RouteMetric + [int]$interface.InterfaceMetric
}
}
}
else { $attempts += "$($interface.InterfaceAlias): no route to $DomainControllerAddress" }
})
if ($interfaces.Count -eq 0) { $attempts += 'No matching connected IPv4 interface' }
foreach ($candidate in ($candidates | Sort-Object @{ Expression = { $_.Preferred }; Descending = $true },
@{ Expression = { $_.PrefixLength }; Descending = $true }, Metric, InterfaceIndex, IPAddress)) {
Write-Host "Checking $($candidate.InterfaceAlias) ($($candidate.IPAddress)) -> $DomainControllerAddress..."
if (Test-TcpPort -Address $DomainControllerAddress -Port 5985 -TimeoutMilliseconds 2000 `
-SourceAddress ([ipaddress]$candidate.IPAddress) -InterfaceIndex $candidate.InterfaceIndex) {
return $candidate
}
$attempts += "$($candidate.InterfaceAlias) [$($candidate.IPAddress), next hop $($candidate.NextHop)]: TCP 5985 unavailable"
}
throw "Cannot reach SGU server $DomainControllerAddress. $($attempts -join '; '). Check the LAN/VPN connection, DHCP or an administrator-assigned IP, routes and the server WinRM firewall. No client IP was changed."
}
$defaultRoute = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' `
function Test-IPv4AddressesSharePrefix {
param(
[Parameter(Mandatory)][ipaddress]$FirstAddress,
[Parameter(Mandatory)][ipaddress]$SecondAddress,
[Parameter(Mandatory)][ValidateRange(0, 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 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 Wait-ClientInterface {
param(
[string]$RequestedAlias,
[Parameter(Mandatory)][ipaddress]$DomainControllerAddress,
[int]$TimeoutSeconds = 20
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
do {
try {
return Resolve-ClientInterfaceAlias -RequestedAlias $RequestedAlias `
-DomainControllerAddress $DomainControllerAddress
}
catch {
$lastFailure = $_
if ((Get-Date) -ge $deadline) { throw $lastFailure }
Write-Host 'Waiting for DHCP, VPN routes or server connectivity to become ready...'
Start-Sleep -Seconds 2
}
} while ($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 |
Sort-Object RouteMetric,InterfaceMetric |
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 ($defaultRoute) {
return [string](Get-NetAdapter -InterfaceIndex $defaultRoute.InterfaceIndex).Name
if (-not $RequestedAddress -and $matchingAddress) {
return [ipaddress]$matchingAddress.IPAddress
}
if (-not $RequestedAddress) {
throw 'Static addressing requires an explicit -ClientIPv4Address. Automatic enrollment preserves DHCP and existing addresses.'
}
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
}
$upAdapters = @(Get-NetAdapter | Where-Object Status -eq 'Up')
if ($upAdapters.Count -eq 1) {
return [string]$upAdapters[0].Name
$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)
$aliases = ($upAdapters.Name | Sort-Object) -join ', '
throw "Could not select a network adapter. Re-run with -NetworkInterfaceAlias. Available adapters: $aliases"
$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 {
param(
[Parameter(Mandatory)][ipaddress]$Address,
[Parameter(Mandatory)][int]$Port,
[int]$TimeoutMilliseconds = 5000
[int]$TimeoutMilliseconds = 5000,
[ipaddress]$SourceAddress,
[int]$InterfaceIndex
)
$client = [Net.Sockets.TcpClient]::new()
$client = [Net.Sockets.TcpClient]::new([Net.Sockets.AddressFamily]::InterNetwork)
$connect = $null
try {
if ($InterfaceIndex) {
# IP_UNICAST_IF (31) expects the interface index in network byte order.
$client.Client.SetSocketOption([Net.Sockets.SocketOptionLevel]::IP,
[Net.Sockets.SocketOptionName]31, [Net.IPAddress]::HostToNetworkOrder($InterfaceIndex))
}
if ($SourceAddress) {
$client.Client.Bind([Net.IPEndPoint]::new($SourceAddress, 0))
}
$connect = $client.BeginConnect($Address, $Port, $null, $null)
if (-not $connect.AsyncWaitHandle.WaitOne($TimeoutMilliseconds)) {
return $false
@@ -92,30 +326,223 @@ function Test-TcpPort {
}
finally {
$client.Dispose()
if ($connect) { $connect.AsyncWaitHandle.Close() }
}
}
function Set-ClientServerRoute {
param(
[Parameter(Mandatory)]$SelectedInterface,
[Parameter(Mandatory)][ipaddress]$DomainControllerAddress
)
$current = @(Find-NetRoute -RemoteIPAddress $DomainControllerAddress.IPAddressToString |
Where-Object { $_.PSObject.Properties['IPAddress'] })
if (@($current | Where-Object InterfaceIndex -eq $SelectedInterface.InterfaceIndex).Count -gt 0) { return }
# Only pin this server when Windows currently chooses a different interface.
# Do not replace default routes or change interface metrics used by Internet traffic.
$route = New-NetRoute -DestinationPrefix "$DomainControllerAddress/32" `
-InterfaceIndex $SelectedInterface.InterfaceIndex -NextHop $SelectedInterface.NextHop `
-RouteMetric 1
$current = @(Find-NetRoute -RemoteIPAddress $DomainControllerAddress.IPAddressToString |
Where-Object { $_.PSObject.Properties['IPAddress'] })
if (@($current | Where-Object InterfaceIndex -eq $SelectedInterface.InterfaceIndex).Count -eq 0) {
$route | Remove-NetRoute -Confirm:$false
throw "Windows still routes $DomainControllerAddress through another interface. Resolve conflicting host routes or VPN policies and retry."
}
}
function Set-ClientDomainDns {
param(
[Parameter(Mandatory)][string]$DnsDomain,
[Parameter(Mandatory)][ipaddress]$ServerAddress
)
$displayName = "SGU domain DNS - $DnsDomain"
$existing = @(Get-DnsClientNrptRule -ErrorAction Stop | Where-Object DisplayName -eq $displayName)
if ($existing.Count -eq 1 -and @($existing[0].NameServers) -contains $ServerAddress.IPAddressToString -and
@($existing[0].Namespace) -contains ".$DnsDomain" -and @($existing[0].Namespace) -contains $DnsDomain) { return }
$existing | Remove-DnsClientNrptRule -Force
Add-DnsClientNrptRule -Namespace @($DnsDomain, ".$DnsDomain") `
-NameServers $ServerAddress.IPAddressToString -DisplayName $displayName | Out-Null
Clear-DnsClientCache
}
function Test-ClientDomainDns {
param([Parameter(Mandatory)][string]$DnsDomain)
try {
$records = @(Resolve-DnsName -Type SRV "_ldap._tcp.dc._msdcs.$DnsDomain" `
-DnsOnly -ErrorAction Stop)
return @($records | Where-Object {
$_.Type -eq 'SRV' -and -not [string]::IsNullOrWhiteSpace([string]$_.NameTarget)
}).Count -gt 0
}
catch {
return $false
}
}
function Set-ClientHostMappings {
param(
[Parameter(Mandatory)][ipaddress]$ServerAddress,
[Parameter(Mandatory)][string[]]$HostNames
)
$hostsPath = Join-Path $env:SystemRoot 'System32\drivers\etc\hosts'
$managedNames = @($HostNames |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
ForEach-Object { $_.Trim().ToLowerInvariant() } |
Select-Object -Unique)
$preservedLines = foreach ($line in [IO.File]::ReadAllLines($hostsPath)) {
$data = ($line -split '#', 2)[0].Trim()
$tokens = @($data -split '\s+' | Where-Object { $_ })
$lineNames = if ($tokens.Count -gt 1) {
@($tokens[1..($tokens.Count - 1)] | ForEach-Object { $_.ToLowerInvariant() })
}
else { @() }
if (@($lineNames | Where-Object { $managedNames -contains $_ }).Count -eq 0) {
$line
}
}
$mapping = '{0} {1} # SGU managed direct enrollment' -f
$ServerAddress.IPAddressToString,($managedNames -join ' ')
[IO.File]::WriteAllLines($hostsPath, @($preservedLines) + $mapping,
[Text.UTF8Encoding]::new($false))
Clear-DnsClientCache
}
function Enable-ClientDnsOverHttps {
param(
[Parameter(Mandatory)][ipaddress]$ServerAddress,
[Parameter(Mandatory)][string]$DohTemplate,
[Parameter(Mandatory)][string]$CertificateBase64
)
if (-not (Get-Command Add-DnsClientDohServerAddress -ErrorAction SilentlyContinue)) {
throw 'This Windows build cannot configure DNS over HTTPS. Permit traditional DNS to the supplied server or update Windows, then retry.'
}
$certificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new(
[Convert]::FromBase64String($CertificateBase64))
$store = [Security.Cryptography.X509Certificates.X509Store]::new(
[Security.Cryptography.X509Certificates.StoreName]::Root,
[Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine)
try {
$store.Open([Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
if (-not @($store.Certificates | Where-Object Thumbprint -eq $certificate.Thumbprint).Count) {
$store.Add($certificate)
}
}
finally {
$store.Close()
$certificate.Dispose()
}
$existing = Get-DnsClientDohServerAddress -ErrorAction SilentlyContinue |
Where-Object ServerAddress -eq $ServerAddress.IPAddressToString |
Select-Object -First 1
if ($existing) {
Set-DnsClientDohServerAddress -ServerAddress $ServerAddress.IPAddressToString `
-DohTemplate $DohTemplate -AllowFallbackToUdp $false -AutoUpgrade $true | Out-Null
}
else {
Add-DnsClientDohServerAddress -ServerAddress $ServerAddress.IPAddressToString `
-DohTemplate $DohTemplate -AllowFallbackToUdp $false -AutoUpgrade $true | Out-Null
}
& "$env:SystemRoot\System32\netsh.exe" dnsclient set global doh=yes | Out-Null
if ($LASTEXITCODE -ne 0) {
throw 'Windows did not enable its global DNS over HTTPS client setting.'
}
Clear-DnsClientCache
}
function Assert-ClientOperatingSystem {
param(
[Parameter(Mandatory)]$OperatingSystem,
[Parameter(Mandatory)][string]$Edition,
[Parameter(Mandatory)][string]$Architecture
)
if ([int]$OperatingSystem.ProductType -ne 1) {
throw 'The client bootstrap supports Windows 10/11 workstations. Use the server bootstrap on Windows Server.'
}
if ([int]$OperatingSystem.BuildNumber -lt 14393 -or $Architecture -ne 'AMD64') {
throw 'This package requires Windows 10 1607 or later, or Windows 11, running x64 Windows PowerShell.'
}
if ($Edition -match '^Core' -or $Edition -match 'Home') {
throw "Windows edition '$Edition' cannot join an Active Directory domain. Upgrade to Pro, Enterprise, or Education, then run this same bootstrap again."
}
}
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) {
throw 'The client bootstrap supports Windows 10/11 workstations. Use the server bootstrap on Windows Server.'
}
$edition = (Get-WindowsEdition -Online).Edition
if ($edition -match '^Core' -or $edition -match 'Home') {
throw "Windows edition '$edition' cannot join an on-premises Active Directory domain or host RDP. Upgrade to Pro, Enterprise, or Education, then run this same bootstrap again."
}
Assert-ClientOperatingSystem -OperatingSystem $operatingSystem -Edition $edition `
-Architecture $env:PROCESSOR_ARCHITECTURE
$windowsBuild = [int]$operatingSystem.BuildNumber
$windowsName = if ($windowsBuild -ge 22000) { 'Windows 11' } else { 'Windows 10' }
Write-Host "$windowsName (build $windowsBuild): unified SGU enrollment."
if (-not $DomainControllerIPv4Address) {
$DomainControllerIPv4Address = [ipaddress](Read-Host 'Fixed IPv4 address of the SGU domain controller')
}
if (-not $ComputerOuDn) {
$baseDn = (($DomainName -split '\.') | ForEach-Object { "DC=$_" }) -join ','
$ComputerOuDn = "OU=Laboratorio,$baseDn"
if ($DomainControllerIPv4Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork -or
$DomainControllerIPv4Address.IPAddressToString -match '^(0\.|127\.|169\.254\.|22[4-9]\.|23\d\.|24\d\.|25[0-5]\.)') {
throw 'Enter a reachable unicast IPv4 address for the domain controller.'
}
$publicDirectEnrollment = $ConnectivityMode -eq 'Direct' -and
-not (Test-PrivateIPv4Address -Address $DomainControllerIPv4Address)
if ($publicDirectEnrollment) {
Write-Host 'Public domain-controller address detected. Direct DNS and domain discovery will be configured automatically.'
}
$packageRoot = $PSScriptRoot
Assert-PackageManifest -PackageRoot $packageRoot
$packageManifest = Assert-PackageManifest -PackageRoot $packageRoot
# Retain the old parameter for existing automation; neither name restricts networking.
if ($CompatibilityProfile -ne 'Auto') {
Write-Warning 'CompatibilityProfile is deprecated. This package uses the same implementation on Windows 10 and 11.'
}
$CompatibilityProfile = 'Auto'
$scriptsRoot = Join-Path $packageRoot 'payload\scripts'
$providerPublishPath = Join-Path $packageRoot 'payload\credential-provider'
$runtimeInstaller = Get-ChildItem (Join-Path $packageRoot 'payload\prerequisites') `
@@ -124,6 +551,7 @@ $runtimeInstaller = Get-ChildItem (Join-Path $packageRoot 'payload\prerequisites
Select-Object -First 1
foreach ($requiredPath in @(
(Join-Path $scriptsRoot 'Enroll-SguDomainClient.ps1'),
(Join-Path $scriptsRoot 'Install-SguRustDeskClient.ps1'),
(Join-Path $scriptsRoot 'Register-SguClientCertificate.ps1'),
(Join-Path $providerPublishPath 'SGU.CredentialProvider.comhost.dll'))) {
if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
@@ -133,20 +561,67 @@ foreach ($requiredPath in @(
if (-not $runtimeInstaller) {
throw 'The offline Microsoft .NET 10 x64 runtime installer is missing from the client package.'
}
if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enroll with SGU server $DomainControllerIPv4Address")) { return }
if ($ClientIPv4Address) {
if (-not $NetworkInterfaceAlias -or $ConnectivityMode -eq 'AzureP2S') {
throw 'Explicit static IP setup requires -NetworkInterfaceAlias with Direct connectivity. Omit -ClientIPv4Address to preserve the current LAN/VPN configuration.'
}
$ClientIPv4Address = Set-ClientDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
-DomainControllerAddress $DomainControllerIPv4Address `
-RequestedAddress $ClientIPv4Address -PrefixLength $ClientPrefixLength
}
$NetworkInterfaceAlias = Resolve-ClientInterfaceAlias -RequestedAlias $NetworkInterfaceAlias
Set-DnsClientServerAddress -InterfaceAlias $NetworkInterfaceAlias `
-ServerAddresses $DomainControllerIPv4Address.IPAddressToString
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
}
$selectedInterface = Wait-ClientInterface -RequestedAlias $NetworkInterfaceAlias `
-DomainControllerAddress $DomainControllerIPv4Address
$NetworkInterfaceAlias = $selectedInterface.InterfaceAlias
$ClientIPv4Address = [ipaddress]$selectedInterface.IPAddress
Set-ClientServerRoute -SelectedInterface $selectedInterface -DomainControllerAddress $DomainControllerIPv4Address
Write-Host "Using $NetworkInterfaceAlias ($ClientIPv4Address)."
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) {
$suggestedUser = if ($DomainNetbios) { "$DomainNetbios\Administrator" }
elseif ($DomainName) { "Administrator@$DomainName" } else { 'Administrator' }
$DomainCredential = Get-Credential `
-UserName "$DomainNetbios\Administrator" `
-Message "Credential permitted to enroll this computer in $DomainName"
-UserName $suggestedUser `
-Message "Domain account permitted to enroll this computer (DOMAIN\user or user@domain)"
}
if (-not $DomainCredential) { throw 'Enrollment cancelled: no domain credential was provided.' }
New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null
$clientCertificatePath = Join-Path $temporaryRoot 'client.cer'
@@ -176,20 +651,196 @@ try {
$serverIdentity = Invoke-Command -Session $session -ScriptBlock {
$computer = Get-CimInstance Win32_ComputerSystem
if ([int]$computer.DomainRole -lt 4) { throw 'The supplied server is not an Active Directory domain controller.' }
Import-Module ActiveDirectory -ErrorAction Stop
$domain = Get-ADDomain -ErrorAction Stop
$labOu = Get-ADOrganizationalUnit -LDAPFilter '(ou=Laboratorio)' `
-SearchBase $domain.DistinguishedName -SearchScope OneLevel -ErrorAction Stop |
Select-Object -First 1
$brokerService = Get-Service SGUAuthBroker -ErrorAction SilentlyContinue
$rustDeskStatusPath = Join-Path $env:ProgramData 'SGU\RustDesk\server.json'
$rustDeskStatus = if (Test-Path -LiteralPath $rustDeskStatusPath -PathType Leaf) {
Get-Content -LiteralPath $rustDeskStatusPath -Raw | ConvertFrom-Json
}
else {
$null
}
$hbbsTask = Get-ScheduledTask -TaskName 'SGU-RustDesk-hbbs' -ErrorAction SilentlyContinue
$hbbrTask = Get-ScheduledTask -TaskName 'SGU-RustDesk-hbbr' -ErrorAction SilentlyContinue
[pscustomobject]@{
ComputerName = $env:COMPUTERNAME
Domain = $computer.Domain
DomainNetbios = $domain.NetBIOSName
ComputerContainer = if ($labOu) { $labOu.DistinguishedName } else { $domain.ComputersContainer }
BrokerService = if ($brokerService) { $brokerService.Status.ToString() } else { 'Missing' }
RustDeskServerAddress = if ($rustDeskStatus) { [string]$rustDeskStatus.ServerAddress } else { $null }
RustDeskPublicKey = if ($rustDeskStatus) { [string]$rustDeskStatus.PublicKey } else { $null }
RustDeskHbbsTask = if ($hbbsTask) { $hbbsTask.State.ToString() } else { 'Missing' }
RustDeskHbbrTask = if ($hbbrTask) { $hbbrTask.State.ToString() } else { 'Missing' }
}
}
if (-not $serverIdentity.Domain -or
-not $serverIdentity.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreCase)) {
if (-not $serverIdentity.Domain -or ($DomainName -and
-not $serverIdentity.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreCase))) {
throw "The server at $DomainControllerIPv4Address belongs to $($serverIdentity.Domain), not $DomainName."
}
if ($DomainNetbios -and $DomainNetbios -ne $serverIdentity.DomainNetbios) {
throw "The supplied NetBIOS domain '$DomainNetbios' does not match '$($serverIdentity.DomainNetbios)'."
}
$DomainName = [string]$serverIdentity.Domain
$DomainNetbios = [string]$serverIdentity.DomainNetbios
if (-not $ComputerOuDn) { $ComputerOuDn = [string]$serverIdentity.ComputerContainer }
$brokerDnsName = "$brokerRecordName.$DomainName"
$brokerEndpoint = "https://${brokerDnsName}:8443/v1/authenticate"
Write-Host "Discovered domain: $DomainName ($DomainNetbios). Computer container: $ComputerOuDn"
if ($serverIdentity.BrokerService -ne 'Running') {
throw "The SGU Authentication Broker is not running on $($serverIdentity.ComputerName)."
}
if ([string]::IsNullOrWhiteSpace($serverIdentity.RustDeskServerAddress) -or
[string]::IsNullOrWhiteSpace($serverIdentity.RustDeskPublicKey) -or
$serverIdentity.RustDeskHbbsTask -ne 'Running' -or
$serverIdentity.RustDeskHbbrTask -ne 'Running') {
throw "The RustDesk server is not ready on $($serverIdentity.ComputerName). Run the current server bootstrap first."
}
$targetComputerName = if ($NewComputerName) { $NewComputerName } else { $env:COMPUTERNAME }
Invoke-Command -Session $session -ScriptBlock {
param($ComputerName, $ComputerPath)
Import-Module ActiveDirectory -ErrorAction Stop
$samAccountName = "$ComputerName`$"
$account = Get-ADComputer -Filter "SamAccountName -eq '$samAccountName'" |
Select-Object -First 1
if (-not $account) {
New-ADComputer -Name $ComputerName -SamAccountName $samAccountName `
-Path $ComputerPath -Enabled $true -ErrorAction Stop
}
} -ArgumentList $targetComputerName,$ComputerOuDn
if ($publicDirectEnrollment) {
$directDns = Invoke-Command -Session $session -ScriptBlock {
param($DnsDomain, $DomainControllerComputerName)
$domainControllerFqdn = "$DomainControllerComputerName.$DnsDomain".ToLowerInvariant()
$dohTemplate = "https://${domainControllerFqdn}:443/dns-query"
$dohCommand = Get-Command Set-DnsServerEncryptionProtocol -ErrorAction SilentlyContinue
if (-not $dohCommand) {
return [pscustomobject]@{
DohSupported = $false
DomainControllerFqdn = $domainControllerFqdn
}
}
$certificate = Get-ChildItem Cert:\LocalMachine\My |
Where-Object {
$_.Subject -eq "CN=$domainControllerFqdn" -and
$_.HasPrivateKey -and
$_.NotAfter -gt (Get-Date).AddDays(30)
} |
Sort-Object NotAfter -Descending |
Select-Object -First 1
if (-not $certificate) {
$certificate = New-SelfSignedCertificate `
-DnsName $domainControllerFqdn `
-CertStoreLocation Cert:\LocalMachine\My `
-FriendlyName 'SGU Direct Enrollment DoH' `
-Type SSLServerAuthentication `
-KeyAlgorithm RSA `
-KeyLength 2048 `
-HashAlgorithm SHA256 `
-KeyExportPolicy NonExportable `
-NotAfter (Get-Date).AddYears(2)
}
$bindingOutput = @(& "$env:SystemRoot\System32\netsh.exe" http show sslcert ipport=0.0.0.0:443 2>&1)
$bindingExists = $LASTEXITCODE -eq 0
$normalizedBinding = (($bindingOutput -join '') -replace '[^0-9A-Fa-f]', '').ToUpperInvariant()
$normalizedThumbprint = ($certificate.Thumbprint -replace ' ', '').ToUpperInvariant()
if ($bindingExists -and -not $normalizedBinding.Contains($normalizedThumbprint)) {
throw 'TCP 443 already has an HTTPS certificate binding that is not managed by SGU. Free that port or configure SGU DoH before enrolling this client.'
}
if (-not $bindingExists) {
& "$env:SystemRoot\System32\netsh.exe" http add sslcert `
ipport=0.0.0.0:443 "certhash=$($certificate.Thumbprint)" `
"appid={47E9CF26-79B7-4C9D-A0AE-ADFA22447A41}" certstorename=MY | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Could not bind the SGU DoH certificate to TCP 443.' }
}
$dnsChanged = $false
$encryption = Get-DnsServerEncryptionProtocol
if (-not $encryption.EnableDoh -or $encryption.UriTemplate -ne $dohTemplate) {
Set-DnsServerEncryptionProtocol -EnableDoh $true -UriTemplate $dohTemplate
$dnsChanged = $true
}
Import-Module ActiveDirectory -ErrorAction Stop
$domainController = Get-ADComputer -Identity $DomainControllerComputerName `
-Properties ServicePrincipalName
if (@($domainController.ServicePrincipalName) -notcontains "cifs/$DnsDomain") {
& "$env:SystemRoot\System32\setspn.exe" -S "cifs/$DnsDomain" $DomainControllerComputerName | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Could not register cifs/$DnsDomain on $DomainControllerComputerName." }
}
$lanmanPath = 'HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters'
$optionalNames = @((Get-ItemProperty $lanmanPath -Name OptionalNames `
-ErrorAction SilentlyContinue).OptionalNames | Where-Object { $_ })
$serverChanged = $false
if ($optionalNames -notcontains $DnsDomain) {
New-ItemProperty -Path $lanmanPath -Name OptionalNames -PropertyType MultiString `
-Value (@($optionalNames) + $DnsDomain) -Force | Out-Null
$serverChanged = $true
}
New-ItemProperty -Path $lanmanPath -Name DisableStrictNameChecking `
-PropertyType DWord -Value 1 -Force | Out-Null
if ($serverChanged) {
Restart-Service LanmanServer -Force
Start-Service Netlogon
}
if ($dnsChanged) {
Restart-Service DNS -Force
Start-Sleep -Seconds 2
}
[pscustomobject]@{
DohSupported = $true
DohTemplate = $dohTemplate
DohCertificateBase64 = [Convert]::ToBase64String($certificate.RawData)
DomainControllerFqdn = $domainControllerFqdn
}
} -ArgumentList $DomainName,$serverIdentity.ComputerName
$directHostNames = @(
$directDns.DomainControllerFqdn,
$DomainName,
$brokerDnsName
)
if ([string]$serverIdentity.RustDeskServerAddress -match '[A-Za-z]') {
$directHostNames += [string]$serverIdentity.RustDeskServerAddress
}
Set-ClientHostMappings -ServerAddress $DomainControllerIPv4Address `
-HostNames $directHostNames
if ($directDns.DohSupported -and
(Get-Command Add-DnsClientDohServerAddress -ErrorAction SilentlyContinue)) {
Enable-ClientDnsOverHttps -ServerAddress $DomainControllerIPv4Address `
-DohTemplate $directDns.DohTemplate `
-CertificateBase64 $directDns.DohCertificateBase64
}
elseif (-not $directDns.DohSupported) {
Write-Warning 'The server does not support DNS over HTTPS; enrollment will use traditional DNS.'
}
else {
Write-Warning 'This Windows build does not support DNS over HTTPS; enrollment will use traditional DNS.'
}
}
Set-ClientDomainDns -DnsDomain $DomainName -ServerAddress $DomainControllerIPv4Address
if (-not (Test-ClientDomainDns -DnsDomain $DomainName)) {
throw "The domain DNS service at $DomainControllerIPv4Address did not return an Active Directory SRV record. For a public server, permit DNS over HTTPS on TCP 443 or traditional DNS from this client network."
}
foreach ($port in @(53, 88, 135, 389, 445, 8443)) {
if (-not (Test-TcpPort -Address $DomainControllerIPv4Address -Port $port -TimeoutMilliseconds 2000)) {
throw "Server $DomainControllerIPv4Address is reachable, but required TCP port $port is unavailable. Check AD/SGU services and the LAN/VPN firewall. Domain join has not started."
}
}
$certificateSubject = "CN=SGU Credential Provider Client $env:COMPUTERNAME"
$clientCertificate = Get-ChildItem Cert:\LocalMachine\My |
@@ -269,11 +920,16 @@ try {
DomainCredential = $DomainCredential
DomainName = $DomainName
DomainNetbios = $DomainNetbios
DomainControllerDnsName = "$($serverIdentity.ComputerName).$DomainName"
ComputerOuDn = $ComputerOuDn
NetworkInterfaceAlias = $NetworkInterfaceAlias
DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString)
DomainDnsConfigured = $true
ConnectivityMode = $ConnectivityMode
RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP"
DotNetRuntimeInstallerPath = $runtimeInstaller.FullName
RustDeskServerAddress = $serverIdentity.RustDeskServerAddress
RustDeskServerPublicKey = $serverIdentity.RustDeskPublicKey
SkipRestart = $true
}
if ($NewComputerName) {
@@ -281,6 +937,31 @@ try {
}
$result = & (Join-Path $scriptsRoot 'Enroll-SguDomainClient.ps1') @enrollmentParameters
$rustDeskEnrollment = $result.RustDesk
if (-not $rustDeskEnrollment -or -not $rustDeskEnrollment.RustDeskId -or
-not $rustDeskEnrollment.AccessPassword) {
throw 'The client RustDesk enrollment did not provide an ID and protected access credential.'
}
$rustDeskPasswordPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR(
$rustDeskEnrollment.AccessPassword)
try {
$rustDeskPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($rustDeskPasswordPointer)
Invoke-Command -Session $session -ScriptBlock {
param($ComputerName, $RustDeskId, $AccessPassword)
$registrationScript = Join-Path $env:ProgramData 'SGU\RustDesk\Register-SguRustDeskDevice.ps1'
if (-not (Test-Path -LiteralPath $registrationScript -PathType Leaf)) {
throw 'The RustDesk device-registration script is missing on the domain controller.'
}
& $registrationScript -ComputerName $ComputerName -RustDeskId $RustDeskId `
-AccessPassword $AccessPassword | Out-Null
} -ArgumentList $env:COMPUTERNAME,$rustDeskEnrollment.RustDeskId,$rustDeskPassword
}
finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($rustDeskPasswordPointer)
$rustDeskPassword = $null
}
$rustDeskEnrollment.PSObject.Properties.Remove('AccessPassword')
}
finally {
if ($session) {
@@ -305,10 +986,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) {
@@ -318,7 +1001,11 @@ 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
}
return
+111
View File
@@ -0,0 +1,111 @@
#Requires -Version 5.1
[CmdletBinding()]
param(
[string]$MonitoringRoot = 'C:\ProgramData\SGU\Monitoring',
[string]$ComputerOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
[ValidateRange(30, 730)]
[int]$RetentionDays = 183,
[string]$BrokerEventLogName = 'SGU Auth Broker',
[switch]$InventoryOnly
)
$ErrorActionPreference = 'Stop'
Import-Module ActiveDirectory -ErrorAction Stop
foreach ($serviceName in 'EventLog','Wecsvc') {
Set-Service -Name $serviceName -StartupType Automatic
if ((Get-Service $serviceName).Status -ne 'Running') {
Start-Service $serviceName
}
}
$archiveRoot = Join-Path $MonitoringRoot 'Archive'
$brokerArchiveRoot = Join-Path $archiveRoot 'Broker'
$reportRoot = Join-Path $MonitoringRoot 'Reports'
New-Item -ItemType Directory -Path $archiveRoot,$brokerArchiveRoot,$reportRoot -Force | Out-Null
function Test-TcpEndpoint {
param(
[Parameter(Mandatory)][string]$ComputerName,
[int]$Port = 5985,
[int]$TimeoutMilliseconds = 900
)
$client = [Net.Sockets.TcpClient]::new()
try {
$pending = $client.BeginConnect($ComputerName, $Port, $null, $null)
if (-not $pending.AsyncWaitHandle.WaitOne($TimeoutMilliseconds)) {
return $false
}
$client.EndConnect($pending)
return $true
}
catch {
return $false
}
finally {
$client.Dispose()
}
}
if (-not $InventoryOnly) {
$forwardedLog = Get-WinEvent -ListLog ForwardedEvents -ErrorAction Stop
if ($forwardedLog.RecordCount -gt 0) {
$archivePath = Join-Path $archiveRoot ("ForwardedEvents-{0:yyyyMMdd-HHmmss}.evtx" -f (Get-Date))
& wevtutil.exe clear-log ForwardedEvents "/backup:$archivePath"
if ($LASTEXITCODE -ne 0) {
throw "Could not archive ForwardedEvents; wevtutil returned exit code $LASTEXITCODE."
}
}
$brokerLog = Get-WinEvent -ListLog $BrokerEventLogName -ErrorAction SilentlyContinue
if ($brokerLog -and $brokerLog.RecordCount -gt 0) {
$brokerArchivePath = Join-Path $brokerArchiveRoot ("SguAuthBroker-{0:yyyyMMdd-HHmmss}.evtx" -f (Get-Date))
& wevtutil.exe clear-log $BrokerEventLogName "/backup:$brokerArchivePath"
if ($LASTEXITCODE -ne 0) {
throw "Could not archive $BrokerEventLogName; wevtutil returned exit code $LASTEXITCODE."
}
}
$cutoff = (Get-Date).AddDays(-$RetentionDays)
Get-ChildItem -LiteralPath $archiveRoot -Filter '*.evtx' -File -Recurse -ErrorAction SilentlyContinue |
Where-Object LastWriteTime -lt $cutoff |
ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force }
}
$computers = @(Get-ADComputer -SearchBase $ComputerOuDn -SearchScope Subtree -Filter * `
-Properties DNSHostName,IPv4Address,OperatingSystem,LastLogonDate,Enabled |
Sort-Object Name)
$inventory = @(foreach ($computer in $computers) {
$target = if ($computer.DNSHostName) { $computer.DNSHostName } else { $computer.Name }
$online = Test-TcpEndpoint -ComputerName $target
[pscustomobject]@{
ComputerName = $computer.Name
DNSHostName = $computer.DNSHostName
IPv4Address = $computer.IPv4Address
OperatingSystem = $computer.OperatingSystem
Enabled = [bool]$computer.Enabled
Status = if ($online) { 'Encendida' } else { 'Apagada o inaccesible' }
WinRMReachable = [bool]$online
LastDomainLogon = if ($computer.LastLogonDate) {
$computer.LastLogonDate.ToUniversalTime().ToString('o')
} else { $null }
CheckedAt = (Get-Date).ToUniversalTime().ToString('o')
}
})
$jsonPath = Join-Path $reportRoot 'machine-status.json'
$csvPath = Join-Path $reportRoot 'machine-status.csv'
[IO.File]::WriteAllText($jsonPath, (ConvertTo-Json -InputObject $inventory -Depth 4), [Text.UTF8Encoding]::new($false))
$inventory | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding UTF8
[pscustomobject]@{
CheckedAt = (Get-Date).ToUniversalTime().ToString('o')
ComputerCount = @($inventory).Count
OnlineCount = @($inventory | Where-Object WinRMReachable).Count
OfflineCount = @($inventory | Where-Object { -not $_.WinRMReachable }).Count
RetentionDays = $RetentionDays
StatusJson = $jsonPath
StatusCsv = $csvPath
}
@@ -0,0 +1,90 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$DataRoot,
[Parameter(Mandatory)]
[ValidatePattern('^[A-Fa-f0-9]{40}$')]
[string]$CertificateThumbprint
)
$ErrorActionPreference = 'Stop'
$requestsRoot = Join-Path $DataRoot 'Public\Requests'
$archiveRoot = Join-Path $DataRoot 'Public\Archive'
$rejectedRoot = Join-Path $DataRoot 'Public\Rejected'
$registrationScript = Join-Path $env:ProgramData 'SGU\RustDesk\Register-SguRustDeskDevice.ps1'
function Write-Result {
param(
[Parameter(Mandatory)][string]$RequestId,
[Parameter(Mandatory)][hashtable]$Value
)
$path = Join-Path $requestsRoot "$RequestId.result.json"
[IO.File]::WriteAllText($path, ($Value | ConvertTo-Json), [Text.UTF8Encoding]::new($false))
}
function Get-ComputerNameFromOwner {
param([Parameter(Mandatory)][string]$Owner)
if ($Owner -notmatch '^[^\\]+\\(?<Name>[A-Za-z0-9][A-Za-z0-9-]{0,62})\$$') {
throw 'The request file owner is not an Active Directory computer account.'
}
return $Matches.Name.ToUpperInvariant()
}
if (-not (Test-Path -LiteralPath $registrationScript -PathType Leaf)) {
throw "The RustDesk inventory registration script is missing: $registrationScript"
}
Import-Module ActiveDirectory -ErrorAction Stop
$certificate = Get-Item -LiteralPath "Cert:\LocalMachine\My\$CertificateThumbprint" -ErrorAction Stop
$rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($certificate)
if (-not $rsa) {
throw 'The Linux RustDesk enrollment certificate does not have an RSA private key.'
}
New-Item -ItemType Directory -Path $requestsRoot, $archiveRoot, $rejectedRoot -Force | Out-Null
Get-ChildItem -LiteralPath $requestsRoot -Filter '*.request' -File | ForEach-Object {
$requestFile = $_
$requestIdMatch = [regex]::Match($requestFile.BaseName,
'(?<Id>[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$')
if (-not $requestIdMatch.Success) {
Move-Item -LiteralPath $requestFile.FullName -Destination (Join-Path $rejectedRoot $requestFile.Name) -Force
return
}
$requestId = $requestIdMatch.Groups['Id'].Value
try {
$ownerComputerName = Get-ComputerNameFromOwner -Owner (Get-Acl -LiteralPath $requestFile.FullName).Owner
$plainText = [Text.Encoding]::UTF8.GetString($rsa.Decrypt(
[IO.File]::ReadAllBytes($requestFile.FullName),
[Security.Cryptography.RSAEncryptionPadding]::OaepSHA256))
$request = $plainText | ConvertFrom-Json -ErrorAction Stop
$computerName = [string]$request.ComputerName
$rustDeskId = [string]$request.RustDeskId
$accessPassword = [string]$request.AccessPassword
$declaredRequestId = [string]$request.RequestId
if ($computerName -notmatch '^[A-Za-z0-9][A-Za-z0-9-]{0,62}$' -or
$computerName.ToUpperInvariant() -ne $ownerComputerName -or
$rustDeskId -notmatch '^\d+$' -or
$accessPassword.Length -lt 12 -or
$declaredRequestId -notmatch '^[0-9a-fA-F-]{36}$') {
throw 'The encrypted Linux RustDesk registration payload is invalid.'
}
Get-ADComputer -Identity $ownerComputerName -ErrorAction Stop | Out-Null
& $registrationScript -ComputerName $ownerComputerName -RustDeskId $rustDeskId `
-AccessPassword $accessPassword -Confirm:$false | Out-Null
Write-Result -RequestId $declaredRequestId -Value @{
Status = 'Registered'
ComputerName = $ownerComputerName
RustDeskId = $rustDeskId
RegisteredAt = (Get-Date).ToString('o')
}
Move-Item -LiteralPath $requestFile.FullName -Destination (Join-Path $archiveRoot $requestFile.Name) -Force
}
catch {
$safeError = $_.Exception.Message -replace '(?i)password[^\r\n]*', 'credential validation failed'
Write-Result -RequestId $requestId -Value @{
Status = 'Rejected'
Error = $safeError
}
Move-Item -LiteralPath $requestFile.FullName -Destination (Join-Path $rejectedRoot $requestFile.Name) -Force -ErrorAction SilentlyContinue
}
}
+89
View File
@@ -0,0 +1,89 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[ValidatePattern('^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$')]
[string]$ClientName,
[string]$OutputDirectory = (Join-Path $PSScriptRoot '..\artifacts\azure-p2s'),
[securestring]$ClientPfxPassword,
[string]$RootSubject = 'CN=SGU Azure P2S Root',
[ValidateRange(1, 10)]
[int]$ClientValidityYears = 2,
[switch]$Force
)
$ErrorActionPreference = 'Stop'
$resolvedOutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
New-Item -ItemType Directory -Path $resolvedOutputDirectory -Force | Out-Null
$rootCertificatePath = Join-Path $resolvedOutputDirectory 'sgu-azure-p2s-root.cer'
$clientCertificatePath = Join-Path $resolvedOutputDirectory "sgu-azure-p2s-$ClientName.pfx"
if ((Test-Path -LiteralPath $clientCertificatePath -PathType Leaf) -and -not $Force) {
throw "$clientCertificatePath already exists. Use -Force only when you intend to replace that exported client credential."
}
if (-not $ClientPfxPassword) {
$ClientPfxPassword = Read-Host 'Password that will protect the exported P2S client certificate' -AsSecureString
}
$rootCertificate = Get-ChildItem Cert:\CurrentUser\My |
Where-Object {
$_.Subject -eq $RootSubject -and
$_.HasPrivateKey -and
$_.NotAfter -gt (Get-Date).AddYears($ClientValidityYears)
} |
Sort-Object NotAfter -Descending |
Select-Object -First 1
if (-not $rootCertificate) {
if (-not $PSCmdlet.ShouldProcess($RootSubject, 'Create a non-exportable Azure P2S root certificate authority')) {
return
}
$rootCertificate = New-SelfSignedCertificate `
-Type Custom `
-Subject $RootSubject `
-CertStoreLocation Cert:\CurrentUser\My `
-KeyAlgorithm RSA `
-KeyLength 4096 `
-HashAlgorithm SHA256 `
-KeySpec Signature `
-KeyExportPolicy NonExportable `
-KeyUsage CertSign,CRLSign,DigitalSignature `
-NotAfter (Get-Date).AddYears(10) `
-TextExtension @('2.5.29.19={critical}{text}ca=1&pathlength=1')
}
if (-not $PSCmdlet.ShouldProcess($ClientName, 'Issue and export an Azure P2S machine certificate')) {
return
}
$clientSubject = "CN=SGU Azure P2S $ClientName"
$clientCertificate = New-SelfSignedCertificate `
-Type Custom `
-Subject $clientSubject `
-DnsName "sgu-p2s-$ClientName" `
-Signer $rootCertificate `
-CertStoreLocation Cert:\CurrentUser\My `
-KeyAlgorithm RSA `
-KeyLength 3072 `
-HashAlgorithm SHA256 `
-KeySpec Signature `
-KeyExportPolicy Exportable `
-KeyUsage DigitalSignature `
-NotAfter (Get-Date).AddYears($ClientValidityYears) `
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.2')
Export-Certificate -Cert $rootCertificate -FilePath $rootCertificatePath -Force | Out-Null
Export-PfxCertificate -Cert $clientCertificate -FilePath $clientCertificatePath `
-Password $ClientPfxPassword -ChainOption BuildChain -CryptoAlgorithmOption AES256_SHA256 `
-Force | Out-Null
[pscustomobject]@{
RootCertificatePath = $rootCertificatePath
RootCertificateThumbprint = $rootCertificate.Thumbprint
RootCertificateData = [Convert]::ToBase64String($rootCertificate.RawData)
ClientName = $ClientName
ClientCertificatePath = $clientCertificatePath
ClientCertificateThumbprint = $clientCertificate.Thumbprint
ClientCertificateExpires = $clientCertificate.NotAfter
RootPrivateKeyExportable = $false
}
+125 -8
View File
@@ -33,7 +33,10 @@ function Write-PackageManifest {
param(
[Parameter(Mandatory)][string]$PackageRoot,
[Parameter(Mandatory)][string]$PackageVersion,
[Parameter(Mandatory)][string]$PackageKind
[Parameter(Mandatory)][string]$PackageKind,
[ValidateSet('Auto')]
[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,17 +87,32 @@ if (-not $runtimeInstaller) {
}
New-Item -ItemType Directory -Path $resolvedOutputRoot -Force | Out-Null
$clientRoot = Join-Path $resolvedOutputRoot "sgu-client-bootstrap-$Version"
$clientRoot = Join-Path $resolvedOutputRoot "sgu-windows-client-bootstrap-$Version"
$serverRoot = Join-Path $resolvedOutputRoot "sgu-server-bootstrap-$Version"
$linuxClientRoot = Join-Path $resolvedOutputRoot "sgu-linux-client-bootstrap-$Version"
$azureRoot = Join-Path $resolvedOutputRoot "sgu-azure-infrastructure-$Version"
$clientZip = "$clientRoot.zip"
$serverZip = "$serverRoot.zip"
foreach ($target in @($clientRoot,$serverRoot,$clientZip,$serverZip)) {
$linuxClientZip = "$linuxClientRoot.zip"
$azureZip = "$azureRoot.zip"
foreach ($target in @(
$clientRoot,$serverRoot,$linuxClientRoot,$azureRoot,
$clientZip,$serverZip,$linuxClientZip,$azureZip)) {
if (Test-Path -LiteralPath $target) {
throw "Release target already exists: $target"
}
}
New-Item -ItemType Directory -Path $clientRoot,$serverRoot -Force | Out-Null
New-Item -ItemType Directory `
-Path $clientRoot,$serverRoot,$linuxClientRoot,$azureRoot `
-Force | Out-Null
$welcomeFontNames = @(
'IndivisaTextSans-Regular.otf',
'IndivisaTextSans-Bold.otf',
'IndivisaTextSans-BoldItalic.otf',
'IndivisaTextSerif-Regular.otf',
'IndivisaTextSerif-BoldItalic.otf'
)
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Invoke-SguClientBootstrap.ps1') `
-Destination (Join-Path $clientRoot 'Invoke-SguClientBootstrap.ps1')
@@ -96,11 +120,14 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguClientEnrollment.cm
-Destination (Join-Path $clientRoot 'Start-SguClientEnrollment.cmd')
$clientScripts = @(
'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1',
'Enroll-SguDomainClient.ps1',
'Install-CredentialProvider.ps1',
'Install-SguEnrollmentGuard.ps1',
'Install-SguRustDeskClient.ps1',
'Register-SguClientCertificate.ps1',
'Repair-SguClientEnrollment.ps1',
'Set-SguStandardLocalUser.ps1',
'Test-SguClientEnrollment.ps1'
)
foreach ($scriptName in $clientScripts) {
@@ -111,21 +138,76 @@ Copy-Item -Path (Join-Path $providerOutput '*') `
-Destination (New-Item -ItemType Directory `
-Path (Join-Path $clientRoot 'payload\credential-provider') -Force).FullName `
-Recurse -Force
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'assets\branding\lasalle-mascot-account.png') `
-Destination (Join-Path $clientRoot 'payload\credential-provider\branding\user.png')
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'assets\branding\darkblue.jpg') `
-Destination (Join-Path $clientRoot 'payload\credential-provider\branding\darkblue.jpg')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Set-SguWelcomeWallpaper.ps1') `
-Destination (Join-Path $clientRoot 'payload\credential-provider\branding\Set-SguWelcomeWallpaper.ps1')
foreach ($fontName in $welcomeFontNames) {
Copy-RequiredFile -Source (Join-Path $repositoryRoot "assets\branding\fonts\$fontName") `
-Destination (Join-Path $clientRoot "payload\credential-provider\branding\fonts\$fontName")
}
Copy-RequiredFile -Source $runtimeInstaller.FullName `
-Destination (Join-Path $clientRoot "payload\prerequisites\$($runtimeInstaller.Name)")
Write-PackageManifest -PackageRoot $clientRoot -PackageVersion $Version -PackageKind Client
# One Windows implementation supports existing LAN/VPN routes and optional Azure P2S.
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguAzureClientEnrollment.cmd') `
-Destination (Join-Path $clientRoot 'Start-SguAzureClientEnrollment.cmd')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Install-SguAzureP2sClient.ps1') `
-Destination (Join-Path $clientRoot 'Install-SguAzureP2sClient.ps1')
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'docs\client-enrollment.md') `
-Destination (Join-Path $clientRoot 'README.md')
Copy-Item -Path (Join-Path $repositoryRoot 'docs\*.md') -Destination $clientRoot
Write-PackageManifest -PackageRoot $clientRoot -PackageVersion $Version `
-PackageKind WindowsClient -CompatibilityProfile Auto `
-TargetOperatingSystem 'Windows 10 1607+ or Windows 11; x64 Pro, Enterprise, or Education'
Compress-Archive -Path (Join-Path $clientRoot '*') -DestinationPath $clientZip `
-CompressionLevel Optimal
# Linux clients use their native PAM/SSSD sign-in stack rather than the Windows
# Credential Provider. Keep their self-contained bootstrap independent so a
# Linux administrator never receives Windows binaries or certificate material.
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Enroll-SguLinuxDomainClient.sh') `
-Destination (Join-Path $linuxClientRoot 'Enroll-SguLinuxDomainClient.sh')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Install-SguLinuxRustDeskClient.sh') `
-Destination (Join-Path $linuxClientRoot 'Install-SguLinuxRustDeskClient.sh')
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'docs\linux-client-enrollment.md') `
-Destination (Join-Path $linuxClientRoot 'README.md')
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Set-SguWelcomeWallpaper.sh') `
-Destination (Join-Path $linuxClientRoot 'welcome-wallpaper\Set-SguWelcomeWallpaper.sh')
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'assets\branding\darkblue.jpg') `
-Destination (Join-Path $linuxClientRoot 'welcome-wallpaper\darkblue.jpg')
foreach ($fontName in $welcomeFontNames) {
Copy-RequiredFile -Source (Join-Path $repositoryRoot "assets\branding\fonts\$fontName") `
-Destination (Join-Path $linuxClientRoot "welcome-wallpaper\fonts\$fontName")
}
Write-PackageManifest -PackageRoot $linuxClientRoot -PackageVersion $Version -PackageKind LinuxClient
Compress-Archive -Path (Join-Path $linuxClientRoot '*') -DestinationPath $linuxClientZip `
-CompressionLevel Optimal
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Initialize-SguDomainController.ps1') `
-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',
'Get-SguUsageReport.ps1',
'Get-SguBrokerLog.ps1',
'Install-SguDomainMonitoring.ps1',
'Install-SguRustDeskClient.ps1',
'Install-SguRustDeskLinuxEnrollment.ps1',
'Install-SguRustDeskServer.ps1',
'Invoke-SguRustDeskLinuxRegistrationProcessor.ps1',
'Invoke-SguMonitoringMaintenance.ps1',
'New-LabCertificate.ps1',
'Get-SguRustDeskDevice.ps1',
'Register-SguClientCertificate.ps1',
'Register-SguRustDeskDevice.ps1',
'Set-LabBrokerDns.ps1',
'Set-SguDomainComputerPolicies.ps1',
'Set-SguDomainUserPolicies.ps1'
@@ -147,23 +229,58 @@ if ($ServerContentPath) {
Copy-Item -Path (Join-Path $ServerContentPath '*') `
-Destination $serverContentTarget -Recurse -Force
}
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Set-SguWelcomeWallpaper.ps1') `
-Destination (Join-Path $serverContentTarget 'welcome-wallpaper\Set-SguWelcomeWallpaper.ps1')
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'assets\branding\darkblue.jpg') `
-Destination (Join-Path $serverContentTarget 'welcome-wallpaper\darkblue.jpg')
foreach ($fontName in $welcomeFontNames) {
Copy-RequiredFile -Source (Join-Path $repositoryRoot "assets\branding\fonts\$fontName") `
-Destination (Join-Path $serverContentTarget "welcome-wallpaper\fonts\$fontName")
}
Write-PackageManifest -PackageRoot $serverRoot -PackageVersion $Version -PackageKind Server
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 $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
WindowsClientPackage = $clientZip
WindowsClientSha256 = (Get-FileHash -LiteralPath $clientZip -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
}
+36 -7
View File
@@ -15,8 +15,10 @@ param(
$ErrorActionPreference = 'Stop'
$tagName = "v$Version"
$assetPaths = @(
(Join-Path $ReleaseDirectory "sgu-client-bootstrap-$Version.zip"),
(Join-Path $ReleaseDirectory "sgu-windows-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) {
@@ -26,7 +28,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) {
@@ -39,17 +43,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 {
@@ -97,8 +109,23 @@ try {
$releaseNotes = @"
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-windows-client-bootstrap-$Version.zip`: paquete único para Windows 10 1607+ y Windows 11 x64 Pro, Enterprise o Education, con LAN, VPN existente y Azure P2S opcional.
- Doble clic en `Start-SguClientEnrollment.cmd`, IP del servidor y credenciales: descubre el dominio autenticado, comprueba las interfaces y rutas disponibles y conserva DHCP, las IP del cliente y el DNS de Internet. Ya no solicita una IP del cliente.
- Si la IP del DC es pública, configura automáticamente DoH autenticado, confianza del certificado, NRPT y nombres del bosque antes de unir el equipo; funciona con cualquier interfaz que pueda alcanzar el servidor.
- Conserva seguridad mTLS, Credential Provider, cuenta estándar, RustDesk, supervisión y autorreparación. El servidor puede ser accesible por LAN, una VPN ya conectada o un CIDR público autorizado.
- `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 e IP pública protegida por NSG; Azure VPN Gateway P2S es opcional.
- El modo directo recibe una lista explícita de CIDR públicos, la replica en NSG y Windows Firewall y deja cerrados los puertos de enrolamiento cuando la lista está vacía.
- Windows 10 y 11 pueden instalar el perfil IKEv2 de todos los usuarios con certificado de máquina y DNS dividido del dominio; la disponibilidad antes del inicio de sesión depende del perfil y de las políticas del equipo.
- 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.
Las contraseñas se solicitan de forma interactiva y no se escriben en archivos ni en la línea de comandos. Verifique los ZIP con `SHA256SUMS-$Version.txt`.
@@ -150,6 +177,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()
}
+97
View File
@@ -0,0 +1,97 @@
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9-]{0,62}$')]
[string]$ComputerName,
[Parameter(Mandatory)]
[ValidatePattern('^\d+$')]
[string]$RustDeskId,
[Parameter(Mandatory)]
[ValidateLength(12, 256)]
[string]$AccessPassword,
[string]$InventoryRoot = "$env:ProgramData\SGU\RustDesk\Devices"
)
$ErrorActionPreference = 'Stop'
function Assert-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Only a local administrator can register a RustDesk device credential.'
}
}
function Initialize-DataProtection {
if (-not ('SguRustDeskDataProtection' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
public static class SguRustDeskDataProtection {
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct DataBlob { public int cbData; public IntPtr pbData; }
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CryptProtectData(ref DataBlob input, string description, IntPtr entropy, IntPtr reserved, IntPtr prompt, int flags, out DataBlob output);
[DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool CryptUnprotectData(ref DataBlob input, IntPtr description, IntPtr entropy, IntPtr reserved, IntPtr prompt, int flags, out DataBlob output);
[DllImport("kernel32.dll", SetLastError = true)] private static extern IntPtr LocalFree(IntPtr memory);
private const int CryptProtectLocalMachine = 0x4;
private static DataBlob ToBlob(byte[] value) { var blob = new DataBlob { cbData = value.Length, pbData = IntPtr.Zero }; if (value.Length > 0) { blob.pbData = Marshal.AllocHGlobal(value.Length); Marshal.Copy(value, 0, blob.pbData, value.Length); } return blob; }
private static byte[] FromBlob(DataBlob blob) { var value = new byte[blob.cbData]; if (blob.cbData > 0) Marshal.Copy(blob.pbData, value, 0, blob.cbData); return value; }
public static byte[] Protect(byte[] value) { var input = ToBlob(value); var output = new DataBlob(); try { if (!CryptProtectData(ref input, null, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, CryptProtectLocalMachine, out output)) throw new Win32Exception(Marshal.GetLastWin32Error()); return FromBlob(output); } finally { if (input.pbData != IntPtr.Zero) Marshal.FreeHGlobal(input.pbData); if (output.pbData != IntPtr.Zero) LocalFree(output.pbData); } }
public static byte[] Unprotect(byte[] value) { var input = ToBlob(value); var output = new DataBlob(); try { if (!CryptUnprotectData(ref input, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, 0, out output)) throw new Win32Exception(Marshal.GetLastWin32Error()); return FromBlob(output); } finally { if (input.pbData != IntPtr.Zero) Marshal.FreeHGlobal(input.pbData); if (output.pbData != IntPtr.Zero) LocalFree(output.pbData); } }
}
'@ -ErrorAction Stop
}
}
function Set-PrivateDirectoryAcl {
param([Parameter(Mandatory)][string]$Path)
New-Item -ItemType Directory -Path $Path -Force | Out-Null
$acl = New-Object Security.AccessControl.DirectorySecurity
$acl.SetAccessRuleProtection($true, $false)
$inheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit'
$allow = [Security.AccessControl.AccessControlType]::Allow
foreach ($sid in @('S-1-5-18', 'S-1-5-32-544')) {
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
[Security.Principal.SecurityIdentifier]::new($sid),
[Security.AccessControl.FileSystemRights]::FullControl,
$inheritance,
[Security.AccessControl.PropagationFlags]::None,
$allow))
}
Set-Acl -LiteralPath $Path -AclObject $acl
}
Assert-Administrator
Initialize-DataProtection
if (-not $PSCmdlet.ShouldProcess($ComputerName, 'Register the protected RustDesk management credential')) {
return
}
Set-PrivateDirectoryAcl -Path $InventoryRoot
$normalizedName = $ComputerName.ToUpperInvariant()
$secretPath = Join-Path $InventoryRoot "$normalizedName.secret"
$metadataPath = Join-Path $InventoryRoot "$normalizedName.json"
$protectedPassword = [SguRustDeskDataProtection]::Protect(
[Text.Encoding]::UTF8.GetBytes($AccessPassword))
[IO.File]::WriteAllBytes($secretPath, $protectedPassword)
$metadata = [ordered]@{
ComputerName = $normalizedName
RustDeskId = $RustDeskId
RegisteredAt = (Get-Date).ToString('o')
SecretPath = $secretPath
}
[IO.File]::WriteAllText($metadataPath, ($metadata | ConvertTo-Json), [Text.UTF8Encoding]::new($false))
[pscustomobject]@{
ComputerName = $normalizedName
RustDeskId = $RustDeskId
Registered = $true
MetadataPath = $metadataPath
}
@@ -0,0 +1,99 @@
#Requires -Version 5.1
#Requires -RunAsAdministrator
[CmdletBinding()]
param(
[string]$ConnectionName = 'SGU Azure Device',
[ValidateRange(30,600)][int]$WaitSeconds = 180
)
$ErrorActionPreference = 'Stop'
$computer = Get-CimInstance Win32_ComputerSystem
if (-not $computer.PartOfDomain) { throw 'The device must already be joined to its domain.' }
$logPath = Join-Path $env:ProgramData 'SGU\Enrollment\azure-domain-connectivity.json'
$deadline = (Get-Date).AddSeconds($WaitSeconds)
$restarted = $false
$controller = $null
try {
do {
$vpn = Get-VpnConnection -Name $ConnectionName -AllUserConnection -ErrorAction SilentlyContinue
$reachable = $false
if ($vpn -and $vpn.ConnectionStatus -eq 'Connected') {
$record = Resolve-DnsName "_ldap._tcp.dc._msdcs.$($computer.Domain)" -Type SRV -ErrorAction SilentlyContinue |
Where-Object Type -eq 'SRV' | Select-Object -First 1
if ($record) {
$controller = $record.NameTarget.TrimEnd('.')
$socket = [Net.Sockets.TcpClient]::new()
try {
$connect = $socket.BeginConnect($controller, 389, $null, $null)
if ($connect.AsyncWaitHandle.WaitOne(2000)) {
$socket.EndConnect($connect)
$reachable = $socket.Connected
}
} catch { $reachable = $false }
finally { $socket.Dispose() }
}
}
if ($reachable) { break }
Start-Sleep -Seconds 5
} while ((Get-Date) -lt $deadline)
if (-not $reachable) { throw "The VPN and a domain controller were not reachable within $WaitSeconds seconds." }
# An early Netlogon attempt can remain failed after the device VPN connects.
# Refresh only that service, after confirming the domain is reachable.
if (-not (Test-ComputerSecureChannel -Server $controller)) {
Restart-Service -Name Netlogon
$restarted = $true
}
$secure = $false
for ($attempt = 0; $attempt -lt 6; $attempt++) {
$secure = Test-ComputerSecureChannel -Server $controller
if ($secure) { break }
Start-Sleep -Seconds 5
}
if (-not $secure) { throw 'The domain is reachable but the secure channel is still invalid. Administrative repair is required.' }
$guard = Get-ScheduledTask -TaskName 'SGU-CredentialProvider-EnrollmentGuard' -ErrorAction SilentlyContinue
$guardResult = $null
if ($guard) {
# Domain principal lookup can recover after the secure channel itself.
# Await the guard and retry a transient failure instead of reporting
# success while its asynchronous repair is still running or failed.
$guardDeadline = (Get-Date).AddMinutes(3)
do {
$guard = Get-ScheduledTask -TaskName $guard.TaskName
if ($guard.State -notin @('Running','Queued')) {
$previousRun = (Get-ScheduledTaskInfo -TaskName $guard.TaskName).LastRunTime
Start-ScheduledTask -InputObject $guard
do {
Start-Sleep -Seconds 2
$guard = Get-ScheduledTask -TaskName $guard.TaskName
$info = Get-ScheduledTaskInfo -TaskName $guard.TaskName
} while (($info.LastRunTime -le $previousRun -or $guard.State -in @('Running','Queued')) -and (Get-Date) -lt $guardDeadline)
if ($info.LastRunTime -gt $previousRun -and $guard.State -notin @('Running','Queued')) {
$guardResult = $info.LastTaskResult
if ($guardResult -eq 0) { break }
}
}
Start-Sleep -Seconds 10
} while ((Get-Date) -lt $guardDeadline)
if ($guardResult -ne 0) { throw "The secure channel recovered, but the enrollment guard did not succeed (result $guardResult)." }
}
[pscustomobject]@{
CheckedAt = (Get-Date).ToString('o')
ComputerName = $computer.Name
Domain = $computer.Domain
DomainController = $controller
ConnectionName = $ConnectionName
NetlogonRestarted = $restarted
SecureChannel = $secure
EnrollmentGuardResult = $guardResult
} | ConvertTo-Json | Set-Content -LiteralPath $logPath
} catch {
[pscustomobject]@{
CheckedAt = (Get-Date).ToString('o')
ConnectionName = $ConnectionName
NetlogonRestarted = $restarted
SecureChannel = $false
Error = $_.Exception.Message
} | ConvertTo-Json | Set-Content -LiteralPath $logPath
throw
}
+24 -1
View File
@@ -9,9 +9,13 @@ $enrollmentRoot = Split-Path $ConfigurationPath -Parent
$testScript = Join-Path $enrollmentRoot 'Test-SguClientEnrollment.ps1'
$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
@@ -28,18 +32,37 @@ if (-not $before.IsValid) {
}
$computer = Get-CimInstance Win32_ComputerSystem
$domainReady = $false
if ($computer.PartOfDomain) {
try {
$domainReady = [bool](Test-ComputerSecureChannel -ErrorAction Stop)
}
catch {
$domainReady = $false
}
}
if ($domainReady) {
& $remoteAccessScript `
-RemoteDesktopPrincipal ([string]$configuration.RemoteDesktopPrincipal) `
-EnableAdministrativeFirewallGroups | Out-Null
& $monitoringScript | Out-Null
}
if ($configuration.RustDeskServerAddress -and $configuration.RustDeskServerPublicKey) {
& $rustDeskScript -ServerAddress ([string]$configuration.RustDeskServerAddress) `
-ServerPublicKey ([string]$configuration.RustDeskServerPublicKey) | Out-Null
}
$verificationParams = @{}
if ($computer.PartOfDomain) {
if ($domainReady) {
$verificationParams.RequireDomainJoined = $true
$verificationParams.RequireRemoteAccess = $true
$verificationParams.RemoteDesktopPrincipal = [string]$configuration.RemoteDesktopPrincipal
}
if ($configuration.RustDeskServerAddress) {
$verificationParams.RequireRustDesk = $true
$verificationParams.RustDeskServerAddress = [string]$configuration.RustDeskServerAddress
}
$after = & $testScript @verificationParams
$after
if (-not $after.IsValid) {
+57 -10
View File
@@ -8,18 +8,65 @@ param(
)
$ErrorActionPreference = 'Stop'
$existing = Get-DnsServerResourceRecord -ZoneName $ZoneName -Name $RecordName -RRType A -ErrorAction SilentlyContinue
if ($existing) {
$current = @($existing.RecordData.IPv4Address.IPAddressToString)
if ($current.Count -ne 1 -or $current[0] -ne $IPv4Address.IPAddressToString) {
# The fixed lab address is an explicit bootstrap input and may change
# when the server is rebuilt. Replace only this exact A record set.
$existing | Remove-DnsServerResourceRecord -ZoneName $ZoneName -Force
Add-DnsServerResourceRecordA -ZoneName $ZoneName -Name $RecordName -IPv4Address $IPv4Address
$dnsReady = $false
for ($attempt = 1; $attempt -le 30; $attempt++) {
try {
$soa = @(Resolve-DnsName $ZoneName -Type SOA -DnsOnly -Server localhost `
-ErrorAction Stop | Where-Object Type -eq SOA)
if ($soa.Count -gt 0) {
$dnsReady = $true
break
}
}
else {
Add-DnsServerResourceRecordA -ZoneName $ZoneName -Name $RecordName -IPv4Address $IPv4Address
catch {
# An AD-integrated zone can take a few seconds to load after DNS starts.
}
Start-Sleep -Seconds 2
}
if (-not $dnsReady) {
throw "DNS did not load the $ZoneName zone before the readiness timeout."
}
$recordReady = $false
for ($attempt = 1; $attempt -le 5; $attempt++) {
$existing = @(Get-DnsServerResourceRecord -ZoneName $ZoneName -Name $RecordName `
-RRType A -ErrorAction SilentlyContinue)
$unwanted = @($existing | Where-Object {
$_.RecordData.IPv4Address.IPAddressToString -ne $IPv4Address.IPAddressToString
})
foreach ($record in $unwanted) {
Remove-DnsServerResourceRecord -ZoneName $ZoneName -InputObject $record -Force
}
$desired = @($existing | Where-Object {
$_.RecordData.IPv4Address.IPAddressToString -eq $IPv4Address.IPAddressToString
})
if ($desired.Count -eq 0) {
try {
Add-DnsServerResourceRecordA -ZoneName $ZoneName -Name $RecordName `
-IPv4Address $IPv4Address -ErrorAction Stop
}
catch {
# A record that becomes visible while an AD-integrated zone is
# finishing its load is harmless; the verified read below decides.
}
}
Start-Sleep -Milliseconds 250
$final = @(Get-DnsServerResourceRecord -ZoneName $ZoneName -Name $RecordName `
-RRType A -ErrorAction SilentlyContinue)
$finalAddresses = @($final | ForEach-Object {
$_.RecordData.IPv4Address.IPAddressToString
})
if ($finalAddresses.Count -eq 1 -and
$finalAddresses[0] -eq $IPv4Address.IPAddressToString) {
$recordReady = $true
break
}
Start-Sleep -Seconds 1
}
if (-not $recordReady) {
throw "The $RecordName.$ZoneName A record could not be set exclusively to $IPv4Address."
}
if ($ExternalForwarders.Count -gt 0) {
+58 -13
View File
@@ -2,7 +2,10 @@
param(
[string]$TargetOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
[string]$GpoName = 'SGU - Windows client experience',
[string]$DomainController = $env:COMPUTERNAME
[string]$DomainController = $env:COMPUTERNAME,
[string]$EventCollectorFqdn,
[string]$WelcomeWallpaperScriptPath = 'C:\ProgramData\SGU\Branding\Set-SguWelcomeWallpaper.ps1',
[string]$WelcomeWallpaperBasePath = 'C:\ProgramData\SGU\Branding\darkblue.jpg'
)
$ErrorActionPreference = 'Stop'
@@ -24,6 +27,14 @@ if (-not $domainDn) {
throw 'TargetOuDn does not contain a domain distinguished name.'
}
$domainName = ($domainDn -replace ',DC=', '.')
if (-not $EventCollectorFqdn) {
$collectorComputer = Get-ADComputer -Identity $DomainController -Properties DNSHostName `
-Server $DomainController -ErrorAction Stop
$EventCollectorFqdn = $collectorComputer.DNSHostName
}
if (-not $EventCollectorFqdn) {
throw 'Could not determine the event collector FQDN.'
}
$gpo = Get-GPO -Name $GpoName -Domain $domainName -Server $DomainController -ErrorAction SilentlyContinue
if (-not $gpo -and $PSCmdlet.ShouldProcess($GpoName, 'Create the SGU Windows client policy GPO')) {
@@ -61,15 +72,46 @@ elseif (-not $existingLinkEnabled -and
$dataCollectionKey = 'HKLM\Software\Policies\Microsoft\Windows\DataCollection'
$powerPolicyRoot = 'HKLM\Software\Policies\Microsoft\Power\PowerSettings'
$credentialProviderPolicyKey = 'HKLM\Software\Policies\Microsoft\Windows\System'
$interactiveLogonPolicyKey = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System'
$accountPicturePolicyKey = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer'
$eventForwardingPolicyKey = 'HKLM\Software\Policies\Microsoft\Windows\EventLog\EventForwarding\SubscriptionManager'
$auditPolicyKey = 'HKLM\System\CurrentControlSet\Control\Lsa'
$runPolicyKey = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Run'
$personalizationPolicyKey = 'HKLM\Software\Policies\Microsoft\Windows\Personalization'
$providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}'
$welcomeWallpaperCommand = 'powershell.exe -NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "{0}" -BaseImagePath "{1}"' -f `
$WelcomeWallpaperScriptPath,$WelcomeWallpaperBasePath
$policies = @(
@{ Key = $dataCollectionKey; Name = 'AllowTelemetry'; Value = 0 },
@{ Key = $dataCollectionKey; Name = 'DisableTelemetryOptInSettingsUx'; Value = 1 },
@{ Key = $dataCollectionKey; Name = 'DisableTelemetryOptInChangeNotification'; Value = 1 },
@{ Key = $dataCollectionKey; Name = 'DisableDiagnosticDataViewer'; Value = 1 },
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\OOBE'; Name = 'DisablePrivacyExperience'; Value = 1 },
@{ Key = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System'; Name = 'EnableFirstLogonAnimation'; Value = 0 },
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\LocationAndSensors'; Name = 'DisableLocation'; Value = 1 },
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\AppPrivacy'; Name = 'LetAppsAccessLocation'; Value = 2 }
@{ Key = $dataCollectionKey; Name = 'AllowTelemetry'; Type = 'DWord'; Value = 0 },
@{ Key = $dataCollectionKey; Name = 'DisableTelemetryOptInSettingsUx'; Type = 'DWord'; Value = 1 },
@{ Key = $dataCollectionKey; Name = 'DisableTelemetryOptInChangeNotification'; Type = 'DWord'; Value = 1 },
@{ Key = $dataCollectionKey; Name = 'DisableDiagnosticDataViewer'; Type = 'DWord'; Value = 1 },
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\OOBE'; Name = 'DisablePrivacyExperience'; Type = 'DWord'; Value = 1 },
@{ Key = $interactiveLogonPolicyKey; Name = 'EnableFirstLogonAnimation'; Type = 'DWord'; Value = 0 },
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\LocationAndSensors'; Name = 'DisableLocation'; Type = 'DWord'; Value = 1 },
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\AppPrivacy'; Name = 'LetAppsAccessLocation'; Type = 'DWord'; Value = 2 },
# Enrollment selects the provider before domain join; this computer GPO
# becomes the authoritative, self-healing configuration afterwards.
@{ Key = $credentialProviderPolicyKey; Name = 'DefaultCredentialProvider'; Type = 'String'; Value = $providerClassId },
@{ Key = $credentialProviderPolicyKey; Name = 'EnumerateLocalUsers'; Type = 'DWord'; Value = 0 },
@{ Key = $interactiveLogonPolicyKey; Name = 'DontDisplayLastUserName'; Type = 'DWord'; Value = 1 },
# Use Windows' native default account image for named user tiles. LogonUI
# retains ownership of the anonymous Other user tile and its circular mask.
@{ Key = $accountPicturePolicyKey; Name = 'UseDefaultTile'; Type = 'DWord'; Value = 1 },
# Source-initiated Windows Event Forwarding. Kerberos authenticates domain
# computers to the collector; no SGU password or reusable secret is logged.
@{ Key = $eventForwardingPolicyKey; Name = '1'; Type = 'String'; Value = "Server=http://${EventCollectorFqdn}:5985/wsman/SubscriptionManager/WEC,Refresh=300" },
@{ Key = $auditPolicyKey; Name = 'SCENoApplyLegacyAuditPolicy'; Type = 'DWord'; Value = 1 },
# The machine GPO remains the authority for every interactive session. The
# local payload lets the first desktop render without depending on SMB.
@{ Key = $runPolicyKey; Name = 'SGUWelcomeWallpaper'; Type = 'String'; Value = $welcomeWallpaperCommand },
@{ Key = $personalizationPolicyKey; Name = 'LockScreenImage'; Type = 'String'; Value = $WelcomeWallpaperBasePath },
@{ Key = $personalizationPolicyKey; Name = 'NoChangingLockScreen'; Type = 'DWord'; Value = 1 }
)
$powerSettingIds = @(
@@ -80,8 +122,8 @@ $powerSettingIds = @(
)
foreach ($settingId in $powerSettingIds) {
$settingKey = "$powerPolicyRoot\$settingId"
$policies += @{ Key = $settingKey; Name = 'ACSettingIndex'; Value = 0 }
$policies += @{ Key = $settingKey; Name = 'DCSettingIndex'; Value = 0 }
$policies += @{ Key = $settingKey; Name = 'ACSettingIndex'; Type = 'DWord'; Value = 0 }
$policies += @{ Key = $settingKey; Name = 'DCSettingIndex'; Type = 'DWord'; Value = 0 }
}
foreach ($policy in $policies) {
@@ -92,7 +134,7 @@ foreach ($policy in $policies) {
-Server $DomainController `
-Key $policy.Key `
-ValueName $policy.Name `
-Type DWord `
-Type $policy.Type `
-Value $policy.Value | Out-Null
}
}
@@ -105,7 +147,7 @@ foreach ($policy in $policies) {
-Server $DomainController `
-Key $policy.Key `
-ValueName $policy.Name
$configuredPolicies[$policy.Name + '@' + $policy.Key] = [int]$configured.Value
$configuredPolicies[$policy.Name + '@' + $policy.Key] = $configured.Value
}
$link = @(Get-GPInheritance -Target $TargetOuDn -Domain $domainName -Server $DomainController).GpoLinks |
Where-Object DisplayName -eq $GpoName |
@@ -120,5 +162,8 @@ $linkEnabled = $link -and (
TargetOu = $TargetOuDn
LinkEnabled = [bool]$linkEnabled
PolicyCount = $configuredPolicies.Count
EventCollector = $EventCollectorFqdn
WelcomeWallpaperCommand = $welcomeWallpaperCommand
LockScreenImage = $WelcomeWallpaperBasePath
Policies = [pscustomobject]$configuredPolicies
}
+41 -1
View File
@@ -3,13 +3,15 @@ param(
[string]$TargetOuDn = 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx',
[string]$GpoName = 'SGU - User session restrictions',
[string]$DomainController = $env:COMPUTERNAME,
[string]$WallpaperPath
[string]$WallpaperPath,
[switch]$ClearManagedWallpaper
)
$ErrorActionPreference = 'Stop'
$policyKey = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\System'
$policyValueName = 'DisableLockWorkstation'
$desktopPolicyKey = 'HKCU\Software\Policies\Microsoft\Windows\Control Panel\Desktop'
$themeKey = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize'
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
@@ -83,6 +85,19 @@ if ($PSCmdlet.ShouldProcess($GpoName, 'Prevent SGU users from manually locking w
-Type String `
-Value '0' | Out-Null
# Apply the native Windows dark theme at user logon. Both values are required:
# one controls the shell and the other controls supported applications.
foreach ($themeValueName in 'AppsUseLightTheme', 'SystemUsesLightTheme') {
Set-GPRegistryValue `
-Name $GpoName `
-Domain $domainName `
-Server $DomainController `
-Key $themeKey `
-ValueName $themeValueName `
-Type DWord `
-Value 0 | Out-Null
}
if ($WallpaperPath) {
Set-GPRegistryValue `
-Name $GpoName `
@@ -101,6 +116,17 @@ if ($PSCmdlet.ShouldProcess($GpoName, 'Prevent SGU users from manually locking w
-Type String `
-Value '10' | Out-Null
}
elseif ($ClearManagedWallpaper) {
foreach ($wallpaperValueName in 'Wallpaper','WallpaperStyle') {
Remove-GPRegistryValue `
-Name $GpoName `
-Domain $domainName `
-Server $DomainController `
-Key $policyKey `
-ValueName $wallpaperValueName `
-ErrorAction SilentlyContinue | Out-Null
}
}
}
$configuredValue = Get-GPRegistryValue `
@@ -115,6 +141,18 @@ $screenSaverValue = Get-GPRegistryValue `
-Server $DomainController `
-Key $desktopPolicyKey `
-ValueName 'ScreenSaveActive'
$appsThemeValue = Get-GPRegistryValue `
-Name $GpoName `
-Domain $domainName `
-Server $DomainController `
-Key $themeKey `
-ValueName 'AppsUseLightTheme'
$systemThemeValue = Get-GPRegistryValue `
-Name $GpoName `
-Domain $domainName `
-Server $DomainController `
-Key $themeKey `
-ValueName 'SystemUsesLightTheme'
$link = @(Get-GPInheritance -Target $TargetOuDn -Domain $domainName -Server $DomainController).GpoLinks |
Where-Object DisplayName -eq $GpoName |
Select-Object -First 1
@@ -138,5 +176,7 @@ if ($WallpaperPath) {
LinkEnabled = [bool]$linkEnabled
DisableLockWorkstation = [int]$configuredValue.Value
ScreenSaverDisabled = [string]$screenSaverValue.Value -eq '0'
DarkMode = ([int]$appsThemeValue.Value -eq 0) -and ([int]$systemThemeValue.Value -eq 0)
Wallpaper = $configuredWallpaper
DynamicWallpaperAllowed = -not [bool]$configuredWallpaper
}
+133
View File
@@ -0,0 +1,133 @@
#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')
}
function Get-LocalGroupMemberSid {
param([Parameter(Mandatory)][string]$Name)
$group = [ADSI]("WinNT://$env:COMPUTERNAME/$Name,group")
foreach ($member in @($group.psbase.Invoke('Members'))) {
try {
$sidBytes = $member.GetType().InvokeMember(
'objectSid',
[Reflection.BindingFlags]::GetProperty,
$null,
$member,
$null)
if ($sidBytes) {
([Security.Principal.SecurityIdentifier]::new([byte[]]$sidBytes, 0)).Value
}
}
catch {
# An orphaned domain SID can no longer resolve after a forest is
# rebuilt. Other members must remain inspectable and unchanged.
}
}
}
$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) {
# Preserve existing credentials on enrollment/repair. Resetting the same
# password after domain join can violate password history/complexity.
Set-LocalUser -Name $userName `
-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-LocalGroupMemberSid -Name $administratorsGroup.Name)
if ($administratorMembers -contains $user.SID.Value) {
([ADSI]("WinNT://$env:COMPUTERNAME/$($administratorsGroup.Name),group")).Remove(
"WinNT://$env:COMPUTERNAME/$userName,user")
}
$standardMembers = @(Get-LocalGroupMemberSid -Name $usersGroup.Name)
if ($standardMembers -notcontains $user.SID.Value) {
([ADSI]("WinNT://$env:COMPUTERNAME/$($usersGroup.Name),group")).Add(
"WinNT://$env:COMPUTERNAME/$userName,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-LocalGroupMemberSid -Name $verifiedAdministratorsGroup.Name)
$verifiedUsers = @(Get-LocalGroupMemberSid -Name $verifiedUsersGroup.Name)
if ($verifiedAdministrators -contains $verifiedUser.SID.Value) {
throw "The local account '$userName' still belongs to the local Administrators group."
}
if ($verifiedUsers -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
}
+500
View File
@@ -0,0 +1,500 @@
#Requires -Version 5.1
[CmdletBinding()]
param(
[string]$BaseImagePath = (Join-Path $env:ProgramData 'SGU\Branding\darkblue.jpg'),
[string]$FontsPath = (Join-Path $env:ProgramData 'SGU\Branding\fonts'),
[string]$OutputPath,
[string]$DisplayName,
[string]$ComputerName = $env:COMPUTERNAME,
[string]$Location,
[string]$OrganizationalUnit,
[ValidateSet('Male', 'Female')]
[string]$Gender,
[ValidateRange(640, 16384)]
[int]$CanvasWidth,
[ValidateRange(480, 16384)]
[int]$CanvasHeight,
[switch]$SkipDirectoryLookup,
[switch]$SkipApply
)
$ErrorActionPreference = 'Stop'
$script:LogPath = Join-Path $env:LOCALAPPDATA 'SGU\Logs\welcome-wallpaper.log'
Add-Type -AssemblyName System.Drawing
function Write-WelcomeLog {
param([Parameter(Mandatory)][string]$Message)
try {
$logDirectory = Split-Path $script:LogPath -Parent
New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null
Add-Content -LiteralPath $script:LogPath `
-Value ('{0:o} {1}' -f (Get-Date), $Message) `
-Encoding UTF8
}
catch {
# The wallpaper must still be generated when logging is unavailable.
}
}
function ConvertTo-LdapFilterValue {
param([Parameter(Mandatory)][string]$Value)
return $Value.Replace('\', '\5c').Replace('*', '\2a').Replace('(', '\28').Replace(')', '\29').Replace(([string][char]0), '\00')
}
function ConvertFrom-LdapRdnValue {
param([Parameter(Mandatory)][string]$Value)
$decoded = [Text.RegularExpressions.Regex]::Replace(
$Value,
'\\([0-9A-Fa-f]{2})',
{ param($match) [char][Convert]::ToByte($match.Groups[1].Value, 16) })
return $decoded.Replace('\,', ',').Replace('\+', '+').Replace('\=', '=').Replace('\\', '\')
}
function Get-ImmediateOrganizationalUnit {
param([string]$DistinguishedName)
if (-not $DistinguishedName) {
return $null
}
$parts = [Text.RegularExpressions.Regex]::Split($DistinguishedName, '(?<!\\),')
foreach ($part in $parts) {
if ($part.StartsWith('OU=', [StringComparison]::OrdinalIgnoreCase)) {
return ConvertFrom-LdapRdnValue -Value $part.Substring(3)
}
}
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,
[Parameter(Mandatory)][string]$MachineName
)
Add-Type -AssemblyName System.DirectoryServices
$rootDse = [DirectoryServices.DirectoryEntry]::new('LDAP://RootDSE')
try {
$namingContext = [string]$rootDse.Properties['defaultNamingContext'][0]
}
finally {
$rootDse.Dispose()
}
if (-not $namingContext) {
throw 'Active Directory did not return a default naming context.'
}
$searchRoot = [DirectoryServices.DirectoryEntry]::new("LDAP://$namingContext")
try {
$userSearcher = [DirectoryServices.DirectorySearcher]::new($searchRoot)
try {
$userSearcher.PageSize = 1
$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]
}
else {
$null
}
$directoryGender = if ($userResult -and $userResult.Properties['info'].Count) {
Get-SguGenderFromInfo -Info ([string]$userResult.Properties['info'][0])
}
else {
$null
}
}
finally {
$userSearcher.Dispose()
}
$computerSearcher = [DirectoryServices.DirectorySearcher]::new($searchRoot)
try {
$computerSearcher.PageSize = 1
$computerSearcher.Filter = '(&(objectCategory=computer)(sAMAccountName={0}))' -f `
(ConvertTo-LdapFilterValue -Value ($MachineName + '$'))
[void]$computerSearcher.PropertiesToLoad.Add('location')
[void]$computerSearcher.PropertiesToLoad.Add('distinguishedName')
$computerResult = $computerSearcher.FindOne()
$directoryLocation = if ($computerResult -and $computerResult.Properties['location'].Count) {
[string]$computerResult.Properties['location'][0]
}
else {
$null
}
$computerDn = if ($computerResult -and $computerResult.Properties['distinguishedname'].Count) {
[string]$computerResult.Properties['distinguishedname'][0]
}
else {
$null
}
}
finally {
$computerSearcher.Dispose()
}
}
finally {
$searchRoot.Dispose()
}
[pscustomobject]@{
DisplayName = $directoryDisplayName
Gender = $directoryGender
Location = $directoryLocation
OrganizationalUnit = Get-ImmediateOrganizationalUnit -DistinguishedName $computerDn
}
}
function Get-SpanishArticle {
param([Parameter(Mandatory)][string]$Value)
if ($Value -match '^(Sala|Aula|Facultad|Unidad|Biblioteca|Oficina|Coordinaci.n)\b') {
return 'la'
}
if ($Value -match '^(Laboratorio|Centro|Edificio|Campus|Taller|Auditorio)\b') {
return 'el'
}
return $null
}
function Get-WelcomeLocationText {
param(
[string]$Room,
[string]$OuName,
[string]$Gender
)
$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 }
if ($Room -and $OuName) {
$roomArticle = Get-SpanishArticle -Value $Room
$ouArticle = Get-SpanishArticle -Value $OuName
$roomPhrase = if ($roomArticle) { "$roomArticle $Room" } else { $Room }
$ouPhrase = if ($ouArticle -eq 'el') { "del $OuName" } elseif ($ouArticle) { "de $ouArticle $OuName" } else { "de $OuName" }
return "$located $roomPhrase $ouPhrase."
}
if ($Room) {
$article = Get-SpanishArticle -Value $Room
$phrase = if ($article) { "$article $Room" } else { $Room }
return "$located $phrase."
}
if ($OuName) {
$article = Get-SpanishArticle -Value $OuName
$phrase = if ($article) { "$article $OuName" } else { $OuName }
return "$located $phrase."
}
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,
[Drawing.FontFamily[]]$PrivateFamilies = @()
)
foreach ($candidate in $Candidates) {
$privateMatch = @($PrivateFamilies | Where-Object Name -eq $candidate | Select-Object -First 1)
if ($privateMatch.Count) {
return $privateMatch[0]
}
if (@([Drawing.FontFamily]::Families | ForEach-Object Name) -contains $candidate) {
return [Drawing.FontFamily]::new($candidate)
}
}
return [Drawing.FontFamily]::GenericSansSerif
}
function New-WelcomeFont {
param(
[Parameter(Mandatory)][Drawing.FontFamily]$Family,
[Parameter(Mandatory)][single]$Size,
[Parameter(Mandatory)][Drawing.FontStyle]$PreferredStyle
)
$style = if ($Family.IsStyleAvailable($PreferredStyle)) { $PreferredStyle } `
elseif ($Family.IsStyleAvailable([Drawing.FontStyle]::Bold)) { [Drawing.FontStyle]::Bold } `
else { [Drawing.FontStyle]::Regular }
return [Drawing.Font]::new($Family, $Size, $style, [Drawing.GraphicsUnit]::Pixel)
}
function Draw-CenteredText {
param(
[Parameter(Mandatory)][Drawing.Graphics]$Graphics,
[Parameter(Mandatory)][string]$Text,
[Parameter(Mandatory)][Drawing.Font]$Font,
[Parameter(Mandatory)][Drawing.Brush]$Brush,
[Parameter(Mandatory)][Drawing.RectangleF]$Bounds,
[Parameter(Mandatory)][Drawing.StringFormat]$Format,
[single]$ShadowOffset = 2
)
$shadowBounds = [Drawing.RectangleF]::new(
$Bounds.X + $ShadowOffset,
$Bounds.Y + $ShadowOffset,
$Bounds.Width,
$Bounds.Height)
$shadow = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(135, 0, 0, 0))
try {
$Graphics.DrawString($Text, $Font, $shadow, $shadowBounds, $Format)
$Graphics.DrawString($Text, $Font, $Brush, $Bounds, $Format)
}
finally {
$shadow.Dispose()
}
}
trap {
Write-WelcomeLog -Message ('ERROR ' + $_.Exception.Message)
throw
}
if (-not (Test-Path -LiteralPath $BaseImagePath -PathType Leaf)) {
throw "The welcome wallpaper base image does not exist: $BaseImagePath"
}
$userName = [Environment]::UserName
$metadata = $null
if (-not $SkipDirectoryLookup) {
try {
$metadata = Get-DirectoryWelcomeMetadata -UserName $userName -MachineName $ComputerName
}
catch {
Write-WelcomeLog -Message ('WARN Active Directory metadata was unavailable: ' + $_.Exception.Message)
}
}
if (-not $PSBoundParameters.ContainsKey('DisplayName')) {
$DisplayName = if ($metadata -and $metadata.DisplayName) { $metadata.DisplayName } else { $userName }
}
if (-not $DisplayName) {
$DisplayName = $userName
}
if (-not $PSBoundParameters.ContainsKey('Location') -and $metadata) {
$Location = $metadata.Location
}
$genderWasProvided = $PSBoundParameters.ContainsKey('Gender')
if (-not $genderWasProvided -and $metadata -and $metadata.Gender -in @('Male', 'Female')) {
# The parameter's ValidateSet also runs on assignments. Missing AD gender
# must leave the optional parameter unset so the neutral wording can render.
$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 -Gender $Gender
if (-not $CanvasWidth -or -not $CanvasHeight) {
try {
Add-Type -AssemblyName System.Windows.Forms
$screenBounds = [Windows.Forms.Screen]::PrimaryScreen.Bounds
if (-not $CanvasWidth) { $CanvasWidth = $screenBounds.Width }
if (-not $CanvasHeight) { $CanvasHeight = $screenBounds.Height }
}
catch {
if (-not $CanvasWidth) { $CanvasWidth = 1600 }
if (-not $CanvasHeight) { $CanvasHeight = 1000 }
}
}
if (-not $OutputPath) {
$wallpaperDirectory = Join-Path $env:LOCALAPPDATA 'SGU\Wallpapers'
$safeComputerName = $ComputerName -replace '[^A-Za-z0-9_.-]', '_'
$OutputPath = Join-Path $wallpaperDirectory "welcome-$safeComputerName.jpg"
}
New-Item -ItemType Directory -Path (Split-Path $OutputPath -Parent) -Force | Out-Null
$source = [Drawing.Image]::FromFile($BaseImagePath)
$canvas = [Drawing.Bitmap]::new($CanvasWidth, $CanvasHeight, [Drawing.Imaging.PixelFormat]::Format24bppRgb)
try {
$graphics = [Drawing.Graphics]::FromImage($canvas)
try {
$graphics.SmoothingMode = [Drawing.Drawing2D.SmoothingMode]::HighQuality
$graphics.InterpolationMode = [Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$graphics.PixelOffsetMode = [Drawing.Drawing2D.PixelOffsetMode]::HighQuality
$graphics.TextRenderingHint = [Drawing.Text.TextRenderingHint]::AntiAliasGridFit
$sourceRatio = $source.Width / $source.Height
$targetRatio = $CanvasWidth / $CanvasHeight
if ($sourceRatio -gt $targetRatio) {
$sourceHeight = $source.Height
$sourceWidth = [int]($sourceHeight * $targetRatio)
$sourceX = [int](($source.Width - $sourceWidth) / 2)
$sourceY = 0
}
else {
$sourceWidth = $source.Width
$sourceHeight = [int]($sourceWidth / $targetRatio)
$sourceX = 0
$sourceY = [int](($source.Height - $sourceHeight) / 2)
}
$graphics.DrawImage(
$source,
[Drawing.Rectangle]::new(0, 0, $CanvasWidth, $CanvasHeight),
$sourceX,
$sourceY,
$sourceWidth,
$sourceHeight,
[Drawing.GraphicsUnit]::Pixel)
$scale = [Math]::Min($CanvasWidth / 1600.0, $CanvasHeight / 1000.0)
$panelWidth = [single]($CanvasWidth * 0.76)
$panelHeight = [single](310 * $scale)
$panelX = [single](($CanvasWidth - $panelWidth) / 2)
$panelY = [single]($CanvasHeight * 0.50 - ($panelHeight / 2))
$panelBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(72, 0, 13, 58))
$whiteBrush = [Drawing.SolidBrush]::new([Drawing.Color]::White)
$accentBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(255, 211, 226, 255))
$linePen = [Drawing.Pen]::new([Drawing.Color]::FromArgb(155, 211, 226, 255), [single](2 * $scale))
$privateFonts = [Drawing.Text.PrivateFontCollection]::new()
if (Test-Path -LiteralPath $FontsPath -PathType Container) {
foreach ($fontFile in Get-ChildItem -LiteralPath $FontsPath -File |
Where-Object Extension -in '.otf','.ttf') {
try {
$privateFonts.AddFontFile($fontFile.FullName)
}
catch {
Write-WelcomeLog -Message ("WARN Font could not be loaded: {0}" -f $fontFile.Name)
}
}
}
$sansFamily = Get-AvailableFontFamily `
-Candidates @('Indivisa Text Sans', 'Indivisa Text', 'Segoe UI') `
-PrivateFamilies $privateFonts.Families
$serifFamily = Get-AvailableFontFamily `
-Candidates @('Indivisa Text Serif', 'Indivisa Serif', 'Georgia') `
-PrivateFamilies $privateFonts.Families
$welcomeFont = New-WelcomeFont -Family $sansFamily -Size ([single](34 * $scale)) -PreferredStyle ([Drawing.FontStyle]::Bold)
$nameFont = New-WelcomeFont -Family $serifFamily -Size ([single](70 * $scale)) `
-PreferredStyle ([Drawing.FontStyle]::Bold -bor [Drawing.FontStyle]::Italic)
$locationFont = New-WelcomeFont -Family $sansFamily -Size ([single](27 * $scale)) `
-PreferredStyle ([Drawing.FontStyle]::Regular)
$format = [Drawing.StringFormat]::new()
$format.Alignment = [Drawing.StringAlignment]::Center
$format.LineAlignment = [Drawing.StringAlignment]::Center
$format.Trimming = [Drawing.StringTrimming]::EllipsisWord
try {
$graphics.FillRectangle($panelBrush, $panelX, $panelY, $panelWidth, $panelHeight)
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
$graphics.DrawLine($linePen, $panelX + 150*$scale, $panelY + 180*$scale, $panelX + $panelWidth - 150*$scale, $panelY + 180*$scale)
Draw-CenteredText -Graphics $graphics -Text $locationText -Font $locationFont `
-Brush $accentBrush -Bounds ([Drawing.RectangleF]::new($panelX + 60*$scale, $panelY + 190*$scale, $panelWidth - 120*$scale, 94*$scale)) -Format $format
}
finally {
$format.Dispose()
$locationFont.Dispose()
$nameFont.Dispose()
$welcomeFont.Dispose()
$serifFamily.Dispose()
$sansFamily.Dispose()
$privateFonts.Dispose()
$linePen.Dispose()
$accentBrush.Dispose()
$whiteBrush.Dispose()
$panelBrush.Dispose()
}
}
finally {
$graphics.Dispose()
}
$jpegCodec = [Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() |
Where-Object MimeType -eq 'image/jpeg' |
Select-Object -First 1
$encoderParameters = [Drawing.Imaging.EncoderParameters]::new(1)
$encoderParameters.Param[0] = [Drawing.Imaging.EncoderParameter]::new(
[Drawing.Imaging.Encoder]::Quality,
[long]94)
try {
$canvas.Save($OutputPath, $jpegCodec, $encoderParameters)
}
finally {
$encoderParameters.Dispose()
}
}
finally {
$canvas.Dispose()
$source.Dispose()
}
if (-not $SkipApply) {
$desktopKey = 'HKCU:\Control Panel\Desktop'
Set-ItemProperty -LiteralPath $desktopKey -Name Wallpaper -Value $OutputPath
Set-ItemProperty -LiteralPath $desktopKey -Name WallpaperStyle -Value '10'
Set-ItemProperty -LiteralPath $desktopKey -Name TileWallpaper -Value '0'
if (-not ('Sgu.NativeMethods' -as [type])) {
Add-Type @'
using System;
using System.Runtime.InteropServices;
namespace Sgu {
public static class NativeMethods {
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern bool SystemParametersInfo(int action, int parameter, string value, int flags);
}
}
'@
}
if (-not [Sgu.NativeMethods]::SystemParametersInfo(20, 0, $OutputPath, 3)) {
throw "Windows could not apply the generated wallpaper. Win32 error: $([Runtime.InteropServices.Marshal]::GetLastWin32Error())"
}
}
$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
}
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env bash
# Generates and applies the SGU welcome wallpaper inside a Linux desktop session.
# It is intentionally best-effort: unavailable AD metadata or desktop APIs must
# never delay or prevent the user's session from opening.
set -uo pipefail
CONFIG_PATH=${SGU_WELCOME_CONFIG:-/etc/sgu/welcome-wallpaper.conf}
INSTALL_ROOT=${SGU_WELCOME_ROOT:-/usr/local/lib/sgu-welcome-wallpaper}
BASE_IMAGE=${SGU_WELCOME_BASE_IMAGE:-${INSTALL_ROOT}/darkblue.jpg}
if [[ -r $CONFIG_PATH ]]; then
# The root-owned file contains only deployment metadata, never credentials.
# shellcheck source=/dev/null
source "$CONFIG_PATH"
fi
DOMAIN_CONTROLLER=${DOMAIN_CONTROLLER:-}
DOMAIN_NAME=${DOMAIN_NAME:-}
BASE_DN=${BASE_DN:-}
state_root=${XDG_STATE_HOME:-${HOME}/.local/state}
wallpaper_root=${XDG_CACHE_HOME:-${HOME}/.cache}/sgu/wallpapers
log_path="${state_root}/sgu/welcome-wallpaper.log"
log_message() {
mkdir -p "$(dirname "$log_path")" 2>/dev/null || true
printf '%s %s\n' "$(date --iso-8601=seconds 2>/dev/null || date)" "$*" >>"$log_path" 2>/dev/null || true
}
fail_softly() {
log_message "ERROR $*"
exit 0
}
[[ -r $BASE_IMAGE ]] || fail_softly "Missing base image: $BASE_IMAGE"
if command -v magick >/dev/null 2>&1; then
image_command=(magick)
elif command -v convert >/dev/null 2>&1; then
image_command=(convert)
else
fail_softly 'ImageMagick is unavailable.'
fi
raw_user=${USER:-$(id -un 2>/dev/null || printf user)}
account_name=${raw_user%@*}
account_name=${account_name##*\\}
display_name=$(getent passwd "$raw_user" 2>/dev/null | awk -F: 'NR == 1 { split($5,a,","); print a[1] }')
[[ -n $display_name ]] || display_name=$account_name
computer_name=$(hostname -s 2>/dev/null || true)
computer_name=${computer_name^^}
location=''
distinguished_name=''
organizational_unit=''
gender=''
read_ldif_value() {
local attribute=$1
local content=$2
local line value
line=$(printf '%s\n' "$content" | awk -v name="$attribute" '
BEGIN { IGNORECASE=1 }
index(tolower($0), tolower(name) ":") == 1 { print; exit }
')
[[ -n $line ]] || return 0
if [[ $line == "${attribute}:: "* || ${line,,} == "${attribute,,}:: "* ]]; then
value=${line#*:: }
printf '%s' "$value" | base64 --decode 2>/dev/null || true
else
printf '%s' "${line#*: }"
fi
}
# SSSD normally obtains a Kerberos ticket during PAM authentication. Use that
# ticket for a read-only AD query; never embed a bind password in this helper.
if [[ -n $DOMAIN_CONTROLLER && -n $BASE_DN ]] &&
command -v ldapsearch >/dev/null 2>&1 &&
command -v klist >/dev/null 2>&1 && klist -s; then
ldap_server=$DOMAIN_CONTROLLER
if [[ -n $DOMAIN_NAME ]] && command -v resolvectl >/dev/null 2>&1; then
discovered_server=$(resolvectl query --type=SRV \
"_ldap._tcp.dc._msdcs.${DOMAIN_NAME}" 2>/dev/null |
awk '/ IN SRV / { for (i=1; i<=NF; i++) if ($i == "SRV") { print $(i+4); exit } }' |
sed 's/\.$//' || true)
[[ -n $discovered_server ]] && ldap_server=$discovered_server
elif [[ -n $DOMAIN_NAME ]] && command -v dig >/dev/null 2>&1; then
discovered_server=$(dig +short SRV "_ldap._tcp.dc._msdcs.${DOMAIN_NAME}" 2>/dev/null |
awk 'NR == 1 { print $4 }' | sed 's/\.$//' || true)
[[ -n $discovered_server ]] && ldap_server=$discovered_server
fi
ldap_result=$(ldapsearch -LLL -N -o ldif-wrap=no -Y GSSAPI \
-H "ldap://${ldap_server}" -b "$BASE_DN" \
"(&(objectCategory=computer)(sAMAccountName=${computer_name}\\24))" \
location distinguishedName 2>/dev/null || true)
location=$(read_ldif_value location "$ldap_result")
distinguished_name=$(read_ldif_value distinguishedName "$ldap_result")
if [[ $distinguished_name =~ ,OU=([^,]+) ]]; then
organizational_unit=${BASH_REMATCH[1]}
organizational_unit=${organizational_unit//\\,/,}
organizational_unit=${organizational_unit//\\=/=}
organizational_unit=${organizational_unit//\\+/+}
fi
# SSSD's GECOS field is not guaranteed to expose AD displayName. Query it
# through the same authenticated LDAP session and retain the account-name
# fallback when the institutional identifier contains unexpected symbols.
if [[ $account_name =~ ^[A-Za-z0-9._-]+$ ]]; then
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 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.'
fi
article_for() {
local value=${1,,}
case "$value" in
sala*|aula*|facultad*|unidad*|biblioteca*|oficina*|coordinación*) printf la ;;
laboratorio*|centro*|edificio*|campus*|taller*|auditorio*) printf el ;;
*) printf '' ;;
esac
}
with_article() {
local value=$1
local article
article=$(article_for "$value")
if [[ -n $article ]]; then
printf '%s %s' "$article" "$value"
else
printf '%s' "$value"
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")
if [[ $ou_article == el ]]; then
ou_phrase="del ${organizational_unit}"
elif [[ -n $ou_article ]]; then
ou_phrase="de ${ou_article} ${organizational_unit}"
else
ou_phrase="de ${organizational_unit}"
fi
location_text="${located_text} ${room_phrase} ${ou_phrase}."
elif [[ -n $location ]]; then
location_text="${located_text} $(with_article "$location")."
elif [[ -n $organizational_unit ]]; then
location_text="${located_text} $(with_article "$organizational_unit")."
else
location_text=$engineering_lab_text
fi
width=1600
height=1000
if command -v xrandr >/dev/null 2>&1; then
geometry=$(xrandr --current 2>/dev/null | awk '/\*/ { print $1; exit }')
if [[ $geometry =~ ^([0-9]+)x([0-9]+)$ ]]; then
width=${BASH_REMATCH[1]}
height=${BASH_REMATCH[2]}
fi
fi
mkdir -p "$wallpaper_root" "$(dirname "$log_path")" ||
fail_softly "Cannot create welcome wallpaper state directories."
safe_computer=${computer_name//[^A-Za-z0-9_.-]/_}
output_path="${wallpaper_root}/welcome-${safe_computer}.jpg"
scale=$(( height * 100 / 1000 ))
(( scale > 45 )) || scale=45
welcome_size=$(( 34 * scale / 100 ))
name_size=$(( 70 * scale / 100 ))
location_size=$(( 27 * scale / 100 ))
panel_width=$(( width * 76 / 100 ))
panel_height=$(( 310 * scale / 100 ))
panel_x1=$(( (width - panel_width) / 2 ))
panel_y1=$(( height / 2 - panel_height / 2 ))
panel_x2=$(( panel_x1 + panel_width ))
panel_y2=$(( panel_y1 + panel_height ))
sans_font='DejaVu-Sans'
serif_font='DejaVu-Serif'
if [[ -r ${INSTALL_ROOT}/fonts/IndivisaTextSans-Bold.otf ]]; then
sans_font="${INSTALL_ROOT}/fonts/IndivisaTextSans-Bold.otf"
fi
if [[ -r ${INSTALL_ROOT}/fonts/IndivisaTextSerif-BoldItalic.otf ]]; then
serif_font="${INSTALL_ROOT}/fonts/IndivisaTextSerif-BoldItalic.otf"
fi
if [[ $sans_font == DejaVu-Sans ]] && command -v fc-list >/dev/null 2>&1; then
if fc-list : family | grep -Fqi 'Indivisa Text Sans'; then
sans_font='Indivisa Text Sans'
elif fc-list : family | grep -Fqi 'Indivisa Text'; then
sans_font='Indivisa Text'
fi
fi
if [[ $serif_font == DejaVu-Serif ]] && command -v fc-list >/dev/null 2>&1; then
if fc-list : family | grep -Fqi 'Indivisa Text Serif'; then
serif_font='Indivisa Text Serif'
elif fc-list : family | grep -Fqi 'Indivisa Serif'; then
serif_font='Indivisa Serif'
fi
fi
if ! "${image_command[@]}" "$BASE_IMAGE" \
-resize "${width}x${height}^" -gravity center -extent "${width}x${height}" \
-fill 'rgba(0,13,58,0.30)' -draw "rectangle ${panel_x1},${panel_y1} ${panel_x2},${panel_y2}" \
-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 ))" "$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" \
-fill '#D3E2FF' -annotate "+0+$(( 88 * scale / 100 ))" "$location_text" \
-quality 94 "$output_path" 2>>"$log_path"; then
fail_softly 'ImageMagick could not render the welcome wallpaper.'
fi
applied=false
if command -v gsettings >/dev/null 2>&1; then
if gsettings list-schemas 2>/dev/null | grep -Fxq 'org.cinnamon.desktop.background'; then
gsettings set org.cinnamon.desktop.background picture-uri "file://${output_path}" >/dev/null 2>&1 || true
gsettings set org.cinnamon.desktop.background picture-options zoom >/dev/null 2>&1 || true
applied=true
fi
if gsettings list-schemas 2>/dev/null | grep -Fxq 'org.gnome.desktop.background'; then
gsettings set org.gnome.desktop.background picture-uri "file://${output_path}" >/dev/null 2>&1 || true
gsettings set org.gnome.desktop.background picture-uri-dark "file://${output_path}" >/dev/null 2>&1 || true
gsettings set org.gnome.desktop.background picture-options zoom >/dev/null 2>&1 || true
applied=true
fi
fi
if [[ $applied == false ]] && command -v xfconf-query >/dev/null 2>&1; then
while IFS= read -r property; do
xfconf-query -c xfce4-desktop -p "$property" -s "$output_path" >/dev/null 2>&1 || true
applied=true
done < <(xfconf-query -c xfce4-desktop -l 2>/dev/null | grep '/last-image$' || true)
fi
if [[ $applied == true ]]; then
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
exit 0
@@ -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 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%
+11 -2
View File
@@ -1,5 +1,14 @@
@echo off
setlocal
set "SGU_BOOTSTRAP_IP=%~1"
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"')); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',$env:SGU_BOOTSTRAP_IP) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode"
exit /b %errorlevel%
set "SGU_CLIENT_IP=%~2"
set "SGU_NETWORK_ALIAS=%~3"
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"'),'-PauseOnError'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',$env:SGU_BOOTSTRAP_IP) }; if ($env:SGU_CLIENT_IP) { $arguments += @('-ClientIPv4Address',$env:SGU_CLIENT_IP) }; if ($env:SGU_NETWORK_ALIAS) { $arguments += @('-NetworkInterfaceAlias',('"' + $env:SGU_NETWORK_ALIAS + '"')) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode"
set "SGU_EXIT_CODE=%errorlevel%"
if not "%SGU_EXIT_CODE%"=="0" (
echo.
echo SGU client enrollment did not complete. Review the elevated window or:
echo C:\ProgramData\SGU\Bootstrap\Client\latest-error.log
pause
)
exit /b %SGU_EXIT_CODE%
+121 -2
View File
@@ -4,6 +4,8 @@ param(
[switch]$RequireRemoteAccess,
[switch]$RequireBrokerHealth,
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
[switch]$RequireRustDesk,
[string]$RustDeskServerAddress,
[switch]$Enforce
)
@@ -17,6 +19,30 @@ $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
function Get-LocalGroupMemberSid {
param([Parameter(Mandatory)][string]$Name)
$group = [ADSI]("WinNT://$env:COMPUTERNAME/$Name,group")
foreach ($member in @($group.psbase.Invoke('Members'))) {
try {
$sidBytes = $member.GetType().InvokeMember(
'objectSid',
[Reflection.BindingFlags]::GetProperty,
$null,
$member,
$null)
if ($sidBytes) {
([Security.Principal.SecurityIdentifier]::new([byte[]]$sidBytes, 0)).Value
}
}
catch {
# Keep validating known members when an old forest SID no longer resolves.
}
}
}
$computer = Get-CimInstance Win32_ComputerSystem
if ($RequireDomainJoined -and -not $computer.PartOfDomain) {
@@ -85,6 +111,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-LocalGroupMemberSid -Name $administratorsGroup.Name)
$standardMembers = @(Get-LocalGroupMemberSid -Name $usersGroup.Name)
$standardLocalUserIsAdministrator =
$administratorMembers -contains $standardLocalUser.SID.Value
$standardLocalUserInUsersGroup =
$standardMembers -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
@@ -147,17 +217,59 @@ $remoteAccessReady = $null
if ($RequireRemoteAccess) {
$remoteDesktopUsersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-555')
$remoteDesktopUsersGroup = ($remoteDesktopUsersSid.Translate([Security.Principal.NTAccount]).Value -split '\\', 2)[1]
$rdpMembers = @(Get-LocalGroupMember -Group $remoteDesktopUsersGroup -ErrorAction SilentlyContinue)
$rdpMembers = @(Get-LocalGroupMemberSid -Name $remoteDesktopUsersGroup)
$remoteDesktopPrincipalSid = $null
try {
$remoteDesktopPrincipalSid = ([Security.Principal.NTAccount]::new($RemoteDesktopPrincipal)).Translate(
[Security.Principal.SecurityIdentifier]).Value
}
catch { }
$remoteAccessReady =
(Get-Service TermService).Status -eq 'Running' -and
(Get-Service WinRM).Status -eq 'Running' -and
(Get-ItemPropertyValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' -Name fDenyTSConnections) -eq 0 -and
$rdpMembers.Name -contains $RemoteDesktopPrincipal
$remoteDesktopPrincipalSid -and $rdpMembers -contains $remoteDesktopPrincipalSid
if (-not $remoteAccessReady) {
$issues.Add('RDP/WinRM or the authorized domain group is not fully configured.')
}
}
$rustDeskReady = $null
$rustDeskId = $null
if ($RequireRustDesk) {
if ([string]::IsNullOrWhiteSpace($RustDeskServerAddress)) {
$issues.Add('RustDesk validation requires RustDeskServerAddress.')
}
$rustDeskService = Get-Service -Name 'RustDesk' -ErrorAction SilentlyContinue
$rustDeskStatePath = Join-Path $env:ProgramData 'SGU\RustDesk\Client\device.json'
$rustDeskSecretPath = Join-Path $env:ProgramData 'SGU\RustDesk\Client\access.secret'
$rustDeskConfigPath = Join-Path $env:WINDIR `
'ServiceProfiles\LocalService\AppData\Roaming\RustDesk\config\RustDesk2.toml'
$rustDeskConfig = if (Test-Path -LiteralPath $rustDeskConfigPath -PathType Leaf) {
Get-Content -LiteralPath $rustDeskConfigPath -Raw
}
else {
''
}
$rustDeskState = $null
try {
$rustDeskState = Get-Content -LiteralPath $rustDeskStatePath -Raw | ConvertFrom-Json
$rustDeskId = [string]$rustDeskState.RustDeskId
}
catch {
# The checks below report the missing or invalid state as one enrollment issue.
}
$rustDeskReady =
$rustDeskService -and $rustDeskService.Status -eq 'Running' -and
(Test-Path -LiteralPath $rustDeskSecretPath -PathType Leaf) -and
$rustDeskState -and $rustDeskState.ServerAddress -eq $RustDeskServerAddress -and
$rustDeskId -match '^\d+$' -and
$rustDeskConfig -match [regex]::Escape("rendezvous_server = '$RustDeskServerAddress`:21116'")
if (-not $rustDeskReady) {
$issues.Add('RustDesk is not installed, running, or configured for the expected self-hosted server.')
}
}
$result = [pscustomobject]@{
ComputerName = $env:COMPUTERNAME
Domain = $computer.Domain
@@ -169,12 +281,19 @@ $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
DotNetRuntimePresent = $dotNetRuntimePresent
BrokerHealth = $brokerHealth
RemoteAccessReady = $remoteAccessReady
RustDeskReady = $rustDeskReady
RustDeskId = $rustDeskId
IsValid = $issues.Count -eq 0
Issues = $issues.ToArray()
}
@@ -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,8 +37,15 @@ 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);
public static InstitutionalProfile? ParseProfessorPayroll(string html, string expectedEmployeeNumber)
=> ParseStaffHeader(html, expectedEmployeeNumber);
private static InstitutionalProfile? ParseStaffHeader(string html, string expectedEmployeeNumber)
{
ArgumentNullException.ThrowIfNull(html);
ArgumentException.ThrowIfNullOrWhiteSpace(expectedEmployeeNumber);
@@ -85,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);
@@ -99,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);
@@ -145,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;
}
@@ -286,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)
{
@@ -318,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[] { '"', '\'' })
+25
View File
@@ -0,0 +1,25 @@
namespace SGU.AuthBroker;
internal static class BrokerEventIds
{
internal static readonly EventId BrokerStarted = new(900, nameof(BrokerStarted));
internal static readonly EventId AuthenticationAuthorized = new(1000, nameof(AuthenticationAuthorized));
internal static readonly EventId AuthenticationRejected = new(1001, nameof(AuthenticationRejected));
internal static readonly EventId AuthenticationUnavailable = new(1002, nameof(AuthenticationUnavailable));
internal static readonly EventId AuthenticationInvalidRequest = new(1003, nameof(AuthenticationInvalidRequest));
internal static readonly EventId SguAuthenticationAccepted = new(1100, nameof(SguAuthenticationAccepted));
internal static readonly EventId SguAuthenticationTimeout = new(1101, nameof(SguAuthenticationTimeout));
internal static readonly EventId SguAuthenticationNetworkFailure = new(1102, nameof(SguAuthenticationNetworkFailure));
internal static readonly EventId ProfileEnrichmentCompleted = new(1200, nameof(ProfileEnrichmentCompleted));
internal static readonly EventId ProfileHtmlUnexpected = new(1201, nameof(ProfileHtmlUnexpected));
internal static readonly EventId ProfileEnrichmentTimeout = new(1202, nameof(ProfileEnrichmentTimeout));
internal static readonly EventId ProfileEnrichmentFailure = new(1203, nameof(ProfileEnrichmentFailure));
internal static readonly EventId ProfilePageUnavailable = new(1204, nameof(ProfilePageUnavailable));
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));
}
@@ -49,6 +49,7 @@ public sealed class BrokerOptions
Ntlm.AdministrativePersonalProfilePath,
Ntlm.AdministrativeLocationProfilePath,
Ntlm.StudentProfilePath,
Ntlm.ProfessorPayrollProfilePath,
Ntlm.MenuProfilePath
})
{
@@ -86,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) &&
@@ -141,6 +150,9 @@ public sealed class NtlmOptions
public string StudentProfilePath { get; init; } =
"/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx";
public string ProfessorPayrollProfilePath { get; init; } =
"/psulsa/gadmon/nomina/consultanomina.aspx";
public string MenuProfilePath { get; init; } = "/psulsa/menu.aspx";
public int MaxProfileBytes { get; init; } = 512 * 1024;
@@ -164,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";
@@ -177,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)
};
}
+68
View File
@@ -1,7 +1,9 @@
using System.Diagnostics;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using SGU.AuthBroker;
using SGU.AuthBroker.Contracts;
using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Directory;
@@ -10,6 +12,16 @@ using SGU.AuthBroker.Services;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Host.UseWindowsService(options => options.ServiceName = "SGU Authentication Broker");
if (builder.Configuration.GetValue("Broker:Diagnostics:UseDedicatedEventLog", false))
{
builder.Logging.ClearProviders();
builder.Logging.AddEventLog(settings =>
{
settings.LogName = "SGU Auth Broker";
settings.SourceName = "SGU.AuthBroker.Operational";
settings.Filter = (_, level) => level >= LogLevel.Information;
});
}
BrokerOptions brokerOptions = builder.Configuration
.GetSection(BrokerOptions.SectionName)
@@ -56,6 +68,12 @@ builder.Services.AddRateLimiter(options =>
});
WebApplication app = builder.Build();
ILogger auditLogger = app.Services.GetRequiredService<ILoggerFactory>()
.CreateLogger("SGU.AuthBroker.Audit");
auditLogger.LogInformation(
BrokerEventIds.BrokerStarted,
"SGU Authentication Broker started with dedicated operational diagnostics enabled={DedicatedDiagnosticsEnabled}.",
builder.Configuration.GetValue("Broker:Diagnostics:UseDedicatedEventLog", false));
app.UseRateLimiter();
app.Use(async (context, next) =>
{
@@ -75,16 +93,58 @@ app.MapPost("/v1/authenticate", async (
{
if (string.IsNullOrWhiteSpace(request.Password) || request.Password.Length > 256)
{
auditLogger.LogInformation(
BrokerEventIds.AuthenticationInvalidRequest,
"Authentication request rejected before validation for {InstitutionalUser}: password was missing or outside the supported length.",
SafeUserName(request.Clave));
request.ReleasePasswordReference();
return Results.BadRequest(new ErrorResponse("MISSING_PASSWORD", "La contraseña es requerida."));
}
Stopwatch elapsed = Stopwatch.StartNew();
try
{
AuthenticationFlowResult result = await workflow
.AuthenticateAsync(request.Clave, request.Password, cancellationToken)
.ConfigureAwait(false);
string institutionalUser = result.Identity?.UserName ?? SafeUserName(request.Clave);
switch (result.Outcome)
{
case AuthenticationFlowOutcome.Authorized:
auditLogger.LogInformation(
BrokerEventIds.AuthenticationAuthorized,
"Authentication completed for {InstitutionalUser} with role {Role} in {ElapsedMilliseconds} ms. AD created={Created}; moved={Moved}.",
institutionalUser,
result.Identity!.Role,
elapsed.ElapsedMilliseconds,
result.Directory!.Created,
result.Directory.Moved);
break;
case AuthenticationFlowOutcome.InvalidCredentials:
auditLogger.LogInformation(
BrokerEventIds.AuthenticationRejected,
"Authentication was rejected for {InstitutionalUser} with code {ErrorCode} after {ElapsedMilliseconds} ms.",
institutionalUser,
result.ErrorCode,
elapsed.ElapsedMilliseconds);
break;
case AuthenticationFlowOutcome.Unavailable:
auditLogger.LogWarning(
BrokerEventIds.AuthenticationUnavailable,
"Authentication was unavailable for {InstitutionalUser} with code {ErrorCode} after {ElapsedMilliseconds} ms.",
institutionalUser,
result.ErrorCode,
elapsed.ElapsedMilliseconds);
break;
default:
auditLogger.LogInformation(
BrokerEventIds.AuthenticationInvalidRequest,
"Authentication request had an invalid institutional user format after {ElapsedMilliseconds} ms.",
elapsed.ElapsedMilliseconds);
break;
}
return result.Outcome switch
{
AuthenticationFlowOutcome.Authorized => Results.Ok(new AuthenticationResponse(
@@ -125,3 +185,11 @@ static IResult Unavailable(HttpContext context, string? errorCode)
static string NormalizeThumbprint(string value) =>
value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant();
static string SafeUserName(string? value)
{
string candidate = value?.Trim().ToUpperInvariant() ?? string.Empty;
return candidate.Length is > 0 and <= 16 && candidate.All(char.IsAsciiLetterOrDigit)
? candidate
: "<invalid-format>";
}
@@ -7,8 +7,12 @@ using SGU.AuthBroker.Options;
namespace SGU.AuthBroker.Services;
public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActiveDirectorySynchronizer
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 =
@@ -26,12 +30,29 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
{
SemaphoreSlim gate = userLocks.GetOrAdd(identity.UserName, static _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
try
{
return await Task.Run(
() => Synchronize(identity, profile, password),
cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogError(
BrokerEventIds.DirectorySynchronizationFailure,
exception,
"Active Directory synchronization failed for {InstitutionalUser} with role {Role}.",
identity.UserName,
identity.Role);
throw;
}
}
finally
{
gate.Release();
@@ -90,6 +111,12 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
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]);
@@ -100,8 +127,8 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
user.Properties["pwdLastSet"].Value = -1;
user.CommitChanges();
TryApplyProfile(user, identity, profile, options.DefaultCompany);
TryEnsureRemoteDesktopGroupMembership(user);
TryApplyProfile(user, identity, profile, options.DefaultCompany, logger);
TryEnsureRemoteDesktopGroupMembership(user, identity.UserName);
return new DirectorySyncResult(
options.DomainNetbios,
@@ -120,7 +147,8 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
DirectoryEntry user,
UserIdentity identity,
InstitutionalProfile? profile,
string defaultCompany)
string defaultCompany,
ILogger logger)
{
try
{
@@ -138,6 +166,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
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);
@@ -146,10 +175,15 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
user.CommitChanges();
}
catch
catch (Exception exception)
{
// Metadata is intentionally best-effort. User creation, password sync,
// and account enablement have already committed successfully.
logger.LogWarning(
BrokerEventIds.DirectoryOptionalMetadataFailure,
exception,
"Optional Active Directory profile metadata could not be committed for {InstitutionalUser}; password synchronization remains completed.",
identity.UserName);
try
{
user.RefreshCache();
@@ -170,7 +204,84 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
}
}
private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user)
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))
{
@@ -194,10 +305,15 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
group.CommitChanges();
}
}
catch
catch (Exception exception)
{
// Remote access is lab policy and must not invalidate a completed
// password synchronization if the optional group is unavailable.
logger.LogWarning(
BrokerEventIds.DirectoryGroupMembershipFailure,
exception,
"Optional remote-desktop group membership could not be updated for {InstitutionalUser}; password synchronization remains completed.",
institutionalUser);
}
}
@@ -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;
@@ -35,6 +37,10 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
string password,
CancellationToken cancellationToken)
{
using IDisposable? logScope = logger.BeginScope(
"InstitutionalUser={InstitutionalUser}; InstitutionalRole={InstitutionalRole}",
identity.UserName,
identity.Role);
Uri authenticationUri = new(
new Uri(options.Endpoint, UriKind.Absolute),
options.AuthenticationPath);
@@ -155,6 +161,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
}
logger.LogInformation(
BrokerEventIds.SguAuthenticationAccepted,
"SGU accepted credentials after an explicit NTLM challenge in {ElapsedMilliseconds} ms.",
elapsed.ElapsedMilliseconds);
return (null, continuationUri);
@@ -184,6 +191,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
logger.LogWarning(
BrokerEventIds.SguAuthenticationTimeout,
"SGU NTLM authentication timed out after {ElapsedMilliseconds} ms.",
elapsed.ElapsedMilliseconds);
return (NtlmValidationResult.Unavailable("NTLM_TIMEOUT"), null);
@@ -191,6 +199,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
catch (HttpRequestException exception)
{
logger.LogWarning(
BrokerEventIds.SguAuthenticationNetworkFailure,
exception,
"SGU NTLM authentication failed after {ElapsedMilliseconds} ms.",
elapsed.ElapsedMilliseconds);
@@ -337,7 +346,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
profile = await TryEnrichStaffProfileAsync(
client,
profile!,
identity.Role,
identity,
allowedHosts,
timeout.Token,
cancellationToken,
@@ -347,6 +356,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
if (profile is null)
{
logger.LogWarning(
BrokerEventIds.ProfileHtmlUnexpected,
"SGU returned a profile page for role {Role}, but no supported profile fields were found after {ElapsedMilliseconds} ms.",
identity.Role,
elapsed.ElapsedMilliseconds);
@@ -354,8 +364,10 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
else
{
logger.LogInformation(
"SGU profile enrichment completed for role {Role} in {ElapsedMilliseconds} ms.",
BrokerEventIds.ProfileEnrichmentCompleted,
"SGU profile enrichment completed for role {Role} with {ProfileFieldCount} supported fields in {ElapsedMilliseconds} ms.",
identity.Role,
CountProfileFields(profile),
elapsed.ElapsedMilliseconds);
}
@@ -382,6 +394,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
catch (OperationCanceledException)
{
logger.LogWarning(
BrokerEventIds.ProfileEnrichmentTimeout,
"SGU profile request for role {Role} timed out after {ElapsedMilliseconds} ms.",
identity.Role,
elapsed.ElapsedMilliseconds);
@@ -389,6 +402,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
catch (Exception exception)
{
logger.LogWarning(
BrokerEventIds.ProfileEnrichmentFailure,
exception,
"SGU profile enrichment failed for role {Role} after {ElapsedMilliseconds} ms.",
identity.Role,
@@ -401,18 +415,24 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
private async Task<InstitutionalProfile> TryEnrichStaffProfileAsync(
HttpClient client,
InstitutionalProfile baseProfile,
InstitutionalRole role,
UserIdentity identity,
HashSet<string> allowedHosts,
CancellationToken timeoutToken,
CancellationToken requestCancellationToken,
Stopwatch elapsed)
{
InstitutionalProfile profile = baseProfile;
(string Path, Func<string, InstitutionalProfile?> Parser)[] pages =
[
(options.AdministrativePersonalProfilePath, SguProfileParser.ParseAdministrativePersonal),
(options.AdministrativeLocationProfilePath, SguProfileParser.ParseAdministrativeLocation)
];
List<(string Path, Func<string, InstitutionalProfile?> Parser)> pages = [];
if (identity.Role == InstitutionalRole.Professor)
{
pages.Add((
options.ProfessorPayrollProfilePath,
html => SguProfileParser.ParseProfessorPayroll(html, identity.NumericId)));
}
pages.Add((
options.AdministrativePersonalProfilePath,
SguProfileParser.ParseAdministrativePersonal));
foreach ((string path, Func<string, InstitutionalProfile?> parser) in pages)
{
@@ -423,26 +443,185 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
GetProfileUri(path),
allowedHosts,
timeoutToken).ConfigureAwait(false);
profile = profile.Overlay(html is null ? null : parser(html));
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);
continue;
}
InstitutionalProfile? pageProfile = parser(html);
if (pageProfile 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);
continue;
}
profile = profile.Overlay(pageProfile);
}
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
{
logger.LogWarning(
BrokerEventIds.ProfileEnrichmentTimeout,
"SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
role,
identity.Role,
elapsed.ElapsedMilliseconds);
break;
return profile;
}
catch (Exception exception)
{
logger.LogWarning(
BrokerEventIds.ProfileEnrichmentFailure,
exception,
"An optional SGU staff profile page for role {Role} failed after {ElapsedMilliseconds} ms; preserving fields already collected.",
role,
identity.Role,
elapsed.ElapsedMilliseconds);
}
}
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;
}
@@ -487,6 +666,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
}
logger.LogWarning(
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile page {Path} returned HTTP {StatusCode}.",
requestedUri.AbsolutePath,
statusCode);
@@ -494,11 +674,52 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
}
logger.LogWarning(
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile page {Path} exceeded the redirect limit.",
requestedUri.AbsolutePath);
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,
@@ -550,6 +771,23 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
right.AbsolutePath.TrimEnd('/'),
StringComparison.OrdinalIgnoreCase);
private static int CountProfileFields(InstitutionalProfile profile) =>
new[]
{
profile.EmployeeNumber,
profile.DisplayName,
profile.GivenName,
profile.Surname,
profile.Email,
profile.EmployeeType,
profile.JobTitle,
profile.Department,
profile.StreetAddress,
profile.City,
profile.State,
profile.PostalCode
}.Count(value => !string.IsNullOrWhiteSpace(value));
private static async Task DrainResponseAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
@@ -595,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,
@@ -604,17 +848,31 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
response.Content,
options.MaxProfileBytes,
timeoutToken).ConfigureAwait(false);
return identity.Role switch
InstitutionalProfile? profile;
switch (identity.Role)
{
InstitutionalRole.Administrative =>
SguProfileParser.ParseAdministrative(html, identity.NumericId) ??
SguProfileParser.ParseMenu(html),
InstitutionalRole.Student =>
SguProfileParser.ParseStudent(html, identity.NumericId) ??
SguProfileParser.ParseMenu(html),
InstitutionalRole.Professor => SguProfileParser.ParseMenu(html),
_ => null
};
case InstitutionalRole.Administrative:
profile = SguProfileParser.ParseAdministrative(html, identity.NumericId);
break;
case InstitutionalRole.Student:
profile = SguProfileParser.ParseStudent(html, identity.NumericId);
break;
case InstitutionalRole.Professor:
return SguProfileParser.ParseMenu(html);
default:
return null;
}
if (profile is not null)
{
return profile;
}
logger.LogWarning(
BrokerEventIds.ProfileHtmlUnexpected,
"The primary SGU profile HTML did not contain the supported field IDs for role {Role}; attempting the menu-name fallback.",
identity.Role);
return SguProfileParser.ParseMenu(html);
}
private static async Task<string> ReadLimitedStringAsync(
+4
View File
@@ -35,6 +35,7 @@
"AdministrativePersonalProfilePath": "/psulsa/gadmon/capitalhumano/datos/personales.aspx",
"AdministrativeLocationProfilePath": "/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
"StudentProfilePath": "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
"ProfessorPayrollProfilePath": "/psulsa/gadmon/nomina/consultanomina.aspx",
"MenuProfilePath": "/psulsa/menu.aspx",
"MaxProfileBytes": 524288,
"AllowedRedirectHosts": [
@@ -49,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
+14 -17
View File
@@ -1,35 +1,32 @@
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Drawing.Drawing2D;
namespace SGU.CredentialProvider;
internal static class ProviderTileIcon
{
public const int Size = 72;
// LogonUI enlarges the dedicated-tile artwork. Supply a dense source image
// so the mascot remains crisp at the large sign-in surface.
public const int Size = 256;
private const string MascotResourceName = "SGU.CredentialProvider.Branding.LaSalleMascot.png";
public static Bitmap Create()
{
Bitmap bitmap = new(Size, Size, PixelFormat.Format32bppArgb);
using Graphics graphics = Graphics.FromImage(bitmap);
graphics.SmoothingMode = SmoothingMode.AntiAlias;
graphics.CompositingQuality = CompositingQuality.HighQuality;
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
graphics.Clear(Color.Transparent);
using SolidBrush background = new(Color.FromArgb(0, 83, 155));
graphics.FillEllipse(background, 1, 1, Size - 2, Size - 2);
using Pen key = new(Color.White, 5.5f)
{
StartCap = LineCap.Round,
EndCap = LineCap.Round,
LineJoin = LineJoin.Round
};
graphics.DrawEllipse(key, 14, 14, 25, 25);
graphics.DrawLine(key, 35, 35, 57, 57);
graphics.DrawLine(key, 47, 47, 55, 39);
graphics.DrawLine(key, 53, 53, 61, 45);
using Stream sourceStream = typeof(ProviderTileIcon).Assembly.GetManifestResourceStream(MascotResourceName)
?? throw new InvalidOperationException($"The branded Credential Provider logo '{MascotResourceName}' is unavailable.");
using Bitmap mascot = new(sourceStream);
using GraphicsPath circularMask = new();
circularMask.AddEllipse(0, 0, Size, Size);
graphics.SetClip(circularMask);
graphics.DrawImage(mascot, new Rectangle(0, 0, Size, Size));
return bitmap;
}
@@ -23,6 +23,11 @@
<ProjectReference Include="..\SGU.AuthBroker.Core\SGU.AuthBroker.Core.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="..\..\assets\branding\lasalle-mexico-provider.png"
LogicalName="SGU.CredentialProvider.Branding.LaSalleMascot.png" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>SGU.CredentialProvider.Tests</_Parameter1>
+419
View File
@@ -0,0 +1,419 @@
$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'
$domainEnrollmentPath = Join-Path $repositoryRoot 'scripts\Enroll-SguDomainClient.ps1'
$repairEnrollmentPath = Join-Path $repositoryRoot 'scripts\Repair-SguClientEnrollment.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',
'ConvertTo-PublicNetworkCidr',
'Get-ActiveIPv4Adapters',
'Resolve-PrivateInterfaceAlias'
)
$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 -in @('Test-IPv4AddressesSharePrefix', 'Resolve-ClientInterfaceAlias',
'Set-ClientServerRoute', 'Set-ClientDomainDns', 'Test-TcpPort', 'Assert-ClientOperatingSystem',
'Wait-ClientInterface', 'Test-PrivateIPv4Address', 'Test-ClientDomainDns')
}, $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 'canonicalizes an explicitly authorized public enrollment network' {
ConvertTo-PublicNetworkCidr -Cidr '200.13.89.183/24' |
Should Be '200.13.89.0/24'
}
It 'rejects private space in the public enrollment allowlist' {
$wasRejected = $false
try {
ConvertTo-PublicNetworkCidr -Cidr '10.77.0.0/16' | 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 $serverBootstrapPath).Parameters.Keys -contains
'PublicEnrollmentNetworks') | 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 'waits for the new address and WinRM route to stabilize' {
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
$source | Should Match "AddressState -eq 'Preferred'"
$source | Should Match 'function Wait-TcpPort'
$source | Should Match 'Wait-TcpPort -Address \$DomainControllerIPv4Address -Port 5985'
}
It 'uses the unified implementation without OS-specific network restrictions' {
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
$source | Should Not Match 'package cannot enroll|belongs to the Windows 11'
$source | Should Not Match 'Read-Host "Fixed IPv4 address for this SGU client'
}
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 'keeps public enrollment closed unless explicit source CIDRs are supplied' {
$template = Get-Content -LiteralPath $bicepPath -Raw
$template | Should Match "name: 'Allow-RDP-from-administrator'"
$template | Should Match "destinationPortRange: '3389'"
$template | Should Match 'param publicEnrollmentSourceAddressPrefixes array = \[\]'
$template | Should Match "name: 'Allow-Direct-AD-TCP'"
$template | Should Match 'sourceAddressPrefixes: publicEnrollmentSourceAddressPrefixes'
$template | Should Match 'param deployVpnGateway bool = true'
$template | Should Not Match "sourceAddressPrefix: '0\.0\.0\.0/0'"
}
}
Describe 'SGU direct public enrollment discovery' {
It 'distinguishes public server addresses from LAN and VPN addresses' {
Test-PrivateIPv4Address -Address ([ipaddress]'10.77.0.4') | Should Be $true
Test-PrivateIPv4Address -Address ([ipaddress]'172.30.0.4') | Should Be $true
Test-PrivateIPv4Address -Address ([ipaddress]'192.168.50.10') | Should Be $true
Test-PrivateIPv4Address -Address ([ipaddress]'20.9.81.130') | Should Be $false
}
It 'bootstraps DoH and host mappings after authenticated server discovery' {
$source = Get-Content -LiteralPath $clientBootstrapPath -Raw
$source | Should Match 'Set-DnsServerEncryptionProtocol'
$source | Should Match 'Enable-ClientDnsOverHttps'
$source | Should Match 'Set-ClientHostMappings'
$source | Should Match 'Test-ClientDomainDns'
$source | Should Match 'Get-DnsClientDohServerAddress'
$source | Should Match 'Add-DnsClientDohServerAddress'
}
}
Describe 'Azure accelerated server adapters' {
It 'ignores an Up accelerated VF that has no IPv4 interface' {
Mock Get-NetAdapter {
[pscustomobject]@{ Name = 'Ethernet'; ifIndex = 4; Status = 'Up' }
[pscustomobject]@{ Name = 'Ethernet VF'; ifIndex = 10; Status = 'Up' }
[pscustomobject]@{ Name = 'Disconnected'; ifIndex = 12; Status = 'Disconnected' }
}
Mock Get-NetIPInterface {
if ($InterfaceIndex -eq 4) {
[pscustomobject]@{ InterfaceIndex = 4; ConnectionState = 'Connected' }
}
}
Mock Get-NetIPConfiguration { [pscustomobject]@{ IPv4DefaultGateway = '10.77.0.1' } }
$adapters = @(Get-ActiveIPv4Adapters)
$adapters.Count | Should Be 1
$adapters[0].Name | Should Be 'Ethernet'
Resolve-PrivateInterfaceAlias | Should Be 'Ethernet'
}
}
Describe 'SGU route and interface discovery' {
BeforeEach {
Mock Get-NetIPInterface {
[pscustomobject]@{ InterfaceIndex = 4; InterfaceAlias = 'Internet'; ConnectionState = 'Connected'; InterfaceMetric = 5 }
[pscustomobject]@{ InterfaceIndex = 8; InterfaceAlias = 'AD VPN'; ConnectionState = 'Connected'; InterfaceMetric = 30 }
}
Mock Get-NetIPAddress {
if ($InterfaceIndex -eq 4) {
[pscustomobject]@{ IPAddress = '192.168.1.2'; AddressState = 'Preferred'; SkipAsSource = $false }
} else {
[pscustomobject]@{ IPAddress = '172.30.0.2'; AddressState = 'Preferred'; SkipAsSource = $false }
}
}
Mock Get-NetRoute {
if ($InterfaceIndex -eq 4) {
[pscustomobject]@{ DestinationPrefix = '0.0.0.0/0'; NextHop = '192.168.1.1'; RouteMetric = 0 }
} else {
[pscustomobject]@{ DestinationPrefix = '10.77.0.0/16'; NextHop = '0.0.0.0'; RouteMetric = 10 }
}
}
Mock Find-NetRoute { [pscustomobject]@{ IPAddress = '192.168.1.2'; InterfaceIndex = 4 } }
Mock Test-TcpPort { $InterfaceIndex -eq 8 }
}
It 'tries another interface when the Internet route cannot reach WinRM' {
$result = Resolve-ClientInterfaceAlias -DomainControllerAddress '10.77.0.4'
$result.InterfaceAlias | Should Be 'AD VPN'
$result.IPAddress | Should Be '172.30.0.2'
Assert-MockCalled Test-TcpPort -Scope It -Times 1 -Exactly -ParameterFilter { $InterfaceIndex -eq 4 }
Assert-MockCalled Test-TcpPort -Scope It -Times 1 -Exactly -ParameterFilter {
$InterfaceIndex -eq 8 -and $SourceAddress -eq [ipaddress]'172.30.0.2'
}
}
It 'uses a functioning Windows route first even with multiple interfaces' {
Mock Test-TcpPort { $true }
(Resolve-ClientInterfaceAlias -DomainControllerAddress '10.77.0.4').InterfaceAlias | Should Be 'Internet'
Assert-MockCalled Test-TcpPort -Scope It -Times 0 -Exactly -ParameterFilter { $InterfaceIndex -eq 8 }
}
It 'honors an explicit interface and never falls back to another' {
Mock Test-TcpPort { $false }
$rejected = $false
try { Resolve-ClientInterfaceAlias -RequestedAlias 'AD VPN' -DomainControllerAddress '10.77.0.4' | Out-Null } catch { $rejected = $true }
$rejected | Should Be $true
Assert-MockCalled Test-TcpPort -Scope It -Times 0 -Exactly -ParameterFilter { $InterfaceIndex -eq 4 }
}
It 'does not require a client to share the server subnet' {
(Resolve-ClientInterfaceAlias -RequestedAlias 'AD VPN' -DomainControllerAddress '10.77.0.4').IPAddress |
Should Be '172.30.0.2'
}
It 'accepts a normal default route when it is the only way to reach AD' {
Mock Get-NetRoute { [pscustomobject]@{ DestinationPrefix = '0.0.0.0/0'; NextHop = '172.30.0.1'; RouteMetric = 0 } }
(Resolve-ClientInterfaceAlias -RequestedAlias 'AD VPN' -DomainControllerAddress '10.77.0.4').NextHop |
Should Be '172.30.0.1'
}
It 'does not probe disconnected or APIPA-only interfaces' {
Mock Get-NetIPAddress { [pscustomobject]@{ IPAddress = '169.254.1.2'; AddressState = 'Preferred' } }
$rejected = $false
try { Resolve-ClientInterfaceAlias -DomainControllerAddress '10.77.0.4' | Out-Null } catch { $rejected = $true }
$rejected | Should Be $true
Assert-MockCalled Test-TcpPort -Scope It -Times 0 -Exactly
}
It 'does not select an adapter without a matching route' {
Mock Get-NetRoute { [pscustomobject]@{ DestinationPrefix = '192.168.60.0/24'; NextHop = '0.0.0.0'; RouteMetric = 0 } }
$rejected = $false
try { Resolve-ClientInterfaceAlias -DomainControllerAddress '10.77.0.4' | Out-Null } catch { $rejected = $true }
$rejected | Should Be $true
Assert-MockCalled Test-TcpPort -Scope It -Times 0 -Exactly
}
It 'selects the longest matching prefix on an interface' {
Mock Get-NetRoute {
[pscustomobject]@{ DestinationPrefix = '0.0.0.0/0'; NextHop = '172.30.0.1'; RouteMetric = 0 }
[pscustomobject]@{ DestinationPrefix = '10.77.0.4/32'; NextHop = '172.30.0.3'; RouteMetric = 100 }
}
(Resolve-ClientInterfaceAlias -RequestedAlias 'AD VPN' -DomainControllerAddress '10.77.0.4').NextHop |
Should Be '172.30.0.3'
}
It 'does not change a working system route' {
Mock New-NetRoute { throw 'Unexpected route mutation' }
Set-ClientServerRoute -SelectedInterface ([pscustomobject]@{ InterfaceIndex = 4 }) -DomainControllerAddress '10.77.0.4'
Assert-MockCalled New-NetRoute -Scope It -Times 0 -Exactly
}
It 'pins only the server when the working adapter differs from the system route' {
$script:routeAdded = $false
Mock Find-NetRoute {
[pscustomobject]@{ IPAddress = '172.30.0.2'; InterfaceIndex = $(if ($script:routeAdded) { 8 } else { 4 }) }
}
Mock New-NetRoute { $script:routeAdded = $true }
Set-ClientServerRoute -SelectedInterface ([pscustomobject]@{ InterfaceIndex = 8; NextHop = '172.30.0.1' }) `
-DomainControllerAddress '10.77.0.4'
Assert-MockCalled New-NetRoute -Scope It -Times 1 -Exactly -ParameterFilter {
$DestinationPrefix -eq '10.77.0.4/32' -and $InterfaceIndex -eq 8 -and $NextHop -eq '172.30.0.1'
}
}
It 'removes its new route and reports a conflicting system route' {
Mock New-NetRoute { [pscustomobject]@{ DestinationPrefix = '10.77.0.4/32'; InterfaceIndex = 8 } }
Mock Remove-NetRoute { }
$rejected = $false
try {
Set-ClientServerRoute -SelectedInterface ([pscustomobject]@{ InterfaceIndex = 8; NextHop = '172.30.0.1' }) `
-DomainControllerAddress '10.77.0.4'
} catch { $rejected = $true }
$rejected | Should Be $true
Assert-MockCalled Remove-NetRoute -Scope It -Times 1 -Exactly
}
}
Describe 'SGU split DNS' {
It 'scopes DNS to the discovered domain and leaves adapter DNS untouched' {
Mock Get-DnsClientNrptRule { }
Mock Remove-DnsClientNrptRule { }
Mock Add-DnsClientNrptRule { }
Mock Clear-DnsClientCache { }
Mock Set-DnsClientServerAddress { throw 'Unexpected adapter DNS change' }
Set-ClientDomainDns -DnsDomain 'example.test' -ServerAddress '10.77.0.4'
Assert-MockCalled Add-DnsClientNrptRule -Scope It -Times 1 -Exactly -ParameterFilter {
$Namespace -contains '.example.test' -and $Namespace -contains 'example.test' -and
$NameServers -eq '10.77.0.4'
}
Assert-MockCalled Set-DnsClientServerAddress -Scope It -Times 0 -Exactly
}
It 'reuses the managed DNS rule on a repeated enrollment' {
Mock Get-DnsClientNrptRule {
[pscustomobject]@{ DisplayName = 'SGU domain DNS - example.test';
NameServers = @('10.77.0.4'); Namespace = @('example.test', '.example.test') }
}
Mock Add-DnsClientNrptRule { throw 'Unexpected DNS rule duplication' }
Set-ClientDomainDns -DnsDomain 'example.test' -ServerAddress '10.77.0.4'
Assert-MockCalled Add-DnsClientNrptRule -Scope It -Times 0 -Exactly
}
}
Describe 'SGU Windows capability checks' {
It 'accepts the same enrollment on Windows 10 LTSC, Windows 10 and Windows 11' {
foreach ($build in @(14393, 17763, 19044, 19045, 22000, 22631, 26100)) {
Assert-ClientOperatingSystem -OperatingSystem ([pscustomobject]@{ ProductType = 1; BuildNumber = $build }) `
-Edition Enterprise -Architecture AMD64
}
}
It 'rejects Home, Server, pre-Windows 10 and incompatible architectures' {
foreach ($sample in @(
@{ ProductType = 1; BuildNumber = 26100; Edition = 'Core'; Architecture = 'AMD64' },
@{ ProductType = 3; BuildNumber = 26100; Edition = 'ServerStandard'; Architecture = 'AMD64' },
@{ ProductType = 1; BuildNumber = 9600; Edition = 'Professional'; Architecture = 'AMD64' },
@{ ProductType = 1; BuildNumber = 26100; Edition = 'Professional'; Architecture = 'ARM64' },
@{ ProductType = 1; BuildNumber = 19045; Edition = 'Professional'; Architecture = 'x86' }
)) {
$rejected = $false
try {
Assert-ClientOperatingSystem -OperatingSystem ([pscustomobject]$sample) `
-Edition $sample.Edition -Architecture $sample.Architecture
} catch { $rejected = $true }
$rejected | Should Be $true
}
}
}
Describe 'SGU repeated domain enrollment' {
It 'rejoins a same-name forest when the machine secure channel is broken' {
$source = Get-Content -LiteralPath $domainEnrollmentPath -Raw
$source | Should Match 'Test-ComputerSecureChannel'
$source | Should Match '\$computer\.PartOfDomain -and \$domainMembershipHealthy'
$source | Should Match 'Reset-ComputerMachinePassword'
$source | Should Match 'DomainControllerDnsName'
$source | Should Match 'Add-Computer @joinParams'
}
It 'defers domain-only repair until the secure channel is healthy' {
$source = Get-Content -LiteralPath $repairEnrollmentPath -Raw
$source | Should Match 'Test-ComputerSecureChannel'
$source | Should Match 'if \(\$domainReady\)'
}
}
Describe 'SGU real TCP probe' {
It 'connects with a bound source and interface without relying on ICMP' {
$listener = [Net.Sockets.TcpListener]::new([ipaddress]'127.0.0.1', 0)
try {
$listener.Start()
$loopback = Get-NetIPAddress -IPAddress '127.0.0.1' -AddressFamily IPv4 | Select-Object -First 1
Test-TcpPort -Address '127.0.0.1' -Port $listener.LocalEndpoint.Port `
-SourceAddress '127.0.0.1' -InterfaceIndex $loopback.InterfaceIndex | Should Be $true
} finally { $listener.Stop() }
}
It 'returns false when the TCP service is closed' {
$listener = [Net.Sockets.TcpListener]::new([ipaddress]'127.0.0.1', 0)
$listener.Start()
$port = $listener.LocalEndpoint.Port
$listener.Stop()
Test-TcpPort -Address '127.0.0.1' -Port $port -TimeoutMilliseconds 200 | Should Be $false
}
}
Describe 'SGU network readiness retries' {
It 'retries discovery while DHCP or VPN routes are initializing' {
$script:discoveryAttempts = 0
Mock Start-Sleep { }
Mock Resolve-ClientInterfaceAlias {
$script:discoveryAttempts++
if ($script:discoveryAttempts -eq 1) { throw 'Address still tentative' }
[pscustomobject]@{ InterfaceAlias = 'AD VPN'; IPAddress = '172.30.0.2' }
}
(Wait-ClientInterface -DomainControllerAddress '10.77.0.4').InterfaceAlias | Should Be 'AD VPN'
Assert-MockCalled Resolve-ClientInterfaceAlias -Scope It -Times 2 -Exactly
}
It 'reports the last network diagnostic when the timeout expires' {
Mock Resolve-ClientInterfaceAlias { throw 'No route to the server' }
$message = ''
try { Wait-ClientInterface -DomainControllerAddress '10.77.0.4' -TimeoutSeconds 0 }
catch { $message = $_.Exception.Message }
$message | Should Match 'No route to the server'
}
}
+75
View File
@@ -0,0 +1,75 @@
$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 'preserves existing local credentials when enrollment is repeated under domain password policies' {
$updates = $scriptAst.FindAll({
param($node)
$node -is [Management.Automation.Language.CommandAst] -and $node.GetCommandName() -eq 'Set-LocalUser'
}, $true)
$updates.Count | Should Be 1
@($updates[0].CommandElements | Where-Object {
$_ -is [Management.Automation.Language.CommandParameterAst] -and $_.ParameterName -eq 'Password'
}).Count | Should Be 0
}
It 'publishes one Windows artifact with automatic compatibility' {
$packageSource = Get-Content -LiteralPath $packageScriptPath -Raw
$releaseSource = Get-Content -LiteralPath $releaseScriptPath -Raw
$packageSource | Should Match 'sgu-windows-client-bootstrap-\$Version'
$packageSource | Should Match '-CompatibilityProfile Auto'
$packageSource | Should Not Match 'sgu-windows10-legacy|sgu-windows11-client'
$releaseSource | Should Match 'sgu-windows-client-bootstrap-\$Version\.zip'
$releaseSource | Should Not Match 'sgu-windows10-legacy|sgu-windows11-client'
}
It 'includes Azure P2S in the shared Windows artifact' {
$packageSource = Get-Content -LiteralPath $packageScriptPath -Raw
$azureLauncher = Get-Content -LiteralPath $azureLauncherPath -Raw
$packageSource.Contains("Join-Path `$clientRoot 'Start-SguAzureClientEnrollment.cmd'") |
Should Be $true
$packageSource.Contains("Join-Path `$clientRoot 'Install-SguAzureP2sClient.ps1'") |
Should Be $true
$azureLauncher | Should Match '-PauseOnError'
}
}
@@ -56,6 +56,58 @@ public sealed class SguProfileParserTests
Assert.Null(SguProfileParser.ParseAdministrative(html, "999999"));
}
[Fact]
public void ParsesOnlyTheSupportedProfessorPayrollHeaderFields()
{
const string html = """
<div id="ctl00_contenedor_decEncabezado_pnlSinPoP">
<span id="ctl00_contenedor_decEncabezado_lblNombre">
013473 - ALEJANDRO LARA VILLARREAL
</span>
<span id="ctl00_contenedor_decEncabezado_lblIndicadorValue">
SINDICALIZADO QUINCENAL (ACTIVO)
</span>
<span id="ctl00_contenedor_decEncabezado_lblCorreo">
alejandro.lara@lasallistas.org.mx
</span>
<img id="ctl00_contenedor_decEncabezado_imgFoto"
src="../admonPersonal/ashx/Fotografia.ashx?id=013473&amp;tp=2" />
<span id="ctl00_contenedor_decEncabezado_lblPuesto">DOCENTE</span>
<span id="ctl00_contenedor_decEncabezado_lblDependencia"></span>
<span id="ctl00_contenedor_decEncabezado_lblJefeNombre">NO EXTRAER</span>
<span id="ctl00_contenedor_decEncabezado_lblJefePuesto">NO EXTRAER</span>
</div>
""";
InstitutionalProfile? profile = SguProfileParser.ParseProfessorPayroll(html, "013473");
Assert.NotNull(profile);
Assert.Equal("013473", profile.EmployeeNumber);
Assert.Equal("Alejandro Lara Villarreal", profile.DisplayName);
Assert.Equal("alejandro.lara@lasallistas.org.mx", profile.Email);
Assert.Equal("Sindicalizado quincenal (activo)", profile.EmployeeType);
Assert.Equal("Docente", profile.JobTitle);
Assert.Null(profile.Department);
Assert.Null(profile.GivenName);
Assert.Null(profile.Surname);
Assert.Null(profile.StreetAddress);
}
[Fact]
public void RejectsProfessorPayrollMetadataForADifferentEmployeeNumber()
{
const string html = """
<span id="ctl00_contenedor_decEncabezado_lblNombre">
013473 - PERSONA INCORRECTA
</span>
<span id="ctl00_contenedor_decEncabezado_lblCorreo">
incorrecta@lasallistas.org.mx
</span>
""";
Assert.Null(SguProfileParser.ParseProfessorPayroll(html, "123456"));
}
[Fact]
public void ParsesStructuredAdministrativeNameWithoutReadingOtherPersonalData()
{
@@ -81,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()
{
@@ -118,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()
{
@@ -130,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",
@@ -148,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]
@@ -163,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>
@@ -210,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]
@@ -209,12 +233,25 @@ public sealed class NtlmCredentialValidatorTests
"""
<span id="ctl00_lblNombreUsuario">MARÍA DEL CARMEN</span>
"""),
Response(
HttpStatusCode.OK,
"""
<span id="ctl00_contenedor_decEncabezado_lblNombre">123456 - MARÍA DEL CARMEN</span>
<span id="ctl00_contenedor_decEncabezado_lblCorreo">docente@lasallistas.org.mx</span>
<span id="ctl00_contenedor_decEncabezado_lblIndicadorValue">SINDICALIZADO QUINCENAL (ACTIVO)</span>
<span id="ctl00_contenedor_decEncabezado_lblPuesto">DOCENTE</span>
<span id="ctl00_contenedor_decEncabezado_lblDependencia"></span>
"""),
Response(
HttpStatusCode.OK,
"""
<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,
@@ -223,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);
@@ -244,17 +291,27 @@ public sealed class NtlmCredentialValidatorTests
Assert.Equal("María del Carmen de la Fuente O'Connor", result.Profile.DisplayName);
Assert.Equal("María del Carmen", result.Profile.GivenName);
Assert.Equal("de la Fuente O'Connor", result.Profile.Surname);
Assert.Equal("123456", result.Profile.EmployeeNumber);
Assert.Equal("docente@lasallistas.org.mx", result.Profile.Email);
Assert.Equal("Sindicalizado quincenal (activo)", result.Profile.EmployeeType);
Assert.Equal("Docente", result.Profile.JobTitle);
Assert.Null(result.Profile.Department);
Assert.Equal("Calle del Sol 15\r\nFlorida", result.Profile.StreetAddress);
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/",
"/psulsa/",
"/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);
}
@@ -270,6 +327,7 @@ public sealed class NtlmCredentialValidatorTests
"""
<span id="ctl00_lblNombreUsuario">MIGUEL DE CERVANTES</span>
"""),
Response(HttpStatusCode.NotFound),
Response(HttpStatusCode.OK, "<html><body>Unrecognized layout</body></html>"),
Response(HttpStatusCode.NotFound));
NtlmCredentialValidator validator = CreateValidator(handler);
@@ -283,7 +341,10 @@ public sealed class NtlmCredentialValidatorTests
Assert.NotNull(result.Profile);
Assert.Equal("Miguel de Cervantes", result.Profile.DisplayName);
Assert.Null(result.Profile.StreetAddress);
Assert.Equal(5, handler.RequestPaths.Count);
Assert.Equal(6, handler.RequestPaths.Count);
Assert.Equal(
"/psulsa/gadmon/nomina/consultanomina.aspx",
handler.RequestPaths[3]);
}
private static NtlmCredentialValidator CreateValidator(SequenceHandler handler)
@@ -298,6 +359,7 @@ public sealed class NtlmCredentialValidatorTests
AdministrativePersonalProfilePath = "/psulsa/gadmon/capitalhumano/datos/personales.aspx",
AdministrativeLocationProfilePath = "/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
StudentProfilePath = "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
ProfessorPayrollProfilePath = "/psulsa/gadmon/nomina/consultanomina.aspx",
AllowedRedirectHosts = ["sgu.example"],
TimeoutSeconds = 5,
ProfileTimeoutSeconds = 5
@@ -332,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.");
@@ -7,7 +7,7 @@ namespace SGU.CredentialProvider.Tests;
public sealed class ProviderTileIconTests
{
[Fact]
public void ProviderPublishesASeventyTwoPixelLogoForSignInOptions()
public void ProviderPublishesAHighResolutionLogoForSignInOptions()
{
SguCredentialProvider provider = new();
@@ -20,22 +20,27 @@ public sealed class ProviderTileIconTests
Assert.Equal(0, logo.Bitmap.GetPixel(ProviderTileIcon.Size - 1, 0).A);
Assert.Equal(0, logo.Bitmap.GetPixel(0, ProviderTileIcon.Size - 1).A);
Assert.Equal(0, logo.Bitmap.GetPixel(ProviderTileIcon.Size - 1, ProviderTileIcon.Size - 1).A);
Assert.Equal(
Color.FromArgb(0, 83, 155).ToArgb(),
logo.Bitmap.GetPixel(6, ProviderTileIcon.Size / 2).ToArgb());
int lightPixels = 0;
int redPixels = 0;
int navyPixels = 0;
for (int x = 0; x < logo.Bitmap.Width; x++)
{
for (int y = 0; y < logo.Bitmap.Height; y++)
{
if (logo.Bitmap.GetPixel(x, y).GetBrightness() > 0.7f)
Color pixel = logo.Bitmap.GetPixel(x, y);
if (pixel.R > 160 && pixel.G < 100 && pixel.B < 100)
{
lightPixels++;
redPixels++;
}
if (pixel.B > pixel.R && pixel.B > pixel.G && pixel.R < 70)
{
navyPixels++;
}
}
}
Assert.InRange(lightPixels, 200, 2_000);
Assert.InRange(redPixels, 1_000, 30_000);
Assert.InRange(navyPixels, 1_000, 50_000);
}
[Fact]
+72
View File
@@ -0,0 +1,72 @@
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$wallpaperScript = Join-Path $repositoryRoot 'scripts\Set-SguWelcomeWallpaper.ps1'
$source = Get-Content -LiteralPath $wallpaperScript -Raw
$tokens = $null
$parseErrors = $null
$ast = [Management.Automation.Language.Parser]::ParseFile($wallpaperScript, [ref]$tokens, [ref]$parseErrors)
if ($parseErrors.Count) { throw ($parseErrors -join [Environment]::NewLine) }
$lookup = $ast.Find({
param($node)
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and
$node.Name -eq 'Get-DirectoryWelcomeMetadata'
}, $true)
function Invoke-WelcomeFixture {
param($DirectoryGender, [string]$ExplicitGender)
# Replace only the external directory lookup. Execute the actual script,
# including its validated parameters, metadata assignment and JPEG renderer.
$fixtureJson = [pscustomobject]@{
DisplayName = 'Usuario de prueba'
Gender = $DirectoryGender
Location = 'Sala de pruebas'
OrganizationalUnit = 'Laboratorio'
} | ConvertTo-Json -Compress
$fixtureFunction = 'function Get-DirectoryWelcomeMetadata { param($UserName, $MachineName); ConvertFrom-Json ''' +
$fixtureJson.Replace("'", "''") + ''' }'
$fixtureSource = $source.Remove($lookup.Extent.StartOffset, $lookup.Extent.EndOffset - $lookup.Extent.StartOffset).
Insert($lookup.Extent.StartOffset, $fixtureFunction)
$testScript = Join-Path $TestDrive ('wallpaper-' + [Guid]::NewGuid().ToString('N') + '.ps1')
[IO.File]::WriteAllText($testScript, $fixtureSource, [Text.UTF8Encoding]::new($false))
$parameters = @{
BaseImagePath = Join-Path $repositoryRoot 'assets\branding\darkblue.jpg'
FontsPath = Join-Path $repositoryRoot 'assets\branding\fonts'
OutputPath = Join-Path $TestDrive ([IO.Path]::GetFileNameWithoutExtension($testScript) + '.jpg')
CanvasWidth = 640
CanvasHeight = 480
SkipApply = $true
}
if ($ExplicitGender) { $parameters.Gender = $ExplicitGender }
$previousLocalAppData = $env:LOCALAPPDATA
try {
$env:LOCALAPPDATA = $TestDrive
& $testScript @parameters
}
finally { $env:LOCALAPPDATA = $previousLocalAppData }
}
Describe 'Welcome wallpaper with AD metadata' {
It 'renders a neutral JPEG when AD has no gender' {
$result = Invoke-WelcomeFixture -DirectoryGender $null
$result.WelcomeHeading | Should Be 'Te damos la bienvenida,'
$result.Applied | Should Be $false
$bitmap = [Drawing.Image]::FromFile($result.OutputPath)
try { $bitmap.Width | Should Be 640; $bitmap.Height | Should Be 480 }
finally { $bitmap.Dispose() }
}
It 'uses neutral wording for empty or unrecognized metadata' {
foreach ($value in @('', 'Unknown')) {
(Invoke-WelcomeFixture -DirectoryGender $value).WelcomeHeading | Should Be 'Te damos la bienvenida,'
}
}
It 'keeps the gendered greetings for recognized directory values' {
(Invoke-WelcomeFixture -DirectoryGender 'Female').WelcomeHeading | Should Be 'Bienvenida,'
(Invoke-WelcomeFixture -DirectoryGender 'Male').WelcomeHeading | Should Be 'Bienvenido,'
}
It 'honors an explicit gender over directory metadata' {
(Invoke-WelcomeFixture -DirectoryGender 'Female' -ExplicitGender 'Male').WelcomeHeading | Should Be 'Bienvenido,'
}
}