diff --git a/README.md b/README.md index 14a64da..22d1720 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Operational documentation: - [Broker location, health, timeout, and recovery](docs/broker-operations.md) - [Windows domain join and remote-access onboarding](docs/windows-client-onboarding.md) - [Required Credential Provider client enrollment](docs/client-enrollment.md) +- [Domain monitoring, usage reports, and six-month retention](docs/monitoring.md) - [Decision: do not persist password verifiers in Redis](docs/decisions/0001-no-password-cache.md) | Prefix | Role | Default OU | diff --git a/docs/architecture.md b/docs/architecture.md index a4bf07c..78d50ef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,6 +105,21 @@ to `OU=Laboratorio`; it suppresses first-logon/privacy/diagnostic prompts, disables location, and enforces always-on display, sleep, and hibernation 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 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 diff --git a/docs/monitoring.md b/docs/monitoring.md new file mode 100644 index 0000000..94ff79b --- /dev/null +++ b/docs/monitoring.md @@ -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: + +- +- +- +- diff --git a/scripts/Deploy-AuthBroker.ps1 b/scripts/Deploy-AuthBroker.ps1 index ff5f63f..9cb99b1 100644 --- a/scripts/Deploy-AuthBroker.ps1 +++ b/scripts/Deploy-AuthBroker.ps1 @@ -46,6 +46,8 @@ param( $ErrorActionPreference = 'Stop' $serviceName = 'SGUAuthBroker' $installPath = Join-Path $env:ProgramFiles 'SGU\AuthBroker' +$brokerEventLogName = 'SGU Auth Broker' +$brokerEventSource = 'SGU.AuthBroker.Operational' $normalizedClientThumbprints = @($AllowedClientThumbprints | ForEach-Object { $_ -replace ' ', '' }) if ($normalizedClientThumbprints.Where({ $_.Length -ne 40 }).Count -gt 0) { throw 'Client certificate thumbprints must contain exactly 40 hexadecimal characters.' @@ -129,6 +131,14 @@ foreach ($file in @('SGU.AuthBroker.exe', 'SGU.AuthBroker.dll', 'appsettings.jso } $productionSettings = @{ + Logging = @{ + EventLog = @{ + LogLevel = @{ + Default = 'Information' + 'Microsoft.AspNetCore' = 'Warning' + } + } + } Kestrel = @{ Endpoints = @{ Https = @{ @@ -143,6 +153,9 @@ $productionSettings = @{ } } Broker = @{ + Diagnostics = @{ + UseDedicatedEventLog = $true + } Tls = @{ AllowedClientThumbprints = $normalizedClientThumbprints CheckCertificateRevocation = -not $DisableCertificateRevocationCheckForLab @@ -198,6 +211,18 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker $settingsJson, $utf8WithoutBom) + if ([Diagnostics.EventLog]::SourceExists($brokerEventSource)) { + $registeredLog = [Diagnostics.EventLog]::LogNameFromSourceName($brokerEventSource, '.') + if (-not $registeredLog.Equals($brokerEventLogName, [StringComparison]::OrdinalIgnoreCase)) { + throw "Event source $brokerEventSource is already registered to $registeredLog." + } + } + else { + New-EventLog -LogName $brokerEventLogName -Source $brokerEventSource + } + Limit-EventLog -LogName $brokerEventLogName -MaximumSize 268435456 ` + -OverflowAction OverwriteAsNeeded + if (-not (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)) { New-Service -Name $serviceName ` -DisplayName 'SGU Authentication Broker' ` @@ -249,4 +274,5 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker 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 } } diff --git a/scripts/Enable-SguClientMonitoring.ps1 b/scripts/Enable-SguClientMonitoring.ps1 new file mode 100644 index 0000000..595cc21 --- /dev/null +++ b/scripts/Enable-SguClientMonitoring.ps1 @@ -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' +} diff --git a/scripts/Enroll-SguDomainClient.ps1 b/scripts/Enroll-SguDomainClient.ps1 index be6d775..91609f6 100644 --- a/scripts/Enroll-SguDomainClient.ps1 +++ b/scripts/Enroll-SguDomainClient.ps1 @@ -39,7 +39,8 @@ foreach ($scriptName in @( 'Install-SguEnrollmentGuard.ps1', 'Test-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)) { 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') ` -RemoteDesktopPrincipal $RemoteDesktopPrincipal ` -EnableAdministrativeFirewallGroups | Out-Null + & (Join-Path $PSScriptRoot 'Enable-SguClientMonitoring.ps1') | Out-Null return & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') ` -RequireDomainJoined ` -RequireRemoteAccess ` diff --git a/scripts/Get-SguBrokerLog.ps1 b/scripts/Get-SguBrokerLog.ps1 new file mode 100644 index 0000000..35dfda7 --- /dev/null +++ b/scripts/Get-SguBrokerLog.ps1 @@ -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 diff --git a/scripts/Get-SguUsageReport.ps1 b/scripts/Get-SguUsageReport.ps1 new file mode 100644 index 0000000..b90034c --- /dev/null +++ b/scripts/Get-SguUsageReport.ps1 @@ -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 diff --git a/scripts/Initialize-SguDomainController.ps1 b/scripts/Initialize-SguDomainController.ps1 index 7e19439..bdb3ed0 100644 --- a/scripts/Initialize-SguDomainController.ps1 +++ b/scripts/Initialize-SguDomainController.ps1 @@ -348,6 +348,10 @@ foreach ($requiredPath in @( (Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1'), (Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1'), (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1'), + (Join-Path $scriptsRoot 'Install-SguDomainMonitoring.ps1'), + (Join-Path $scriptsRoot 'Invoke-SguMonitoringMaintenance.ps1'), + (Join-Path $scriptsRoot 'Get-SguUsageReport.ps1'), + (Join-Path $scriptsRoot 'Get-SguBrokerLog.ps1'), (Join-Path $brokerPublishPath 'SGU.AuthBroker.exe'))) { if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) { throw "The server bootstrap package is incomplete: $requiredPath" @@ -606,8 +610,15 @@ else { -RemoteAddress $privateSubnet | Out-Null } +$collectorFqdn = "$env:COMPUTERNAME.$DomainName" & (Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1') ` - -TargetOuDn $laboratoryOuDn -DomainController $env:COMPUTERNAME | Out-Null + -TargetOuDn $laboratoryOuDn ` + -DomainController $env:COMPUTERNAME ` + -EventCollectorFqdn $collectorFqdn | Out-Null +& (Join-Path $scriptsRoot 'Install-SguDomainMonitoring.ps1') ` + -CollectorFqdn $collectorFqdn ` + -ComputerOuDn $laboratoryOuDn ` + -RetentionDays 183 | Out-Null $userPolicyParameters = @{ TargetOuDn = $usersOuDn DomainController = $env:COMPUTERNAME @@ -632,6 +643,9 @@ $validation = [ordered]@{ BrokerPortListening = [bool](Get-NetTCPConnection -LocalPort 8443 -State Listen -ErrorAction SilentlyContinue) WinRM = (Get-Service WinRM).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" LaboratoryOu = $laboratoryOuDn UsersOu = $usersOuDn @@ -643,7 +657,9 @@ $validation = [ordered]@{ if ($validation.BrokerService -ne 'Running' -or -not $validation.BrokerPortListening -or $validation.WinRM -ne 'Running' -or - $validation.RemoteDesktop -ne 'Running') { + $validation.RemoteDesktop -ne 'Running' -or + $validation.EventCollector -ne 'Running' -or + -not $validation.EventSubscription) { throw 'Server finalization did not pass service validation. Review bootstrap.log and re-run the bootstrap.' } diff --git a/scripts/Install-SguDomainMonitoring.ps1 b/scripts/Install-SguDomainMonitoring.ps1 new file mode 100644 index 0000000..4828c64 --- /dev/null +++ b/scripts/Install-SguDomainMonitoring.ps1 @@ -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 = @' + + + + + + +'@ + $escapedQuery = [Security.SecurityElement]::Escape($query) + $subscriptionXml = @" + + + $subscriptionId + SourceInitiated + SGU interactive sessions, failures, reconnects, and workstation power state. + true + http://schemas.microsoft.com/wbem/wsman/1/windows/EventLog + Custom + + 530000 + + + $escapedQuery + false + HTTP + Events + + ForwardedEvents + O:NSG:NSD:(A;;GA;;;DC)(A;;GA;;;NS) + +"@ + + 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'" +} diff --git a/scripts/Install-SguEnrollmentGuard.ps1 b/scripts/Install-SguEnrollmentGuard.ps1 index 104e242..45fbc31 100644 --- a/scripts/Install-SguEnrollmentGuard.ps1 +++ b/scripts/Install-SguEnrollmentGuard.ps1 @@ -28,6 +28,7 @@ $enrollmentRoot = Join-Path $env:ProgramData 'SGU\Enrollment' $sourceScripts = @( 'Install-CredentialProvider.ps1', 'Enable-LabRemoteAccess.ps1', + 'Enable-SguClientMonitoring.ps1', 'Test-SguClientEnrollment.ps1', 'Repair-SguClientEnrollment.ps1' ) diff --git a/scripts/Invoke-SguMonitoringMaintenance.ps1 b/scripts/Invoke-SguMonitoringMaintenance.ps1 new file mode 100644 index 0000000..5e5b22e --- /dev/null +++ b/scripts/Invoke-SguMonitoringMaintenance.ps1 @@ -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 +} diff --git a/scripts/New-SguBootstrapPackages.ps1 b/scripts/New-SguBootstrapPackages.ps1 index abb9b3e..901c5e3 100644 --- a/scripts/New-SguBootstrapPackages.ps1 +++ b/scripts/New-SguBootstrapPackages.ps1 @@ -96,6 +96,7 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguClientEnrollment.cm -Destination (Join-Path $clientRoot 'Start-SguClientEnrollment.cmd') $clientScripts = @( 'Enable-LabRemoteAccess.ps1', + 'Enable-SguClientMonitoring.ps1', 'Enroll-SguDomainClient.ps1', 'Install-CredentialProvider.ps1', 'Install-SguEnrollmentGuard.ps1', @@ -126,6 +127,10 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguServerBootstrap.cmd $serverScripts = @( 'Deploy-AuthBroker.ps1', 'Enable-SguServerRemoteManagement.ps1', + 'Get-SguUsageReport.ps1', + 'Get-SguBrokerLog.ps1', + 'Install-SguDomainMonitoring.ps1', + 'Invoke-SguMonitoringMaintenance.ps1', 'New-LabCertificate.ps1', 'Register-SguClientCertificate.ps1', 'Set-LabBrokerDns.ps1', diff --git a/scripts/Publish-GiteaRelease.ps1 b/scripts/Publish-GiteaRelease.ps1 index f028901..70b415a 100644 --- a/scripts/Publish-GiteaRelease.ps1 +++ b/scripts/Publish-GiteaRelease.ps1 @@ -100,6 +100,7 @@ Bootstrap reproducible para el laboratorio SGU. - **Advertencia:** el bootstrap de servidor crea un bosque nuevo. No restaura los SID, contraseñas ni relaciones de confianza del bosque anterior; para conservarlos se requiere una recuperación de bosque desde una copia de estado del sistema. - `sgu-server-bootstrap-$Version.zip`: crea el bosque AD/DNS, OUs, grupo RDP, GPO, recurso `Packages`, broker mTLS y administración remota; se reanuda solo después del reinicio. - `sgu-client-bootstrap-$Version.zip`: registra un certificado mTLS único, instala y valida el Credential Provider antes de unir el equipo al dominio, habilita RDP/WinRM y se repara al arranque. +- El servidor configura WEF/WEC para registrar sesiones y fallos, inventariar el estado alcanzable de las máquinas cada cinco minutos y conservar durante 183 días tanto esos eventos como el diagnóstico estructurado del Auth Broker. - Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host. Las contraseñas se solicitan de forma interactiva y no se escriben en archivos ni en la línea de comandos. Verifique los ZIP con `SHA256SUMS-$Version.txt`. diff --git a/scripts/Repair-SguClientEnrollment.ps1 b/scripts/Repair-SguClientEnrollment.ps1 index efe0c5e..6197621 100644 --- a/scripts/Repair-SguClientEnrollment.ps1 +++ b/scripts/Repair-SguClientEnrollment.ps1 @@ -9,6 +9,7 @@ $enrollmentRoot = Split-Path $ConfigurationPath -Parent $testScript = Join-Path $enrollmentRoot 'Test-SguClientEnrollment.ps1' $installScript = Join-Path $enrollmentRoot 'Install-CredentialProvider.ps1' $remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1' +$monitoringScript = Join-Path $enrollmentRoot 'Enable-SguClientMonitoring.ps1' $before = & $testScript if (-not $before.IsValid) { @@ -32,6 +33,7 @@ if ($computer.PartOfDomain) { & $remoteAccessScript ` -RemoteDesktopPrincipal ([string]$configuration.RemoteDesktopPrincipal) ` -EnableAdministrativeFirewallGroups | Out-Null + & $monitoringScript | Out-Null } $verificationParams = @{} diff --git a/scripts/Set-SguDomainComputerPolicies.ps1 b/scripts/Set-SguDomainComputerPolicies.ps1 index d947007..0887cf4 100644 --- a/scripts/Set-SguDomainComputerPolicies.ps1 +++ b/scripts/Set-SguDomainComputerPolicies.ps1 @@ -2,7 +2,8 @@ param( [string]$TargetOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx', [string]$GpoName = 'SGU - Windows client experience', - [string]$DomainController = $env:COMPUTERNAME + [string]$DomainController = $env:COMPUTERNAME, + [string]$EventCollectorFqdn ) $ErrorActionPreference = 'Stop' @@ -24,6 +25,14 @@ if (-not $domainDn) { throw 'TargetOuDn does not contain a domain distinguished name.' } $domainName = ($domainDn -replace ',DC=', '.') +if (-not $EventCollectorFqdn) { + $collectorComputer = Get-ADComputer -Identity $DomainController -Properties DNSHostName ` + -Server $DomainController -ErrorAction Stop + $EventCollectorFqdn = $collectorComputer.DNSHostName +} +if (-not $EventCollectorFqdn) { + throw 'Could not determine the event collector FQDN.' +} $gpo = Get-GPO -Name $GpoName -Domain $domainName -Server $DomainController -ErrorAction SilentlyContinue if (-not $gpo -and $PSCmdlet.ShouldProcess($GpoName, 'Create the SGU Windows client policy GPO')) { @@ -64,6 +73,8 @@ $powerPolicyRoot = 'HKLM\Software\Policies\Microsoft\Power\PowerSettings' $credentialProviderPolicyKey = 'HKLM\Software\Policies\Microsoft\Windows\System' $interactiveLogonPolicyKey = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System' $accountPicturePolicyKey = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer' +$eventForwardingPolicyKey = 'HKLM\Software\Policies\Microsoft\Windows\EventLog\EventForwarding\SubscriptionManager' +$auditPolicyKey = 'HKLM\System\CurrentControlSet\Control\Lsa' $providerClassId = '{D789CFD8-5AD4-489F-9B83-7EB5D9D09335}' $policies = @( @{ Key = $dataCollectionKey; Name = 'AllowTelemetry'; Type = 'DWord'; Value = 0 }, @@ -83,7 +94,12 @@ $policies = @( # Use Windows' native default account image for named user tiles. LogonUI # retains ownership of the anonymous Other user tile and its circular mask. - @{ Key = $accountPicturePolicyKey; Name = 'UseDefaultTile'; Type = 'DWord'; Value = 1 } + @{ 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 = @( @@ -134,5 +150,6 @@ $linkEnabled = $link -and ( TargetOu = $TargetOuDn LinkEnabled = [bool]$linkEnabled PolicyCount = $configuredPolicies.Count + EventCollector = $EventCollectorFqdn Policies = [pscustomobject]$configuredPolicies } diff --git a/src/SGU.AuthBroker/BrokerEventIds.cs b/src/SGU.AuthBroker/BrokerEventIds.cs new file mode 100644 index 0000000..6b279b5 --- /dev/null +++ b/src/SGU.AuthBroker/BrokerEventIds.cs @@ -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)); +} diff --git a/src/SGU.AuthBroker/Program.cs b/src/SGU.AuthBroker/Program.cs index 6db45c9..cd4b9f4 100644 --- a/src/SGU.AuthBroker/Program.cs +++ b/src/SGU.AuthBroker/Program.cs @@ -1,7 +1,9 @@ +using System.Diagnostics; using System.Net.Security; using System.Security.Cryptography.X509Certificates; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Server.Kestrel.Https; +using SGU.AuthBroker; using SGU.AuthBroker.Contracts; using SGU.AuthBroker.Core.Authentication; using SGU.AuthBroker.Core.Directory; @@ -10,6 +12,16 @@ using SGU.AuthBroker.Services; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.Host.UseWindowsService(options => options.ServiceName = "SGU Authentication Broker"); +if (builder.Configuration.GetValue("Broker:Diagnostics:UseDedicatedEventLog", false)) +{ + builder.Logging.ClearProviders(); + builder.Logging.AddEventLog(settings => + { + settings.LogName = "SGU Auth Broker"; + settings.SourceName = "SGU.AuthBroker.Operational"; + settings.Filter = (_, level) => level >= LogLevel.Information; + }); +} BrokerOptions brokerOptions = builder.Configuration .GetSection(BrokerOptions.SectionName) @@ -56,6 +68,12 @@ builder.Services.AddRateLimiter(options => }); WebApplication app = builder.Build(); +ILogger auditLogger = app.Services.GetRequiredService() + .CreateLogger("SGU.AuthBroker.Audit"); +auditLogger.LogInformation( + BrokerEventIds.BrokerStarted, + "SGU Authentication Broker started with dedicated operational diagnostics enabled={DedicatedDiagnosticsEnabled}.", + builder.Configuration.GetValue("Broker:Diagnostics:UseDedicatedEventLog", false)); app.UseRateLimiter(); app.Use(async (context, next) => { @@ -75,16 +93,58 @@ app.MapPost("/v1/authenticate", async ( { if (string.IsNullOrWhiteSpace(request.Password) || request.Password.Length > 256) { + auditLogger.LogInformation( + BrokerEventIds.AuthenticationInvalidRequest, + "Authentication request rejected before validation for {InstitutionalUser}: password was missing or outside the supported length.", + SafeUserName(request.Clave)); request.ReleasePasswordReference(); return Results.BadRequest(new ErrorResponse("MISSING_PASSWORD", "La contraseña es requerida.")); } + Stopwatch elapsed = Stopwatch.StartNew(); try { AuthenticationFlowResult result = await workflow .AuthenticateAsync(request.Clave, request.Password, cancellationToken) .ConfigureAwait(false); + string institutionalUser = result.Identity?.UserName ?? SafeUserName(request.Clave); + switch (result.Outcome) + { + case AuthenticationFlowOutcome.Authorized: + auditLogger.LogInformation( + BrokerEventIds.AuthenticationAuthorized, + "Authentication completed for {InstitutionalUser} with role {Role} in {ElapsedMilliseconds} ms. AD created={Created}; moved={Moved}.", + institutionalUser, + result.Identity!.Role, + elapsed.ElapsedMilliseconds, + result.Directory!.Created, + result.Directory.Moved); + break; + case AuthenticationFlowOutcome.InvalidCredentials: + auditLogger.LogInformation( + BrokerEventIds.AuthenticationRejected, + "Authentication was rejected for {InstitutionalUser} with code {ErrorCode} after {ElapsedMilliseconds} ms.", + institutionalUser, + result.ErrorCode, + elapsed.ElapsedMilliseconds); + break; + case AuthenticationFlowOutcome.Unavailable: + auditLogger.LogWarning( + BrokerEventIds.AuthenticationUnavailable, + "Authentication was unavailable for {InstitutionalUser} with code {ErrorCode} after {ElapsedMilliseconds} ms.", + institutionalUser, + result.ErrorCode, + elapsed.ElapsedMilliseconds); + break; + default: + auditLogger.LogInformation( + BrokerEventIds.AuthenticationInvalidRequest, + "Authentication request had an invalid institutional user format after {ElapsedMilliseconds} ms.", + elapsed.ElapsedMilliseconds); + break; + } + return result.Outcome switch { AuthenticationFlowOutcome.Authorized => Results.Ok(new AuthenticationResponse( @@ -125,3 +185,11 @@ static IResult Unavailable(HttpContext context, string? errorCode) static string NormalizeThumbprint(string value) => value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant(); + +static string SafeUserName(string? value) +{ + string candidate = value?.Trim().ToUpperInvariant() ?? string.Empty; + return candidate.Length is > 0 and <= 16 && candidate.All(char.IsAsciiLetterOrDigit) + ? candidate + : ""; +} diff --git a/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs b/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs index 98e0107..4c19c52 100644 --- a/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs +++ b/src/SGU.AuthBroker/Services/ActiveDirectorySynchronizer.cs @@ -7,7 +7,9 @@ using SGU.AuthBroker.Options; namespace SGU.AuthBroker.Services; -public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActiveDirectorySynchronizer +public sealed class ActiveDirectorySynchronizer( + BrokerOptions options, + ILogger logger) : IActiveDirectorySynchronizer { private const int AccountDisabled = 0x0002; private const int NormalAccount = 0x0200; @@ -28,9 +30,26 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive await gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { - return await Task.Run( - () => Synchronize(identity, profile, password), - cancellationToken).ConfigureAwait(false); + try + { + return await Task.Run( + () => Synchronize(identity, profile, password), + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + logger.LogError( + BrokerEventIds.DirectorySynchronizationFailure, + exception, + "Active Directory synchronization failed for {InstitutionalUser} with role {Role}.", + identity.UserName, + identity.Role); + throw; + } } finally { @@ -100,8 +119,8 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive user.Properties["pwdLastSet"].Value = -1; user.CommitChanges(); - TryApplyProfile(user, identity, profile, options.DefaultCompany); - TryEnsureRemoteDesktopGroupMembership(user); + TryApplyProfile(user, identity, profile, options.DefaultCompany, logger); + TryEnsureRemoteDesktopGroupMembership(user, identity.UserName); return new DirectorySyncResult( options.DomainNetbios, @@ -120,7 +139,8 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive DirectoryEntry user, UserIdentity identity, InstitutionalProfile? profile, - string defaultCompany) + string defaultCompany, + ILogger logger) { try { @@ -146,10 +166,15 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive user.CommitChanges(); } - catch + catch (Exception exception) { // Metadata is intentionally best-effort. User creation, password sync, // and account enablement have already committed successfully. + logger.LogWarning( + BrokerEventIds.DirectoryOptionalMetadataFailure, + exception, + "Optional Active Directory profile metadata could not be committed for {InstitutionalUser}; password synchronization remains completed.", + identity.UserName); try { user.RefreshCache(); @@ -170,7 +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)) { @@ -194,10 +219,15 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive group.CommitChanges(); } } - catch + catch (Exception exception) { // Remote access is lab policy and must not invalidate a completed // password synchronization if the optional group is unavailable. + logger.LogWarning( + BrokerEventIds.DirectoryGroupMembershipFailure, + exception, + "Optional remote-desktop group membership could not be updated for {InstitutionalUser}; password synchronization remains completed.", + institutionalUser); } } diff --git a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs index 46fbe39..7191ce5 100644 --- a/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs +++ b/src/SGU.AuthBroker/Services/NtlmCredentialValidator.cs @@ -35,6 +35,10 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator string password, CancellationToken cancellationToken) { + using IDisposable? logScope = logger.BeginScope( + "InstitutionalUser={InstitutionalUser}; InstitutionalRole={InstitutionalRole}", + identity.UserName, + identity.Role); Uri authenticationUri = new( new Uri(options.Endpoint, UriKind.Absolute), options.AuthenticationPath); @@ -155,6 +159,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator } logger.LogInformation( + BrokerEventIds.SguAuthenticationAccepted, "SGU accepted credentials after an explicit NTLM challenge in {ElapsedMilliseconds} ms.", elapsed.ElapsedMilliseconds); return (null, continuationUri); @@ -184,6 +189,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { logger.LogWarning( + BrokerEventIds.SguAuthenticationTimeout, "SGU NTLM authentication timed out after {ElapsedMilliseconds} ms.", elapsed.ElapsedMilliseconds); return (NtlmValidationResult.Unavailable("NTLM_TIMEOUT"), null); @@ -191,6 +197,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator catch (HttpRequestException exception) { logger.LogWarning( + BrokerEventIds.SguAuthenticationNetworkFailure, exception, "SGU NTLM authentication failed after {ElapsedMilliseconds} ms.", elapsed.ElapsedMilliseconds); @@ -347,6 +354,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator if (profile is null) { logger.LogWarning( + BrokerEventIds.ProfileHtmlUnexpected, "SGU returned a profile page for role {Role}, but no supported profile fields were found after {ElapsedMilliseconds} ms.", identity.Role, elapsed.ElapsedMilliseconds); @@ -354,8 +362,10 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator else { logger.LogInformation( - "SGU profile enrichment completed for role {Role} in {ElapsedMilliseconds} ms.", + BrokerEventIds.ProfileEnrichmentCompleted, + "SGU profile enrichment completed for role {Role} with {ProfileFieldCount} supported fields in {ElapsedMilliseconds} ms.", identity.Role, + CountProfileFields(profile), elapsed.ElapsedMilliseconds); } @@ -382,6 +392,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator catch (OperationCanceledException) { logger.LogWarning( + BrokerEventIds.ProfileEnrichmentTimeout, "SGU profile request for role {Role} timed out after {ElapsedMilliseconds} ms.", identity.Role, elapsed.ElapsedMilliseconds); @@ -389,6 +400,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator catch (Exception exception) { logger.LogWarning( + BrokerEventIds.ProfileEnrichmentFailure, exception, "SGU profile enrichment failed for role {Role} after {ElapsedMilliseconds} ms.", identity.Role, @@ -423,11 +435,33 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator GetProfileUri(path), allowedHosts, timeoutToken).ConfigureAwait(false); - profile = profile.Overlay(html is null ? null : parser(html)); + if (html is null) + { + logger.LogWarning( + BrokerEventIds.ProfilePageUnavailable, + "Optional SGU profile page {Path} did not return usable HTML for role {Role}; preserving fields already collected.", + path, + 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) { logger.LogWarning( + BrokerEventIds.ProfileEnrichmentTimeout, "SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.", role, elapsed.ElapsedMilliseconds); @@ -436,6 +470,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator catch (Exception exception) { logger.LogWarning( + BrokerEventIds.ProfileEnrichmentFailure, exception, "An optional SGU staff profile page for role {Role} failed after {ElapsedMilliseconds} ms; preserving fields already collected.", role, @@ -487,6 +522,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator } logger.LogWarning( + BrokerEventIds.ProfilePageUnavailable, "Optional SGU profile page {Path} returned HTTP {StatusCode}.", requestedUri.AbsolutePath, statusCode); @@ -494,6 +530,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator } logger.LogWarning( + BrokerEventIds.ProfilePageUnavailable, "Optional SGU profile page {Path} exceeded the redirect limit.", requestedUri.AbsolutePath); return null; @@ -550,6 +587,23 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator right.AbsolutePath.TrimEnd('/'), StringComparison.OrdinalIgnoreCase); + private static int CountProfileFields(InstitutionalProfile profile) => + new[] + { + profile.EmployeeNumber, + profile.DisplayName, + profile.GivenName, + profile.Surname, + profile.Email, + profile.EmployeeType, + profile.JobTitle, + profile.Department, + profile.StreetAddress, + profile.City, + profile.State, + profile.PostalCode + }.Count(value => !string.IsNullOrWhiteSpace(value)); + private static async Task DrainResponseAsync( HttpResponseMessage response, CancellationToken cancellationToken) @@ -604,17 +658,31 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator response.Content, options.MaxProfileBytes, timeoutToken).ConfigureAwait(false); - return identity.Role switch + InstitutionalProfile? profile; + switch (identity.Role) { - InstitutionalRole.Administrative => - SguProfileParser.ParseAdministrative(html, identity.NumericId) ?? - SguProfileParser.ParseMenu(html), - InstitutionalRole.Student => - SguProfileParser.ParseStudent(html, identity.NumericId) ?? - SguProfileParser.ParseMenu(html), - InstitutionalRole.Professor => SguProfileParser.ParseMenu(html), - _ => null - }; + case InstitutionalRole.Administrative: + profile = SguProfileParser.ParseAdministrative(html, identity.NumericId); + break; + case InstitutionalRole.Student: + profile = SguProfileParser.ParseStudent(html, identity.NumericId); + break; + case InstitutionalRole.Professor: + return SguProfileParser.ParseMenu(html); + default: + return null; + } + + if (profile is not null) + { + return profile; + } + + logger.LogWarning( + BrokerEventIds.ProfileHtmlUnexpected, + "The primary SGU profile HTML did not contain the supported field IDs for role {Role}; attempting the menu-name fallback.", + identity.Role); + return SguProfileParser.ParseMenu(html); } private static async Task ReadLimitedStringAsync(