Compare commits

..
3 Commits
25 changed files with 1364 additions and 33 deletions
+9
View File
@@ -50,6 +50,8 @@ Operational documentation:
- [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)
- [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 |
@@ -105,6 +107,13 @@ 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
```
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: 90 KiB

+15
View File
@@ -105,6 +105,21 @@ 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.
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
+26 -2
View File
@@ -1,6 +1,6 @@
# Recuperación desde cero y alta en una sola ejecución # Recuperación desde cero y alta en una sola ejecución
Los releases entregan dos ZIP independientes. Cada uno contiene sus binarios, Los releases entregan tres 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.
@@ -131,6 +131,30 @@ 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 +174,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 tres ZIP antes de usarlos.
+78
View File
@@ -0,0 +1,78 @@
# 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.
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
```
`--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.
## 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`.
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.
El objeto de equipo aparece como `NOMBRE` en `OU=Laboratorio`. SSSD registra su registro A cuando la actualización DNS dinámica está activada.
## Inicio de sesión de dominio
Después de la unión, el formato explícito es:
```text
usuario@lci.lasalle.mx
```
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
```
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.
+118
View File
@@ -0,0 +1,118 @@
# 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.
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>
+27 -1
View File
@@ -46,6 +46,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.'
@@ -129,6 +131,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 +153,9 @@ $productionSettings = @{
} }
} }
Broker = @{ Broker = @{
Diagnostics = @{
UseDedicatedEventLog = $true
}
Tls = @{ Tls = @{
AllowedClientThumbprints = $normalizedClientThumbprints AllowedClientThumbprints = $normalizedClientThumbprints
CheckCertificateRevocation = -not $DisableCertificateRevocationCheckForLab CheckCertificateRevocation = -not $DisableCertificateRevocationCheckForLab
@@ -198,6 +211,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 +274,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 } }
+61
View File
@@ -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'
}
+3 -1
View File
@@ -39,7 +39,8 @@ foreach ($scriptName in @(
'Install-SguEnrollmentGuard.ps1', 'Install-SguEnrollmentGuard.ps1',
'Test-SguClientEnrollment.ps1', 'Test-SguClientEnrollment.ps1',
'Repair-SguClientEnrollment.ps1', 'Repair-SguClientEnrollment.ps1',
'Enable-LabRemoteAccess.ps1')) { 'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.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."
} }
@@ -96,6 +97,7 @@ 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
& (Join-Path $PSScriptRoot 'Enable-SguClientMonitoring.ps1') | Out-Null
return & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') ` return & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') `
-RequireDomainJoined ` -RequireDomainJoined `
-RequireRemoteAccess ` -RequireRemoteAccess `
+233
View File
@@ -0,0 +1,233 @@
#!/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'
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
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.
--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"
}
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 ;;
--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
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y "${packages[@]}"
pam-auth-update --enable mkhomedir --force
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
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
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_ssh() {
[[ $ENABLE_SSH == true ]] || return
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
}
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
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
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"
realm list
+81
View File
@@ -0,0 +1,81 @@
#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'
}
# 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
+176
View File
@@ -0,0 +1,176 @@
#Requires -Version 5.1
[CmdletBinding()]
param(
[datetime]$Since = (Get-Date).AddDays(-183),
[datetime]$Until = (Get-Date),
[string]$UserName,
[string]$ComputerName,
[string]$MonitoringRoot = 'C:\ProgramData\SGU\Monitoring',
[string]$OutputCsv
)
$ErrorActionPreference = 'Stop'
$eventIds = @(4624,4625,4634,4647,4778,4779,6005,6006,6008)
$events = [Collections.Generic.List[object]]::new()
try {
# Windows Server 2025 can crash the Windows Event Log service when a
# structured query is evaluated against ForwardedEvents (wevtsvc.dll,
# exception 0xc0000420). Read the channel without a server-side query and
# apply every predicate in this process instead.
Get-WinEvent -LogName 'ForwardedEvents' -ErrorAction Stop |
Where-Object {
$_.Id -in $eventIds -and
$_.TimeCreated -ge $Since -and
$_.TimeCreated -le $Until
} |
ForEach-Object { $events.Add($_) }
}
catch [System.Exception] {
if ($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*') {
Write-Verbose $_.Exception.Message
}
}
$archiveRoot = Join-Path $MonitoringRoot 'Archive'
Get-ChildItem -LiteralPath $archiveRoot -Filter '*.evtx' -File -ErrorAction SilentlyContinue |
Where-Object LastWriteTime -ge $Since.AddDays(-1) |
ForEach-Object {
try {
Get-WinEvent -Path $_.FullName -Oldest -ErrorAction Stop |
Where-Object { $_.Id -in $eventIds -and $_.TimeCreated -ge $Since -and $_.TimeCreated -le $Until } |
ForEach-Object { $events.Add($_) }
}
catch {
Write-Warning "Could not read archive $($_.FullName): $($_.Exception.Message)"
}
}
function Get-EventData {
param([Parameter(Mandatory)]$EventRecord)
$xml = [xml]$EventRecord.ToXml()
$data = @{}
foreach ($item in @($xml.Event.EventData.Data)) {
if ($item.Name) {
$data[[string]$item.Name] = [string]$item.'#text'
}
}
[pscustomobject]@{
Computer = [string]$xml.Event.System.Computer
Data = $data
}
}
$openSessions = @{}
$rows = [Collections.Generic.List[object]]::new()
$ignoredUsers = @('ANONYMOUS LOGON','DWM-1','DWM-2','DWM-3','LOCAL SERVICE','NETWORK SERVICE','SYSTEM','UMFD-0','UMFD-1','UMFD-2','UMFD-3')
foreach ($eventRecord in @($events | Sort-Object TimeCreated,RecordId)) {
$parsed = Get-EventData -EventRecord $eventRecord
$machine = ($parsed.Computer -split '\.')[0].ToUpperInvariant()
$data = $parsed.Data
if ($eventRecord.Id -in 6005,6006,6008) {
foreach ($key in @($openSessions.Keys | Where-Object { $_ -like "$machine|*" })) {
$session = $openSessions[$key]
$rows.Add([pscustomobject]@{
User = $session.User
Computer = $machine
StartedAt = $session.StartedAt
EndedAt = $eventRecord.TimeCreated
Duration = $eventRecord.TimeCreated - $session.StartedAt
DurationMinutes = [math]::Round(($eventRecord.TimeCreated - $session.StartedAt).TotalMinutes, 2)
LogonType = $session.LogonType
Result = 'Interrumpida por apagado o reinicio'
FailureStatus = $null
})
$openSessions.Remove($key)
}
continue
}
if ($eventRecord.Id -eq 4625) {
$failedUser = [string]$data.TargetUserName
if ($failedUser -and $failedUser -notlike '*$' -and $failedUser.ToUpperInvariant() -notin $ignoredUsers) {
$rows.Add([pscustomobject]@{
User = if ($data.TargetDomainName) { "$($data.TargetDomainName)\$failedUser" } else { $failedUser }
Computer = $machine
StartedAt = $eventRecord.TimeCreated
EndedAt = $eventRecord.TimeCreated
Duration = [timespan]::Zero
DurationMinutes = 0
LogonType = [string]$data.LogonType
Result = 'Fallida'
FailureStatus = "$($data.Status)/$($data.SubStatus)"
})
}
continue
}
if ($eventRecord.Id -eq 4624) {
$logonType = [string]$data.LogonType
$targetUser = [string]$data.TargetUserName
if ($logonType -notin @('2','10','11') -or -not $targetUser -or $targetUser -like '*$' -or
$targetUser.ToUpperInvariant() -in $ignoredUsers) {
continue
}
$logonId = [string]$data.TargetLogonId
$key = "$machine|$logonId"
$openSessions[$key] = [pscustomobject]@{
User = if ($data.TargetDomainName) { "$($data.TargetDomainName)\$targetUser" } else { $targetUser }
StartedAt = $eventRecord.TimeCreated
LogonType = $logonType
}
continue
}
if ($eventRecord.Id -in 4634,4647) {
$logonId = if ($eventRecord.Id -eq 4634) { [string]$data.TargetLogonId } else { [string]$data.SubjectLogonId }
$key = "$machine|$logonId"
if ($openSessions.ContainsKey($key)) {
$session = $openSessions[$key]
$rows.Add([pscustomobject]@{
User = $session.User
Computer = $machine
StartedAt = $session.StartedAt
EndedAt = $eventRecord.TimeCreated
Duration = $eventRecord.TimeCreated - $session.StartedAt
DurationMinutes = [math]::Round(($eventRecord.TimeCreated - $session.StartedAt).TotalMinutes, 2)
LogonType = $session.LogonType
Result = 'Completada'
FailureStatus = $null
})
$openSessions.Remove($key)
}
}
}
foreach ($key in $openSessions.Keys) {
$session = $openSessions[$key]
$machine = ($key -split '\|', 2)[0]
$rows.Add([pscustomobject]@{
User = $session.User
Computer = $machine
StartedAt = $session.StartedAt
EndedAt = $null
Duration = $Until - $session.StartedAt
DurationMinutes = [math]::Round(($Until - $session.StartedAt).TotalMinutes, 2)
LogonType = $session.LogonType
Result = 'Sesión posiblemente activa'
FailureStatus = $null
})
}
$result = @($rows | Where-Object {
(-not $UserName -or $_.User -like "*$UserName*") -and
(-not $ComputerName -or $_.Computer -like "*$ComputerName*")
} | Sort-Object StartedAt -Descending)
if ($OutputCsv) {
$resolvedOutput = [IO.Path]::GetFullPath($OutputCsv)
New-Item -ItemType Directory -Path (Split-Path $resolvedOutput -Parent) -Force | Out-Null
$result | Export-Csv -LiteralPath $resolvedOutput -NoTypeInformation -Encoding UTF8
}
$result
+18 -2
View File
@@ -348,6 +348,10 @@ 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 'Invoke-SguMonitoringMaintenance.ps1'),
(Join-Path $scriptsRoot 'Get-SguUsageReport.ps1'),
(Join-Path $scriptsRoot 'Get-SguBrokerLog.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"
@@ -606,8 +610,15 @@ else {
-RemoteAddress $privateSubnet | Out-Null -RemoteAddress $privateSubnet | 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
@@ -632,6 +643,9 @@ $validation = [ordered]@{
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()
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 +657,9 @@ $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.EventCollector -ne 'Running' -or
-not $validation.EventSubscription) {
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.'
} }
+147
View File
@@ -0,0 +1,147 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess)]
param(
[string]$CollectorFqdn = "$env:COMPUTERNAME.$env:USERDNSDOMAIN",
[string]$ComputerOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
[string]$MonitoringRoot = 'C:\ProgramData\SGU\Monitoring',
[ValidateRange(30, 730)]
[int]$RetentionDays = 183
)
$ErrorActionPreference = 'Stop'
$subscriptionId = 'SGU-Lab-Monitoring'
$maintenanceScriptName = 'Invoke-SguMonitoringMaintenance.ps1'
$reportScriptName = 'Get-SguUsageReport.ps1'
$brokerReportScriptName = 'Get-SguBrokerLog.ps1'
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated Windows PowerShell session on the domain event collector.'
}
Import-Module ActiveDirectory -ErrorAction Stop
Get-ADOrganizationalUnit -Identity $ComputerOuDn -ErrorAction Stop | Out-Null
foreach ($requiredScript in $maintenanceScriptName,$reportScriptName,$brokerReportScriptName) {
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot $requiredScript) -PathType Leaf)) {
throw "$requiredScript must be beside Install-SguDomainMonitoring.ps1."
}
}
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install the SGU domain monitoring collector')) {
Set-Service EventLog -StartupType Automatic
if ((Get-Service EventLog).Status -ne 'Running') {
Start-Service EventLog
}
& wecutil.exe quick-config /quiet
if ($LASTEXITCODE -ne 0) {
throw "wecutil quick-config failed with exit code $LASTEXITCODE."
}
Set-Service Wecsvc -StartupType Automatic
Start-Service Wecsvc
& wevtutil.exe set-log ForwardedEvents /enabled:true /maxsize:536870912 /retention:false /autobackup:false
if ($LASTEXITCODE -ne 0) {
throw "wevtutil failed to configure ForwardedEvents with exit code $LASTEXITCODE."
}
$query = @'
<QueryList>
<Query Id="0">
<Select Path="Security">*[System[(EventID=4624 or EventID=4625 or EventID=4634 or EventID=4647 or EventID=4778 or EventID=4779)]]</Select>
<Select Path="System">*[System[(EventID=12 or EventID=13 or EventID=41 or EventID=1074 or EventID=6005 or EventID=6006 or EventID=6008)]]</Select>
</Query>
</QueryList>
'@
$escapedQuery = [Security.SecurityElement]::Escape($query)
$subscriptionXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<Subscription xmlns="http://schemas.microsoft.com/2006/03/windows/events/subscription">
<SubscriptionId>$subscriptionId</SubscriptionId>
<SubscriptionType>SourceInitiated</SubscriptionType>
<Description>SGU interactive sessions, failures, reconnects, and workstation power state.</Description>
<Enabled>true</Enabled>
<Uri>http://schemas.microsoft.com/wbem/wsman/1/windows/EventLog</Uri>
<ConfigurationMode>Custom</ConfigurationMode>
<Delivery Mode="Push">
<Batching><MaxItems>5</MaxItems><MaxLatencyTime>30000</MaxLatencyTime></Batching>
<PushSettings><Heartbeat Interval="60000"/></PushSettings>
</Delivery>
<Query>$escapedQuery</Query>
<ReadExistingEvents>false</ReadExistingEvents>
<TransportName>HTTP</TransportName>
<ContentFormat>Events</ContentFormat>
<Locale Language="es-MX"/>
<LogFile>ForwardedEvents</LogFile>
<AllowedSourceDomainComputers>O:NSG:NSD:(A;;GA;;;DC)(A;;GA;;;NS)</AllowedSourceDomainComputers>
</Subscription>
"@
New-Item -ItemType Directory -Path $MonitoringRoot -Force | Out-Null
$subscriptionPath = Join-Path $MonitoringRoot 'SGU-Lab-Monitoring.xml'
[IO.File]::WriteAllText($subscriptionPath, $subscriptionXml, [Text.UTF8Encoding]::new($true))
$existingSubscriptions = @(& wecutil.exe enum-subscription 2>$null)
if ($existingSubscriptions -contains $subscriptionId) {
& wecutil.exe delete-subscription $subscriptionId
if ($LASTEXITCODE -ne 0) {
throw "Could not replace the existing $subscriptionId subscription."
}
}
& wecutil.exe create-subscription $subscriptionPath
if ($LASTEXITCODE -ne 0) {
throw "Could not create the $subscriptionId subscription."
}
foreach ($scriptName in $maintenanceScriptName,$reportScriptName,$brokerReportScriptName) {
Copy-Item -LiteralPath (Join-Path $PSScriptRoot $scriptName) `
-Destination (Join-Path $MonitoringRoot $scriptName) -Force
}
$configuration = [ordered]@{
CollectorFqdn = $CollectorFqdn
ComputerOuDn = $ComputerOuDn
RetentionDays = $RetentionDays
SubscriptionId = $subscriptionId
}
[IO.File]::WriteAllText(
(Join-Path $MonitoringRoot 'monitoring.json'),
($configuration | ConvertTo-Json),
[Text.UTF8Encoding]::new($false))
$powerShell = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
$maintenanceScript = Join-Path $MonitoringRoot $maintenanceScriptName
$inventoryAction = New-ScheduledTaskAction -Execute $powerShell -Argument (
"-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$maintenanceScript`" " +
"-MonitoringRoot `"$MonitoringRoot`" -ComputerOuDn `"$ComputerOuDn`" -RetentionDays $RetentionDays -InventoryOnly")
$inventoryTrigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
-RepetitionInterval (New-TimeSpan -Minutes 5) `
-RepetitionDuration (New-TimeSpan -Days 3650)
$taskSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes 10) -RestartCount 2 `
-RestartInterval (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName 'SGU-Monitoring-Inventory' -Action $inventoryAction `
-Trigger $inventoryTrigger -Settings $taskSettings -User 'SYSTEM' -RunLevel Highest -Force | Out-Null
$retentionAction = New-ScheduledTaskAction -Execute $powerShell -Argument (
"-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$maintenanceScript`" " +
"-MonitoringRoot `"$MonitoringRoot`" -ComputerOuDn `"$ComputerOuDn`" -RetentionDays $RetentionDays")
$retentionTrigger = New-ScheduledTaskTrigger -Daily -At '12:10 AM'
Register-ScheduledTask -TaskName 'SGU-Monitoring-Retention' -Action $retentionAction `
-Trigger $retentionTrigger -Settings $taskSettings -User 'SYSTEM' -RunLevel Highest -Force | Out-Null
& $maintenanceScript -MonitoringRoot $MonitoringRoot -ComputerOuDn $ComputerOuDn `
-RetentionDays $RetentionDays -InventoryOnly | Out-Null
}
[pscustomobject]@{
Collector = $CollectorFqdn
CollectorService = (Get-Service Wecsvc).Status.ToString()
SubscriptionId = $subscriptionId
SubscriptionEnabled = @(& wecutil.exe enum-subscription) -contains $subscriptionId
RetentionDays = $RetentionDays
InventoryTask = (Get-ScheduledTask -TaskName 'SGU-Monitoring-Inventory').State
RetentionTask = (Get-ScheduledTask -TaskName 'SGU-Monitoring-Retention').State
MachineStatusPath = Join-Path $MonitoringRoot 'Reports\machine-status.json'
UsageReportCommand = "& '$MonitoringRoot\$reportScriptName'"
BrokerLogCommand = "& '$MonitoringRoot\$brokerReportScriptName'"
}
+1
View File
@@ -28,6 +28,7 @@ $enrollmentRoot = Join-Path $env:ProgramData 'SGU\Enrollment'
$sourceScripts = @( $sourceScripts = @(
'Install-CredentialProvider.ps1', 'Install-CredentialProvider.ps1',
'Enable-LabRemoteAccess.ps1', 'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1',
'Test-SguClientEnrollment.ps1', 'Test-SguClientEnrollment.ps1',
'Repair-SguClientEnrollment.ps1' 'Repair-SguClientEnrollment.ps1'
) )
+111
View File
@@ -0,0 +1,111 @@
#Requires -Version 5.1
[CmdletBinding()]
param(
[string]$MonitoringRoot = 'C:\ProgramData\SGU\Monitoring',
[string]$ComputerOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
[ValidateRange(30, 730)]
[int]$RetentionDays = 183,
[string]$BrokerEventLogName = 'SGU Auth Broker',
[switch]$InventoryOnly
)
$ErrorActionPreference = 'Stop'
Import-Module ActiveDirectory -ErrorAction Stop
foreach ($serviceName in 'EventLog','Wecsvc') {
Set-Service -Name $serviceName -StartupType Automatic
if ((Get-Service $serviceName).Status -ne 'Running') {
Start-Service $serviceName
}
}
$archiveRoot = Join-Path $MonitoringRoot 'Archive'
$brokerArchiveRoot = Join-Path $archiveRoot 'Broker'
$reportRoot = Join-Path $MonitoringRoot 'Reports'
New-Item -ItemType Directory -Path $archiveRoot,$brokerArchiveRoot,$reportRoot -Force | Out-Null
function Test-TcpEndpoint {
param(
[Parameter(Mandatory)][string]$ComputerName,
[int]$Port = 5985,
[int]$TimeoutMilliseconds = 900
)
$client = [Net.Sockets.TcpClient]::new()
try {
$pending = $client.BeginConnect($ComputerName, $Port, $null, $null)
if (-not $pending.AsyncWaitHandle.WaitOne($TimeoutMilliseconds)) {
return $false
}
$client.EndConnect($pending)
return $true
}
catch {
return $false
}
finally {
$client.Dispose()
}
}
if (-not $InventoryOnly) {
$forwardedLog = Get-WinEvent -ListLog ForwardedEvents -ErrorAction Stop
if ($forwardedLog.RecordCount -gt 0) {
$archivePath = Join-Path $archiveRoot ("ForwardedEvents-{0:yyyyMMdd-HHmmss}.evtx" -f (Get-Date))
& wevtutil.exe clear-log ForwardedEvents "/backup:$archivePath"
if ($LASTEXITCODE -ne 0) {
throw "Could not archive ForwardedEvents; wevtutil returned exit code $LASTEXITCODE."
}
}
$brokerLog = Get-WinEvent -ListLog $BrokerEventLogName -ErrorAction SilentlyContinue
if ($brokerLog -and $brokerLog.RecordCount -gt 0) {
$brokerArchivePath = Join-Path $brokerArchiveRoot ("SguAuthBroker-{0:yyyyMMdd-HHmmss}.evtx" -f (Get-Date))
& wevtutil.exe clear-log $BrokerEventLogName "/backup:$brokerArchivePath"
if ($LASTEXITCODE -ne 0) {
throw "Could not archive $BrokerEventLogName; wevtutil returned exit code $LASTEXITCODE."
}
}
$cutoff = (Get-Date).AddDays(-$RetentionDays)
Get-ChildItem -LiteralPath $archiveRoot -Filter '*.evtx' -File -Recurse -ErrorAction SilentlyContinue |
Where-Object LastWriteTime -lt $cutoff |
ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force }
}
$computers = @(Get-ADComputer -SearchBase $ComputerOuDn -SearchScope Subtree -Filter * `
-Properties DNSHostName,IPv4Address,OperatingSystem,LastLogonDate,Enabled |
Sort-Object Name)
$inventory = @(foreach ($computer in $computers) {
$target = if ($computer.DNSHostName) { $computer.DNSHostName } else { $computer.Name }
$online = Test-TcpEndpoint -ComputerName $target
[pscustomobject]@{
ComputerName = $computer.Name
DNSHostName = $computer.DNSHostName
IPv4Address = $computer.IPv4Address
OperatingSystem = $computer.OperatingSystem
Enabled = [bool]$computer.Enabled
Status = if ($online) { 'Encendida' } else { 'Apagada o inaccesible' }
WinRMReachable = [bool]$online
LastDomainLogon = if ($computer.LastLogonDate) {
$computer.LastLogonDate.ToUniversalTime().ToString('o')
} else { $null }
CheckedAt = (Get-Date).ToUniversalTime().ToString('o')
}
})
$jsonPath = Join-Path $reportRoot 'machine-status.json'
$csvPath = Join-Path $reportRoot 'machine-status.csv'
[IO.File]::WriteAllText($jsonPath, (ConvertTo-Json -InputObject $inventory -Depth 4), [Text.UTF8Encoding]::new($false))
$inventory | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding UTF8
[pscustomobject]@{
CheckedAt = (Get-Date).ToUniversalTime().ToString('o')
ComputerCount = @($inventory).Count
OnlineCount = @($inventory | Where-Object WinRMReachable).Count
OfflineCount = @($inventory | Where-Object { -not $_.WinRMReachable }).Count
RetentionDays = $RetentionDays
StatusJson = $jsonPath
StatusCsv = $csvPath
}
+23 -2
View File
@@ -80,15 +80,17 @@ 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"
$clientZip = "$clientRoot.zip" $clientZip = "$clientRoot.zip"
$serverZip = "$serverRoot.zip" $serverZip = "$serverRoot.zip"
foreach ($target in @($clientRoot,$serverRoot,$clientZip,$serverZip)) { $linuxClientZip = "$linuxClientRoot.zip"
foreach ($target in @($clientRoot,$serverRoot,$linuxClientRoot,$clientZip,$serverZip,$linuxClientZip)) {
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 -Force | Out-Null
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')
@@ -96,6 +98,7 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguClientEnrollment.cm
-Destination (Join-Path $clientRoot 'Start-SguClientEnrollment.cmd') -Destination (Join-Path $clientRoot 'Start-SguClientEnrollment.cmd')
$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',
@@ -119,6 +122,17 @@ Write-PackageManifest -PackageRoot $clientRoot -PackageVersion $Version -Package
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 $repositoryRoot 'docs\linux-client-enrollment.md') `
-Destination (Join-Path $linuxClientRoot 'README.md')
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') `
@@ -126,6 +140,10 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguServerBootstrap.cmd
$serverScripts = @( $serverScripts = @(
'Deploy-AuthBroker.ps1', 'Deploy-AuthBroker.ps1',
'Enable-SguServerRemoteManagement.ps1', 'Enable-SguServerRemoteManagement.ps1',
'Get-SguUsageReport.ps1',
'Get-SguBrokerLog.ps1',
'Install-SguDomainMonitoring.ps1',
'Invoke-SguMonitoringMaintenance.ps1',
'New-LabCertificate.ps1', 'New-LabCertificate.ps1',
'Register-SguClientCertificate.ps1', 'Register-SguClientCertificate.ps1',
'Set-LabBrokerDns.ps1', 'Set-LabBrokerDns.ps1',
@@ -156,6 +174,7 @@ Compress-Archive -Path (Join-Path $serverRoot '*') -DestinationPath $serverZip `
$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))
) )
$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,6 +183,8 @@ $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
Checksums = $checksumsPath Checksums = $checksumsPath
+3
View File
@@ -17,6 +17,7 @@ $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 "SHA256SUMS-$Version.txt") (Join-Path $ReleaseDirectory "SHA256SUMS-$Version.txt")
) )
foreach ($assetPath in $assetPaths) { foreach ($assetPath in $assetPaths) {
@@ -100,6 +101,8 @@ 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.
- 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`.
+2
View File
@@ -9,6 +9,7 @@ $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'
$before = & $testScript $before = & $testScript
if (-not $before.IsValid) { if (-not $before.IsValid) {
@@ -32,6 +33,7 @@ if ($computer.PartOfDomain) {
& $remoteAccessScript ` & $remoteAccessScript `
-RemoteDesktopPrincipal ([string]$configuration.RemoteDesktopPrincipal) ` -RemoteDesktopPrincipal ([string]$configuration.RemoteDesktopPrincipal) `
-EnableAdministrativeFirewallGroups | Out-Null -EnableAdministrativeFirewallGroups | Out-Null
& $monitoringScript | Out-Null
} }
$verificationParams = @{} $verificationParams = @{}
+19 -2
View File
@@ -2,7 +2,8 @@
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
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
@@ -24,6 +25,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,6 +73,8 @@ $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'
$providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}' $providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}'
$policies = @( $policies = @(
@{ Key = $dataCollectionKey; Name = 'AllowTelemetry'; Type = 'DWord'; Value = 0 }, @{ Key = $dataCollectionKey; Name = 'AllowTelemetry'; Type = 'DWord'; Value = 0 },
@@ -83,7 +94,12 @@ $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 }
) )
$powerSettingIds = @( $powerSettingIds = @(
@@ -134,5 +150,6 @@ $linkEnabled = $link -and (
TargetOu = $TargetOuDn TargetOu = $TargetOuDn
LinkEnabled = [bool]$linkEnabled LinkEnabled = [bool]$linkEnabled
PolicyCount = $configuredPolicies.Count PolicyCount = $configuredPolicies.Count
EventCollector = $EventCollectorFqdn
Policies = [pscustomobject]$configuredPolicies Policies = [pscustomobject]$configuredPolicies
} }
+24
View File
@@ -0,0 +1,24 @@
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));
}
+68
View File
@@ -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,7 +7,9 @@ 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 int AccountDisabled = 0x0002; private const int AccountDisabled = 0x0002;
private const int NormalAccount = 0x0200; private const int NormalAccount = 0x0200;
@@ -26,12 +28,29 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
{ {
SemaphoreSlim gate = userLocks.GetOrAdd(identity.UserName, static _ => new SemaphoreSlim(1, 1)); SemaphoreSlim gate = userLocks.GetOrAdd(identity.UserName, static _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false); await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
try try
{ {
return await Task.Run( return await Task.Run(
() => Synchronize(identity, profile, password), () => Synchronize(identity, profile, password),
cancellationToken).ConfigureAwait(false); 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
{ {
gate.Release(); gate.Release();
@@ -100,8 +119,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 +139,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
{ {
@@ -146,10 +166,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 +195,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
} }
} }
private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user) private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user, string institutionalUser)
{ {
if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn)) if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn))
{ {
@@ -194,10 +219,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);
} }
} }
@@ -35,6 +35,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 +159,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 +189,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 +197,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);
@@ -347,6 +354,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 +362,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 +392,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 +400,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,
@@ -423,11 +435,33 @@ 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,
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,
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, role,
elapsed.ElapsedMilliseconds); elapsed.ElapsedMilliseconds);
@@ -436,6 +470,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
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, role,
@@ -487,6 +522,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,6 +530,7 @@ 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;
@@ -550,6 +587,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)
@@ -604,17 +658,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(
@@ -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>