Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6bd625e4e | ||
|
|
8baa47fe1e | ||
|
|
a24c25a3fb | ||
|
|
991fc70600 | ||
|
|
93871b62b0 | ||
|
|
1fe2006404 | ||
|
|
7a4f599f55 | ||
|
|
0a2dbbeb93 | ||
|
|
f87495f21c | ||
|
|
5f5772f8cd | ||
|
|
a2ac027dc3 | ||
|
|
bb6bcf3e85 | ||
|
|
db395c9215 | ||
|
|
ef9be0f897 | ||
|
|
675db6bc1e | ||
|
|
b3ec649199 | ||
|
|
9f32ed2cb1 | ||
|
|
c737bd3192 | ||
|
|
dcbf5e87e3 | ||
|
|
f2a40f051b |
@@ -348,3 +348,6 @@ MigrationBackup/
|
|||||||
|
|
||||||
# Ionide (cross platform F# VS Code tools) working folder
|
# Ionide (cross platform F# VS Code tools) working folder
|
||||||
.ionide/
|
.ionide/
|
||||||
|
|
||||||
|
# Ejemplos de Usuarios
|
||||||
|
tmp/
|
||||||
@@ -9,7 +9,7 @@ source and adds an SGU-specific provider, an mTLS-protected broker, Active
|
|||||||
Directory synchronization, deployment scripts, and tests.
|
Directory synchronization, deployment scripts, and tests.
|
||||||
|
|
||||||
Ready-to-run bootstrap packages are published on the
|
Ready-to-run bootstrap packages are published on the
|
||||||
[releases page](https://github.lci.ulsa.mx/alexrg/SGU-CredentialProvider/releases).
|
[releases page](https://gitea.lci.ulsa.mx/alexrg/SGU-CredentialProvider/releases).
|
||||||
|
|
||||||
## Authentication contract
|
## Authentication contract
|
||||||
|
|
||||||
@@ -44,19 +44,29 @@ skips that optional field. Missing or changed presentation HTML never blocks
|
|||||||
authentication or password synchronization after the lightweight NTLM root has
|
authentication or password synchronization after the lightweight NTLM root has
|
||||||
accepted the credential.
|
accepted the credential.
|
||||||
|
|
||||||
|
For administrative staff and professors, the location page is enriched with its
|
||||||
|
ASP.NET PageMethods responses. `GetDireccion` supplies the saved state,
|
||||||
|
municipality and neighborhood identifiers; `GetLocalidadListado` resolves the
|
||||||
|
municipality name, and `GetColoniasListado` validates or supplies the
|
||||||
|
neighborhood name. This avoids reading the temporary `Seleccione...` values
|
||||||
|
visible while the browser populates those controls asynchronously.
|
||||||
|
|
||||||
Operational documentation:
|
Operational documentation:
|
||||||
|
|
||||||
- [One-command server recovery and client enrollment](docs/bootstrap-recovery.md)
|
- [One-command server recovery and client enrollment](docs/bootstrap-recovery.md)
|
||||||
- [Broker location, health, timeout, and recovery](docs/broker-operations.md)
|
- [Broker location, health, timeout, and recovery](docs/broker-operations.md)
|
||||||
- [Windows domain join and remote-access onboarding](docs/windows-client-onboarding.md)
|
- [Windows domain join and remote-access onboarding](docs/windows-client-onboarding.md)
|
||||||
- [Required Credential Provider client enrollment](docs/client-enrollment.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)
|
- [Decision: do not persist password verifiers in Redis](docs/decisions/0001-no-password-cache.md)
|
||||||
|
|
||||||
| Prefix | Role | Default OU |
|
| Prefix | Role | Default OU | Security group in the same OU |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| `DO` | Professor / docente | `OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
|
| `DO` | Professor / docente | `OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` | `SGU-Docentes` |
|
||||||
| `AL` | Student / alumno | `OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
|
| `AL` | Student / alumno | `OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` | `SGU-Alumnos` |
|
||||||
| `AD` | Administrative | `OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` |
|
| `AD` | Administrative | `OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx` | `SGU-Administrativos` |
|
||||||
|
|
||||||
If the broker or institutional NTLM authority is unavailable, the provider
|
If the broker or institutional NTLM authority is unavailable, the provider
|
||||||
submits the unchanged credentials to Windows for normal AD/cached-domain
|
submits the unchanged credentials to Windows for normal AD/cached-domain
|
||||||
@@ -94,6 +104,9 @@ the latest .NET 10 x64 runtime. The broker is published self-contained.
|
|||||||
Follow [docs/lab-runbook.md](docs/lab-runbook.md). Review
|
Follow [docs/lab-runbook.md](docs/lab-runbook.md). Review
|
||||||
[docs/security.md](docs/security.md) before production deployment and
|
[docs/security.md](docs/security.md) before production deployment and
|
||||||
[docs/architecture.md](docs/architecture.md) for the component contract.
|
[docs/architecture.md](docs/architecture.md) for the component contract.
|
||||||
|
For a public Azure VM connected to local Hyper-V clients through Azure VPN
|
||||||
|
Gateway, use [docs/azure-vpn-deployment.md](docs/azure-vpn-deployment.md). AD
|
||||||
|
ports remain private even though the VM owns a public IP.
|
||||||
|
|
||||||
Never disable the built-in Microsoft password Credential Provider. It is the
|
Never disable the built-in Microsoft password Credential Provider. It is the
|
||||||
supported recovery path if a third-party provider fails to load.
|
supported recovery path if a third-party provider fails to load.
|
||||||
@@ -105,6 +118,15 @@ Start-SguServerBootstrap.cmd 192.168.50.10
|
|||||||
Start-SguClientEnrollment.cmd 192.168.50.10
|
Start-SguClientEnrollment.cmd 192.168.50.10
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Linux clients are enrolled through their native PAM/SSSD stack instead of the
|
||||||
|
Windows Credential Provider:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash ./Enroll-SguLinuxDomainClient.sh \
|
||||||
|
--domain-controller 192.168.50.10 \
|
||||||
|
--enable-hyperv-enhanced-session
|
||||||
|
```
|
||||||
|
|
||||||
The server command creates a new forest and resumes by itself after its required
|
The server command creates a new forest and resumes by itself after its required
|
||||||
restart. The client command registers a unique non-exportable mTLS certificate,
|
restart. The client command registers a unique non-exportable mTLS certificate,
|
||||||
installs and validates SGU before domain join, then enables the managed remote
|
installs and validates SGU before domain join, then enables the managed remote
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
+56
-11
@@ -23,13 +23,19 @@ is forced because NTLM authentication is connection-bound.
|
|||||||
The authoritative logical GET is sent to `/psulsa/`, a lightweight route that
|
The authoritative logical GET is sent to `/psulsa/`, a lightweight route that
|
||||||
returns the NTLM challenge without waiting for the slow application pages. A
|
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
|
`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
|
accepted it. The broker then makes separately bounded, best-effort profile GETs.
|
||||||
administrative incident overview for `AD`, the student information page for
|
It uses the administrative incident overview for `AD`, the student information
|
||||||
`AL`, or the portal menu for `DO`. After the incident page confirms an `AD`
|
page for `AL`, and the portal menu as a conservative base for `DO`. After the
|
||||||
employee number, two additional GETs in the same in-memory session read the
|
incident page confirms an `AD` employee number, two additional GETs in the same
|
||||||
structured name from `datos/personales.aspx` and the address from
|
in-memory session read the structured name and selected sex from
|
||||||
`datos/ubicacion.aspx`. Docentes keep the menu name as a base and attempt those
|
`datos/personales.aspx` and the address inputs from `datos/ubicacion.aspx`. The
|
||||||
same two shared staff modules without requiring them to exist. A supplemental
|
broker then calls the location page's `GetDireccion`, `GetLocalidadListado`, and
|
||||||
|
`GetColoniasListado` methods to correlate the saved state, municipality, and
|
||||||
|
neighborhood identifiers instead of reading transient `Seleccione...` options.
|
||||||
|
Docentes request
|
||||||
|
`nomina/consultanomina.aspx` for a matching employee number, email, employee
|
||||||
|
type and job title, then attempt the same two shared staff modules without
|
||||||
|
requiring any optional route to exist. A supplemental
|
||||||
404, changed/missing element ID, other failure, or timeout preserves fields
|
404, changed/missing element ID, other failure, or timeout preserves fields
|
||||||
already collected, and a profile timeout does not invalidate an already
|
already collected, and a profile timeout does not invalidate an already
|
||||||
authenticated credential. NTLM may still require its normal
|
authenticated credential. NTLM may still require its normal
|
||||||
@@ -59,16 +65,30 @@ passes the submitted password directly to ADSI `SetPassword`.
|
|||||||
When the authenticated HTML exposes recognized stable IDs, the broker also
|
When the authenticated HTML exposes recognized stable IDs, the broker also
|
||||||
updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`,
|
updates the applicable `displayName`, `givenName`, `sn`, `mail`, `title`,
|
||||||
`department`, `employeeType`, `employeeID`, `streetAddress`, `l`, `st`, and
|
`department`, `employeeType`, `employeeID`, `streetAddress`, `l`, `st`, and
|
||||||
`postalCode` attributes. Administrative and student numbers must match the six
|
`postalCode` attributes. The SGU sex value is normalized to `Male`/`Female` and
|
||||||
|
written as the managed `SGU-Gender:` line in the built-in `info` attribute while
|
||||||
|
preserving unrelated notes. Administrative and student numbers must match the six
|
||||||
numeric digits of the requested identity before any role-specific metadata is
|
numeric digits of the requested identity before any role-specific metadata is
|
||||||
trusted. Administrative personal and location pages are accepted only after
|
trusted. Administrative personal and location pages are accepted only after
|
||||||
that incident-page match. A docente's supplemental fields remain tied to the
|
that incident-page match. Docente payroll metadata must match the requested
|
||||||
fresh NTLM-authenticated portal session and are optional; the menu display name
|
six-digit number; all docente supplemental fields remain tied to the fresh
|
||||||
remains usable if neither shared page is available. Student faculty/department
|
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.
|
is deliberately left unset because the verified page does not expose it.
|
||||||
Missing metadata does not clear existing AD values and never changes the
|
Missing metadata does not clear existing AD values and never changes the
|
||||||
password outcome.
|
password outcome.
|
||||||
|
|
||||||
|
Every synchronization also enforces one idempotent security-group membership
|
||||||
|
from the classified institutional prefix: `AL` to `SGU-Alumnos`, `AD` to
|
||||||
|
`SGU-Administrativos`, and `DO` to `SGU-Docentes`. Each role group is stored
|
||||||
|
inside its corresponding user OU. During an upgrade,
|
||||||
|
the bootstrap moves a legacy group from the `Usuarios-SGU` root while preserving
|
||||||
|
its SID and memberships instead of creating a duplicate. Membership enforcement
|
||||||
|
happens synchronously inside the broker before the institutional password is written to AD. A missing
|
||||||
|
or inaccessible role group therefore fails provisioning instead of leaving a
|
||||||
|
new usable account without its authorization classification. Existing accounts
|
||||||
|
are repaired automatically on their next successful SGU authentication.
|
||||||
|
|
||||||
Human-readable SGU values are decoded with BOM/header/meta detection, strict
|
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
|
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`
|
titles are normalized with Spanish-aware casing; particles such as `de`, `del`
|
||||||
@@ -105,6 +125,31 @@ to `OU=Laboratorio`; it suppresses first-logon/privacy/diagnostic prompts,
|
|||||||
disables location, and enforces always-on display, sleep, and hibernation
|
disables location, and enforces always-on display, sleep, and hibernation
|
||||||
settings for managed clients.
|
settings for managed clients.
|
||||||
|
|
||||||
|
That computer GPO also owns the base lock-screen image and a per-logon command
|
||||||
|
for the personalized desktop wallpaper. The client-side renderer reads the
|
||||||
|
managed `SGU-Gender: Male|Female` line from the user's built-in `info` attribute
|
||||||
|
(without requiring an irreversible AD schema extension). It uses neutral Spanish
|
||||||
|
when that optional enrichment is unavailable. The renderer also reads the
|
||||||
|
authenticated user's `displayName` plus the computer object's `location` and
|
||||||
|
immediate parent OU, then composes those values over the bundled dark-blue
|
||||||
|
background with the bundled Indivisa fonts. Missing directory attributes degrade
|
||||||
|
to deterministic text and never block the interactive session.
|
||||||
|
|
||||||
|
The domain controller is also the source-initiated Windows Event Collector for
|
||||||
|
managed laboratory computers. Kerberos-authenticated WEF sends only selected
|
||||||
|
logon/logoff, failed-logon, reconnect/disconnect, and operating-system power
|
||||||
|
events to `ForwardedEvents`. Daily EVTX archives are retained for 183 days, and
|
||||||
|
a five-minute server-side inventory records WinRM reachability and AD last-logon
|
||||||
|
metadata. Session-duration reports correlate Windows logon IDs; no password or
|
||||||
|
SGU HTTP payload is included in this monitoring path.
|
||||||
|
|
||||||
|
Broker diagnostics use the dedicated `SGU Auth Broker` Windows log with stable
|
||||||
|
event IDs for authorization outcomes, SGU network/timeout failures, unexpected
|
||||||
|
profile HTML, partial enrichment, and AD synchronization warnings. The same
|
||||||
|
daily maintenance task archives that log for 183 days. Messages identify the
|
||||||
|
institutional user and role but never include passwords, password verifiers, or
|
||||||
|
raw SGU HTML.
|
||||||
|
|
||||||
Per-user synchronization is serialized inside the broker to prevent concurrent
|
Per-user synchronization is serialized inside the broker to prevent concurrent
|
||||||
create/reset races. Production deployments should run the broker as a gMSA with
|
create/reset races. Production deployments should run the broker as a gMSA with
|
||||||
delegated create-user, move-user, write-property, enable-account, and reset-password
|
delegated create-user, move-user, write-property, enable-account, and reset-password
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# Active Directory SGU en Azure con VPN Point-to-Site
|
||||||
|
|
||||||
|
Esta variante conserva Active Directory en una VM Windows Server 2025 con IP
|
||||||
|
pública de Azure, pero **no publica Active Directory en Internet**. La IP pública
|
||||||
|
sirve para el ciclo de vida y, opcionalmente, RDP desde un único CIDR
|
||||||
|
administrativo. DNS, Kerberos, LDAP, SMB, RPC, WinRM, Auth Broker, monitoreo y
|
||||||
|
RustDesk viajan por Azure VPN Gateway Point-to-Site (P2S).
|
||||||
|
|
||||||
|
La plantilla crea:
|
||||||
|
|
||||||
|
- VNet `10.77.0.0/16`, subnet del DC `10.77.0.0/24` y `GatewaySubnet`;
|
||||||
|
- Windows Server 2025 con IP privada estática `10.77.0.4` reservada en la NIC;
|
||||||
|
- IP pública Standard para la VM, protegida por NSG;
|
||||||
|
- VPN Gateway `VpnGw1` con IKEv2/SSTP y autenticación por certificados;
|
||||||
|
- pool P2S `172.30.0.0/24`, autorizado en los firewalls SGU;
|
||||||
|
- DNS de la NIC del servidor apuntando a `10.77.0.4`.
|
||||||
|
|
||||||
|
Los prefijos son parámetros. Deben ser RFC1918 y no deben solaparse con las
|
||||||
|
redes usadas por Hyper-V, el `Default Switch`, Wi-Fi o Ethernet locales.
|
||||||
|
|
||||||
|
## 1. Crear la autoridad P2S y el certificado de administración
|
||||||
|
|
||||||
|
En la estación administrativa donde está el repositorio:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$p2s = .\scripts\New-SguAzureP2sCertificates.ps1 `
|
||||||
|
-ClientName 'AdminWorkstation'
|
||||||
|
```
|
||||||
|
|
||||||
|
Se pide una contraseña para proteger el PFX. La clave privada de la autoridad
|
||||||
|
raíz permanece no exportable en `Cert:\CurrentUser\My`; Azure recibe solamente
|
||||||
|
el `.cer` público. El PFX es una credencial de acceso a la VNet: se debe copiar
|
||||||
|
únicamente a la VM correspondiente y eliminarse de ubicaciones compartidas
|
||||||
|
después de importarlo.
|
||||||
|
|
||||||
|
## 2. Desplegar Azure
|
||||||
|
|
||||||
|
Requisitos: Azure CLI, una sesión iniciada con `az login`, permisos para crear
|
||||||
|
red, gateway, IP pública y VM, y una suscripción seleccionable.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$azure = .\scripts\Deploy-SguAzureInfrastructure.ps1 `
|
||||||
|
-SubscriptionId '00000000-0000-0000-0000-000000000000' `
|
||||||
|
-ResourceGroupName 'rg-sgu-lab' `
|
||||||
|
-Location 'centralus' `
|
||||||
|
-AdministratorUsername 'azureadmin' `
|
||||||
|
-P2sRootCertificatePath $p2s.RootCertificatePath
|
||||||
|
```
|
||||||
|
|
||||||
|
La contraseña local de la VM se solicita como `SecureString`, se coloca sólo en
|
||||||
|
un archivo temporal con ACL exclusiva para el usuario actual y se elimina al
|
||||||
|
terminar. No aparece en los argumentos de Azure CLI ni queda guardada en el
|
||||||
|
repositorio.
|
||||||
|
|
||||||
|
Por omisión ningún puerto administrativo de la VM se abre desde Internet. Para
|
||||||
|
habilitar temporalmente RDP durante el bootstrap, indique exclusivamente su IP
|
||||||
|
pública actual:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
-AdministratorSourceAddressPrefix '203.0.113.10/32'
|
||||||
|
```
|
||||||
|
|
||||||
|
No utilice `0.0.0.0/0`. El despliegue de un VPN Gateway suele tardar bastante
|
||||||
|
más que la VM; el comando espera hasta que Azure entregue un resultado final.
|
||||||
|
|
||||||
|
## 3. Descargar P2S y entrar por la IP privada
|
||||||
|
|
||||||
|
Cuando el gateway esté `Succeeded`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$vpn = .\scripts\Get-SguAzureP2sPackage.ps1 `
|
||||||
|
-SubscriptionId '00000000-0000-0000-0000-000000000000' `
|
||||||
|
-ResourceGroupName 'rg-sgu-lab' `
|
||||||
|
-VpnGatewayName $azure.VpnGatewayName
|
||||||
|
|
||||||
|
.\scripts\Install-SguAzureP2sClient.ps1 `
|
||||||
|
-VpnProfilePackagePath $vpn.PackagePath `
|
||||||
|
-ClientCertificatePfxPath $p2s.ClientCertificatePath `
|
||||||
|
-ClientRootCertificatePath $p2s.RootCertificatePath `
|
||||||
|
-Connect
|
||||||
|
```
|
||||||
|
|
||||||
|
Con el túnel conectado, use RDP contra `10.77.0.4` y habilite la redirección de
|
||||||
|
una unidad local para copiar `sgu-server-bootstrap-VERSION.zip` a la VM. La NIC
|
||||||
|
ya apunta a su futura dirección DNS propia, por lo que la resolución pública no
|
||||||
|
está disponible hasta que el bootstrap instale DNS y sus reenviadores. Así no es
|
||||||
|
necesario abrir 3389 en la IP pública. La opción
|
||||||
|
`AdministratorSourceAddressPrefix` queda como ruta de recuperación temporal,
|
||||||
|
no como el camino normal.
|
||||||
|
|
||||||
|
## 4. Ejecutar el bootstrap dentro de Windows Server
|
||||||
|
|
||||||
|
Descargue y extraiga `sgu-server-bootstrap-VERSION.zip` dentro de la VM. La IP
|
||||||
|
que recibe el bootstrap es la **privada** de la NIC, nunca la pública:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
Start-SguAzureServerBootstrap.cmd 10.77.0.4 172.30.0.0/24
|
||||||
|
```
|
||||||
|
|
||||||
|
El modo `PlatformManaged` comprueba que Azure ya asignó `10.77.0.4/24`, pero no
|
||||||
|
deshabilita DHCP, no reemplaza la ruta predeterminada y no reinicia el adaptador.
|
||||||
|
El DNS de AD publica únicamente la dirección privada. `168.63.129.16` se usa
|
||||||
|
como reenviador DNS de la plataforma Azure.
|
||||||
|
|
||||||
|
Después del reinicio de promoción, verificar:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-Content C:\ProgramData\SGU\Bootstrap\Server\bootstrap-complete.json
|
||||||
|
Resolve-DnsName _ldap._tcp.dc._msdcs.lci.lasalle.mx -Type SRV -Server 10.77.0.4
|
||||||
|
```
|
||||||
|
|
||||||
|
El JSON debe indicar `NetworkConfigurationMode = PlatformManaged`, el pool P2S
|
||||||
|
en `TrustedClientNetworks` y ambos prefijos en `AllowedRemoteAddresses`.
|
||||||
|
|
||||||
|
## 5. Emitir un certificado y enrolar cada VM Hyper-V
|
||||||
|
|
||||||
|
En la estación administrativa, emita una credencial distinta por equipo:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$w11 = .\scripts\New-SguAzureP2sCertificates.ps1 -ClientName 'Windows11'
|
||||||
|
```
|
||||||
|
|
||||||
|
Copie a la VM Windows 11 de Hyper-V:
|
||||||
|
|
||||||
|
- `sgu-client-bootstrap-VERSION.zip` extraído;
|
||||||
|
- `$vpn.PackagePath`;
|
||||||
|
- `$w11.ClientCertificatePath`;
|
||||||
|
- `sgu-azure-p2s-root.cer`.
|
||||||
|
|
||||||
|
Desde la carpeta extraída del bootstrap de cliente, instale P2S y enrole:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
Start-SguAzureClientEnrollment.cmd 10.77.0.4 C:\SGU\sgu-azure-vpn-client.zip C:\SGU\sgu-azure-p2s-Windows11.pfx C:\SGU\sgu-azure-p2s-root.cer
|
||||||
|
```
|
||||||
|
|
||||||
|
En una sola ejecución el comando:
|
||||||
|
|
||||||
|
1. importa el certificado de cliente en `LocalMachine\My` sin dejar la
|
||||||
|
contraseña en disco;
|
||||||
|
2. instala un perfil IKEv2 de todos los usuarios llamado `SGU Azure P2S`;
|
||||||
|
3. agrega la ruta `10.77.0.0/16` y una regla NRPT que envía sólo
|
||||||
|
`.lci.lasalle.mx` al DNS `10.77.0.4`;
|
||||||
|
4. conecta P2S con certificado de máquina;
|
||||||
|
5. registra mTLS, instala SGU/RustDesk y une el equipo al dominio;
|
||||||
|
6. reinicia Windows.
|
||||||
|
|
||||||
|
Si la red local bloquea IKEv2 (UDP 500/4500), el paquete de Azure también
|
||||||
|
incluye un perfil SSTP sobre TCP 443, pero ese fallback todavía requiere
|
||||||
|
instalación manual con el instalador oficial incluido en `WindowsAmd64`.
|
||||||
|
|
||||||
|
Windows 11 Pro admite unión a AD y VPN nativa, pero Microsoft no licencia el
|
||||||
|
**Always On VPN device tunnel** para Pro. Por ello el perfil se instala para
|
||||||
|
todos los usuarios y se puede seleccionar desde el control de red de la
|
||||||
|
pantalla de inicio de sesión; antes del primer logon de una cuenta de dominio,
|
||||||
|
conecte `SGU Azure P2S` allí. Enterprise/Education pueden recibir posteriormente
|
||||||
|
un device tunnel Always On, pero eso no es requisito del enrolamiento SGU.
|
||||||
|
|
||||||
|
Validación dentro del cliente, con la VPN conectada:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-VpnConnection -Name 'SGU Azure P2S' -AllUserConnection
|
||||||
|
Get-DnsClientNrptRule | Where-Object DisplayName -like 'SGU Azure P2S*'
|
||||||
|
Test-NetConnection 10.77.0.4 -Port 5985
|
||||||
|
Resolve-DnsName _ldap._tcp.dc._msdcs.lci.lasalle.mx -Type SRV
|
||||||
|
nltest.exe /dsgetdc:lci.lasalle.mx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Seguridad y referencias
|
||||||
|
|
||||||
|
No agregue reglas NSG públicas para 53, 88, 135, 389, 445, 464, 636, 3268,
|
||||||
|
3269 ni RPC dinámico. El conjunto de puertos necesario para una unión de dominio
|
||||||
|
es precisamente la razón de encapsularlo en P2S.
|
||||||
|
|
||||||
|
- [Azure VPN Gateway P2S con certificados](https://learn.microsoft.com/en-us/azure/vpn-gateway/point-to-site-certificate-gateway)
|
||||||
|
- [Cliente P2S nativo de Windows](https://learn.microsoft.com/en-us/azure/vpn-gateway/point-to-site-vpn-client-certificate)
|
||||||
|
- [Instalación de certificados P2S](https://learn.microsoft.com/en-us/azure/vpn-gateway/point-to-site-how-to-vpn-client-install-azure-cert)
|
||||||
|
- [Puertos necesarios para unir un dominio](https://learn.microsoft.com/en-us/troubleshoot/windows-server/identity/active-directory-domain-join-troubleshooting-guidance)
|
||||||
|
- [Requisitos de edición de Windows](https://learn.microsoft.com/en-us/windows/security/licensing-and-edition-requirements)
|
||||||
|
- [Limitación de Always On device tunnel](https://learn.microsoft.com/en-us/azure/vpn-gateway/vpn-gateway-howto-always-on-device-tunnel)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Recuperación desde cero y alta en una sola ejecución
|
# Recuperación desde cero y alta en una sola ejecución
|
||||||
|
|
||||||
Los releases entregan dos ZIP independientes. Cada uno contiene sus binarios,
|
Los releases entregan cuatro ZIP independientes. Cada uno contiene sus binarios,
|
||||||
scripts, instalador offline requerido y un manifiesto SHA-256 interno. No contienen
|
scripts, instalador offline requerido y un manifiesto SHA-256 interno. No contienen
|
||||||
contraseñas, claves privadas ni certificados reutilizables.
|
contraseñas, claves privadas ni certificados reutilizables.
|
||||||
|
|
||||||
@@ -17,6 +17,11 @@ Compatible con Windows Server con Windows PowerShell 5.1. El servidor necesita
|
|||||||
una interfaz privada para el dominio y, para autenticar contra SGU, salida HTTPS
|
una interfaz privada para el dominio y, para autenticar contra SGU, salida HTTPS
|
||||||
por esa u otra interfaz.
|
por esa u otra interfaz.
|
||||||
|
|
||||||
|
Cuando el servidor vive en Azure, no se configura la IP dentro del sistema
|
||||||
|
operativo. La NIC reserva la IP privada y se usa el modo `PlatformManaged`; el
|
||||||
|
procedimiento completo, incluidos VPN Gateway y los clientes Hyper-V, está en
|
||||||
|
[azure-vpn-deployment.md](azure-vpn-deployment.md).
|
||||||
|
|
||||||
1. Descargar y extraer `sgu-server-bootstrap-VERSION.zip`.
|
1. Descargar y extraer `sgu-server-bootstrap-VERSION.zip`.
|
||||||
2. Abrir el directorio extraído.
|
2. Abrir el directorio extraído.
|
||||||
3. Ejecutar, indicando la IP fija que tendrá el controlador:
|
3. Ejecutar, indicando la IP fija que tendrá el controlador:
|
||||||
@@ -55,6 +60,8 @@ El proceso crea o configura de forma idempotente:
|
|||||||
- RDP con NLA, WinRM/PowerShell Remoting y reglas administrativas limitadas a
|
- RDP con NLA, WinRM/PowerShell Remoting y reglas administrativas limitadas a
|
||||||
la subred privada indicada, incluso si Windows tarda en reconocer el perfil
|
la subred privada indicada, incluso si Windows tarda en reconocer el perfil
|
||||||
Domain después de la promoción;
|
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.
|
- pantalla, suspensión e hibernación en Nunca.
|
||||||
|
|
||||||
En un servidor con dos NIC, el bootstrap desactiva el registro DNS de la NIC de
|
En un servidor con dos NIC, el bootstrap desactiva el registro DNS de la NIC de
|
||||||
@@ -66,9 +73,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
|
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.
|
rechaza todos los certificados hasta que el primer cliente registra el suyo.
|
||||||
Los archivos opcionales colocados en `payload\server-content\Packages` al crear
|
Los archivos opcionales colocados en `payload\server-content\Packages` al crear
|
||||||
el release se copian al recurso compartido. Si allí existe `wallpaper.jpg`,
|
el release se copian al recurso compartido. El paquete siempre incluye
|
||||||
`wallpaper.jpeg`, `wallpaper.png` o `wallpaper.bmp`, la GPO de usuarios lo aplica
|
`welcome-wallpaper`: fondo azul, fuentes Indivisa y generador de respaldo para
|
||||||
automáticamente como fondo con ajuste Fill.
|
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:
|
Estado y diagnóstico:
|
||||||
|
|
||||||
@@ -110,6 +118,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;
|
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
|
9. al arrancar, activa RDP/NLA, WinRM y las reglas Domain, y vuelve a validar el
|
||||||
enrolamiento.
|
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:
|
Para elegir adaptador o nombre del equipo explícitamente:
|
||||||
|
|
||||||
@@ -125,12 +136,42 @@ 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
|
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.
|
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
|
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
|
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
|
bosque modificar el dominio. Para la operación soportada, el script aplica una
|
||||||
transacción proveedor-primero y se niega a unir un equipo que no haya pasado las
|
transacción proveedor-primero y se niega a unir un equipo que no haya pasado las
|
||||||
validaciones.
|
validaciones.
|
||||||
|
|
||||||
|
## Cliente Linux nuevo
|
||||||
|
|
||||||
|
El ZIP `sgu-linux-client-bootstrap-VERSION.zip` usa el mecanismo nativo de
|
||||||
|
Linux: `realmd`, Kerberos, SSSD, NSS y PAM. No distribuye ni instala el
|
||||||
|
Credential Provider de Windows.
|
||||||
|
|
||||||
|
En una VM con Internet por `Default Switch` y una NIC privada conectada a
|
||||||
|
`Laboratorio AD`, ejecutar como administrador:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash ./Enroll-SguLinuxDomainClient.sh \
|
||||||
|
--domain-controller 192.168.50.10 \
|
||||||
|
--domain-interface eth0 \
|
||||||
|
--domain-address 192.168.50.12/24 \
|
||||||
|
--enable-ssh
|
||||||
|
```
|
||||||
|
|
||||||
|
La contraseña de la cuenta autorizada para la unión se solicita de forma
|
||||||
|
interactiva por `realmd`; no se incluye en la línea de comandos. El script
|
||||||
|
configura DNS de AD en la NIC privada, preserva la ruta de Internet, crea la
|
||||||
|
cuenta de equipo en `OU=Laboratorio`, habilita creación de directorio personal
|
||||||
|
mediante PAM y valida la cuenta de equipo con `adcli testjoin`. La documentación
|
||||||
|
de operación completa está en [linux-client-enrollment.md](linux-client-enrollment.md).
|
||||||
|
|
||||||
## Crear y publicar un release
|
## Crear y publicar un release
|
||||||
|
|
||||||
Desde el repositorio y con el SDK fijado en `global.json`:
|
Desde el repositorio y con el SDK fijado en `global.json`:
|
||||||
@@ -150,4 +191,4 @@ la línea de comandos. Para empaquetar recursos institucionales adicionales:
|
|||||||
-ServerContentPath C:\Preparacion\Packages
|
-ServerContentPath C:\Preparacion\Packages
|
||||||
```
|
```
|
||||||
|
|
||||||
`SHA256SUMS-VERSION.txt` permite comprobar ambos ZIP antes de usarlos.
|
`SHA256SUMS-VERSION.txt` permite comprobar los cuatro ZIP antes de usarlos.
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ Eso es comportamiento esperado, no una caída del servicio.
|
|||||||
`/psulsa/`. El enriquecimiento usa el límite total independiente
|
`/psulsa/`. El enriquecimiento usa el límite total independiente
|
||||||
`ProfileTimeoutSeconds` —**90 segundos** en la configuración del laboratorio—
|
`ProfileTimeoutSeconds` —**90 segundos** en la configuración del laboratorio—
|
||||||
y conserva los campos que alcance a obtener si una página de personal se
|
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
|
retrasa, no existe o cambia sus IDs. Para docentes esto incluye consulta de
|
||||||
nombre y ubicación para docentes. El Credential Provider mantiene su propio límite de **90
|
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
|
segundos**: si SGU excede ese presupuesto, Windows continúa por el fallback
|
||||||
normal de AD o credenciales de dominio en caché.
|
normal de AD o credenciales de dominio en caché.
|
||||||
- El instalador configura recuperación del servicio con reinicios a los 5, 15
|
- El instalador configura recuperación del servicio con reinicios a los 5, 15
|
||||||
|
|||||||
@@ -72,11 +72,13 @@ Orden de la transacción:
|
|||||||
1. instala .NET y los binarios versionados;
|
1. instala .NET y los binarios versionados;
|
||||||
2. registra COM, configura SGU como proveedor predeterminado y oculta el
|
2. registra COM, configura SGU como proveedor predeterminado y oculta el
|
||||||
último usuario que cerró sesión;
|
último usuario que cerró sesión;
|
||||||
3. instala el guard de autorreparación;
|
3. crea o actualiza la cuenta local estándar `alumno` con la contraseña
|
||||||
4. exige health mTLS del broker y ejecuta las comprobaciones locales;
|
`ingenieria`, la habilita y garantiza que no pertenezca a Administradores;
|
||||||
5. configura DNS del dominio;
|
4. instala el guard de autorreparación;
|
||||||
6. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` y reinicia;
|
5. exige health mTLS del broker y ejecuta las comprobaciones locales;
|
||||||
7. al arrancar, el guard habilita RDP, NLA y WinRM y comprueba el estado final.
|
6. configura DNS del dominio;
|
||||||
|
7. sólo entonces ejecuta `Add-Computer` en `OU=Laboratorio` y reinicia;
|
||||||
|
8. al arrancar, el guard habilita RDP, NLA y WinRM y comprueba el estado final.
|
||||||
|
|
||||||
La directiva de Windows **Assign a default credential provider** selecciona SGU
|
La directiva de Windows **Assign a default credential provider** selecciona SGU
|
||||||
por defecto. El instalador también habilita **Interactive logon: Don't display
|
por defecto. El instalador también habilita **Interactive logon: Don't display
|
||||||
@@ -112,6 +114,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
|
batería. El guard de enrolamiento vuelve a aplicar `powercfg /hibernate off` y
|
||||||
los tiempos en cero al inicio y diariamente.
|
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
|
`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
|
la fase OOBE y Microsoft la reserva para pruebas de OEM/System Builder. La GPO
|
||||||
usa las alternativas soportadas `DisablePrivacyExperience=1` y
|
usa las alternativas soportadas `DisablePrivacyExperience=1` y
|
||||||
@@ -133,8 +140,9 @@ Start-ScheduledTask -TaskName SGU-CredentialProvider-EnrollmentGuard
|
|||||||
|
|
||||||
Un resultado válido exige simultáneamente binario y registro COM, configuración,
|
Un resultado válido exige simultáneamente binario y registro COM, configuración,
|
||||||
certificados, .NET 10, proveedor SGU predeterminado, último usuario oculto,
|
certificados, .NET 10, proveedor SGU predeterminado, último usuario oculto,
|
||||||
enumeración local deshabilitada y proveedor de contraseña de Microsoft
|
enumeración local deshabilitada, cuenta local estándar `alumno` habilitada y
|
||||||
preservado. El script de reparación se encuentra en
|
fuera del grupo Administradores, y proveedor de contraseña de Microsoft
|
||||||
|
preservado. El guard recrea o corrige esa cuenta de forma idempotente. El script de reparación se encuentra en
|
||||||
`C:\ProgramData\SGU\Enrollment` con ACL exclusiva para `SYSTEM` y
|
`C:\ProgramData\SGU\Enrollment` con ACL exclusiva para `SYSTEM` y
|
||||||
administradores.
|
administradores.
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ Get-Service SGUAuthBroker
|
|||||||
Get-NetTCPConnection -LocalPort 8443 -State Listen
|
Get-NetTCPConnection -LocalPort 8443 -State Listen
|
||||||
sc.exe qfailure SGUAuthBroker
|
sc.exe qfailure SGUAuthBroker
|
||||||
Get-ADOrganizationalUnit -Filter * -SearchBase 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx'
|
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
|
## 4. Broker preflight from Windows 10
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Enrolamiento de clientes Linux
|
||||||
|
|
||||||
|
El enrolador Linux incorpora una estación Debian/Ubuntu o RHEL/Fedora/Rocky/AlmaLinux a `lci.lasalle.mx` mediante `realmd`, `adcli`, Kerberos y SSSD. No instala el Credential Provider de Windows: Linux conserva su propio inicio de sesión PAM/SSSD. También instala RustDesk, lo dirige al servidor RustDesk autoalojado y registra el ID y la contraseña de acceso desatendido en el inventario protegido del controlador de dominio.
|
||||||
|
|
||||||
|
La contraseña de la cuenta autorizada para unir equipos se solicita directamente por `realmd`. Nunca se acepta como argumento, ni se escribe en archivos, registros o la línea de comandos.
|
||||||
|
|
||||||
|
## Requisitos de red
|
||||||
|
|
||||||
|
El equipo debe alcanzar por una interfaz privada al controlador de dominio, DNS y Kerberos. En el laboratorio:
|
||||||
|
|
||||||
|
| Servicio | Destino |
|
||||||
|
| --- | --- |
|
||||||
|
| Controlador y DNS | `192.168.50.10` |
|
||||||
|
| Dominio | `lci.lasalle.mx` |
|
||||||
|
| OU de equipos | `OU=Laboratorio,DC=lci,DC=lasalle,DC=mx` |
|
||||||
|
|
||||||
|
Para una VM Hyper-V con dos NIC, mantén el Internet en `Default Switch` y conecta una segunda NIC a `Laboratorio AD`. El enrolador puede configurar la segunda NIC sin modificar la ruta predeterminada:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash ./Enroll-SguLinuxDomainClient.sh \
|
||||||
|
--domain-controller 192.168.50.10 \
|
||||||
|
--domain-interface eth0 \
|
||||||
|
--domain-address 192.168.50.12/24 \
|
||||||
|
--enable-ssh \
|
||||||
|
--enable-hyperv-enhanced-session
|
||||||
|
```
|
||||||
|
|
||||||
|
`--domain-interface` y `--domain-address` son opcionales como pareja. Si ya se aprovisionó la red privada mediante cloud-init, DHCP o gestión de configuración, omítelos y conserva únicamente `--domain-controller`.
|
||||||
|
|
||||||
|
El script se niega a reconfigurar una interfaz que posea la ruta predeterminada; así no deja a la máquina sin salida a Internet al agregar AD.
|
||||||
|
|
||||||
|
En Ubuntu con GNOME, cierra la sesión gráfica de **Sesión básica** antes de
|
||||||
|
entrar con el mismo usuario mediante **Sesión mejorada**. GNOME no admite dos
|
||||||
|
escritorios simultáneos del mismo usuario; intentar conservar ambos produce una
|
||||||
|
pantalla negra aunque XRDP haya autenticado correctamente. El inicio automático
|
||||||
|
de sesión de GDM también debe permanecer deshabilitado.
|
||||||
|
|
||||||
|
## Qué instala y configura
|
||||||
|
|
||||||
|
1. Instala `realmd`, `adcli`, SSSD, Kerberos y los módulos NSS/PAM adecuados para la familia de distribución.
|
||||||
|
2. Comprueba el registro DNS SRV de Active Directory y sincronización de hora ya existente.
|
||||||
|
3. Establece el nombre de host `NOMBRE.lci.lasalle.mx` antes de crear la cuenta de equipo.
|
||||||
|
4. Une el equipo con `adcli` en `OU=Laboratorio`.
|
||||||
|
5. Activa SSSD, creación de directorio personal mediante PAM y valida la contraseña de la cuenta de equipo con `adcli testjoin`. En distribuciones que habilitan los respondedores NSS/PAM de SSSD tanto en `sssd.conf` como mediante sockets de systemd, desactiva los sockets duplicados para evitar una colisión al arrancar.
|
||||||
|
6. Cuando se proporcionó la NIC privada, activa actualizaciones DNS dinámicas de SSSD en esa interfaz.
|
||||||
|
7. Con `--enable-ssh`, instala y habilita OpenSSH y abre únicamente el servicio SSH cuando el firewall local ya está activo.
|
||||||
|
8. Registra `lightdm` y `cinnamon-screensaver` como inicios interactivos ante las políticas GPO de SSSD. En equipos con LightDM oculta la lista de cuentas y conserva únicamente el ingreso manual: usuario y contraseña, necesario para el primer acceso de un usuario del dominio.
|
||||||
|
9. Con `--enable-hyperv-enhanced-session`, configura XRDP sobre Hyper-V sockets para que VMConnect pueda usar **Sesión mejorada**, repara certificados incompletos, registra `xrdp-sesman` en el mismo mapa interactivo y valida ambos servicios XRDP.
|
||||||
|
10. Instala el fondo azul, las fuentes Indivisa y un autoinicio compatible con Cinnamon, GNOME y XFCE. En cada sesión gráfica genera el saludo con el nombre del usuario y la ubicación/OU del equipo obtenidas de AD.
|
||||||
|
11. Instala RustDesk 1.4.9 desde el paquete oficial comprobado, configura exclusivamente el servidor institucional y crea una contraseña aleatoria de acceso desatendido. También fuerza el greeter de GDM o SDDM a usar X11, porque RustDesk no admite controlar la pantalla de acceso bajo Wayland; LightDM ya usa X11. La contraseña no se muestra en Linux: viaja cifrada con la clave pública del controlador y éste la conserva mediante su inventario protegido. Si el enrolador cambia el backend gráfico, reinicia el equipo al finalizar para activarlo.
|
||||||
|
|
||||||
|
El objeto de equipo aparece como `NOMBRE` en `OU=Laboratorio`. SSSD registra su registro A cuando la actualización DNS dinámica está activada.
|
||||||
|
|
||||||
|
## Inicio de sesión de dominio
|
||||||
|
|
||||||
|
Después de la unión se acepta directamente la clave institucional corta:
|
||||||
|
|
||||||
|
```text
|
||||||
|
al201428
|
||||||
|
```
|
||||||
|
|
||||||
|
El formato UPN explícito también permanece disponible:
|
||||||
|
|
||||||
|
```text
|
||||||
|
usuario@lci.lasalle.mx
|
||||||
|
```
|
||||||
|
|
||||||
|
En Linux Mint aparece únicamente el ingreso manual. Escribe la clave corta y
|
||||||
|
su contraseña; no se muestra una lista ni mosaicos de cuentas locales o del
|
||||||
|
dominio.
|
||||||
|
|
||||||
|
La primera sesión crea `/home/usuario@lci.lasalle.mx`. El valor predeterminado de SSSD conserva credenciales para desconexiones breves de la red; las contraseñas no son administradas ni almacenadas por el Auth Broker.
|
||||||
|
|
||||||
|
Para limitar quién puede iniciar sesión, incluye un grupo de AD:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash ./Enroll-SguLinuxDomainClient.sh \
|
||||||
|
--domain-controller 192.168.50.10 \
|
||||||
|
--allow-group 'SG-Laboratorio-Linux-Users'
|
||||||
|
```
|
||||||
|
|
||||||
|
Ese modo ejecuta `realm deny --all` seguido de `realm permit --groups`; crea y administra el grupo antes de usarlo.
|
||||||
|
|
||||||
|
## Verificación y salida controlada
|
||||||
|
|
||||||
|
```bash
|
||||||
|
realm list
|
||||||
|
sudo adcli testjoin --domain=lci.lasalle.mx
|
||||||
|
getent passwd 'usuario@lci.lasalle.mx'
|
||||||
|
sudo sssctl domain-status lci.lasalle.mx
|
||||||
|
sudo sssctl user-checks usuario -a acct -s lightdm
|
||||||
|
sudo sssctl user-checks usuario -a acct -s cinnamon-screensaver
|
||||||
|
systemctl is-active rustdesk
|
||||||
|
sudo cat /var/lib/sgu/rustdesk/device.json
|
||||||
|
```
|
||||||
|
|
||||||
|
En el controlador de dominio, el mismo inventario protegido usado por Windows
|
||||||
|
muestra el ID de un cliente Linux y, únicamente bajo solicitud explícita de un
|
||||||
|
administrador, su contraseña de RustDesk:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& C:\ProgramData\SGU\RustDesk\Get-SguRustDeskDevice.ps1
|
||||||
|
& C:\ProgramData\SGU\RustDesk\Get-SguRustDeskDevice.ps1 `
|
||||||
|
-ComputerName ALEX-LMINT -RevealPassword
|
||||||
|
```
|
||||||
|
|
||||||
|
Usa `--disable-rustdesk` sólo cuando una estación deba quedar expresamente sin
|
||||||
|
soporte remoto. `--rustdesk-registration-share` permite especificar el UNC del
|
||||||
|
controlador cuando una topología de DNS no puede resolver automáticamente el
|
||||||
|
controlador de dominio.
|
||||||
|
|
||||||
|
Para sacar un equipo del dominio de forma explícita:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo realm leave lci.lasalle.mx
|
||||||
|
```
|
||||||
|
|
||||||
|
Esta última acción elimina la relación de confianza local; debe ejecutarse sólo durante baja o reconstrucción del equipo.
|
||||||
|
|
||||||
|
La personalización gráfica es deliberadamente opcional: si ImageMagick, LDAP o
|
||||||
|
la API del escritorio fallan, no revierte la unión ni impide iniciar sesión. Consulta
|
||||||
|
los detalles y las reglas de degradación en [welcome-wallpaper.md](welcome-wallpaper.md).
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Monitoreo y registros de uso
|
||||||
|
|
||||||
|
El dominio usa **Windows Event Forwarding (WEF)** y el servicio nativo
|
||||||
|
**Windows Event Collector (Wecsvc)**. No instala un agente de telemetría y no
|
||||||
|
registra contraseñas, contenido de escritorio ni páginas visitadas.
|
||||||
|
|
||||||
|
## Datos recopilados
|
||||||
|
|
||||||
|
La suscripción `SGU-Lab-Monitoring` recibe desde los equipos de
|
||||||
|
`OU=Laboratorio`:
|
||||||
|
|
||||||
|
- inicios de sesión correctos y fallidos (`4624` y `4625`);
|
||||||
|
- cierre de sesión, desconexión y reconexión (`4634`, `4647`, `4778`, `4779`);
|
||||||
|
- arranque, apagado, reinicio y apagado inesperado (`12`, `13`, `41`, `1074`,
|
||||||
|
`6005`, `6006`, `6008`).
|
||||||
|
|
||||||
|
Con el identificador de sesión de Windows se obtiene quién usó qué equipo,
|
||||||
|
hora de entrada, hora de salida y duración. Los eventos fallidos conservan el
|
||||||
|
estado de Windows, pero nunca la contraseña introducida.
|
||||||
|
|
||||||
|
El broker escribe un registro separado de Windows llamado `SGU Auth Broker`.
|
||||||
|
Cada solicitud válida queda correlacionada mediante usuario institucional,
|
||||||
|
rol, `TraceId`, resultado y tiempo total. Los Event ID estables distinguen:
|
||||||
|
|
||||||
|
- `1000` autorización completada; `1001` credenciales rechazadas; `1002`
|
||||||
|
servicio no disponible; `1003` solicitud inválida;
|
||||||
|
- `1100` SGU aceptó la autenticación; `1101` timeout; `1102` fallo de red/DNS;
|
||||||
|
- `1200` enriquecimiento completado y cantidad de campos; `1201` HTML sin los
|
||||||
|
IDs admitidos; `1202` timeout; `1203` excepción; `1204` página opcional no
|
||||||
|
disponible;
|
||||||
|
- `1300` fallo de sincronización AD; `1301` metadatos opcionales no aplicados;
|
||||||
|
`1302` membresía RDP opcional no aplicada; `1303` cuenta agregada a su grupo
|
||||||
|
institucional de Alumnos, Administrativos o Docentes.
|
||||||
|
|
||||||
|
No se almacena HTML, contraseña, hash de contraseña ni contenido de la
|
||||||
|
respuesta SGU.
|
||||||
|
|
||||||
|
## Retención
|
||||||
|
|
||||||
|
`ForwardedEvents` tiene un límite de 512 MB. La tarea
|
||||||
|
`SGU-Monitoring-Retention` lo archiva diariamente en:
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\ProgramData\SGU\Monitoring\Archive
|
||||||
|
```
|
||||||
|
|
||||||
|
El mismo ciclo archiva `SGU Auth Broker` en `Archive\Broker`. Solo elimina
|
||||||
|
archivos `.evtx` cuya antigüedad supera **183 días**. La tarea
|
||||||
|
`SGU-Monitoring-Inventory` actualiza cada cinco minutos el inventario en:
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\ProgramData\SGU\Monitoring\Reports\machine-status.json
|
||||||
|
C:\ProgramData\SGU\Monitoring\Reports\machine-status.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
`Encendida` significa que WinRM respondió en TCP 5985. `Apagada o inaccesible`
|
||||||
|
también puede significar que el equipo está arrancando, perdió la red o tiene
|
||||||
|
el puerto bloqueado; Active Directory por sí solo no conoce el estado eléctrico
|
||||||
|
instantáneo de un PC.
|
||||||
|
|
||||||
|
## Consultas
|
||||||
|
|
||||||
|
En el servidor, como administrador:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Estado actual de las máquinas
|
||||||
|
Get-Content 'C:\ProgramData\SGU\Monitoring\Reports\machine-status.json' -Raw |
|
||||||
|
ConvertFrom-Json | Format-Table ComputerName,Status,IPv4Address,LastDomainLogon
|
||||||
|
|
||||||
|
# Uso de los últimos seis meses
|
||||||
|
& 'C:\ProgramData\SGU\Monitoring\Get-SguUsageReport.ps1' |
|
||||||
|
Format-Table User,Computer,StartedAt,EndedAt,DurationMinutes,Result
|
||||||
|
|
||||||
|
# Un usuario o una máquina
|
||||||
|
& 'C:\ProgramData\SGU\Monitoring\Get-SguUsageReport.ps1' -UserName AL201428
|
||||||
|
& 'C:\ProgramData\SGU\Monitoring\Get-SguUsageReport.ps1' -ComputerName LCI-W11-01
|
||||||
|
|
||||||
|
# Diagnóstico del broker; admite -UserName, -Level, -EventId y -Text
|
||||||
|
& 'C:\ProgramData\SGU\Monitoring\Get-SguBrokerLog.ps1' -UserName AL201428 |
|
||||||
|
Format-Table TimeCreated,Level,EventId,EventName,Message -Wrap
|
||||||
|
& 'C:\ProgramData\SGU\Monitoring\Get-SguBrokerLog.ps1' -Level Warning
|
||||||
|
|
||||||
|
# Exportación
|
||||||
|
& 'C:\ProgramData\SGU\Monitoring\Get-SguUsageReport.ps1' `
|
||||||
|
-OutputCsv 'C:\ProgramData\SGU\Monitoring\Reports\usage.csv'
|
||||||
|
```
|
||||||
|
|
||||||
|
Para revisar la salud del colector:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-Service Wecsvc
|
||||||
|
wecutil enum-subscription
|
||||||
|
wecutil get-subscriptionruntimestatus SGU-Lab-Monitoring
|
||||||
|
Get-WinEvent -LogName ForwardedEvents -MaxEvents 20
|
||||||
|
Get-WinEvent -LogName 'SGU Auth Broker' -MaxEvents 20
|
||||||
|
Get-ScheduledTask -TaskName 'SGU-Monitoring-*'
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Windows Server 2025:** no consultes `ForwardedEvents` mediante
|
||||||
|
> `-FilterHashtable` ni XPath. Algunas compilaciones tienen una regresión que
|
||||||
|
> termina el servicio Windows Event Log (`wevtsvc.dll`, `0xc0000420`). El
|
||||||
|
> reporte incluido lee el canal sin consulta estructurada y aplica los filtros
|
||||||
|
> en memoria.
|
||||||
|
|
||||||
|
El bootstrap del servidor crea el colector, la suscripción y las tareas. El GPO
|
||||||
|
`SGU - Windows client experience` publica el Subscription Manager por FQDN. El
|
||||||
|
bootstrap y la reparación diaria del cliente habilitan las subcategorías de
|
||||||
|
auditoría y el acceso de `NETWORK SERVICE` al registro Security.
|
||||||
|
|
||||||
|
La suscripción usa `ContentFormat=Events`: conserva el XML original que emplea
|
||||||
|
el reporte y evita que cada cliente tenga que renderizar texto localizado antes
|
||||||
|
de enviarlo.
|
||||||
|
|
||||||
|
Referencias de Microsoft:
|
||||||
|
|
||||||
|
- <https://learn.microsoft.com/en-us/windows/win32/wec/setting-up-a-source-initiated-subscription>
|
||||||
|
- <https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/wecutil>
|
||||||
|
- <https://learn.microsoft.com/en-us/windows/security/threat-protection/auditing/event-4624>
|
||||||
|
- <https://learn.microsoft.com/en-us/windows/security/threat-protection/auditing/event-4634>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# RustDesk autoalojado y acceso remoto administrado
|
||||||
|
|
||||||
|
El bootstrap del controlador de dominio instala un servidor RustDesk OSS
|
||||||
|
autoalojado y el bootstrap de cada cliente Windows inscrito instala el agente
|
||||||
|
RustDesk administrado. Esto permite administrar el propio controlador y cada
|
||||||
|
cliente del laboratorio sin depender de los servidores públicos de RustDesk.
|
||||||
|
|
||||||
|
## Componentes y red
|
||||||
|
|
||||||
|
El controlador inicia ambos componentes bajo `SYSTEM` mediante las tareas
|
||||||
|
programadas `SGU-RustDesk-hbbs` y `SGU-RustDesk-hbbr`:
|
||||||
|
|
||||||
|
| Componente | Función | Puerto entrante |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `hbbs` | ID/rendezvous y prueba NAT | TCP 21115-21116 y UDP 21116 |
|
||||||
|
| `hbbr` | Relay para sesiones que no pueden ser directas | TCP 21117 |
|
||||||
|
|
||||||
|
Las reglas se llaman **SGU RustDesk** y sólo aceptan la subred privada que se
|
||||||
|
indicó al bootstrap del servidor. No se habilitan el servidor web ni los puertos
|
||||||
|
21118/21119. Los clientes y el servidor necesitan salida HTTPS a GitHub sólo
|
||||||
|
durante una primera instalación o actualización, para descargar el binario
|
||||||
|
verificado por SHA-256.
|
||||||
|
|
||||||
|
El nombre interno usado por los clientes es `rustdesk.lci.lasalle.mx`; el
|
||||||
|
bootstrap del DC mantiene su registro A en DNS apuntando a la IP fija del
|
||||||
|
controlador.
|
||||||
|
|
||||||
|
## Alta automática de un equipo Windows
|
||||||
|
|
||||||
|
Al finalizar `Invoke-SguClientBootstrap.ps1`, antes de considerar válido el
|
||||||
|
enrolamiento, el flujo:
|
||||||
|
|
||||||
|
1. lee la clave pública del servidor a través de la sesión autenticada con el
|
||||||
|
DC;
|
||||||
|
2. instala RustDesk desde el MSI oficial, comprobando el SHA-256 fijado;
|
||||||
|
3. instala y arranca el servicio `RustDesk` como automático;
|
||||||
|
4. configura ID, relay y clave pública del servidor privado en el contexto del
|
||||||
|
servicio y para perfiles nuevos;
|
||||||
|
5. crea una contraseña única de acceso desatendido, cifrada con DPAPI local y
|
||||||
|
protegida por ACL para `SYSTEM` y administradores locales;
|
||||||
|
6. registra únicamente el ID y la contraseña cifrada en el inventario del DC.
|
||||||
|
|
||||||
|
La contraseña no se imprime, no se pone en el manifiesto y no se añade a los
|
||||||
|
logs. El inventario del servidor usa DPAPI de la máquina y está limitado por ACL
|
||||||
|
a `SYSTEM` y administradores del servidor.
|
||||||
|
|
||||||
|
El mismo flujo se aplica al DC, por lo que también se puede administrar de forma
|
||||||
|
remota. Reejecutar el bootstrap mantiene el ID y la contraseña existentes, y
|
||||||
|
vuelve a validar configuración, tareas, servicio y conectividad sin crear otro
|
||||||
|
registro.
|
||||||
|
|
||||||
|
## Verificación y operación
|
||||||
|
|
||||||
|
En el DC, como administrador:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-ScheduledTask SGU-RustDesk-hbbs,SGU-RustDesk-hbbr |
|
||||||
|
Select-Object TaskName,State
|
||||||
|
Get-NetTCPConnection -State Listen -LocalPort 21116,21117
|
||||||
|
Get-Content C:\ProgramData\SGU\RustDesk\server.json
|
||||||
|
& C:\ProgramData\SGU\RustDesk\Get-SguRustDeskDevice.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
El último comando muestra los nombres, IDs y fecha de alta, sin contraseñas. Si
|
||||||
|
un administrador necesita recuperar una contraseña para conectarse desde el
|
||||||
|
cliente controlador de RustDesk, puede hacerlo explícitamente en la consola del
|
||||||
|
DC:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& C:\ProgramData\SGU\RustDesk\Get-SguRustDeskDevice.ps1 `
|
||||||
|
-ComputerName LCI-01 -RevealPassword
|
||||||
|
```
|
||||||
|
|
||||||
|
Trata esa salida como una credencial administrativa: no la pegues en tickets,
|
||||||
|
capturas ni registros. En RustDesk, conecta usando el ID inventariado y el modo
|
||||||
|
de autenticación por contraseña permanente.
|
||||||
|
|
||||||
|
En un cliente, los indicadores locales son:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-Service RustDesk
|
||||||
|
Get-Content C:\ProgramData\SGU\RustDesk\Client\device.json
|
||||||
|
Test-NetConnection rustdesk.lci.lasalle.mx -Port 21116
|
||||||
|
```
|
||||||
|
|
||||||
|
Si un agente deja de funcionar, se puede repetir el bootstrap del cliente. El
|
||||||
|
guardián de enrolamiento también repara la configuración de RustDesk al inicio
|
||||||
|
cuando la información del servidor sigue presente en su estado de enrolamiento.
|
||||||
|
|
||||||
|
## Límites operativos
|
||||||
|
|
||||||
|
Este alcance automatiza el cliente Windows entregado por
|
||||||
|
`Invoke-SguClientBootstrap.ps1`. El bootstrap Linux conserva su inicio PAM/SSSD
|
||||||
|
independiente y usa un flujo propio de inventario: autentica con la cuenta de
|
||||||
|
equipo Kerberos, cifra la contraseña de RustDesk para el controlador y recibe
|
||||||
|
su confirmación desde la cola protegida. No reutiliza ni expone contraseñas de
|
||||||
|
Windows.
|
||||||
+28
-12
@@ -1,5 +1,14 @@
|
|||||||
# Security model
|
# Security model
|
||||||
|
|
||||||
|
## Public Azure deployment
|
||||||
|
|
||||||
|
Owning a public Azure IP does not make the domain controller an Internet-facing
|
||||||
|
directory service. The supported cloud topology exposes no AD DS, DNS, SMB,
|
||||||
|
RPC, WinRM, broker, monitoring, or RustDesk port publicly. Hyper-V and later
|
||||||
|
physical Windows clients enter the VNet through certificate-authenticated Azure
|
||||||
|
VPN Gateway P2S; the Azure NSG and Windows firewall accept the P2S pool and the
|
||||||
|
private VNet only. See [azure-vpn-deployment.md](azure-vpn-deployment.md).
|
||||||
|
|
||||||
## Password handling
|
## Password handling
|
||||||
|
|
||||||
- The Credential Provider receives the password in Lithnet's secure password
|
- The Credential Provider receives the password in Lithnet's secure password
|
||||||
@@ -33,26 +42,33 @@
|
|||||||
- Administrative enrichment first verifies the employee number and reads
|
- Administrative enrichment first verifies the employee number and reads
|
||||||
employee type/status, email, job title, and department from the incident
|
employee type/status, email, job title, and department from the incident
|
||||||
overview. Only after that match, it reads given names and paternal/maternal
|
overview. Only after that match, it reads given names and paternal/maternal
|
||||||
surnames from the personal page plus street, exterior/interior number,
|
surnames and the normalized `Male`/`Female` value from the personal page plus
|
||||||
neighborhood, locality, state, and postal code from the location page.
|
street, exterior/interior number, neighborhood, locality, state, and postal
|
||||||
- Administrative birth date, RFC, CURP, sex, blood type, marital status,
|
code from the location page. AD stores only the controlled `SGU-Gender` line,
|
||||||
|
not the original HTML field.
|
||||||
|
- Administrative birth date, RFC, CURP, blood type, marital status,
|
||||||
nationality, telephone, email lists, housing type, and emergency-contact
|
nationality, telephone, email lists, housing type, and emergency-contact
|
||||||
fields are ignored.
|
fields are ignored.
|
||||||
- Student enrichment reads only the matching student number, given names,
|
- Student enrichment reads only the matching student number, given names,
|
||||||
paternal/maternal surnames, email, career, street, neighborhood,
|
paternal/maternal surnames, email, career, street, neighborhood,
|
||||||
city/municipality, state, and postal code from known element IDs.
|
city/municipality, state, postal code, and normalized sex from known element
|
||||||
- Student CURP, birth date, sex, blood type, marital status, telephone, mobile,
|
IDs.
|
||||||
|
- Student CURP, birth date, blood type, marital status, telephone, mobile,
|
||||||
guardian, medical, financial, and academic-history values are ignored.
|
guardian, medical, financial, and academic-history values are ignored.
|
||||||
- Professor enrichment keeps the menu display name as its base and optionally
|
- Professor enrichment keeps the menu display name as its base. From the payroll
|
||||||
reads only the same name and postal-address element IDs used by staff pages.
|
consultation header it reads only a matching employee number, name, email,
|
||||||
A missing professor route or element never makes authentication fail.
|
employee type/status, job title, and the optional department field. It then
|
||||||
- Incident details, calendars, photographs, manager names, and manager positions
|
optionally reads the same structured-name and postal-address element IDs used
|
||||||
are deliberately ignored.
|
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
|
- The employee or student number must match the authenticated `AD` or `AL` key
|
||||||
before role-specific metadata is synchronized. The two supplemental
|
before role-specific metadata is synchronized. The two supplemental
|
||||||
administrative pages are never requested unless the incident page supplied
|
administrative pages are never requested unless the incident page supplied
|
||||||
the matching employee number. Professor supplemental data comes from the
|
the matching employee number. Professor payroll metadata independently
|
||||||
same fresh, request-scoped NTLM session as its menu fallback.
|
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
|
- If SGU changes its HTML, authentication and exact-password synchronization
|
||||||
continue without enrichment; existing AD metadata is not erased.
|
continue without enrichment; existing AD metadata is not erased.
|
||||||
- Slow profile pages cannot change an accepted credential into a rejection. The
|
- Slow profile pages cannot change an accepted credential into a rejection. The
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Fondo de bienvenida personalizado
|
||||||
|
|
||||||
|
El enrolamiento instala un fondo base azul, las familias `Indivisa Text Sans` y
|
||||||
|
`Indivisa Text Serif`, y un generador local. La GPO de equipos
|
||||||
|
`SGU - Windows client experience` ejecuta el generador al abrir cada sesión y
|
||||||
|
mantiene el fondo base en la pantalla de bloqueo.
|
||||||
|
|
||||||
|
Windows no conoce todavía la identidad que se autenticará mientras muestra la
|
||||||
|
pantalla previa al inicio de sesión. Por ello, esa pantalla utiliza el fondo base
|
||||||
|
sin datos personales y la composición individual se genera inmediatamente
|
||||||
|
después de autenticar, antes de que el usuario empiece a trabajar en el escritorio.
|
||||||
|
|
||||||
|
## Datos y degradación controlada
|
||||||
|
|
||||||
|
El generador consulta Active Directory con la identidad ya autenticada y sin
|
||||||
|
guardar credenciales. Obtiene:
|
||||||
|
|
||||||
|
- `displayName` del usuario; si falta, utiliza `sAMAccountName`.
|
||||||
|
- La línea administrada `SGU-Gender: Male|Female` del atributo `info`; el Auth
|
||||||
|
Broker la obtiene del SGU y conserva cualquier otra nota que ya exista.
|
||||||
|
- `location` del objeto de equipo.
|
||||||
|
- La OU padre inmediata a partir de `distinguishedName`.
|
||||||
|
|
||||||
|
El saludo usa `Bienvenido/ubicado` para `Male` y `Bienvenida/ubicada` para
|
||||||
|
`Female`. Cuando el enriquecimiento no produjo este dato, utiliza la redacción
|
||||||
|
neutral `Te damos la bienvenida` y `Ubicación:`. El texto secundario sigue estas
|
||||||
|
reglas:
|
||||||
|
|
||||||
|
1. Con `location` y OU: `Estás ubicado en la Sala de Inmersión del Centro de Experiencia Digital.`
|
||||||
|
2. Con sólo uno de los datos: muestra únicamente el dato disponible.
|
||||||
|
3. Sin ambos: el texto adaptado `Bienvenido/Bienvenida al Laboratorio...`; sin
|
||||||
|
sexo disponible, la forma neutral `Acceso al Laboratorio de Cómputo de Ingeniería.`
|
||||||
|
|
||||||
|
La ausencia de AD, de un atributo o de una tipografía nunca bloquea la sesión.
|
||||||
|
Los errores de generación se registran en
|
||||||
|
`%LOCALAPPDATA%\SGU\Logs\welcome-wallpaper.log`.
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
|
||||||
|
El paquete de cliente copia los recursos a `C:\ProgramData\SGU\Branding`. La
|
||||||
|
GPO crea el valor de equipo `SGUWelcomeWallpaper` bajo
|
||||||
|
`HKLM\Software\Microsoft\Windows\CurrentVersion\Run`; por tanto, se ejecuta en
|
||||||
|
el contexto de cada usuario y puede leer sus datos de AD. El resultado se guarda
|
||||||
|
en `%LOCALAPPDATA%\SGU\Wallpapers` y se aplica con la API nativa de Windows.
|
||||||
|
|
||||||
|
La antigua directiva estática de escritorio se elimina para que no sobrescriba
|
||||||
|
el archivo individual. La personalización sigue estando gobernada por dominio:
|
||||||
|
el comando de inicio y la pantalla de bloqueo pertenecen a la GPO de equipos.
|
||||||
|
|
||||||
|
## Linux
|
||||||
|
|
||||||
|
El paquete Linux instala el generador en
|
||||||
|
`/usr/local/lib/sgu-welcome-wallpaper` y registra
|
||||||
|
`/etc/xdg/autostart/sgu-welcome-wallpaper.desktop`. Utiliza el ticket Kerberos
|
||||||
|
creado por SSSD para consultar el objeto de equipo mediante LDAP; nunca contiene
|
||||||
|
una contraseña de enlace.
|
||||||
|
|
||||||
|
Se admiten Cinnamon, GNOME y XFCE. La composición requiere ImageMagick; si la
|
||||||
|
dependencia o la API del escritorio no está disponible, el enrolamiento y el
|
||||||
|
inicio de sesión continúan normalmente y se escribe un diagnóstico en
|
||||||
|
`~/.local/state/sgu/welcome-wallpaper.log`.
|
||||||
|
|
||||||
|
## Tipografía
|
||||||
|
|
||||||
|
Los archivos OTF necesarios viajan dentro de cada paquete y se cargan en memoria
|
||||||
|
para renderizar el fondo; no se sustituyen fuentes del sistema. Se usa Sans en el
|
||||||
|
saludo y la ubicación, y Serif Bold Italic en el nombre. Si los archivos no
|
||||||
|
pueden cargarse, Windows usa Segoe UI/Georgia y Linux usa DejaVu Sans/Serif.
|
||||||
@@ -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
|
telemetría, ubicación y **Hi / Preparing Windows** antes de que un usuario SGU
|
||||||
entre por primera vez.
|
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:
|
Microsoft documenta este derecho en:
|
||||||
<https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-userrights#allowlogonthroughremotedesktop>
|
<https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-userrights#allowlogonthroughremotedesktop>
|
||||||
y PowerShell Remoting en:
|
y PowerShell Remoting en:
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
targetScope = 'resourceGroup'
|
||||||
|
|
||||||
|
@description('Short prefix used for every Azure resource.')
|
||||||
|
@minLength(3)
|
||||||
|
@maxLength(18)
|
||||||
|
param deploymentPrefix string = 'sgu-lab'
|
||||||
|
|
||||||
|
@description('Azure region for the virtual network, gateway, and VM.')
|
||||||
|
param location string = resourceGroup().location
|
||||||
|
|
||||||
|
@description('Windows Server VM administrator name. This must not be Administrator.')
|
||||||
|
@minLength(1)
|
||||||
|
@maxLength(20)
|
||||||
|
param administratorUsername string
|
||||||
|
|
||||||
|
@secure()
|
||||||
|
@description('Windows Server VM administrator password.')
|
||||||
|
param administratorPassword string
|
||||||
|
|
||||||
|
@description('Windows Server computer name; Active Directory limits this to 15 characters.')
|
||||||
|
@minLength(1)
|
||||||
|
@maxLength(15)
|
||||||
|
param computerName string = 'SGU-DC01'
|
||||||
|
|
||||||
|
@description('VM size for the Windows Server 2025 domain controller.')
|
||||||
|
param vmSize string = 'Standard_D2s_v5'
|
||||||
|
|
||||||
|
@description('Address space assigned to the Azure virtual network.')
|
||||||
|
param virtualNetworkAddressPrefix string = '10.77.0.0/16'
|
||||||
|
|
||||||
|
@description('Subnet that contains the domain controller.')
|
||||||
|
param domainControllerSubnetPrefix string = '10.77.0.0/24'
|
||||||
|
|
||||||
|
@description('Reserved Azure VPN Gateway subnet. Use /27 or larger.')
|
||||||
|
param gatewaySubnetPrefix string = '10.77.255.0/27'
|
||||||
|
|
||||||
|
@description('Static private IP reserved on the Azure NIC for AD DS and DNS.')
|
||||||
|
param domainControllerPrivateIp string = '10.77.0.4'
|
||||||
|
|
||||||
|
@description('Point-to-site client pool. It must not overlap the VNet or local Hyper-V networks.')
|
||||||
|
param vpnClientAddressPoolPrefix string = '172.30.0.0/24'
|
||||||
|
|
||||||
|
@description('Name presented for the trusted P2S root certificate.')
|
||||||
|
param p2sRootCertificateName string = 'SGU-P2S-Root'
|
||||||
|
|
||||||
|
@description('Base64 DER bytes of the trusted P2S root certificate, without PEM markers.')
|
||||||
|
param p2sRootCertificateData string
|
||||||
|
|
||||||
|
@description('Optional public CIDR allowed to RDP to the VM public IP, for example 203.0.113.10/32. Leave empty to expose no management port.')
|
||||||
|
param administratorSourceAddressPrefix string = ''
|
||||||
|
|
||||||
|
var virtualNetworkName = '${deploymentPrefix}-vnet'
|
||||||
|
var domainControllerSubnetName = 'DomainControllers'
|
||||||
|
var gatewaySubnetName = 'GatewaySubnet'
|
||||||
|
var networkSecurityGroupName = '${deploymentPrefix}-dc-nsg'
|
||||||
|
var domainControllerPublicIpName = '${deploymentPrefix}-dc-pip'
|
||||||
|
var gatewayPublicIpName = '${deploymentPrefix}-vpngw-pip'
|
||||||
|
var networkInterfaceName = '${deploymentPrefix}-dc-nic'
|
||||||
|
var virtualMachineName = '${deploymentPrefix}-dc'
|
||||||
|
var virtualNetworkGatewayName = '${deploymentPrefix}-vpngw'
|
||||||
|
|
||||||
|
resource networkSecurityGroup 'Microsoft.Network/networkSecurityGroups@2024-05-01' = {
|
||||||
|
name: networkSecurityGroupName
|
||||||
|
location: location
|
||||||
|
properties: {
|
||||||
|
securityRules: concat([
|
||||||
|
{
|
||||||
|
name: 'Allow-SGU-P2S-clients'
|
||||||
|
properties: {
|
||||||
|
priority: 100
|
||||||
|
access: 'Allow'
|
||||||
|
direction: 'Inbound'
|
||||||
|
protocol: '*'
|
||||||
|
sourcePortRange: '*'
|
||||||
|
destinationPortRange: '*'
|
||||||
|
sourceAddressPrefix: vpnClientAddressPoolPrefix
|
||||||
|
destinationAddressPrefix: domainControllerPrivateIp
|
||||||
|
description: 'AD, DNS, broker, monitoring, and RustDesk are reachable only through the authenticated P2S address pool.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
], empty(administratorSourceAddressPrefix) ? [] : [
|
||||||
|
{
|
||||||
|
name: 'Allow-RDP-from-administrator'
|
||||||
|
properties: {
|
||||||
|
priority: 110
|
||||||
|
access: 'Allow'
|
||||||
|
direction: 'Inbound'
|
||||||
|
protocol: 'Tcp'
|
||||||
|
sourcePortRange: '*'
|
||||||
|
destinationPortRange: '3389'
|
||||||
|
sourceAddressPrefix: administratorSourceAddressPrefix
|
||||||
|
destinationAddressPrefix: domainControllerPrivateIp
|
||||||
|
description: 'Optional bootstrap-only RDP access from one explicitly supplied public CIDR.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' = {
|
||||||
|
name: virtualNetworkName
|
||||||
|
location: location
|
||||||
|
properties: {
|
||||||
|
addressSpace: {
|
||||||
|
addressPrefixes: [
|
||||||
|
virtualNetworkAddressPrefix
|
||||||
|
]
|
||||||
|
}
|
||||||
|
subnets: [
|
||||||
|
{
|
||||||
|
name: domainControllerSubnetName
|
||||||
|
properties: {
|
||||||
|
addressPrefix: domainControllerSubnetPrefix
|
||||||
|
networkSecurityGroup: {
|
||||||
|
id: networkSecurityGroup.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name: gatewaySubnetName
|
||||||
|
properties: {
|
||||||
|
addressPrefix: gatewaySubnetPrefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource domainControllerPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = {
|
||||||
|
name: domainControllerPublicIpName
|
||||||
|
location: location
|
||||||
|
sku: {
|
||||||
|
name: 'Standard'
|
||||||
|
}
|
||||||
|
properties: {
|
||||||
|
publicIPAllocationMethod: 'Static'
|
||||||
|
publicIPAddressVersion: 'IPv4'
|
||||||
|
idleTimeoutInMinutes: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource gatewayPublicIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = {
|
||||||
|
name: gatewayPublicIpName
|
||||||
|
location: location
|
||||||
|
sku: {
|
||||||
|
name: 'Standard'
|
||||||
|
}
|
||||||
|
properties: {
|
||||||
|
publicIPAllocationMethod: 'Static'
|
||||||
|
publicIPAddressVersion: 'IPv4'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource networkInterface 'Microsoft.Network/networkInterfaces@2024-05-01' = {
|
||||||
|
name: networkInterfaceName
|
||||||
|
location: location
|
||||||
|
properties: {
|
||||||
|
enableAcceleratedNetworking: true
|
||||||
|
dnsSettings: {
|
||||||
|
dnsServers: [
|
||||||
|
domainControllerPrivateIp
|
||||||
|
]
|
||||||
|
}
|
||||||
|
ipConfigurations: [
|
||||||
|
{
|
||||||
|
name: 'ipconfig1'
|
||||||
|
properties: {
|
||||||
|
privateIPAllocationMethod: 'Static'
|
||||||
|
privateIPAddressVersion: 'IPv4'
|
||||||
|
privateIPAddress: domainControllerPrivateIp
|
||||||
|
subnet: {
|
||||||
|
id: resourceId('Microsoft.Network/virtualNetworks/subnets', virtualNetworkName, domainControllerSubnetName)
|
||||||
|
}
|
||||||
|
publicIPAddress: {
|
||||||
|
id: domainControllerPublicIp.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
dependsOn: [
|
||||||
|
virtualNetwork
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource virtualMachine 'Microsoft.Compute/virtualMachines@2024-07-01' = {
|
||||||
|
name: virtualMachineName
|
||||||
|
location: location
|
||||||
|
identity: {
|
||||||
|
type: 'SystemAssigned'
|
||||||
|
}
|
||||||
|
properties: {
|
||||||
|
hardwareProfile: {
|
||||||
|
vmSize: vmSize
|
||||||
|
}
|
||||||
|
securityProfile: {
|
||||||
|
securityType: 'TrustedLaunch'
|
||||||
|
uefiSettings: {
|
||||||
|
secureBootEnabled: true
|
||||||
|
vTpmEnabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
osProfile: {
|
||||||
|
computerName: computerName
|
||||||
|
adminUsername: administratorUsername
|
||||||
|
adminPassword: administratorPassword
|
||||||
|
windowsConfiguration: {
|
||||||
|
provisionVMAgent: true
|
||||||
|
enableAutomaticUpdates: true
|
||||||
|
patchSettings: {
|
||||||
|
patchMode: 'AutomaticByPlatform'
|
||||||
|
assessmentMode: 'AutomaticByPlatform'
|
||||||
|
enableHotpatching: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
storageProfile: {
|
||||||
|
imageReference: {
|
||||||
|
publisher: 'MicrosoftWindowsServer'
|
||||||
|
offer: 'WindowsServer'
|
||||||
|
sku: '2025-datacenter-azure-edition'
|
||||||
|
version: 'latest'
|
||||||
|
}
|
||||||
|
osDisk: {
|
||||||
|
createOption: 'FromImage'
|
||||||
|
managedDisk: {
|
||||||
|
storageAccountType: 'Premium_LRS'
|
||||||
|
}
|
||||||
|
deleteOption: 'Delete'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
networkProfile: {
|
||||||
|
networkInterfaces: [
|
||||||
|
{
|
||||||
|
id: networkInterface.id
|
||||||
|
properties: {
|
||||||
|
primary: true
|
||||||
|
deleteOption: 'Delete'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
diagnosticsProfile: {
|
||||||
|
bootDiagnostics: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource virtualNetworkGateway 'Microsoft.Network/virtualNetworkGateways@2024-05-01' = {
|
||||||
|
name: virtualNetworkGatewayName
|
||||||
|
location: location
|
||||||
|
properties: {
|
||||||
|
gatewayType: 'Vpn'
|
||||||
|
vpnType: 'RouteBased'
|
||||||
|
activeActive: false
|
||||||
|
enableBgp: false
|
||||||
|
ipConfigurations: [
|
||||||
|
{
|
||||||
|
name: 'gateway-ipconfig'
|
||||||
|
properties: {
|
||||||
|
privateIPAllocationMethod: 'Dynamic'
|
||||||
|
subnet: {
|
||||||
|
id: resourceId('Microsoft.Network/virtualNetworks/subnets', virtualNetworkName, gatewaySubnetName)
|
||||||
|
}
|
||||||
|
publicIPAddress: {
|
||||||
|
id: gatewayPublicIp.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
sku: {
|
||||||
|
name: 'VpnGw1'
|
||||||
|
tier: 'VpnGw1'
|
||||||
|
}
|
||||||
|
vpnClientConfiguration: {
|
||||||
|
vpnClientAddressPool: {
|
||||||
|
addressPrefixes: [
|
||||||
|
vpnClientAddressPoolPrefix
|
||||||
|
]
|
||||||
|
}
|
||||||
|
vpnClientProtocols: [
|
||||||
|
'IkeV2'
|
||||||
|
'SSTP'
|
||||||
|
]
|
||||||
|
vpnAuthenticationTypes: [
|
||||||
|
'Certificate'
|
||||||
|
]
|
||||||
|
vpnClientRootCertificates: [
|
||||||
|
{
|
||||||
|
name: p2sRootCertificateName
|
||||||
|
properties: {
|
||||||
|
publicCertData: p2sRootCertificateData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependsOn: [
|
||||||
|
virtualNetwork
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
output domainControllerName string = virtualMachine.name
|
||||||
|
output domainControllerPrivateIp string = domainControllerPrivateIp
|
||||||
|
output domainControllerPublicIp string = domainControllerPublicIp.properties.ipAddress
|
||||||
|
output virtualNetworkName string = virtualNetwork.name
|
||||||
|
output virtualNetworkAddressPrefix string = virtualNetworkAddressPrefix
|
||||||
|
output vpnGatewayName string = virtualNetworkGateway.name
|
||||||
|
output vpnClientAddressPoolPrefix string = vpnClientAddressPoolPrefix
|
||||||
|
output serverBootstrapArguments array = [
|
||||||
|
'-ServerIPv4Address'
|
||||||
|
domainControllerPrivateIp
|
||||||
|
'-PrefixLength'
|
||||||
|
last(split(domainControllerSubnetPrefix, '/'))
|
||||||
|
'-NetworkConfigurationMode'
|
||||||
|
'PlatformManaged'
|
||||||
|
'-TrustedClientNetworks'
|
||||||
|
vpnClientAddressPoolPrefix
|
||||||
|
'-DnsForwarders'
|
||||||
|
'168.63.129.16'
|
||||||
|
]
|
||||||
@@ -22,6 +22,8 @@ param(
|
|||||||
[ValidatePattern('^/')]
|
[ValidatePattern('^/')]
|
||||||
[string]$StudentProfilePath = '/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx',
|
[string]$StudentProfilePath = '/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx',
|
||||||
[ValidatePattern('^/')]
|
[ValidatePattern('^/')]
|
||||||
|
[string]$ProfessorPayrollProfilePath = '/psulsa/gadmon/nomina/consultanomina.aspx',
|
||||||
|
[ValidatePattern('^/')]
|
||||||
[string]$MenuProfilePath = '/psulsa/menu.aspx',
|
[string]$MenuProfilePath = '/psulsa/menu.aspx',
|
||||||
[ValidateRange(32768, 2097152)]
|
[ValidateRange(32768, 2097152)]
|
||||||
[int]$MaxProfileBytes = 524288,
|
[int]$MaxProfileBytes = 524288,
|
||||||
@@ -29,6 +31,9 @@ param(
|
|||||||
[string]$BaseDn = 'DC=lci,DC=lasalle,DC=mx',
|
[string]$BaseDn = 'DC=lci,DC=lasalle,DC=mx',
|
||||||
[string]$DomainNetbios = 'LCI',
|
[string]$DomainNetbios = 'LCI',
|
||||||
[string]$UpnSuffix = 'lci.lasalle.mx',
|
[string]$UpnSuffix = 'lci.lasalle.mx',
|
||||||
|
[string]$ProfessorGroupDn = '',
|
||||||
|
[string]$StudentGroupDn = '',
|
||||||
|
[string]$AdministrativeGroupDn = '',
|
||||||
[string]$RemoteDesktopGroupDn = '',
|
[string]$RemoteDesktopGroupDn = '',
|
||||||
[ValidateLength(1, 64)]
|
[ValidateLength(1, 64)]
|
||||||
[string]$DefaultCompany = 'La Salle',
|
[string]$DefaultCompany = 'La Salle',
|
||||||
@@ -46,6 +51,8 @@ param(
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$serviceName = 'SGUAuthBroker'
|
$serviceName = 'SGUAuthBroker'
|
||||||
$installPath = Join-Path $env:ProgramFiles 'SGU\AuthBroker'
|
$installPath = Join-Path $env:ProgramFiles 'SGU\AuthBroker'
|
||||||
|
$brokerEventLogName = 'SGU Auth Broker'
|
||||||
|
$brokerEventSource = 'SGU.AuthBroker.Operational'
|
||||||
$normalizedClientThumbprints = @($AllowedClientThumbprints | ForEach-Object { $_ -replace ' ', '' })
|
$normalizedClientThumbprints = @($AllowedClientThumbprints | ForEach-Object { $_ -replace ' ', '' })
|
||||||
if ($normalizedClientThumbprints.Where({ $_.Length -ne 40 }).Count -gt 0) {
|
if ($normalizedClientThumbprints.Where({ $_.Length -ne 40 }).Count -gt 0) {
|
||||||
throw 'Client certificate thumbprints must contain exactly 40 hexadecimal characters.'
|
throw 'Client certificate thumbprints must contain exactly 40 hexadecimal characters.'
|
||||||
@@ -68,10 +75,27 @@ if (-not $serverCertificate.Verify()) {
|
|||||||
throw 'The HTTPS server certificate chain is not trusted or is outside its validity period. Import the issuing CA chain; for a self-signed lab certificate, trust its public .cer in LocalMachine\Root.'
|
throw 'The HTTPS server certificate chain is not trusted or is outside its validity period. Import the issuing CA chain; for a self-signed lab certificate, trust its public .cer in LocalMachine\Root.'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Import-Module ActiveDirectory -ErrorAction Stop
|
||||||
|
|
||||||
|
function ConvertTo-LdapFilterValue {
|
||||||
|
param([Parameter(Mandatory)][string]$Value)
|
||||||
|
|
||||||
|
return $Value.Replace('\', '\5c').Replace('*', '\2a').Replace('(', '\28').Replace(')', '\29').Replace(([string][char]0), '\00')
|
||||||
|
}
|
||||||
|
|
||||||
|
$usersOuName = 'Usuarios-SGU'
|
||||||
|
$usersOuDn = "OU=$usersOuName,$BaseDn"
|
||||||
|
if ([string]::IsNullOrWhiteSpace($ProfessorGroupDn)) {
|
||||||
|
$ProfessorGroupDn = "CN=SGU-Docentes,OU=Docentes,$usersOuDn"
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($StudentGroupDn)) {
|
||||||
|
$StudentGroupDn = "CN=SGU-Alumnos,OU=Alumnos,$usersOuDn"
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($AdministrativeGroupDn)) {
|
||||||
|
$AdministrativeGroupDn = "CN=SGU-Administrativos,OU=Administrativos,$usersOuDn"
|
||||||
|
}
|
||||||
|
|
||||||
if ($CreateMissingOus) {
|
if ($CreateMissingOus) {
|
||||||
Import-Module ActiveDirectory -ErrorAction Stop
|
|
||||||
$usersOuName = 'Usuarios-SGU'
|
|
||||||
$usersOuDn = "OU=$usersOuName,$BaseDn"
|
|
||||||
if (-not (Get-ADOrganizationalUnit -LDAPFilter "(ou=$usersOuName)" -SearchBase $BaseDn -SearchScope OneLevel -Server $LdapHost -ErrorAction SilentlyContinue)) {
|
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
|
New-ADOrganizationalUnit -Name $usersOuName -Path $BaseDn -ProtectedFromAccidentalDeletion $true -Server $LdapHost | Out-Null
|
||||||
}
|
}
|
||||||
@@ -113,8 +137,63 @@ if ($CreateMissingOus) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$roleGroupDefinitions = @(
|
||||||
|
[pscustomobject]@{ Role = 'Professor'; Dn = $ProfessorGroupDn; Description = 'SGU accounts with the DO institutional prefix.' }
|
||||||
|
[pscustomobject]@{ Role = 'Student'; Dn = $StudentGroupDn; Description = 'SGU accounts with the AL institutional prefix.' }
|
||||||
|
[pscustomobject]@{ Role = 'Administrative'; Dn = $AdministrativeGroupDn; Description = 'SGU accounts with the AD institutional prefix.' }
|
||||||
|
)
|
||||||
|
foreach ($definition in $roleGroupDefinitions) {
|
||||||
|
if (-not $definition.Dn.EndsWith(",$BaseDn", [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw "$($definition.Role)GroupDn must identify a security group beneath BaseDn."
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$roleGroup = Get-ADGroup -Identity $definition.Dn -Server $LdapHost -ErrorAction Stop
|
||||||
|
}
|
||||||
|
catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException] {
|
||||||
|
$roleGroup = $null
|
||||||
|
}
|
||||||
|
if (-not $roleGroup -and $CreateMissingOus) {
|
||||||
|
$groupDnMatch = [regex]::Match($definition.Dn, '^CN=(?<Name>[^,]+),(?<Path>.+)$', [Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
||||||
|
if (-not $groupDnMatch.Success) {
|
||||||
|
throw "$($definition.Role)GroupDn must start with a simple CN component."
|
||||||
|
}
|
||||||
|
$groupName = $groupDnMatch.Groups['Name'].Value
|
||||||
|
$groupPath = $groupDnMatch.Groups['Path'].Value
|
||||||
|
if ($groupName.Length -gt 20) {
|
||||||
|
throw "$($definition.Role) group name exceeds the 20-character sAMAccountName limit."
|
||||||
|
}
|
||||||
|
|
||||||
|
$matchingGroups = @(Get-ADGroup `
|
||||||
|
-LDAPFilter "(sAMAccountName=$(ConvertTo-LdapFilterValue -Value $groupName))" `
|
||||||
|
-SearchBase $BaseDn -SearchScope Subtree -Server $LdapHost -ErrorAction Stop)
|
||||||
|
if ($matchingGroups.Count -gt 1) {
|
||||||
|
throw "More than one Active Directory group uses sAMAccountName $groupName; the bootstrap cannot select one safely."
|
||||||
|
}
|
||||||
|
if ($matchingGroups.Count -eq 1) {
|
||||||
|
if ($matchingGroups[0].GroupCategory -ne 'Security') {
|
||||||
|
throw "$($definition.Role)GroupDn must identify a security group."
|
||||||
|
}
|
||||||
|
Move-ADObject -Identity $matchingGroups[0].DistinguishedName `
|
||||||
|
-TargetPath $groupPath -Server $LdapHost -Confirm:$false -ErrorAction Stop
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
New-ADGroup -Name $groupName -SamAccountName $groupName `
|
||||||
|
-GroupCategory Security -GroupScope Global `
|
||||||
|
-Path $groupPath `
|
||||||
|
-Description $definition.Description -Server $LdapHost | Out-Null
|
||||||
|
}
|
||||||
|
$roleGroup = Get-ADGroup -Identity $definition.Dn -Server $LdapHost -ErrorAction Stop
|
||||||
|
}
|
||||||
|
if (-not $roleGroup) {
|
||||||
|
throw "The required $($definition.Role) security group does not exist: $($definition.Dn)"
|
||||||
|
}
|
||||||
|
if ($roleGroup.GroupCategory -ne 'Security') {
|
||||||
|
throw "$($definition.Role)GroupDn must identify a security group."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($RemoteDesktopGroupDn) {
|
if ($RemoteDesktopGroupDn) {
|
||||||
Import-Module ActiveDirectory -ErrorAction Stop
|
|
||||||
$remoteDesktopGroup = Get-ADGroup -Identity $RemoteDesktopGroupDn -Server $LdapHost -ErrorAction Stop
|
$remoteDesktopGroup = Get-ADGroup -Identity $RemoteDesktopGroupDn -Server $LdapHost -ErrorAction Stop
|
||||||
if ($remoteDesktopGroup.GroupCategory -ne 'Security' -or
|
if ($remoteDesktopGroup.GroupCategory -ne 'Security' -or
|
||||||
-not $remoteDesktopGroup.DistinguishedName.EndsWith(",$BaseDn", [StringComparison]::OrdinalIgnoreCase)) {
|
-not $remoteDesktopGroup.DistinguishedName.EndsWith(",$BaseDn", [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
@@ -129,6 +208,14 @@ foreach ($file in @('SGU.AuthBroker.exe', 'SGU.AuthBroker.dll', 'appsettings.jso
|
|||||||
}
|
}
|
||||||
|
|
||||||
$productionSettings = @{
|
$productionSettings = @{
|
||||||
|
Logging = @{
|
||||||
|
EventLog = @{
|
||||||
|
LogLevel = @{
|
||||||
|
Default = 'Information'
|
||||||
|
'Microsoft.AspNetCore' = 'Warning'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Kestrel = @{
|
Kestrel = @{
|
||||||
Endpoints = @{
|
Endpoints = @{
|
||||||
Https = @{
|
Https = @{
|
||||||
@@ -143,6 +230,9 @@ $productionSettings = @{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Broker = @{
|
Broker = @{
|
||||||
|
Diagnostics = @{
|
||||||
|
UseDedicatedEventLog = $true
|
||||||
|
}
|
||||||
Tls = @{
|
Tls = @{
|
||||||
AllowedClientThumbprints = $normalizedClientThumbprints
|
AllowedClientThumbprints = $normalizedClientThumbprints
|
||||||
CheckCertificateRevocation = -not $DisableCertificateRevocationCheckForLab
|
CheckCertificateRevocation = -not $DisableCertificateRevocationCheckForLab
|
||||||
@@ -158,6 +248,7 @@ $productionSettings = @{
|
|||||||
AdministrativePersonalProfilePath = $AdministrativePersonalProfilePath
|
AdministrativePersonalProfilePath = $AdministrativePersonalProfilePath
|
||||||
AdministrativeLocationProfilePath = $AdministrativeLocationProfilePath
|
AdministrativeLocationProfilePath = $AdministrativeLocationProfilePath
|
||||||
StudentProfilePath = $StudentProfilePath
|
StudentProfilePath = $StudentProfilePath
|
||||||
|
ProfessorPayrollProfilePath = $ProfessorPayrollProfilePath
|
||||||
MenuProfilePath = $MenuProfilePath
|
MenuProfilePath = $MenuProfilePath
|
||||||
MaxProfileBytes = $MaxProfileBytes
|
MaxProfileBytes = $MaxProfileBytes
|
||||||
AllowedRedirectHosts = $AllowedNtlmRedirectHosts
|
AllowedRedirectHosts = $AllowedNtlmRedirectHosts
|
||||||
@@ -170,6 +261,9 @@ $productionSettings = @{
|
|||||||
ProfessorOuDn = "OU=Docentes,OU=Usuarios-SGU,$BaseDn"
|
ProfessorOuDn = "OU=Docentes,OU=Usuarios-SGU,$BaseDn"
|
||||||
StudentOuDn = "OU=Alumnos,OU=Usuarios-SGU,$BaseDn"
|
StudentOuDn = "OU=Alumnos,OU=Usuarios-SGU,$BaseDn"
|
||||||
AdministrativeOuDn = "OU=Administrativos,OU=Usuarios-SGU,$BaseDn"
|
AdministrativeOuDn = "OU=Administrativos,OU=Usuarios-SGU,$BaseDn"
|
||||||
|
ProfessorGroupDn = $ProfessorGroupDn
|
||||||
|
StudentGroupDn = $StudentGroupDn
|
||||||
|
AdministrativeGroupDn = $AdministrativeGroupDn
|
||||||
RemoteDesktopGroupDn = $RemoteDesktopGroupDn
|
RemoteDesktopGroupDn = $RemoteDesktopGroupDn
|
||||||
DefaultCompany = $DefaultCompany
|
DefaultCompany = $DefaultCompany
|
||||||
CreateMissingOus = [bool]$CreateMissingOus
|
CreateMissingOus = [bool]$CreateMissingOus
|
||||||
@@ -198,6 +292,18 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker
|
|||||||
$settingsJson,
|
$settingsJson,
|
||||||
$utf8WithoutBom)
|
$utf8WithoutBom)
|
||||||
|
|
||||||
|
if ([Diagnostics.EventLog]::SourceExists($brokerEventSource)) {
|
||||||
|
$registeredLog = [Diagnostics.EventLog]::LogNameFromSourceName($brokerEventSource, '.')
|
||||||
|
if (-not $registeredLog.Equals($brokerEventLogName, [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw "Event source $brokerEventSource is already registered to $registeredLog."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
New-EventLog -LogName $brokerEventLogName -Source $brokerEventSource
|
||||||
|
}
|
||||||
|
Limit-EventLog -LogName $brokerEventLogName -MaximumSize 268435456 `
|
||||||
|
-OverflowAction OverwriteAsNeeded
|
||||||
|
|
||||||
if (-not (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)) {
|
if (-not (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)) {
|
||||||
New-Service -Name $serviceName `
|
New-Service -Name $serviceName `
|
||||||
-DisplayName 'SGU Authentication Broker' `
|
-DisplayName 'SGU Authentication Broker' `
|
||||||
@@ -249,4 +355,5 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker
|
|||||||
Start-Service -Name $serviceName
|
Start-Service -Name $serviceName
|
||||||
}
|
}
|
||||||
|
|
||||||
Get-Service -Name $serviceName | Select-Object Name, Status, StartType
|
Get-Service -Name $serviceName | Select-Object Name, Status, StartType,
|
||||||
|
@{ Name = 'EventLog'; Expression = { $brokerEventLogName } }
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
[CmdletBinding(SupportsShouldProcess)]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$SubscriptionId,
|
||||||
|
[string]$ResourceGroupName = 'rg-sgu-lab',
|
||||||
|
[string]$Location = 'centralus',
|
||||||
|
[string]$DeploymentPrefix = 'sgu-lab',
|
||||||
|
[Parameter(Mandatory)][string]$AdministratorUsername,
|
||||||
|
[securestring]$AdministratorPassword,
|
||||||
|
[Parameter(Mandatory)][string]$P2sRootCertificatePath,
|
||||||
|
[string]$ComputerName = 'SGU-DC01',
|
||||||
|
[string]$VmSize = 'Standard_D2s_v5',
|
||||||
|
[string]$VirtualNetworkAddressPrefix = '10.77.0.0/16',
|
||||||
|
[string]$DomainControllerSubnetPrefix = '10.77.0.0/24',
|
||||||
|
[ipaddress]$DomainControllerPrivateIp = '10.77.0.4',
|
||||||
|
[string]$GatewaySubnetPrefix = '10.77.255.0/27',
|
||||||
|
[string]$VpnClientAddressPoolPrefix = '172.30.0.0/24',
|
||||||
|
[string]$AdministratorSourceAddressPrefix = '',
|
||||||
|
[string]$TemplateFile = (Join-Path $PSScriptRoot '..\infra\azure\main.bicep')
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProgressPreference = 'SilentlyContinue'
|
||||||
|
|
||||||
|
if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
|
||||||
|
throw 'Azure CLI is required. Install it from https://aka.ms/installazurecliwindows and run az login.'
|
||||||
|
}
|
||||||
|
if (-not (Test-Path -LiteralPath $TemplateFile -PathType Leaf)) {
|
||||||
|
throw "Azure Bicep template not found: $TemplateFile"
|
||||||
|
}
|
||||||
|
if (-not (Test-Path -LiteralPath $P2sRootCertificatePath -PathType Leaf)) {
|
||||||
|
throw "P2S root certificate not found: $P2sRootCertificatePath"
|
||||||
|
}
|
||||||
|
if (-not $AdministratorPassword) {
|
||||||
|
$AdministratorPassword = Read-Host 'Password for the local Azure VM administrator' -AsSecureString
|
||||||
|
}
|
||||||
|
|
||||||
|
$rootCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new(
|
||||||
|
(Resolve-Path -LiteralPath $P2sRootCertificatePath).Path)
|
||||||
|
if (-not ($rootCertificate.Extensions | Where-Object {
|
||||||
|
$_.Oid -and $_.Oid.Value -eq '2.5.29.19' -and $_.Format($false) -match 'CA' })) {
|
||||||
|
throw 'P2sRootCertificatePath must contain a certificate-authority certificate.'
|
||||||
|
}
|
||||||
|
$rootCertificateData = [Convert]::ToBase64String($rootCertificate.RawData)
|
||||||
|
|
||||||
|
$account = & az account show --output json 2>$null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw 'Azure CLI is not signed in. Run az login, then retry.'
|
||||||
|
}
|
||||||
|
& az account set --subscription $SubscriptionId --only-show-errors
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Could not select Azure subscription $SubscriptionId."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($PSCmdlet.ShouldProcess("$ResourceGroupName in $Location", 'Create Azure VNet, Windows Server 2025 VM, public IP, and P2S VPN Gateway')) {
|
||||||
|
& az group create --name $ResourceGroupName --location $Location --only-show-errors --output none
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Could not create or update resource group $ResourceGroupName."
|
||||||
|
}
|
||||||
|
|
||||||
|
$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ("sgu-azure-" + [Guid]::NewGuid().ToString('N'))
|
||||||
|
$parametersPath = Join-Path $temporaryRoot 'parameters.json'
|
||||||
|
$passwordPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($AdministratorPassword)
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null
|
||||||
|
$acl = Get-Acl -LiteralPath $temporaryRoot
|
||||||
|
$acl.SetAccessRuleProtection($true, $false)
|
||||||
|
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
|
||||||
|
[Security.Principal.WindowsIdentity]::GetCurrent().User,
|
||||||
|
[Security.AccessControl.FileSystemRights]::FullControl,
|
||||||
|
[Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit',
|
||||||
|
[Security.AccessControl.PropagationFlags]::None,
|
||||||
|
[Security.AccessControl.AccessControlType]::Allow))
|
||||||
|
Set-Acl -LiteralPath $temporaryRoot -AclObject $acl
|
||||||
|
|
||||||
|
$plainPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($passwordPointer)
|
||||||
|
$parameters = [ordered]@{
|
||||||
|
'$schema' = 'https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#'
|
||||||
|
contentVersion = '1.0.0.0'
|
||||||
|
parameters = [ordered]@{
|
||||||
|
deploymentPrefix = @{ value = $DeploymentPrefix }
|
||||||
|
location = @{ value = $Location }
|
||||||
|
administratorUsername = @{ value = $AdministratorUsername }
|
||||||
|
administratorPassword = @{ value = $plainPassword }
|
||||||
|
computerName = @{ value = $ComputerName }
|
||||||
|
vmSize = @{ value = $VmSize }
|
||||||
|
virtualNetworkAddressPrefix = @{ value = $VirtualNetworkAddressPrefix }
|
||||||
|
domainControllerSubnetPrefix = @{ value = $DomainControllerSubnetPrefix }
|
||||||
|
gatewaySubnetPrefix = @{ value = $GatewaySubnetPrefix }
|
||||||
|
domainControllerPrivateIp = @{ value = $DomainControllerPrivateIp.IPAddressToString }
|
||||||
|
vpnClientAddressPoolPrefix = @{ value = $VpnClientAddressPoolPrefix }
|
||||||
|
p2sRootCertificateData = @{ value = $rootCertificateData }
|
||||||
|
administratorSourceAddressPrefix = @{ value = $AdministratorSourceAddressPrefix }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[IO.File]::WriteAllText(
|
||||||
|
$parametersPath,
|
||||||
|
($parameters | ConvertTo-Json -Depth 8),
|
||||||
|
[Text.UTF8Encoding]::new($false))
|
||||||
|
$plainPassword = $null
|
||||||
|
$parameters.parameters.administratorPassword.value = $null
|
||||||
|
|
||||||
|
$deploymentName = 'sgu-{0}' -f (Get-Date -Format 'yyyyMMdd-HHmmss')
|
||||||
|
$deploymentOutput = & az deployment group create `
|
||||||
|
--name $deploymentName `
|
||||||
|
--resource-group $ResourceGroupName `
|
||||||
|
--template-file (Resolve-Path -LiteralPath $TemplateFile).Path `
|
||||||
|
--parameters "@$parametersPath" `
|
||||||
|
--only-show-errors `
|
||||||
|
--output json
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw 'Azure deployment failed. Review the Azure CLI error above; no bootstrap credential was persisted by this script.'
|
||||||
|
}
|
||||||
|
$deployment = ($deploymentOutput -join [Environment]::NewLine) | ConvertFrom-Json
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($passwordPointer -ne [IntPtr]::Zero) {
|
||||||
|
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($passwordPointer)
|
||||||
|
}
|
||||||
|
$AdministratorPassword = $null
|
||||||
|
if ($temporaryRoot -and (Test-Path -LiteralPath $temporaryRoot)) {
|
||||||
|
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$values = @{}
|
||||||
|
foreach ($property in $deployment.properties.outputs.PSObject.Properties) {
|
||||||
|
$values[$property.Name] = $property.Value.value
|
||||||
|
}
|
||||||
|
[pscustomobject]@{
|
||||||
|
ResourceGroupName = $ResourceGroupName
|
||||||
|
DeploymentName = $deploymentName
|
||||||
|
DomainControllerName = $values.domainControllerName
|
||||||
|
DomainControllerPrivateIp = $values.domainControllerPrivateIp
|
||||||
|
DomainControllerPublicIp = $values.domainControllerPublicIp
|
||||||
|
VpnGatewayName = $values.vpnGatewayName
|
||||||
|
VpnClientAddressPoolPrefix = $values.vpnClientAddressPoolPrefix
|
||||||
|
ServerBootstrapArguments = $values.serverBootstrapArguments
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
[CmdletBinding(SupportsShouldProcess)]
|
||||||
|
param()
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||||
|
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||||
|
throw 'Run this script from an elevated Windows PowerShell session.'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use invariant audit subcategory GUIDs so this works on English and Spanish
|
||||||
|
# installations. Logon, logoff, and other logon/logoff events provide the
|
||||||
|
# session identifiers required to correlate usage centrally.
|
||||||
|
$auditSubcategories = @(
|
||||||
|
'{0CCE9215-69AE-11D9-BED3-505054503030}', # Logon
|
||||||
|
'{0CCE9216-69AE-11D9-BED3-505054503030}', # Logoff
|
||||||
|
'{0CCE921C-69AE-11D9-BED3-505054503030}' # Other Logon/Logoff Events
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable SGU session auditing and event forwarding prerequisites')) {
|
||||||
|
foreach ($subcategory in $auditSubcategories) {
|
||||||
|
& auditpol.exe /set "/subcategory:$subcategory" /success:enable /failure:enable | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "auditpol failed for subcategory $subcategory with exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security events are read by the Windows Event Forwarding plug-in under
|
||||||
|
# NETWORK SERVICE. Resolve both principals by SID for localized Windows.
|
||||||
|
$eventLogReadersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-573')
|
||||||
|
$networkServiceSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-20')
|
||||||
|
$members = @(Get-LocalGroupMember -SID $eventLogReadersSid -ErrorAction SilentlyContinue)
|
||||||
|
$eventLogReaderMembershipChanged = $false
|
||||||
|
if ($members.SID.Value -notcontains $networkServiceSid.Value) {
|
||||||
|
$networkServiceAccount = $networkServiceSid.Translate([Security.Principal.NTAccount]).Value
|
||||||
|
Add-LocalGroupMember -SID $eventLogReadersSid -Member $networkServiceAccount
|
||||||
|
$eventLogReaderMembershipChanged = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-Service WinRM -StartupType Automatic
|
||||||
|
if ((Get-Service WinRM).Status -ne 'Running') {
|
||||||
|
Start-Service WinRM
|
||||||
|
}
|
||||||
|
elseif ($eventLogReaderMembershipChanged) {
|
||||||
|
Restart-Service WinRM -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
& wevtutil.exe set-log Security /maxsize:268435456 /retention:false /autobackup:false
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "wevtutil failed to configure the local Security log with exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[pscustomobject]@{
|
||||||
|
ComputerName = $env:COMPUTERNAME
|
||||||
|
WinRM = (Get-Service WinRM).Status.ToString()
|
||||||
|
SecurityLogMaximumBytes = (Get-WinEvent -ListLog Security).MaximumSizeInBytes
|
||||||
|
AuditSubcategories = $auditSubcategories
|
||||||
|
EventForwardingPolicy = Test-Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\EventLog\EventForwarding\SubscriptionManager'
|
||||||
|
}
|
||||||
@@ -22,8 +22,12 @@ param(
|
|||||||
[string]$NewComputerName,
|
[string]$NewComputerName,
|
||||||
[string]$NetworkInterfaceAlias = 'Ethernet',
|
[string]$NetworkInterfaceAlias = 'Ethernet',
|
||||||
[string[]]$DomainDnsServerAddresses = @('192.168.50.10'),
|
[string[]]$DomainDnsServerAddresses = @('192.168.50.10'),
|
||||||
|
[ValidateSet('Direct', 'AzureP2S')]
|
||||||
|
[string]$ConnectivityMode = 'Direct',
|
||||||
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
|
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
|
||||||
[string]$DotNetRuntimeInstallerPath,
|
[string]$DotNetRuntimeInstallerPath,
|
||||||
|
[string]$RustDeskServerAddress,
|
||||||
|
[string]$RustDeskServerPublicKey,
|
||||||
[switch]$SkipRestart
|
[switch]$SkipRestart
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,9 +41,12 @@ if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administra
|
|||||||
foreach ($scriptName in @(
|
foreach ($scriptName in @(
|
||||||
'Install-CredentialProvider.ps1',
|
'Install-CredentialProvider.ps1',
|
||||||
'Install-SguEnrollmentGuard.ps1',
|
'Install-SguEnrollmentGuard.ps1',
|
||||||
|
'Set-SguStandardLocalUser.ps1',
|
||||||
'Test-SguClientEnrollment.ps1',
|
'Test-SguClientEnrollment.ps1',
|
||||||
'Repair-SguClientEnrollment.ps1',
|
'Repair-SguClientEnrollment.ps1',
|
||||||
'Enable-LabRemoteAccess.ps1')) {
|
'Enable-LabRemoteAccess.ps1',
|
||||||
|
'Enable-SguClientMonitoring.ps1',
|
||||||
|
'Install-SguRustDeskClient.ps1')) {
|
||||||
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot $scriptName) -PathType Leaf)) {
|
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot $scriptName) -PathType Leaf)) {
|
||||||
throw "$scriptName must be beside Enroll-SguDomainClient.ps1."
|
throw "$scriptName must be beside Enroll-SguDomainClient.ps1."
|
||||||
}
|
}
|
||||||
@@ -72,22 +79,52 @@ $guardParams = @{
|
|||||||
TimeoutSeconds = 90
|
TimeoutSeconds = 90
|
||||||
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
|
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
|
||||||
DotNetRuntimeInstallerPath = $DotNetRuntimeInstallerPath
|
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')) {
|
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
|
# The broker uses a domain DNS name even before the machine joins the
|
||||||
# domain. Point at AD DNS first so the provider-first health check works on
|
# domain. Point at AD DNS first so the provider-first health check works on
|
||||||
# a completely clean Windows installation.
|
# a completely clean Windows installation.
|
||||||
Set-DnsClientServerAddress `
|
if ($ConnectivityMode -eq 'Direct') {
|
||||||
-InterfaceAlias $NetworkInterfaceAlias `
|
Set-DnsClientServerAddress `
|
||||||
-ServerAddresses $DomainDnsServerAddresses
|
-InterfaceAlias $NetworkInterfaceAlias `
|
||||||
|
-ServerAddresses $DomainDnsServerAddresses
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$nrptDisplayName = "SGU Azure P2S DNS - $DomainName"
|
||||||
|
$nrptRule = Get-DnsClientNrptRule -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object DisplayName -eq $nrptDisplayName |
|
||||||
|
Select-Object -First 1
|
||||||
|
if (-not $nrptRule -or
|
||||||
|
@($DomainDnsServerAddresses | Where-Object { @($nrptRule.NameServers) -contains $_ }).Count -eq 0) {
|
||||||
|
throw "AzureP2S enrollment requires the managed NRPT rule '$nrptDisplayName'. Run Install-SguAzureP2sClient.ps1 first."
|
||||||
|
}
|
||||||
|
}
|
||||||
Resolve-DnsName -Type SRV "_ldap._tcp.dc._msdcs.$DomainName" -ErrorAction Stop | Out-Null
|
Resolve-DnsName -Type SRV "_ldap._tcp.dc._msdcs.$DomainName" -ErrorAction Stop | Out-Null
|
||||||
|
|
||||||
& (Join-Path $PSScriptRoot 'Install-CredentialProvider.ps1') @installParams | Out-Null
|
& (Join-Path $PSScriptRoot 'Install-CredentialProvider.ps1') @installParams | Out-Null
|
||||||
|
if ($RustDeskServerAddress) {
|
||||||
|
$rustDeskResult = & (Join-Path $PSScriptRoot 'Install-SguRustDeskClient.ps1') `
|
||||||
|
-ServerAddress $RustDeskServerAddress `
|
||||||
|
-ServerPublicKey $RustDeskServerPublicKey
|
||||||
|
}
|
||||||
|
$localStudentUser = & (Join-Path $PSScriptRoot 'Set-SguStandardLocalUser.ps1')
|
||||||
& (Join-Path $PSScriptRoot 'Install-SguEnrollmentGuard.ps1') @guardParams | Out-Null
|
& (Join-Path $PSScriptRoot 'Install-SguEnrollmentGuard.ps1') @guardParams | Out-Null
|
||||||
|
|
||||||
$preJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') `
|
$testParameters = @{ RequireBrokerHealth = $true }
|
||||||
-RequireBrokerHealth
|
if ($RustDeskServerAddress) {
|
||||||
|
$testParameters.RequireRustDesk = $true
|
||||||
|
$testParameters.RustDeskServerAddress = $RustDeskServerAddress
|
||||||
|
}
|
||||||
|
$preJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @testParameters
|
||||||
if (-not $preJoin.IsValid) {
|
if (-not $preJoin.IsValid) {
|
||||||
throw "Domain join refused because SGU enrollment is invalid: $($preJoin.Issues -join ' ')"
|
throw "Domain join refused because SGU enrollment is invalid: $($preJoin.Issues -join ' ')"
|
||||||
}
|
}
|
||||||
@@ -96,10 +133,20 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
|
|||||||
& (Join-Path $PSScriptRoot 'Enable-LabRemoteAccess.ps1') `
|
& (Join-Path $PSScriptRoot 'Enable-LabRemoteAccess.ps1') `
|
||||||
-RemoteDesktopPrincipal $RemoteDesktopPrincipal `
|
-RemoteDesktopPrincipal $RemoteDesktopPrincipal `
|
||||||
-EnableAdministrativeFirewallGroups | Out-Null
|
-EnableAdministrativeFirewallGroups | Out-Null
|
||||||
return & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') `
|
& (Join-Path $PSScriptRoot 'Enable-SguClientMonitoring.ps1') | Out-Null
|
||||||
-RequireDomainJoined `
|
$postJoinParameters = @{
|
||||||
-RequireRemoteAccess `
|
RequireDomainJoined = $true
|
||||||
-RemoteDesktopPrincipal $RemoteDesktopPrincipal
|
RequireRemoteAccess = $true
|
||||||
|
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
|
||||||
|
}
|
||||||
|
if ($RustDeskServerAddress) {
|
||||||
|
$postJoinParameters.RequireRustDesk = $true
|
||||||
|
$postJoinParameters.RustDeskServerAddress = $RustDeskServerAddress
|
||||||
|
}
|
||||||
|
$postJoin = & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') @postJoinParameters
|
||||||
|
$postJoin | Add-Member -NotePropertyName StandardLocalUser -NotePropertyValue $localStudentUser
|
||||||
|
$postJoin | Add-Member -NotePropertyName RustDesk -NotePropertyValue $rustDeskResult
|
||||||
|
return $postJoin
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not $DomainCredential) {
|
if (-not $DomainCredential) {
|
||||||
@@ -129,6 +176,9 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
|
|||||||
[pscustomobject]@{
|
[pscustomobject]@{
|
||||||
ComputerName = if ($NewComputerName) { $NewComputerName } else { $env:COMPUTERNAME }
|
ComputerName = if ($NewComputerName) { $NewComputerName } else { $env:COMPUTERNAME }
|
||||||
DomainName = $DomainName
|
DomainName = $DomainName
|
||||||
|
ConnectivityMode = $ConnectivityMode
|
||||||
ProviderValidatedBeforeJoin = $true
|
ProviderValidatedBeforeJoin = $true
|
||||||
|
StandardLocalUser = $localStudentUser
|
||||||
|
RustDesk = $rustDeskResult
|
||||||
RestartRequired = [bool]$SkipRestart
|
RestartRequired = [bool]$SkipRestart
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+513
@@ -0,0 +1,513 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Enroll-SguLinuxDomainClient.sh
|
||||||
|
#
|
||||||
|
# Idempotently joins a Debian/Ubuntu or RHEL-family Linux workstation to the
|
||||||
|
# SGU Active Directory laboratory. The join password is always requested by
|
||||||
|
# realmd; this script never accepts, logs, or stores it.
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
IFS=$'\n\t'
|
||||||
|
SCRIPT_DIRECTORY=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
|
||||||
|
DOMAIN_NAME='lci.lasalle.mx'
|
||||||
|
DOMAIN_CONTROLLER=''
|
||||||
|
DOMAIN_DNS=''
|
||||||
|
COMPUTER_OU='OU=Laboratorio,DC=lci,DC=lasalle,DC=mx'
|
||||||
|
JOIN_USER='Administrator'
|
||||||
|
DOMAIN_INTERFACE=''
|
||||||
|
DOMAIN_ADDRESS=''
|
||||||
|
COMPUTER_NAME=''
|
||||||
|
ALLOW_GROUP=''
|
||||||
|
ENABLE_SSH=false
|
||||||
|
ENABLE_HYPERV_ENHANCED_SESSION=false
|
||||||
|
ENABLE_RUSTDESK=true
|
||||||
|
RUSTDESK_REGISTRATION_SHARE=''
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
sudo ./Enroll-SguLinuxDomainClient.sh --domain-controller <IPv4-or-FQDN> [options]
|
||||||
|
|
||||||
|
Required:
|
||||||
|
--domain-controller VALUE Fixed IPv4 address or DNS name of the AD controller.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--domain-name VALUE AD DNS domain (default: lci.lasalle.mx).
|
||||||
|
--domain-dns VALUE DNS server for the AD network (default: domain controller).
|
||||||
|
--computer-ou DN Destination computer OU.
|
||||||
|
--join-user USER AD account permitted to join computers (default: Administrator).
|
||||||
|
--computer-name NAME NetBIOS host name; its FQDN becomes NAME.DOMAIN.
|
||||||
|
--domain-interface IFACE Private NIC connected to the AD network.
|
||||||
|
--domain-address CIDR Static IPv4 address for --domain-interface, e.g. 192.168.50.12/24.
|
||||||
|
--allow-group GROUP Restrict Linux sign-in to this AD group after joining.
|
||||||
|
--enable-ssh Install, enable, and (when active) permit OpenSSH in the local firewall.
|
||||||
|
--enable-hyperv-enhanced-session
|
||||||
|
Install and configure XRDP over Hyper-V sockets for VMConnect.
|
||||||
|
--disable-rustdesk Do not install the managed RustDesk remote-support client.
|
||||||
|
--rustdesk-registration-share UNC
|
||||||
|
Override the protected controller SMB enrollment share.
|
||||||
|
--help Show this help.
|
||||||
|
|
||||||
|
Network safety:
|
||||||
|
--domain-interface and --domain-address must be supplied together. The selected
|
||||||
|
interface must not own the default route, so the command cannot replace the
|
||||||
|
Internet route while attaching a private AD NIC.
|
||||||
|
|
||||||
|
The AD password is requested interactively by realmd. It is never accepted as an
|
||||||
|
argument or written to a file, log, or command line.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'ERROR: %s\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
need_command() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || fail "Required command is unavailable: $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
packages_are_installed() {
|
||||||
|
local package_name
|
||||||
|
for package_name in "$@"; do
|
||||||
|
dpkg-query -W -f='${db:Status-Status}' "$package_name" 2>/dev/null | grep -Fxq 'installed' \
|
||||||
|
|| return 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
apt_get_with_retry() {
|
||||||
|
local attempt
|
||||||
|
for attempt in $(seq 1 60); do
|
||||||
|
if apt-get "$@"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock \
|
||||||
|
>/dev/null 2>&1; then
|
||||||
|
printf 'Waiting for another package operation before retrying apt-get %s.\n' "$1" >&2
|
||||||
|
sleep 5
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fail "apt-get $1 failed for a reason other than a temporary package lock."
|
||||||
|
done
|
||||||
|
fail 'Timed out waiting for another package operation to finish.'
|
||||||
|
}
|
||||||
|
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
--domain-controller) DOMAIN_CONTROLLER=${2:?Missing value for --domain-controller}; shift 2 ;;
|
||||||
|
--domain-name) DOMAIN_NAME=${2:?Missing value for --domain-name}; shift 2 ;;
|
||||||
|
--domain-dns) DOMAIN_DNS=${2:?Missing value for --domain-dns}; shift 2 ;;
|
||||||
|
--computer-ou) COMPUTER_OU=${2:?Missing value for --computer-ou}; shift 2 ;;
|
||||||
|
--join-user) JOIN_USER=${2:?Missing value for --join-user}; shift 2 ;;
|
||||||
|
--computer-name) COMPUTER_NAME=${2:?Missing value for --computer-name}; shift 2 ;;
|
||||||
|
--domain-interface) DOMAIN_INTERFACE=${2:?Missing value for --domain-interface}; shift 2 ;;
|
||||||
|
--domain-address) DOMAIN_ADDRESS=${2:?Missing value for --domain-address}; shift 2 ;;
|
||||||
|
--allow-group) ALLOW_GROUP=${2:?Missing value for --allow-group}; shift 2 ;;
|
||||||
|
--enable-ssh) ENABLE_SSH=true; shift ;;
|
||||||
|
--enable-hyperv-enhanced-session) ENABLE_HYPERV_ENHANCED_SESSION=true; shift ;;
|
||||||
|
--disable-rustdesk) ENABLE_RUSTDESK=false; shift ;;
|
||||||
|
--rustdesk-registration-share) RUSTDESK_REGISTRATION_SHARE=${2:?Missing value for --rustdesk-registration-share}; shift 2 ;;
|
||||||
|
--help|-h) usage; exit 0 ;;
|
||||||
|
*) fail "Unknown argument: $1. Use --help for usage." ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ ${EUID} -eq 0 ]] || fail 'Run this command with sudo or as root.'
|
||||||
|
[[ -n $DOMAIN_CONTROLLER ]] || fail '--domain-controller is required.'
|
||||||
|
|
||||||
|
if [[ -z $DOMAIN_DNS ]]; then
|
||||||
|
DOMAIN_DNS=$DOMAIN_CONTROLLER
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n $DOMAIN_INTERFACE || -n $DOMAIN_ADDRESS ]]; then
|
||||||
|
[[ -n $DOMAIN_INTERFACE && -n $DOMAIN_ADDRESS ]] || \
|
||||||
|
fail '--domain-interface and --domain-address must be supplied together.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z $COMPUTER_NAME ]]; then
|
||||||
|
COMPUTER_NAME=$(hostname -s)
|
||||||
|
fi
|
||||||
|
COMPUTER_NAME=${COMPUTER_NAME^^}
|
||||||
|
HOST_FQDN="${COMPUTER_NAME,,}.${DOMAIN_NAME,,}"
|
||||||
|
|
||||||
|
install_prerequisites() {
|
||||||
|
local -a packages=()
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
packages=(realmd sssd sssd-tools adcli libnss-sss libpam-sss krb5-user packagekit samba-common-bin)
|
||||||
|
if [[ $ENABLE_SSH == true ]]; then
|
||||||
|
packages+=(openssh-server)
|
||||||
|
fi
|
||||||
|
if [[ $ENABLE_HYPERV_ENHANCED_SESSION == true ]]; then
|
||||||
|
packages+=(xrdp xorgxrdp ssl-cert)
|
||||||
|
|
||||||
|
# XRDP's Debian post-install script cannot replace a dangling
|
||||||
|
# certificate symlink left by an interrupted/older installation.
|
||||||
|
# Remove only dangling links so dpkg can recreate them safely.
|
||||||
|
local xrdp_link
|
||||||
|
for xrdp_link in /etc/xrdp/cert.pem /etc/xrdp/key.pem; do
|
||||||
|
if [[ -L $xrdp_link && ! -e $xrdp_link ]]; then
|
||||||
|
rm -f -- "$xrdp_link"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
if ! packages_are_installed "${packages[@]}"; then
|
||||||
|
apt_get_with_retry update
|
||||||
|
apt_get_with_retry install -y "${packages[@]}"
|
||||||
|
fi
|
||||||
|
# `pam-auth-update` takes the debconf database lock even when its
|
||||||
|
# profile is already enabled. Avoid that unnecessary package-manager
|
||||||
|
# dependency on repeat enrollment runs.
|
||||||
|
if ! grep -Eq '^[[:space:]]*[^#].*pam_mkhomedir\.so' /etc/pam.d/common-session; then
|
||||||
|
pam-auth-update --enable mkhomedir --force
|
||||||
|
fi
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v dnf >/dev/null 2>&1; then
|
||||||
|
packages=(realmd sssd sssd-tools adcli oddjob oddjob-mkhomedir samba-common-tools krb5-workstation)
|
||||||
|
if [[ $ENABLE_SSH == true ]]; then
|
||||||
|
packages+=(openssh-server)
|
||||||
|
fi
|
||||||
|
dnf install -y "${packages[@]}"
|
||||||
|
authselect select sssd with-mkhomedir --force
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail 'Supported package managers are apt-get (Debian/Ubuntu) and dnf (RHEL/Fedora/Rocky/AlmaLinux).'
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_private_ad_interface() {
|
||||||
|
[[ -n $DOMAIN_INTERFACE ]] || return 0
|
||||||
|
need_command nmcli
|
||||||
|
ip link show "$DOMAIN_INTERFACE" >/dev/null 2>&1 || \
|
||||||
|
fail "Network interface does not exist: $DOMAIN_INTERFACE"
|
||||||
|
|
||||||
|
local default_interface
|
||||||
|
default_interface=$(ip route show default | awk 'NR == 1 { print $5 }')
|
||||||
|
if [[ $default_interface == "$DOMAIN_INTERFACE" ]]; then
|
||||||
|
fail "Refusing to reconfigure $DOMAIN_INTERFACE because it owns the default route. Use the private AD NIC."
|
||||||
|
fi
|
||||||
|
|
||||||
|
local connection_name="SGU-Lab-AD-${DOMAIN_INTERFACE}"
|
||||||
|
if ! nmcli -t -f NAME connection show | grep -Fxq "$connection_name"; then
|
||||||
|
nmcli connection add type ethernet ifname "$DOMAIN_INTERFACE" con-name "$connection_name"
|
||||||
|
fi
|
||||||
|
nmcli connection modify "$connection_name" \
|
||||||
|
connection.autoconnect yes \
|
||||||
|
ipv4.method manual \
|
||||||
|
ipv4.addresses "$DOMAIN_ADDRESS" \
|
||||||
|
ipv4.dns "$DOMAIN_DNS" \
|
||||||
|
ipv4.dns-search "$DOMAIN_NAME" \
|
||||||
|
ipv4.never-default yes \
|
||||||
|
ipv6.method ignore
|
||||||
|
nmcli connection up "$connection_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_sssd_dyndns() {
|
||||||
|
[[ -n $DOMAIN_INTERFACE ]] || return 0
|
||||||
|
local configuration_directory='/etc/sssd/conf.d'
|
||||||
|
local configuration_path="${configuration_directory}/90-sgu-dyndns.conf"
|
||||||
|
local temporary_path
|
||||||
|
temporary_path=$(mktemp)
|
||||||
|
printf '%s\n' \
|
||||||
|
"[domain/${DOMAIN_NAME,,}]" \
|
||||||
|
"ad_hostname = ${HOST_FQDN}" \
|
||||||
|
'dyndns_update = True' \
|
||||||
|
'dyndns_update_ptr = True' \
|
||||||
|
"dyndns_iface = ${DOMAIN_INTERFACE}" \
|
||||||
|
'dyndns_refresh_interval = 43200' >"$temporary_path"
|
||||||
|
install -d -o root -g root -m 700 "$configuration_directory"
|
||||||
|
install -o root -g root -m 600 "$temporary_path" "$configuration_path"
|
||||||
|
rm -f "$temporary_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_short_domain_login_names() {
|
||||||
|
local configuration_path='/etc/sssd/sssd.conf'
|
||||||
|
[[ -f $configuration_path ]] || return 0
|
||||||
|
|
||||||
|
# Institutional account names (AL/AD/DO) are unique in this lab and are
|
||||||
|
# the identifiers users already know. Keep UPN logins valid while also
|
||||||
|
# allowing the short form in PAM applications such as XRDP/VMConnect.
|
||||||
|
if grep -Eq '^[[:space:]]*use_fully_qualified_names[[:space:]]*=' "$configuration_path"; then
|
||||||
|
sed -Ei 's/^[[:space:]]*use_fully_qualified_names[[:space:]]*=.*/use_fully_qualified_names = False/' \
|
||||||
|
"$configuration_path"
|
||||||
|
else
|
||||||
|
sed -Ei "/^\[domain\/${DOMAIN_NAME//./\\.}\]$/a use_fully_qualified_names = False" \
|
||||||
|
"$configuration_path"
|
||||||
|
fi
|
||||||
|
chmod 600 "$configuration_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_sssd_responder_mode() {
|
||||||
|
local configuration_path='/etc/sssd/sssd.conf'
|
||||||
|
[[ -f $configuration_path ]] || return 0
|
||||||
|
|
||||||
|
# realmd writes a persistent responder list, while recent Debian-family
|
||||||
|
# packages can enable the same NSS/PAM responders through systemd sockets.
|
||||||
|
# Running both modes makes the sockets fail at boot and can leave graphical
|
||||||
|
# PAM clients unable to contact SSSD reliably. Keep realmd's persistent
|
||||||
|
# responders and disable only the duplicate socket units when they exist.
|
||||||
|
local unit
|
||||||
|
for unit in sssd-nss.socket sssd-pam.socket sssd-pam-priv.socket; do
|
||||||
|
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q "^${unit}"; then
|
||||||
|
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||||
|
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_graphical_domain_login() {
|
||||||
|
local sssd_configuration_directory='/etc/sssd/conf.d'
|
||||||
|
local temporary_sssd_configuration
|
||||||
|
local interactive_services='+lightdm,+cinnamon-screensaver'
|
||||||
|
if [[ $ENABLE_HYPERV_ENHANCED_SESSION == true ]]; then
|
||||||
|
interactive_services+=',+xrdp-sesman'
|
||||||
|
fi
|
||||||
|
|
||||||
|
temporary_sssd_configuration=$(mktemp)
|
||||||
|
printf '%s\n' \
|
||||||
|
"[domain/${DOMAIN_NAME,,}]" \
|
||||||
|
"ad_gpo_map_interactive = ${interactive_services}" >"$temporary_sssd_configuration"
|
||||||
|
install -d -o root -g root -m 700 "$sssd_configuration_directory"
|
||||||
|
install -o root -g root -m 600 "$temporary_sssd_configuration" \
|
||||||
|
"${sssd_configuration_directory}/91-sgu-graphical-login.conf"
|
||||||
|
rm -f "$temporary_sssd_configuration"
|
||||||
|
rm -f "${sssd_configuration_directory}/91-sgu-xrdp.conf"
|
||||||
|
|
||||||
|
# Do not disclose a list of local/domain accounts at the console. Slick
|
||||||
|
# Greeter still provides the explicit manual prompt needed for a first AD
|
||||||
|
# sign-in (AL/AD/DO identifier and password).
|
||||||
|
if [[ -d /etc/lightdm/lightdm.conf.d ]]; then
|
||||||
|
local temporary_lightdm_configuration
|
||||||
|
temporary_lightdm_configuration=$(mktemp)
|
||||||
|
printf '%s\n' \
|
||||||
|
'[Seat:*]' \
|
||||||
|
'greeter-show-manual-login=true' \
|
||||||
|
'greeter-hide-users=true' >"$temporary_lightdm_configuration"
|
||||||
|
install -o root -g root -m 644 "$temporary_lightdm_configuration" \
|
||||||
|
'/etc/lightdm/lightdm.conf.d/91-sgu-domain-login.conf'
|
||||||
|
rm -f "$temporary_lightdm_configuration"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_ssh() {
|
||||||
|
[[ $ENABLE_SSH == true ]] || return 0
|
||||||
|
local service_name='sshd'
|
||||||
|
if systemctl list-unit-files ssh.service >/dev/null 2>&1; then
|
||||||
|
service_name='ssh'
|
||||||
|
fi
|
||||||
|
systemctl enable --now "$service_name"
|
||||||
|
if command -v ufw >/dev/null 2>&1 && ufw status | grep -q '^Status: active'; then
|
||||||
|
ufw allow OpenSSH
|
||||||
|
elif command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld; then
|
||||||
|
firewall-cmd --permanent --add-service=ssh
|
||||||
|
firewall-cmd --reload
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_hyperv_enhanced_session() {
|
||||||
|
[[ $ENABLE_HYPERV_ENHANCED_SESSION == true ]] || return 0
|
||||||
|
|
||||||
|
command -v xrdp >/dev/null 2>&1 || {
|
||||||
|
printf 'WARNING: XRDP is unavailable; Hyper-V Enhanced Session was not enabled.\n' >&2
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
local xrdp_configuration='/etc/xrdp/xrdp.ini'
|
||||||
|
[[ -f $xrdp_configuration ]] || {
|
||||||
|
printf 'WARNING: %s is missing; Hyper-V Enhanced Session was not enabled.\n' "$xrdp_configuration" >&2
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# VMConnect uses AF_VSOCK rather than TCP. Only change the first occurrence,
|
||||||
|
# which belongs to [Globals]; later port entries describe XRDP backends.
|
||||||
|
sed -Ei '0,/^port=.*/s|^port=.*|port=vsock://-1:3389|' "$xrdp_configuration"
|
||||||
|
if grep -q '^use_vsock=' "$xrdp_configuration"; then
|
||||||
|
sed -Ei '0,/^use_vsock=.*/s|^use_vsock=.*|use_vsock=true|' "$xrdp_configuration"
|
||||||
|
else
|
||||||
|
sed -Ei '/^port=vsock:\/\/-1:3389/a use_vsock=true' "$xrdp_configuration"
|
||||||
|
fi
|
||||||
|
sed -Ei '0,/^security_layer=.*/s|^security_layer=.*|security_layer=rdp|' "$xrdp_configuration"
|
||||||
|
sed -Ei '0,/^crypt_level=.*/s|^crypt_level=.*|crypt_level=none|' "$xrdp_configuration"
|
||||||
|
|
||||||
|
# A clean Ubuntu installation can contain XRDP symlinks before the
|
||||||
|
# snake-oil certificate has actually been generated.
|
||||||
|
if [[ ! -s /etc/ssl/certs/ssl-cert-snakeoil.pem || \
|
||||||
|
! -s /etc/ssl/private/ssl-cert-snakeoil.key ]]; then
|
||||||
|
if command -v make-ssl-cert >/dev/null 2>&1; then
|
||||||
|
make-ssl-cert generate-default-snakeoil --force-overwrite
|
||||||
|
else
|
||||||
|
printf 'WARNING: make-ssl-cert is unavailable; XRDP certificate generation was skipped.\n' >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
usermod -aG ssl-cert xrdp
|
||||||
|
|
||||||
|
# xrdp-sesman (root) and xrdp (the xrdp account) share /run/xrdp. Give the
|
||||||
|
# directory the shared group/mode so the second service can create its PID
|
||||||
|
# file instead of timing out while VMConnect remains at "Connecting".
|
||||||
|
local override_directory='/etc/systemd/system/xrdp-sesman.service.d'
|
||||||
|
local temporary_override
|
||||||
|
temporary_override=$(mktemp)
|
||||||
|
printf '%s\n' \
|
||||||
|
'[Service]' \
|
||||||
|
'Group=xrdp' \
|
||||||
|
'RuntimeDirectory=xrdp' \
|
||||||
|
'RuntimeDirectoryMode=0775' >"$temporary_override"
|
||||||
|
install -d -o root -g root -m 755 "$override_directory"
|
||||||
|
install -o root -g root -m 644 "$temporary_override" \
|
||||||
|
"${override_directory}/sgu-runtime.conf"
|
||||||
|
rm -f "$temporary_override"
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable xrdp xrdp-sesman
|
||||||
|
systemctl restart xrdp
|
||||||
|
systemctl is-active --quiet xrdp
|
||||||
|
systemctl is-active --quiet xrdp-sesman
|
||||||
|
}
|
||||||
|
|
||||||
|
install_welcome_wallpaper() {
|
||||||
|
local source_directory="${SCRIPT_DIRECTORY}/welcome-wallpaper"
|
||||||
|
local source_script="${source_directory}/Set-SguWelcomeWallpaper.sh"
|
||||||
|
local source_image="${source_directory}/darkblue.jpg"
|
||||||
|
local install_directory='/usr/local/lib/sgu-welcome-wallpaper'
|
||||||
|
local configuration_directory='/etc/sgu'
|
||||||
|
local autostart_directory='/etc/xdg/autostart'
|
||||||
|
|
||||||
|
if [[ ! -r $source_script || ! -r $source_image ]]; then
|
||||||
|
printf 'WARNING: Welcome wallpaper assets are absent; domain enrollment will continue without desktop branding.\n' >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Desktop branding is optional and must never invalidate an otherwise valid
|
||||||
|
# domain join. Install its distribution-specific dependencies best-effort.
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
if ! apt_get_with_retry install -y imagemagick ldap-utils fontconfig; then
|
||||||
|
printf 'WARNING: Could not install welcome wallpaper dependencies; enrollment remains valid.\n' >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
if ! dnf install -y ImageMagick openldap-clients fontconfig; then
|
||||||
|
printf 'WARNING: Could not install welcome wallpaper dependencies; enrollment remains valid.\n' >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -d -o root -g root -m 755 "$install_directory" "$configuration_directory" "$autostart_directory"
|
||||||
|
install -o root -g root -m 755 "$source_script" "${install_directory}/Set-SguWelcomeWallpaper.sh"
|
||||||
|
install -o root -g root -m 644 "$source_image" "${install_directory}/darkblue.jpg"
|
||||||
|
if compgen -G "${source_directory}/fonts/*.[ot]tf" >/dev/null; then
|
||||||
|
install -d -o root -g root -m 755 "${install_directory}/fonts"
|
||||||
|
install -o root -g root -m 644 "${source_directory}"/fonts/*.[ot]tf "${install_directory}/fonts/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local base_dn=''
|
||||||
|
local component
|
||||||
|
IFS='.' read -ra domain_components <<<"$DOMAIN_NAME"
|
||||||
|
for component in "${domain_components[@]}"; do
|
||||||
|
if [[ -n $base_dn ]]; then
|
||||||
|
base_dn+=','
|
||||||
|
fi
|
||||||
|
base_dn+="DC=${component}"
|
||||||
|
done
|
||||||
|
|
||||||
|
local temporary_configuration
|
||||||
|
temporary_configuration=$(mktemp)
|
||||||
|
printf 'DOMAIN_CONTROLLER=%q\nDOMAIN_NAME=%q\nBASE_DN=%q\n' \
|
||||||
|
"$DOMAIN_CONTROLLER" "$DOMAIN_NAME" "$base_dn" >"$temporary_configuration"
|
||||||
|
install -o root -g root -m 644 "$temporary_configuration" \
|
||||||
|
"${configuration_directory}/welcome-wallpaper.conf"
|
||||||
|
rm -f "$temporary_configuration"
|
||||||
|
|
||||||
|
local temporary_autostart
|
||||||
|
temporary_autostart=$(mktemp)
|
||||||
|
cat >"$temporary_autostart" <<'EOF'
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=SGU welcome wallpaper
|
||||||
|
Comment=Generate a personalized La Salle laboratory welcome wallpaper
|
||||||
|
Exec=/usr/local/lib/sgu-welcome-wallpaper/Set-SguWelcomeWallpaper.sh
|
||||||
|
Terminal=false
|
||||||
|
NoDisplay=true
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
|
X-Cinnamon-Autostart-enabled=true
|
||||||
|
EOF
|
||||||
|
install -o root -g root -m 644 "$temporary_autostart" \
|
||||||
|
"${autostart_directory}/sgu-welcome-wallpaper.desktop"
|
||||||
|
rm -f "$temporary_autostart"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_managed_rustdesk() {
|
||||||
|
[[ $ENABLE_RUSTDESK == true ]] || return 0
|
||||||
|
local installer="${SCRIPT_DIRECTORY}/Install-SguLinuxRustDeskClient.sh"
|
||||||
|
if [[ ! -r $installer ]]; then
|
||||||
|
fail 'The managed Linux RustDesk installer is missing from this bootstrap package.'
|
||||||
|
fi
|
||||||
|
local -a parameters=(--domain-name "$DOMAIN_NAME")
|
||||||
|
if [[ -n $RUSTDESK_REGISTRATION_SHARE ]]; then
|
||||||
|
parameters+=(--registration-share "$RUSTDESK_REGISTRATION_SHARE")
|
||||||
|
fi
|
||||||
|
bash "$installer" "${parameters[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_domain_connectivity() {
|
||||||
|
need_command getent
|
||||||
|
getent ahostsv4 "$DOMAIN_CONTROLLER" >/dev/null || \
|
||||||
|
fail "Could not resolve the domain controller: $DOMAIN_CONTROLLER"
|
||||||
|
if command -v resolvectl >/dev/null 2>&1; then
|
||||||
|
resolvectl query --type=SRV "_ldap._tcp.dc._msdcs.${DOMAIN_NAME}" >/dev/null || \
|
||||||
|
fail "AD DNS does not provide _ldap._tcp.dc._msdcs.${DOMAIN_NAME}."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_private_ad_interface
|
||||||
|
install_prerequisites
|
||||||
|
verify_domain_connectivity
|
||||||
|
|
||||||
|
# Establish a canonical host name before adcli creates or refreshes the
|
||||||
|
# computer object, SPNs, and keytab entries.
|
||||||
|
hostnamectl set-hostname "$HOST_FQDN"
|
||||||
|
|
||||||
|
if realm list --name-only 2>/dev/null | grep -Fxqi "$DOMAIN_NAME"; then
|
||||||
|
printf 'Computer is already joined to %s; validating and refreshing configuration.\n' "$DOMAIN_NAME"
|
||||||
|
else
|
||||||
|
realm discover "$DOMAIN_NAME" >/dev/null
|
||||||
|
printf 'Joining %s. realmd will request the password for %s interactively.\n' "$DOMAIN_NAME" "$JOIN_USER"
|
||||||
|
realm join \
|
||||||
|
--membership-software=adcli \
|
||||||
|
--client-software=sssd \
|
||||||
|
--computer-ou="$COMPUTER_OU" \
|
||||||
|
--user="$JOIN_USER" \
|
||||||
|
"$DOMAIN_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
enable_sssd_dyndns
|
||||||
|
enable_short_domain_login_names
|
||||||
|
configure_sssd_responder_mode
|
||||||
|
configure_graphical_domain_login
|
||||||
|
systemctl enable --now sssd
|
||||||
|
sssctl config-check
|
||||||
|
systemctl restart sssd
|
||||||
|
adcli update --domain="$DOMAIN_NAME" --host-fqdn="$HOST_FQDN" --computer-name="$COMPUTER_NAME"
|
||||||
|
adcli testjoin --domain="$DOMAIN_NAME"
|
||||||
|
|
||||||
|
if [[ -n $ALLOW_GROUP ]]; then
|
||||||
|
realm deny --all
|
||||||
|
realm permit --groups "$ALLOW_GROUP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
enable_ssh
|
||||||
|
configure_hyperv_enhanced_session
|
||||||
|
install_welcome_wallpaper
|
||||||
|
install_managed_rustdesk
|
||||||
|
|
||||||
|
printf '\nLinux enrollment completed.\n'
|
||||||
|
printf ' Host: %s\n' "$HOST_FQDN"
|
||||||
|
printf ' Domain: %s\n' "$DOMAIN_NAME"
|
||||||
|
printf ' OU: %s\n' "$COMPUTER_OU"
|
||||||
|
printf ' Login format: %%U@%s\n' "$DOMAIN_NAME"
|
||||||
|
printf ' Welcome wallpaper: generated at each graphical sign-in when the desktop is supported.\n'
|
||||||
|
if [[ $ENABLE_RUSTDESK == true ]]; then
|
||||||
|
printf ' RustDesk: configured and registered in the controller inventory.\n'
|
||||||
|
fi
|
||||||
|
realm list
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
[CmdletBinding(SupportsShouldProcess)]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$SubscriptionId,
|
||||||
|
[Parameter(Mandatory)][string]$ResourceGroupName,
|
||||||
|
[Parameter(Mandatory)][string]$VpnGatewayName,
|
||||||
|
[string]$OutputPath = (Join-Path $PSScriptRoot '..\artifacts\azure-p2s\sgu-azure-vpn-client.zip')
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProgressPreference = 'SilentlyContinue'
|
||||||
|
if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
|
||||||
|
throw 'Azure CLI is required.'
|
||||||
|
}
|
||||||
|
& az account set --subscription $SubscriptionId --only-show-errors
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Could not select Azure subscription $SubscriptionId."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($PSCmdlet.ShouldProcess($OutputPath, 'Generate and download the Azure P2S client package')) {
|
||||||
|
$downloadUriText = & az network vnet-gateway vpn-client generate `
|
||||||
|
--resource-group $ResourceGroupName `
|
||||||
|
--name $VpnGatewayName `
|
||||||
|
--processor-architecture Amd64 `
|
||||||
|
--authentication-method EAPTLS `
|
||||||
|
--only-show-errors `
|
||||||
|
--output tsv
|
||||||
|
$downloadUriText = ($downloadUriText -join '').Trim()
|
||||||
|
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($downloadUriText)) {
|
||||||
|
throw 'Azure did not generate a P2S client package URL.'
|
||||||
|
}
|
||||||
|
$downloadUri = $null
|
||||||
|
if (-not [uri]::TryCreate($downloadUriText, [UriKind]::Absolute, [ref]$downloadUri) -or
|
||||||
|
$downloadUri.Scheme -ne 'https') {
|
||||||
|
throw 'Azure returned an invalid VPN client package URL.'
|
||||||
|
}
|
||||||
|
$resolvedOutputPath = [IO.Path]::GetFullPath($OutputPath)
|
||||||
|
New-Item -ItemType Directory -Path (Split-Path $resolvedOutputPath -Parent) -Force | Out-Null
|
||||||
|
Invoke-WebRequest -Uri $downloadUri -OutFile $resolvedOutputPath -UseBasicParsing
|
||||||
|
if ((Get-Item -LiteralPath $resolvedOutputPath).Length -lt 1024) {
|
||||||
|
throw 'The downloaded VPN client package is unexpectedly small.'
|
||||||
|
}
|
||||||
|
[pscustomobject]@{
|
||||||
|
PackagePath = $resolvedOutputPath
|
||||||
|
Sha256 = (Get-FileHash -LiteralPath $resolvedOutputPath -Algorithm SHA256).Hash
|
||||||
|
VpnGatewayName = $VpnGatewayName
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[datetime]$Since = (Get-Date).AddDays(-183),
|
||||||
|
[datetime]$Until = (Get-Date),
|
||||||
|
[string]$UserName,
|
||||||
|
[ValidateSet('Critical','Error','Warning','Information','Verbose')]
|
||||||
|
[string]$Level,
|
||||||
|
[int[]]$EventId,
|
||||||
|
[string]$Text,
|
||||||
|
[string]$MonitoringRoot = 'C:\ProgramData\SGU\Monitoring',
|
||||||
|
[string]$BrokerEventLogName = 'SGU Auth Broker',
|
||||||
|
[string]$OutputCsv
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$events = [Collections.Generic.List[object]]::new()
|
||||||
|
$eventNames = @{
|
||||||
|
900 = 'BrokerStarted'
|
||||||
|
1000 = 'AuthenticationAuthorized'
|
||||||
|
1001 = 'AuthenticationRejected'
|
||||||
|
1002 = 'AuthenticationUnavailable'
|
||||||
|
1003 = 'AuthenticationInvalidRequest'
|
||||||
|
1100 = 'SguAuthenticationAccepted'
|
||||||
|
1101 = 'SguAuthenticationTimeout'
|
||||||
|
1102 = 'SguAuthenticationNetworkFailure'
|
||||||
|
1200 = 'ProfileEnrichmentCompleted'
|
||||||
|
1201 = 'ProfileHtmlUnexpected'
|
||||||
|
1202 = 'ProfileEnrichmentTimeout'
|
||||||
|
1203 = 'ProfileEnrichmentFailure'
|
||||||
|
1204 = 'ProfilePageUnavailable'
|
||||||
|
1300 = 'DirectorySynchronizationFailure'
|
||||||
|
1301 = 'DirectoryOptionalMetadataFailure'
|
||||||
|
1302 = 'DirectoryGroupMembershipFailure'
|
||||||
|
1303 = 'DirectoryRoleGroupMembershipAdded'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep these reads unfiltered. Besides making archived and current logs behave
|
||||||
|
# identically, this avoids the Windows Server 2025 ForwardedEvents query defect.
|
||||||
|
if (Get-WinEvent -ListLog $BrokerEventLogName -ErrorAction SilentlyContinue) {
|
||||||
|
Get-WinEvent -LogName $BrokerEventLogName -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.TimeCreated -ge $Since -and $_.TimeCreated -le $Until } |
|
||||||
|
ForEach-Object { $events.Add($_) }
|
||||||
|
}
|
||||||
|
|
||||||
|
$brokerArchiveRoot = Join-Path $MonitoringRoot 'Archive\Broker'
|
||||||
|
Get-ChildItem -LiteralPath $brokerArchiveRoot -Filter '*.evtx' -File -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object LastWriteTime -ge $Since.AddDays(-1) |
|
||||||
|
ForEach-Object {
|
||||||
|
try {
|
||||||
|
Get-WinEvent -Path $_.FullName -Oldest -ErrorAction Stop |
|
||||||
|
Where-Object { $_.TimeCreated -ge $Since -and $_.TimeCreated -le $Until } |
|
||||||
|
ForEach-Object { $events.Add($_) }
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not read broker archive $($_.FullName): $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = @($events | Where-Object {
|
||||||
|
(-not $UserName -or $_.Message -like "*$UserName*") -and
|
||||||
|
(-not $Level -or $_.LevelDisplayName -eq $Level) -and
|
||||||
|
(-not $EventId -or $_.Id -in $EventId) -and
|
||||||
|
(-not $Text -or $_.Message -like "*$Text*")
|
||||||
|
} | Sort-Object TimeCreated -Descending | ForEach-Object {
|
||||||
|
[pscustomobject]@{
|
||||||
|
TimeCreated = $_.TimeCreated
|
||||||
|
Level = $_.LevelDisplayName
|
||||||
|
EventId = $_.Id
|
||||||
|
EventName = $eventNames[[int]$_.Id]
|
||||||
|
Provider = $_.ProviderName
|
||||||
|
Message = $_.Message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if ($OutputCsv) {
|
||||||
|
$resolvedOutput = [IO.Path]::GetFullPath($OutputCsv)
|
||||||
|
New-Item -ItemType Directory -Path (Split-Path $resolvedOutput -Parent) -Force | Out-Null
|
||||||
|
$result | Export-Csv -LiteralPath $resolvedOutput -NoTypeInformation -Encoding UTF8
|
||||||
|
}
|
||||||
|
|
||||||
|
$result
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[datetime]$Since = (Get-Date).AddDays(-183),
|
||||||
|
[datetime]$Until = (Get-Date),
|
||||||
|
[string]$UserName,
|
||||||
|
[string]$ComputerName,
|
||||||
|
[string]$MonitoringRoot = 'C:\ProgramData\SGU\Monitoring',
|
||||||
|
[string]$OutputCsv
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$eventIds = @(4624,4625,4634,4647,4778,4779,6005,6006,6008)
|
||||||
|
$events = [Collections.Generic.List[object]]::new()
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Windows Server 2025 can crash the Windows Event Log service when a
|
||||||
|
# structured query is evaluated against ForwardedEvents (wevtsvc.dll,
|
||||||
|
# exception 0xc0000420). Read the channel without a server-side query and
|
||||||
|
# apply every predicate in this process instead.
|
||||||
|
Get-WinEvent -LogName 'ForwardedEvents' -ErrorAction Stop |
|
||||||
|
Where-Object {
|
||||||
|
$_.Id -in $eventIds -and
|
||||||
|
$_.TimeCreated -ge $Since -and
|
||||||
|
$_.TimeCreated -le $Until
|
||||||
|
} |
|
||||||
|
ForEach-Object { $events.Add($_) }
|
||||||
|
}
|
||||||
|
catch [System.Exception] {
|
||||||
|
if ($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*') {
|
||||||
|
Write-Verbose $_.Exception.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$archiveRoot = Join-Path $MonitoringRoot 'Archive'
|
||||||
|
Get-ChildItem -LiteralPath $archiveRoot -Filter '*.evtx' -File -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object LastWriteTime -ge $Since.AddDays(-1) |
|
||||||
|
ForEach-Object {
|
||||||
|
try {
|
||||||
|
Get-WinEvent -Path $_.FullName -Oldest -ErrorAction Stop |
|
||||||
|
Where-Object { $_.Id -in $eventIds -and $_.TimeCreated -ge $Since -and $_.TimeCreated -le $Until } |
|
||||||
|
ForEach-Object { $events.Add($_) }
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "Could not read archive $($_.FullName): $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-EventData {
|
||||||
|
param([Parameter(Mandatory)]$EventRecord)
|
||||||
|
|
||||||
|
$xml = [xml]$EventRecord.ToXml()
|
||||||
|
$data = @{}
|
||||||
|
foreach ($item in @($xml.Event.EventData.Data)) {
|
||||||
|
if ($item.Name) {
|
||||||
|
$data[[string]$item.Name] = [string]$item.'#text'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[pscustomobject]@{
|
||||||
|
Computer = [string]$xml.Event.System.Computer
|
||||||
|
Data = $data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$openSessions = @{}
|
||||||
|
$rows = [Collections.Generic.List[object]]::new()
|
||||||
|
$ignoredUsers = @('ANONYMOUS LOGON','DWM-1','DWM-2','DWM-3','LOCAL SERVICE','NETWORK SERVICE','SYSTEM','UMFD-0','UMFD-1','UMFD-2','UMFD-3')
|
||||||
|
|
||||||
|
foreach ($eventRecord in @($events | Sort-Object TimeCreated,RecordId)) {
|
||||||
|
$parsed = Get-EventData -EventRecord $eventRecord
|
||||||
|
$machine = ($parsed.Computer -split '\.')[0].ToUpperInvariant()
|
||||||
|
$data = $parsed.Data
|
||||||
|
|
||||||
|
if ($eventRecord.Id -in 6005,6006,6008) {
|
||||||
|
foreach ($key in @($openSessions.Keys | Where-Object { $_ -like "$machine|*" })) {
|
||||||
|
$session = $openSessions[$key]
|
||||||
|
$rows.Add([pscustomobject]@{
|
||||||
|
User = $session.User
|
||||||
|
Computer = $machine
|
||||||
|
StartedAt = $session.StartedAt
|
||||||
|
EndedAt = $eventRecord.TimeCreated
|
||||||
|
Duration = $eventRecord.TimeCreated - $session.StartedAt
|
||||||
|
DurationMinutes = [math]::Round(($eventRecord.TimeCreated - $session.StartedAt).TotalMinutes, 2)
|
||||||
|
LogonType = $session.LogonType
|
||||||
|
Result = 'Interrumpida por apagado o reinicio'
|
||||||
|
FailureStatus = $null
|
||||||
|
})
|
||||||
|
$openSessions.Remove($key)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($eventRecord.Id -eq 4625) {
|
||||||
|
$failedUser = [string]$data.TargetUserName
|
||||||
|
if ($failedUser -and $failedUser -notlike '*$' -and $failedUser.ToUpperInvariant() -notin $ignoredUsers) {
|
||||||
|
$rows.Add([pscustomobject]@{
|
||||||
|
User = if ($data.TargetDomainName) { "$($data.TargetDomainName)\$failedUser" } else { $failedUser }
|
||||||
|
Computer = $machine
|
||||||
|
StartedAt = $eventRecord.TimeCreated
|
||||||
|
EndedAt = $eventRecord.TimeCreated
|
||||||
|
Duration = [timespan]::Zero
|
||||||
|
DurationMinutes = 0
|
||||||
|
LogonType = [string]$data.LogonType
|
||||||
|
Result = 'Fallida'
|
||||||
|
FailureStatus = "$($data.Status)/$($data.SubStatus)"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($eventRecord.Id -eq 4624) {
|
||||||
|
$logonType = [string]$data.LogonType
|
||||||
|
$targetUser = [string]$data.TargetUserName
|
||||||
|
if ($logonType -notin @('2','10','11') -or -not $targetUser -or $targetUser -like '*$' -or
|
||||||
|
$targetUser.ToUpperInvariant() -in $ignoredUsers) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$logonId = [string]$data.TargetLogonId
|
||||||
|
$key = "$machine|$logonId"
|
||||||
|
$openSessions[$key] = [pscustomobject]@{
|
||||||
|
User = if ($data.TargetDomainName) { "$($data.TargetDomainName)\$targetUser" } else { $targetUser }
|
||||||
|
StartedAt = $eventRecord.TimeCreated
|
||||||
|
LogonType = $logonType
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($eventRecord.Id -in 4634,4647) {
|
||||||
|
$logonId = if ($eventRecord.Id -eq 4634) { [string]$data.TargetLogonId } else { [string]$data.SubjectLogonId }
|
||||||
|
$key = "$machine|$logonId"
|
||||||
|
if ($openSessions.ContainsKey($key)) {
|
||||||
|
$session = $openSessions[$key]
|
||||||
|
$rows.Add([pscustomobject]@{
|
||||||
|
User = $session.User
|
||||||
|
Computer = $machine
|
||||||
|
StartedAt = $session.StartedAt
|
||||||
|
EndedAt = $eventRecord.TimeCreated
|
||||||
|
Duration = $eventRecord.TimeCreated - $session.StartedAt
|
||||||
|
DurationMinutes = [math]::Round(($eventRecord.TimeCreated - $session.StartedAt).TotalMinutes, 2)
|
||||||
|
LogonType = $session.LogonType
|
||||||
|
Result = 'Completada'
|
||||||
|
FailureStatus = $null
|
||||||
|
})
|
||||||
|
$openSessions.Remove($key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($key in $openSessions.Keys) {
|
||||||
|
$session = $openSessions[$key]
|
||||||
|
$machine = ($key -split '\|', 2)[0]
|
||||||
|
$rows.Add([pscustomobject]@{
|
||||||
|
User = $session.User
|
||||||
|
Computer = $machine
|
||||||
|
StartedAt = $session.StartedAt
|
||||||
|
EndedAt = $null
|
||||||
|
Duration = $Until - $session.StartedAt
|
||||||
|
DurationMinutes = [math]::Round(($Until - $session.StartedAt).TotalMinutes, 2)
|
||||||
|
LogonType = $session.LogonType
|
||||||
|
Result = 'Sesión posiblemente activa'
|
||||||
|
FailureStatus = $null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = @($rows | Where-Object {
|
||||||
|
(-not $UserName -or $_.User -like "*$UserName*") -and
|
||||||
|
(-not $ComputerName -or $_.Computer -like "*$ComputerName*")
|
||||||
|
} | Sort-Object StartedAt -Descending)
|
||||||
|
|
||||||
|
if ($OutputCsv) {
|
||||||
|
$resolvedOutput = [IO.Path]::GetFullPath($OutputCsv)
|
||||||
|
New-Item -ItemType Directory -Path (Split-Path $resolvedOutput -Parent) -Force | Out-Null
|
||||||
|
$result | Export-Csv -LiteralPath $resolvedOutput -NoTypeInformation -Encoding UTF8
|
||||||
|
}
|
||||||
|
|
||||||
|
$result
|
||||||
@@ -6,10 +6,14 @@ param(
|
|||||||
[int]$PrefixLength = 24,
|
[int]$PrefixLength = 24,
|
||||||
[string]$NetworkInterfaceAlias,
|
[string]$NetworkInterfaceAlias,
|
||||||
[ipaddress]$DefaultGateway,
|
[ipaddress]$DefaultGateway,
|
||||||
|
[ValidateSet('GuestStatic', 'PlatformManaged')]
|
||||||
|
[string]$NetworkConfigurationMode = 'GuestStatic',
|
||||||
|
[string[]]$TrustedClientNetworks = @(),
|
||||||
[ipaddress[]]$DnsForwarders = @(),
|
[ipaddress[]]$DnsForwarders = @(),
|
||||||
[string]$DomainName = 'lci.lasalle.mx',
|
[string]$DomainName = 'lci.lasalle.mx',
|
||||||
[string]$DomainNetbios = 'LCI',
|
[string]$DomainNetbios = 'LCI',
|
||||||
[string]$BrokerRecordName = 'sgu-auth',
|
[string]$BrokerRecordName = 'sgu-auth',
|
||||||
|
[string]$RustDeskRecordName = 'rustdesk',
|
||||||
[string]$PackageSharePath = 'C:\Packages',
|
[string]$PackageSharePath = 'C:\Packages',
|
||||||
[securestring]$SafeModeAdministratorPassword,
|
[securestring]$SafeModeAdministratorPassword,
|
||||||
[switch]$SkipRestart,
|
[switch]$SkipRestart,
|
||||||
@@ -68,6 +72,67 @@ function Get-DomainBaseDn {
|
|||||||
return (($DnsDomainName -split '\.') | ForEach-Object { "DC=$_" }) -join ','
|
return (($DnsDomainName -split '\.') | ForEach-Object { "DC=$_" }) -join ','
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-PrivateIPv4Address {
|
||||||
|
param([Parameter(Mandatory)][ipaddress]$Address)
|
||||||
|
|
||||||
|
if ($Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
$bytes = $Address.GetAddressBytes()
|
||||||
|
return $bytes[0] -eq 10 -or
|
||||||
|
($bytes[0] -eq 172 -and $bytes[1] -ge 16 -and $bytes[1] -le 31) -or
|
||||||
|
($bytes[0] -eq 192 -and $bytes[1] -eq 168)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-NetworkCidr {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][ipaddress]$Address,
|
||||||
|
[Parameter(Mandatory)][ValidateRange(1, 32)][int]$NetworkPrefixLength
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($Address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
|
||||||
|
throw 'Only IPv4 networks are supported by the SGU bootstrap.'
|
||||||
|
}
|
||||||
|
$addressBytes = $Address.GetAddressBytes()
|
||||||
|
$networkBytes = [byte[]]::new(4)
|
||||||
|
$remainingBits = $NetworkPrefixLength
|
||||||
|
for ($index = 0; $index -lt 4; $index++) {
|
||||||
|
$mask = if ($remainingBits -ge 8) {
|
||||||
|
255
|
||||||
|
}
|
||||||
|
elseif ($remainingBits -le 0) {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
256 - [Math]::Pow(2, 8 - $remainingBits)
|
||||||
|
}
|
||||||
|
$networkBytes[$index] = [byte]($addressBytes[$index] -band [int]$mask)
|
||||||
|
$remainingBits -= 8
|
||||||
|
}
|
||||||
|
return "$(($networkBytes | ForEach-Object { [string]$_ }) -join '.')/$NetworkPrefixLength"
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-PrivateNetworkCidr {
|
||||||
|
param([Parameter(Mandatory)][string]$Cidr)
|
||||||
|
|
||||||
|
if ($Cidr -notmatch '^([^/]+)/(\d{1,2})$') {
|
||||||
|
throw "Trusted client network '$Cidr' must use IPv4 CIDR notation, for example 172.30.0.0/24."
|
||||||
|
}
|
||||||
|
$address = $null
|
||||||
|
if (-not [ipaddress]::TryParse($Matches[1], [ref]$address) -or
|
||||||
|
$address.AddressFamily -ne [Net.Sockets.AddressFamily]::InterNetwork) {
|
||||||
|
throw "Trusted client network '$Cidr' is not a valid IPv4 network."
|
||||||
|
}
|
||||||
|
$networkPrefixLength = [int]$Matches[2]
|
||||||
|
if ($networkPrefixLength -lt 1 -or $networkPrefixLength -gt 32) {
|
||||||
|
throw "Trusted client network '$Cidr' has an invalid prefix length."
|
||||||
|
}
|
||||||
|
if (-not (Test-PrivateIPv4Address -Address $address)) {
|
||||||
|
throw "Trusted client network '$Cidr' is not private RFC1918 space. The bootstrap never exposes AD services to public client addresses."
|
||||||
|
}
|
||||||
|
return ConvertTo-NetworkCidr -Address $address -NetworkPrefixLength $networkPrefixLength
|
||||||
|
}
|
||||||
|
|
||||||
function Resolve-PrivateInterfaceAlias {
|
function Resolve-PrivateInterfaceAlias {
|
||||||
param([string]$RequestedAlias)
|
param([string]$RequestedAlias)
|
||||||
|
|
||||||
@@ -146,6 +211,27 @@ function Set-StaticDomainAddress {
|
|||||||
-ServerAddresses $Address.IPAddressToString
|
-ServerAddresses $Address.IPAddressToString
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Assert-PlatformManagedDomainAddress {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$InterfaceAlias,
|
||||||
|
[Parameter(Mandatory)][ipaddress]$Address,
|
||||||
|
[Parameter(Mandatory)][int]$NetworkPrefixLength
|
||||||
|
)
|
||||||
|
|
||||||
|
$adapter = Get-NetAdapter -Name $InterfaceAlias -ErrorAction Stop
|
||||||
|
$matchingAddress = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
|
||||||
|
-IPAddress $Address.IPAddressToString -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object PrefixLength -eq $NetworkPrefixLength |
|
||||||
|
Select-Object -First 1
|
||||||
|
if (-not $matchingAddress) {
|
||||||
|
$observed = @(Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 `
|
||||||
|
-ErrorAction SilentlyContinue |
|
||||||
|
Where-Object PrefixOrigin -ne 'WellKnown' |
|
||||||
|
ForEach-Object { "$($_.IPAddress)/$($_.PrefixLength)" }) -join ', '
|
||||||
|
throw "PlatformManaged mode expected $Address/$NetworkPrefixLength on $InterfaceAlias, but found: $observed. Configure a static private IP on the Azure NIC before running the bootstrap; do not assign it inside Windows."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Register-ResumeTask {
|
function Register-ResumeTask {
|
||||||
param([Parameter(Mandatory)][string]$ScriptPath)
|
param([Parameter(Mandatory)][string]$ScriptPath)
|
||||||
|
|
||||||
@@ -309,16 +395,29 @@ if ($Resume -or (-not $ServerIPv4Address -and $existingState)) {
|
|||||||
$PrefixLength = [int]$existingState.PrefixLength
|
$PrefixLength = [int]$existingState.PrefixLength
|
||||||
$NetworkInterfaceAlias = [string]$existingState.NetworkInterfaceAlias
|
$NetworkInterfaceAlias = [string]$existingState.NetworkInterfaceAlias
|
||||||
$DefaultGateway = if ($existingState.DefaultGateway) { [ipaddress][string]$existingState.DefaultGateway } else { $null }
|
$DefaultGateway = if ($existingState.DefaultGateway) { [ipaddress][string]$existingState.DefaultGateway } else { $null }
|
||||||
|
$NetworkConfigurationMode = if ($existingState.NetworkConfigurationMode) { [string]$existingState.NetworkConfigurationMode } else { 'GuestStatic' }
|
||||||
|
$TrustedClientNetworks = if ($existingState.TrustedClientNetworks) { @($existingState.TrustedClientNetworks | ForEach-Object { [string]$_ }) } else { @() }
|
||||||
$DnsForwarders = @($existingState.DnsForwarders | ForEach-Object { [ipaddress][string]$_ })
|
$DnsForwarders = @($existingState.DnsForwarders | ForEach-Object { [ipaddress][string]$_ })
|
||||||
$DomainName = [string]$existingState.DomainName
|
$DomainName = [string]$existingState.DomainName
|
||||||
$DomainNetbios = [string]$existingState.DomainNetbios
|
$DomainNetbios = [string]$existingState.DomainNetbios
|
||||||
$BrokerRecordName = [string]$existingState.BrokerRecordName
|
$BrokerRecordName = [string]$existingState.BrokerRecordName
|
||||||
|
$RustDeskRecordName = if ($existingState.RustDeskRecordName) { [string]$existingState.RustDeskRecordName } else { $RustDeskRecordName }
|
||||||
$PackageSharePath = [string]$existingState.PackageSharePath
|
$PackageSharePath = [string]$existingState.PackageSharePath
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not $ServerIPv4Address) {
|
if (-not $ServerIPv4Address) {
|
||||||
$ServerIPv4Address = [ipaddress](Read-Host 'Fixed IPv4 address for this domain controller')
|
$ServerIPv4Address = [ipaddress](Read-Host 'Fixed IPv4 address for this domain controller')
|
||||||
}
|
}
|
||||||
|
if (-not (Test-PrivateIPv4Address -Address $ServerIPv4Address)) {
|
||||||
|
throw 'ServerIPv4Address must be the private address of the domain controller. An Azure public IP is never assigned to AD or published in domain DNS.'
|
||||||
|
}
|
||||||
|
$domainSubnet = ConvertTo-NetworkCidr -Address $ServerIPv4Address `
|
||||||
|
-NetworkPrefixLength $PrefixLength
|
||||||
|
$TrustedClientNetworks = @($TrustedClientNetworks |
|
||||||
|
ForEach-Object { ConvertTo-PrivateNetworkCidr -Cidr $_ } |
|
||||||
|
Where-Object { $_ -ne $domainSubnet } |
|
||||||
|
Select-Object -Unique)
|
||||||
|
$allowedRemoteAddresses = @($domainSubnet) + $TrustedClientNetworks
|
||||||
|
|
||||||
$sourceRoot = $PSScriptRoot
|
$sourceRoot = $PSScriptRoot
|
||||||
if (-not $Resume) {
|
if (-not $Resume) {
|
||||||
@@ -336,6 +435,7 @@ else {
|
|||||||
$NetworkInterfaceAlias = Resolve-PrivateInterfaceAlias -RequestedAlias $NetworkInterfaceAlias
|
$NetworkInterfaceAlias = Resolve-PrivateInterfaceAlias -RequestedAlias $NetworkInterfaceAlias
|
||||||
$baseDn = Get-DomainBaseDn -DnsDomainName $DomainName
|
$baseDn = Get-DomainBaseDn -DnsDomainName $DomainName
|
||||||
$brokerDnsName = "$BrokerRecordName.$DomainName"
|
$brokerDnsName = "$BrokerRecordName.$DomainName"
|
||||||
|
$rustDeskDnsName = "$RustDeskRecordName.$DomainName"
|
||||||
$stagedScriptPath = Join-Path $bootstrapRoot 'Initialize-SguDomainController.ps1'
|
$stagedScriptPath = Join-Path $bootstrapRoot 'Initialize-SguDomainController.ps1'
|
||||||
$scriptsRoot = Join-Path $bootstrapRoot 'payload\scripts'
|
$scriptsRoot = Join-Path $bootstrapRoot 'payload\scripts'
|
||||||
$brokerPublishPath = Join-Path $bootstrapRoot 'payload\broker'
|
$brokerPublishPath = Join-Path $bootstrapRoot 'payload\broker'
|
||||||
@@ -348,6 +448,16 @@ foreach ($requiredPath in @(
|
|||||||
(Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1'),
|
(Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1'),
|
||||||
(Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1'),
|
(Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1'),
|
||||||
(Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.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'))) {
|
(Join-Path $brokerPublishPath 'SGU.AuthBroker.exe'))) {
|
||||||
if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
|
if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
|
||||||
throw "The server bootstrap package is incomplete: $requiredPath"
|
throw "The server bootstrap package is incomplete: $requiredPath"
|
||||||
@@ -370,10 +480,13 @@ if (-not $existingState) {
|
|||||||
PrefixLength = $PrefixLength
|
PrefixLength = $PrefixLength
|
||||||
NetworkInterfaceAlias = $NetworkInterfaceAlias
|
NetworkInterfaceAlias = $NetworkInterfaceAlias
|
||||||
DefaultGateway = if ($DefaultGateway) { $DefaultGateway.IPAddressToString } else { $null }
|
DefaultGateway = if ($DefaultGateway) { $DefaultGateway.IPAddressToString } else { $null }
|
||||||
|
NetworkConfigurationMode = $NetworkConfigurationMode
|
||||||
|
TrustedClientNetworks = $TrustedClientNetworks
|
||||||
DnsForwarders = @($DnsForwarders | ForEach-Object IPAddressToString)
|
DnsForwarders = @($DnsForwarders | ForEach-Object IPAddressToString)
|
||||||
DomainName = $DomainName
|
DomainName = $DomainName
|
||||||
DomainNetbios = $DomainNetbios
|
DomainNetbios = $DomainNetbios
|
||||||
BrokerRecordName = $BrokerRecordName
|
BrokerRecordName = $BrokerRecordName
|
||||||
|
RustDeskRecordName = $RustDeskRecordName
|
||||||
PackageSharePath = $PackageSharePath
|
PackageSharePath = $PackageSharePath
|
||||||
}
|
}
|
||||||
[IO.File]::WriteAllText(
|
[IO.File]::WriteAllText(
|
||||||
@@ -382,9 +495,16 @@ if (-not $existingState) {
|
|||||||
[Text.UTF8Encoding]::new($false))
|
[Text.UTF8Encoding]::new($false))
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-BootstrapLog "Configuring $NetworkInterfaceAlias as $ServerIPv4Address/$PrefixLength."
|
if ($NetworkConfigurationMode -eq 'PlatformManaged') {
|
||||||
Set-StaticDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
|
Write-BootstrapLog "Validating platform-managed address $ServerIPv4Address/$PrefixLength on $NetworkInterfaceAlias without changing DHCP, routes, or the Azure NIC."
|
||||||
-Address $ServerIPv4Address -NetworkPrefixLength $PrefixLength -Gateway $DefaultGateway
|
Assert-PlatformManagedDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
|
||||||
|
-Address $ServerIPv4Address -NetworkPrefixLength $PrefixLength
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-BootstrapLog "Configuring $NetworkInterfaceAlias as $ServerIPv4Address/$PrefixLength."
|
||||||
|
Set-StaticDomainAddress -InterfaceAlias $NetworkInterfaceAlias `
|
||||||
|
-Address $ServerIPv4Address -NetworkPrefixLength $PrefixLength -Gateway $DefaultGateway
|
||||||
|
}
|
||||||
|
|
||||||
$computer = Get-CimInstance Win32_ComputerSystem
|
$computer = Get-CimInstance Win32_ComputerSystem
|
||||||
if (-not $computer.PartOfDomain) {
|
if (-not $computer.PartOfDomain) {
|
||||||
@@ -458,8 +578,17 @@ Wait-ActiveDirectoryReady -ExpectedBaseDn $baseDn
|
|||||||
$domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias `
|
$domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias `
|
||||||
-ErrorAction SilentlyContinue
|
-ErrorAction SilentlyContinue
|
||||||
if (-not $domainProfile -or $domainProfile.NetworkCategory -ne 'DomainAuthenticated') {
|
if (-not $domainProfile -or $domainProfile.NetworkCategory -ne 'DomainAuthenticated') {
|
||||||
Write-BootstrapLog "Refreshing $NetworkInterfaceAlias so Windows detects the domain network profile."
|
if ($NetworkConfigurationMode -eq 'GuestStatic') {
|
||||||
Restart-NetAdapter -Name $NetworkInterfaceAlias -Confirm:$false
|
Write-BootstrapLog "Refreshing $NetworkInterfaceAlias so Windows detects the domain network profile."
|
||||||
|
Restart-NetAdapter -Name $NetworkInterfaceAlias -Confirm:$false
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
# Restarting an Azure NIC from inside the guest can sever the only
|
||||||
|
# management path. Refresh NLA instead; this does not change the
|
||||||
|
# platform-managed address, DHCP lease, route, or link state.
|
||||||
|
Write-BootstrapLog 'Refreshing Network Location Awareness without restarting the Azure adapter.'
|
||||||
|
Restart-Service NlaSvc -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
for ($attempt = 1; $attempt -le 15; $attempt++) {
|
for ($attempt = 1; $attempt -le 15; $attempt++) {
|
||||||
Start-Sleep -Seconds 2
|
Start-Sleep -Seconds 2
|
||||||
$domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias `
|
$domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias `
|
||||||
@@ -512,6 +641,10 @@ if (-not $remoteDesktopGroup) {
|
|||||||
-RecordName $BrokerRecordName `
|
-RecordName $BrokerRecordName `
|
||||||
-IPv4Address $ServerIPv4Address `
|
-IPv4Address $ServerIPv4Address `
|
||||||
-ExternalForwarders $DnsForwarders | Out-Null
|
-ExternalForwarders $DnsForwarders | Out-Null
|
||||||
|
& (Join-Path $scriptsRoot 'Set-LabBrokerDns.ps1') `
|
||||||
|
-ZoneName $DomainName `
|
||||||
|
-RecordName $RustDeskRecordName `
|
||||||
|
-IPv4Address $ServerIPv4Address | Out-Null
|
||||||
|
|
||||||
$certificateDirectory = Join-Path $bootstrapRoot 'certificates'
|
$certificateDirectory = Join-Path $bootstrapRoot 'certificates'
|
||||||
$serverCertificate = Get-ChildItem Cert:\LocalMachine\My |
|
$serverCertificate = Get-ChildItem Cert:\LocalMachine\My |
|
||||||
@@ -559,7 +692,7 @@ if (Test-Path -LiteralPath $brokerConfigurationPath -PathType Leaf) {
|
|||||||
-RemoteDesktopGroupDn $remoteDesktopGroup.DistinguishedName `
|
-RemoteDesktopGroupDn $remoteDesktopGroup.DistinguishedName `
|
||||||
-DefaultCompany 'La Salle' `
|
-DefaultCompany 'La Salle' `
|
||||||
-FirewallLocalAddress $ServerIPv4Address `
|
-FirewallLocalAddress $ServerIPv4Address `
|
||||||
-FirewallRemoteAddress "$($ServerIPv4Address.IPAddressToString)/$PrefixLength" `
|
-FirewallRemoteAddress $allowedRemoteAddresses `
|
||||||
-CreateMissingOus `
|
-CreateMissingOus `
|
||||||
-DisableCertificateRevocationCheckForLab | Out-Null
|
-DisableCertificateRevocationCheckForLab | Out-Null
|
||||||
|
|
||||||
@@ -573,9 +706,8 @@ foreach ($hostRecord in $hostRecords) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$privateSubnet = "$($ServerIPv4Address.IPAddressToString)/$PrefixLength"
|
|
||||||
& (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') `
|
& (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') `
|
||||||
-AllowedRemoteAddress $privateSubnet | Out-Null
|
-AllowedRemoteAddress $allowedRemoteAddresses | Out-Null
|
||||||
|
|
||||||
$contentPath = Join-Path $bootstrapRoot 'payload\server-content\Packages'
|
$contentPath = Join-Path $bootstrapRoot 'payload\server-content\Packages'
|
||||||
if (Test-Path -LiteralPath $contentPath -PathType Container) {
|
if (Test-Path -LiteralPath $contentPath -PathType Container) {
|
||||||
@@ -595,7 +727,7 @@ if (-not $packageFirewallRule) {
|
|||||||
-Protocol TCP `
|
-Protocol TCP `
|
||||||
-LocalPort 445 `
|
-LocalPort 445 `
|
||||||
-LocalAddress $ServerIPv4Address.IPAddressToString `
|
-LocalAddress $ServerIPv4Address.IPAddressToString `
|
||||||
-RemoteAddress $privateSubnet `
|
-RemoteAddress $allowedRemoteAddresses `
|
||||||
-Profile Any | Out-Null
|
-Profile Any | Out-Null
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -603,35 +735,84 @@ else {
|
|||||||
$packageFirewallRule | Get-NetFirewallAddressFilter |
|
$packageFirewallRule | Get-NetFirewallAddressFilter |
|
||||||
Set-NetFirewallAddressFilter `
|
Set-NetFirewallAddressFilter `
|
||||||
-LocalAddress $ServerIPv4Address.IPAddressToString `
|
-LocalAddress $ServerIPv4Address.IPAddressToString `
|
||||||
-RemoteAddress $privateSubnet | Out-Null
|
-RemoteAddress $allowedRemoteAddresses | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$collectorFqdn = "$env:COMPUTERNAME.$DomainName"
|
||||||
& (Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1') `
|
& (Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1') `
|
||||||
-TargetOuDn $laboratoryOuDn -DomainController $env:COMPUTERNAME | Out-Null
|
-TargetOuDn $laboratoryOuDn `
|
||||||
|
-DomainController $env:COMPUTERNAME `
|
||||||
|
-EventCollectorFqdn $collectorFqdn | Out-Null
|
||||||
|
& (Join-Path $scriptsRoot 'Install-SguDomainMonitoring.ps1') `
|
||||||
|
-CollectorFqdn $collectorFqdn `
|
||||||
|
-ComputerOuDn $laboratoryOuDn `
|
||||||
|
-RetentionDays 183 | Out-Null
|
||||||
$userPolicyParameters = @{
|
$userPolicyParameters = @{
|
||||||
TargetOuDn = $usersOuDn
|
TargetOuDn = $usersOuDn
|
||||||
DomainController = $env:COMPUTERNAME
|
DomainController = $env:COMPUTERNAME
|
||||||
}
|
ClearManagedWallpaper = $true
|
||||||
$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)"
|
|
||||||
}
|
}
|
||||||
& (Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1') @userPolicyParameters | Out-Null
|
& (Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1') @userPolicyParameters | Out-Null
|
||||||
|
|
||||||
|
$rustDeskServer = & (Join-Path $scriptsRoot 'Install-SguRustDeskServer.ps1') `
|
||||||
|
-ServerAddress $rustDeskDnsName `
|
||||||
|
-FirewallRemoteAddress $allowedRemoteAddresses
|
||||||
|
$rustDeskManagementRoot = Join-Path $env:ProgramData 'SGU\RustDesk'
|
||||||
|
New-Item -ItemType Directory -Path $rustDeskManagementRoot -Force | Out-Null
|
||||||
|
foreach ($scriptName in @(
|
||||||
|
'Register-SguRustDeskDevice.ps1',
|
||||||
|
'Get-SguRustDeskDevice.ps1',
|
||||||
|
'Install-SguRustDeskLinuxEnrollment.ps1',
|
||||||
|
'Invoke-SguRustDeskLinuxRegistrationProcessor.ps1')) {
|
||||||
|
Copy-Item -LiteralPath (Join-Path $scriptsRoot $scriptName) `
|
||||||
|
-Destination (Join-Path $rustDeskManagementRoot $scriptName) -Force
|
||||||
|
}
|
||||||
|
$rustDeskLinuxEnrollment = & (Join-Path $rustDeskManagementRoot 'Install-SguRustDeskLinuxEnrollment.ps1') `
|
||||||
|
-DomainName $DomainName `
|
||||||
|
-ServerAddress $rustDeskDnsName `
|
||||||
|
-ServerPublicKey $rustDeskServer.PublicKey `
|
||||||
|
-ProcessorScriptPath (Join-Path $rustDeskManagementRoot 'Invoke-SguRustDeskLinuxRegistrationProcessor.ps1')
|
||||||
|
$rustDeskServerClient = & (Join-Path $scriptsRoot 'Install-SguRustDeskClient.ps1') `
|
||||||
|
-ServerAddress $rustDeskDnsName `
|
||||||
|
-ServerPublicKey $rustDeskServer.PublicKey
|
||||||
|
$rustDeskPasswordPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR(
|
||||||
|
$rustDeskServerClient.AccessPassword)
|
||||||
|
try {
|
||||||
|
$rustDeskPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($rustDeskPasswordPointer)
|
||||||
|
& (Join-Path $rustDeskManagementRoot 'Register-SguRustDeskDevice.ps1') `
|
||||||
|
-ComputerName $env:COMPUTERNAME `
|
||||||
|
-RustDeskId $rustDeskServerClient.RustDeskId `
|
||||||
|
-AccessPassword $rustDeskPassword | Out-Null
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($rustDeskPasswordPointer)
|
||||||
|
$rustDeskPassword = $null
|
||||||
|
}
|
||||||
|
|
||||||
$validation = [ordered]@{
|
$validation = [ordered]@{
|
||||||
CompletedAt = (Get-Date).ToString('o')
|
CompletedAt = (Get-Date).ToString('o')
|
||||||
ComputerName = $env:COMPUTERNAME
|
ComputerName = $env:COMPUTERNAME
|
||||||
DomainName = $DomainName
|
DomainName = $DomainName
|
||||||
ServerIPv4Address = $ServerIPv4Address.IPAddressToString
|
ServerIPv4Address = $ServerIPv4Address.IPAddressToString
|
||||||
|
NetworkConfigurationMode = $NetworkConfigurationMode
|
||||||
|
TrustedClientNetworks = $TrustedClientNetworks
|
||||||
|
AllowedRemoteAddresses = $allowedRemoteAddresses
|
||||||
BrokerDnsName = $brokerDnsName
|
BrokerDnsName = $brokerDnsName
|
||||||
BrokerCertificateThumbprint = $serverCertificate.Thumbprint
|
BrokerCertificateThumbprint = $serverCertificate.Thumbprint
|
||||||
BrokerService = (Get-Service SGUAuthBroker).Status.ToString()
|
BrokerService = (Get-Service SGUAuthBroker).Status.ToString()
|
||||||
BrokerPortListening = [bool](Get-NetTCPConnection -LocalPort 8443 -State Listen -ErrorAction SilentlyContinue)
|
BrokerPortListening = [bool](Get-NetTCPConnection -LocalPort 8443 -State Listen -ErrorAction SilentlyContinue)
|
||||||
WinRM = (Get-Service WinRM).Status.ToString()
|
WinRM = (Get-Service WinRM).Status.ToString()
|
||||||
RemoteDesktop = (Get-Service TermService).Status.ToString()
|
RemoteDesktop = (Get-Service TermService).Status.ToString()
|
||||||
|
RustDeskServerAddress = $rustDeskServer.ServerAddress
|
||||||
|
RustDeskHbbsTask = $rustDeskServer.HbbsTask
|
||||||
|
RustDeskHbbrTask = $rustDeskServer.HbbrTask
|
||||||
|
RustDeskHbbsListening = $rustDeskServer.HbbsListening
|
||||||
|
RustDeskHbbrListening = $rustDeskServer.HbbrListening
|
||||||
|
RustDeskLinuxRegistrationTask = (Get-ScheduledTask -TaskName $rustDeskLinuxEnrollment.RegistrationTask).State.ToString()
|
||||||
|
RustDeskServerClientId = $rustDeskServerClient.RustDeskId
|
||||||
|
EventCollector = (Get-Service Wecsvc).Status.ToString()
|
||||||
|
EventSubscription = @(& wecutil.exe enum-subscription) -contains 'SGU-Lab-Monitoring'
|
||||||
|
MonitoringRetentionDays = 183
|
||||||
PackageShare = "\\$env:COMPUTERNAME\Packages"
|
PackageShare = "\\$env:COMPUTERNAME\Packages"
|
||||||
LaboratoryOu = $laboratoryOuDn
|
LaboratoryOu = $laboratoryOuDn
|
||||||
UsersOu = $usersOuDn
|
UsersOu = $usersOuDn
|
||||||
@@ -643,7 +824,15 @@ $validation = [ordered]@{
|
|||||||
if ($validation.BrokerService -ne 'Running' -or
|
if ($validation.BrokerService -ne 'Running' -or
|
||||||
-not $validation.BrokerPortListening -or
|
-not $validation.BrokerPortListening -or
|
||||||
$validation.WinRM -ne 'Running' -or
|
$validation.WinRM -ne 'Running' -or
|
||||||
$validation.RemoteDesktop -ne 'Running') {
|
$validation.RemoteDesktop -ne 'Running' -or
|
||||||
|
$validation.RustDeskHbbsTask -ne 'Running' -or
|
||||||
|
$validation.RustDeskHbbrTask -ne 'Running' -or
|
||||||
|
$validation.RustDeskLinuxRegistrationTask -notin @('Ready', 'Running') -or
|
||||||
|
-not $validation.RustDeskHbbsListening -or
|
||||||
|
-not $validation.RustDeskHbbrListening -or
|
||||||
|
$validation.EventCollector -ne 'Running' -or
|
||||||
|
-not $validation.EventSubscription -or
|
||||||
|
$validation.DomainNetworkProfile -ne 'DomainAuthenticated') {
|
||||||
throw 'Server finalization did not pass service validation. Review bootstrap.log and re-run the bootstrap.'
|
throw 'Server finalization did not pass service validation. Review bootstrap.log and re-run the bootstrap.'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ $defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
|
|||||||
$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
|
$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
|
||||||
$accountPictureSourcePath = Join-Path $PublishPath 'branding\user.png'
|
$accountPictureSourcePath = Join-Path $PublishPath 'branding\user.png'
|
||||||
$accountPictureDirectory = Join-Path $env:ProgramData 'Microsoft\User Account Pictures'
|
$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()
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||||
@@ -122,6 +126,25 @@ function Install-DefaultAccountPicture {
|
|||||||
return $true
|
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 (Test-DotNet10Runtime)) {
|
||||||
if (-not $InstallDotNetRuntime) {
|
if (-not $InstallDotNetRuntime) {
|
||||||
throw 'Microsoft .NET 10 x64 runtime is required. Re-run with -InstallDotNetRuntime or install it first.'
|
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
|
# The domain GPO selects the Windows default account picture. Install its
|
||||||
# branded bitmap during enrollment so no per-machine manual setup is needed.
|
# branded bitmap during enrollment so no per-machine manual setup is needed.
|
||||||
Install-DefaultAccountPicture -SourcePath $accountPictureSourcePath | Out-Null
|
Install-DefaultAccountPicture -SourcePath $accountPictureSourcePath | Out-Null
|
||||||
|
Install-WelcomeWallpaperAssets | Out-Null
|
||||||
|
|
||||||
New-Item -ItemType Directory -Path (Split-Path $settingsPath -Parent) -Force | Out-Null
|
New-Item -ItemType Directory -Path (Split-Path $settingsPath -Parent) -Force | Out-Null
|
||||||
$settingsJson = @{
|
$settingsJson = @{
|
||||||
@@ -324,4 +348,7 @@ catch {
|
|||||||
-LiteralPath $defaultProviderPolicyPath `
|
-LiteralPath $defaultProviderPolicyPath `
|
||||||
-Name EnumerateLocalUsers) -eq 0
|
-Name EnumerateLocalUsers) -eq 0
|
||||||
SystemPasswordProviderPreserved = $true
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
#Requires -RunAsAdministrator
|
||||||
|
[CmdletBinding(SupportsShouldProcess)]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$VpnProfilePackagePath,
|
||||||
|
[Parameter(Mandatory)][string]$ClientCertificatePfxPath,
|
||||||
|
[securestring]$ClientCertificatePfxPassword,
|
||||||
|
[Parameter(Mandatory)][string]$ClientRootCertificatePath,
|
||||||
|
[string]$ConnectionName = 'SGU Azure P2S',
|
||||||
|
[string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'),
|
||||||
|
[ipaddress]$DomainControllerIPv4Address = '10.77.0.4',
|
||||||
|
[string]$DomainName = 'lci.lasalle.mx',
|
||||||
|
[switch]$Connect
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
foreach ($path in @($VpnProfilePackagePath,$ClientCertificatePfxPath,$ClientRootCertificatePath)) {
|
||||||
|
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
|
||||||
|
throw "Required P2S file not found: $path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $ClientCertificatePfxPassword) {
|
||||||
|
$ClientCertificatePfxPassword = Read-Host 'Password protecting the P2S client PFX' -AsSecureString
|
||||||
|
}
|
||||||
|
|
||||||
|
$temporaryRoot = Join-Path $env:ProgramData ("SGU\AzureP2S\Import-" + [Guid]::NewGuid().ToString('N'))
|
||||||
|
try {
|
||||||
|
Expand-Archive -LiteralPath $VpnProfilePackagePath -DestinationPath $temporaryRoot -Force
|
||||||
|
$vpnSettingsPath = Get-ChildItem -LiteralPath $temporaryRoot -Recurse -Filter VpnSettings.xml -File |
|
||||||
|
Select-Object -First 1 -ExpandProperty FullName
|
||||||
|
if (-not $vpnSettingsPath) {
|
||||||
|
throw 'The Azure package does not contain Generic\VpnSettings.xml. Generate it with IKEv2 enabled.'
|
||||||
|
}
|
||||||
|
[xml]$vpnSettings = Get-Content -LiteralPath $vpnSettingsPath -Raw
|
||||||
|
$vpnServerNode = $vpnSettings.SelectSingleNode('//*[local-name()="VpnServer"]')
|
||||||
|
if (-not $vpnServerNode -or [string]::IsNullOrWhiteSpace($vpnServerNode.InnerText)) {
|
||||||
|
throw 'VpnSettings.xml does not contain the Azure VPN gateway FQDN.'
|
||||||
|
}
|
||||||
|
$vpnServer = $vpnServerNode.InnerText.Trim()
|
||||||
|
|
||||||
|
$serverRootPath = Get-ChildItem -LiteralPath (Split-Path $vpnSettingsPath -Parent) `
|
||||||
|
-Filter VpnServerRoot.cer -File | Select-Object -First 1 -ExpandProperty FullName
|
||||||
|
if ($serverRootPath) {
|
||||||
|
Import-Certificate -FilePath $serverRootPath -CertStoreLocation Cert:\LocalMachine\Root | Out-Null
|
||||||
|
}
|
||||||
|
$clientRoot = Import-Certificate -FilePath $ClientRootCertificatePath `
|
||||||
|
-CertStoreLocation Cert:\LocalMachine\Root | Select-Object -First 1
|
||||||
|
$clientCertificates = @(Import-PfxCertificate -FilePath $ClientCertificatePfxPath `
|
||||||
|
-Password $ClientCertificatePfxPassword -CertStoreLocation Cert:\LocalMachine\My)
|
||||||
|
$clientCertificate = $clientCertificates |
|
||||||
|
Where-Object {
|
||||||
|
$_.HasPrivateKey -and
|
||||||
|
$_.NotAfter -gt (Get-Date) -and
|
||||||
|
@($_.EnhancedKeyUsageList | ForEach-Object ObjectId) -contains '1.3.6.1.5.5.7.3.2'
|
||||||
|
} |
|
||||||
|
Sort-Object NotAfter -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
if (-not $clientCertificate) {
|
||||||
|
throw 'The imported PFX does not contain a valid Client Authentication certificate with a private key.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($PSCmdlet.ShouldProcess($ConnectionName, 'Install an all-user IKEv2 Azure P2S connection using a machine certificate')) {
|
||||||
|
$existingConnection = Get-VpnConnection -Name $ConnectionName -AllUserConnection `
|
||||||
|
-ErrorAction SilentlyContinue
|
||||||
|
if ($existingConnection) {
|
||||||
|
Remove-VpnConnection -Name $ConnectionName -AllUserConnection -Force
|
||||||
|
}
|
||||||
|
Add-VpnConnection `
|
||||||
|
-Name $ConnectionName `
|
||||||
|
-ServerAddress $vpnServer `
|
||||||
|
-TunnelType Ikev2 `
|
||||||
|
-AuthenticationMethod MachineCertificate `
|
||||||
|
-MachineCertificateIssuerFilter $clientRoot `
|
||||||
|
-MachineCertificateEKUFilter '1.3.6.1.5.5.7.3.2' `
|
||||||
|
-EncryptionLevel Required `
|
||||||
|
-SplitTunneling `
|
||||||
|
-AllUserConnection `
|
||||||
|
-DnsSuffix $DomainName `
|
||||||
|
-Force | Out-Null
|
||||||
|
foreach ($prefix in $AzureNetworkPrefixes) {
|
||||||
|
Add-VpnConnectionRoute -ConnectionName $ConnectionName `
|
||||||
|
-DestinationPrefix $prefix -AllUserConnection -PassThru | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$nrptDisplayName = "SGU Azure P2S DNS - $DomainName"
|
||||||
|
Get-DnsClientNrptRule -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object DisplayName -eq $nrptDisplayName |
|
||||||
|
Remove-DnsClientNrptRule -Force
|
||||||
|
Add-DnsClientNrptRule `
|
||||||
|
-Namespace ".$DomainName" `
|
||||||
|
-NameServers $DomainControllerIPv4Address.IPAddressToString `
|
||||||
|
-DisplayName $nrptDisplayName `
|
||||||
|
-Comment 'Managed by SGU Azure P2S bootstrap; routes only the AD namespace to the domain controller.' | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Connect) {
|
||||||
|
& "$env:SystemRoot\System32\rasdial.exe" $ConnectionName
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Windows could not connect $ConnectionName. Verify UDP 500/4500 (IKEv2) or use the Azure-generated SSTP profile when the local network blocks IKEv2."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = Get-VpnConnection -Name $ConnectionName -AllUserConnection
|
||||||
|
[pscustomobject]@{
|
||||||
|
ConnectionName = $connection.Name
|
||||||
|
ServerAddress = $connection.ServerAddress
|
||||||
|
TunnelType = $connection.TunnelType
|
||||||
|
AllUserConnection = $true
|
||||||
|
AuthenticationMethod = $connection.AuthenticationMethod
|
||||||
|
ConnectionStatus = $connection.ConnectionStatus
|
||||||
|
ClientCertificateThumbprint = $clientCertificate.Thumbprint
|
||||||
|
DomainControllerIPv4Address = $DomainControllerIPv4Address.IPAddressToString
|
||||||
|
DomainDnsNamespace = ".$DomainName"
|
||||||
|
AzureNetworkPrefixes = $AzureNetworkPrefixes
|
||||||
|
AvailableBeforeLogon = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$ClientCertificatePfxPassword = $null
|
||||||
|
if (Test-Path -LiteralPath $temporaryRoot) {
|
||||||
|
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
[CmdletBinding(SupportsShouldProcess)]
|
||||||
|
param(
|
||||||
|
[string]$CollectorFqdn = "$env:COMPUTERNAME.$env:USERDNSDOMAIN",
|
||||||
|
[string]$ComputerOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
|
||||||
|
[string]$MonitoringRoot = 'C:\ProgramData\SGU\Monitoring',
|
||||||
|
[ValidateRange(30, 730)]
|
||||||
|
[int]$RetentionDays = 183
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$subscriptionId = 'SGU-Lab-Monitoring'
|
||||||
|
$maintenanceScriptName = 'Invoke-SguMonitoringMaintenance.ps1'
|
||||||
|
$reportScriptName = 'Get-SguUsageReport.ps1'
|
||||||
|
$brokerReportScriptName = 'Get-SguBrokerLog.ps1'
|
||||||
|
|
||||||
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||||
|
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||||
|
throw 'Run this script from an elevated Windows PowerShell session on the domain event collector.'
|
||||||
|
}
|
||||||
|
|
||||||
|
Import-Module ActiveDirectory -ErrorAction Stop
|
||||||
|
Get-ADOrganizationalUnit -Identity $ComputerOuDn -ErrorAction Stop | Out-Null
|
||||||
|
|
||||||
|
foreach ($requiredScript in $maintenanceScriptName,$reportScriptName,$brokerReportScriptName) {
|
||||||
|
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot $requiredScript) -PathType Leaf)) {
|
||||||
|
throw "$requiredScript must be beside Install-SguDomainMonitoring.ps1."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install the SGU domain monitoring collector')) {
|
||||||
|
Set-Service EventLog -StartupType Automatic
|
||||||
|
if ((Get-Service EventLog).Status -ne 'Running') {
|
||||||
|
Start-Service EventLog
|
||||||
|
}
|
||||||
|
& wecutil.exe quick-config /quiet
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "wecutil quick-config failed with exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
Set-Service Wecsvc -StartupType Automatic
|
||||||
|
Start-Service Wecsvc
|
||||||
|
& wevtutil.exe set-log ForwardedEvents /enabled:true /maxsize:536870912 /retention:false /autobackup:false
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "wevtutil failed to configure ForwardedEvents with exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = @'
|
||||||
|
<QueryList>
|
||||||
|
<Query Id="0">
|
||||||
|
<Select Path="Security">*[System[(EventID=4624 or EventID=4625 or EventID=4634 or EventID=4647 or EventID=4778 or EventID=4779)]]</Select>
|
||||||
|
<Select Path="System">*[System[(EventID=12 or EventID=13 or EventID=41 or EventID=1074 or EventID=6005 or EventID=6006 or EventID=6008)]]</Select>
|
||||||
|
</Query>
|
||||||
|
</QueryList>
|
||||||
|
'@
|
||||||
|
$escapedQuery = [Security.SecurityElement]::Escape($query)
|
||||||
|
$subscriptionXml = @"
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Subscription xmlns="http://schemas.microsoft.com/2006/03/windows/events/subscription">
|
||||||
|
<SubscriptionId>$subscriptionId</SubscriptionId>
|
||||||
|
<SubscriptionType>SourceInitiated</SubscriptionType>
|
||||||
|
<Description>SGU interactive sessions, failures, reconnects, and workstation power state.</Description>
|
||||||
|
<Enabled>true</Enabled>
|
||||||
|
<Uri>http://schemas.microsoft.com/wbem/wsman/1/windows/EventLog</Uri>
|
||||||
|
<ConfigurationMode>Custom</ConfigurationMode>
|
||||||
|
<Delivery Mode="Push">
|
||||||
|
<Batching><MaxItems>5</MaxItems><MaxLatencyTime>30000</MaxLatencyTime></Batching>
|
||||||
|
<PushSettings><Heartbeat Interval="60000"/></PushSettings>
|
||||||
|
</Delivery>
|
||||||
|
<Query>$escapedQuery</Query>
|
||||||
|
<ReadExistingEvents>false</ReadExistingEvents>
|
||||||
|
<TransportName>HTTP</TransportName>
|
||||||
|
<ContentFormat>Events</ContentFormat>
|
||||||
|
<Locale Language="es-MX"/>
|
||||||
|
<LogFile>ForwardedEvents</LogFile>
|
||||||
|
<AllowedSourceDomainComputers>O:NSG:NSD:(A;;GA;;;DC)(A;;GA;;;NS)</AllowedSourceDomainComputers>
|
||||||
|
</Subscription>
|
||||||
|
"@
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $MonitoringRoot -Force | Out-Null
|
||||||
|
$subscriptionPath = Join-Path $MonitoringRoot 'SGU-Lab-Monitoring.xml'
|
||||||
|
[IO.File]::WriteAllText($subscriptionPath, $subscriptionXml, [Text.UTF8Encoding]::new($true))
|
||||||
|
$existingSubscriptions = @(& wecutil.exe enum-subscription 2>$null)
|
||||||
|
if ($existingSubscriptions -contains $subscriptionId) {
|
||||||
|
& wecutil.exe delete-subscription $subscriptionId
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Could not replace the existing $subscriptionId subscription."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
& wecutil.exe create-subscription $subscriptionPath
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Could not create the $subscriptionId subscription."
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($scriptName in $maintenanceScriptName,$reportScriptName,$brokerReportScriptName) {
|
||||||
|
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $scriptName) `
|
||||||
|
-Destination (Join-Path $MonitoringRoot $scriptName) -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
$configuration = [ordered]@{
|
||||||
|
CollectorFqdn = $CollectorFqdn
|
||||||
|
ComputerOuDn = $ComputerOuDn
|
||||||
|
RetentionDays = $RetentionDays
|
||||||
|
SubscriptionId = $subscriptionId
|
||||||
|
}
|
||||||
|
[IO.File]::WriteAllText(
|
||||||
|
(Join-Path $MonitoringRoot 'monitoring.json'),
|
||||||
|
($configuration | ConvertTo-Json),
|
||||||
|
[Text.UTF8Encoding]::new($false))
|
||||||
|
|
||||||
|
$powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||||
|
$maintenanceScript = Join-Path $MonitoringRoot $maintenanceScriptName
|
||||||
|
$inventoryAction = New-ScheduledTaskAction -Execute $powerShell -Argument (
|
||||||
|
"-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$maintenanceScript`" " +
|
||||||
|
"-MonitoringRoot `"$MonitoringRoot`" -ComputerOuDn `"$ComputerOuDn`" -RetentionDays $RetentionDays -InventoryOnly")
|
||||||
|
$inventoryTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
|
||||||
|
-RepetitionInterval (New-TimeSpan -Minutes 5) `
|
||||||
|
-RepetitionDuration (New-TimeSpan -Days 3650)
|
||||||
|
$taskSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
|
||||||
|
-ExecutionTimeLimit (New-TimeSpan -Minutes 10) -RestartCount 2 `
|
||||||
|
-RestartInterval (New-TimeSpan -Minutes 1)
|
||||||
|
Register-ScheduledTask -TaskName 'SGU-Monitoring-Inventory' -Action $inventoryAction `
|
||||||
|
-Trigger $inventoryTrigger -Settings $taskSettings -User 'SYSTEM' -RunLevel Highest -Force | Out-Null
|
||||||
|
|
||||||
|
$retentionAction = New-ScheduledTaskAction -Execute $powerShell -Argument (
|
||||||
|
"-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$maintenanceScript`" " +
|
||||||
|
"-MonitoringRoot `"$MonitoringRoot`" -ComputerOuDn `"$ComputerOuDn`" -RetentionDays $RetentionDays")
|
||||||
|
$retentionTrigger = New-ScheduledTaskTrigger -Daily -At '12:10 AM'
|
||||||
|
Register-ScheduledTask -TaskName 'SGU-Monitoring-Retention' -Action $retentionAction `
|
||||||
|
-Trigger $retentionTrigger -Settings $taskSettings -User 'SYSTEM' -RunLevel Highest -Force | Out-Null
|
||||||
|
|
||||||
|
& $maintenanceScript -MonitoringRoot $MonitoringRoot -ComputerOuDn $ComputerOuDn `
|
||||||
|
-RetentionDays $RetentionDays -InventoryOnly | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
[pscustomobject]@{
|
||||||
|
Collector = $CollectorFqdn
|
||||||
|
CollectorService = (Get-Service Wecsvc).Status.ToString()
|
||||||
|
SubscriptionId = $subscriptionId
|
||||||
|
SubscriptionEnabled = @(& wecutil.exe enum-subscription) -contains $subscriptionId
|
||||||
|
RetentionDays = $RetentionDays
|
||||||
|
InventoryTask = (Get-ScheduledTask -TaskName 'SGU-Monitoring-Inventory').State
|
||||||
|
RetentionTask = (Get-ScheduledTask -TaskName 'SGU-Monitoring-Retention').State
|
||||||
|
MachineStatusPath = Join-Path $MonitoringRoot 'Reports\machine-status.json'
|
||||||
|
UsageReportCommand = "& '$MonitoringRoot\$reportScriptName'"
|
||||||
|
BrokerLogCommand = "& '$MonitoringRoot\$brokerReportScriptName'"
|
||||||
|
}
|
||||||
@@ -19,7 +19,9 @@ param(
|
|||||||
[ValidateRange(2, 90)]
|
[ValidateRange(2, 90)]
|
||||||
[int]$TimeoutSeconds = 90,
|
[int]$TimeoutSeconds = 90,
|
||||||
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
|
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
|
||||||
[string]$DotNetRuntimeInstallerPath
|
[string]$DotNetRuntimeInstallerPath,
|
||||||
|
[string]$RustDeskServerAddress,
|
||||||
|
[string]$RustDeskServerPublicKey
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
@@ -28,6 +30,9 @@ $enrollmentRoot = Join-Path $env:ProgramData 'SGU\Enrollment'
|
|||||||
$sourceScripts = @(
|
$sourceScripts = @(
|
||||||
'Install-CredentialProvider.ps1',
|
'Install-CredentialProvider.ps1',
|
||||||
'Enable-LabRemoteAccess.ps1',
|
'Enable-LabRemoteAccess.ps1',
|
||||||
|
'Enable-SguClientMonitoring.ps1',
|
||||||
|
'Install-SguRustDeskClient.ps1',
|
||||||
|
'Set-SguStandardLocalUser.ps1',
|
||||||
'Test-SguClientEnrollment.ps1',
|
'Test-SguClientEnrollment.ps1',
|
||||||
'Repair-SguClientEnrollment.ps1'
|
'Repair-SguClientEnrollment.ps1'
|
||||||
)
|
)
|
||||||
@@ -53,6 +58,10 @@ if ($DotNetRuntimeInstallerPath -and
|
|||||||
-not (Test-Path -LiteralPath $DotNetRuntimeInstallerPath -PathType Leaf)) {
|
-not (Test-Path -LiteralPath $DotNetRuntimeInstallerPath -PathType Leaf)) {
|
||||||
throw 'DotNetRuntimeInstallerPath does not exist.'
|
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')) {
|
if ($PSCmdlet.ShouldProcess($enrollmentRoot, 'Install the SGU enrollment repair guard')) {
|
||||||
New-Item -ItemType Directory -Path $enrollmentRoot -Force | Out-Null
|
New-Item -ItemType Directory -Path $enrollmentRoot -Force | Out-Null
|
||||||
@@ -87,6 +96,8 @@ if ($PSCmdlet.ShouldProcess($enrollmentRoot, 'Install the SGU enrollment repair
|
|||||||
TimeoutSeconds = $TimeoutSeconds
|
TimeoutSeconds = $TimeoutSeconds
|
||||||
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
|
RemoteDesktopPrincipal = $RemoteDesktopPrincipal
|
||||||
DotNetRuntimeInstallerPath = $guardRuntimeInstaller
|
DotNetRuntimeInstallerPath = $guardRuntimeInstaller
|
||||||
|
RustDeskServerAddress = $RustDeskServerAddress
|
||||||
|
RustDeskServerPublicKey = $RustDeskServerPublicKey
|
||||||
}
|
}
|
||||||
$configurationPath = Join-Path $enrollmentRoot 'enrollment.json'
|
$configurationPath = Join-Path $enrollmentRoot 'enrollment.json'
|
||||||
[IO.File]::WriteAllText(
|
[IO.File]::WriteAllText(
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
[CmdletBinding(SupportsShouldProcess)]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9.-]*$')]
|
||||||
|
[string]$ServerAddress,
|
||||||
|
|
||||||
|
[string]$InstallRoot = "$env:ProgramFiles\SGU\RustDeskServer",
|
||||||
|
[string]$DataRoot = "$env:ProgramData\SGU\RustDesk\Server",
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string[]]$FirewallRemoteAddress = @('192.168.50.0/24'),
|
||||||
|
[uri]$DownloadUri = 'https://github.com/rustdesk/rustdesk-server/releases/download/1.1.16/rustdesk-server-windows-x86_64-unsigned.zip',
|
||||||
|
[ValidatePattern('^[A-Fa-f0-9]{64}$')]
|
||||||
|
[string]$ExpectedSha256 = 'B865A3A62FC8755B45480C508F1C4871C3338590408DDA8C58C7E9C373B7ADB0'
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ProgressPreference = 'SilentlyContinue'
|
||||||
|
$hbbsTaskName = 'SGU-RustDesk-hbbs'
|
||||||
|
$hbbrTaskName = 'SGU-RustDesk-hbbr'
|
||||||
|
$downloadRoot = Join-Path $env:ProgramData 'SGU\RustDesk\Downloads'
|
||||||
|
$archivePath = Join-Path $downloadRoot 'rustdesk-server-windows-x86_64-1.1.16.zip'
|
||||||
|
|
||||||
|
function Assert-Administrator {
|
||||||
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||||
|
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||||
|
throw 'Run this script from an elevated Windows PowerShell session.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-PrivateDirectoryAcl {
|
||||||
|
param([Parameter(Mandatory)][string]$Path)
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $Path -Force | Out-Null
|
||||||
|
$acl = New-Object Security.AccessControl.DirectorySecurity
|
||||||
|
$acl.SetAccessRuleProtection($true, $false)
|
||||||
|
$inheritance = [Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit'
|
||||||
|
$allow = [Security.AccessControl.AccessControlType]::Allow
|
||||||
|
foreach ($sid in @('S-1-5-18', 'S-1-5-32-544')) {
|
||||||
|
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
|
||||||
|
[Security.Principal.SecurityIdentifier]::new($sid),
|
||||||
|
[Security.AccessControl.FileSystemRights]::FullControl,
|
||||||
|
$inheritance,
|
||||||
|
[Security.AccessControl.PropagationFlags]::None,
|
||||||
|
$allow))
|
||||||
|
}
|
||||||
|
Set-Acl -LiteralPath $Path -AclObject $acl
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-FileHash {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Path,
|
||||||
|
[Parameter(Mandatory)][string]$ExpectedHash
|
||||||
|
)
|
||||||
|
|
||||||
|
$actualHash = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
|
||||||
|
if (-not $actualHash.Equals($ExpectedHash, [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw "SHA-256 verification failed for $Path."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Copy-IfDifferent {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Source,
|
||||||
|
[Parameter(Mandatory)][string]$Destination
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $Destination -PathType Leaf) -or
|
||||||
|
(Get-FileHash -LiteralPath $Source -Algorithm SHA256).Hash -ne
|
||||||
|
(Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash) {
|
||||||
|
Copy-Item -LiteralPath $Source -Destination $Destination -Force
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-RustDeskFirewallRule {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Name,
|
||||||
|
[Parameter(Mandatory)][ValidateSet('TCP', 'UDP')][string]$Protocol,
|
||||||
|
[Parameter(Mandatory)][string]$LocalPort
|
||||||
|
)
|
||||||
|
|
||||||
|
$rule = Get-NetFirewallRule -DisplayName $Name -ErrorAction SilentlyContinue
|
||||||
|
if (-not $rule) {
|
||||||
|
$rule = New-NetFirewallRule -DisplayName $Name -Group 'SGU RustDesk' `
|
||||||
|
-Direction Inbound -Action Allow -Protocol $Protocol -LocalPort $LocalPort `
|
||||||
|
-RemoteAddress $FirewallRemoteAddress -Profile Domain -Enabled True
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$rule | Set-NetFirewallRule -Enabled True -Profile Domain -Action Allow | Out-Null
|
||||||
|
$rule | Get-NetFirewallPortFilter | Set-NetFirewallPortFilter `
|
||||||
|
-Protocol $Protocol -LocalPort $LocalPort | Out-Null
|
||||||
|
$rule | Get-NetFirewallAddressFilter | Set-NetFirewallAddressFilter `
|
||||||
|
-RemoteAddress $FirewallRemoteAddress | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-RustDeskTasks {
|
||||||
|
foreach ($taskName in @($hbbsTaskName, $hbbrTaskName)) {
|
||||||
|
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||||
|
if ($task -and $task.State -eq 'Running') {
|
||||||
|
Stop-ScheduledTask -TaskName $taskName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function Register-RustDeskServerTask {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$TaskName,
|
||||||
|
[Parameter(Mandatory)][string]$Executable,
|
||||||
|
[string]$Arguments
|
||||||
|
)
|
||||||
|
|
||||||
|
# New-ScheduledTaskAction rejects an empty -Argument value. hbbr has no
|
||||||
|
# command-line arguments, whereas hbbs needs the relay endpoint, so add
|
||||||
|
# the parameter only when it is meaningful.
|
||||||
|
$actionParameters = @{
|
||||||
|
Execute = $Executable
|
||||||
|
WorkingDirectory = $DataRoot
|
||||||
|
}
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($Arguments)) {
|
||||||
|
$actionParameters.Argument = $Arguments
|
||||||
|
}
|
||||||
|
$action = New-ScheduledTaskAction @actionParameters
|
||||||
|
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||||
|
$trigger.Delay = 'PT30S'
|
||||||
|
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
|
||||||
|
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
||||||
|
-ExecutionTimeLimit ([TimeSpan]::Zero) `
|
||||||
|
-RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
|
||||||
|
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
|
||||||
|
-Settings $settings -User 'SYSTEM' -RunLevel Highest -Force | Out-Null
|
||||||
|
$registeredTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop
|
||||||
|
if ($registeredTask.State -ne 'Running') {
|
||||||
|
Start-ScheduledTask -TaskName $TaskName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-ForRustDeskServer {
|
||||||
|
for ($attempt = 1; $attempt -le 30; $attempt++) {
|
||||||
|
$hbbsListening = [bool](Get-NetTCPConnection -LocalPort 21116 -State Listen `
|
||||||
|
-ErrorAction SilentlyContinue)
|
||||||
|
$hbbrListening = [bool](Get-NetTCPConnection -LocalPort 21117 -State Listen `
|
||||||
|
-ErrorAction SilentlyContinue)
|
||||||
|
$publicKeyReady = Test-Path -LiteralPath (Join-Path $DataRoot 'id_ed25519.pub') -PathType Leaf
|
||||||
|
if ($hbbsListening -and $hbbrListening -and $publicKeyReady) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
}
|
||||||
|
throw 'RustDesk hbbs/hbbr did not become ready within 60 seconds.'
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert-Administrator
|
||||||
|
if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and configure the RustDesk OSS rendezvous and relay server')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-PrivateDirectoryAcl -Path $DataRoot
|
||||||
|
$managementRoot = Split-Path $DataRoot -Parent
|
||||||
|
Set-PrivateDirectoryAcl -Path $managementRoot
|
||||||
|
New-Item -ItemType Directory -Path $InstallRoot,$downloadRoot -Force | Out-Null
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $archivePath -PathType Leaf) -or
|
||||||
|
(Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash -ne $ExpectedSha256) {
|
||||||
|
Invoke-WebRequest -Uri $DownloadUri -OutFile $archivePath -UseBasicParsing
|
||||||
|
}
|
||||||
|
Assert-FileHash -Path $archivePath -ExpectedHash $ExpectedSha256
|
||||||
|
|
||||||
|
$stagingRoot = Join-Path $env:TEMP ('sgu-rustdesk-server-' + [Guid]::NewGuid().ToString('N'))
|
||||||
|
try {
|
||||||
|
Expand-Archive -LiteralPath $archivePath -DestinationPath $stagingRoot -Force
|
||||||
|
$payloadRoot = Join-Path $stagingRoot 'x86_64'
|
||||||
|
$sourceHbbs = Join-Path $payloadRoot 'hbbs.exe'
|
||||||
|
$sourceHbbr = Join-Path $payloadRoot 'hbbr.exe'
|
||||||
|
foreach ($required in @($sourceHbbs, $sourceHbbr)) {
|
||||||
|
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
|
||||||
|
throw "The verified RustDesk archive is missing $required."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetHbbs = Join-Path $InstallRoot 'hbbs.exe'
|
||||||
|
$targetHbbr = Join-Path $InstallRoot 'hbbr.exe'
|
||||||
|
$requiresBinaryUpdate =
|
||||||
|
-not (Test-Path -LiteralPath $targetHbbs) -or
|
||||||
|
-not (Test-Path -LiteralPath $targetHbbr) -or
|
||||||
|
(Get-FileHash -LiteralPath $sourceHbbs -Algorithm SHA256).Hash -ne
|
||||||
|
(Get-FileHash -LiteralPath $targetHbbs -Algorithm SHA256).Hash -or
|
||||||
|
(Get-FileHash -LiteralPath $sourceHbbr -Algorithm SHA256).Hash -ne
|
||||||
|
(Get-FileHash -LiteralPath $targetHbbr -Algorithm SHA256).Hash
|
||||||
|
if ($requiresBinaryUpdate) {
|
||||||
|
Stop-RustDeskTasks
|
||||||
|
Copy-IfDifferent -Source $sourceHbbs -Destination $targetHbbs | Out-Null
|
||||||
|
Copy-IfDifferent -Source $sourceHbbr -Destination $targetHbbr | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (Test-Path -LiteralPath $stagingRoot) {
|
||||||
|
Remove-Item -LiteralPath $stagingRoot -Recurse -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-RustDeskFirewallRule -Name 'SGU RustDesk hbbs (TCP)' -Protocol TCP -LocalPort '21115-21116'
|
||||||
|
Set-RustDeskFirewallRule -Name 'SGU RustDesk hbbr (TCP)' -Protocol TCP -LocalPort '21117'
|
||||||
|
Set-RustDeskFirewallRule -Name 'SGU RustDesk hbbs (UDP)' -Protocol UDP -LocalPort '21116'
|
||||||
|
|
||||||
|
Register-RustDeskServerTask -TaskName $hbbrTaskName -Executable (Join-Path $InstallRoot 'hbbr.exe')
|
||||||
|
Register-RustDeskServerTask -TaskName $hbbsTaskName -Executable (Join-Path $InstallRoot 'hbbs.exe') `
|
||||||
|
-Arguments "-r $ServerAddress`:21117"
|
||||||
|
Wait-ForRustDeskServer
|
||||||
|
|
||||||
|
$publicKey = (Get-Content -LiteralPath (Join-Path $DataRoot 'id_ed25519.pub') -Raw).Trim()
|
||||||
|
if ([string]::IsNullOrWhiteSpace($publicKey)) {
|
||||||
|
throw 'RustDesk generated an empty public key.'
|
||||||
|
}
|
||||||
|
$statusPath = Join-Path (Split-Path $DataRoot -Parent) 'server.json'
|
||||||
|
$status = [ordered]@{
|
||||||
|
ServerAddress = $ServerAddress
|
||||||
|
PublicKey = $publicKey
|
||||||
|
PublicKeySha256 = ([Security.Cryptography.SHA256]::Create().ComputeHash(
|
||||||
|
[Text.Encoding]::UTF8.GetBytes($publicKey)) | ForEach-Object ToString x2) -join ''
|
||||||
|
HbbsTaskName = $hbbsTaskName
|
||||||
|
HbbrTaskName = $hbbrTaskName
|
||||||
|
HbbsTcpPort = 21116
|
||||||
|
HbbrTcpPort = 21117
|
||||||
|
InstalledAt = (Get-Date).ToString('o')
|
||||||
|
}
|
||||||
|
[IO.File]::WriteAllText($statusPath, ($status | ConvertTo-Json), [Text.UTF8Encoding]::new($false))
|
||||||
|
|
||||||
|
[pscustomobject]@{
|
||||||
|
ServerAddress = $ServerAddress
|
||||||
|
PublicKey = $publicKey
|
||||||
|
PublicKeySha256 = $status.PublicKeySha256
|
||||||
|
HbbsTask = (Get-ScheduledTask -TaskName $hbbsTaskName).State.ToString()
|
||||||
|
HbbrTask = (Get-ScheduledTask -TaskName $hbbrTaskName).State.ToString()
|
||||||
|
HbbsListening = [bool](Get-NetTCPConnection -LocalPort 21116 -State Listen -ErrorAction SilentlyContinue)
|
||||||
|
HbbrListening = [bool](Get-NetTCPConnection -LocalPort 21117 -State Listen -ErrorAction SilentlyContinue)
|
||||||
|
StatusPath = $statusPath
|
||||||
|
}
|
||||||
@@ -8,6 +8,14 @@ param(
|
|||||||
[string]$DomainNetbios = 'LCI',
|
[string]$DomainNetbios = 'LCI',
|
||||||
[string]$ComputerOuDn,
|
[string]$ComputerOuDn,
|
||||||
[string]$NewComputerName,
|
[string]$NewComputerName,
|
||||||
|
[ValidateSet('Direct', 'AzureP2S')]
|
||||||
|
[string]$ConnectivityMode = 'Direct',
|
||||||
|
[string]$VpnConnectionName = 'SGU Azure P2S',
|
||||||
|
[string]$VpnProfilePackagePath,
|
||||||
|
[string]$VpnClientCertificatePfxPath,
|
||||||
|
[securestring]$VpnClientCertificatePfxPassword,
|
||||||
|
[string]$VpnClientRootCertificatePath,
|
||||||
|
[string[]]$AzureNetworkPrefixes = @('10.77.0.0/16'),
|
||||||
[switch]$SkipRestart
|
[switch]$SkipRestart
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -95,6 +103,30 @@ function Test-TcpPort {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Connect-SguAzureP2s {
|
||||||
|
param([Parameter(Mandatory)][string]$ConnectionName)
|
||||||
|
|
||||||
|
$connection = Get-VpnConnection -Name $ConnectionName -AllUserConnection `
|
||||||
|
-ErrorAction SilentlyContinue
|
||||||
|
if (-not $connection) {
|
||||||
|
throw "The all-user VPN connection '$ConnectionName' is not installed. Run Install-SguAzureP2sClient.ps1 in this VM first."
|
||||||
|
}
|
||||||
|
if ($connection.TunnelType -notcontains 'Ikev2' -and $connection.TunnelType -ne 'Ikev2') {
|
||||||
|
throw "The VPN connection '$ConnectionName' is not configured for IKEv2."
|
||||||
|
}
|
||||||
|
if ($connection.ConnectionStatus -ne 'Connected') {
|
||||||
|
& "$env:SystemRoot\System32\rasdial.exe" $ConnectionName
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Could not connect the Azure P2S profile '$ConnectionName'. Verify the machine certificate and that UDP 500/4500 is permitted by the local network."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$connection = Get-VpnConnection -Name $ConnectionName -AllUserConnection
|
||||||
|
if ($connection.ConnectionStatus -ne 'Connected') {
|
||||||
|
throw "The Azure P2S profile '$ConnectionName' did not reach Connected state."
|
||||||
|
}
|
||||||
|
return $connection
|
||||||
|
}
|
||||||
|
|
||||||
Assert-Administrator
|
Assert-Administrator
|
||||||
$operatingSystem = Get-CimInstance Win32_OperatingSystem
|
$operatingSystem = Get-CimInstance Win32_OperatingSystem
|
||||||
if ([int]$operatingSystem.ProductType -ne 1) {
|
if ([int]$operatingSystem.ProductType -ne 1) {
|
||||||
@@ -124,6 +156,7 @@ $runtimeInstaller = Get-ChildItem (Join-Path $packageRoot 'payload\prerequisites
|
|||||||
Select-Object -First 1
|
Select-Object -First 1
|
||||||
foreach ($requiredPath in @(
|
foreach ($requiredPath in @(
|
||||||
(Join-Path $scriptsRoot 'Enroll-SguDomainClient.ps1'),
|
(Join-Path $scriptsRoot 'Enroll-SguDomainClient.ps1'),
|
||||||
|
(Join-Path $scriptsRoot 'Install-SguRustDeskClient.ps1'),
|
||||||
(Join-Path $scriptsRoot 'Register-SguClientCertificate.ps1'),
|
(Join-Path $scriptsRoot 'Register-SguClientCertificate.ps1'),
|
||||||
(Join-Path $providerPublishPath 'SGU.CredentialProvider.comhost.dll'))) {
|
(Join-Path $providerPublishPath 'SGU.CredentialProvider.comhost.dll'))) {
|
||||||
if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
|
if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
|
||||||
@@ -134,9 +167,52 @@ if (-not $runtimeInstaller) {
|
|||||||
throw 'The offline Microsoft .NET 10 x64 runtime installer is missing from the client package.'
|
throw 'The offline Microsoft .NET 10 x64 runtime installer is missing from the client package.'
|
||||||
}
|
}
|
||||||
|
|
||||||
$NetworkInterfaceAlias = Resolve-ClientInterfaceAlias -RequestedAlias $NetworkInterfaceAlias
|
if ($ConnectivityMode -eq 'AzureP2S') {
|
||||||
Set-DnsClientServerAddress -InterfaceAlias $NetworkInterfaceAlias `
|
$existingVpnConnection = Get-VpnConnection -Name $VpnConnectionName -AllUserConnection `
|
||||||
-ServerAddresses $DomainControllerIPv4Address.IPAddressToString
|
-ErrorAction SilentlyContinue
|
||||||
|
if (-not $existingVpnConnection) {
|
||||||
|
$installerPath = Join-Path $packageRoot 'Install-SguAzureP2sClient.ps1'
|
||||||
|
if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) {
|
||||||
|
throw 'Install-SguAzureP2sClient.ps1 is missing from the client bootstrap package.'
|
||||||
|
}
|
||||||
|
foreach ($vpnInput in @(
|
||||||
|
@{ Name = 'VpnProfilePackagePath'; Value = $VpnProfilePackagePath },
|
||||||
|
@{ Name = 'VpnClientCertificatePfxPath'; Value = $VpnClientCertificatePfxPath },
|
||||||
|
@{ Name = 'VpnClientRootCertificatePath'; Value = $VpnClientRootCertificatePath })) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace([string]$vpnInput.Value)) {
|
||||||
|
throw "$($vpnInput.Name) is required the first time an Azure P2S client is enrolled."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$vpnInstallParameters = @{
|
||||||
|
VpnProfilePackagePath = $VpnProfilePackagePath
|
||||||
|
ClientCertificatePfxPath = $VpnClientCertificatePfxPath
|
||||||
|
ClientRootCertificatePath = $VpnClientRootCertificatePath
|
||||||
|
ConnectionName = $VpnConnectionName
|
||||||
|
AzureNetworkPrefixes = $AzureNetworkPrefixes
|
||||||
|
DomainControllerIPv4Address = $DomainControllerIPv4Address
|
||||||
|
DomainName = $DomainName
|
||||||
|
}
|
||||||
|
if ($VpnClientCertificatePfxPassword) {
|
||||||
|
$vpnInstallParameters.ClientCertificatePfxPassword = $VpnClientCertificatePfxPassword
|
||||||
|
}
|
||||||
|
& $installerPath @vpnInstallParameters | Out-Null
|
||||||
|
}
|
||||||
|
$vpnConnection = Connect-SguAzureP2s -ConnectionName $VpnConnectionName
|
||||||
|
$nrptDisplayName = "SGU Azure P2S DNS - $DomainName"
|
||||||
|
$nrptRule = Get-DnsClientNrptRule -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object DisplayName -eq $nrptDisplayName |
|
||||||
|
Select-Object -First 1
|
||||||
|
if (-not $nrptRule -or
|
||||||
|
@($nrptRule.NameServers) -notcontains $DomainControllerIPv4Address.IPAddressToString) {
|
||||||
|
throw "The SGU NRPT rule for $DomainName is missing or does not point to $DomainControllerIPv4Address. Re-run Install-SguAzureP2sClient.ps1."
|
||||||
|
}
|
||||||
|
$NetworkInterfaceAlias = $vpnConnection.Name
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$NetworkInterfaceAlias = Resolve-ClientInterfaceAlias -RequestedAlias $NetworkInterfaceAlias
|
||||||
|
Set-DnsClientServerAddress -InterfaceAlias $NetworkInterfaceAlias `
|
||||||
|
-ServerAddresses $DomainControllerIPv4Address.IPAddressToString
|
||||||
|
}
|
||||||
|
|
||||||
if (-not (Test-TcpPort -Address $DomainControllerIPv4Address -Port 5985)) {
|
if (-not (Test-TcpPort -Address $DomainControllerIPv4Address -Port 5985)) {
|
||||||
throw "The domain controller at $DomainControllerIPv4Address is not accepting WinRM on TCP 5985. Run the server bootstrap first and verify the selected IP."
|
throw "The domain controller at $DomainControllerIPv4Address is not accepting WinRM on TCP 5985. Run the server bootstrap first and verify the selected IP."
|
||||||
@@ -177,10 +253,23 @@ try {
|
|||||||
$serverIdentity = Invoke-Command -Session $session -ScriptBlock {
|
$serverIdentity = Invoke-Command -Session $session -ScriptBlock {
|
||||||
$computer = Get-CimInstance Win32_ComputerSystem
|
$computer = Get-CimInstance Win32_ComputerSystem
|
||||||
$brokerService = Get-Service SGUAuthBroker -ErrorAction SilentlyContinue
|
$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]@{
|
[pscustomobject]@{
|
||||||
ComputerName = $env:COMPUTERNAME
|
ComputerName = $env:COMPUTERNAME
|
||||||
Domain = $computer.Domain
|
Domain = $computer.Domain
|
||||||
BrokerService = if ($brokerService) { $brokerService.Status.ToString() } else { 'Missing' }
|
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
|
if (-not $serverIdentity.Domain -or
|
||||||
@@ -190,6 +279,12 @@ try {
|
|||||||
if ($serverIdentity.BrokerService -ne 'Running') {
|
if ($serverIdentity.BrokerService -ne 'Running') {
|
||||||
throw "The SGU Authentication Broker is not running on $($serverIdentity.ComputerName)."
|
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"
|
$certificateSubject = "CN=SGU Credential Provider Client $env:COMPUTERNAME"
|
||||||
$clientCertificate = Get-ChildItem Cert:\LocalMachine\My |
|
$clientCertificate = Get-ChildItem Cert:\LocalMachine\My |
|
||||||
@@ -272,8 +367,11 @@ try {
|
|||||||
ComputerOuDn = $ComputerOuDn
|
ComputerOuDn = $ComputerOuDn
|
||||||
NetworkInterfaceAlias = $NetworkInterfaceAlias
|
NetworkInterfaceAlias = $NetworkInterfaceAlias
|
||||||
DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString)
|
DomainDnsServerAddresses = @($DomainControllerIPv4Address.IPAddressToString)
|
||||||
|
ConnectivityMode = $ConnectivityMode
|
||||||
RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP"
|
RemoteDesktopPrincipal = "$DomainNetbios\SG-Laboratorio-Usuarios-RDP"
|
||||||
DotNetRuntimeInstallerPath = $runtimeInstaller.FullName
|
DotNetRuntimeInstallerPath = $runtimeInstaller.FullName
|
||||||
|
RustDeskServerAddress = $serverIdentity.RustDeskServerAddress
|
||||||
|
RustDeskServerPublicKey = $serverIdentity.RustDeskPublicKey
|
||||||
SkipRestart = $true
|
SkipRestart = $true
|
||||||
}
|
}
|
||||||
if ($NewComputerName) {
|
if ($NewComputerName) {
|
||||||
@@ -281,6 +379,31 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$result = & (Join-Path $scriptsRoot 'Enroll-SguDomainClient.ps1') @enrollmentParameters
|
$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 {
|
finally {
|
||||||
if ($session) {
|
if ($session) {
|
||||||
@@ -309,6 +432,7 @@ finally {
|
|||||||
}
|
}
|
||||||
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
$DomainCredential = $null
|
$DomainCredential = $null
|
||||||
|
$VpnClientCertificatePfxPassword = $null
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($SkipRestart) {
|
if ($SkipRestart) {
|
||||||
@@ -318,7 +442,10 @@ if ($SkipRestart) {
|
|||||||
ProviderInstalled = $true
|
ProviderInstalled = $true
|
||||||
ClientCertificateRegistered = $true
|
ClientCertificateRegistered = $true
|
||||||
BrokerEndpoint = $brokerEndpoint
|
BrokerEndpoint = $brokerEndpoint
|
||||||
|
ConnectivityMode = $ConnectivityMode
|
||||||
|
VpnConnectionName = if ($ConnectivityMode -eq 'AzureP2S') { $VpnConnectionName } else { $null }
|
||||||
RestartRequired = $true
|
RestartRequired = $true
|
||||||
|
RustDesk = if ($result) { $result.RustDesk } else { $null }
|
||||||
EnrollmentResult = $result
|
EnrollmentResult = $result
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$MonitoringRoot = 'C:\ProgramData\SGU\Monitoring',
|
||||||
|
[string]$ComputerOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
|
||||||
|
[ValidateRange(30, 730)]
|
||||||
|
[int]$RetentionDays = 183,
|
||||||
|
[string]$BrokerEventLogName = 'SGU Auth Broker',
|
||||||
|
[switch]$InventoryOnly
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Import-Module ActiveDirectory -ErrorAction Stop
|
||||||
|
|
||||||
|
foreach ($serviceName in 'EventLog','Wecsvc') {
|
||||||
|
Set-Service -Name $serviceName -StartupType Automatic
|
||||||
|
if ((Get-Service $serviceName).Status -ne 'Running') {
|
||||||
|
Start-Service $serviceName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$archiveRoot = Join-Path $MonitoringRoot 'Archive'
|
||||||
|
$brokerArchiveRoot = Join-Path $archiveRoot 'Broker'
|
||||||
|
$reportRoot = Join-Path $MonitoringRoot 'Reports'
|
||||||
|
New-Item -ItemType Directory -Path $archiveRoot,$brokerArchiveRoot,$reportRoot -Force | Out-Null
|
||||||
|
|
||||||
|
function Test-TcpEndpoint {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$ComputerName,
|
||||||
|
[int]$Port = 5985,
|
||||||
|
[int]$TimeoutMilliseconds = 900
|
||||||
|
)
|
||||||
|
|
||||||
|
$client = [Net.Sockets.TcpClient]::new()
|
||||||
|
try {
|
||||||
|
$pending = $client.BeginConnect($ComputerName, $Port, $null, $null)
|
||||||
|
if (-not $pending.AsyncWaitHandle.WaitOne($TimeoutMilliseconds)) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
$client.EndConnect($pending)
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$client.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $InventoryOnly) {
|
||||||
|
$forwardedLog = Get-WinEvent -ListLog ForwardedEvents -ErrorAction Stop
|
||||||
|
if ($forwardedLog.RecordCount -gt 0) {
|
||||||
|
$archivePath = Join-Path $archiveRoot ("ForwardedEvents-{0:yyyyMMdd-HHmmss}.evtx" -f (Get-Date))
|
||||||
|
& wevtutil.exe clear-log ForwardedEvents "/backup:$archivePath"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Could not archive ForwardedEvents; wevtutil returned exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$brokerLog = Get-WinEvent -ListLog $BrokerEventLogName -ErrorAction SilentlyContinue
|
||||||
|
if ($brokerLog -and $brokerLog.RecordCount -gt 0) {
|
||||||
|
$brokerArchivePath = Join-Path $brokerArchiveRoot ("SguAuthBroker-{0:yyyyMMdd-HHmmss}.evtx" -f (Get-Date))
|
||||||
|
& wevtutil.exe clear-log $BrokerEventLogName "/backup:$brokerArchivePath"
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Could not archive $BrokerEventLogName; wevtutil returned exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutoff = (Get-Date).AddDays(-$RetentionDays)
|
||||||
|
Get-ChildItem -LiteralPath $archiveRoot -Filter '*.evtx' -File -Recurse -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object LastWriteTime -lt $cutoff |
|
||||||
|
ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force }
|
||||||
|
}
|
||||||
|
|
||||||
|
$computers = @(Get-ADComputer -SearchBase $ComputerOuDn -SearchScope Subtree -Filter * `
|
||||||
|
-Properties DNSHostName,IPv4Address,OperatingSystem,LastLogonDate,Enabled |
|
||||||
|
Sort-Object Name)
|
||||||
|
|
||||||
|
$inventory = @(foreach ($computer in $computers) {
|
||||||
|
$target = if ($computer.DNSHostName) { $computer.DNSHostName } else { $computer.Name }
|
||||||
|
$online = Test-TcpEndpoint -ComputerName $target
|
||||||
|
[pscustomobject]@{
|
||||||
|
ComputerName = $computer.Name
|
||||||
|
DNSHostName = $computer.DNSHostName
|
||||||
|
IPv4Address = $computer.IPv4Address
|
||||||
|
OperatingSystem = $computer.OperatingSystem
|
||||||
|
Enabled = [bool]$computer.Enabled
|
||||||
|
Status = if ($online) { 'Encendida' } else { 'Apagada o inaccesible' }
|
||||||
|
WinRMReachable = [bool]$online
|
||||||
|
LastDomainLogon = if ($computer.LastLogonDate) {
|
||||||
|
$computer.LastLogonDate.ToUniversalTime().ToString('o')
|
||||||
|
} else { $null }
|
||||||
|
CheckedAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$jsonPath = Join-Path $reportRoot 'machine-status.json'
|
||||||
|
$csvPath = Join-Path $reportRoot 'machine-status.csv'
|
||||||
|
[IO.File]::WriteAllText($jsonPath, (ConvertTo-Json -InputObject $inventory -Depth 4), [Text.UTF8Encoding]::new($false))
|
||||||
|
$inventory | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding UTF8
|
||||||
|
|
||||||
|
[pscustomobject]@{
|
||||||
|
CheckedAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||||
|
ComputerCount = @($inventory).Count
|
||||||
|
OnlineCount = @($inventory | Where-Object WinRMReachable).Count
|
||||||
|
OfflineCount = @($inventory | Where-Object { -not $_.WinRMReachable }).Count
|
||||||
|
RetentionDays = $RetentionDays
|
||||||
|
StatusJson = $jsonPath
|
||||||
|
StatusCsv = $csvPath
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$DataRoot,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[ValidatePattern('^[A-Fa-f0-9]{40}$')]
|
||||||
|
[string]$CertificateThumbprint
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$requestsRoot = Join-Path $DataRoot 'Public\Requests'
|
||||||
|
$archiveRoot = Join-Path $DataRoot 'Public\Archive'
|
||||||
|
$rejectedRoot = Join-Path $DataRoot 'Public\Rejected'
|
||||||
|
$registrationScript = Join-Path $env:ProgramData 'SGU\RustDesk\Register-SguRustDeskDevice.ps1'
|
||||||
|
|
||||||
|
function Write-Result {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$RequestId,
|
||||||
|
[Parameter(Mandatory)][hashtable]$Value
|
||||||
|
)
|
||||||
|
$path = Join-Path $requestsRoot "$RequestId.result.json"
|
||||||
|
[IO.File]::WriteAllText($path, ($Value | ConvertTo-Json), [Text.UTF8Encoding]::new($false))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ComputerNameFromOwner {
|
||||||
|
param([Parameter(Mandatory)][string]$Owner)
|
||||||
|
if ($Owner -notmatch '^[^\\]+\\(?<Name>[A-Za-z0-9][A-Za-z0-9-]{0,62})\$$') {
|
||||||
|
throw 'The request file owner is not an Active Directory computer account.'
|
||||||
|
}
|
||||||
|
return $Matches.Name.ToUpperInvariant()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $registrationScript -PathType Leaf)) {
|
||||||
|
throw "The RustDesk inventory registration script is missing: $registrationScript"
|
||||||
|
}
|
||||||
|
Import-Module ActiveDirectory -ErrorAction Stop
|
||||||
|
$certificate = Get-Item -LiteralPath "Cert:\LocalMachine\My\$CertificateThumbprint" -ErrorAction Stop
|
||||||
|
$rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($certificate)
|
||||||
|
if (-not $rsa) {
|
||||||
|
throw 'The Linux RustDesk enrollment certificate does not have an RSA private key.'
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $requestsRoot, $archiveRoot, $rejectedRoot -Force | Out-Null
|
||||||
|
Get-ChildItem -LiteralPath $requestsRoot -Filter '*.request' -File | ForEach-Object {
|
||||||
|
$requestFile = $_
|
||||||
|
$requestIdMatch = [regex]::Match($requestFile.BaseName,
|
||||||
|
'(?<Id>[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$')
|
||||||
|
if (-not $requestIdMatch.Success) {
|
||||||
|
Move-Item -LiteralPath $requestFile.FullName -Destination (Join-Path $rejectedRoot $requestFile.Name) -Force
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$requestId = $requestIdMatch.Groups['Id'].Value
|
||||||
|
try {
|
||||||
|
$ownerComputerName = Get-ComputerNameFromOwner -Owner (Get-Acl -LiteralPath $requestFile.FullName).Owner
|
||||||
|
$plainText = [Text.Encoding]::UTF8.GetString($rsa.Decrypt(
|
||||||
|
[IO.File]::ReadAllBytes($requestFile.FullName),
|
||||||
|
[Security.Cryptography.RSAEncryptionPadding]::OaepSHA256))
|
||||||
|
$request = $plainText | ConvertFrom-Json -ErrorAction Stop
|
||||||
|
$computerName = [string]$request.ComputerName
|
||||||
|
$rustDeskId = [string]$request.RustDeskId
|
||||||
|
$accessPassword = [string]$request.AccessPassword
|
||||||
|
$declaredRequestId = [string]$request.RequestId
|
||||||
|
if ($computerName -notmatch '^[A-Za-z0-9][A-Za-z0-9-]{0,62}$' -or
|
||||||
|
$computerName.ToUpperInvariant() -ne $ownerComputerName -or
|
||||||
|
$rustDeskId -notmatch '^\d+$' -or
|
||||||
|
$accessPassword.Length -lt 12 -or
|
||||||
|
$declaredRequestId -notmatch '^[0-9a-fA-F-]{36}$') {
|
||||||
|
throw 'The encrypted Linux RustDesk registration payload is invalid.'
|
||||||
|
}
|
||||||
|
Get-ADComputer -Identity $ownerComputerName -ErrorAction Stop | Out-Null
|
||||||
|
& $registrationScript -ComputerName $ownerComputerName -RustDeskId $rustDeskId `
|
||||||
|
-AccessPassword $accessPassword -Confirm:$false | Out-Null
|
||||||
|
Write-Result -RequestId $declaredRequestId -Value @{
|
||||||
|
Status = 'Registered'
|
||||||
|
ComputerName = $ownerComputerName
|
||||||
|
RustDeskId = $rustDeskId
|
||||||
|
RegisteredAt = (Get-Date).ToString('o')
|
||||||
|
}
|
||||||
|
Move-Item -LiteralPath $requestFile.FullName -Destination (Join-Path $archiveRoot $requestFile.Name) -Force
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$safeError = $_.Exception.Message -replace '(?i)password[^\r\n]*', 'credential validation failed'
|
||||||
|
Write-Result -RequestId $requestId -Value @{
|
||||||
|
Status = 'Rejected'
|
||||||
|
Error = $safeError
|
||||||
|
}
|
||||||
|
Move-Item -LiteralPath $requestFile.FullName -Destination (Join-Path $rejectedRoot $requestFile.Name) -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
[CmdletBinding(SupportsShouldProcess)]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[ValidatePattern('^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$')]
|
||||||
|
[string]$ClientName,
|
||||||
|
[string]$OutputDirectory = (Join-Path $PSScriptRoot '..\artifacts\azure-p2s'),
|
||||||
|
[securestring]$ClientPfxPassword,
|
||||||
|
[string]$RootSubject = 'CN=SGU Azure P2S Root',
|
||||||
|
[ValidateRange(1, 10)]
|
||||||
|
[int]$ClientValidityYears = 2,
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$resolvedOutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
|
||||||
|
New-Item -ItemType Directory -Path $resolvedOutputDirectory -Force | Out-Null
|
||||||
|
$rootCertificatePath = Join-Path $resolvedOutputDirectory 'sgu-azure-p2s-root.cer'
|
||||||
|
$clientCertificatePath = Join-Path $resolvedOutputDirectory "sgu-azure-p2s-$ClientName.pfx"
|
||||||
|
if ((Test-Path -LiteralPath $clientCertificatePath -PathType Leaf) -and -not $Force) {
|
||||||
|
throw "$clientCertificatePath already exists. Use -Force only when you intend to replace that exported client credential."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $ClientPfxPassword) {
|
||||||
|
$ClientPfxPassword = Read-Host 'Password that will protect the exported P2S client certificate' -AsSecureString
|
||||||
|
}
|
||||||
|
|
||||||
|
$rootCertificate = Get-ChildItem Cert:\CurrentUser\My |
|
||||||
|
Where-Object {
|
||||||
|
$_.Subject -eq $RootSubject -and
|
||||||
|
$_.HasPrivateKey -and
|
||||||
|
$_.NotAfter -gt (Get-Date).AddYears($ClientValidityYears)
|
||||||
|
} |
|
||||||
|
Sort-Object NotAfter -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
|
||||||
|
if (-not $rootCertificate) {
|
||||||
|
if (-not $PSCmdlet.ShouldProcess($RootSubject, 'Create a non-exportable Azure P2S root certificate authority')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$rootCertificate = New-SelfSignedCertificate `
|
||||||
|
-Type Custom `
|
||||||
|
-Subject $RootSubject `
|
||||||
|
-CertStoreLocation Cert:\CurrentUser\My `
|
||||||
|
-KeyAlgorithm RSA `
|
||||||
|
-KeyLength 4096 `
|
||||||
|
-HashAlgorithm SHA256 `
|
||||||
|
-KeySpec Signature `
|
||||||
|
-KeyExportPolicy NonExportable `
|
||||||
|
-KeyUsage CertSign,CRLSign,DigitalSignature `
|
||||||
|
-NotAfter (Get-Date).AddYears(10) `
|
||||||
|
-TextExtension @('2.5.29.19={critical}{text}ca=1&pathlength=1')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $PSCmdlet.ShouldProcess($ClientName, 'Issue and export an Azure P2S machine certificate')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$clientSubject = "CN=SGU Azure P2S $ClientName"
|
||||||
|
$clientCertificate = New-SelfSignedCertificate `
|
||||||
|
-Type Custom `
|
||||||
|
-Subject $clientSubject `
|
||||||
|
-DnsName "sgu-p2s-$ClientName" `
|
||||||
|
-Signer $rootCertificate `
|
||||||
|
-CertStoreLocation Cert:\CurrentUser\My `
|
||||||
|
-KeyAlgorithm RSA `
|
||||||
|
-KeyLength 3072 `
|
||||||
|
-HashAlgorithm SHA256 `
|
||||||
|
-KeySpec Signature `
|
||||||
|
-KeyExportPolicy Exportable `
|
||||||
|
-KeyUsage DigitalSignature `
|
||||||
|
-NotAfter (Get-Date).AddYears($ClientValidityYears) `
|
||||||
|
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.2')
|
||||||
|
|
||||||
|
Export-Certificate -Cert $rootCertificate -FilePath $rootCertificatePath -Force | Out-Null
|
||||||
|
Export-PfxCertificate -Cert $clientCertificate -FilePath $clientCertificatePath `
|
||||||
|
-Password $ClientPfxPassword -ChainOption BuildChain -CryptoAlgorithmOption AES256_SHA256 `
|
||||||
|
-Force | Out-Null
|
||||||
|
|
||||||
|
[pscustomobject]@{
|
||||||
|
RootCertificatePath = $rootCertificatePath
|
||||||
|
RootCertificateThumbprint = $rootCertificate.Thumbprint
|
||||||
|
RootCertificateData = [Convert]::ToBase64String($rootCertificate.RawData)
|
||||||
|
ClientName = $ClientName
|
||||||
|
ClientCertificatePath = $clientCertificatePath
|
||||||
|
ClientCertificateThumbprint = $clientCertificate.Thumbprint
|
||||||
|
ClientCertificateExpires = $clientCertificate.NotAfter
|
||||||
|
RootPrivateKeyExportable = $false
|
||||||
|
}
|
||||||
@@ -80,27 +80,45 @@ if (-not $runtimeInstaller) {
|
|||||||
New-Item -ItemType Directory -Path $resolvedOutputRoot -Force | Out-Null
|
New-Item -ItemType Directory -Path $resolvedOutputRoot -Force | Out-Null
|
||||||
$clientRoot = Join-Path $resolvedOutputRoot "sgu-client-bootstrap-$Version"
|
$clientRoot = Join-Path $resolvedOutputRoot "sgu-client-bootstrap-$Version"
|
||||||
$serverRoot = Join-Path $resolvedOutputRoot "sgu-server-bootstrap-$Version"
|
$serverRoot = Join-Path $resolvedOutputRoot "sgu-server-bootstrap-$Version"
|
||||||
|
$linuxClientRoot = Join-Path $resolvedOutputRoot "sgu-linux-client-bootstrap-$Version"
|
||||||
|
$azureRoot = Join-Path $resolvedOutputRoot "sgu-azure-infrastructure-$Version"
|
||||||
$clientZip = "$clientRoot.zip"
|
$clientZip = "$clientRoot.zip"
|
||||||
$serverZip = "$serverRoot.zip"
|
$serverZip = "$serverRoot.zip"
|
||||||
foreach ($target in @($clientRoot,$serverRoot,$clientZip,$serverZip)) {
|
$linuxClientZip = "$linuxClientRoot.zip"
|
||||||
|
$azureZip = "$azureRoot.zip"
|
||||||
|
foreach ($target in @($clientRoot,$serverRoot,$linuxClientRoot,$azureRoot,$clientZip,$serverZip,$linuxClientZip,$azureZip)) {
|
||||||
if (Test-Path -LiteralPath $target) {
|
if (Test-Path -LiteralPath $target) {
|
||||||
throw "Release target already exists: $target"
|
throw "Release target already exists: $target"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
New-Item -ItemType Directory -Path $clientRoot,$serverRoot -Force | Out-Null
|
New-Item -ItemType Directory -Path $clientRoot,$serverRoot,$linuxClientRoot,$azureRoot -Force | Out-Null
|
||||||
|
$welcomeFontNames = @(
|
||||||
|
'IndivisaTextSans-Regular.otf',
|
||||||
|
'IndivisaTextSans-Bold.otf',
|
||||||
|
'IndivisaTextSans-BoldItalic.otf',
|
||||||
|
'IndivisaTextSerif-Regular.otf',
|
||||||
|
'IndivisaTextSerif-BoldItalic.otf'
|
||||||
|
)
|
||||||
|
|
||||||
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Invoke-SguClientBootstrap.ps1') `
|
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Invoke-SguClientBootstrap.ps1') `
|
||||||
-Destination (Join-Path $clientRoot 'Invoke-SguClientBootstrap.ps1')
|
-Destination (Join-Path $clientRoot 'Invoke-SguClientBootstrap.ps1')
|
||||||
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguClientEnrollment.cmd') `
|
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguClientEnrollment.cmd') `
|
||||||
-Destination (Join-Path $clientRoot 'Start-SguClientEnrollment.cmd')
|
-Destination (Join-Path $clientRoot 'Start-SguClientEnrollment.cmd')
|
||||||
|
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguAzureClientEnrollment.cmd') `
|
||||||
|
-Destination (Join-Path $clientRoot 'Start-SguAzureClientEnrollment.cmd')
|
||||||
|
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Install-SguAzureP2sClient.ps1') `
|
||||||
|
-Destination (Join-Path $clientRoot 'Install-SguAzureP2sClient.ps1')
|
||||||
$clientScripts = @(
|
$clientScripts = @(
|
||||||
'Enable-LabRemoteAccess.ps1',
|
'Enable-LabRemoteAccess.ps1',
|
||||||
|
'Enable-SguClientMonitoring.ps1',
|
||||||
'Enroll-SguDomainClient.ps1',
|
'Enroll-SguDomainClient.ps1',
|
||||||
'Install-CredentialProvider.ps1',
|
'Install-CredentialProvider.ps1',
|
||||||
'Install-SguEnrollmentGuard.ps1',
|
'Install-SguEnrollmentGuard.ps1',
|
||||||
|
'Install-SguRustDeskClient.ps1',
|
||||||
'Register-SguClientCertificate.ps1',
|
'Register-SguClientCertificate.ps1',
|
||||||
'Repair-SguClientEnrollment.ps1',
|
'Repair-SguClientEnrollment.ps1',
|
||||||
|
'Set-SguStandardLocalUser.ps1',
|
||||||
'Test-SguClientEnrollment.ps1'
|
'Test-SguClientEnrollment.ps1'
|
||||||
)
|
)
|
||||||
foreach ($scriptName in $clientScripts) {
|
foreach ($scriptName in $clientScripts) {
|
||||||
@@ -113,21 +131,62 @@ Copy-Item -Path (Join-Path $providerOutput '*') `
|
|||||||
-Recurse -Force
|
-Recurse -Force
|
||||||
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'assets\branding\lasalle-mascot-account.png') `
|
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'assets\branding\lasalle-mascot-account.png') `
|
||||||
-Destination (Join-Path $clientRoot 'payload\credential-provider\branding\user.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 `
|
Copy-RequiredFile -Source $runtimeInstaller.FullName `
|
||||||
-Destination (Join-Path $clientRoot "payload\prerequisites\$($runtimeInstaller.Name)")
|
-Destination (Join-Path $clientRoot "payload\prerequisites\$($runtimeInstaller.Name)")
|
||||||
Write-PackageManifest -PackageRoot $clientRoot -PackageVersion $Version -PackageKind Client
|
Write-PackageManifest -PackageRoot $clientRoot -PackageVersion $Version -PackageKind Client
|
||||||
Compress-Archive -Path (Join-Path $clientRoot '*') -DestinationPath $clientZip `
|
Compress-Archive -Path (Join-Path $clientRoot '*') -DestinationPath $clientZip `
|
||||||
-CompressionLevel Optimal
|
-CompressionLevel Optimal
|
||||||
|
|
||||||
|
# Linux clients use their native PAM/SSSD sign-in stack rather than the Windows
|
||||||
|
# Credential Provider. Keep their self-contained bootstrap independent so a
|
||||||
|
# Linux administrator never receives Windows binaries or certificate material.
|
||||||
|
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Enroll-SguLinuxDomainClient.sh') `
|
||||||
|
-Destination (Join-Path $linuxClientRoot 'Enroll-SguLinuxDomainClient.sh')
|
||||||
|
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Install-SguLinuxRustDeskClient.sh') `
|
||||||
|
-Destination (Join-Path $linuxClientRoot 'Install-SguLinuxRustDeskClient.sh')
|
||||||
|
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'docs\linux-client-enrollment.md') `
|
||||||
|
-Destination (Join-Path $linuxClientRoot 'README.md')
|
||||||
|
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Set-SguWelcomeWallpaper.sh') `
|
||||||
|
-Destination (Join-Path $linuxClientRoot 'welcome-wallpaper\Set-SguWelcomeWallpaper.sh')
|
||||||
|
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'assets\branding\darkblue.jpg') `
|
||||||
|
-Destination (Join-Path $linuxClientRoot 'welcome-wallpaper\darkblue.jpg')
|
||||||
|
foreach ($fontName in $welcomeFontNames) {
|
||||||
|
Copy-RequiredFile -Source (Join-Path $repositoryRoot "assets\branding\fonts\$fontName") `
|
||||||
|
-Destination (Join-Path $linuxClientRoot "welcome-wallpaper\fonts\$fontName")
|
||||||
|
}
|
||||||
|
Write-PackageManifest -PackageRoot $linuxClientRoot -PackageVersion $Version -PackageKind LinuxClient
|
||||||
|
Compress-Archive -Path (Join-Path $linuxClientRoot '*') -DestinationPath $linuxClientZip `
|
||||||
|
-CompressionLevel Optimal
|
||||||
|
|
||||||
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Initialize-SguDomainController.ps1') `
|
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Initialize-SguDomainController.ps1') `
|
||||||
-Destination (Join-Path $serverRoot 'Initialize-SguDomainController.ps1')
|
-Destination (Join-Path $serverRoot 'Initialize-SguDomainController.ps1')
|
||||||
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguServerBootstrap.cmd') `
|
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguServerBootstrap.cmd') `
|
||||||
-Destination (Join-Path $serverRoot 'Start-SguServerBootstrap.cmd')
|
-Destination (Join-Path $serverRoot 'Start-SguServerBootstrap.cmd')
|
||||||
|
Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguAzureServerBootstrap.cmd') `
|
||||||
|
-Destination (Join-Path $serverRoot 'Start-SguAzureServerBootstrap.cmd')
|
||||||
$serverScripts = @(
|
$serverScripts = @(
|
||||||
'Deploy-AuthBroker.ps1',
|
'Deploy-AuthBroker.ps1',
|
||||||
'Enable-SguServerRemoteManagement.ps1',
|
'Enable-SguServerRemoteManagement.ps1',
|
||||||
|
'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',
|
'New-LabCertificate.ps1',
|
||||||
|
'Get-SguRustDeskDevice.ps1',
|
||||||
'Register-SguClientCertificate.ps1',
|
'Register-SguClientCertificate.ps1',
|
||||||
|
'Register-SguRustDeskDevice.ps1',
|
||||||
'Set-LabBrokerDns.ps1',
|
'Set-LabBrokerDns.ps1',
|
||||||
'Set-SguDomainComputerPolicies.ps1',
|
'Set-SguDomainComputerPolicies.ps1',
|
||||||
'Set-SguDomainUserPolicies.ps1'
|
'Set-SguDomainUserPolicies.ps1'
|
||||||
@@ -149,13 +208,44 @@ if ($ServerContentPath) {
|
|||||||
Copy-Item -Path (Join-Path $ServerContentPath '*') `
|
Copy-Item -Path (Join-Path $ServerContentPath '*') `
|
||||||
-Destination $serverContentTarget -Recurse -Force
|
-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
|
Write-PackageManifest -PackageRoot $serverRoot -PackageVersion $Version -PackageKind Server
|
||||||
Compress-Archive -Path (Join-Path $serverRoot '*') -DestinationPath $serverZip `
|
Compress-Archive -Path (Join-Path $serverRoot '*') -DestinationPath $serverZip `
|
||||||
-CompressionLevel Optimal
|
-CompressionLevel Optimal
|
||||||
|
|
||||||
|
# Azure infrastructure is packaged separately because it runs on the trusted
|
||||||
|
# administrator workstation, not inside the domain controller or a client.
|
||||||
|
$azureScriptsRoot = Join-Path $azureRoot 'scripts'
|
||||||
|
$azureInfrastructureRoot = Join-Path $azureRoot 'infra\azure'
|
||||||
|
New-Item -ItemType Directory -Path $azureScriptsRoot,$azureInfrastructureRoot -Force | Out-Null
|
||||||
|
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'infra\azure\main.bicep') `
|
||||||
|
-Destination (Join-Path $azureInfrastructureRoot 'main.bicep')
|
||||||
|
foreach ($scriptName in @(
|
||||||
|
'New-SguAzureP2sCertificates.ps1',
|
||||||
|
'Deploy-SguAzureInfrastructure.ps1',
|
||||||
|
'Get-SguAzureP2sPackage.ps1',
|
||||||
|
'Install-SguAzureP2sClient.ps1')) {
|
||||||
|
Copy-RequiredFile -Source (Join-Path $PSScriptRoot $scriptName) `
|
||||||
|
-Destination (Join-Path $azureScriptsRoot $scriptName)
|
||||||
|
}
|
||||||
|
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'docs\azure-vpn-deployment.md') `
|
||||||
|
-Destination (Join-Path $azureRoot 'README.md')
|
||||||
|
Write-PackageManifest -PackageRoot $azureRoot -PackageVersion $Version -PackageKind AzureInfrastructure
|
||||||
|
Compress-Archive -Path (Join-Path $azureRoot '*') -DestinationPath $azureZip `
|
||||||
|
-CompressionLevel Optimal
|
||||||
|
|
||||||
$checksums = @(
|
$checksums = @(
|
||||||
("{0} {1}" -f (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash, (Split-Path $clientZip -Leaf))
|
("{0} {1}" -f (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash, (Split-Path $clientZip -Leaf))
|
||||||
("{0} {1}" -f (Get-FileHash -LiteralPath $serverZip -Algorithm SHA256).Hash, (Split-Path $serverZip -Leaf))
|
("{0} {1}" -f (Get-FileHash -LiteralPath $serverZip -Algorithm SHA256).Hash, (Split-Path $serverZip -Leaf))
|
||||||
|
("{0} {1}" -f (Get-FileHash -LiteralPath $linuxClientZip -Algorithm SHA256).Hash, (Split-Path $linuxClientZip -Leaf))
|
||||||
|
("{0} {1}" -f (Get-FileHash -LiteralPath $azureZip -Algorithm SHA256).Hash, (Split-Path $azureZip -Leaf))
|
||||||
)
|
)
|
||||||
$checksumsPath = Join-Path $resolvedOutputRoot "SHA256SUMS-$Version.txt"
|
$checksumsPath = Join-Path $resolvedOutputRoot "SHA256SUMS-$Version.txt"
|
||||||
[IO.File]::WriteAllLines($checksumsPath, $checksums, [Text.UTF8Encoding]::new($false))
|
[IO.File]::WriteAllLines($checksumsPath, $checksums, [Text.UTF8Encoding]::new($false))
|
||||||
@@ -164,8 +254,12 @@ $checksumsPath = Join-Path $resolvedOutputRoot "SHA256SUMS-$Version.txt"
|
|||||||
Version = $Version
|
Version = $Version
|
||||||
ClientPackage = $clientZip
|
ClientPackage = $clientZip
|
||||||
ClientSha256 = (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash
|
ClientSha256 = (Get-FileHash -LiteralPath $clientZip -Algorithm SHA256).Hash
|
||||||
|
LinuxClientPackage = $linuxClientZip
|
||||||
|
LinuxClientSha256 = (Get-FileHash -LiteralPath $linuxClientZip -Algorithm SHA256).Hash
|
||||||
ServerPackage = $serverZip
|
ServerPackage = $serverZip
|
||||||
ServerSha256 = (Get-FileHash -LiteralPath $serverZip -Algorithm SHA256).Hash
|
ServerSha256 = (Get-FileHash -LiteralPath $serverZip -Algorithm SHA256).Hash
|
||||||
|
AzureInfrastructurePackage = $azureZip
|
||||||
|
AzureInfrastructureSha256 = (Get-FileHash -LiteralPath $azureZip -Algorithm SHA256).Hash
|
||||||
Checksums = $checksumsPath
|
Checksums = $checksumsPath
|
||||||
RuntimeInstaller = $runtimeInstaller.Name
|
RuntimeInstaller = $runtimeInstaller.Name
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ $tagName = "v$Version"
|
|||||||
$assetPaths = @(
|
$assetPaths = @(
|
||||||
(Join-Path $ReleaseDirectory "sgu-client-bootstrap-$Version.zip"),
|
(Join-Path $ReleaseDirectory "sgu-client-bootstrap-$Version.zip"),
|
||||||
(Join-Path $ReleaseDirectory "sgu-server-bootstrap-$Version.zip"),
|
(Join-Path $ReleaseDirectory "sgu-server-bootstrap-$Version.zip"),
|
||||||
|
(Join-Path $ReleaseDirectory "sgu-linux-client-bootstrap-$Version.zip"),
|
||||||
|
(Join-Path $ReleaseDirectory "sgu-azure-infrastructure-$Version.zip"),
|
||||||
(Join-Path $ReleaseDirectory "SHA256SUMS-$Version.txt")
|
(Join-Path $ReleaseDirectory "SHA256SUMS-$Version.txt")
|
||||||
)
|
)
|
||||||
foreach ($assetPath in $assetPaths) {
|
foreach ($assetPath in $assetPaths) {
|
||||||
@@ -26,7 +28,9 @@ foreach ($assetPath in $assetPaths) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$token = $env:GITEA_TOKEN
|
$token = $env:GITEA_TOKEN
|
||||||
if (-not $token) {
|
$authorizationScheme = 'token'
|
||||||
|
$authorizationParameter = $token
|
||||||
|
if (-not $authorizationParameter) {
|
||||||
$credentialInput = "protocol=$($GiteaBaseUri.Scheme)`nhost=$($GiteaBaseUri.Host)`n`n"
|
$credentialInput = "protocol=$($GiteaBaseUri.Scheme)`nhost=$($GiteaBaseUri.Host)`n`n"
|
||||||
$credentialOutput = $credentialInput | & git credential fill
|
$credentialOutput = $credentialInput | & git credential fill
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
@@ -39,17 +43,25 @@ if (-not $token) {
|
|||||||
$credentialValues[$parts[0]] = $parts[1]
|
$credentialValues[$parts[0]] = $parts[1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$token = $credentialValues.password
|
if ($credentialValues.username -and $credentialValues.password) {
|
||||||
|
$authorizationScheme = 'Basic'
|
||||||
|
$basicCredential = '{0}:{1}' -f $credentialValues.username,$credentialValues.password
|
||||||
|
$authorizationParameter = [Convert]::ToBase64String(
|
||||||
|
[Text.Encoding]::UTF8.GetBytes($basicCredential))
|
||||||
|
$basicCredential = $null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (-not $token) {
|
if (-not $authorizationParameter) {
|
||||||
throw 'No Gitea token is available. Set GITEA_TOKEN for this process or sign in through Git Credential Manager.'
|
throw 'No Gitea credential is available. Set GITEA_TOKEN for this process or sign in through Git Credential Manager.'
|
||||||
}
|
}
|
||||||
|
|
||||||
Add-Type -AssemblyName System.Net.Http
|
Add-Type -AssemblyName System.Net.Http
|
||||||
$handler = [Net.Http.HttpClientHandler]::new()
|
$handler = [Net.Http.HttpClientHandler]::new()
|
||||||
$client = [Net.Http.HttpClient]::new($handler)
|
$client = [Net.Http.HttpClient]::new($handler)
|
||||||
$client.BaseAddress = [uri]($GiteaBaseUri.AbsoluteUri.TrimEnd('/') + '/')
|
$client.BaseAddress = [uri]($GiteaBaseUri.AbsoluteUri.TrimEnd('/') + '/')
|
||||||
$client.DefaultRequestHeaders.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new('token', $token)
|
$client.DefaultRequestHeaders.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new(
|
||||||
|
$authorizationScheme,
|
||||||
|
$authorizationParameter)
|
||||||
$client.DefaultRequestHeaders.UserAgent.ParseAdd('SGU-CredentialProvider-Release/1.0')
|
$client.DefaultRequestHeaders.UserAgent.ParseAdd('SGU-CredentialProvider-Release/1.0')
|
||||||
|
|
||||||
function Invoke-GiteaJson {
|
function Invoke-GiteaJson {
|
||||||
@@ -100,6 +112,15 @@ Bootstrap reproducible para el laboratorio SGU.
|
|||||||
- **Advertencia:** el bootstrap de servidor crea un bosque nuevo. No restaura los SID, contraseñas ni relaciones de confianza del bosque anterior; para conservarlos se requiere una recuperación de bosque desde una copia de estado del sistema.
|
- **Advertencia:** el bootstrap de servidor crea un bosque nuevo. No restaura los SID, contraseñas ni relaciones de confianza del bosque anterior; para conservarlos se requiere una recuperación de bosque desde una copia de estado del sistema.
|
||||||
- `sgu-server-bootstrap-$Version.zip`: crea el bosque AD/DNS, OUs, grupo RDP, GPO, recurso `Packages`, broker mTLS y administración remota; se reanuda solo después del reinicio.
|
- `sgu-server-bootstrap-$Version.zip`: crea el bosque AD/DNS, OUs, grupo RDP, GPO, recurso `Packages`, broker mTLS y administración remota; se reanuda solo después del reinicio.
|
||||||
- `sgu-client-bootstrap-$Version.zip`: registra un certificado mTLS único, instala y valida el Credential Provider antes de unir el equipo al dominio, habilita RDP/WinRM y se repara al arranque.
|
- `sgu-client-bootstrap-$Version.zip`: registra un certificado mTLS único, instala y valida el Credential Provider antes de unir el equipo al dominio, habilita RDP/WinRM y se repara al arranque.
|
||||||
|
- `sgu-linux-client-bootstrap-$Version.zip`: une clientes Debian/Ubuntu o RHEL/Fedora/Rocky/AlmaLinux con realmd, Kerberos y SSSD. Solicita interactivamente la contraseña de unión y no instala el Credential Provider de Windows.
|
||||||
|
- `sgu-azure-infrastructure-$Version.zip`: despliega mediante Bicep una VM Windows Server 2025, red privada, IP pública protegida por NSG y Azure VPN Gateway P2S; también genera certificados por equipo y descarga el perfil de cliente.
|
||||||
|
- El bootstrap Azure conserva la IP privada administrada por la NIC de Azure, autoriza el pool P2S en los firewalls SGU y nunca publica LDAP, Kerberos, SMB, RPC, WinRM ni el Auth Broker directamente a Internet.
|
||||||
|
- Los Windows 11 Pro pueden instalar un perfil IKEv2 de todos los usuarios con certificado de máquina, DNS dividido para `lci.lasalle.mx` y ejecutarlo desde la pantalla de inicio de sesión antes de autenticar una cuenta de dominio nueva.
|
||||||
|
- El Auth Broker clasifica sin tareas programadas cada cuenta autenticada: `AL` se agrega a `SGU-Alumnos`, `AD` a `SGU-Administrativos` y `DO` a `SGU-Docentes`; el bootstrap crea cada grupo dentro de la OU de su rol y migra idempotentemente cualquier grupo heredado sin cambiar su SID.
|
||||||
|
- El Auth Broker resuelve la dirección guardada de administrativos y docentes mediante `GetDireccion`, `GetLocalidadListado` y `GetColoniasListado`, evitando conservar los valores transitorios `Seleccione...` de los controles dinámicos de SGU.
|
||||||
|
- El enrolamiento y la reparación de clientes Windows crean y verifican idempotentemente la cuenta local estándar `alumno`, sin pertenencia al grupo de administradores.
|
||||||
|
- El enriquecimiento obtiene el sexo de los módulos SGU de personal/alumnos, lo conserva como la línea administrada `SGU-Gender: Male|Female` en Notas de AD y adapta el fondo de Windows/Linux; cuando falta utiliza redacción neutral.
|
||||||
|
- El servidor configura WEF/WEC para registrar sesiones y fallos, inventariar el estado alcanzable de las máquinas cada cinco minutos y conservar durante 183 días tanto esos eventos como el diagnóstico estructurado del Auth Broker.
|
||||||
- Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host.
|
- Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host.
|
||||||
|
|
||||||
Las contraseñas se solicitan de forma interactiva y no se escriben en archivos ni en la línea de comandos. Verifique los ZIP con `SHA256SUMS-$Version.txt`.
|
Las contraseñas se solicitan de forma interactiva y no se escriben en archivos ni en la línea de comandos. Verifique los ZIP con `SHA256SUMS-$Version.txt`.
|
||||||
@@ -151,6 +172,8 @@ Las contraseñas se solicitan de forma interactiva y no se escriben en archivos
|
|||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
$token = $null
|
$token = $null
|
||||||
|
$authorizationParameter = $null
|
||||||
|
$credentialValues = $null
|
||||||
$client.Dispose()
|
$client.Dispose()
|
||||||
$handler.Dispose()
|
$handler.Dispose()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -9,9 +9,13 @@ $enrollmentRoot = Split-Path $ConfigurationPath -Parent
|
|||||||
$testScript = Join-Path $enrollmentRoot 'Test-SguClientEnrollment.ps1'
|
$testScript = Join-Path $enrollmentRoot 'Test-SguClientEnrollment.ps1'
|
||||||
$installScript = Join-Path $enrollmentRoot 'Install-CredentialProvider.ps1'
|
$installScript = Join-Path $enrollmentRoot 'Install-CredentialProvider.ps1'
|
||||||
$remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1'
|
$remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1'
|
||||||
|
$monitoringScript = Join-Path $enrollmentRoot 'Enable-SguClientMonitoring.ps1'
|
||||||
|
$rustDeskScript = Join-Path $enrollmentRoot 'Install-SguRustDeskClient.ps1'
|
||||||
|
$localUserScript = Join-Path $enrollmentRoot 'Set-SguStandardLocalUser.ps1'
|
||||||
|
|
||||||
$before = & $testScript
|
$before = & $testScript
|
||||||
if (-not $before.IsValid) {
|
if (-not $before.IsValid) {
|
||||||
|
& $localUserScript | Out-Null
|
||||||
$installParams = @{
|
$installParams = @{
|
||||||
PublishPath = [string]$configuration.PublishPath
|
PublishPath = [string]$configuration.PublishPath
|
||||||
BrokerEndpoint = [string]$configuration.BrokerEndpoint
|
BrokerEndpoint = [string]$configuration.BrokerEndpoint
|
||||||
@@ -32,6 +36,12 @@ if ($computer.PartOfDomain) {
|
|||||||
& $remoteAccessScript `
|
& $remoteAccessScript `
|
||||||
-RemoteDesktopPrincipal ([string]$configuration.RemoteDesktopPrincipal) `
|
-RemoteDesktopPrincipal ([string]$configuration.RemoteDesktopPrincipal) `
|
||||||
-EnableAdministrativeFirewallGroups | Out-Null
|
-EnableAdministrativeFirewallGroups | Out-Null
|
||||||
|
& $monitoringScript | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($configuration.RustDeskServerAddress -and $configuration.RustDeskServerPublicKey) {
|
||||||
|
& $rustDeskScript -ServerAddress ([string]$configuration.RustDeskServerAddress) `
|
||||||
|
-ServerPublicKey ([string]$configuration.RustDeskServerPublicKey) | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
$verificationParams = @{}
|
$verificationParams = @{}
|
||||||
@@ -40,6 +50,10 @@ if ($computer.PartOfDomain) {
|
|||||||
$verificationParams.RequireRemoteAccess = $true
|
$verificationParams.RequireRemoteAccess = $true
|
||||||
$verificationParams.RemoteDesktopPrincipal = [string]$configuration.RemoteDesktopPrincipal
|
$verificationParams.RemoteDesktopPrincipal = [string]$configuration.RemoteDesktopPrincipal
|
||||||
}
|
}
|
||||||
|
if ($configuration.RustDeskServerAddress) {
|
||||||
|
$verificationParams.RequireRustDesk = $true
|
||||||
|
$verificationParams.RustDeskServerAddress = [string]$configuration.RustDeskServerAddress
|
||||||
|
}
|
||||||
$after = & $testScript @verificationParams
|
$after = & $testScript @verificationParams
|
||||||
$after
|
$after
|
||||||
if (-not $after.IsValid) {
|
if (-not $after.IsValid) {
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
param(
|
param(
|
||||||
[string]$TargetOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
|
[string]$TargetOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
|
||||||
[string]$GpoName = 'SGU - Windows client experience',
|
[string]$GpoName = 'SGU - Windows client experience',
|
||||||
[string]$DomainController = $env:COMPUTERNAME
|
[string]$DomainController = $env:COMPUTERNAME,
|
||||||
|
[string]$EventCollectorFqdn,
|
||||||
|
[string]$WelcomeWallpaperScriptPath = 'C:\ProgramData\SGU\Branding\Set-SguWelcomeWallpaper.ps1',
|
||||||
|
[string]$WelcomeWallpaperBasePath = 'C:\ProgramData\SGU\Branding\darkblue.jpg'
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
@@ -24,6 +27,14 @@ if (-not $domainDn) {
|
|||||||
throw 'TargetOuDn does not contain a domain distinguished name.'
|
throw 'TargetOuDn does not contain a domain distinguished name.'
|
||||||
}
|
}
|
||||||
$domainName = ($domainDn -replace ',DC=', '.')
|
$domainName = ($domainDn -replace ',DC=', '.')
|
||||||
|
if (-not $EventCollectorFqdn) {
|
||||||
|
$collectorComputer = Get-ADComputer -Identity $DomainController -Properties DNSHostName `
|
||||||
|
-Server $DomainController -ErrorAction Stop
|
||||||
|
$EventCollectorFqdn = $collectorComputer.DNSHostName
|
||||||
|
}
|
||||||
|
if (-not $EventCollectorFqdn) {
|
||||||
|
throw 'Could not determine the event collector FQDN.'
|
||||||
|
}
|
||||||
|
|
||||||
$gpo = Get-GPO -Name $GpoName -Domain $domainName -Server $DomainController -ErrorAction SilentlyContinue
|
$gpo = Get-GPO -Name $GpoName -Domain $domainName -Server $DomainController -ErrorAction SilentlyContinue
|
||||||
if (-not $gpo -and $PSCmdlet.ShouldProcess($GpoName, 'Create the SGU Windows client policy GPO')) {
|
if (-not $gpo -and $PSCmdlet.ShouldProcess($GpoName, 'Create the SGU Windows client policy GPO')) {
|
||||||
@@ -64,7 +75,13 @@ $powerPolicyRoot = 'HKLM\Software\Policies\Microsoft\Power\PowerSettings'
|
|||||||
$credentialProviderPolicyKey = 'HKLM\Software\Policies\Microsoft\Windows\System'
|
$credentialProviderPolicyKey = 'HKLM\Software\Policies\Microsoft\Windows\System'
|
||||||
$interactiveLogonPolicyKey = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System'
|
$interactiveLogonPolicyKey = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System'
|
||||||
$accountPicturePolicyKey = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer'
|
$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}'
|
$providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}'
|
||||||
|
$welcomeWallpaperCommand = 'powershell.exe -NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "{0}" -BaseImagePath "{1}"' -f `
|
||||||
|
$WelcomeWallpaperScriptPath,$WelcomeWallpaperBasePath
|
||||||
$policies = @(
|
$policies = @(
|
||||||
@{ Key = $dataCollectionKey; Name = 'AllowTelemetry'; Type = 'DWord'; Value = 0 },
|
@{ Key = $dataCollectionKey; Name = 'AllowTelemetry'; Type = 'DWord'; Value = 0 },
|
||||||
@{ Key = $dataCollectionKey; Name = 'DisableTelemetryOptInSettingsUx'; Type = 'DWord'; Value = 1 },
|
@{ Key = $dataCollectionKey; Name = 'DisableTelemetryOptInSettingsUx'; Type = 'DWord'; Value = 1 },
|
||||||
@@ -83,7 +100,18 @@ $policies = @(
|
|||||||
|
|
||||||
# Use Windows' native default account image for named user tiles. LogonUI
|
# Use Windows' native default account image for named user tiles. LogonUI
|
||||||
# retains ownership of the anonymous Other user tile and its circular mask.
|
# retains ownership of the anonymous Other user tile and its circular mask.
|
||||||
@{ Key = $accountPicturePolicyKey; Name = 'UseDefaultTile'; Type = 'DWord'; Value = 1 }
|
@{ Key = $accountPicturePolicyKey; Name = 'UseDefaultTile'; Type = 'DWord'; Value = 1 },
|
||||||
|
|
||||||
|
# Source-initiated Windows Event Forwarding. Kerberos authenticates domain
|
||||||
|
# computers to the collector; no SGU password or reusable secret is logged.
|
||||||
|
@{ Key = $eventForwardingPolicyKey; Name = '1'; Type = 'String'; Value = "Server=http://${EventCollectorFqdn}:5985/wsman/SubscriptionManager/WEC,Refresh=300" },
|
||||||
|
@{ Key = $auditPolicyKey; Name = 'SCENoApplyLegacyAuditPolicy'; Type = 'DWord'; Value = 1 },
|
||||||
|
|
||||||
|
# The machine GPO remains the authority for every interactive session. The
|
||||||
|
# local payload lets the first desktop render without depending on SMB.
|
||||||
|
@{ Key = $runPolicyKey; Name = 'SGUWelcomeWallpaper'; Type = 'String'; Value = $welcomeWallpaperCommand },
|
||||||
|
@{ Key = $personalizationPolicyKey; Name = 'LockScreenImage'; Type = 'String'; Value = $WelcomeWallpaperBasePath },
|
||||||
|
@{ Key = $personalizationPolicyKey; Name = 'NoChangingLockScreen'; Type = 'DWord'; Value = 1 }
|
||||||
)
|
)
|
||||||
|
|
||||||
$powerSettingIds = @(
|
$powerSettingIds = @(
|
||||||
@@ -134,5 +162,8 @@ $linkEnabled = $link -and (
|
|||||||
TargetOu = $TargetOuDn
|
TargetOu = $TargetOuDn
|
||||||
LinkEnabled = [bool]$linkEnabled
|
LinkEnabled = [bool]$linkEnabled
|
||||||
PolicyCount = $configuredPolicies.Count
|
PolicyCount = $configuredPolicies.Count
|
||||||
|
EventCollector = $EventCollectorFqdn
|
||||||
|
WelcomeWallpaperCommand = $welcomeWallpaperCommand
|
||||||
|
LockScreenImage = $WelcomeWallpaperBasePath
|
||||||
Policies = [pscustomobject]$configuredPolicies
|
Policies = [pscustomobject]$configuredPolicies
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ param(
|
|||||||
[string]$TargetOuDn = 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx',
|
[string]$TargetOuDn = 'OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx',
|
||||||
[string]$GpoName = 'SGU - User session restrictions',
|
[string]$GpoName = 'SGU - User session restrictions',
|
||||||
[string]$DomainController = $env:COMPUTERNAME,
|
[string]$DomainController = $env:COMPUTERNAME,
|
||||||
[string]$WallpaperPath
|
[string]$WallpaperPath,
|
||||||
|
[switch]$ClearManagedWallpaper
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
@@ -115,6 +116,17 @@ if ($PSCmdlet.ShouldProcess($GpoName, 'Prevent SGU users from manually locking w
|
|||||||
-Type String `
|
-Type String `
|
||||||
-Value '10' | Out-Null
|
-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 `
|
$configuredValue = Get-GPRegistryValue `
|
||||||
@@ -166,4 +178,5 @@ if ($WallpaperPath) {
|
|||||||
ScreenSaverDisabled = [string]$screenSaverValue.Value -eq '0'
|
ScreenSaverDisabled = [string]$screenSaverValue.Value -eq '0'
|
||||||
DarkMode = ([int]$appsThemeValue.Value -eq 0) -and ([int]$systemThemeValue.Value -eq 0)
|
DarkMode = ([int]$appsThemeValue.Value -eq 0) -and ([int]$systemThemeValue.Value -eq 0)
|
||||||
Wallpaper = $configuredWallpaper
|
Wallpaper = $configuredWallpaper
|
||||||
|
DynamicWallpaperAllowed = -not [bool]$configuredWallpaper
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
[CmdletBinding(SupportsShouldProcess)]
|
||||||
|
param()
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$userName = 'alumno'
|
||||||
|
$plainTextPassword = 'ingenieria'
|
||||||
|
$description = 'Cuenta local estandar de recuperacion para equipos SGU'
|
||||||
|
|
||||||
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
|
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||||
|
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||||
|
throw 'Run this script from an elevated Windows PowerShell session.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Create or update standard local user $userName")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$securePassword = ConvertTo-SecureString $plainTextPassword -AsPlainText -Force
|
||||||
|
try {
|
||||||
|
$user = Get-LocalUser -Name $userName -ErrorAction SilentlyContinue
|
||||||
|
if ($user -and $user.SID.Value.EndsWith('-500', [StringComparison]::Ordinal)) {
|
||||||
|
throw "The local account '$userName' is the built-in Administrator account and cannot be converted to a standard user."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
Set-LocalUser -Name $userName `
|
||||||
|
-Password $securePassword `
|
||||||
|
-PasswordNeverExpires $true `
|
||||||
|
-Description $description
|
||||||
|
if (-not $user.Enabled) {
|
||||||
|
Enable-LocalUser -Name $userName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
New-LocalUser -Name $userName `
|
||||||
|
-Password $securePassword `
|
||||||
|
-PasswordNeverExpires `
|
||||||
|
-Description $description | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = Get-LocalUser -Name $userName -ErrorAction Stop
|
||||||
|
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
||||||
|
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
|
||||||
|
$administratorsGroup = Get-LocalGroup -SID $administratorsSid -ErrorAction Stop
|
||||||
|
$usersGroup = Get-LocalGroup -SID $usersSid -ErrorAction Stop
|
||||||
|
$administratorMembers = @(Get-LocalGroupMember -Group $administratorsGroup -ErrorAction Stop)
|
||||||
|
if ($administratorMembers.SID.Value -contains $user.SID.Value) {
|
||||||
|
Remove-LocalGroupMember -Group $administratorsGroup -Member $user -Confirm:$false
|
||||||
|
}
|
||||||
|
|
||||||
|
$standardMembers = @(Get-LocalGroupMember -Group $usersGroup -ErrorAction Stop)
|
||||||
|
if ($standardMembers.SID.Value -notcontains $user.SID.Value) {
|
||||||
|
Add-LocalGroupMember -Group $usersGroup -Member $user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$securePassword = $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$verifiedUser = Get-LocalUser -Name $userName -ErrorAction Stop
|
||||||
|
$verifiedAdministratorsGroup = Get-LocalGroup `
|
||||||
|
-SID ([Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')) `
|
||||||
|
-ErrorAction Stop
|
||||||
|
$verifiedUsersGroup = Get-LocalGroup `
|
||||||
|
-SID ([Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')) `
|
||||||
|
-ErrorAction Stop
|
||||||
|
$verifiedAdministrators = @(Get-LocalGroupMember -Group $verifiedAdministratorsGroup -ErrorAction Stop)
|
||||||
|
$verifiedUsers = @(Get-LocalGroupMember -Group $verifiedUsersGroup -ErrorAction Stop)
|
||||||
|
if (@($verifiedAdministrators).SID.Value -contains $verifiedUser.SID.Value) {
|
||||||
|
throw "The local account '$userName' still belongs to the local Administrators group."
|
||||||
|
}
|
||||||
|
if ($verifiedUsers.SID.Value -notcontains $verifiedUser.SID.Value) {
|
||||||
|
throw "The local account '$userName' does not belong to the local Users group."
|
||||||
|
}
|
||||||
|
|
||||||
|
[pscustomobject]@{
|
||||||
|
UserName = $verifiedUser.Name
|
||||||
|
Enabled = $verifiedUser.Enabled
|
||||||
|
IsAdministrator = $false
|
||||||
|
IsStandardUser = $true
|
||||||
|
PasswordNeverExpires = $verifiedUser.PasswordNeverExpires
|
||||||
|
}
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$BaseImagePath = (Join-Path $env:ProgramData 'SGU\Branding\darkblue.jpg'),
|
||||||
|
[string]$FontsPath = (Join-Path $env:ProgramData 'SGU\Branding\fonts'),
|
||||||
|
[string]$OutputPath,
|
||||||
|
[string]$DisplayName,
|
||||||
|
[string]$ComputerName = $env:COMPUTERNAME,
|
||||||
|
[string]$Location,
|
||||||
|
[string]$OrganizationalUnit,
|
||||||
|
[ValidateSet('Male', 'Female')]
|
||||||
|
[string]$Gender,
|
||||||
|
[ValidateRange(640, 16384)]
|
||||||
|
[int]$CanvasWidth,
|
||||||
|
[ValidateRange(480, 16384)]
|
||||||
|
[int]$CanvasHeight,
|
||||||
|
[switch]$SkipDirectoryLookup,
|
||||||
|
[switch]$SkipApply
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$script:LogPath = Join-Path $env:LOCALAPPDATA 'SGU\Logs\welcome-wallpaper.log'
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
|
||||||
|
function Write-WelcomeLog {
|
||||||
|
param([Parameter(Mandatory)][string]$Message)
|
||||||
|
|
||||||
|
try {
|
||||||
|
$logDirectory = Split-Path $script:LogPath -Parent
|
||||||
|
New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null
|
||||||
|
Add-Content -LiteralPath $script:LogPath `
|
||||||
|
-Value ('{0:o} {1}' -f (Get-Date), $Message) `
|
||||||
|
-Encoding UTF8
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# The wallpaper must still be generated when logging is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-LdapFilterValue {
|
||||||
|
param([Parameter(Mandatory)][string]$Value)
|
||||||
|
|
||||||
|
return $Value.Replace('\', '\5c').Replace('*', '\2a').Replace('(', '\28').Replace(')', '\29').Replace(([string][char]0), '\00')
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertFrom-LdapRdnValue {
|
||||||
|
param([Parameter(Mandatory)][string]$Value)
|
||||||
|
|
||||||
|
$decoded = [Text.RegularExpressions.Regex]::Replace(
|
||||||
|
$Value,
|
||||||
|
'\\([0-9A-Fa-f]{2})',
|
||||||
|
{ param($match) [char][Convert]::ToByte($match.Groups[1].Value, 16) })
|
||||||
|
return $decoded.Replace('\,', ',').Replace('\+', '+').Replace('\=', '=').Replace('\\', '\')
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ImmediateOrganizationalUnit {
|
||||||
|
param([string]$DistinguishedName)
|
||||||
|
|
||||||
|
if (-not $DistinguishedName) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = [Text.RegularExpressions.Regex]::Split($DistinguishedName, '(?<!\\),')
|
||||||
|
foreach ($part in $parts) {
|
||||||
|
if ($part.StartsWith('OU=', [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
return ConvertFrom-LdapRdnValue -Value $part.Substring(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-SguGenderFromInfo {
|
||||||
|
param([string]$Info)
|
||||||
|
|
||||||
|
if (-not $Info) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($line in $Info -split '\r?\n') {
|
||||||
|
if ($line -match '^\s*SGU-Gender:\s*(Male|Female)\s*$') {
|
||||||
|
return [Globalization.CultureInfo]::InvariantCulture.TextInfo.ToTitleCase(
|
||||||
|
$Matches[1].ToLowerInvariant())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-DirectoryWelcomeMetadata {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$UserName,
|
||||||
|
[Parameter(Mandatory)][string]$MachineName
|
||||||
|
)
|
||||||
|
|
||||||
|
Add-Type -AssemblyName System.DirectoryServices
|
||||||
|
$rootDse = [DirectoryServices.DirectoryEntry]::new('LDAP://RootDSE')
|
||||||
|
try {
|
||||||
|
$namingContext = [string]$rootDse.Properties['defaultNamingContext'][0]
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$rootDse.Dispose()
|
||||||
|
}
|
||||||
|
if (-not $namingContext) {
|
||||||
|
throw 'Active Directory did not return a default naming context.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$searchRoot = [DirectoryServices.DirectoryEntry]::new("LDAP://$namingContext")
|
||||||
|
try {
|
||||||
|
$userSearcher = [DirectoryServices.DirectorySearcher]::new($searchRoot)
|
||||||
|
try {
|
||||||
|
$userSearcher.PageSize = 1
|
||||||
|
$userSearcher.Filter = '(&(objectCategory=person)(objectClass=user)(sAMAccountName={0}))' -f `
|
||||||
|
(ConvertTo-LdapFilterValue -Value $UserName)
|
||||||
|
[void]$userSearcher.PropertiesToLoad.Add('displayName')
|
||||||
|
[void]$userSearcher.PropertiesToLoad.Add('info')
|
||||||
|
$userResult = $userSearcher.FindOne()
|
||||||
|
$directoryDisplayName = if ($userResult -and $userResult.Properties['displayname'].Count) {
|
||||||
|
[string]$userResult.Properties['displayname'][0]
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$null
|
||||||
|
}
|
||||||
|
$directoryGender = if ($userResult -and $userResult.Properties['info'].Count) {
|
||||||
|
Get-SguGenderFromInfo -Info ([string]$userResult.Properties['info'][0])
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$userSearcher.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
$computerSearcher = [DirectoryServices.DirectorySearcher]::new($searchRoot)
|
||||||
|
try {
|
||||||
|
$computerSearcher.PageSize = 1
|
||||||
|
$computerSearcher.Filter = '(&(objectCategory=computer)(sAMAccountName={0}))' -f `
|
||||||
|
(ConvertTo-LdapFilterValue -Value ($MachineName + '$'))
|
||||||
|
[void]$computerSearcher.PropertiesToLoad.Add('location')
|
||||||
|
[void]$computerSearcher.PropertiesToLoad.Add('distinguishedName')
|
||||||
|
$computerResult = $computerSearcher.FindOne()
|
||||||
|
$directoryLocation = if ($computerResult -and $computerResult.Properties['location'].Count) {
|
||||||
|
[string]$computerResult.Properties['location'][0]
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$null
|
||||||
|
}
|
||||||
|
$computerDn = if ($computerResult -and $computerResult.Properties['distinguishedname'].Count) {
|
||||||
|
[string]$computerResult.Properties['distinguishedname'][0]
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$computerSearcher.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$searchRoot.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
[pscustomobject]@{
|
||||||
|
DisplayName = $directoryDisplayName
|
||||||
|
Gender = $directoryGender
|
||||||
|
Location = $directoryLocation
|
||||||
|
OrganizationalUnit = Get-ImmediateOrganizationalUnit -DistinguishedName $computerDn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-SpanishArticle {
|
||||||
|
param([Parameter(Mandatory)][string]$Value)
|
||||||
|
|
||||||
|
if ($Value -match '^(Sala|Aula|Facultad|Unidad|Biblioteca|Oficina|Coordinaci.n)\b') {
|
||||||
|
return 'la'
|
||||||
|
}
|
||||||
|
if ($Value -match '^(Laboratorio|Centro|Edificio|Campus|Taller|Auditorio)\b') {
|
||||||
|
return 'el'
|
||||||
|
}
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-WelcomeLocationText {
|
||||||
|
param(
|
||||||
|
[string]$Room,
|
||||||
|
[string]$OuName,
|
||||||
|
[string]$Gender
|
||||||
|
)
|
||||||
|
|
||||||
|
$located = switch ($Gender) {
|
||||||
|
'Male' { 'Est{0}s ubicado en' -f [char]0x00E1 }
|
||||||
|
'Female' { 'Est{0}s ubicada en' -f [char]0x00E1 }
|
||||||
|
default { 'Ubicaci{0}n:' -f [char]0x00F3 }
|
||||||
|
}
|
||||||
|
$engineeringLab = switch ($Gender) {
|
||||||
|
'Male' { 'Bienvenido al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED }
|
||||||
|
'Female' { 'Bienvenida al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED }
|
||||||
|
default { 'Acceso al Laboratorio de C{0}mputo de Ingenier{1}a.' -f [char]0x00F3,[char]0x00ED }
|
||||||
|
}
|
||||||
|
$Room = if ($Room) { $Room.Trim() } else { $null }
|
||||||
|
$OuName = if ($OuName) { $OuName.Trim() } else { $null }
|
||||||
|
|
||||||
|
if ($Room -and $OuName) {
|
||||||
|
$roomArticle = Get-SpanishArticle -Value $Room
|
||||||
|
$ouArticle = Get-SpanishArticle -Value $OuName
|
||||||
|
$roomPhrase = if ($roomArticle) { "$roomArticle $Room" } else { $Room }
|
||||||
|
$ouPhrase = if ($ouArticle -eq 'el') { "del $OuName" } elseif ($ouArticle) { "de $ouArticle $OuName" } else { "de $OuName" }
|
||||||
|
return "$located $roomPhrase $ouPhrase."
|
||||||
|
}
|
||||||
|
if ($Room) {
|
||||||
|
$article = Get-SpanishArticle -Value $Room
|
||||||
|
$phrase = if ($article) { "$article $Room" } else { $Room }
|
||||||
|
return "$located $phrase."
|
||||||
|
}
|
||||||
|
if ($OuName) {
|
||||||
|
$article = Get-SpanishArticle -Value $OuName
|
||||||
|
$phrase = if ($article) { "$article $OuName" } else { $OuName }
|
||||||
|
return "$located $phrase."
|
||||||
|
}
|
||||||
|
return $engineeringLab
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-WelcomeHeading {
|
||||||
|
param([string]$Gender)
|
||||||
|
|
||||||
|
switch ($Gender) {
|
||||||
|
'Male' { return 'Bienvenido,' }
|
||||||
|
'Female' { return 'Bienvenida,' }
|
||||||
|
default { return 'Te damos la bienvenida,' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-AvailableFontFamily {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string[]]$Candidates,
|
||||||
|
[Drawing.FontFamily[]]$PrivateFamilies = @()
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($candidate in $Candidates) {
|
||||||
|
$privateMatch = @($PrivateFamilies | Where-Object Name -eq $candidate | Select-Object -First 1)
|
||||||
|
if ($privateMatch.Count) {
|
||||||
|
return $privateMatch[0]
|
||||||
|
}
|
||||||
|
if (@([Drawing.FontFamily]::Families | ForEach-Object Name) -contains $candidate) {
|
||||||
|
return [Drawing.FontFamily]::new($candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [Drawing.FontFamily]::GenericSansSerif
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-WelcomeFont {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][Drawing.FontFamily]$Family,
|
||||||
|
[Parameter(Mandatory)][single]$Size,
|
||||||
|
[Parameter(Mandatory)][Drawing.FontStyle]$PreferredStyle
|
||||||
|
)
|
||||||
|
|
||||||
|
$style = if ($Family.IsStyleAvailable($PreferredStyle)) { $PreferredStyle } `
|
||||||
|
elseif ($Family.IsStyleAvailable([Drawing.FontStyle]::Bold)) { [Drawing.FontStyle]::Bold } `
|
||||||
|
else { [Drawing.FontStyle]::Regular }
|
||||||
|
return [Drawing.Font]::new($Family, $Size, $style, [Drawing.GraphicsUnit]::Pixel)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Draw-CenteredText {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][Drawing.Graphics]$Graphics,
|
||||||
|
[Parameter(Mandatory)][string]$Text,
|
||||||
|
[Parameter(Mandatory)][Drawing.Font]$Font,
|
||||||
|
[Parameter(Mandatory)][Drawing.Brush]$Brush,
|
||||||
|
[Parameter(Mandatory)][Drawing.RectangleF]$Bounds,
|
||||||
|
[Parameter(Mandatory)][Drawing.StringFormat]$Format,
|
||||||
|
[single]$ShadowOffset = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
$shadowBounds = [Drawing.RectangleF]::new(
|
||||||
|
$Bounds.X + $ShadowOffset,
|
||||||
|
$Bounds.Y + $ShadowOffset,
|
||||||
|
$Bounds.Width,
|
||||||
|
$Bounds.Height)
|
||||||
|
$shadow = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(135, 0, 0, 0))
|
||||||
|
try {
|
||||||
|
$Graphics.DrawString($Text, $Font, $shadow, $shadowBounds, $Format)
|
||||||
|
$Graphics.DrawString($Text, $Font, $Brush, $Bounds, $Format)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$shadow.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trap {
|
||||||
|
Write-WelcomeLog -Message ('ERROR ' + $_.Exception.Message)
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $BaseImagePath -PathType Leaf)) {
|
||||||
|
throw "The welcome wallpaper base image does not exist: $BaseImagePath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$userName = [Environment]::UserName
|
||||||
|
$metadata = $null
|
||||||
|
if (-not $SkipDirectoryLookup) {
|
||||||
|
try {
|
||||||
|
$metadata = Get-DirectoryWelcomeMetadata -UserName $userName -MachineName $ComputerName
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-WelcomeLog -Message ('WARN Active Directory metadata was unavailable: ' + $_.Exception.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $PSBoundParameters.ContainsKey('DisplayName')) {
|
||||||
|
$DisplayName = if ($metadata -and $metadata.DisplayName) { $metadata.DisplayName } else { $userName }
|
||||||
|
}
|
||||||
|
if (-not $DisplayName) {
|
||||||
|
$DisplayName = $userName
|
||||||
|
}
|
||||||
|
if (-not $PSBoundParameters.ContainsKey('Location') -and $metadata) {
|
||||||
|
$Location = $metadata.Location
|
||||||
|
}
|
||||||
|
$genderWasProvided = $PSBoundParameters.ContainsKey('Gender')
|
||||||
|
if (-not $genderWasProvided -and $metadata) {
|
||||||
|
$Gender = $metadata.Gender
|
||||||
|
}
|
||||||
|
$welcomeHeading = Get-WelcomeHeading -Gender $Gender
|
||||||
|
if (-not $PSBoundParameters.ContainsKey('OrganizationalUnit') -and $metadata) {
|
||||||
|
$OrganizationalUnit = $metadata.OrganizationalUnit
|
||||||
|
}
|
||||||
|
$locationText = Get-WelcomeLocationText -Room $Location -OuName $OrganizationalUnit -Gender $Gender
|
||||||
|
|
||||||
|
if (-not $CanvasWidth -or -not $CanvasHeight) {
|
||||||
|
try {
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
$screenBounds = [Windows.Forms.Screen]::PrimaryScreen.Bounds
|
||||||
|
if (-not $CanvasWidth) { $CanvasWidth = $screenBounds.Width }
|
||||||
|
if (-not $CanvasHeight) { $CanvasHeight = $screenBounds.Height }
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
if (-not $CanvasWidth) { $CanvasWidth = 1600 }
|
||||||
|
if (-not $CanvasHeight) { $CanvasHeight = 1000 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $OutputPath) {
|
||||||
|
$wallpaperDirectory = Join-Path $env:LOCALAPPDATA 'SGU\Wallpapers'
|
||||||
|
$safeComputerName = $ComputerName -replace '[^A-Za-z0-9_.-]', '_'
|
||||||
|
$OutputPath = Join-Path $wallpaperDirectory "welcome-$safeComputerName.jpg"
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Path (Split-Path $OutputPath -Parent) -Force | Out-Null
|
||||||
|
|
||||||
|
$source = [Drawing.Image]::FromFile($BaseImagePath)
|
||||||
|
$canvas = [Drawing.Bitmap]::new($CanvasWidth, $CanvasHeight, [Drawing.Imaging.PixelFormat]::Format24bppRgb)
|
||||||
|
try {
|
||||||
|
$graphics = [Drawing.Graphics]::FromImage($canvas)
|
||||||
|
try {
|
||||||
|
$graphics.SmoothingMode = [Drawing.Drawing2D.SmoothingMode]::HighQuality
|
||||||
|
$graphics.InterpolationMode = [Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||||
|
$graphics.PixelOffsetMode = [Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
||||||
|
$graphics.TextRenderingHint = [Drawing.Text.TextRenderingHint]::AntiAliasGridFit
|
||||||
|
|
||||||
|
$sourceRatio = $source.Width / $source.Height
|
||||||
|
$targetRatio = $CanvasWidth / $CanvasHeight
|
||||||
|
if ($sourceRatio -gt $targetRatio) {
|
||||||
|
$sourceHeight = $source.Height
|
||||||
|
$sourceWidth = [int]($sourceHeight * $targetRatio)
|
||||||
|
$sourceX = [int](($source.Width - $sourceWidth) / 2)
|
||||||
|
$sourceY = 0
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$sourceWidth = $source.Width
|
||||||
|
$sourceHeight = [int]($sourceWidth / $targetRatio)
|
||||||
|
$sourceX = 0
|
||||||
|
$sourceY = [int](($source.Height - $sourceHeight) / 2)
|
||||||
|
}
|
||||||
|
$graphics.DrawImage(
|
||||||
|
$source,
|
||||||
|
[Drawing.Rectangle]::new(0, 0, $CanvasWidth, $CanvasHeight),
|
||||||
|
$sourceX,
|
||||||
|
$sourceY,
|
||||||
|
$sourceWidth,
|
||||||
|
$sourceHeight,
|
||||||
|
[Drawing.GraphicsUnit]::Pixel)
|
||||||
|
|
||||||
|
$scale = [Math]::Min($CanvasWidth / 1600.0, $CanvasHeight / 1000.0)
|
||||||
|
$panelWidth = [single]($CanvasWidth * 0.76)
|
||||||
|
$panelHeight = [single](310 * $scale)
|
||||||
|
$panelX = [single](($CanvasWidth - $panelWidth) / 2)
|
||||||
|
$panelY = [single]($CanvasHeight * 0.50 - ($panelHeight / 2))
|
||||||
|
$panelBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(72, 0, 13, 58))
|
||||||
|
$whiteBrush = [Drawing.SolidBrush]::new([Drawing.Color]::White)
|
||||||
|
$accentBrush = [Drawing.SolidBrush]::new([Drawing.Color]::FromArgb(255, 211, 226, 255))
|
||||||
|
$linePen = [Drawing.Pen]::new([Drawing.Color]::FromArgb(155, 211, 226, 255), [single](2 * $scale))
|
||||||
|
$privateFonts = [Drawing.Text.PrivateFontCollection]::new()
|
||||||
|
if (Test-Path -LiteralPath $FontsPath -PathType Container) {
|
||||||
|
foreach ($fontFile in Get-ChildItem -LiteralPath $FontsPath -File |
|
||||||
|
Where-Object Extension -in '.otf','.ttf') {
|
||||||
|
try {
|
||||||
|
$privateFonts.AddFontFile($fontFile.FullName)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-WelcomeLog -Message ("WARN Font could not be loaded: {0}" -f $fontFile.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$sansFamily = Get-AvailableFontFamily `
|
||||||
|
-Candidates @('Indivisa Text Sans', 'Indivisa Text', 'Segoe UI') `
|
||||||
|
-PrivateFamilies $privateFonts.Families
|
||||||
|
$serifFamily = Get-AvailableFontFamily `
|
||||||
|
-Candidates @('Indivisa Text Serif', 'Indivisa Serif', 'Georgia') `
|
||||||
|
-PrivateFamilies $privateFonts.Families
|
||||||
|
$welcomeFont = New-WelcomeFont -Family $sansFamily -Size ([single](34 * $scale)) -PreferredStyle ([Drawing.FontStyle]::Bold)
|
||||||
|
$nameFont = New-WelcomeFont -Family $serifFamily -Size ([single](70 * $scale)) `
|
||||||
|
-PreferredStyle ([Drawing.FontStyle]::Bold -bor [Drawing.FontStyle]::Italic)
|
||||||
|
$locationFont = New-WelcomeFont -Family $sansFamily -Size ([single](27 * $scale)) `
|
||||||
|
-PreferredStyle ([Drawing.FontStyle]::Regular)
|
||||||
|
$format = [Drawing.StringFormat]::new()
|
||||||
|
$format.Alignment = [Drawing.StringAlignment]::Center
|
||||||
|
$format.LineAlignment = [Drawing.StringAlignment]::Center
|
||||||
|
$format.Trimming = [Drawing.StringTrimming]::EllipsisWord
|
||||||
|
try {
|
||||||
|
$graphics.FillRectangle($panelBrush, $panelX, $panelY, $panelWidth, $panelHeight)
|
||||||
|
Draw-CenteredText -Graphics $graphics -Text $welcomeHeading -Font $welcomeFont `
|
||||||
|
-Brush $accentBrush -Bounds ([Drawing.RectangleF]::new($panelX, $panelY + 24*$scale, $panelWidth, 50*$scale)) -Format $format
|
||||||
|
Draw-CenteredText -Graphics $graphics -Text $DisplayName -Font $nameFont `
|
||||||
|
-Brush $whiteBrush -Bounds ([Drawing.RectangleF]::new($panelX + 30*$scale, $panelY + 64*$scale, $panelWidth - 60*$scale, 105*$scale)) -Format $format
|
||||||
|
$graphics.DrawLine($linePen, $panelX + 150*$scale, $panelY + 180*$scale, $panelX + $panelWidth - 150*$scale, $panelY + 180*$scale)
|
||||||
|
Draw-CenteredText -Graphics $graphics -Text $locationText -Font $locationFont `
|
||||||
|
-Brush $accentBrush -Bounds ([Drawing.RectangleF]::new($panelX + 60*$scale, $panelY + 190*$scale, $panelWidth - 120*$scale, 94*$scale)) -Format $format
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$format.Dispose()
|
||||||
|
$locationFont.Dispose()
|
||||||
|
$nameFont.Dispose()
|
||||||
|
$welcomeFont.Dispose()
|
||||||
|
$serifFamily.Dispose()
|
||||||
|
$sansFamily.Dispose()
|
||||||
|
$privateFonts.Dispose()
|
||||||
|
$linePen.Dispose()
|
||||||
|
$accentBrush.Dispose()
|
||||||
|
$whiteBrush.Dispose()
|
||||||
|
$panelBrush.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$graphics.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
$jpegCodec = [Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() |
|
||||||
|
Where-Object MimeType -eq 'image/jpeg' |
|
||||||
|
Select-Object -First 1
|
||||||
|
$encoderParameters = [Drawing.Imaging.EncoderParameters]::new(1)
|
||||||
|
$encoderParameters.Param[0] = [Drawing.Imaging.EncoderParameter]::new(
|
||||||
|
[Drawing.Imaging.Encoder]::Quality,
|
||||||
|
[long]94)
|
||||||
|
try {
|
||||||
|
$canvas.Save($OutputPath, $jpegCodec, $encoderParameters)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$encoderParameters.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$canvas.Dispose()
|
||||||
|
$source.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $SkipApply) {
|
||||||
|
$desktopKey = 'HKCU:\Control Panel\Desktop'
|
||||||
|
Set-ItemProperty -LiteralPath $desktopKey -Name Wallpaper -Value $OutputPath
|
||||||
|
Set-ItemProperty -LiteralPath $desktopKey -Name WallpaperStyle -Value '10'
|
||||||
|
Set-ItemProperty -LiteralPath $desktopKey -Name TileWallpaper -Value '0'
|
||||||
|
if (-not ('Sgu.NativeMethods' -as [type])) {
|
||||||
|
Add-Type @'
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
namespace Sgu {
|
||||||
|
public static class NativeMethods {
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
public static extern bool SystemParametersInfo(int action, int parameter, string value, int flags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
}
|
||||||
|
if (-not [Sgu.NativeMethods]::SystemParametersInfo(20, 0, $OutputPath, 3)) {
|
||||||
|
throw "Windows could not apply the generated wallpaper. Win32 error: $([Runtime.InteropServices.Marshal]::GetLastWin32Error())"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$genderLogValue = if ($Gender) { $Gender } else { 'Neutral' }
|
||||||
|
Write-WelcomeLog -Message ("OK computer={0}; gender={1}; location={2}; ou={3}; output={4}" -f $ComputerName,$genderLogValue,[bool]$Location,[bool]$OrganizationalUnit,$OutputPath)
|
||||||
|
[pscustomobject]@{
|
||||||
|
DisplayName = $DisplayName
|
||||||
|
ComputerName = $ComputerName
|
||||||
|
Location = $Location
|
||||||
|
OrganizationalUnit = $OrganizationalUnit
|
||||||
|
Gender = $Gender
|
||||||
|
WelcomeHeading = $welcomeHeading
|
||||||
|
LocationText = $locationText
|
||||||
|
OutputPath = $OutputPath
|
||||||
|
Applied = -not $SkipApply
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generates and applies the SGU welcome wallpaper inside a Linux desktop session.
|
||||||
|
# It is intentionally best-effort: unavailable AD metadata or desktop APIs must
|
||||||
|
# never delay or prevent the user's session from opening.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
CONFIG_PATH=${SGU_WELCOME_CONFIG:-/etc/sgu/welcome-wallpaper.conf}
|
||||||
|
INSTALL_ROOT=${SGU_WELCOME_ROOT:-/usr/local/lib/sgu-welcome-wallpaper}
|
||||||
|
BASE_IMAGE=${SGU_WELCOME_BASE_IMAGE:-${INSTALL_ROOT}/darkblue.jpg}
|
||||||
|
|
||||||
|
if [[ -r $CONFIG_PATH ]]; then
|
||||||
|
# The root-owned file contains only deployment metadata, never credentials.
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$CONFIG_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
DOMAIN_CONTROLLER=${DOMAIN_CONTROLLER:-}
|
||||||
|
DOMAIN_NAME=${DOMAIN_NAME:-}
|
||||||
|
BASE_DN=${BASE_DN:-}
|
||||||
|
state_root=${XDG_STATE_HOME:-${HOME}/.local/state}
|
||||||
|
wallpaper_root=${XDG_CACHE_HOME:-${HOME}/.cache}/sgu/wallpapers
|
||||||
|
log_path="${state_root}/sgu/welcome-wallpaper.log"
|
||||||
|
|
||||||
|
log_message() {
|
||||||
|
mkdir -p "$(dirname "$log_path")" 2>/dev/null || true
|
||||||
|
printf '%s %s\n' "$(date --iso-8601=seconds 2>/dev/null || date)" "$*" >>"$log_path" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
fail_softly() {
|
||||||
|
log_message "ERROR $*"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -r $BASE_IMAGE ]] || fail_softly "Missing base image: $BASE_IMAGE"
|
||||||
|
|
||||||
|
if command -v magick >/dev/null 2>&1; then
|
||||||
|
image_command=(magick)
|
||||||
|
elif command -v convert >/dev/null 2>&1; then
|
||||||
|
image_command=(convert)
|
||||||
|
else
|
||||||
|
fail_softly 'ImageMagick is unavailable.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
raw_user=${USER:-$(id -un 2>/dev/null || printf user)}
|
||||||
|
account_name=${raw_user%@*}
|
||||||
|
account_name=${account_name##*\\}
|
||||||
|
display_name=$(getent passwd "$raw_user" 2>/dev/null | awk -F: 'NR == 1 { split($5,a,","); print a[1] }')
|
||||||
|
[[ -n $display_name ]] || display_name=$account_name
|
||||||
|
|
||||||
|
computer_name=$(hostname -s 2>/dev/null || true)
|
||||||
|
computer_name=${computer_name^^}
|
||||||
|
location=''
|
||||||
|
distinguished_name=''
|
||||||
|
organizational_unit=''
|
||||||
|
gender=''
|
||||||
|
|
||||||
|
read_ldif_value() {
|
||||||
|
local attribute=$1
|
||||||
|
local content=$2
|
||||||
|
local line value
|
||||||
|
line=$(printf '%s\n' "$content" | awk -v name="$attribute" '
|
||||||
|
BEGIN { IGNORECASE=1 }
|
||||||
|
index(tolower($0), tolower(name) ":") == 1 { print; exit }
|
||||||
|
')
|
||||||
|
[[ -n $line ]] || return 0
|
||||||
|
if [[ $line == "${attribute}:: "* || ${line,,} == "${attribute,,}:: "* ]]; then
|
||||||
|
value=${line#*:: }
|
||||||
|
printf '%s' "$value" | base64 --decode 2>/dev/null || true
|
||||||
|
else
|
||||||
|
printf '%s' "${line#*: }"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# SSSD normally obtains a Kerberos ticket during PAM authentication. Use that
|
||||||
|
# ticket for a read-only AD query; never embed a bind password in this helper.
|
||||||
|
if [[ -n $DOMAIN_CONTROLLER && -n $BASE_DN ]] &&
|
||||||
|
command -v ldapsearch >/dev/null 2>&1 &&
|
||||||
|
command -v klist >/dev/null 2>&1 && klist -s; then
|
||||||
|
ldap_server=$DOMAIN_CONTROLLER
|
||||||
|
if [[ -n $DOMAIN_NAME ]] && command -v resolvectl >/dev/null 2>&1; then
|
||||||
|
discovered_server=$(resolvectl query --type=SRV \
|
||||||
|
"_ldap._tcp.dc._msdcs.${DOMAIN_NAME}" 2>/dev/null |
|
||||||
|
awk '/ IN SRV / { for (i=1; i<=NF; i++) if ($i == "SRV") { print $(i+4); exit } }' |
|
||||||
|
sed 's/\.$//' || true)
|
||||||
|
[[ -n $discovered_server ]] && ldap_server=$discovered_server
|
||||||
|
elif [[ -n $DOMAIN_NAME ]] && command -v dig >/dev/null 2>&1; then
|
||||||
|
discovered_server=$(dig +short SRV "_ldap._tcp.dc._msdcs.${DOMAIN_NAME}" 2>/dev/null |
|
||||||
|
awk 'NR == 1 { print $4 }' | sed 's/\.$//' || true)
|
||||||
|
[[ -n $discovered_server ]] && ldap_server=$discovered_server
|
||||||
|
fi
|
||||||
|
ldap_result=$(ldapsearch -LLL -N -o ldif-wrap=no -Y GSSAPI \
|
||||||
|
-H "ldap://${ldap_server}" -b "$BASE_DN" \
|
||||||
|
"(&(objectCategory=computer)(sAMAccountName=${computer_name}\\24))" \
|
||||||
|
location distinguishedName 2>/dev/null || true)
|
||||||
|
location=$(read_ldif_value location "$ldap_result")
|
||||||
|
distinguished_name=$(read_ldif_value distinguishedName "$ldap_result")
|
||||||
|
if [[ $distinguished_name =~ ,OU=([^,]+) ]]; then
|
||||||
|
organizational_unit=${BASH_REMATCH[1]}
|
||||||
|
organizational_unit=${organizational_unit//\\,/,}
|
||||||
|
organizational_unit=${organizational_unit//\\=/=}
|
||||||
|
organizational_unit=${organizational_unit//\\+/+}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# SSSD's GECOS field is not guaranteed to expose AD displayName. Query it
|
||||||
|
# through the same authenticated LDAP session and retain the account-name
|
||||||
|
# fallback when the institutional identifier contains unexpected symbols.
|
||||||
|
if [[ $account_name =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||||
|
user_result=$(ldapsearch -LLL -N -o ldif-wrap=no -Y GSSAPI \
|
||||||
|
-H "ldap://${ldap_server}" -b "$BASE_DN" \
|
||||||
|
"(&(objectCategory=person)(objectClass=user)(sAMAccountName=${account_name}))" \
|
||||||
|
displayName info 2>/dev/null || true)
|
||||||
|
directory_display_name=$(read_ldif_value displayName "$user_result")
|
||||||
|
[[ -n $directory_display_name ]] && display_name=$directory_display_name
|
||||||
|
directory_info=$(read_ldif_value info "$user_result")
|
||||||
|
gender=$(printf '%s\n' "$directory_info" | awk -F: '
|
||||||
|
tolower($1) ~ /^[[:space:]]*sgu-gender[[:space:]]*$/ {
|
||||||
|
value=tolower($2); gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
|
||||||
|
if (value == "male") print "Male"
|
||||||
|
else if (value == "female") print "Female"
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
')
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_message 'WARN AD metadata query skipped because Kerberos or LDAP session data was unavailable.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
article_for() {
|
||||||
|
local value=${1,,}
|
||||||
|
case "$value" in
|
||||||
|
sala*|aula*|facultad*|unidad*|biblioteca*|oficina*|coordinación*) printf la ;;
|
||||||
|
laboratorio*|centro*|edificio*|campus*|taller*|auditorio*) printf el ;;
|
||||||
|
*) printf '' ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
with_article() {
|
||||||
|
local value=$1
|
||||||
|
local article
|
||||||
|
article=$(article_for "$value")
|
||||||
|
if [[ -n $article ]]; then
|
||||||
|
printf '%s %s' "$article" "$value"
|
||||||
|
else
|
||||||
|
printf '%s' "$value"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$gender" in
|
||||||
|
Male)
|
||||||
|
welcome_text='Bienvenido,'
|
||||||
|
located_text='Estás ubicado en'
|
||||||
|
engineering_lab_text='Bienvenido al Laboratorio de Cómputo de Ingeniería.'
|
||||||
|
;;
|
||||||
|
Female)
|
||||||
|
welcome_text='Bienvenida,'
|
||||||
|
located_text='Estás ubicada en'
|
||||||
|
engineering_lab_text='Bienvenida al Laboratorio de Cómputo de Ingeniería.'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
welcome_text='Te damos la bienvenida,'
|
||||||
|
located_text='Ubicación:'
|
||||||
|
engineering_lab_text='Acceso al Laboratorio de Cómputo de Ingeniería.'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ -n $location && -n $organizational_unit ]]; then
|
||||||
|
room_phrase=$(with_article "$location")
|
||||||
|
ou_article=$(article_for "$organizational_unit")
|
||||||
|
if [[ $ou_article == el ]]; then
|
||||||
|
ou_phrase="del ${organizational_unit}"
|
||||||
|
elif [[ -n $ou_article ]]; then
|
||||||
|
ou_phrase="de ${ou_article} ${organizational_unit}"
|
||||||
|
else
|
||||||
|
ou_phrase="de ${organizational_unit}"
|
||||||
|
fi
|
||||||
|
location_text="${located_text} ${room_phrase} ${ou_phrase}."
|
||||||
|
elif [[ -n $location ]]; then
|
||||||
|
location_text="${located_text} $(with_article "$location")."
|
||||||
|
elif [[ -n $organizational_unit ]]; then
|
||||||
|
location_text="${located_text} $(with_article "$organizational_unit")."
|
||||||
|
else
|
||||||
|
location_text=$engineering_lab_text
|
||||||
|
fi
|
||||||
|
|
||||||
|
width=1600
|
||||||
|
height=1000
|
||||||
|
if command -v xrandr >/dev/null 2>&1; then
|
||||||
|
geometry=$(xrandr --current 2>/dev/null | awk '/\*/ { print $1; exit }')
|
||||||
|
if [[ $geometry =~ ^([0-9]+)x([0-9]+)$ ]]; then
|
||||||
|
width=${BASH_REMATCH[1]}
|
||||||
|
height=${BASH_REMATCH[2]}
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$wallpaper_root" "$(dirname "$log_path")" ||
|
||||||
|
fail_softly "Cannot create welcome wallpaper state directories."
|
||||||
|
safe_computer=${computer_name//[^A-Za-z0-9_.-]/_}
|
||||||
|
output_path="${wallpaper_root}/welcome-${safe_computer}.jpg"
|
||||||
|
scale=$(( height * 100 / 1000 ))
|
||||||
|
(( scale > 45 )) || scale=45
|
||||||
|
welcome_size=$(( 34 * scale / 100 ))
|
||||||
|
name_size=$(( 70 * scale / 100 ))
|
||||||
|
location_size=$(( 27 * scale / 100 ))
|
||||||
|
panel_width=$(( width * 76 / 100 ))
|
||||||
|
panel_height=$(( 310 * scale / 100 ))
|
||||||
|
panel_x1=$(( (width - panel_width) / 2 ))
|
||||||
|
panel_y1=$(( height / 2 - panel_height / 2 ))
|
||||||
|
panel_x2=$(( panel_x1 + panel_width ))
|
||||||
|
panel_y2=$(( panel_y1 + panel_height ))
|
||||||
|
|
||||||
|
sans_font='DejaVu-Sans'
|
||||||
|
serif_font='DejaVu-Serif'
|
||||||
|
if [[ -r ${INSTALL_ROOT}/fonts/IndivisaTextSans-Bold.otf ]]; then
|
||||||
|
sans_font="${INSTALL_ROOT}/fonts/IndivisaTextSans-Bold.otf"
|
||||||
|
fi
|
||||||
|
if [[ -r ${INSTALL_ROOT}/fonts/IndivisaTextSerif-BoldItalic.otf ]]; then
|
||||||
|
serif_font="${INSTALL_ROOT}/fonts/IndivisaTextSerif-BoldItalic.otf"
|
||||||
|
fi
|
||||||
|
if [[ $sans_font == DejaVu-Sans ]] && command -v fc-list >/dev/null 2>&1; then
|
||||||
|
if fc-list : family | grep -Fqi 'Indivisa Text Sans'; then
|
||||||
|
sans_font='Indivisa Text Sans'
|
||||||
|
elif fc-list : family | grep -Fqi 'Indivisa Text'; then
|
||||||
|
sans_font='Indivisa Text'
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ $serif_font == DejaVu-Serif ]] && command -v fc-list >/dev/null 2>&1; then
|
||||||
|
if fc-list : family | grep -Fqi 'Indivisa Text Serif'; then
|
||||||
|
serif_font='Indivisa Text Serif'
|
||||||
|
elif fc-list : family | grep -Fqi 'Indivisa Serif'; then
|
||||||
|
serif_font='Indivisa Serif'
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! "${image_command[@]}" "$BASE_IMAGE" \
|
||||||
|
-resize "${width}x${height}^" -gravity center -extent "${width}x${height}" \
|
||||||
|
-fill 'rgba(0,13,58,0.30)' -draw "rectangle ${panel_x1},${panel_y1} ${panel_x2},${panel_y2}" \
|
||||||
|
-gravity center \
|
||||||
|
-font "$sans_font" -weight 700 -style Normal -pointsize "$welcome_size" \
|
||||||
|
-fill '#D3E2FF' -stroke 'rgba(0,0,0,0.48)' -strokewidth 1 \
|
||||||
|
-annotate "+0-$(( 92 * scale / 100 ))" "$welcome_text" \
|
||||||
|
-font "$serif_font" -weight 700 -style Italic -pointsize "$name_size" \
|
||||||
|
-fill white -annotate "+0-$(( 22 * scale / 100 ))" "$display_name" \
|
||||||
|
-font "$sans_font" -weight 400 -style Normal -pointsize "$location_size" \
|
||||||
|
-fill '#D3E2FF' -annotate "+0+$(( 88 * scale / 100 ))" "$location_text" \
|
||||||
|
-quality 94 "$output_path" 2>>"$log_path"; then
|
||||||
|
fail_softly 'ImageMagick could not render the welcome wallpaper.'
|
||||||
|
fi
|
||||||
|
|
||||||
|
applied=false
|
||||||
|
if command -v gsettings >/dev/null 2>&1; then
|
||||||
|
if gsettings list-schemas 2>/dev/null | grep -Fxq 'org.cinnamon.desktop.background'; then
|
||||||
|
gsettings set org.cinnamon.desktop.background picture-uri "file://${output_path}" >/dev/null 2>&1 || true
|
||||||
|
gsettings set org.cinnamon.desktop.background picture-options zoom >/dev/null 2>&1 || true
|
||||||
|
applied=true
|
||||||
|
fi
|
||||||
|
if gsettings list-schemas 2>/dev/null | grep -Fxq 'org.gnome.desktop.background'; then
|
||||||
|
gsettings set org.gnome.desktop.background picture-uri "file://${output_path}" >/dev/null 2>&1 || true
|
||||||
|
gsettings set org.gnome.desktop.background picture-uri-dark "file://${output_path}" >/dev/null 2>&1 || true
|
||||||
|
gsettings set org.gnome.desktop.background picture-options zoom >/dev/null 2>&1 || true
|
||||||
|
applied=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $applied == false ]] && command -v xfconf-query >/dev/null 2>&1; then
|
||||||
|
while IFS= read -r property; do
|
||||||
|
xfconf-query -c xfce4-desktop -p "$property" -s "$output_path" >/dev/null 2>&1 || true
|
||||||
|
applied=true
|
||||||
|
done < <(xfconf-query -c xfce4-desktop -l 2>/dev/null | grep '/last-image$' || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $applied == true ]]; then
|
||||||
|
log_message "OK computer=${computer_name}; gender=${gender:-Neutral}; location=$([[ -n $location ]] && printf true || printf false); ou=$([[ -n $organizational_unit ]] && printf true || printf false); output=${output_path}"
|
||||||
|
else
|
||||||
|
log_message 'WARN Wallpaper rendered, but no supported desktop background API was found.'
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
set "SGU_BOOTSTRAP_IP=%~1"
|
||||||
|
set "SGU_VPN_PACKAGE=%~2"
|
||||||
|
set "SGU_VPN_PFX=%~3"
|
||||||
|
set "SGU_VPN_ROOT=%~4"
|
||||||
|
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Invoke-SguClientBootstrap.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"'),'-ConnectivityMode','AzureP2S'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-DomainControllerIPv4Address',('"' + $env:SGU_BOOTSTRAP_IP + '"')) }; if ($env:SGU_VPN_PACKAGE) { $arguments += @('-VpnProfilePackagePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PACKAGE) + '"')) }; if ($env:SGU_VPN_PFX) { $arguments += @('-VpnClientCertificatePfxPath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_PFX) + '"')) }; if ($env:SGU_VPN_ROOT) { $arguments += @('-VpnClientRootCertificatePath',('"' + [IO.Path]::GetFullPath($env:SGU_VPN_ROOT) + '"')) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode"
|
||||||
|
exit /b %errorlevel%
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
set "SGU_BOOTSTRAP_IP=%~1"
|
||||||
|
set "SGU_VPN_POOL=%~2"
|
||||||
|
if "%SGU_VPN_POOL%"=="" set "SGU_VPN_POOL=172.30.0.0/24"
|
||||||
|
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "$script = Join-Path '%~dp0' 'Initialize-SguDomainController.ps1'; $arguments = @('-NoLogo','-NoProfile','-ExecutionPolicy','Bypass','-File',('"' + $script + '"'),'-NetworkConfigurationMode','PlatformManaged','-TrustedClientNetworks',$env:SGU_VPN_POOL,'-DnsForwarders','168.63.129.16'); if ($env:SGU_BOOTSTRAP_IP) { $arguments += @('-ServerIPv4Address',$env:SGU_BOOTSTRAP_IP) }; $process = Start-Process -FilePath powershell.exe -Verb RunAs -ArgumentList $arguments -Wait -PassThru; exit $process.ExitCode"
|
||||||
|
exit /b %errorlevel%
|
||||||
@@ -4,6 +4,8 @@ param(
|
|||||||
[switch]$RequireRemoteAccess,
|
[switch]$RequireRemoteAccess,
|
||||||
[switch]$RequireBrokerHealth,
|
[switch]$RequireBrokerHealth,
|
||||||
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
|
[string]$RemoteDesktopPrincipal = 'LCI\SG-Laboratorio-Usuarios-RDP',
|
||||||
|
[switch]$RequireRustDesk,
|
||||||
|
[string]$RustDeskServerAddress,
|
||||||
[switch]$Enforce
|
[switch]$Enforce
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ $defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
|
|||||||
$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
|
$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
|
||||||
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
|
$settingsPath = Join-Path $env:ProgramData 'SGU\CredentialProvider\settings.json'
|
||||||
$issues = [Collections.Generic.List[string]]::new()
|
$issues = [Collections.Generic.List[string]]::new()
|
||||||
|
$standardLocalUserName = 'alumno'
|
||||||
|
|
||||||
$computer = Get-CimInstance Win32_ComputerSystem
|
$computer = Get-CimInstance Win32_ComputerSystem
|
||||||
if ($RequireDomainJoined -and -not $computer.PartOfDomain) {
|
if ($RequireDomainJoined -and -not $computer.PartOfDomain) {
|
||||||
@@ -85,6 +88,41 @@ if (-not $passwordProviderPreserved) {
|
|||||||
$issues.Add('The built-in Microsoft password provider registration is missing.')
|
$issues.Add('The built-in Microsoft password provider registration is missing.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$standardLocalUser = Get-LocalUser -Name $standardLocalUserName -ErrorAction SilentlyContinue
|
||||||
|
$standardLocalUserPresent = [bool]$standardLocalUser
|
||||||
|
$standardLocalUserEnabled = $standardLocalUserPresent -and $standardLocalUser.Enabled
|
||||||
|
$standardLocalUserIsAdministrator = $false
|
||||||
|
$standardLocalUserInUsersGroup = $false
|
||||||
|
$standardLocalUserPasswordNeverExpires =
|
||||||
|
$standardLocalUserPresent -and $standardLocalUser.PasswordNeverExpires
|
||||||
|
if ($standardLocalUserPresent) {
|
||||||
|
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
||||||
|
$usersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545')
|
||||||
|
$administratorsGroup = Get-LocalGroup -SID $administratorsSid -ErrorAction Stop
|
||||||
|
$usersGroup = Get-LocalGroup -SID $usersSid -ErrorAction Stop
|
||||||
|
$administratorMembers = @(Get-LocalGroupMember -Group $administratorsGroup -ErrorAction Stop)
|
||||||
|
$standardMembers = @(Get-LocalGroupMember -Group $usersGroup -ErrorAction Stop)
|
||||||
|
$standardLocalUserIsAdministrator =
|
||||||
|
$administratorMembers.SID.Value -contains $standardLocalUser.SID.Value
|
||||||
|
$standardLocalUserInUsersGroup =
|
||||||
|
$standardMembers.SID.Value -contains $standardLocalUser.SID.Value
|
||||||
|
}
|
||||||
|
if (-not $standardLocalUserPresent) {
|
||||||
|
$issues.Add("The required standard local user '$standardLocalUserName' is missing.")
|
||||||
|
}
|
||||||
|
elseif (-not $standardLocalUserEnabled) {
|
||||||
|
$issues.Add("The required standard local user '$standardLocalUserName' is disabled.")
|
||||||
|
}
|
||||||
|
elseif ($standardLocalUserIsAdministrator) {
|
||||||
|
$issues.Add("The required standard local user '$standardLocalUserName' belongs to the local Administrators group.")
|
||||||
|
}
|
||||||
|
elseif (-not $standardLocalUserInUsersGroup) {
|
||||||
|
$issues.Add("The required standard local user '$standardLocalUserName' does not belong to the local Users group.")
|
||||||
|
}
|
||||||
|
elseif (-not $standardLocalUserPasswordNeverExpires) {
|
||||||
|
$issues.Add("The required standard local user '$standardLocalUserName' does not retain its enrollment password.")
|
||||||
|
}
|
||||||
|
|
||||||
$settings = $null
|
$settings = $null
|
||||||
try {
|
try {
|
||||||
$settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json
|
$settings = Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json
|
||||||
@@ -158,6 +196,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]@{
|
$result = [pscustomobject]@{
|
||||||
ComputerName = $env:COMPUTERNAME
|
ComputerName = $env:COMPUTERNAME
|
||||||
Domain = $computer.Domain
|
Domain = $computer.Domain
|
||||||
@@ -169,12 +243,19 @@ $result = [pscustomobject]@{
|
|||||||
LastSignedInUserHidden = $lastSignedInUserHidden
|
LastSignedInUserHidden = $lastSignedInUserHidden
|
||||||
LocalUserEnumerationDisabled = $localUserEnumerationDisabled
|
LocalUserEnumerationDisabled = $localUserEnumerationDisabled
|
||||||
PasswordProviderPreserved = $passwordProviderPreserved
|
PasswordProviderPreserved = $passwordProviderPreserved
|
||||||
|
StandardLocalUserPresent = $standardLocalUserPresent
|
||||||
|
StandardLocalUserEnabled = $standardLocalUserEnabled
|
||||||
|
StandardLocalUserIsAdministrator = $standardLocalUserIsAdministrator
|
||||||
|
StandardLocalUserInUsersGroup = $standardLocalUserInUsersGroup
|
||||||
|
StandardLocalUserPasswordNeverExpires = $standardLocalUserPasswordNeverExpires
|
||||||
SettingsPresent = [bool]$settings
|
SettingsPresent = [bool]$settings
|
||||||
ClientCertificatePresent = [bool]$clientCertificatePresent
|
ClientCertificatePresent = [bool]$clientCertificatePresent
|
||||||
ServerCertificateTrusted = $serverCertificateTrusted
|
ServerCertificateTrusted = $serverCertificateTrusted
|
||||||
DotNetRuntimePresent = $dotNetRuntimePresent
|
DotNetRuntimePresent = $dotNetRuntimePresent
|
||||||
BrokerHealth = $brokerHealth
|
BrokerHealth = $brokerHealth
|
||||||
RemoteAccessReady = $remoteAccessReady
|
RemoteAccessReady = $remoteAccessReady
|
||||||
|
RustDeskReady = $rustDeskReady
|
||||||
|
RustDeskId = $rustDeskId
|
||||||
IsValid = $issues.Count -eq 0
|
IsValid = $issues.Count -eq 0
|
||||||
Issues = $issues.ToArray()
|
Issues = $issues.ToArray()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SGU.AuthBroker.Core.Profiles;
|
||||||
|
|
||||||
|
public enum InstitutionalGender
|
||||||
|
{
|
||||||
|
Male,
|
||||||
|
Female
|
||||||
|
}
|
||||||
@@ -12,7 +12,8 @@ public sealed record InstitutionalProfile(
|
|||||||
string? StreetAddress = null,
|
string? StreetAddress = null,
|
||||||
string? City = null,
|
string? City = null,
|
||||||
string? State = null,
|
string? State = null,
|
||||||
string? PostalCode = null)
|
string? PostalCode = null,
|
||||||
|
InstitutionalGender? Gender = null)
|
||||||
{
|
{
|
||||||
public bool HasValues =>
|
public bool HasValues =>
|
||||||
EmployeeNumber is not null ||
|
EmployeeNumber is not null ||
|
||||||
@@ -26,7 +27,8 @@ public sealed record InstitutionalProfile(
|
|||||||
StreetAddress is not null ||
|
StreetAddress is not null ||
|
||||||
City is not null ||
|
City is not null ||
|
||||||
State is not null ||
|
State is not null ||
|
||||||
PostalCode is not null;
|
PostalCode is not null ||
|
||||||
|
Gender is not null;
|
||||||
|
|
||||||
public InstitutionalProfile Overlay(InstitutionalProfile? values) =>
|
public InstitutionalProfile Overlay(InstitutionalProfile? values) =>
|
||||||
values is null
|
values is null
|
||||||
@@ -44,6 +46,7 @@ public sealed record InstitutionalProfile(
|
|||||||
StreetAddress = values.StreetAddress ?? StreetAddress,
|
StreetAddress = values.StreetAddress ?? StreetAddress,
|
||||||
City = values.City ?? City,
|
City = values.City ?? City,
|
||||||
State = values.State ?? State,
|
State = values.State ?? State,
|
||||||
PostalCode = values.PostalCode ?? PostalCode
|
PostalCode = values.PostalCode ?? PostalCode,
|
||||||
|
Gender = values.Gender ?? Gender
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace SGU.AuthBroker.Core.Profiles;
|
||||||
|
|
||||||
|
public sealed record SguAdministrativeLocationSelection(
|
||||||
|
string? StateId = null,
|
||||||
|
string? MunicipalityId = null,
|
||||||
|
string? NeighborhoodId = null,
|
||||||
|
string? StateName = null,
|
||||||
|
string? MunicipalityName = null,
|
||||||
|
string? NeighborhoodName = null,
|
||||||
|
string? PostalCode = null)
|
||||||
|
{
|
||||||
|
public bool HasValues =>
|
||||||
|
StateId is not null ||
|
||||||
|
MunicipalityId is not null ||
|
||||||
|
NeighborhoodId is not null ||
|
||||||
|
StateName is not null ||
|
||||||
|
MunicipalityName is not null ||
|
||||||
|
NeighborhoodName is not null ||
|
||||||
|
PostalCode is not null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Mail;
|
using System.Net.Mail;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace SGU.AuthBroker.Core.Profiles;
|
namespace SGU.AuthBroker.Core.Profiles;
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ public static class SguProfileParser
|
|||||||
private const string AdministrativeGivenNameId = "ctl00_contenedor_txtNombre";
|
private const string AdministrativeGivenNameId = "ctl00_contenedor_txtNombre";
|
||||||
private const string AdministrativePaternalSurnameId = "ctl00_contenedor_txtApaterno";
|
private const string AdministrativePaternalSurnameId = "ctl00_contenedor_txtApaterno";
|
||||||
private const string AdministrativeMaternalSurnameId = "ctl00_contenedor_txtAmaterno";
|
private const string AdministrativeMaternalSurnameId = "ctl00_contenedor_txtAmaterno";
|
||||||
|
private const string AdministrativeGenderId = "ctl00_contenedor_ddlsexo";
|
||||||
|
private const string AdministrativeGenderName = "ctl00$contenedor$ddlsexo";
|
||||||
private const string AdministrativeStreetId = "ctl00_contenedor_txtCalle";
|
private const string AdministrativeStreetId = "ctl00_contenedor_txtCalle";
|
||||||
private const string AdministrativeExteriorNumberId = "ctl00_contenedor_txtNoExt";
|
private const string AdministrativeExteriorNumberId = "ctl00_contenedor_txtNoExt";
|
||||||
private const string AdministrativeInteriorNumberId = "ctl00_contenedor_txtNoInt";
|
private const string AdministrativeInteriorNumberId = "ctl00_contenedor_txtNoInt";
|
||||||
@@ -34,8 +37,15 @@ public static class SguProfileParser
|
|||||||
private const string StudentCityId = "ctl00_contenedor_HistorialAlumno1_lblCiudadAlumnoHP";
|
private const string StudentCityId = "ctl00_contenedor_HistorialAlumno1_lblCiudadAlumnoHP";
|
||||||
private const string StudentMunicipalityId = "ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP";
|
private const string StudentMunicipalityId = "ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP";
|
||||||
private const string StudentPostalCodeId = "ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP";
|
private const string StudentPostalCodeId = "ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP";
|
||||||
|
private const string StudentGenderId = "ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP";
|
||||||
|
|
||||||
public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber)
|
public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber)
|
||||||
|
=> ParseStaffHeader(html, expectedEmployeeNumber);
|
||||||
|
|
||||||
|
public static InstitutionalProfile? ParseProfessorPayroll(string html, string expectedEmployeeNumber)
|
||||||
|
=> ParseStaffHeader(html, expectedEmployeeNumber);
|
||||||
|
|
||||||
|
private static InstitutionalProfile? ParseStaffHeader(string html, string expectedEmployeeNumber)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(html);
|
ArgumentNullException.ThrowIfNull(html);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(expectedEmployeeNumber);
|
ArgumentException.ThrowIfNullOrWhiteSpace(expectedEmployeeNumber);
|
||||||
@@ -85,11 +95,22 @@ public static class SguProfileParser
|
|||||||
InstitutionalProfile profile = new(
|
InstitutionalProfile profile = new(
|
||||||
DisplayName: displayName,
|
DisplayName: displayName,
|
||||||
GivenName: givenName,
|
GivenName: givenName,
|
||||||
Surname: surname);
|
Surname: surname,
|
||||||
|
Gender: ParseStaffGender(ExtractSelectedOptionValue(
|
||||||
|
html,
|
||||||
|
AdministrativeGenderId,
|
||||||
|
AdministrativeGenderName)));
|
||||||
return profile.HasValues ? profile : null;
|
return profile.HasValues ? profile : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static InstitutionalProfile? ParseAdministrativeLocation(string html)
|
public static InstitutionalProfile? ParseAdministrativeLocation(string html) =>
|
||||||
|
ParseAdministrativeLocation(html, null, null, null);
|
||||||
|
|
||||||
|
public static InstitutionalProfile? ParseAdministrativeLocation(
|
||||||
|
string html,
|
||||||
|
SguAdministrativeLocationSelection? selection,
|
||||||
|
string? localitiesJson,
|
||||||
|
string? neighborhoodsJson)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(html);
|
ArgumentNullException.ThrowIfNull(html);
|
||||||
|
|
||||||
@@ -99,18 +120,79 @@ public static class SguProfileParser
|
|||||||
string? interiorNumber = NormalizeAddressUnit(
|
string? interiorNumber = NormalizeAddressUnit(
|
||||||
ExtractInputValue(html, AdministrativeInteriorNumberId));
|
ExtractInputValue(html, AdministrativeInteriorNumberId));
|
||||||
string? streetLine = BuildAdministrativeStreetLine(street, exteriorNumber, interiorNumber);
|
string? streetLine = BuildAdministrativeStreetLine(street, exteriorNumber, interiorNumber);
|
||||||
string? neighborhood = NormalizeTitle(
|
string? neighborhood = NormalizeTitle(FirstNonEmpty(
|
||||||
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId),
|
selection?.NeighborhoodName,
|
||||||
256);
|
ResolveNeighborhoodName(neighborhoodsJson, selection),
|
||||||
|
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId)), 256);
|
||||||
|
string? city = NormalizeTitle(FirstNonEmpty(
|
||||||
|
selection?.MunicipalityName,
|
||||||
|
ResolveLocalityName(localitiesJson, selection),
|
||||||
|
ExtractSelectedOptionText(html, AdministrativeCityId)), 128);
|
||||||
|
string? state = NormalizeTitle(FirstNonEmpty(
|
||||||
|
selection?.StateName,
|
||||||
|
ExtractOptionTextByValue(html, AdministrativeStateId, selection?.StateId),
|
||||||
|
ExtractSelectedOptionText(html, AdministrativeStateId)), 128);
|
||||||
|
string? postalCode = NormalizePostalCode(FirstNonEmpty(
|
||||||
|
selection?.PostalCode,
|
||||||
|
ExtractInputValue(html, AdministrativePostalCodeId)));
|
||||||
|
|
||||||
InstitutionalProfile profile = new(
|
InstitutionalProfile profile = new(
|
||||||
StreetAddress: BuildStreetAddress(streetLine, neighborhood, null, null),
|
StreetAddress: BuildStreetAddress(streetLine, neighborhood, null, null),
|
||||||
City: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeCityId), 128),
|
City: city,
|
||||||
State: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeStateId), 128),
|
State: state,
|
||||||
PostalCode: NormalizePostalCode(ExtractInputValue(html, AdministrativePostalCodeId)));
|
PostalCode: postalCode);
|
||||||
return profile.HasValues ? profile : null;
|
return profile.HasValues ? profile : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static SguAdministrativeLocationSelection? ParseAdministrativeLocationSelection(
|
||||||
|
string json,
|
||||||
|
string? expectedPostalCode)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(json);
|
||||||
|
|
||||||
|
string? expected = NormalizePostalCode(expectedPostalCode);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(json);
|
||||||
|
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (JsonElement value in values.EnumerateArray())
|
||||||
|
{
|
||||||
|
string? postalCode = NormalizePostalCode(GetJsonString(value, "p_Cp"));
|
||||||
|
if (expected is not null &&
|
||||||
|
!string.Equals(postalCode, expected, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
SguAdministrativeLocationSelection selection = new(
|
||||||
|
StateId: NormalizeCatalogId(GetJsonString(value, "p_IdEstado")),
|
||||||
|
MunicipalityId: NormalizeCatalogId(GetJsonString(value, "p_IdMunicipio")),
|
||||||
|
NeighborhoodId: NormalizeCatalogId(GetJsonString(value, "p_IdCP")),
|
||||||
|
StateName: Limit(GetJsonString(value, "p_NombreEstado"), 128),
|
||||||
|
MunicipalityName: Limit(GetJsonString(value, "p_NombreMunicipio"), 128),
|
||||||
|
NeighborhoodName: Limit(FirstNonEmpty(
|
||||||
|
GetJsonString(value, "p_NombreColonia"),
|
||||||
|
GetJsonString(value, "p_NombreAsentamiento"),
|
||||||
|
GetJsonString(value, "p_Nombre")), 256),
|
||||||
|
PostalCode: postalCode);
|
||||||
|
if (selection.HasValues)
|
||||||
|
{
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
|
public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(html);
|
ArgumentNullException.ThrowIfNull(html);
|
||||||
@@ -145,7 +227,8 @@ public static class SguProfileParser
|
|||||||
StreetAddress: streetAddress,
|
StreetAddress: streetAddress,
|
||||||
City: city ?? municipality,
|
City: city ?? municipality,
|
||||||
State: NormalizeTitle(ExtractSpanText(html, StudentStateId), 128),
|
State: NormalizeTitle(ExtractSpanText(html, StudentStateId), 128),
|
||||||
PostalCode: NormalizePostalCode(ExtractSpanText(html, StudentPostalCodeId)));
|
PostalCode: NormalizePostalCode(ExtractSpanText(html, StudentPostalCodeId)),
|
||||||
|
Gender: ParseStudentGender(ExtractSpanText(html, StudentGenderId)));
|
||||||
return profile.HasValues ? profile : null;
|
return profile.HasValues ? profile : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,11 +369,248 @@ public static class SguProfileParser
|
|||||||
return nonPlaceholderOptions.Count == 1 ? nonPlaceholderOptions[0] : null;
|
return nonPlaceholderOptions.Count == 1 ? nonPlaceholderOptions[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? ExtractSelectedOptionValue(string html, string id, string name)
|
||||||
|
{
|
||||||
|
string? openingTag = FindOpeningTag(html, "select", id) ??
|
||||||
|
FindOpeningTagByAttribute(html, "select", "name", name);
|
||||||
|
if (openingTag is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int openingTagIndex = html.IndexOf(openingTag, StringComparison.OrdinalIgnoreCase);
|
||||||
|
int contentStart = openingTagIndex + openingTag.Length;
|
||||||
|
int contentEnd = html.IndexOf("</select", contentStart, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (openingTagIndex < 0 || contentEnd < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? selectedValue = ExtractAttributeValue(openingTag, "value");
|
||||||
|
string optionsHtml = html[contentStart..contentEnd];
|
||||||
|
int searchFrom = 0;
|
||||||
|
while (searchFrom < optionsHtml.Length)
|
||||||
|
{
|
||||||
|
int optionStart = optionsHtml.IndexOf("<option", searchFrom, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (optionStart < 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
int optionTagEnd = optionsHtml.IndexOf('>', optionStart);
|
||||||
|
if (optionTagEnd < 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
string optionTag = optionsHtml[optionStart..(optionTagEnd + 1)];
|
||||||
|
string? optionValue = ExtractAttributeValue(optionTag, "value");
|
||||||
|
if (optionValue is not null &&
|
||||||
|
(HasAttribute(optionTag, "selected") ||
|
||||||
|
(selectedValue is not null &&
|
||||||
|
string.Equals(optionValue, selectedValue, StringComparison.Ordinal))))
|
||||||
|
{
|
||||||
|
return NormalizeText(optionValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchFrom = optionTagEnd + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractOptionTextByValue(string html, string id, string? expectedValue)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(expectedValue))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? openingTag = FindOpeningTag(html, "select", id);
|
||||||
|
if (openingTag is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int openingTagIndex = html.IndexOf(openingTag, StringComparison.OrdinalIgnoreCase);
|
||||||
|
int contentStart = openingTagIndex + openingTag.Length;
|
||||||
|
int contentEnd = html.IndexOf("</select", contentStart, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (openingTagIndex < 0 || contentEnd < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string optionsHtml = html[contentStart..contentEnd];
|
||||||
|
int searchFrom = 0;
|
||||||
|
while (searchFrom < optionsHtml.Length)
|
||||||
|
{
|
||||||
|
int optionStart = optionsHtml.IndexOf("<option", searchFrom, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (optionStart < 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
int optionTagEnd = optionsHtml.IndexOf('>', optionStart);
|
||||||
|
int optionEnd = optionTagEnd < 0
|
||||||
|
? -1
|
||||||
|
: optionsHtml.IndexOf("</option", optionTagEnd + 1, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (optionTagEnd < 0 || optionEnd < 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
string optionTag = optionsHtml[optionStart..(optionTagEnd + 1)];
|
||||||
|
string? optionValue = ExtractAttributeValue(optionTag, "value");
|
||||||
|
if (string.Equals(optionValue, expectedValue, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return NormalizeText(optionsHtml[(optionTagEnd + 1)..optionEnd]);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchFrom = optionEnd + "</option".Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveLocalityName(
|
||||||
|
string? json,
|
||||||
|
SguAdministrativeLocationSelection? selection)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json) ||
|
||||||
|
string.IsNullOrWhiteSpace(selection?.MunicipalityId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(json);
|
||||||
|
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (JsonElement value in values.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (string.Equals(
|
||||||
|
NormalizeCatalogId(GetJsonString(value, "Id_Municipio")),
|
||||||
|
selection.MunicipalityId,
|
||||||
|
StringComparison.Ordinal) &&
|
||||||
|
(selection.StateId is null || string.Equals(
|
||||||
|
NormalizeCatalogId(GetJsonString(value, "ID_Estado")),
|
||||||
|
selection.StateId,
|
||||||
|
StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
return GetJsonString(value, "Nombre");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveNeighborhoodName(
|
||||||
|
string? json,
|
||||||
|
SguAdministrativeLocationSelection? selection)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json) || selection is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(json);
|
||||||
|
if (!TryGetPageMethodArray(document.RootElement, out JsonElement values))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<string> postalCodeMatches = [];
|
||||||
|
foreach (JsonElement value in values.EnumerateArray())
|
||||||
|
{
|
||||||
|
string? neighborhoodId = NormalizeCatalogId(GetJsonString(value, "p_IdCP"));
|
||||||
|
string? postalCode = NormalizePostalCode(GetJsonString(value, "p_Cp"));
|
||||||
|
string? name = GetJsonString(value, "p_Nombre");
|
||||||
|
if (name is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.NeighborhoodId is not null &&
|
||||||
|
string.Equals(neighborhoodId, selection.NeighborhoodId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.PostalCode is not null &&
|
||||||
|
string.Equals(postalCode, selection.PostalCode, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
postalCodeMatches.Add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return postalCodeMatches.Count == 1 ? postalCodeMatches[0] : null;
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetPageMethodArray(JsonElement root, out JsonElement values)
|
||||||
|
{
|
||||||
|
values = default;
|
||||||
|
return root.ValueKind == JsonValueKind.Object &&
|
||||||
|
root.TryGetProperty("d", out values) &&
|
||||||
|
values.ValueKind == JsonValueKind.Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetJsonString(JsonElement value, string propertyName)
|
||||||
|
{
|
||||||
|
if (value.ValueKind != JsonValueKind.Object ||
|
||||||
|
!value.TryGetProperty(propertyName, out JsonElement property))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return property.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => property.GetString(),
|
||||||
|
JsonValueKind.Number => property.GetRawText(),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeCatalogId(string? value)
|
||||||
|
{
|
||||||
|
string? candidate = value?.Trim();
|
||||||
|
return string.IsNullOrEmpty(candidate) ||
|
||||||
|
candidate.Length > 32 ||
|
||||||
|
!candidate.All(char.IsAsciiLetterOrDigit)
|
||||||
|
? null
|
||||||
|
: candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FirstNonEmpty(params string?[] values) =>
|
||||||
|
values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value));
|
||||||
|
|
||||||
private static string? FindOpeningTag(string html, string tagName, string id)
|
private static string? FindOpeningTag(string html, string tagName, string id)
|
||||||
|
=> FindOpeningTagByAttribute(html, tagName, "id", id);
|
||||||
|
|
||||||
|
private static string? FindOpeningTagByAttribute(
|
||||||
|
string html,
|
||||||
|
string tagName,
|
||||||
|
string attributeName,
|
||||||
|
string attributeValue)
|
||||||
{
|
{
|
||||||
foreach (char quote in new[] { '"', '\'' })
|
foreach (char quote in new[] { '"', '\'' })
|
||||||
{
|
{
|
||||||
string marker = $"id={quote}{id}{quote}";
|
string marker = $"{attributeName}={quote}{attributeValue}{quote}";
|
||||||
int searchFrom = 0;
|
int searchFrom = 0;
|
||||||
while (searchFrom < html.Length)
|
while (searchFrom < html.Length)
|
||||||
{
|
{
|
||||||
@@ -318,6 +638,20 @@ public static class SguProfileParser
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static InstitutionalGender? ParseStaffGender(string? value) => value?.Trim() switch
|
||||||
|
{
|
||||||
|
"1" => InstitutionalGender.Male,
|
||||||
|
"2" => InstitutionalGender.Female,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
private static InstitutionalGender? ParseStudentGender(string? value) => value?.Trim().ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"M" => InstitutionalGender.Male,
|
||||||
|
"F" => InstitutionalGender.Female,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
private static string? ExtractAttributeValue(string openingTag, string attributeName)
|
private static string? ExtractAttributeValue(string openingTag, string attributeName)
|
||||||
{
|
{
|
||||||
foreach (char quote in new[] { '"', '\'' })
|
foreach (char quote in new[] { '"', '\'' })
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace SGU.AuthBroker;
|
||||||
|
|
||||||
|
internal static class BrokerEventIds
|
||||||
|
{
|
||||||
|
internal static readonly EventId BrokerStarted = new(900, nameof(BrokerStarted));
|
||||||
|
internal static readonly EventId AuthenticationAuthorized = new(1000, nameof(AuthenticationAuthorized));
|
||||||
|
internal static readonly EventId AuthenticationRejected = new(1001, nameof(AuthenticationRejected));
|
||||||
|
internal static readonly EventId AuthenticationUnavailable = new(1002, nameof(AuthenticationUnavailable));
|
||||||
|
internal static readonly EventId AuthenticationInvalidRequest = new(1003, nameof(AuthenticationInvalidRequest));
|
||||||
|
|
||||||
|
internal static readonly EventId SguAuthenticationAccepted = new(1100, nameof(SguAuthenticationAccepted));
|
||||||
|
internal static readonly EventId SguAuthenticationTimeout = new(1101, nameof(SguAuthenticationTimeout));
|
||||||
|
internal static readonly EventId SguAuthenticationNetworkFailure = new(1102, nameof(SguAuthenticationNetworkFailure));
|
||||||
|
|
||||||
|
internal static readonly EventId ProfileEnrichmentCompleted = new(1200, nameof(ProfileEnrichmentCompleted));
|
||||||
|
internal static readonly EventId ProfileHtmlUnexpected = new(1201, nameof(ProfileHtmlUnexpected));
|
||||||
|
internal static readonly EventId ProfileEnrichmentTimeout = new(1202, nameof(ProfileEnrichmentTimeout));
|
||||||
|
internal static readonly EventId ProfileEnrichmentFailure = new(1203, nameof(ProfileEnrichmentFailure));
|
||||||
|
internal static readonly EventId ProfilePageUnavailable = new(1204, nameof(ProfilePageUnavailable));
|
||||||
|
|
||||||
|
internal static readonly EventId DirectorySynchronizationFailure = new(1300, nameof(DirectorySynchronizationFailure));
|
||||||
|
internal static readonly EventId DirectoryOptionalMetadataFailure = new(1301, nameof(DirectoryOptionalMetadataFailure));
|
||||||
|
internal static readonly EventId DirectoryGroupMembershipFailure = new(1302, nameof(DirectoryGroupMembershipFailure));
|
||||||
|
internal static readonly EventId DirectoryRoleGroupMembershipAdded = new(1303, nameof(DirectoryRoleGroupMembershipAdded));
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ public sealed class BrokerOptions
|
|||||||
Ntlm.AdministrativePersonalProfilePath,
|
Ntlm.AdministrativePersonalProfilePath,
|
||||||
Ntlm.AdministrativeLocationProfilePath,
|
Ntlm.AdministrativeLocationProfilePath,
|
||||||
Ntlm.StudentProfilePath,
|
Ntlm.StudentProfilePath,
|
||||||
|
Ntlm.ProfessorPayrollProfilePath,
|
||||||
Ntlm.MenuProfilePath
|
Ntlm.MenuProfilePath
|
||||||
})
|
})
|
||||||
{
|
{
|
||||||
@@ -86,6 +87,14 @@ public sealed class BrokerOptions
|
|||||||
{
|
{
|
||||||
throw new InvalidOperationException($"The OU mapping for {role} must be beneath BaseDn.");
|
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) &&
|
if (!string.IsNullOrWhiteSpace(Directory.RemoteDesktopGroupDn) &&
|
||||||
@@ -141,6 +150,9 @@ public sealed class NtlmOptions
|
|||||||
public string StudentProfilePath { get; init; } =
|
public string StudentProfilePath { get; init; } =
|
||||||
"/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx";
|
"/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx";
|
||||||
|
|
||||||
|
public string ProfessorPayrollProfilePath { get; init; } =
|
||||||
|
"/psulsa/gadmon/nomina/consultanomina.aspx";
|
||||||
|
|
||||||
public string MenuProfilePath { get; init; } = "/psulsa/menu.aspx";
|
public string MenuProfilePath { get; init; } = "/psulsa/menu.aspx";
|
||||||
|
|
||||||
public int MaxProfileBytes { get; init; } = 512 * 1024;
|
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 AdministrativeOuDn { get; init; } = "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||||
|
|
||||||
|
public string ProfessorGroupDn { get; init; } = "CN=SGU-Docentes,OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||||
|
|
||||||
|
public string StudentGroupDn { get; init; } = "CN=SGU-Alumnos,OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||||
|
|
||||||
|
public string AdministrativeGroupDn { get; init; } = "CN=SGU-Administrativos,OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx";
|
||||||
|
|
||||||
public string RemoteDesktopGroupDn { get; init; } = string.Empty;
|
public string RemoteDesktopGroupDn { get; init; } = string.Empty;
|
||||||
|
|
||||||
public string DefaultCompany { get; init; } = "La Salle";
|
public string DefaultCompany { get; init; } = "La Salle";
|
||||||
@@ -177,4 +195,12 @@ public sealed class ActiveDirectoryOptions
|
|||||||
InstitutionalRole.Administrative => AdministrativeOuDn,
|
InstitutionalRole.Administrative => AdministrativeOuDn,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(role), role, null)
|
_ => 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)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using System.Net.Security;
|
using System.Net.Security;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using Microsoft.AspNetCore.Server.Kestrel.Https;
|
using Microsoft.AspNetCore.Server.Kestrel.Https;
|
||||||
|
using SGU.AuthBroker;
|
||||||
using SGU.AuthBroker.Contracts;
|
using SGU.AuthBroker.Contracts;
|
||||||
using SGU.AuthBroker.Core.Authentication;
|
using SGU.AuthBroker.Core.Authentication;
|
||||||
using SGU.AuthBroker.Core.Directory;
|
using SGU.AuthBroker.Core.Directory;
|
||||||
@@ -10,6 +12,16 @@ using SGU.AuthBroker.Services;
|
|||||||
|
|
||||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||||
builder.Host.UseWindowsService(options => options.ServiceName = "SGU Authentication Broker");
|
builder.Host.UseWindowsService(options => options.ServiceName = "SGU Authentication Broker");
|
||||||
|
if (builder.Configuration.GetValue("Broker:Diagnostics:UseDedicatedEventLog", false))
|
||||||
|
{
|
||||||
|
builder.Logging.ClearProviders();
|
||||||
|
builder.Logging.AddEventLog(settings =>
|
||||||
|
{
|
||||||
|
settings.LogName = "SGU Auth Broker";
|
||||||
|
settings.SourceName = "SGU.AuthBroker.Operational";
|
||||||
|
settings.Filter = (_, level) => level >= LogLevel.Information;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
BrokerOptions brokerOptions = builder.Configuration
|
BrokerOptions brokerOptions = builder.Configuration
|
||||||
.GetSection(BrokerOptions.SectionName)
|
.GetSection(BrokerOptions.SectionName)
|
||||||
@@ -56,6 +68,12 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
ILogger auditLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger("SGU.AuthBroker.Audit");
|
||||||
|
auditLogger.LogInformation(
|
||||||
|
BrokerEventIds.BrokerStarted,
|
||||||
|
"SGU Authentication Broker started with dedicated operational diagnostics enabled={DedicatedDiagnosticsEnabled}.",
|
||||||
|
builder.Configuration.GetValue("Broker:Diagnostics:UseDedicatedEventLog", false));
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
app.Use(async (context, next) =>
|
app.Use(async (context, next) =>
|
||||||
{
|
{
|
||||||
@@ -75,16 +93,58 @@ app.MapPost("/v1/authenticate", async (
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Password) || request.Password.Length > 256)
|
if (string.IsNullOrWhiteSpace(request.Password) || request.Password.Length > 256)
|
||||||
{
|
{
|
||||||
|
auditLogger.LogInformation(
|
||||||
|
BrokerEventIds.AuthenticationInvalidRequest,
|
||||||
|
"Authentication request rejected before validation for {InstitutionalUser}: password was missing or outside the supported length.",
|
||||||
|
SafeUserName(request.Clave));
|
||||||
request.ReleasePasswordReference();
|
request.ReleasePasswordReference();
|
||||||
return Results.BadRequest(new ErrorResponse("MISSING_PASSWORD", "La contraseña es requerida."));
|
return Results.BadRequest(new ErrorResponse("MISSING_PASSWORD", "La contraseña es requerida."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Stopwatch elapsed = Stopwatch.StartNew();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
AuthenticationFlowResult result = await workflow
|
AuthenticationFlowResult result = await workflow
|
||||||
.AuthenticateAsync(request.Clave, request.Password, cancellationToken)
|
.AuthenticateAsync(request.Clave, request.Password, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
string institutionalUser = result.Identity?.UserName ?? SafeUserName(request.Clave);
|
||||||
|
switch (result.Outcome)
|
||||||
|
{
|
||||||
|
case AuthenticationFlowOutcome.Authorized:
|
||||||
|
auditLogger.LogInformation(
|
||||||
|
BrokerEventIds.AuthenticationAuthorized,
|
||||||
|
"Authentication completed for {InstitutionalUser} with role {Role} in {ElapsedMilliseconds} ms. AD created={Created}; moved={Moved}.",
|
||||||
|
institutionalUser,
|
||||||
|
result.Identity!.Role,
|
||||||
|
elapsed.ElapsedMilliseconds,
|
||||||
|
result.Directory!.Created,
|
||||||
|
result.Directory.Moved);
|
||||||
|
break;
|
||||||
|
case AuthenticationFlowOutcome.InvalidCredentials:
|
||||||
|
auditLogger.LogInformation(
|
||||||
|
BrokerEventIds.AuthenticationRejected,
|
||||||
|
"Authentication was rejected for {InstitutionalUser} with code {ErrorCode} after {ElapsedMilliseconds} ms.",
|
||||||
|
institutionalUser,
|
||||||
|
result.ErrorCode,
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
break;
|
||||||
|
case AuthenticationFlowOutcome.Unavailable:
|
||||||
|
auditLogger.LogWarning(
|
||||||
|
BrokerEventIds.AuthenticationUnavailable,
|
||||||
|
"Authentication was unavailable for {InstitutionalUser} with code {ErrorCode} after {ElapsedMilliseconds} ms.",
|
||||||
|
institutionalUser,
|
||||||
|
result.ErrorCode,
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
auditLogger.LogInformation(
|
||||||
|
BrokerEventIds.AuthenticationInvalidRequest,
|
||||||
|
"Authentication request had an invalid institutional user format after {ElapsedMilliseconds} ms.",
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
return result.Outcome switch
|
return result.Outcome switch
|
||||||
{
|
{
|
||||||
AuthenticationFlowOutcome.Authorized => Results.Ok(new AuthenticationResponse(
|
AuthenticationFlowOutcome.Authorized => Results.Ok(new AuthenticationResponse(
|
||||||
@@ -125,3 +185,11 @@ static IResult Unavailable(HttpContext context, string? errorCode)
|
|||||||
|
|
||||||
static string NormalizeThumbprint(string value) =>
|
static string NormalizeThumbprint(string value) =>
|
||||||
value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant();
|
value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant();
|
||||||
|
|
||||||
|
static string SafeUserName(string? value)
|
||||||
|
{
|
||||||
|
string candidate = value?.Trim().ToUpperInvariant() ?? string.Empty;
|
||||||
|
return candidate.Length is > 0 and <= 16 && candidate.All(char.IsAsciiLetterOrDigit)
|
||||||
|
? candidate
|
||||||
|
: "<invalid-format>";
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ using SGU.AuthBroker.Options;
|
|||||||
|
|
||||||
namespace SGU.AuthBroker.Services;
|
namespace SGU.AuthBroker.Services;
|
||||||
|
|
||||||
public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActiveDirectorySynchronizer
|
public sealed class ActiveDirectorySynchronizer(
|
||||||
|
BrokerOptions options,
|
||||||
|
ILogger<ActiveDirectorySynchronizer> logger) : IActiveDirectorySynchronizer
|
||||||
{
|
{
|
||||||
|
private const string GenderMetadataPrefix = "SGU-Gender:";
|
||||||
|
private const int InfoAttributeMaximumLength = 1024;
|
||||||
private const int AccountDisabled = 0x0002;
|
private const int AccountDisabled = 0x0002;
|
||||||
private const int NormalAccount = 0x0200;
|
private const int NormalAccount = 0x0200;
|
||||||
private static readonly AuthenticationTypes BindFlags =
|
private static readonly AuthenticationTypes BindFlags =
|
||||||
@@ -28,9 +32,26 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
|||||||
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await Task.Run(
|
try
|
||||||
() => Synchronize(identity, profile, password),
|
{
|
||||||
cancellationToken).ConfigureAwait(false);
|
return await Task.Run(
|
||||||
|
() => Synchronize(identity, profile, password),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
BrokerEventIds.DirectorySynchronizationFailure,
|
||||||
|
exception,
|
||||||
|
"Active Directory synchronization failed for {InstitutionalUser} with role {Role}.",
|
||||||
|
identity.UserName,
|
||||||
|
identity.Role);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -90,6 +111,12 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
|||||||
user.CommitChanges();
|
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.
|
// The exact institutional password received by the broker is passed to AD.
|
||||||
// It is not derived, transformed, written to disk, or included in logs.
|
// It is not derived, transformed, written to disk, or included in logs.
|
||||||
user.Invoke("SetPassword", [password]);
|
user.Invoke("SetPassword", [password]);
|
||||||
@@ -100,8 +127,8 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
|||||||
user.Properties["pwdLastSet"].Value = -1;
|
user.Properties["pwdLastSet"].Value = -1;
|
||||||
user.CommitChanges();
|
user.CommitChanges();
|
||||||
|
|
||||||
TryApplyProfile(user, identity, profile, options.DefaultCompany);
|
TryApplyProfile(user, identity, profile, options.DefaultCompany, logger);
|
||||||
TryEnsureRemoteDesktopGroupMembership(user);
|
TryEnsureRemoteDesktopGroupMembership(user, identity.UserName);
|
||||||
|
|
||||||
return new DirectorySyncResult(
|
return new DirectorySyncResult(
|
||||||
options.DomainNetbios,
|
options.DomainNetbios,
|
||||||
@@ -120,7 +147,8 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
|||||||
DirectoryEntry user,
|
DirectoryEntry user,
|
||||||
UserIdentity identity,
|
UserIdentity identity,
|
||||||
InstitutionalProfile? profile,
|
InstitutionalProfile? profile,
|
||||||
string defaultCompany)
|
string defaultCompany,
|
||||||
|
ILogger logger)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -138,6 +166,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
|||||||
SetOptionalProperty(user, "l", profile.City);
|
SetOptionalProperty(user, "l", profile.City);
|
||||||
SetOptionalProperty(user, "st", profile.State);
|
SetOptionalProperty(user, "st", profile.State);
|
||||||
SetOptionalProperty(user, "postalCode", profile.PostalCode);
|
SetOptionalProperty(user, "postalCode", profile.PostalCode);
|
||||||
|
SetGenderMetadata(user, profile.Gender, identity.UserName, logger);
|
||||||
if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal))
|
if (string.Equals(profile.EmployeeNumber, identity.NumericId, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
SetOptionalProperty(user, "employeeID", profile.EmployeeNumber);
|
SetOptionalProperty(user, "employeeID", profile.EmployeeNumber);
|
||||||
@@ -146,10 +175,15 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
|||||||
|
|
||||||
user.CommitChanges();
|
user.CommitChanges();
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
// Metadata is intentionally best-effort. User creation, password sync,
|
// Metadata is intentionally best-effort. User creation, password sync,
|
||||||
// and account enablement have already committed successfully.
|
// and account enablement have already committed successfully.
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.DirectoryOptionalMetadataFailure,
|
||||||
|
exception,
|
||||||
|
"Optional Active Directory profile metadata could not be committed for {InstitutionalUser}; password synchronization remains completed.",
|
||||||
|
identity.UserName);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
user.RefreshCache();
|
user.RefreshCache();
|
||||||
@@ -170,7 +204,84 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user)
|
private static void SetGenderMetadata(
|
||||||
|
DirectoryEntry entry,
|
||||||
|
InstitutionalGender? gender,
|
||||||
|
string institutionalUser,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
if (gender is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string existing = Convert.ToString(entry.Properties["info"].Value) ?? string.Empty;
|
||||||
|
string? updated = MergeGenderMetadata(existing, gender);
|
||||||
|
if (updated is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.DirectoryOptionalMetadataFailure,
|
||||||
|
"Gender metadata was not written for {InstitutionalUser} because the Active Directory info attribute has no remaining capacity.",
|
||||||
|
institutionalUser);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.Properties["info"].Value = updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string? MergeGenderMetadata(
|
||||||
|
string? existing,
|
||||||
|
InstitutionalGender? gender)
|
||||||
|
{
|
||||||
|
if (gender is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string managedLine = $"{GenderMetadataPrefix} {gender}";
|
||||||
|
string normalizedExisting = (existing ?? string.Empty)
|
||||||
|
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||||
|
.Replace('\r', '\n');
|
||||||
|
string[] preservedLines = string.IsNullOrEmpty(normalizedExisting)
|
||||||
|
? []
|
||||||
|
: normalizedExisting
|
||||||
|
.Split('\n')
|
||||||
|
.Where(line => !line.TrimStart().StartsWith(
|
||||||
|
GenderMetadataPrefix,
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
string updated = string.Join("\r\n", preservedLines.Append(managedLine));
|
||||||
|
return updated.Length <= InfoAttributeMaximumLength ? updated : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureRoleGroupMembership(DirectoryEntry user, UserIdentity identity)
|
||||||
|
{
|
||||||
|
user.RefreshCache(["distinguishedName"]);
|
||||||
|
string? userDn = Convert.ToString(user.Properties["distinguishedName"].Value);
|
||||||
|
if (string.IsNullOrWhiteSpace(userDn))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Active Directory did not return a distinguished name for {identity.UserName}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string groupDn = options.GetGroupDn(identity.Role);
|
||||||
|
using DirectoryEntry group = Bind(groupDn);
|
||||||
|
_ = group.NativeObject;
|
||||||
|
if (group.Properties["member"].Contains(userDn))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
group.Properties["member"].Add(userDn);
|
||||||
|
group.CommitChanges();
|
||||||
|
logger.LogInformation(
|
||||||
|
BrokerEventIds.DirectoryRoleGroupMembershipAdded,
|
||||||
|
"Added {InstitutionalUser} with role {Role} to Active Directory security group {GroupDn}.",
|
||||||
|
identity.UserName,
|
||||||
|
identity.Role,
|
||||||
|
groupDn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user, string institutionalUser)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn))
|
if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn))
|
||||||
{
|
{
|
||||||
@@ -194,10 +305,15 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
|||||||
group.CommitChanges();
|
group.CommitChanges();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
// Remote access is lab policy and must not invalidate a completed
|
// Remote access is lab policy and must not invalidate a completed
|
||||||
// password synchronization if the optional group is unavailable.
|
// password synchronization if the optional group is unavailable.
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.DirectoryGroupMembershipFailure,
|
||||||
|
exception,
|
||||||
|
"Optional remote-desktop group membership could not be updated for {InstitutionalUser}; password synchronization remains completed.",
|
||||||
|
institutionalUser);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
using SGU.AuthBroker.Core.Authentication;
|
using SGU.AuthBroker.Core.Authentication;
|
||||||
using SGU.AuthBroker.Core.Identity;
|
using SGU.AuthBroker.Core.Identity;
|
||||||
using SGU.AuthBroker.Core.Profiles;
|
using SGU.AuthBroker.Core.Profiles;
|
||||||
@@ -35,6 +37,10 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
string password,
|
string password,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
using IDisposable? logScope = logger.BeginScope(
|
||||||
|
"InstitutionalUser={InstitutionalUser}; InstitutionalRole={InstitutionalRole}",
|
||||||
|
identity.UserName,
|
||||||
|
identity.Role);
|
||||||
Uri authenticationUri = new(
|
Uri authenticationUri = new(
|
||||||
new Uri(options.Endpoint, UriKind.Absolute),
|
new Uri(options.Endpoint, UriKind.Absolute),
|
||||||
options.AuthenticationPath);
|
options.AuthenticationPath);
|
||||||
@@ -155,6 +161,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
|
BrokerEventIds.SguAuthenticationAccepted,
|
||||||
"SGU accepted credentials after an explicit NTLM challenge in {ElapsedMilliseconds} ms.",
|
"SGU accepted credentials after an explicit NTLM challenge in {ElapsedMilliseconds} ms.",
|
||||||
elapsed.ElapsedMilliseconds);
|
elapsed.ElapsedMilliseconds);
|
||||||
return (null, continuationUri);
|
return (null, continuationUri);
|
||||||
@@ -184,6 +191,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.SguAuthenticationTimeout,
|
||||||
"SGU NTLM authentication timed out after {ElapsedMilliseconds} ms.",
|
"SGU NTLM authentication timed out after {ElapsedMilliseconds} ms.",
|
||||||
elapsed.ElapsedMilliseconds);
|
elapsed.ElapsedMilliseconds);
|
||||||
return (NtlmValidationResult.Unavailable("NTLM_TIMEOUT"), null);
|
return (NtlmValidationResult.Unavailable("NTLM_TIMEOUT"), null);
|
||||||
@@ -191,6 +199,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
catch (HttpRequestException exception)
|
catch (HttpRequestException exception)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.SguAuthenticationNetworkFailure,
|
||||||
exception,
|
exception,
|
||||||
"SGU NTLM authentication failed after {ElapsedMilliseconds} ms.",
|
"SGU NTLM authentication failed after {ElapsedMilliseconds} ms.",
|
||||||
elapsed.ElapsedMilliseconds);
|
elapsed.ElapsedMilliseconds);
|
||||||
@@ -337,7 +346,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
profile = await TryEnrichStaffProfileAsync(
|
profile = await TryEnrichStaffProfileAsync(
|
||||||
client,
|
client,
|
||||||
profile!,
|
profile!,
|
||||||
identity.Role,
|
identity,
|
||||||
allowedHosts,
|
allowedHosts,
|
||||||
timeout.Token,
|
timeout.Token,
|
||||||
cancellationToken,
|
cancellationToken,
|
||||||
@@ -347,6 +356,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
if (profile is null)
|
if (profile is null)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileHtmlUnexpected,
|
||||||
"SGU returned a profile page for role {Role}, but no supported profile fields were found after {ElapsedMilliseconds} ms.",
|
"SGU returned a profile page for role {Role}, but no supported profile fields were found after {ElapsedMilliseconds} ms.",
|
||||||
identity.Role,
|
identity.Role,
|
||||||
elapsed.ElapsedMilliseconds);
|
elapsed.ElapsedMilliseconds);
|
||||||
@@ -354,8 +364,10 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"SGU profile enrichment completed for role {Role} in {ElapsedMilliseconds} ms.",
|
BrokerEventIds.ProfileEnrichmentCompleted,
|
||||||
|
"SGU profile enrichment completed for role {Role} with {ProfileFieldCount} supported fields in {ElapsedMilliseconds} ms.",
|
||||||
identity.Role,
|
identity.Role,
|
||||||
|
CountProfileFields(profile),
|
||||||
elapsed.ElapsedMilliseconds);
|
elapsed.ElapsedMilliseconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +394,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileEnrichmentTimeout,
|
||||||
"SGU profile request for role {Role} timed out after {ElapsedMilliseconds} ms.",
|
"SGU profile request for role {Role} timed out after {ElapsedMilliseconds} ms.",
|
||||||
identity.Role,
|
identity.Role,
|
||||||
elapsed.ElapsedMilliseconds);
|
elapsed.ElapsedMilliseconds);
|
||||||
@@ -389,6 +402,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileEnrichmentFailure,
|
||||||
exception,
|
exception,
|
||||||
"SGU profile enrichment failed for role {Role} after {ElapsedMilliseconds} ms.",
|
"SGU profile enrichment failed for role {Role} after {ElapsedMilliseconds} ms.",
|
||||||
identity.Role,
|
identity.Role,
|
||||||
@@ -401,18 +415,24 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
private async Task<InstitutionalProfile> TryEnrichStaffProfileAsync(
|
private async Task<InstitutionalProfile> TryEnrichStaffProfileAsync(
|
||||||
HttpClient client,
|
HttpClient client,
|
||||||
InstitutionalProfile baseProfile,
|
InstitutionalProfile baseProfile,
|
||||||
InstitutionalRole role,
|
UserIdentity identity,
|
||||||
HashSet<string> allowedHosts,
|
HashSet<string> allowedHosts,
|
||||||
CancellationToken timeoutToken,
|
CancellationToken timeoutToken,
|
||||||
CancellationToken requestCancellationToken,
|
CancellationToken requestCancellationToken,
|
||||||
Stopwatch elapsed)
|
Stopwatch elapsed)
|
||||||
{
|
{
|
||||||
InstitutionalProfile profile = baseProfile;
|
InstitutionalProfile profile = baseProfile;
|
||||||
(string Path, Func<string, InstitutionalProfile?> Parser)[] pages =
|
List<(string Path, Func<string, InstitutionalProfile?> Parser)> pages = [];
|
||||||
[
|
if (identity.Role == InstitutionalRole.Professor)
|
||||||
(options.AdministrativePersonalProfilePath, SguProfileParser.ParseAdministrativePersonal),
|
{
|
||||||
(options.AdministrativeLocationProfilePath, SguProfileParser.ParseAdministrativeLocation)
|
pages.Add((
|
||||||
];
|
options.ProfessorPayrollProfilePath,
|
||||||
|
html => SguProfileParser.ParseProfessorPayroll(html, identity.NumericId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.Add((
|
||||||
|
options.AdministrativePersonalProfilePath,
|
||||||
|
SguProfileParser.ParseAdministrativePersonal));
|
||||||
|
|
||||||
foreach ((string path, Func<string, InstitutionalProfile?> parser) in pages)
|
foreach ((string path, Func<string, InstitutionalProfile?> parser) in pages)
|
||||||
{
|
{
|
||||||
@@ -423,26 +443,185 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
GetProfileUri(path),
|
GetProfileUri(path),
|
||||||
allowedHosts,
|
allowedHosts,
|
||||||
timeoutToken).ConfigureAwait(false);
|
timeoutToken).ConfigureAwait(false);
|
||||||
profile = profile.Overlay(html is null ? null : parser(html));
|
if (html is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfilePageUnavailable,
|
||||||
|
"Optional SGU profile page {Path} did not return usable HTML for role {Role}; preserving fields already collected.",
|
||||||
|
path,
|
||||||
|
identity.Role);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
InstitutionalProfile? pageProfile = parser(html);
|
||||||
|
if (pageProfile is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileHtmlUnexpected,
|
||||||
|
"Optional SGU profile page {Path} returned HTML without its supported field IDs for role {Role}; preserving fields already collected.",
|
||||||
|
path,
|
||||||
|
identity.Role);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
profile = profile.Overlay(pageProfile);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileEnrichmentTimeout,
|
||||||
"SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
"SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||||
role,
|
identity.Role,
|
||||||
elapsed.ElapsedMilliseconds);
|
elapsed.ElapsedMilliseconds);
|
||||||
break;
|
return profile;
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileEnrichmentFailure,
|
||||||
exception,
|
exception,
|
||||||
"An optional SGU staff profile page for role {Role} failed after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
"An optional SGU staff profile page for role {Role} failed after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||||
role,
|
identity.Role,
|
||||||
elapsed.ElapsedMilliseconds);
|
elapsed.ElapsedMilliseconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return await TryEnrichStaffLocationAsync(
|
||||||
|
client,
|
||||||
|
profile,
|
||||||
|
identity,
|
||||||
|
allowedHosts,
|
||||||
|
timeoutToken,
|
||||||
|
requestCancellationToken,
|
||||||
|
elapsed).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<InstitutionalProfile> TryEnrichStaffLocationAsync(
|
||||||
|
HttpClient client,
|
||||||
|
InstitutionalProfile profile,
|
||||||
|
UserIdentity identity,
|
||||||
|
HashSet<string> allowedHosts,
|
||||||
|
CancellationToken timeoutToken,
|
||||||
|
CancellationToken requestCancellationToken,
|
||||||
|
Stopwatch elapsed)
|
||||||
|
{
|
||||||
|
string path = options.AdministrativeLocationProfilePath;
|
||||||
|
Uri locationPageUri = GetProfileUri(path);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string? html = await TryFetchAdditionalProfilePageAsync(
|
||||||
|
client,
|
||||||
|
locationPageUri,
|
||||||
|
allowedHosts,
|
||||||
|
timeoutToken).ConfigureAwait(false);
|
||||||
|
if (html is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfilePageUnavailable,
|
||||||
|
"Optional SGU profile page {Path} did not return usable HTML for role {Role}; preserving fields already collected.",
|
||||||
|
path,
|
||||||
|
identity.Role);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
InstitutionalProfile? staticLocation = SguProfileParser.ParseAdministrativeLocation(html);
|
||||||
|
if (staticLocation is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileHtmlUnexpected,
|
||||||
|
"Optional SGU profile page {Path} returned HTML without its supported field IDs for role {Role}; preserving fields already collected.",
|
||||||
|
path,
|
||||||
|
identity.Role);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
profile = profile.Overlay(staticLocation);
|
||||||
|
if (string.IsNullOrWhiteSpace(staticLocation.PostalCode))
|
||||||
|
{
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? directionJson = await TryPostProfilePageMethodAsync(
|
||||||
|
client,
|
||||||
|
GetAdministrativeLocationMethodUri("GetDireccion"),
|
||||||
|
locationPageUri,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["CodigoPostal"] = staticLocation.PostalCode
|
||||||
|
},
|
||||||
|
allowedHosts,
|
||||||
|
timeoutToken).ConfigureAwait(false);
|
||||||
|
if (directionJson is null)
|
||||||
|
{
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
SguAdministrativeLocationSelection? selection =
|
||||||
|
SguProfileParser.ParseAdministrativeLocationSelection(
|
||||||
|
directionJson,
|
||||||
|
staticLocation.PostalCode);
|
||||||
|
if (selection is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileHtmlUnexpected,
|
||||||
|
"SGU location method GetDireccion returned an unexpected payload for role {Role}; preserving the static address fields.",
|
||||||
|
identity.Role);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? localitiesJson = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(selection.StateId))
|
||||||
|
{
|
||||||
|
localitiesJson = await TryPostProfilePageMethodAsync(
|
||||||
|
client,
|
||||||
|
GetAdministrativeLocationMethodUri("GetLocalidadListado"),
|
||||||
|
locationPageUri,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["pIdEstado"] = selection.StateId
|
||||||
|
},
|
||||||
|
allowedHosts,
|
||||||
|
timeoutToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
string? neighborhoodsJson = await TryPostProfilePageMethodAsync(
|
||||||
|
client,
|
||||||
|
GetAdministrativeLocationMethodUri("GetColoniasListado"),
|
||||||
|
locationPageUri,
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["pIdEstado"] = string.Empty,
|
||||||
|
["pLocalidad"] = string.Empty,
|
||||||
|
["CodigoPostal"] = selection.PostalCode ?? staticLocation.PostalCode
|
||||||
|
},
|
||||||
|
allowedHosts,
|
||||||
|
timeoutToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
InstitutionalProfile? resolvedLocation = SguProfileParser.ParseAdministrativeLocation(
|
||||||
|
html,
|
||||||
|
selection,
|
||||||
|
localitiesJson,
|
||||||
|
neighborhoodsJson);
|
||||||
|
return profile.Overlay(resolvedLocation);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileEnrichmentTimeout,
|
||||||
|
"SGU optional staff location enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||||
|
identity.Role,
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileEnrichmentFailure,
|
||||||
|
exception,
|
||||||
|
"SGU optional staff location enrichment failed for role {Role} after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||||
|
identity.Role,
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,6 +666,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfilePageUnavailable,
|
||||||
"Optional SGU profile page {Path} returned HTTP {StatusCode}.",
|
"Optional SGU profile page {Path} returned HTTP {StatusCode}.",
|
||||||
requestedUri.AbsolutePath,
|
requestedUri.AbsolutePath,
|
||||||
statusCode);
|
statusCode);
|
||||||
@@ -494,11 +674,52 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfilePageUnavailable,
|
||||||
"Optional SGU profile page {Path} exceeded the redirect limit.",
|
"Optional SGU profile page {Path} exceeded the redirect limit.",
|
||||||
requestedUri.AbsolutePath);
|
requestedUri.AbsolutePath);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string?> TryPostProfilePageMethodAsync(
|
||||||
|
HttpClient client,
|
||||||
|
Uri requestedUri,
|
||||||
|
Uri referrerUri,
|
||||||
|
IReadOnlyDictionary<string, string> payload,
|
||||||
|
HashSet<string> allowedHosts,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!IsAllowedHttpsUri(requestedUri, allowedHosts) ||
|
||||||
|
!IsAllowedHttpsUri(referrerUri, allowedHosts))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
using HttpRequestMessage request = new(HttpMethod.Post, requestedUri);
|
||||||
|
request.Headers.Referrer = referrerUri;
|
||||||
|
request.Content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(payload),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json");
|
||||||
|
using HttpResponseMessage response = await client
|
||||||
|
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
int statusCode = (int)response.StatusCode;
|
||||||
|
if (statusCode is >= 200 and < 300)
|
||||||
|
{
|
||||||
|
return await ReadLimitedStringAsync(
|
||||||
|
response.Content,
|
||||||
|
options.MaxProfileBytes,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfilePageUnavailable,
|
||||||
|
"Optional SGU profile method {Path} returned HTTP {StatusCode}.",
|
||||||
|
requestedUri.AbsolutePath,
|
||||||
|
statusCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static void AddCredential(
|
private static void AddCredential(
|
||||||
Uri uri,
|
Uri uri,
|
||||||
CredentialCache credentialCache,
|
CredentialCache credentialCache,
|
||||||
@@ -550,6 +771,23 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
right.AbsolutePath.TrimEnd('/'),
|
right.AbsolutePath.TrimEnd('/'),
|
||||||
StringComparison.OrdinalIgnoreCase);
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static int CountProfileFields(InstitutionalProfile profile) =>
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
profile.EmployeeNumber,
|
||||||
|
profile.DisplayName,
|
||||||
|
profile.GivenName,
|
||||||
|
profile.Surname,
|
||||||
|
profile.Email,
|
||||||
|
profile.EmployeeType,
|
||||||
|
profile.JobTitle,
|
||||||
|
profile.Department,
|
||||||
|
profile.StreetAddress,
|
||||||
|
profile.City,
|
||||||
|
profile.State,
|
||||||
|
profile.PostalCode
|
||||||
|
}.Count(value => !string.IsNullOrWhiteSpace(value));
|
||||||
|
|
||||||
private static async Task DrainResponseAsync(
|
private static async Task DrainResponseAsync(
|
||||||
HttpResponseMessage response,
|
HttpResponseMessage response,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -595,6 +833,12 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
private Uri GetProfileUri(string path) =>
|
private Uri GetProfileUri(string path) =>
|
||||||
new(new Uri(options.Endpoint, UriKind.Absolute), path);
|
new(new Uri(options.Endpoint, UriKind.Absolute), path);
|
||||||
|
|
||||||
|
private Uri GetAdministrativeLocationMethodUri(string methodName)
|
||||||
|
{
|
||||||
|
Uri pageUri = GetProfileUri(options.AdministrativeLocationProfilePath);
|
||||||
|
return new Uri($"{pageUri.GetLeftPart(UriPartial.Path).TrimEnd('/')}/{methodName}");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<InstitutionalProfile?> TryReadProfileAsync(
|
private async Task<InstitutionalProfile?> TryReadProfileAsync(
|
||||||
HttpResponseMessage response,
|
HttpResponseMessage response,
|
||||||
UserIdentity identity,
|
UserIdentity identity,
|
||||||
@@ -604,17 +848,31 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
response.Content,
|
response.Content,
|
||||||
options.MaxProfileBytes,
|
options.MaxProfileBytes,
|
||||||
timeoutToken).ConfigureAwait(false);
|
timeoutToken).ConfigureAwait(false);
|
||||||
return identity.Role switch
|
InstitutionalProfile? profile;
|
||||||
|
switch (identity.Role)
|
||||||
{
|
{
|
||||||
InstitutionalRole.Administrative =>
|
case InstitutionalRole.Administrative:
|
||||||
SguProfileParser.ParseAdministrative(html, identity.NumericId) ??
|
profile = SguProfileParser.ParseAdministrative(html, identity.NumericId);
|
||||||
SguProfileParser.ParseMenu(html),
|
break;
|
||||||
InstitutionalRole.Student =>
|
case InstitutionalRole.Student:
|
||||||
SguProfileParser.ParseStudent(html, identity.NumericId) ??
|
profile = SguProfileParser.ParseStudent(html, identity.NumericId);
|
||||||
SguProfileParser.ParseMenu(html),
|
break;
|
||||||
InstitutionalRole.Professor => SguProfileParser.ParseMenu(html),
|
case InstitutionalRole.Professor:
|
||||||
_ => null
|
return SguProfileParser.ParseMenu(html);
|
||||||
};
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile is not null)
|
||||||
|
{
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileHtmlUnexpected,
|
||||||
|
"The primary SGU profile HTML did not contain the supported field IDs for role {Role}; attempting the menu-name fallback.",
|
||||||
|
identity.Role);
|
||||||
|
return SguProfileParser.ParseMenu(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<string> ReadLimitedStringAsync(
|
private static async Task<string> ReadLimitedStringAsync(
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"AdministrativePersonalProfilePath": "/psulsa/gadmon/capitalhumano/datos/personales.aspx",
|
"AdministrativePersonalProfilePath": "/psulsa/gadmon/capitalhumano/datos/personales.aspx",
|
||||||
"AdministrativeLocationProfilePath": "/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
|
"AdministrativeLocationProfilePath": "/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
|
||||||
"StudentProfilePath": "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
|
"StudentProfilePath": "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
|
||||||
|
"ProfessorPayrollProfilePath": "/psulsa/gadmon/nomina/consultanomina.aspx",
|
||||||
"MenuProfilePath": "/psulsa/menu.aspx",
|
"MenuProfilePath": "/psulsa/menu.aspx",
|
||||||
"MaxProfileBytes": 524288,
|
"MaxProfileBytes": 524288,
|
||||||
"AllowedRedirectHosts": [
|
"AllowedRedirectHosts": [
|
||||||
@@ -49,6 +50,9 @@
|
|||||||
"ProfessorOuDn": "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
"ProfessorOuDn": "OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||||
"StudentOuDn": "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
"StudentOuDn": "OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||||
"AdministrativeOuDn": "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
"AdministrativeOuDn": "OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||||
|
"ProfessorGroupDn": "CN=SGU-Docentes,OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||||
|
"StudentGroupDn": "CN=SGU-Alumnos,OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||||
|
"AdministrativeGroupDn": "CN=SGU-Administrativos,OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx",
|
||||||
"RemoteDesktopGroupDn": "",
|
"RemoteDesktopGroupDn": "",
|
||||||
"DefaultCompany": "La Salle",
|
"DefaultCompany": "La Salle",
|
||||||
"CreateMissingOus": false
|
"CreateMissingOus": false
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<EmbeddedResource Include="..\..\assets\branding\lasalle-mascot-provider-v2.png"
|
<EmbeddedResource Include="..\..\assets\branding\lasalle-mexico-provider.png"
|
||||||
LogicalName="SGU.CredentialProvider.Branding.LaSalleMascot.png" />
|
LogicalName="SGU.CredentialProvider.Branding.LaSalleMascot.png" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||||
|
$serverBootstrapPath = Join-Path $repositoryRoot 'scripts\Initialize-SguDomainController.ps1'
|
||||||
|
$clientBootstrapPath = Join-Path $repositoryRoot 'scripts\Invoke-SguClientBootstrap.ps1'
|
||||||
|
$azureClientPath = Join-Path $repositoryRoot 'scripts\Install-SguAzureP2sClient.ps1'
|
||||||
|
$bicepPath = Join-Path $repositoryRoot 'infra\azure\main.bicep'
|
||||||
|
|
||||||
|
$tokens = $null
|
||||||
|
$parseErrors = $null
|
||||||
|
$serverAst = [Management.Automation.Language.Parser]::ParseFile(
|
||||||
|
$serverBootstrapPath,
|
||||||
|
[ref]$tokens,
|
||||||
|
[ref]$parseErrors)
|
||||||
|
if ($parseErrors.Count -gt 0) {
|
||||||
|
throw ($parseErrors -join [Environment]::NewLine)
|
||||||
|
}
|
||||||
|
$networkFunctionNames = @(
|
||||||
|
'Test-PrivateIPv4Address',
|
||||||
|
'ConvertTo-NetworkCidr',
|
||||||
|
'ConvertTo-PrivateNetworkCidr'
|
||||||
|
)
|
||||||
|
$networkFunctions = $serverAst.FindAll({
|
||||||
|
param($node)
|
||||||
|
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and
|
||||||
|
$networkFunctionNames -contains $node.Name
|
||||||
|
}, $true)
|
||||||
|
Invoke-Expression (($networkFunctions | ForEach-Object { $_.Extent.Text }) -join [Environment]::NewLine)
|
||||||
|
|
||||||
|
Describe 'SGU public-cloud network safety' {
|
||||||
|
It 'canonicalizes a host address to its IPv4 network' {
|
||||||
|
ConvertTo-NetworkCidr -Address ([ipaddress]'10.77.0.4') `
|
||||||
|
-NetworkPrefixLength 24 | Should Be '10.77.0.0/24'
|
||||||
|
}
|
||||||
|
|
||||||
|
It 'canonicalizes the trusted P2S pool' {
|
||||||
|
ConvertTo-PrivateNetworkCidr -Cidr '172.30.4.19/16' |
|
||||||
|
Should Be '172.30.0.0/16'
|
||||||
|
}
|
||||||
|
|
||||||
|
It 'rejects a public trusted-client CIDR' {
|
||||||
|
$wasRejected = $false
|
||||||
|
try {
|
||||||
|
ConvertTo-PrivateNetworkCidr -Cidr '8.8.8.0/24' | Out-Null
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$wasRejected = $true
|
||||||
|
}
|
||||||
|
$wasRejected | Should Be $true
|
||||||
|
}
|
||||||
|
|
||||||
|
It 'exposes explicit Azure modes on both bootstraps' {
|
||||||
|
((Get-Command $serverBootstrapPath).Parameters.Keys -contains
|
||||||
|
'NetworkConfigurationMode') | Should Be $true
|
||||||
|
((Get-Command $serverBootstrapPath).Parameters.Keys -contains
|
||||||
|
'TrustedClientNetworks') | Should Be $true
|
||||||
|
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
|
||||||
|
'ConnectivityMode') | Should Be $true
|
||||||
|
((Get-Command $clientBootstrapPath).Parameters.Keys -contains
|
||||||
|
'VpnProfilePackagePath') | Should Be $true
|
||||||
|
}
|
||||||
|
|
||||||
|
It 'uses an all-user machine-certificate VPN profile' {
|
||||||
|
$source = Get-Content -LiteralPath $azureClientPath -Raw
|
||||||
|
$source | Should Match '-AuthenticationMethod MachineCertificate'
|
||||||
|
$source | Should Match '-AllUserConnection'
|
||||||
|
$source | Should Match 'Add-DnsClientNrptRule'
|
||||||
|
}
|
||||||
|
|
||||||
|
It 'limits optional public administration to RDP' {
|
||||||
|
$template = Get-Content -LiteralPath $bicepPath -Raw
|
||||||
|
$template | Should Match "name: 'Allow-RDP-from-administrator'"
|
||||||
|
$template | Should Match "destinationPortRange: '3389'"
|
||||||
|
$template | Should Not Match "sourceAddressPrefix: '0\.0\.0\.0/0'"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,58 @@ public sealed class SguProfileParserTests
|
|||||||
Assert.Null(SguProfileParser.ParseAdministrative(html, "999999"));
|
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&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]
|
[Fact]
|
||||||
public void ParsesStructuredAdministrativeNameWithoutReadingOtherPersonalData()
|
public void ParsesStructuredAdministrativeNameWithoutReadingOtherPersonalData()
|
||||||
{
|
{
|
||||||
@@ -81,6 +133,64 @@ public sealed class SguProfileParserTests
|
|||||||
Assert.Null(profile.Email);
|
Assert.Null(profile.Email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("ctl00_contenedor_ddlsexo", "ctl00$contenedor$ddlsexo", "1", InstitutionalGender.Male)]
|
||||||
|
[InlineData("alternate-id", "ctl00$contenedor$ddlsexo", "2", InstitutionalGender.Female)]
|
||||||
|
public void ParsesStaffGenderFromTheSelectedPersonalDataOption(
|
||||||
|
string id,
|
||||||
|
string name,
|
||||||
|
string selectedValue,
|
||||||
|
InstitutionalGender expected)
|
||||||
|
{
|
||||||
|
string html = $"""
|
||||||
|
<select id="{id}" name="{name}">
|
||||||
|
<option value="">Seleccione...</option>
|
||||||
|
<option value="1"{(selectedValue == "1" ? " selected=\"selected\"" : string.Empty)}>Masculino</option>
|
||||||
|
<option value="2"{(selectedValue == "2" ? " selected=\"selected\"" : string.Empty)}>Femenino</option>
|
||||||
|
</select>
|
||||||
|
""";
|
||||||
|
|
||||||
|
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativePersonal(html);
|
||||||
|
|
||||||
|
Assert.NotNull(profile);
|
||||||
|
Assert.Equal(expected, profile.Gender);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("M", InstitutionalGender.Male)]
|
||||||
|
[InlineData("f", InstitutionalGender.Female)]
|
||||||
|
public void ParsesStudentGenderFromTheInformationSpan(
|
||||||
|
string source,
|
||||||
|
InstitutionalGender expected)
|
||||||
|
{
|
||||||
|
string html = $"""
|
||||||
|
<span id="ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP">123456</span>
|
||||||
|
<span id="ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP">{source}</span>
|
||||||
|
""";
|
||||||
|
|
||||||
|
InstitutionalProfile? profile = SguProfileParser.ParseStudent(html, "123456");
|
||||||
|
|
||||||
|
Assert.NotNull(profile);
|
||||||
|
Assert.Equal(expected, profile.Gender);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IgnoresUnknownGenderValuesWithoutFailingProfileParsing()
|
||||||
|
{
|
||||||
|
const string html = """
|
||||||
|
<input id="ctl00_contenedor_txtNombre" value="PERSONA" />
|
||||||
|
<select id="ctl00_contenedor_ddlsexo">
|
||||||
|
<option selected="selected" value="9">SIN CLASIFICAR</option>
|
||||||
|
</select>
|
||||||
|
""";
|
||||||
|
|
||||||
|
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativePersonal(html);
|
||||||
|
|
||||||
|
Assert.NotNull(profile);
|
||||||
|
Assert.Equal("Persona", profile.DisplayName);
|
||||||
|
Assert.Null(profile.Gender);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ParsesAdministrativeAddressFromInputsAndSelectedOptions()
|
public void ParsesAdministrativeAddressFromInputsAndSelectedOptions()
|
||||||
{
|
{
|
||||||
@@ -118,6 +228,65 @@ public sealed class SguProfileParserTests
|
|||||||
Assert.Null(profile.Email);
|
Assert.Null(profile.Email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolvesAdministrativeAddressFromPageMethodIdentifiers()
|
||||||
|
{
|
||||||
|
const string html = """
|
||||||
|
<html><body>
|
||||||
|
<input id='ctl00_contenedor_txtCalle' value='RETORNO 1, SUR 16' />
|
||||||
|
<input id='ctl00_contenedor_txtNoExt' value='74' />
|
||||||
|
<input id='ctl00_contenedor_txtNoInt' value='' />
|
||||||
|
<input id='ctl00_contenedor_txtCP' value='08500' />
|
||||||
|
<select id='ctl00_contenedor_ddlEstado'>
|
||||||
|
<option selected='selected' value='0'>Seleccione...</option>
|
||||||
|
<option value='09'>CIUDAD DE MÉXICO</option>
|
||||||
|
</select>
|
||||||
|
<select id='ctl00_contenedor_ddlLocalidad'>
|
||||||
|
<option selected='selected' value='0'>Seleccione alguna localidad...</option>
|
||||||
|
</select>
|
||||||
|
<select id='ctl00_contenedor_ddlColonia'>
|
||||||
|
<option selected='selected' value='0,0'>Seleccione alguna colonia...</option>
|
||||||
|
</select>
|
||||||
|
</body></html>
|
||||||
|
""";
|
||||||
|
const string directionJson = """
|
||||||
|
{"d":[{"p_IdCP":"091263","p_IdEstado":"09","p_NombreEstado":"","p_IdMunicipio":"006","p_NombreMunicipio":"","p_NombreColonia":"","p_Cp":"08500"}]}
|
||||||
|
""";
|
||||||
|
const string localitiesJson = """
|
||||||
|
{"d":[{"ID_Estado":"09","Id_Municipio":"002","Nombre":"AZCAPOTZALCO"},{"ID_Estado":"09","Id_Municipio":"006","Nombre":"IZTACALCO"}]}
|
||||||
|
""";
|
||||||
|
const string neighborhoodsJson = """
|
||||||
|
{"d":[{"p_IdCP":"091263","p_Nombre":"AGRÍCOLA ORIENTAL","p_Cp":"08500"}]}
|
||||||
|
""";
|
||||||
|
|
||||||
|
SguAdministrativeLocationSelection? selection =
|
||||||
|
SguProfileParser.ParseAdministrativeLocationSelection(directionJson, "08500");
|
||||||
|
InstitutionalProfile? profile = SguProfileParser.ParseAdministrativeLocation(
|
||||||
|
html,
|
||||||
|
selection,
|
||||||
|
localitiesJson,
|
||||||
|
neighborhoodsJson);
|
||||||
|
|
||||||
|
Assert.NotNull(selection);
|
||||||
|
Assert.Equal("09", selection.StateId);
|
||||||
|
Assert.Equal("006", selection.MunicipalityId);
|
||||||
|
Assert.Equal("091263", selection.NeighborhoodId);
|
||||||
|
Assert.NotNull(profile);
|
||||||
|
Assert.Equal("Retorno 1, Sur 16 74\r\nAgrícola Oriental", profile.StreetAddress);
|
||||||
|
Assert.Equal("Iztacalco", profile.City);
|
||||||
|
Assert.Equal("Ciudad de México", profile.State);
|
||||||
|
Assert.Equal("08500", profile.PostalCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("not-json")]
|
||||||
|
[InlineData("{\"d\":{}}")]
|
||||||
|
[InlineData("{\"d\":[]}")]
|
||||||
|
public void RejectsUnexpectedAdministrativeLocationPayloads(string json)
|
||||||
|
{
|
||||||
|
Assert.Null(SguProfileParser.ParseAdministrativeLocationSelection(json, "08500"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AdministrativePagesOverlayTheVerifiedIncidentsProfile()
|
public void AdministrativePagesOverlayTheVerifiedIncidentsProfile()
|
||||||
{
|
{
|
||||||
@@ -130,7 +299,8 @@ public sealed class SguProfileParserTests
|
|||||||
InstitutionalProfile personal = new(
|
InstitutionalProfile personal = new(
|
||||||
DisplayName: "María del Carmen de la Fuente",
|
DisplayName: "María del Carmen de la Fuente",
|
||||||
GivenName: "María del Carmen",
|
GivenName: "María del Carmen",
|
||||||
Surname: "de la Fuente");
|
Surname: "de la Fuente",
|
||||||
|
Gender: InstitutionalGender.Female);
|
||||||
InstitutionalProfile location = new(
|
InstitutionalProfile location = new(
|
||||||
StreetAddress: "Calle Uno 10",
|
StreetAddress: "Calle Uno 10",
|
||||||
City: "Ciudad de México",
|
City: "Ciudad de México",
|
||||||
@@ -148,6 +318,7 @@ public sealed class SguProfileParserTests
|
|||||||
Assert.Equal("Ingeniería", combined.Department);
|
Assert.Equal("Ingeniería", combined.Department);
|
||||||
Assert.Equal("Calle Uno 10", combined.StreetAddress);
|
Assert.Equal("Calle Uno 10", combined.StreetAddress);
|
||||||
Assert.Equal("01000", combined.PostalCode);
|
Assert.Equal("01000", combined.PostalCode);
|
||||||
|
Assert.Equal(InstitutionalGender.Female, combined.Gender);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -163,6 +334,7 @@ public sealed class SguProfileParserTests
|
|||||||
<span id="ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP">
|
<span id="ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP">
|
||||||
<a href="mailto:alumna@lasalle.mx">ALUMNA@LASALLE.MX</a>
|
<a href="mailto:alumna@lasalle.mx">ALUMNA@LASALLE.MX</a>
|
||||||
</span>
|
</span>
|
||||||
|
<span id="ctl00_contenedor_HistorialAlumno1_lblSexoAlumnoHP">F</span>
|
||||||
<span id="ctl00_contenedor_HistorialAlumno1_lblCURPAlumnoHP">
|
<span id="ctl00_contenedor_HistorialAlumno1_lblCURPAlumnoHP">
|
||||||
DATO-SENSIBLE-QUE-NO-DEBE-EXTRAERSE
|
DATO-SENSIBLE-QUE-NO-DEBE-EXTRAERSE
|
||||||
</span>
|
</span>
|
||||||
@@ -210,6 +382,7 @@ public sealed class SguProfileParserTests
|
|||||||
Assert.Equal("Ciudad de México", profile.City);
|
Assert.Equal("Ciudad de México", profile.City);
|
||||||
Assert.Equal("Ciudad de México", profile.State);
|
Assert.Equal("Ciudad de México", profile.State);
|
||||||
Assert.Equal("08500", profile.PostalCode);
|
Assert.Equal("08500", profile.PostalCode);
|
||||||
|
Assert.Equal(InstitutionalGender.Female, profile.Gender);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using SGU.AuthBroker.Core.Profiles;
|
||||||
|
using SGU.AuthBroker.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SGU.AuthBroker.Tests;
|
||||||
|
|
||||||
|
public sealed class ActiveDirectorySynchronizerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GenderMetadataPreservesUnmanagedNotesAndReplacesItsManagedLine()
|
||||||
|
{
|
||||||
|
const string existing = " Responsable de laboratorio \r\n\r\nSGU-Gender: Male\r\nTurno vespertino";
|
||||||
|
|
||||||
|
string? updated = ActiveDirectorySynchronizer.MergeGenderMetadata(
|
||||||
|
existing,
|
||||||
|
InstitutionalGender.Female);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
" Responsable de laboratorio \r\n\r\nTurno vespertino\r\nSGU-Gender: Female",
|
||||||
|
updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenderMetadataDoesNotTruncateAnExistingFullNotesField()
|
||||||
|
{
|
||||||
|
string existing = new('x', 1024);
|
||||||
|
|
||||||
|
string? updated = ActiveDirectorySynchronizer.MergeGenderMetadata(
|
||||||
|
existing,
|
||||||
|
InstitutionalGender.Male);
|
||||||
|
|
||||||
|
Assert.Null(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using SGU.AuthBroker.Options;
|
using SGU.AuthBroker.Options;
|
||||||
|
using SGU.AuthBroker.Core.Identity;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace SGU.AuthBroker.Tests;
|
namespace SGU.AuthBroker.Tests;
|
||||||
@@ -28,4 +29,31 @@ public sealed class BrokerOptionsTests
|
|||||||
|
|
||||||
Assert.Contains("thumbprint", exception.Message, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("thumbprint", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(InstitutionalRole.Student, "CN=SGU-Alumnos,OU=Alumnos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
|
||||||
|
[InlineData(InstitutionalRole.Administrative, "CN=SGU-Administrativos,OU=Administrativos,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
|
||||||
|
[InlineData(InstitutionalRole.Professor, "CN=SGU-Docentes,OU=Docentes,OU=Usuarios-SGU,DC=lci,DC=lasalle,DC=mx")]
|
||||||
|
public void DefaultRoleGroupMappingsMatchInstitutionalPrefixes(InstitutionalRole role, string expectedGroupDn)
|
||||||
|
{
|
||||||
|
ActiveDirectoryOptions options = new();
|
||||||
|
|
||||||
|
Assert.Equal(expectedGroupDn, options.GetGroupDn(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateRejectsARoleGroupOutsideTheConfiguredDirectoryBase()
|
||||||
|
{
|
||||||
|
BrokerOptions options = new()
|
||||||
|
{
|
||||||
|
Directory = new ActiveDirectoryOptions
|
||||||
|
{
|
||||||
|
StudentGroupDn = "CN=SGU-Alumnos,DC=example,DC=invalid"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(options.Validate);
|
||||||
|
|
||||||
|
Assert.Contains("security-group", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
|
|||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using SGU.AuthBroker.Core.Authentication;
|
using SGU.AuthBroker.Core.Authentication;
|
||||||
using SGU.AuthBroker.Core.Identity;
|
using SGU.AuthBroker.Core.Identity;
|
||||||
|
using SGU.AuthBroker.Core.Profiles;
|
||||||
using SGU.AuthBroker.Options;
|
using SGU.AuthBroker.Options;
|
||||||
using SGU.AuthBroker.Services;
|
using SGU.AuthBroker.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -132,6 +133,10 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
|
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
|
||||||
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
|
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
|
||||||
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" />
|
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" />
|
||||||
|
<select name="ctl00$contenedor$ddlsexo">
|
||||||
|
<option value="1">Masculino</option>
|
||||||
|
<option selected="selected" value="2">Femenino</option>
|
||||||
|
</select>
|
||||||
"""),
|
"""),
|
||||||
Response(
|
Response(
|
||||||
HttpStatusCode.OK,
|
HttpStatusCode.OK,
|
||||||
@@ -140,14 +145,24 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
<input id="ctl00_contenedor_txtNoExt" value="15" />
|
<input id="ctl00_contenedor_txtNoExt" value="15" />
|
||||||
<input id="ctl00_contenedor_txtCP" value="01000" />
|
<input id="ctl00_contenedor_txtCP" value="01000" />
|
||||||
<select id="ctl00_contenedor_ddlEstado">
|
<select id="ctl00_contenedor_ddlEstado">
|
||||||
<option selected="selected">CIUDAD DE MÉXICO</option>
|
<option selected="selected" value="0">Seleccione...</option>
|
||||||
|
<option value="09">CIUDAD DE MÉXICO</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="ctl00_contenedor_ddlLocalidad">
|
<select id="ctl00_contenedor_ddlLocalidad">
|
||||||
<option selected="selected">ÁLVARO OBREGÓN</option>
|
<option selected="selected" value="0">Seleccione alguna localidad...</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="ctl00_contenedor_ddlColonia">
|
<select id="ctl00_contenedor_ddlColonia">
|
||||||
<option>FLORIDA</option>
|
<option selected="selected" value="0,0">Seleccione alguna colonia...</option>
|
||||||
</select>
|
</select>
|
||||||
|
"""),
|
||||||
|
JsonResponse("""
|
||||||
|
{"d":[{"p_IdCP":"090001","p_IdEstado":"09","p_NombreEstado":"","p_IdMunicipio":"010","p_NombreMunicipio":"","p_NombreColonia":"FLORIDA","p_Cp":"01000"}]}
|
||||||
|
"""),
|
||||||
|
JsonResponse("""
|
||||||
|
{"d":[{"ID_Estado":"09","Id_Municipio":"010","Nombre":"ÁLVARO OBREGÓN"}]}
|
||||||
|
"""),
|
||||||
|
JsonResponse("""
|
||||||
|
{"d":[{"p_IdCP":"090001","p_Nombre":"FLORIDA","p_Cp":"01000"}]}
|
||||||
"""));
|
"""));
|
||||||
NtlmCredentialValidator validator = CreateValidator(handler);
|
NtlmCredentialValidator validator = CreateValidator(handler);
|
||||||
|
|
||||||
@@ -168,15 +183,24 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
Assert.Equal("Álvaro Obregón", result.Profile.City);
|
Assert.Equal("Álvaro Obregón", result.Profile.City);
|
||||||
Assert.Equal("Ciudad de México", result.Profile.State);
|
Assert.Equal("Ciudad de México", result.Profile.State);
|
||||||
Assert.Equal("01000", result.Profile.PostalCode);
|
Assert.Equal("01000", result.Profile.PostalCode);
|
||||||
|
Assert.Equal(InstitutionalGender.Female, result.Profile.Gender);
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
[
|
[
|
||||||
"/psulsa/",
|
"/psulsa/",
|
||||||
"/psulsa/",
|
"/psulsa/",
|
||||||
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
|
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
|
||||||
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
|
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
|
||||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx"
|
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
|
||||||
|
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetDireccion",
|
||||||
|
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetLocalidadListado",
|
||||||
|
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetColoniasListado"
|
||||||
],
|
],
|
||||||
handler.RequestPaths);
|
handler.RequestPaths);
|
||||||
|
Assert.Equal("{\"CodigoPostal\":\"01000\"}", handler.RequestBodies[5]);
|
||||||
|
Assert.Equal("{\"pIdEstado\":\"09\"}", handler.RequestBodies[6]);
|
||||||
|
Assert.Equal(
|
||||||
|
"{\"pIdEstado\":\"\",\"pLocalidad\":\"\",\"CodigoPostal\":\"01000\"}",
|
||||||
|
handler.RequestBodies[7]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -209,12 +233,25 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
"""
|
"""
|
||||||
<span id="ctl00_lblNombreUsuario">MARÍA DEL CARMEN</span>
|
<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(
|
Response(
|
||||||
HttpStatusCode.OK,
|
HttpStatusCode.OK,
|
||||||
"""
|
"""
|
||||||
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
|
<input id="ctl00_contenedor_txtNombre" value="MARÍA DEL CARMEN" />
|
||||||
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
|
<input id="ctl00_contenedor_txtApaterno" value="DE LA FUENTE" />
|
||||||
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" />
|
<input id="ctl00_contenedor_txtAmaterno" value="O'CONNOR" />
|
||||||
|
<select id="ctl00_contenedor_ddlsexo">
|
||||||
|
<option selected="selected" value="1">Masculino</option>
|
||||||
|
<option value="2">Femenino</option>
|
||||||
|
</select>
|
||||||
"""),
|
"""),
|
||||||
Response(
|
Response(
|
||||||
HttpStatusCode.OK,
|
HttpStatusCode.OK,
|
||||||
@@ -223,14 +260,24 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
<input id="ctl00_contenedor_txtNoExt" value="15" />
|
<input id="ctl00_contenedor_txtNoExt" value="15" />
|
||||||
<input id="ctl00_contenedor_txtCP" value="01000" />
|
<input id="ctl00_contenedor_txtCP" value="01000" />
|
||||||
<select id="ctl00_contenedor_ddlEstado">
|
<select id="ctl00_contenedor_ddlEstado">
|
||||||
<option selected="selected">CIUDAD DE MÉXICO</option>
|
<option selected="selected" value="0">Seleccione...</option>
|
||||||
|
<option value="09">CIUDAD DE MÉXICO</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="ctl00_contenedor_ddlLocalidad">
|
<select id="ctl00_contenedor_ddlLocalidad">
|
||||||
<option selected="selected">ÁLVARO OBREGÓN</option>
|
<option selected="selected" value="0">Seleccione alguna localidad...</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="ctl00_contenedor_ddlColonia">
|
<select id="ctl00_contenedor_ddlColonia">
|
||||||
<option selected="selected">FLORIDA</option>
|
<option selected="selected" value="0,0">Seleccione alguna colonia...</option>
|
||||||
</select>
|
</select>
|
||||||
|
"""),
|
||||||
|
JsonResponse("""
|
||||||
|
{"d":[{"p_IdCP":"090001","p_IdEstado":"09","p_NombreEstado":"","p_IdMunicipio":"010","p_NombreMunicipio":"","p_NombreColonia":"FLORIDA","p_Cp":"01000"}]}
|
||||||
|
"""),
|
||||||
|
JsonResponse("""
|
||||||
|
{"d":[{"ID_Estado":"09","Id_Municipio":"010","Nombre":"ÁLVARO OBREGÓN"}]}
|
||||||
|
"""),
|
||||||
|
JsonResponse("""
|
||||||
|
{"d":[{"p_IdCP":"090001","p_Nombre":"FLORIDA","p_Cp":"01000"}]}
|
||||||
"""));
|
"""));
|
||||||
NtlmCredentialValidator validator = CreateValidator(handler);
|
NtlmCredentialValidator validator = CreateValidator(handler);
|
||||||
|
|
||||||
@@ -244,17 +291,27 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
Assert.Equal("María del Carmen de la Fuente O'Connor", result.Profile.DisplayName);
|
Assert.Equal("María del Carmen de la Fuente O'Connor", result.Profile.DisplayName);
|
||||||
Assert.Equal("María del Carmen", result.Profile.GivenName);
|
Assert.Equal("María del Carmen", result.Profile.GivenName);
|
||||||
Assert.Equal("de la Fuente O'Connor", result.Profile.Surname);
|
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("Calle del Sol 15\r\nFlorida", result.Profile.StreetAddress);
|
||||||
Assert.Equal("Álvaro Obregón", result.Profile.City);
|
Assert.Equal("Álvaro Obregón", result.Profile.City);
|
||||||
Assert.Equal("Ciudad de México", result.Profile.State);
|
Assert.Equal("Ciudad de México", result.Profile.State);
|
||||||
Assert.Equal("01000", result.Profile.PostalCode);
|
Assert.Equal("01000", result.Profile.PostalCode);
|
||||||
|
Assert.Equal(InstitutionalGender.Male, result.Profile.Gender);
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
[
|
[
|
||||||
"/psulsa/",
|
"/psulsa/",
|
||||||
"/psulsa/",
|
"/psulsa/",
|
||||||
"/psulsa/menu.aspx",
|
"/psulsa/menu.aspx",
|
||||||
|
"/psulsa/gadmon/nomina/consultanomina.aspx",
|
||||||
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
|
"/psulsa/gadmon/capitalhumano/datos/personales.aspx",
|
||||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx"
|
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
|
||||||
|
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetDireccion",
|
||||||
|
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetLocalidadListado",
|
||||||
|
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx/GetColoniasListado"
|
||||||
],
|
],
|
||||||
handler.RequestPaths);
|
handler.RequestPaths);
|
||||||
}
|
}
|
||||||
@@ -270,6 +327,7 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
"""
|
"""
|
||||||
<span id="ctl00_lblNombreUsuario">MIGUEL DE CERVANTES</span>
|
<span id="ctl00_lblNombreUsuario">MIGUEL DE CERVANTES</span>
|
||||||
"""),
|
"""),
|
||||||
|
Response(HttpStatusCode.NotFound),
|
||||||
Response(HttpStatusCode.OK, "<html><body>Unrecognized layout</body></html>"),
|
Response(HttpStatusCode.OK, "<html><body>Unrecognized layout</body></html>"),
|
||||||
Response(HttpStatusCode.NotFound));
|
Response(HttpStatusCode.NotFound));
|
||||||
NtlmCredentialValidator validator = CreateValidator(handler);
|
NtlmCredentialValidator validator = CreateValidator(handler);
|
||||||
@@ -283,7 +341,10 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
Assert.NotNull(result.Profile);
|
Assert.NotNull(result.Profile);
|
||||||
Assert.Equal("Miguel de Cervantes", result.Profile.DisplayName);
|
Assert.Equal("Miguel de Cervantes", result.Profile.DisplayName);
|
||||||
Assert.Null(result.Profile.StreetAddress);
|
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)
|
private static NtlmCredentialValidator CreateValidator(SequenceHandler handler)
|
||||||
@@ -298,6 +359,7 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
AdministrativePersonalProfilePath = "/psulsa/gadmon/capitalhumano/datos/personales.aspx",
|
AdministrativePersonalProfilePath = "/psulsa/gadmon/capitalhumano/datos/personales.aspx",
|
||||||
AdministrativeLocationProfilePath = "/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
|
AdministrativeLocationProfilePath = "/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
|
||||||
StudentProfilePath = "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
|
StudentProfilePath = "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
|
||||||
|
ProfessorPayrollProfilePath = "/psulsa/gadmon/nomina/consultanomina.aspx",
|
||||||
AllowedRedirectHosts = ["sgu.example"],
|
AllowedRedirectHosts = ["sgu.example"],
|
||||||
TimeoutSeconds = 5,
|
TimeoutSeconds = 5,
|
||||||
ProfileTimeoutSeconds = 5
|
ProfileTimeoutSeconds = 5
|
||||||
@@ -332,18 +394,29 @@ public sealed class NtlmCredentialValidatorTests
|
|||||||
Content = new StringContent(content)
|
Content = new StringContent(content)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static HttpResponseMessage JsonResponse(string content) =>
|
||||||
|
new(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(content, null, "application/json")
|
||||||
|
};
|
||||||
|
|
||||||
private sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
private sealed class SequenceHandler(params HttpResponseMessage[] responses) : HttpMessageHandler
|
||||||
{
|
{
|
||||||
private readonly Queue<HttpResponseMessage> responses = new(responses);
|
private readonly Queue<HttpResponseMessage> responses = new(responses);
|
||||||
|
|
||||||
public List<string> RequestPaths { get; } = [];
|
public List<string> RequestPaths { get; } = [];
|
||||||
|
|
||||||
|
public List<string?> RequestBodies { get; } = [];
|
||||||
|
|
||||||
protected override Task<HttpResponseMessage> SendAsync(
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
HttpRequestMessage request,
|
HttpRequestMessage request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
RequestPaths.Add(request.RequestUri!.AbsolutePath);
|
RequestPaths.Add(request.RequestUri!.AbsolutePath);
|
||||||
|
RequestBodies.Add(request.Content is null
|
||||||
|
? null
|
||||||
|
: request.Content.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult());
|
||||||
if (responses.Count == 0)
|
if (responses.Count == 0)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("The validator sent more requests than expected.");
|
throw new InvalidOperationException("The validator sent more requests than expected.");
|
||||||
|
|||||||
Reference in New Issue
Block a user