Compare commits

..
12 Commits
51 changed files with 3231 additions and 68 deletions
+3
View File
@@ -348,3 +348,6 @@ MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Ejemplos de Usuarios
tmp/
+4 -1
View File
@@ -51,6 +51,7 @@ Operational documentation:
- [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)
@@ -111,7 +112,9 @@ 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
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+28 -10
View File
@@ -23,13 +23,15 @@ 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 from `datos/personales.aspx` and the
address from `datos/ubicacion.aspx`. 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
@@ -62,13 +64,22 @@ updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`,
`postalCode` attributes. 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`. This 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`
@@ -105,6 +116,13 @@ 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
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
+15 -3
View File
@@ -55,6 +55,8 @@ El proceso crea o configura de forma idempotente:
- 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
@@ -66,9 +68,10 @@ continúa por la NIC que tenga el gateway predeterminado.
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:
@@ -110,6 +113,9 @@ equipos. La contraseña existe sólo en memoria. El bootstrap:
8. sólo entonces ejecuta `Add-Computer` dentro de `OU=Laboratorio` 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.
Para elegir adaptador o nombre del equipo explícitamente:
@@ -125,6 +131,12 @@ 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
+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
+5
View File
@@ -112,6 +112,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
+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
+48 -4
View File
@@ -1,6 +1,6 @@
# 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.
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.
@@ -21,33 +21,54 @@ sudo bash ./Enroll-SguLinuxDomainClient.sh \
--domain-controller 192.168.50.10 \
--domain-interface eth0 \
--domain-address 192.168.50.12/24 \
--enable-ssh
--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`.
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, el formato explícito es:
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:
@@ -67,8 +88,27 @@ 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
@@ -76,3 +116,7 @@ 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).
+2 -1
View File
@@ -29,7 +29,8 @@ rol, `TraceId`, resultado y tiempo total. Los Event ID estables distinguen:
IDs admitidos; `1202` timeout; `1203` excepción; `1204` página opcional no
disponible;
- `1300` fallo de sincronización AD; `1301` metadatos opcionales no aplicados;
`1302` membresía RDP opcional no aplicada.
`1302` membresía RDP opcional no aplicada; `1303` cuenta agregada a su grupo
institucional de Alumnos, Administrativos o Docentes.
No se almacena HTML, contraseña, hash de contraseña ni contenido de la
respuesta SGU.
+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.
+11 -7
View File
@@ -43,16 +43,20 @@
city/municipality, state, and postal code from known element IDs.
- Student CURP, birth date, sex, 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
+62
View File
@@ -0,0 +1,62 @@
# 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`.
- `location` del objeto de equipo.
- La OU padre inmediata a partir de `distinguishedName`.
El texto secundario sigue estas reglas:
1. Con `location` y OU: `Estás ubicado en la Sala de Inmersión del Centro de Experiencia Digital.`
2. Con sólo uno de los datos: muestra únicamente el dato disponible.
3. Sin ambos: `Bienvenido al Laboratorio de Cómputo de Ingeniería.`
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:
+59 -2
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',
@@ -70,10 +75,20 @@ 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
$usersOuName = 'Usuarios-SGU'
$usersOuDn = "OU=$usersOuName,$BaseDn"
if ([string]::IsNullOrWhiteSpace($ProfessorGroupDn)) {
$ProfessorGroupDn = "CN=SGU-Docentes,$usersOuDn"
}
if ([string]::IsNullOrWhiteSpace($StudentGroupDn)) {
$StudentGroupDn = "CN=SGU-Alumnos,$usersOuDn"
}
if ([string]::IsNullOrWhiteSpace($AdministrativeGroupDn)) {
$AdministrativeGroupDn = "CN=SGU-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
}
@@ -115,8 +130,46 @@ 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
if ($groupName.Length -gt 20) {
throw "$($definition.Role) group name exceeds the 20-character sAMAccountName limit."
}
New-ADGroup -Name $groupName -SamAccountName $groupName `
-GroupCategory Security -GroupScope Global `
-Path $groupDnMatch.Groups['Path'].Value `
-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)) {
@@ -171,6 +224,7 @@ $productionSettings = @{
AdministrativePersonalProfilePath = $AdministrativePersonalProfilePath
AdministrativeLocationProfilePath = $AdministrativeLocationProfilePath
StudentProfilePath = $StudentProfilePath
ProfessorPayrollProfilePath = $ProfessorPayrollProfilePath
MenuProfilePath = $MenuProfilePath
MaxProfileBytes = $MaxProfileBytes
AllowedRedirectHosts = $AllowedNtlmRedirectHosts
@@ -183,6 +237,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
+36 -7
View File
@@ -24,6 +24,8 @@ param(
[string[]]$DomainDnsServerAddresses = @('192.168.50.10'),
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
[string]$DotNetRuntimeInstallerPath,
[string]$RustDeskServerAddress,
[string]$RustDeskServerPublicKey,
[switch]$SkipRestart
)
@@ -40,7 +42,8 @@ foreach ($scriptName in @(
'Test-SguClientEnrollment.ps1',
'Repair-SguClientEnrollment.ps1',
'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.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."
}
@@ -73,8 +76,16 @@ $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
@@ -85,10 +96,19 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
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
}
& (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 ' ')"
}
@@ -98,10 +118,18 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
-RemoteDesktopPrincipal $RemoteDesktopPrincipal `
-EnableAdministrativeFirewallGroups | Out-Null
& (Join-Path $PSScriptRoot 'Enable-SguClientMonitoring.ps1') | Out-Null
return & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') `
-RequireDomainJoined `
-RequireRemoteAccess `
-RemoteDesktopPrincipal $RemoteDesktopPrincipal
$postJoinParameters = @{
RequireDomainJoined = $true
RequireRemoteAccess = $true
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
}
if ($RustDeskServerAddress) {
$postJoinParameters.RequireRustDesk = $true
$postJoinParameters.RustDeskServerAddress = $RustDeskServerAddress
}
$postJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @postJoinParameters
$postJoin | Add-Member -NotePropertyName RustDesk -NotePropertyValue $rustDeskResult
return $postJoin
}
if (-not $DomainCredential) {
@@ -132,5 +160,6 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
ComputerName = if ($NewComputerName) { $NewComputerName } else { $env:COMPUTERNAME }
DomainName = $DomainName
ProviderValidatedBeforeJoin = $true
RustDesk = $rustDeskResult
RestartRequired = [bool]$SkipRestart
}
+285 -5
View File
@@ -7,6 +7,7 @@
set -Eeuo pipefail
IFS=$'\n\t'
SCRIPT_DIRECTORY=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
DOMAIN_NAME='lci.lasalle.mx'
DOMAIN_CONTROLLER=''
@@ -18,6 +19,9 @@ DOMAIN_ADDRESS=''
COMPUTER_NAME=''
ALLOW_GROUP=''
ENABLE_SSH=false
ENABLE_HYPERV_ENHANCED_SESSION=false
ENABLE_RUSTDESK=true
RUSTDESK_REGISTRATION_SHARE=''
usage() {
cat <<'EOF'
@@ -37,6 +41,11 @@ Options:
--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:
@@ -58,6 +67,31 @@ 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 ;;
@@ -70,6 +104,9 @@ while (($#)); do
--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
@@ -100,10 +137,30 @@ install_prerequisites() {
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
apt-get update
apt-get install -y "${packages[@]}"
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
@@ -121,7 +178,7 @@ install_prerequisites() {
}
configure_private_ad_interface() {
[[ -n $DOMAIN_INTERFACE ]] || return
[[ -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"
@@ -148,7 +205,7 @@ configure_private_ad_interface() {
}
enable_sssd_dyndns() {
[[ -n $DOMAIN_INTERFACE ]] || return
[[ -n $DOMAIN_INTERFACE ]] || return 0
local configuration_directory='/etc/sssd/conf.d'
local configuration_path="${configuration_directory}/90-sgu-dyndns.conf"
local temporary_path
@@ -165,8 +222,77 @@ enable_sssd_dyndns() {
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
[[ $ENABLE_SSH == true ]] || return 0
local service_name='sshd'
if systemctl list-unit-files ssh.service >/dev/null 2>&1; then
service_name='ssh'
@@ -180,6 +306,150 @@ enable_ssh() {
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 || \
@@ -212,6 +482,9 @@ else
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
@@ -224,10 +497,17 @@ if [[ -n $ALLOW_GROUP ]]; then
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
+1
View File
@@ -32,6 +32,7 @@ $eventNames = @{
1300 = 'DirectorySynchronizationFailure'
1301 = 'DirectoryOptionalMetadataFailure'
1302 = 'DirectoryGroupMembershipFailure'
1303 = 'DirectoryRoleGroupMembershipAdded'
}
# Keep these reads unfiltered. Besides making archived and current logs behave
+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
+62 -7
View File
@@ -10,6 +10,7 @@ param(
[string]$DomainName = 'lci.lasalle.mx',
[string]$DomainNetbios = 'LCI',
[string]$BrokerRecordName = 'sgu-auth',
[string]$RustDeskRecordName = 'rustdesk',
[string]$PackageSharePath = 'C:\Packages',
[securestring]$SafeModeAdministratorPassword,
[switch]$SkipRestart,
@@ -313,6 +314,7 @@ if ($Resume -or (-not $ServerIPv4Address -and $existingState)) {
$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
}
@@ -336,6 +338,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'
@@ -349,9 +352,15 @@ foreach ($requiredPath in @(
(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"
@@ -378,6 +387,7 @@ if (-not $existingState) {
DomainName = $DomainName
DomainNetbios = $DomainNetbios
BrokerRecordName = $BrokerRecordName
RustDeskRecordName = $RustDeskRecordName
PackageSharePath = $PackageSharePath
}
[IO.File]::WriteAllText(
@@ -516,6 +526,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 |
@@ -622,16 +636,45 @@ $collectorFqdn = "$env:COMPUTERNAME.$DomainName"
$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 $privateSubnet
$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
@@ -643,6 +686,13 @@ $validation = [ordered]@{
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
@@ -658,6 +708,11 @@ if ($validation.BrokerService -ne 'Running' -or
-not $validation.BrokerPortListening -or
$validation.WinRM -ne 'Running' -or
$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) {
throw 'Server finalization did not pass service validation. Review bootstrap.log and re-run the bootstrap.'
+27
View File
@@ -37,6 +37,10 @@ $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)
@@ -122,6 +126,25 @@ function Install-DefaultAccountPicture {
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.'
@@ -235,6 +258,7 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credenti
# 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 = @{
@@ -324,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)
}
+10 -1
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'
@@ -29,6 +31,7 @@ $sourceScripts = @(
'Install-CredentialProvider.ps1',
'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1',
'Install-SguRustDeskClient.ps1',
'Test-SguClientEnrollment.ps1',
'Repair-SguClientEnrollment.ps1'
)
@@ -54,6 +57,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
@@ -88,6 +95,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(
+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
}
+240
View File
@@ -0,0 +1,240 @@
[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",
[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
}
+48
View File
@@ -124,6 +124,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)) {
@@ -177,10 +178,23 @@ try {
$serverIdentity = Invoke-Command -Session $session -ScriptBlock {
$computer = Get-CimInstance Win32_ComputerSystem
$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
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
@@ -190,6 +204,12 @@ try {
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."
}
$certificateSubject = "CN=SGU Credential Provider Client $env:COMPUTERNAME"
$clientCertificate = Get-ChildItem Cert:\LocalMachine\My |
@@ -274,6 +294,8 @@ try {
DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString)
RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP"
DotNetRuntimeInstallerPath = $runtimeInstaller.FullName
RustDeskServerAddress = $serverIdentity.RustDeskServerAddress
RustDeskServerPublicKey = $serverIdentity.RustDeskPublicKey
SkipRestart = $true
}
if ($NewComputerName) {
@@ -281,6 +303,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) {
@@ -319,6 +366,7 @@ if ($SkipRestart) {
ClientCertificateRegistered = $true
BrokerEndpoint = $brokerEndpoint
RestartRequired = $true
RustDesk = if ($result) { $result.RustDesk } else { $null }
EnrollmentResult = $result
}
return
@@ -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
}
}
+40
View File
@@ -91,6 +91,13 @@ foreach ($target in @($clientRoot,$serverRoot,$linuxClientRoot,$clientZip,$serve
}
New-Item -ItemType Directory -Path $clientRoot,$serverRoot,$linuxClientRoot -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')
@@ -102,6 +109,7 @@ $clientScripts = @(
'Enroll-SguDomainClient.ps1',
'Install-CredentialProvider.ps1',
'Install-SguEnrollmentGuard.ps1',
'Install-SguRustDeskClient.ps1',
'Register-SguClientCertificate.ps1',
'Repair-SguClientEnrollment.ps1',
'Test-SguClientEnrollment.ps1'
@@ -116,6 +124,14 @@ Copy-Item -Path (Join-Path $providerOutput '*') `
-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
@@ -127,8 +143,18 @@ Compress-Archive -Path (Join-Path $clientRoot '*') -DestinationPath $clientZip `
# 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
@@ -143,9 +169,15 @@ $serverScripts = @(
'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'
@@ -167,6 +199,14 @@ 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
+1
View File
@@ -102,6 +102,7 @@ Bootstrap reproducible para el laboratorio SGU.
- `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-linux-client-bootstrap-$Version.zip`: une clientes Debian/Ubuntu o RHEL/Fedora/Rocky/AlmaLinux con realmd, Kerberos y SSSD. Solicita interactivamente la contraseña de unión y no instala el Credential Provider de Windows.
- El Auth Broker clasifica sin tareas programadas cada cuenta autenticada: `AL` se agrega a `SGU-Alumnos`, `AD` a `SGU-Administrativos` y `DO` a `SGU-Docentes`; el bootstrap crea estos grupos de seguridad de forma idempotente.
- 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.
+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
}
+10
View File
@@ -10,6 +10,7 @@ $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'
$before = & $testScript
if (-not $before.IsValid) {
@@ -36,12 +37,21 @@ if ($computer.PartOfDomain) {
& $monitoringScript | Out-Null
}
if ($configuration.RustDeskServerAddress -and $configuration.RustDeskServerPublicKey) {
& $rustDeskScript -ServerAddress ([string]$configuration.RustDeskServerAddress) `
-ServerPublicKey ([string]$configuration.RustDeskServerPublicKey) | Out-Null
}
$verificationParams = @{}
if ($computer.PartOfDomain) {
$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) {
+16 -2
View File
@@ -3,7 +3,9 @@ param(
[string]$TargetOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
[string]$GpoName = 'SGU - Windows client experience',
[string]$DomainController = $env:COMPUTERNAME,
[string]$EventCollectorFqdn
[string]$EventCollectorFqdn,
[string]$WelcomeWallpaperScriptPath = 'C:\ProgramData\SGU\Branding\Set-SguWelcomeWallpaper.ps1',
[string]$WelcomeWallpaperBasePath = 'C:\ProgramData\SGU\Branding\darkblue.jpg'
)
$ErrorActionPreference = 'Stop'
@@ -75,7 +77,11 @@ $interactiveLogonPolicyKey = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Pol
$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'; Type = 'DWord'; Value = 0 },
@{ Key = $dataCollectionKey; Name = 'DisableTelemetryOptInSettingsUx'; Type = 'DWord'; Value = 1 },
@@ -99,7 +105,13 @@ $policies = @(
# 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 }
@{ 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 = @(
@@ -151,5 +163,7 @@ $linkEnabled = $link -and (
LinkEnabled = [bool]$linkEnabled
PolicyCount = $configuredPolicies.Count
EventCollector = $EventCollectorFqdn
WelcomeWallpaperCommand = $welcomeWallpaperCommand
LockScreenImage = $WelcomeWallpaperBasePath
Policies = [pscustomobject]$configuredPolicies
}
+14 -1
View File
@@ -3,7 +3,8 @@ 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'
@@ -115,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 `
@@ -166,4 +178,5 @@ if ($WallpaperPath) {
ScreenSaverDisabled = [string]$screenSaverValue.Value -eq '0'
DarkMode = ([int]$appsThemeValue.Value -eq 0) -and ([int]$systemThemeValue.Value -eq 0)
Wallpaper = $configuredWallpaper
DynamicWallpaperAllowed = -not [bool]$configuredWallpaper
}
+445
View File
@@ -0,0 +1,445 @@
#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,
[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-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')
$userResult = $userSearcher.FindOne()
$directoryDisplayName = if ($userResult -and $userResult.Properties['displayname'].Count) {
[string]$userResult.Properties['displayname'][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
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
)
$located = 'Est{0}s ubicado en' -f [char]0x00E1
$engineeringLab = 'Bienvenido al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED
$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-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
}
if (-not $PSBoundParameters.ContainsKey('OrganizationalUnit') -and $metadata) {
$OrganizationalUnit = $metadata.OrganizationalUnit
}
$locationText = Get-WelcomeLocationText -Room $Location -OuName $OrganizationalUnit
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 'Bienvenido,' -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())"
}
}
Write-WelcomeLog -Message ("OK computer={0}; location={1}; ou={2}; output={3}" -f $ComputerName,[bool]$Location,[bool]$OrganizationalUnit,$OutputPath)
[pscustomobject]@{
DisplayName = $DisplayName
ComputerName = $ComputerName
Location = $Location
OrganizationalUnit = $OrganizationalUnit
LocationText = $locationText
OutputPath = $OutputPath
Applied = -not $SkipApply
}
+249
View File
@@ -0,0 +1,249 @@
#!/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=''
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 2>/dev/null || true)
directory_display_name=$(read_ldif_value displayName "$user_result")
[[ -n $directory_display_name ]] && display_name=$directory_display_name
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
}
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="Estás ubicado en ${room_phrase} ${ou_phrase}."
elif [[ -n $location ]]; then
location_text="Estás ubicado en $(with_article "$location")."
elif [[ -n $organizational_unit ]]; then
location_text="Estás ubicado en $(with_article "$organizational_unit")."
else
location_text='Bienvenido al Laboratorio de Cómputo de Ingeniería.'
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 ))" 'Bienvenido,' \
-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}; 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
+40
View File
@@ -4,6 +4,8 @@ param(
[switch]$RequireRemoteAccess,
[switch]$RequireBrokerHealth,
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
[switch]$RequireRustDesk,
[string]$RustDeskServerAddress,
[switch]$Enforce
)
@@ -158,6 +160,42 @@ if ($RequireRemoteAccess) {
}
}
$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
@@ -175,6 +213,8 @@ $result = [pscustomobject]@{
DotNetRuntimePresent = $dotNetRuntimePresent
BrokerHealth = $brokerHealth
RemoteAccessReady = $remoteAccessReady
RustDeskReady = $rustDeskReady
RustDeskId = $rustDeskId
IsValid = $issues.Count -eq 0
Issues = $issues.ToArray()
}
@@ -36,6 +36,12 @@ public static class SguProfileParser
private const string StudentPostalCodeId = "ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP";
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);
+1
View File
@@ -21,4 +21,5 @@ internal static class BrokerEventIds
internal static readonly EventId DirectorySynchronizationFailure = new(1300, nameof(DirectorySynchronizationFailure));
internal static readonly EventId DirectoryOptionalMetadataFailure = new(1301, nameof(DirectoryOptionalMetadataFailure));
internal static readonly EventId DirectoryGroupMembershipFailure = new(1302, nameof(DirectoryGroupMembershipFailure));
internal static readonly EventId DirectoryRoleGroupMembershipAdded = new(1303, nameof(DirectoryRoleGroupMembershipAdded));
}
@@ -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=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
public string StudentGroupDn { get; init; } = "CN=SGU-Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
public string AdministrativeGroupDn { get; init; } = "CN=SGU-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)
};
}
@@ -109,6 +109,12 @@ public sealed class ActiveDirectorySynchronizer(
user.CommitChanges();
}
// Role membership is part of account provisioning, not optional
// enrichment. Do it before changing the password so a missing or
// inaccessible authorization group cannot leave a newly usable
// account without its required classification.
EnsureRoleGroupMembership(user, identity);
// The exact institutional password received by the broker is passed to AD.
// It is not derived, transformed, written to disk, or included in logs.
user.Invoke("SetPassword", [password]);
@@ -195,6 +201,33 @@ public sealed class ActiveDirectorySynchronizer(
}
}
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))
@@ -344,7 +344,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
profile = await TryEnrichStaffProfileAsync(
client,
profile!,
identity.Role,
identity,
allowedHosts,
timeout.Token,
cancellationToken,
@@ -413,18 +413,27 @@ 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));
pages.Add((
options.AdministrativeLocationProfilePath,
SguProfileParser.ParseAdministrativeLocation));
foreach ((string path, Func<string, InstitutionalProfile?> parser) in pages)
{
@@ -441,7 +450,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
BrokerEventIds.ProfilePageUnavailable,
"Optional SGU profile page {Path} did not return usable HTML for role {Role}; preserving fields already collected.",
path,
role);
identity.Role);
continue;
}
@@ -452,7 +461,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
BrokerEventIds.ProfileHtmlUnexpected,
"Optional SGU profile page {Path} returned HTML without its supported field IDs for role {Role}; preserving fields already collected.",
path,
role);
identity.Role);
continue;
}
@@ -463,7 +472,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
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;
}
@@ -473,7 +482,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
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);
}
}
+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=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"StudentGroupDn": "CN=SGU-Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"AdministrativeGroupDn": "CN=SGU-Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
"RemoteDesktopGroupDn": "",
"DefaultCompany": "La Salle",
"CreateMissingOus": false
@@ -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()
{
@@ -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=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
[InlineData(InstitutionalRole.Administrative, "CN=SGU-Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
[InlineData(InstitutionalRole.Professor, "CN=SGU-Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
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);
}
}
@@ -209,6 +209,15 @@ 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,
"""
@@ -244,6 +253,11 @@ 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);
@@ -253,6 +267,7 @@ public sealed class NtlmCredentialValidatorTests
"/psulsa/",
"/psulsa/",
"/psulsa/menu.aspx",
"/psulsa/gadmon/nomina/consultanomina.aspx",
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx"
],
@@ -270,6 +285,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 +299,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 +317,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