Compare commits

..
1 Commits
Author SHA1 Message Date
alexrg dcbf5e87e3 Add six-month domain and broker monitoring 2026-09-04 16:57:34 -06:00
20 changed files with 998 additions and 28 deletions
+1
View File
@@ -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 |
+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
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
+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'
$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 } }
+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',
'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 `
+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-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.'
}
+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 = @(
'Install-CredentialProvider.ps1',
'Enable-LabRemoteAccess.ps1',
'Enable-SguClientMonitoring.ps1',
'Test-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
}
+5
View File
@@ -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',
+1
View File
@@ -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`.
+2
View File
@@ -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 = @{}
+19 -2
View File
@@ -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
}
+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.Security.Cryptography.X509Certificates;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using SGU.AuthBroker;
using SGU.AuthBroker.Contracts;
using SGU.AuthBroker.Core.Authentication;
using SGU.AuthBroker.Core.Directory;
@@ -10,6 +12,16 @@ using SGU.AuthBroker.Services;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Host.UseWindowsService(options => options.ServiceName = "SGU Authentication Broker");
if (builder.Configuration.GetValue("Broker:Diagnostics:UseDedicatedEventLog", false))
{
builder.Logging.ClearProviders();
builder.Logging.AddEventLog(settings =>
{
settings.LogName = "SGU Auth Broker";
settings.SourceName = "SGU.AuthBroker.Operational";
settings.Filter = (_, level) => level >= LogLevel.Information;
});
}
BrokerOptions brokerOptions = builder.Configuration
.GetSection(BrokerOptions.SectionName)
@@ -56,6 +68,12 @@ builder.Services.AddRateLimiter(options =>
});
WebApplication app = builder.Build();
ILogger auditLogger = app.Services.GetRequiredService<ILoggerFactory>()
.CreateLogger("SGU.AuthBroker.Audit");
auditLogger.LogInformation(
BrokerEventIds.BrokerStarted,
"SGU Authentication Broker started with dedicated operational diagnostics enabled={DedicatedDiagnosticsEnabled}.",
builder.Configuration.GetValue("Broker:Diagnostics:UseDedicatedEventLog", false));
app.UseRateLimiter();
app.Use(async (context, next) =>
{
@@ -75,16 +93,58 @@ app.MapPost("/v1/authenticate", async (
{
if (string.IsNullOrWhiteSpace(request.Password) || request.Password.Length > 256)
{
auditLogger.LogInformation(
BrokerEventIds.AuthenticationInvalidRequest,
"Authentication request rejected before validation for {InstitutionalUser}: password was missing or outside the supported length.",
SafeUserName(request.Clave));
request.ReleasePasswordReference();
return Results.BadRequest(new ErrorResponse("MISSING_PASSWORD", "La contraseña es requerida."));
}
Stopwatch elapsed = Stopwatch.StartNew();
try
{
AuthenticationFlowResult result = await workflow
.AuthenticateAsync(request.Clave, request.Password, cancellationToken)
.ConfigureAwait(false);
string institutionalUser = result.Identity?.UserName ?? SafeUserName(request.Clave);
switch (result.Outcome)
{
case AuthenticationFlowOutcome.Authorized:
auditLogger.LogInformation(
BrokerEventIds.AuthenticationAuthorized,
"Authentication completed for {InstitutionalUser} with role {Role} in {ElapsedMilliseconds} ms. AD created={Created}; moved={Moved}.",
institutionalUser,
result.Identity!.Role,
elapsed.ElapsedMilliseconds,
result.Directory!.Created,
result.Directory.Moved);
break;
case AuthenticationFlowOutcome.InvalidCredentials:
auditLogger.LogInformation(
BrokerEventIds.AuthenticationRejected,
"Authentication was rejected for {InstitutionalUser} with code {ErrorCode} after {ElapsedMilliseconds} ms.",
institutionalUser,
result.ErrorCode,
elapsed.ElapsedMilliseconds);
break;
case AuthenticationFlowOutcome.Unavailable:
auditLogger.LogWarning(
BrokerEventIds.AuthenticationUnavailable,
"Authentication was unavailable for {InstitutionalUser} with code {ErrorCode} after {ElapsedMilliseconds} ms.",
institutionalUser,
result.ErrorCode,
elapsed.ElapsedMilliseconds);
break;
default:
auditLogger.LogInformation(
BrokerEventIds.AuthenticationInvalidRequest,
"Authentication request had an invalid institutional user format after {ElapsedMilliseconds} ms.",
elapsed.ElapsedMilliseconds);
break;
}
return result.Outcome switch
{
AuthenticationFlowOutcome.Authorized => Results.Ok(new AuthenticationResponse(
@@ -125,3 +185,11 @@ static IResult Unavailable(HttpContext context, string? errorCode)
static string NormalizeThumbprint(string value) =>
value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant();
static string SafeUserName(string? value)
{
string candidate = value?.Trim().ToUpperInvariant() ?? string.Empty;
return candidate.Length is > 0 and <= 16 && candidate.All(char.IsAsciiLetterOrDigit)
? candidate
: "<invalid-format>";
}
@@ -7,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<ActiveDirectorySynchronizer> 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);
}
}
@@ -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<string> ReadLimitedStringAsync(