Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcbf5e87e3 | ||
|
|
f2a40f051b | ||
|
|
17c8960cdd | ||
|
|
b5f526e244 | ||
|
|
b3e40fabb5 | ||
|
|
a850b56a02 | ||
|
|
ac531db05e | ||
|
|
c8572eb8d4 | ||
|
|
57572c5567 | ||
|
|
1d7c312a67 |
@@ -8,6 +8,9 @@ The repository starts from the current
|
|||||||
source and adds an SGU-specific provider, an mTLS-protected broker, Active
|
source and adds an SGU-specific provider, an mTLS-protected broker, Active
|
||||||
Directory synchronization, deployment scripts, and tests.
|
Directory synchronization, deployment scripts, and tests.
|
||||||
|
|
||||||
|
Ready-to-run bootstrap packages are published on the
|
||||||
|
[releases page](https://github.lci.ulsa.mx/alexrg/SGU-CredentialProvider/releases).
|
||||||
|
|
||||||
## Authentication contract
|
## Authentication contract
|
||||||
|
|
||||||
1. The Windows tile collects a `DO`, `AL`, or `AD` institutional key and a password.
|
1. The Windows tile collects a `DO`, `AL`, or `AD` institutional key and a password.
|
||||||
@@ -47,6 +50,7 @@ Operational documentation:
|
|||||||
- [Broker location, health, timeout, and recovery](docs/broker-operations.md)
|
- [Broker location, health, timeout, and recovery](docs/broker-operations.md)
|
||||||
- [Windows domain join and remote-access onboarding](docs/windows-client-onboarding.md)
|
- [Windows domain join and remote-access onboarding](docs/windows-client-onboarding.md)
|
||||||
- [Required Credential Provider client enrollment](docs/client-enrollment.md)
|
- [Required Credential Provider client enrollment](docs/client-enrollment.md)
|
||||||
|
- [Domain monitoring, usage reports, and six-month retention](docs/monitoring.md)
|
||||||
- [Decision: do not persist password verifiers in Redis](docs/decisions/0001-no-password-cache.md)
|
- [Decision: do not persist password verifiers in Redis](docs/decisions/0001-no-password-cache.md)
|
||||||
|
|
||||||
| Prefix | Role | Default OU |
|
| Prefix | Role | Default OU |
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
+19
-1
@@ -85,7 +85,10 @@ The generic SGU credential is rendered as a dedicated branded tile instead of
|
|||||||
being grouped below the anonymous **Other user** tile. Machine policy assigns
|
being grouped below the anonymous **Other user** tile. Machine policy assigns
|
||||||
the SGU CLSID as the default provider, hides the last signed-in identity, and
|
the SGU CLSID as the default provider, hides the last signed-in identity, and
|
||||||
disables local-user enumeration while retaining the built-in Microsoft password
|
disables local-user enumeration while retaining the built-in Microsoft password
|
||||||
provider and its **Other user** recovery path. It enumerates one
|
provider and its **Other user** recovery path. The computer GPO also applies
|
||||||
|
Windows' native default account picture to named Windows accounts; client
|
||||||
|
enrollment installs the La Salle mascot bitmap in Windows' standard account-picture
|
||||||
|
location before that GPO takes effect. It enumerates one
|
||||||
`CPFT_TILE_IMAGE` and places the `CPFT_LARGE_TEXT` heading immediately after it
|
`CPFT_TILE_IMAGE` and places the `CPFT_LARGE_TEXT` heading immediately after it
|
||||||
with `CPFS_DISPLAY_IN_SELECTED_TILE`. LogonUI owns field typography and vertical
|
with `CPFS_DISPLAY_IN_SELECTED_TILE`. LogonUI owns field typography and vertical
|
||||||
tile order: on Windows 10 and 11, the account-name title used by **Other user**
|
tile order: on Windows 10 and 11, the account-name title used by **Other user**
|
||||||
@@ -102,6 +105,21 @@ to `OU=Laboratorio`; it suppresses first-logon/privacy/diagnostic prompts,
|
|||||||
disables location, and enforces always-on display, sleep, and hibernation
|
disables location, and enforces always-on display, sleep, and hibernation
|
||||||
settings for managed clients.
|
settings for managed clients.
|
||||||
|
|
||||||
|
The domain controller is also the source-initiated Windows Event Collector for
|
||||||
|
managed laboratory computers. Kerberos-authenticated WEF sends only selected
|
||||||
|
logon/logoff, failed-logon, reconnect/disconnect, and operating-system power
|
||||||
|
events to `ForwardedEvents`. Daily EVTX archives are retained for 183 days, and
|
||||||
|
a five-minute server-side inventory records WinRM reachability and AD last-logon
|
||||||
|
metadata. Session-duration reports correlate Windows logon IDs; no password or
|
||||||
|
SGU HTTP payload is included in this monitoring path.
|
||||||
|
|
||||||
|
Broker diagnostics use the dedicated `SGU Auth Broker` Windows log with stable
|
||||||
|
event IDs for authorization outcomes, SGU network/timeout failures, unexpected
|
||||||
|
profile HTML, partial enrichment, and AD synchronization warnings. The same
|
||||||
|
daily maintenance task archives that log for 183 days. Messages identify the
|
||||||
|
institutional user and role but never include passwords, password verifiers, or
|
||||||
|
raw SGU HTML.
|
||||||
|
|
||||||
Per-user synchronization is serialized inside the broker to prevent concurrent
|
Per-user synchronization is serialized inside the broker to prevent concurrent
|
||||||
create/reset races. Production deployments should run the broker as a gMSA with
|
create/reset races. Production deployments should run the broker as a gMSA with
|
||||||
delegated create-user, move-user, write-property, enable-account, and reset-password
|
delegated create-user, move-user, write-property, enable-account, and reset-password
|
||||||
|
|||||||
@@ -52,10 +52,17 @@ El proceso crea o configura de forma idempotente:
|
|||||||
- GPO de experiencia del equipo y restricciones de sesión SGU;
|
- GPO de experiencia del equipo y restricciones de sesión SGU;
|
||||||
- certificado de servidor no exportable y broker mTLS en TCP 8443;
|
- certificado de servidor no exportable y broker mTLS en TCP 8443;
|
||||||
- recurso `\\SERVIDOR\Packages`, con lectura para Domain Computers;
|
- recurso `\\SERVIDOR\Packages`, con lectura para Domain Computers;
|
||||||
- RDP con NLA, WinRM/PowerShell Remoting y reglas administrativas sólo en el
|
- RDP con NLA, WinRM/PowerShell Remoting y reglas administrativas limitadas a
|
||||||
perfil Domain;
|
la subred privada indicada, incluso si Windows tarda en reconocer el perfil
|
||||||
|
Domain después de la promoción;
|
||||||
- pantalla, suspensión e hibernación en Nunca.
|
- pantalla, suspensión e hibernación en Nunca.
|
||||||
|
|
||||||
|
En un servidor con dos NIC, el bootstrap desactiva el registro DNS de la NIC de
|
||||||
|
Internet y obliga al servicio DNS a escuchar y publicar únicamente la IP fija
|
||||||
|
privada. También vuelve a iniciar brevemente esa NIC privada si Windows Server
|
||||||
|
2025 todavía la clasifica como Public al terminar la promoción. La salida HTTPS
|
||||||
|
continúa por la NIC que tenga el gateway predeterminado.
|
||||||
|
|
||||||
El broker arranca con una lista de clientes vacía. Eso no abre el servicio: mTLS
|
El broker arranca con una lista de clientes vacía. Eso no abre el servicio: mTLS
|
||||||
rechaza todos los certificados hasta que el primer cliente registra el suyo.
|
rechaza todos los certificados hasta que el primer cliente registra el suyo.
|
||||||
Los archivos opcionales colocados en `payload\server-content\Packages` al crear
|
Los archivos opcionales colocados en `payload\server-content\Packages` al crear
|
||||||
@@ -129,8 +136,8 @@ validaciones.
|
|||||||
Desde el repositorio y con el SDK fijado en `global.json`:
|
Desde el repositorio y con el SDK fijado en `global.json`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\scripts\New-SguBootstrapPackages.ps1 -Version 0.1.0
|
.\scripts\New-SguBootstrapPackages.ps1 -Version 0.1.1
|
||||||
.\scripts\Publish-GiteaRelease.ps1 -Version 0.1.0
|
.\scripts\Publish-GiteaRelease.ps1 -Version 0.1.1
|
||||||
```
|
```
|
||||||
|
|
||||||
El segundo comando usa `GITEA_TOKEN` sólo en memoria o, si no está definido,
|
El segundo comando usa `GITEA_TOKEN` sólo en memoria o, si no está definido,
|
||||||
@@ -139,7 +146,7 @@ la línea de comandos. Para empaquetar recursos institucionales adicionales:
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\scripts\New-SguBootstrapPackages.ps1 `
|
.\scripts\New-SguBootstrapPackages.ps1 `
|
||||||
-Version 0.1.0 `
|
-Version 0.1.1 `
|
||||||
-ServerContentPath C:\Preparacion\Packages
|
-ServerContentPath C:\Preparacion\Packages
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -36,6 +36,9 @@ param(
|
|||||||
[int]$NtlmTimeoutSeconds = 20,
|
[int]$NtlmTimeoutSeconds = 20,
|
||||||
[ValidateRange(2, 90)]
|
[ValidateRange(2, 90)]
|
||||||
[int]$ProfileTimeoutSeconds = 90,
|
[int]$ProfileTimeoutSeconds = 90,
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string[]]$FirewallRemoteAddress = @('LocalSubnet'),
|
||||||
|
[ipaddress]$FirewallLocalAddress,
|
||||||
[switch]$CreateMissingOus,
|
[switch]$CreateMissingOus,
|
||||||
[switch]$DisableCertificateRevocationCheckForLab
|
[switch]$DisableCertificateRevocationCheckForLab
|
||||||
)
|
)
|
||||||
@@ -43,6 +46,8 @@ param(
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$serviceName = 'SGUAuthBroker'
|
$serviceName = 'SGUAuthBroker'
|
||||||
$installPath = Join-Path $env:ProgramFiles 'SGU\AuthBroker'
|
$installPath = Join-Path $env:ProgramFiles 'SGU\AuthBroker'
|
||||||
|
$brokerEventLogName = 'SGU Auth Broker'
|
||||||
|
$brokerEventSource = 'SGU.AuthBroker.Operational'
|
||||||
$normalizedClientThumbprints = @($AllowedClientThumbprints | ForEach-Object { $_ -replace ' ', '' })
|
$normalizedClientThumbprints = @($AllowedClientThumbprints | ForEach-Object { $_ -replace ' ', '' })
|
||||||
if ($normalizedClientThumbprints.Where({ $_.Length -ne 40 }).Count -gt 0) {
|
if ($normalizedClientThumbprints.Where({ $_.Length -ne 40 }).Count -gt 0) {
|
||||||
throw 'Client certificate thumbprints must contain exactly 40 hexadecimal characters.'
|
throw 'Client certificate thumbprints must contain exactly 40 hexadecimal characters.'
|
||||||
@@ -126,6 +131,14 @@ foreach ($file in @('SGU.AuthBroker.exe', 'SGU.AuthBroker.dll', 'appsettings.jso
|
|||||||
}
|
}
|
||||||
|
|
||||||
$productionSettings = @{
|
$productionSettings = @{
|
||||||
|
Logging = @{
|
||||||
|
EventLog = @{
|
||||||
|
LogLevel = @{
|
||||||
|
Default = 'Information'
|
||||||
|
'Microsoft.AspNetCore' = 'Warning'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Kestrel = @{
|
Kestrel = @{
|
||||||
Endpoints = @{
|
Endpoints = @{
|
||||||
Https = @{
|
Https = @{
|
||||||
@@ -140,6 +153,9 @@ $productionSettings = @{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Broker = @{
|
Broker = @{
|
||||||
|
Diagnostics = @{
|
||||||
|
UseDedicatedEventLog = $true
|
||||||
|
}
|
||||||
Tls = @{
|
Tls = @{
|
||||||
AllowedClientThumbprints = $normalizedClientThumbprints
|
AllowedClientThumbprints = $normalizedClientThumbprints
|
||||||
CheckCertificateRevocation = -not $DisableCertificateRevocationCheckForLab
|
CheckCertificateRevocation = -not $DisableCertificateRevocationCheckForLab
|
||||||
@@ -195,6 +211,18 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker
|
|||||||
$settingsJson,
|
$settingsJson,
|
||||||
$utf8WithoutBom)
|
$utf8WithoutBom)
|
||||||
|
|
||||||
|
if ([Diagnostics.EventLog]::SourceExists($brokerEventSource)) {
|
||||||
|
$registeredLog = [Diagnostics.EventLog]::LogNameFromSourceName($brokerEventSource, '.')
|
||||||
|
if (-not $registeredLog.Equals($brokerEventLogName, [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw "Event source $brokerEventSource is already registered to $registeredLog."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
New-EventLog -LogName $brokerEventLogName -Source $brokerEventSource
|
||||||
|
}
|
||||||
|
Limit-EventLog -LogName $brokerEventLogName -MaximumSize 268435456 `
|
||||||
|
-OverflowAction OverwriteAsNeeded
|
||||||
|
|
||||||
if (-not (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)) {
|
if (-not (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)) {
|
||||||
New-Service -Name $serviceName `
|
New-Service -Name $serviceName `
|
||||||
-DisplayName 'SGU Authentication Broker' `
|
-DisplayName 'SGU Authentication Broker' `
|
||||||
@@ -215,12 +243,36 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install the SGU Authentication Broker
|
|||||||
throw 'Could not enable recovery for non-crash SGUAuthBroker failures.'
|
throw 'Could not enable recovery for non-crash SGUAuthBroker failures.'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not (Get-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' -ErrorAction SilentlyContinue)) {
|
$firewallRule = Get-NetFirewallRule `
|
||||||
New-NetFirewallRule -DisplayName 'SGU Authentication Broker (mTLS)' `
|
-DisplayName 'SGU Authentication Broker (mTLS)' `
|
||||||
-Direction Inbound -Action Allow -Protocol TCP -LocalPort 8443 -Profile Domain | Out-Null
|
-ErrorAction SilentlyContinue
|
||||||
|
if (-not $firewallRule) {
|
||||||
|
$firewallParameters = @{
|
||||||
|
DisplayName = 'SGU Authentication Broker (mTLS)'
|
||||||
|
Direction = 'Inbound'
|
||||||
|
Action = 'Allow'
|
||||||
|
Protocol = 'TCP'
|
||||||
|
LocalPort = 8443
|
||||||
|
Profile = 'Any'
|
||||||
|
RemoteAddress = $FirewallRemoteAddress
|
||||||
|
}
|
||||||
|
if ($FirewallLocalAddress) {
|
||||||
|
$firewallParameters.LocalAddress = $FirewallLocalAddress.IPAddressToString
|
||||||
|
}
|
||||||
|
$firewallRule = New-NetFirewallRule @firewallParameters
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$firewallRule | Set-NetFirewallRule -Enabled True -Profile Any
|
||||||
|
$addressParameters = @{ RemoteAddress = $FirewallRemoteAddress }
|
||||||
|
if ($FirewallLocalAddress) {
|
||||||
|
$addressParameters.LocalAddress = $FirewallLocalAddress.IPAddressToString
|
||||||
|
}
|
||||||
|
$firewallRule | Get-NetFirewallAddressFilter |
|
||||||
|
Set-NetFirewallAddressFilter @addressParameters | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
Start-Service -Name $serviceName
|
Start-Service -Name $serviceName
|
||||||
}
|
}
|
||||||
|
|
||||||
Get-Service -Name $serviceName | Select-Object Name, Status, StartType
|
Get-Service -Name $serviceName | Select-Object Name, Status, StartType,
|
||||||
|
@{ Name = 'EventLog'; Expression = { $brokerEventLogName } }
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ $remoteDesktopUsersSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-
|
|||||||
$remoteDesktopUsersGroup = ($remoteDesktopUsersSid.Translate([Security.Principal.NTAccount]).Value -split '\\', 2)[1]
|
$remoteDesktopUsersGroup = ($remoteDesktopUsersSid.Translate([Security.Principal.NTAccount]).Value -split '\\', 2)[1]
|
||||||
|
|
||||||
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enable RDP and grant $RemoteDesktopPrincipal access")) {
|
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enable RDP and grant $RemoteDesktopPrincipal access")) {
|
||||||
|
function Invoke-PowerCfgBestEffort {
|
||||||
|
param([Parameter(Mandatory)][string[]]$Arguments)
|
||||||
|
|
||||||
|
# Start-Process keeps powercfg's policy-override diagnostic on its own
|
||||||
|
# stderr stream. In PowerShell 7, directly invoking that native command
|
||||||
|
# turns stderr into a terminating ErrorRecord under $ErrorActionPreference
|
||||||
|
# = 'Stop', which previously aborted this unrelated remediation work.
|
||||||
|
$process = Start-Process -FilePath "$env:SystemRoot\System32\powercfg.exe" `
|
||||||
|
-ArgumentList $Arguments -Wait -PassThru -WindowStyle Hidden
|
||||||
|
if ($process.ExitCode -ne 0) {
|
||||||
|
Write-Warning "powercfg $($Arguments -join ' ') returned exit code $($process.ExitCode); continuing enrollment repair."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($powerChange in @(
|
foreach ($powerChange in @(
|
||||||
@('monitor-timeout-ac', '0'),
|
@('monitor-timeout-ac', '0'),
|
||||||
@('monitor-timeout-dc', '0'),
|
@('monitor-timeout-dc', '0'),
|
||||||
@@ -27,15 +41,9 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, "Enable RDP and grant $RemoteDesk
|
|||||||
@('standby-timeout-dc', '0'),
|
@('standby-timeout-dc', '0'),
|
||||||
@('hibernate-timeout-ac', '0'),
|
@('hibernate-timeout-ac', '0'),
|
||||||
@('hibernate-timeout-dc', '0'))) {
|
@('hibernate-timeout-dc', '0'))) {
|
||||||
& powercfg.exe /change $powerChange[0] $powerChange[1]
|
Invoke-PowerCfgBestEffort -Arguments @('/change', $powerChange[0], $powerChange[1])
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "powercfg /change $($powerChange[0]) failed with exit code $LASTEXITCODE."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& powercfg.exe /hibernate off
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "powercfg /hibernate off failed with exit code $LASTEXITCODE."
|
|
||||||
}
|
}
|
||||||
|
Invoke-PowerCfgBestEffort -Arguments @('/hibernate', 'off')
|
||||||
|
|
||||||
Set-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' `
|
Set-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' `
|
||||||
-Name fDenyTSConnections -Type DWord -Value 0
|
-Name fDenyTSConnections -Type DWord -Value 0
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
[CmdletBinding(SupportsShouldProcess)]
|
[CmdletBinding(SupportsShouldProcess)]
|
||||||
param()
|
param(
|
||||||
|
[ValidateNotNullOrEmpty()]
|
||||||
|
[string[]]$AllowedRemoteAddress = @('LocalSubnet')
|
||||||
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
@@ -38,9 +41,12 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable secure administrative RDP
|
|||||||
|
|
||||||
Set-Service -Name TermService -StartupType Automatic
|
Set-Service -Name TermService -StartupType Automatic
|
||||||
Start-Service -Name TermService
|
Start-Service -Name TermService
|
||||||
Get-NetFirewallRule -Name 'RemoteDesktop-UserMode-In-TCP','RemoteDesktop-UserMode-In-UDP' `
|
$remoteDesktopRules = @(Get-NetFirewallRule `
|
||||||
-ErrorAction SilentlyContinue |
|
-Name 'RemoteDesktop-UserMode-In-TCP','RemoteDesktop-UserMode-In-UDP' `
|
||||||
Set-NetFirewallRule -Enabled True -Profile Domain
|
-ErrorAction SilentlyContinue)
|
||||||
|
$remoteDesktopRules | Set-NetFirewallRule -Enabled True -Profile Any
|
||||||
|
$remoteDesktopRules | Get-NetFirewallAddressFilter |
|
||||||
|
Set-NetFirewallAddressFilter -RemoteAddress $AllowedRemoteAddress | Out-Null
|
||||||
|
|
||||||
$enableRemoting = Start-Process `
|
$enableRemoting = Start-Process `
|
||||||
-FilePath "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" `
|
-FilePath "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" `
|
||||||
@@ -57,9 +63,12 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable secure administrative RDP
|
|||||||
|
|
||||||
Set-Service -Name WinRM -StartupType Automatic
|
Set-Service -Name WinRM -StartupType Automatic
|
||||||
Start-Service -Name WinRM
|
Start-Service -Name WinRM
|
||||||
Get-NetFirewallRule -Name 'WINRM-HTTP-In-TCP','WINRM-HTTP-In-TCP-NoScope' `
|
$winRmRules = @(Get-NetFirewallRule `
|
||||||
-ErrorAction SilentlyContinue |
|
-Name 'WINRM-HTTP-In-TCP','WINRM-HTTP-In-TCP-NoScope' `
|
||||||
Set-NetFirewallRule -Enabled True -Profile Domain
|
-ErrorAction SilentlyContinue)
|
||||||
|
$winRmRules | Set-NetFirewallRule -Enabled True -Profile Any
|
||||||
|
$winRmRules | Get-NetFirewallAddressFilter |
|
||||||
|
Set-NetFirewallAddressFilter -RemoteAddress $AllowedRemoteAddress | Out-Null
|
||||||
Get-NetFirewallRule -Name 'WINRM-HTTP-In-TCP-PUBLIC' -ErrorAction SilentlyContinue |
|
Get-NetFirewallRule -Name 'WINRM-HTTP-In-TCP-PUBLIC' -ErrorAction SilentlyContinue |
|
||||||
Disable-NetFirewallRule
|
Disable-NetFirewallRule
|
||||||
|
|
||||||
@@ -74,8 +83,11 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable secure administrative RDP
|
|||||||
'WMI-WINMGMT-In-TCP',
|
'WMI-WINMGMT-In-TCP',
|
||||||
'WMI-ASYNC-In-TCP'
|
'WMI-ASYNC-In-TCP'
|
||||||
)
|
)
|
||||||
Get-NetFirewallRule -Name $administrativeRules -ErrorAction SilentlyContinue |
|
$enabledAdministrativeRules = @(Get-NetFirewallRule `
|
||||||
Set-NetFirewallRule -Enabled True -Profile Domain
|
-Name $administrativeRules -ErrorAction SilentlyContinue)
|
||||||
|
$enabledAdministrativeRules | Set-NetFirewallRule -Enabled True -Profile Any
|
||||||
|
$enabledAdministrativeRules | Get-NetFirewallAddressFilter |
|
||||||
|
Set-NetFirewallAddressFilter -RemoteAddress $AllowedRemoteAddress | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
[pscustomobject]@{
|
[pscustomobject]@{
|
||||||
@@ -88,7 +100,8 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Enable secure administrative RDP
|
|||||||
-Name UserAuthentication) -eq 1
|
-Name UserAuthentication) -eq 1
|
||||||
TermService = (Get-Service TermService).Status
|
TermService = (Get-Service TermService).Status
|
||||||
WinRM = (Get-Service WinRM).Status
|
WinRM = (Get-Service WinRM).Status
|
||||||
FirewallProfile = 'Domain'
|
FirewallProfile = 'Any'
|
||||||
|
AllowedRemoteAddress = $AllowedRemoteAddress
|
||||||
AdministrativeAccessOnly = $true
|
AdministrativeAccessOnly = $true
|
||||||
AlwaysOnPowerPolicyApplied = $true
|
AlwaysOnPowerPolicyApplied = $true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ foreach ($scriptName in @(
|
|||||||
'Install-SguEnrollmentGuard.ps1',
|
'Install-SguEnrollmentGuard.ps1',
|
||||||
'Test-SguClientEnrollment.ps1',
|
'Test-SguClientEnrollment.ps1',
|
||||||
'Repair-SguClientEnrollment.ps1',
|
'Repair-SguClientEnrollment.ps1',
|
||||||
'Enable-LabRemoteAccess.ps1')) {
|
'Enable-LabRemoteAccess.ps1',
|
||||||
|
'Enable-SguClientMonitoring.ps1')) {
|
||||||
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot $scriptName) -PathType Leaf)) {
|
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot $scriptName) -PathType Leaf)) {
|
||||||
throw "$scriptName must be beside Enroll-SguDomainClient.ps1."
|
throw "$scriptName must be beside Enroll-SguDomainClient.ps1."
|
||||||
}
|
}
|
||||||
@@ -96,6 +97,7 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Install and verify SGU before jo
|
|||||||
& (Join-Path $PSScriptRoot 'Enable-LabRemoteAccess.ps1') `
|
& (Join-Path $PSScriptRoot 'Enable-LabRemoteAccess.ps1') `
|
||||||
-RemoteDesktopPrincipal $RemoteDesktopPrincipal `
|
-RemoteDesktopPrincipal $RemoteDesktopPrincipal `
|
||||||
-EnableAdministrativeFirewallGroups | Out-Null
|
-EnableAdministrativeFirewallGroups | Out-Null
|
||||||
|
& (Join-Path $PSScriptRoot 'Enable-SguClientMonitoring.ps1') | Out-Null
|
||||||
return & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') `
|
return & (Join-Path $PSScriptRoot 'Test-SguClientEnrollment.ps1') `
|
||||||
-RequireDomainJoined `
|
-RequireDomainJoined `
|
||||||
-RequireRemoteAccess `
|
-RequireRemoteAccess `
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -171,8 +171,10 @@ function Ensure-OrganizationalUnit {
|
|||||||
)
|
)
|
||||||
|
|
||||||
$distinguishedName = "OU=$Name,$Path"
|
$distinguishedName = "OU=$Name,$Path"
|
||||||
$existing = Get-ADOrganizationalUnit -Identity $distinguishedName -Server $Server `
|
$escapedName = $Name.Replace('\', '\5c').Replace('*', '\2a').Replace('(', '\28').Replace(')', '\29')
|
||||||
-ErrorAction SilentlyContinue
|
$existing = Get-ADOrganizationalUnit -LDAPFilter "(ou=$escapedName)" `
|
||||||
|
-SearchBase $Path -SearchScope OneLevel -Server $Server -ErrorAction Stop |
|
||||||
|
Select-Object -First 1
|
||||||
if (-not $existing) {
|
if (-not $existing) {
|
||||||
New-ADOrganizationalUnit -Name $Name -Path $Path `
|
New-ADOrganizationalUnit -Name $Name -Path $Path `
|
||||||
-ProtectedFromAccidentalDeletion $true -Server $Server | Out-Null
|
-ProtectedFromAccidentalDeletion $true -Server $Server | Out-Null
|
||||||
@@ -180,6 +182,57 @@ function Ensure-OrganizationalUnit {
|
|||||||
return $distinguishedName
|
return $distinguishedName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Wait-ActiveDirectoryReady {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$ExpectedBaseDn,
|
||||||
|
[ValidateRange(1, 120)][int]$Attempts = 36,
|
||||||
|
[ValidateRange(1, 30)][int]$DelaySeconds = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
|
||||||
|
try {
|
||||||
|
$rootDse = Get-ADRootDSE -Server localhost -ErrorAction Stop
|
||||||
|
if ($rootDse.DefaultNamingContext -eq $ExpectedBaseDn) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
if ($attempt -eq $Attempts) {
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds $DelaySeconds
|
||||||
|
}
|
||||||
|
throw "Active Directory did not publish $ExpectedBaseDn before the readiness timeout."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-DnsZoneReady {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$ZoneName,
|
||||||
|
[Parameter(Mandatory)][ipaddress]$DnsServer,
|
||||||
|
[ValidateRange(1, 120)][int]$Attempts = 30,
|
||||||
|
[ValidateRange(1, 30)][int]$DelaySeconds = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
|
||||||
|
try {
|
||||||
|
$soa = @(Resolve-DnsName $ZoneName -Type SOA -DnsOnly `
|
||||||
|
-Server $DnsServer.IPAddressToString -ErrorAction Stop |
|
||||||
|
Where-Object Type -eq SOA)
|
||||||
|
if ($soa.Count -gt 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
if ($attempt -eq $Attempts) {
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds $DelaySeconds
|
||||||
|
}
|
||||||
|
throw "DNS did not load the $ZoneName zone before the readiness timeout."
|
||||||
|
}
|
||||||
|
|
||||||
function Set-PackageShare {
|
function Set-PackageShare {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)][string]$Path,
|
[Parameter(Mandatory)][string]$Path,
|
||||||
@@ -234,6 +287,10 @@ function Set-PackageShare {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Assert-Administrator
|
Assert-Administrator
|
||||||
|
trap {
|
||||||
|
Write-BootstrapLog ("ERROR: " + $_.Exception.Message)
|
||||||
|
throw
|
||||||
|
}
|
||||||
$operatingSystem = Get-CimInstance Win32_OperatingSystem
|
$operatingSystem = Get-CimInstance Win32_OperatingSystem
|
||||||
if ([int]$operatingSystem.ProductType -eq 1) {
|
if ([int]$operatingSystem.ProductType -eq 1) {
|
||||||
throw 'The domain controller bootstrap requires Windows Server, not a Windows client edition.'
|
throw 'The domain controller bootstrap requires Windows Server, not a Windows client edition.'
|
||||||
@@ -291,6 +348,10 @@ foreach ($requiredPath in @(
|
|||||||
(Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1'),
|
(Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1'),
|
||||||
(Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1'),
|
(Join-Path $scriptsRoot 'Set-SguDomainUserPolicies.ps1'),
|
||||||
(Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1'),
|
(Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1'),
|
||||||
|
(Join-Path $scriptsRoot 'Install-SguDomainMonitoring.ps1'),
|
||||||
|
(Join-Path $scriptsRoot 'Invoke-SguMonitoringMaintenance.ps1'),
|
||||||
|
(Join-Path $scriptsRoot 'Get-SguUsageReport.ps1'),
|
||||||
|
(Join-Path $scriptsRoot 'Get-SguBrokerLog.ps1'),
|
||||||
(Join-Path $brokerPublishPath 'SGU.AuthBroker.exe'))) {
|
(Join-Path $brokerPublishPath 'SGU.AuthBroker.exe'))) {
|
||||||
if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
|
if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
|
||||||
throw "The server bootstrap package is incomplete: $requiredPath"
|
throw "The server bootstrap package is incomplete: $requiredPath"
|
||||||
@@ -379,24 +440,75 @@ if (-not $computer.Domain.Equals($DomainName, [StringComparison]::OrdinalIgnoreC
|
|||||||
}
|
}
|
||||||
|
|
||||||
Write-BootstrapLog 'Finalizing Active Directory, DNS, policies, broker, shares, and remote management.'
|
Write-BootstrapLog 'Finalizing Active Directory, DNS, policies, broker, shares, and remote management.'
|
||||||
Import-Module ActiveDirectory -ErrorAction Stop
|
|
||||||
$domain = Get-ADDomain -Identity $DomainName -Server $env:COMPUTERNAME
|
|
||||||
|
|
||||||
$laboratoryOuDn = Ensure-OrganizationalUnit -Name 'Laboratorio' -Path $baseDn -Server $env:COMPUTERNAME
|
# Once the machine is a DC, every active adapter must query the local DNS
|
||||||
$usersOuDn = Ensure-OrganizationalUnit -Name 'Usuarios-SGU' -Path $baseDn -Server $env:COMPUTERNAME
|
# service. Only the private domain adapter may publish its address in the AD
|
||||||
|
# zone; otherwise clients can receive the DHCP/NAT address of the Internet NIC.
|
||||||
|
Get-NetAdapter | Where-Object Status -eq 'Up' | ForEach-Object {
|
||||||
|
Set-DnsClientServerAddress -InterfaceIndex $_.ifIndex `
|
||||||
|
-ServerAddresses $ServerIPv4Address.IPAddressToString
|
||||||
|
Set-DnsClient -InterfaceIndex $_.ifIndex `
|
||||||
|
-RegisterThisConnectionsAddress:($_.Name -eq $NetworkInterfaceAlias)
|
||||||
|
}
|
||||||
|
|
||||||
|
Clear-DnsClientCache
|
||||||
|
Register-DnsClient
|
||||||
|
Import-Module ActiveDirectory -ErrorAction Stop
|
||||||
|
Wait-ActiveDirectoryReady -ExpectedBaseDn $baseDn
|
||||||
|
|
||||||
|
# A newly promoted Windows Server 2025 DC can retain the Public firewall
|
||||||
|
# profile because network identification ran before local DNS and LDAP were
|
||||||
|
# ready. A private-adapter bounce triggers the supported domain-detection path.
|
||||||
|
$domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias `
|
||||||
|
-ErrorAction SilentlyContinue
|
||||||
|
if (-not $domainProfile -or $domainProfile.NetworkCategory -ne 'DomainAuthenticated') {
|
||||||
|
Write-BootstrapLog "Refreshing $NetworkInterfaceAlias so Windows detects the domain network profile."
|
||||||
|
Restart-NetAdapter -Name $NetworkInterfaceAlias -Confirm:$false
|
||||||
|
for ($attempt = 1; $attempt -le 15; $attempt++) {
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
$domainProfile = Get-NetConnectionProfile -InterfaceAlias $NetworkInterfaceAlias `
|
||||||
|
-ErrorAction SilentlyContinue
|
||||||
|
if ($domainProfile -and $domainProfile.NetworkCategory -eq 'DomainAuthenticated') {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Apply the single-address DNS listener only after any adapter refresh. That
|
||||||
|
# avoids transient DNS socket errors while the private address is momentarily
|
||||||
|
# unavailable, while still preventing the Internet/NAT address from being
|
||||||
|
# published once finalization completes.
|
||||||
|
New-ItemProperty `
|
||||||
|
-Path 'HKLM:\SYSTEM\CurrentControlSet\Services\DNS\Parameters' `
|
||||||
|
-Name PublishAddresses `
|
||||||
|
-PropertyType String `
|
||||||
|
-Value $ServerIPv4Address.IPAddressToString `
|
||||||
|
-Force | Out-Null
|
||||||
|
$dnsServerSetting = Get-DnsServerSetting -All -WarningAction SilentlyContinue
|
||||||
|
$dnsServerSetting.ListeningIPAddress = @($ServerIPv4Address)
|
||||||
|
Set-DnsServerSetting -InputObject $dnsServerSetting -WarningAction SilentlyContinue | Out-Null
|
||||||
|
Restart-Service DNS -Force
|
||||||
|
Wait-DnsZoneReady -ZoneName $DomainName -DnsServer $ServerIPv4Address
|
||||||
|
|
||||||
|
$adServer = 'localhost'
|
||||||
|
$domain = Get-ADDomain -Identity $DomainName -Server $adServer
|
||||||
|
$laboratoryOuDn = Ensure-OrganizationalUnit -Name 'Laboratorio' -Path $baseDn -Server $adServer
|
||||||
|
$usersOuDn = Ensure-OrganizationalUnit -Name 'Usuarios-SGU' -Path $baseDn -Server $adServer
|
||||||
foreach ($ouName in @('Docentes', 'Alumnos', 'Administrativos')) {
|
foreach ($ouName in @('Docentes', 'Alumnos', 'Administrativos')) {
|
||||||
Ensure-OrganizationalUnit -Name $ouName -Path $usersOuDn -Server $env:COMPUTERNAME | Out-Null
|
Ensure-OrganizationalUnit -Name $ouName -Path $usersOuDn -Server $adServer | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
$remoteDesktopGroupName = 'SG-Laboratorio-Usuarios-RDP'
|
$remoteDesktopGroupName = 'SG-Laboratorio-Usuarios-RDP'
|
||||||
$remoteDesktopGroup = Get-ADGroup -Identity $remoteDesktopGroupName -Server $env:COMPUTERNAME `
|
$remoteDesktopGroup = Get-ADGroup -LDAPFilter "(sAMAccountName=$remoteDesktopGroupName)" `
|
||||||
|
-SearchBase $baseDn -SearchScope Subtree -Server $adServer `
|
||||||
-ErrorAction SilentlyContinue
|
-ErrorAction SilentlyContinue
|
||||||
if (-not $remoteDesktopGroup) {
|
if (-not $remoteDesktopGroup) {
|
||||||
New-ADGroup -Name $remoteDesktopGroupName -SamAccountName $remoteDesktopGroupName `
|
New-ADGroup -Name $remoteDesktopGroupName -SamAccountName $remoteDesktopGroupName `
|
||||||
-GroupCategory Security -GroupScope Global -Path $laboratoryOuDn `
|
-GroupCategory Security -GroupScope Global -Path $laboratoryOuDn `
|
||||||
-Description 'SGU users permitted to use Remote Desktop on laboratory clients.' `
|
-Description 'SGU users permitted to use Remote Desktop on laboratory clients.' `
|
||||||
-Server $env:COMPUTERNAME | Out-Null
|
-Server $adServer | Out-Null
|
||||||
$remoteDesktopGroup = Get-ADGroup -Identity $remoteDesktopGroupName -Server $env:COMPUTERNAME
|
$remoteDesktopGroup = Get-ADGroup -LDAPFilter "(sAMAccountName=$remoteDesktopGroupName)" `
|
||||||
|
-SearchBase $laboratoryOuDn -SearchScope OneLevel -Server $adServer
|
||||||
}
|
}
|
||||||
|
|
||||||
& (Join-Path $scriptsRoot 'Set-LabBrokerDns.ps1') `
|
& (Join-Path $scriptsRoot 'Set-LabBrokerDns.ps1') `
|
||||||
@@ -444,16 +556,30 @@ if (Test-Path -LiteralPath $brokerConfigurationPath -PathType Leaf) {
|
|||||||
-PublishPath $brokerPublishPath `
|
-PublishPath $brokerPublishPath `
|
||||||
-ServerCertificateSubject $brokerDnsName `
|
-ServerCertificateSubject $brokerDnsName `
|
||||||
-AllowedClientThumbprints $allowedClientThumbprints `
|
-AllowedClientThumbprints $allowedClientThumbprints `
|
||||||
-LdapHost $env:COMPUTERNAME `
|
-LdapHost $adServer `
|
||||||
-BaseDn $baseDn `
|
-BaseDn $baseDn `
|
||||||
-DomainNetbios $DomainNetbios `
|
-DomainNetbios $DomainNetbios `
|
||||||
-UpnSuffix $DomainName `
|
-UpnSuffix $DomainName `
|
||||||
-RemoteDesktopGroupDn $remoteDesktopGroup.DistinguishedName `
|
-RemoteDesktopGroupDn $remoteDesktopGroup.DistinguishedName `
|
||||||
-DefaultCompany 'La Salle' `
|
-DefaultCompany 'La Salle' `
|
||||||
|
-FirewallLocalAddress $ServerIPv4Address `
|
||||||
|
-FirewallRemoteAddress "$($ServerIPv4Address.IPAddressToString)/$PrefixLength" `
|
||||||
-CreateMissingOus `
|
-CreateMissingOus `
|
||||||
-DisableCertificateRevocationCheckForLab | Out-Null
|
-DisableCertificateRevocationCheckForLab | Out-Null
|
||||||
|
|
||||||
& (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') | Out-Null
|
# Remove stale A records registered by any non-domain/NAT adapter before its
|
||||||
|
# dynamic DNS registration was disabled.
|
||||||
|
$hostRecords = @(Get-DnsServerResourceRecord -ZoneName $DomainName `
|
||||||
|
-Name $env:COMPUTERNAME -RRType A -ErrorAction SilentlyContinue)
|
||||||
|
foreach ($hostRecord in $hostRecords) {
|
||||||
|
if ($hostRecord.RecordData.IPv4Address.IPAddressToString -ne $ServerIPv4Address.IPAddressToString) {
|
||||||
|
Remove-DnsServerResourceRecord -ZoneName $DomainName -InputObject $hostRecord -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$privateSubnet = "$($ServerIPv4Address.IPAddressToString)/$PrefixLength"
|
||||||
|
& (Join-Path $scriptsRoot 'Enable-SguServerRemoteManagement.ps1') `
|
||||||
|
-AllowedRemoteAddress $privateSubnet | Out-Null
|
||||||
|
|
||||||
$contentPath = Join-Path $bootstrapRoot 'payload\server-content\Packages'
|
$contentPath = Join-Path $bootstrapRoot 'payload\server-content\Packages'
|
||||||
if (Test-Path -LiteralPath $contentPath -PathType Container) {
|
if (Test-Path -LiteralPath $contentPath -PathType Container) {
|
||||||
@@ -463,8 +589,36 @@ if (Test-Path -LiteralPath $contentPath -PathType Container) {
|
|||||||
Set-PackageShare -Path $PackageSharePath -NetbiosName $DomainNetbios `
|
Set-PackageShare -Path $PackageSharePath -NetbiosName $DomainNetbios `
|
||||||
-DomainSid $domain.DomainSID.Value
|
-DomainSid $domain.DomainSID.Value
|
||||||
|
|
||||||
|
$packageFirewallRule = Get-NetFirewallRule -DisplayName 'SGU Bootstrap Packages (SMB)' `
|
||||||
|
-ErrorAction SilentlyContinue
|
||||||
|
if (-not $packageFirewallRule) {
|
||||||
|
New-NetFirewallRule `
|
||||||
|
-DisplayName 'SGU Bootstrap Packages (SMB)' `
|
||||||
|
-Direction Inbound `
|
||||||
|
-Action Allow `
|
||||||
|
-Protocol TCP `
|
||||||
|
-LocalPort 445 `
|
||||||
|
-LocalAddress $ServerIPv4Address.IPAddressToString `
|
||||||
|
-RemoteAddress $privateSubnet `
|
||||||
|
-Profile Any | Out-Null
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$packageFirewallRule | Set-NetFirewallRule -Enabled True -Profile Any
|
||||||
|
$packageFirewallRule | Get-NetFirewallAddressFilter |
|
||||||
|
Set-NetFirewallAddressFilter `
|
||||||
|
-LocalAddress $ServerIPv4Address.IPAddressToString `
|
||||||
|
-RemoteAddress $privateSubnet | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$collectorFqdn = "$env:COMPUTERNAME.$DomainName"
|
||||||
& (Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1') `
|
& (Join-Path $scriptsRoot 'Set-SguDomainComputerPolicies.ps1') `
|
||||||
-TargetOuDn $laboratoryOuDn -DomainController $env:COMPUTERNAME | Out-Null
|
-TargetOuDn $laboratoryOuDn `
|
||||||
|
-DomainController $env:COMPUTERNAME `
|
||||||
|
-EventCollectorFqdn $collectorFqdn | Out-Null
|
||||||
|
& (Join-Path $scriptsRoot 'Install-SguDomainMonitoring.ps1') `
|
||||||
|
-CollectorFqdn $collectorFqdn `
|
||||||
|
-ComputerOuDn $laboratoryOuDn `
|
||||||
|
-RetentionDays 183 | Out-Null
|
||||||
$userPolicyParameters = @{
|
$userPolicyParameters = @{
|
||||||
TargetOuDn = $usersOuDn
|
TargetOuDn = $usersOuDn
|
||||||
DomainController = $env:COMPUTERNAME
|
DomainController = $env:COMPUTERNAME
|
||||||
@@ -489,16 +643,23 @@ $validation = [ordered]@{
|
|||||||
BrokerPortListening = [bool](Get-NetTCPConnection -LocalPort 8443 -State Listen -ErrorAction SilentlyContinue)
|
BrokerPortListening = [bool](Get-NetTCPConnection -LocalPort 8443 -State Listen -ErrorAction SilentlyContinue)
|
||||||
WinRM = (Get-Service WinRM).Status.ToString()
|
WinRM = (Get-Service WinRM).Status.ToString()
|
||||||
RemoteDesktop = (Get-Service TermService).Status.ToString()
|
RemoteDesktop = (Get-Service TermService).Status.ToString()
|
||||||
|
EventCollector = (Get-Service Wecsvc).Status.ToString()
|
||||||
|
EventSubscription = @(& wecutil.exe enum-subscription) -contains 'SGU-Lab-Monitoring'
|
||||||
|
MonitoringRetentionDays = 183
|
||||||
PackageShare = "\\$env:COMPUTERNAME\Packages"
|
PackageShare = "\\$env:COMPUTERNAME\Packages"
|
||||||
LaboratoryOu = $laboratoryOuDn
|
LaboratoryOu = $laboratoryOuDn
|
||||||
UsersOu = $usersOuDn
|
UsersOu = $usersOuDn
|
||||||
RemoteDesktopGroup = $remoteDesktopGroup.DistinguishedName
|
RemoteDesktopGroup = $remoteDesktopGroup.DistinguishedName
|
||||||
|
DomainNetworkProfile = [string](Get-NetConnectionProfile `
|
||||||
|
-InterfaceAlias $NetworkInterfaceAlias -ErrorAction SilentlyContinue).NetworkCategory
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($validation.BrokerService -ne 'Running' -or
|
if ($validation.BrokerService -ne 'Running' -or
|
||||||
-not $validation.BrokerPortListening -or
|
-not $validation.BrokerPortListening -or
|
||||||
$validation.WinRM -ne 'Running' -or
|
$validation.WinRM -ne 'Running' -or
|
||||||
$validation.RemoteDesktop -ne 'Running') {
|
$validation.RemoteDesktop -ne 'Running' -or
|
||||||
|
$validation.EventCollector -ne 'Running' -or
|
||||||
|
-not $validation.EventSubscription) {
|
||||||
throw 'Server finalization did not pass service validation. Review bootstrap.log and re-run the bootstrap.'
|
throw 'Server finalization did not pass service validation. Review bootstrap.log and re-run the bootstrap.'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ $providerRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authent
|
|||||||
$classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId\InprocServer32"
|
$classRegistryPath = "HKLM:\SOFTWARE\Classes\CLSID\$providerClassId\InprocServer32"
|
||||||
$defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
|
$defaultProviderPolicyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System'
|
||||||
$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
|
$interactiveLogonPolicyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
|
||||||
|
$accountPictureSourcePath = Join-Path $PublishPath 'branding\user.png'
|
||||||
|
$accountPictureDirectory = Join-Path $env:ProgramData 'Microsoft\User Account Pictures'
|
||||||
|
|
||||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||||
@@ -56,6 +58,70 @@ function Test-DotNet10Runtime {
|
|||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Install-DefaultAccountPicture {
|
||||||
|
param([Parameter(Mandatory)][string]$SourcePath)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $SourcePath -PathType Leaf)) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
New-Item -ItemType Directory -Path $accountPictureDirectory -Force | Out-Null
|
||||||
|
|
||||||
|
function Save-AccountPicture {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][Drawing.Image]$Image,
|
||||||
|
[Parameter(Mandatory)][string]$Path,
|
||||||
|
[Parameter(Mandatory)][Drawing.Imaging.ImageFormat]$Format
|
||||||
|
)
|
||||||
|
|
||||||
|
$stream = [IO.MemoryStream]::new()
|
||||||
|
try {
|
||||||
|
$Image.Save($stream, $Format)
|
||||||
|
[IO.File]::WriteAllBytes($Path, $stream.ToArray())
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$stream.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = [Drawing.Image]::FromFile($SourcePath)
|
||||||
|
try {
|
||||||
|
foreach ($size in @(192, 48, 40, 32)) {
|
||||||
|
$bitmap = [Drawing.Bitmap]::new($size, $size)
|
||||||
|
try {
|
||||||
|
$graphics = [Drawing.Graphics]::FromImage($bitmap)
|
||||||
|
try {
|
||||||
|
$graphics.Clear([Drawing.Color]::Transparent)
|
||||||
|
$graphics.InterpolationMode = [Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||||
|
$graphics.DrawImage($source, [Drawing.Rectangle]::new(0, 0, $size, $size))
|
||||||
|
Save-AccountPicture -Image $bitmap `
|
||||||
|
-Path (Join-Path $accountPictureDirectory "user-$size.png") `
|
||||||
|
-Format ([Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$graphics.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$bitmap.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Save-AccountPicture -Image $source `
|
||||||
|
-Path (Join-Path $accountPictureDirectory 'user.png') `
|
||||||
|
-Format ([Drawing.Imaging.ImageFormat]::Png)
|
||||||
|
Save-AccountPicture -Image $source `
|
||||||
|
-Path (Join-Path $accountPictureDirectory 'user.bmp') `
|
||||||
|
-Format ([Drawing.Imaging.ImageFormat]::Bmp)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$source.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
if (-not (Test-DotNet10Runtime)) {
|
if (-not (Test-DotNet10Runtime)) {
|
||||||
if (-not $InstallDotNetRuntime) {
|
if (-not $InstallDotNetRuntime) {
|
||||||
throw 'Microsoft .NET 10 x64 runtime is required. Re-run with -InstallDotNetRuntime or install it first.'
|
throw 'Microsoft .NET 10 x64 runtime is required. Re-run with -InstallDotNetRuntime or install it first.'
|
||||||
@@ -166,6 +232,10 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credenti
|
|||||||
[IO.File]::WriteAllText($completeMarker, $packageHash, [Text.UTF8Encoding]::new($false))
|
[IO.File]::WriteAllText($completeMarker, $packageHash, [Text.UTF8Encoding]::new($false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The domain GPO selects the Windows default account picture. Install its
|
||||||
|
# branded bitmap during enrollment so no per-machine manual setup is needed.
|
||||||
|
Install-DefaultAccountPicture -SourcePath $accountPictureSourcePath | Out-Null
|
||||||
|
|
||||||
New-Item -ItemType Directory -Path (Split-Path $settingsPath -Parent) -Force | Out-Null
|
New-Item -ItemType Directory -Path (Split-Path $settingsPath -Parent) -Force | Out-Null
|
||||||
$settingsJson = @{
|
$settingsJson = @{
|
||||||
BrokerEndpoint = $BrokerEndpoint
|
BrokerEndpoint = $BrokerEndpoint
|
||||||
@@ -179,10 +249,14 @@ if ($PSCmdlet.ShouldProcess($installPath, 'Install and register the SGU Credenti
|
|||||||
|
|
||||||
$acl = Get-Acl -LiteralPath (Split-Path $settingsPath -Parent)
|
$acl = Get-Acl -LiteralPath (Split-Path $settingsPath -Parent)
|
||||||
$acl.SetAccessRuleProtection($true, $false)
|
$acl.SetAccessRuleProtection($true, $false)
|
||||||
|
# Resolve built-in identities by SID instead of localized display names.
|
||||||
|
# "BUILTIN\Administrators" is not resolvable on every non-English client.
|
||||||
|
$systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18')
|
||||||
|
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
||||||
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
|
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
|
||||||
'SYSTEM', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
|
$systemSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
|
||||||
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
|
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
|
||||||
'BUILTIN\Administrators', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
|
$administratorsSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
|
||||||
Set-Acl -LiteralPath (Split-Path $settingsPath -Parent) -AclObject $acl
|
Set-Acl -LiteralPath (Split-Path $settingsPath -Parent) -AclObject $acl
|
||||||
|
|
||||||
New-Item -Path $classRegistryPath -Force | Out-Null
|
New-Item -Path $classRegistryPath -Force | Out-Null
|
||||||
|
|||||||
@@ -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'"
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ $enrollmentRoot = Join-Path $env:ProgramData 'SGU\Enrollment'
|
|||||||
$sourceScripts = @(
|
$sourceScripts = @(
|
||||||
'Install-CredentialProvider.ps1',
|
'Install-CredentialProvider.ps1',
|
||||||
'Enable-LabRemoteAccess.ps1',
|
'Enable-LabRemoteAccess.ps1',
|
||||||
|
'Enable-SguClientMonitoring.ps1',
|
||||||
'Test-SguClientEnrollment.ps1',
|
'Test-SguClientEnrollment.ps1',
|
||||||
'Repair-SguClientEnrollment.ps1'
|
'Repair-SguClientEnrollment.ps1'
|
||||||
)
|
)
|
||||||
@@ -71,8 +72,12 @@ if ($PSCmdlet.ShouldProcess($enrollmentRoot, 'Install the SGU enrollment repair
|
|||||||
$runtimeDirectory = Join-Path $enrollmentRoot 'prerequisites'
|
$runtimeDirectory = Join-Path $enrollmentRoot 'prerequisites'
|
||||||
New-Item -ItemType Directory -Path $runtimeDirectory -Force | Out-Null
|
New-Item -ItemType Directory -Path $runtimeDirectory -Force | Out-Null
|
||||||
$guardRuntimeInstaller = Join-Path $runtimeDirectory (Split-Path $DotNetRuntimeInstallerPath -Leaf)
|
$guardRuntimeInstaller = Join-Path $runtimeDirectory (Split-Path $DotNetRuntimeInstallerPath -Leaf)
|
||||||
|
$sourceRuntimeInstaller = [IO.Path]::GetFullPath($DotNetRuntimeInstallerPath)
|
||||||
|
$destinationRuntimeInstaller = [IO.Path]::GetFullPath($guardRuntimeInstaller)
|
||||||
|
if (-not $sourceRuntimeInstaller.Equals($destinationRuntimeInstaller, [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
Copy-Item -LiteralPath $DotNetRuntimeInstallerPath -Destination $guardRuntimeInstaller -Force
|
Copy-Item -LiteralPath $DotNetRuntimeInstallerPath -Destination $guardRuntimeInstaller -Force
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$guardConfiguration = [ordered]@{
|
$guardConfiguration = [ordered]@{
|
||||||
PublishPath = $guardPublishPath
|
PublishPath = $guardPublishPath
|
||||||
@@ -92,10 +97,13 @@ if ($PSCmdlet.ShouldProcess($enrollmentRoot, 'Install the SGU enrollment repair
|
|||||||
|
|
||||||
$acl = Get-Acl -LiteralPath $enrollmentRoot
|
$acl = Get-Acl -LiteralPath $enrollmentRoot
|
||||||
$acl.SetAccessRuleProtection($true, $false)
|
$acl.SetAccessRuleProtection($true, $false)
|
||||||
|
# Well-known SIDs are invariant across localized Windows installations.
|
||||||
|
$systemSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-18')
|
||||||
|
$administratorsSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
||||||
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
|
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
|
||||||
'SYSTEM', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
|
$systemSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
|
||||||
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
|
$acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new(
|
||||||
'BUILTIN\Administrators', 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
|
$administratorsSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
|
||||||
Set-Acl -LiteralPath $enrollmentRoot -AclObject $acl
|
Set-Acl -LiteralPath $enrollmentRoot -AclObject $acl
|
||||||
|
|
||||||
$repairScript = Join-Path $enrollmentRoot 'Repair-SguClientEnrollment.ps1'
|
$repairScript = Join-Path $enrollmentRoot 'Repair-SguClientEnrollment.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
|
||||||
|
}
|
||||||
@@ -96,6 +96,7 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguClientEnrollment.cm
|
|||||||
-Destination (Join-Path $clientRoot 'Start-SguClientEnrollment.cmd')
|
-Destination (Join-Path $clientRoot 'Start-SguClientEnrollment.cmd')
|
||||||
$clientScripts = @(
|
$clientScripts = @(
|
||||||
'Enable-LabRemoteAccess.ps1',
|
'Enable-LabRemoteAccess.ps1',
|
||||||
|
'Enable-SguClientMonitoring.ps1',
|
||||||
'Enroll-SguDomainClient.ps1',
|
'Enroll-SguDomainClient.ps1',
|
||||||
'Install-CredentialProvider.ps1',
|
'Install-CredentialProvider.ps1',
|
||||||
'Install-SguEnrollmentGuard.ps1',
|
'Install-SguEnrollmentGuard.ps1',
|
||||||
@@ -111,6 +112,8 @@ Copy-Item -Path (Join-Path $providerOutput '*') `
|
|||||||
-Destination (New-Item -ItemType Directory `
|
-Destination (New-Item -ItemType Directory `
|
||||||
-Path (Join-Path $clientRoot 'payload\credential-provider') -Force).FullName `
|
-Path (Join-Path $clientRoot 'payload\credential-provider') -Force).FullName `
|
||||||
-Recurse -Force
|
-Recurse -Force
|
||||||
|
Copy-RequiredFile -Source (Join-Path $repositoryRoot 'assets\branding\lasalle-mascot-account.png') `
|
||||||
|
-Destination (Join-Path $clientRoot 'payload\credential-provider\branding\user.png')
|
||||||
Copy-RequiredFile -Source $runtimeInstaller.FullName `
|
Copy-RequiredFile -Source $runtimeInstaller.FullName `
|
||||||
-Destination (Join-Path $clientRoot "payload\prerequisites\$($runtimeInstaller.Name)")
|
-Destination (Join-Path $clientRoot "payload\prerequisites\$($runtimeInstaller.Name)")
|
||||||
Write-PackageManifest -PackageRoot $clientRoot -PackageVersion $Version -PackageKind Client
|
Write-PackageManifest -PackageRoot $clientRoot -PackageVersion $Version -PackageKind Client
|
||||||
@@ -124,6 +127,10 @@ Copy-RequiredFile -Source (Join-Path $PSScriptRoot 'Start-SguServerBootstrap.cmd
|
|||||||
$serverScripts = @(
|
$serverScripts = @(
|
||||||
'Deploy-AuthBroker.ps1',
|
'Deploy-AuthBroker.ps1',
|
||||||
'Enable-SguServerRemoteManagement.ps1',
|
'Enable-SguServerRemoteManagement.ps1',
|
||||||
|
'Get-SguUsageReport.ps1',
|
||||||
|
'Get-SguBrokerLog.ps1',
|
||||||
|
'Install-SguDomainMonitoring.ps1',
|
||||||
|
'Invoke-SguMonitoringMaintenance.ps1',
|
||||||
'New-LabCertificate.ps1',
|
'New-LabCertificate.ps1',
|
||||||
'Register-SguClientCertificate.ps1',
|
'Register-SguClientCertificate.ps1',
|
||||||
'Set-LabBrokerDns.ps1',
|
'Set-LabBrokerDns.ps1',
|
||||||
|
|||||||
@@ -97,8 +97,10 @@ try {
|
|||||||
$releaseNotes = @"
|
$releaseNotes = @"
|
||||||
Bootstrap reproducible para el laboratorio SGU.
|
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-server-bootstrap-$Version.zip`: crea el bosque AD/DNS, OUs, grupo RDP, GPO, recurso `Packages`, broker mTLS y administración remota; se reanuda solo después del reinicio.
|
||||||
- `sgu-client-bootstrap-$Version.zip`: registra un certificado mTLS único, instala y valida el Credential Provider antes de unir el equipo al dominio, habilita RDP/WinRM y se repara al arranque.
|
- `sgu-client-bootstrap-$Version.zip`: registra un certificado mTLS único, instala y valida el Credential Provider antes de unir el equipo al dominio, habilita RDP/WinRM y se repara al arranque.
|
||||||
|
- El servidor configura WEF/WEC para registrar sesiones y fallos, inventariar el estado alcanzable de las máquinas cada cinco minutos y conservar durante 183 días tanto esos eventos como el diagnóstico estructurado del Auth Broker.
|
||||||
- Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host.
|
- Windows Home se detecta y se rechaza con una explicación, ya que no admite unión a Active Directory ni RDP host.
|
||||||
|
|
||||||
Las contraseñas se solicitan de forma interactiva y no se escriben en archivos ni en la línea de comandos. Verifique los ZIP con `SHA256SUMS-$Version.txt`.
|
Las contraseñas se solicitan de forma interactiva y no se escriben en archivos ni en la línea de comandos. Verifique los ZIP con `SHA256SUMS-$Version.txt`.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ $enrollmentRoot = Split-Path $ConfigurationPath -Parent
|
|||||||
$testScript = Join-Path $enrollmentRoot 'Test-SguClientEnrollment.ps1'
|
$testScript = Join-Path $enrollmentRoot 'Test-SguClientEnrollment.ps1'
|
||||||
$installScript = Join-Path $enrollmentRoot 'Install-CredentialProvider.ps1'
|
$installScript = Join-Path $enrollmentRoot 'Install-CredentialProvider.ps1'
|
||||||
$remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1'
|
$remoteAccessScript = Join-Path $enrollmentRoot 'Enable-LabRemoteAccess.ps1'
|
||||||
|
$monitoringScript = Join-Path $enrollmentRoot 'Enable-SguClientMonitoring.ps1'
|
||||||
|
|
||||||
$before = & $testScript
|
$before = & $testScript
|
||||||
if (-not $before.IsValid) {
|
if (-not $before.IsValid) {
|
||||||
@@ -32,6 +33,7 @@ if ($computer.PartOfDomain) {
|
|||||||
& $remoteAccessScript `
|
& $remoteAccessScript `
|
||||||
-RemoteDesktopPrincipal ([string]$configuration.RemoteDesktopPrincipal) `
|
-RemoteDesktopPrincipal ([string]$configuration.RemoteDesktopPrincipal) `
|
||||||
-EnableAdministrativeFirewallGroups | Out-Null
|
-EnableAdministrativeFirewallGroups | Out-Null
|
||||||
|
& $monitoringScript | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
$verificationParams = @{}
|
$verificationParams = @{}
|
||||||
|
|||||||
@@ -8,18 +8,65 @@ param(
|
|||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$existing = Get-DnsServerResourceRecord -ZoneName $ZoneName -Name $RecordName -RRType A -ErrorAction SilentlyContinue
|
$dnsReady = $false
|
||||||
if ($existing) {
|
for ($attempt = 1; $attempt -le 30; $attempt++) {
|
||||||
$current = @($existing.RecordData.IPv4Address.IPAddressToString)
|
try {
|
||||||
if ($current.Count -ne 1 -or $current[0] -ne $IPv4Address.IPAddressToString) {
|
$soa = @(Resolve-DnsName $ZoneName -Type SOA -DnsOnly -Server localhost `
|
||||||
# The fixed lab address is an explicit bootstrap input and may change
|
-ErrorAction Stop | Where-Object Type -eq SOA)
|
||||||
# when the server is rebuilt. Replace only this exact A record set.
|
if ($soa.Count -gt 0) {
|
||||||
$existing | Remove-DnsServerResourceRecord -ZoneName $ZoneName -Force
|
$dnsReady = $true
|
||||||
Add-DnsServerResourceRecordA -ZoneName $ZoneName -Name $RecordName -IPv4Address $IPv4Address
|
break
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# An AD-integrated zone can take a few seconds to load after DNS starts.
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
}
|
}
|
||||||
else {
|
if (-not $dnsReady) {
|
||||||
Add-DnsServerResourceRecordA -ZoneName $ZoneName -Name $RecordName -IPv4Address $IPv4Address
|
throw "DNS did not load the $ZoneName zone before the readiness timeout."
|
||||||
|
}
|
||||||
|
|
||||||
|
$recordReady = $false
|
||||||
|
for ($attempt = 1; $attempt -le 5; $attempt++) {
|
||||||
|
$existing = @(Get-DnsServerResourceRecord -ZoneName $ZoneName -Name $RecordName `
|
||||||
|
-RRType A -ErrorAction SilentlyContinue)
|
||||||
|
$unwanted = @($existing | Where-Object {
|
||||||
|
$_.RecordData.IPv4Address.IPAddressToString -ne $IPv4Address.IPAddressToString
|
||||||
|
})
|
||||||
|
foreach ($record in $unwanted) {
|
||||||
|
Remove-DnsServerResourceRecord -ZoneName $ZoneName -InputObject $record -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
$desired = @($existing | Where-Object {
|
||||||
|
$_.RecordData.IPv4Address.IPAddressToString -eq $IPv4Address.IPAddressToString
|
||||||
|
})
|
||||||
|
if ($desired.Count -eq 0) {
|
||||||
|
try {
|
||||||
|
Add-DnsServerResourceRecordA -ZoneName $ZoneName -Name $RecordName `
|
||||||
|
-IPv4Address $IPv4Address -ErrorAction Stop
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
# A record that becomes visible while an AD-integrated zone is
|
||||||
|
# finishing its load is harmless; the verified read below decides.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
$final = @(Get-DnsServerResourceRecord -ZoneName $ZoneName -Name $RecordName `
|
||||||
|
-RRType A -ErrorAction SilentlyContinue)
|
||||||
|
$finalAddresses = @($final | ForEach-Object {
|
||||||
|
$_.RecordData.IPv4Address.IPAddressToString
|
||||||
|
})
|
||||||
|
if ($finalAddresses.Count -eq 1 -and
|
||||||
|
$finalAddresses[0] -eq $IPv4Address.IPAddressToString) {
|
||||||
|
$recordReady = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
if (-not $recordReady) {
|
||||||
|
throw "The $RecordName.$ZoneName A record could not be set exclusively to $IPv4Address."
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($ExternalForwarders.Count -gt 0) {
|
if ($ExternalForwarders.Count -gt 0) {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
param(
|
param(
|
||||||
[string]$TargetOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
|
[string]$TargetOuDn = 'OU=Laboratorio,DC=lci,DC=lasalle,DC=mx',
|
||||||
[string]$GpoName = 'SGU - Windows client experience',
|
[string]$GpoName = 'SGU - Windows client experience',
|
||||||
[string]$DomainController = $env:COMPUTERNAME
|
[string]$DomainController = $env:COMPUTERNAME,
|
||||||
|
[string]$EventCollectorFqdn
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
@@ -24,6 +25,14 @@ if (-not $domainDn) {
|
|||||||
throw 'TargetOuDn does not contain a domain distinguished name.'
|
throw 'TargetOuDn does not contain a domain distinguished name.'
|
||||||
}
|
}
|
||||||
$domainName = ($domainDn -replace ',DC=', '.')
|
$domainName = ($domainDn -replace ',DC=', '.')
|
||||||
|
if (-not $EventCollectorFqdn) {
|
||||||
|
$collectorComputer = Get-ADComputer -Identity $DomainController -Properties DNSHostName `
|
||||||
|
-Server $DomainController -ErrorAction Stop
|
||||||
|
$EventCollectorFqdn = $collectorComputer.DNSHostName
|
||||||
|
}
|
||||||
|
if (-not $EventCollectorFqdn) {
|
||||||
|
throw 'Could not determine the event collector FQDN.'
|
||||||
|
}
|
||||||
|
|
||||||
$gpo = Get-GPO -Name $GpoName -Domain $domainName -Server $DomainController -ErrorAction SilentlyContinue
|
$gpo = Get-GPO -Name $GpoName -Domain $domainName -Server $DomainController -ErrorAction SilentlyContinue
|
||||||
if (-not $gpo -and $PSCmdlet.ShouldProcess($GpoName, 'Create the SGU Windows client policy GPO')) {
|
if (-not $gpo -and $PSCmdlet.ShouldProcess($GpoName, 'Create the SGU Windows client policy GPO')) {
|
||||||
@@ -61,15 +70,36 @@ elseif (-not $existingLinkEnabled -and
|
|||||||
|
|
||||||
$dataCollectionKey = 'HKLM\Software\Policies\Microsoft\Windows\DataCollection'
|
$dataCollectionKey = 'HKLM\Software\Policies\Microsoft\Windows\DataCollection'
|
||||||
$powerPolicyRoot = 'HKLM\Software\Policies\Microsoft\Power\PowerSettings'
|
$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 = @(
|
$policies = @(
|
||||||
@{ Key = $dataCollectionKey; Name = 'AllowTelemetry'; Value = 0 },
|
@{ Key = $dataCollectionKey; Name = 'AllowTelemetry'; Type = 'DWord'; Value = 0 },
|
||||||
@{ Key = $dataCollectionKey; Name = 'DisableTelemetryOptInSettingsUx'; Value = 1 },
|
@{ Key = $dataCollectionKey; Name = 'DisableTelemetryOptInSettingsUx'; Type = 'DWord'; Value = 1 },
|
||||||
@{ Key = $dataCollectionKey; Name = 'DisableTelemetryOptInChangeNotification'; Value = 1 },
|
@{ Key = $dataCollectionKey; Name = 'DisableTelemetryOptInChangeNotification'; Type = 'DWord'; Value = 1 },
|
||||||
@{ Key = $dataCollectionKey; Name = 'DisableDiagnosticDataViewer'; Value = 1 },
|
@{ Key = $dataCollectionKey; Name = 'DisableDiagnosticDataViewer'; Type = 'DWord'; Value = 1 },
|
||||||
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\OOBE'; Name = 'DisablePrivacyExperience'; Value = 1 },
|
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\OOBE'; Name = 'DisablePrivacyExperience'; Type = 'DWord'; Value = 1 },
|
||||||
@{ Key = 'HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System'; Name = 'EnableFirstLogonAnimation'; Value = 0 },
|
@{ Key = $interactiveLogonPolicyKey; Name = 'EnableFirstLogonAnimation'; Type = 'DWord'; Value = 0 },
|
||||||
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\LocationAndSensors'; Name = 'DisableLocation'; Value = 1 },
|
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\LocationAndSensors'; Name = 'DisableLocation'; Type = 'DWord'; Value = 1 },
|
||||||
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\AppPrivacy'; Name = 'LetAppsAccessLocation'; Value = 2 }
|
@{ Key = 'HKLM\Software\Policies\Microsoft\Windows\AppPrivacy'; Name = 'LetAppsAccessLocation'; Type = 'DWord'; Value = 2 },
|
||||||
|
|
||||||
|
# Enrollment selects the provider before domain join; this computer GPO
|
||||||
|
# becomes the authoritative, self-healing configuration afterwards.
|
||||||
|
@{ Key = $credentialProviderPolicyKey; Name = 'DefaultCredentialProvider'; Type = 'String'; Value = $providerClassId },
|
||||||
|
@{ Key = $credentialProviderPolicyKey; Name = 'EnumerateLocalUsers'; Type = 'DWord'; Value = 0 },
|
||||||
|
@{ Key = $interactiveLogonPolicyKey; Name = 'DontDisplayLastUserName'; Type = 'DWord'; Value = 1 },
|
||||||
|
|
||||||
|
# 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 },
|
||||||
|
|
||||||
|
# Source-initiated Windows Event Forwarding. Kerberos authenticates domain
|
||||||
|
# computers to the collector; no SGU password or reusable secret is logged.
|
||||||
|
@{ Key = $eventForwardingPolicyKey; Name = '1'; Type = 'String'; Value = "Server=http://${EventCollectorFqdn}:5985/wsman/SubscriptionManager/WEC,Refresh=300" },
|
||||||
|
@{ Key = $auditPolicyKey; Name = 'SCENoApplyLegacyAuditPolicy'; Type = 'DWord'; Value = 1 }
|
||||||
)
|
)
|
||||||
|
|
||||||
$powerSettingIds = @(
|
$powerSettingIds = @(
|
||||||
@@ -80,8 +110,8 @@ $powerSettingIds = @(
|
|||||||
)
|
)
|
||||||
foreach ($settingId in $powerSettingIds) {
|
foreach ($settingId in $powerSettingIds) {
|
||||||
$settingKey = "$powerPolicyRoot\$settingId"
|
$settingKey = "$powerPolicyRoot\$settingId"
|
||||||
$policies += @{ Key = $settingKey; Name = 'ACSettingIndex'; Value = 0 }
|
$policies += @{ Key = $settingKey; Name = 'ACSettingIndex'; Type = 'DWord'; Value = 0 }
|
||||||
$policies += @{ Key = $settingKey; Name = 'DCSettingIndex'; Value = 0 }
|
$policies += @{ Key = $settingKey; Name = 'DCSettingIndex'; Type = 'DWord'; Value = 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($policy in $policies) {
|
foreach ($policy in $policies) {
|
||||||
@@ -92,7 +122,7 @@ foreach ($policy in $policies) {
|
|||||||
-Server $DomainController `
|
-Server $DomainController `
|
||||||
-Key $policy.Key `
|
-Key $policy.Key `
|
||||||
-ValueName $policy.Name `
|
-ValueName $policy.Name `
|
||||||
-Type DWord `
|
-Type $policy.Type `
|
||||||
-Value $policy.Value | Out-Null
|
-Value $policy.Value | Out-Null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +135,7 @@ foreach ($policy in $policies) {
|
|||||||
-Server $DomainController `
|
-Server $DomainController `
|
||||||
-Key $policy.Key `
|
-Key $policy.Key `
|
||||||
-ValueName $policy.Name
|
-ValueName $policy.Name
|
||||||
$configuredPolicies[$policy.Name + '@' + $policy.Key] = [int]$configured.Value
|
$configuredPolicies[$policy.Name + '@' + $policy.Key] = $configured.Value
|
||||||
}
|
}
|
||||||
$link = @(Get-GPInheritance -Target $TargetOuDn -Domain $domainName -Server $DomainController).GpoLinks |
|
$link = @(Get-GPInheritance -Target $TargetOuDn -Domain $domainName -Server $DomainController).GpoLinks |
|
||||||
Where-Object DisplayName -eq $GpoName |
|
Where-Object DisplayName -eq $GpoName |
|
||||||
@@ -120,5 +150,6 @@ $linkEnabled = $link -and (
|
|||||||
TargetOu = $TargetOuDn
|
TargetOu = $TargetOuDn
|
||||||
LinkEnabled = [bool]$linkEnabled
|
LinkEnabled = [bool]$linkEnabled
|
||||||
PolicyCount = $configuredPolicies.Count
|
PolicyCount = $configuredPolicies.Count
|
||||||
|
EventCollector = $EventCollectorFqdn
|
||||||
Policies = [pscustomobject]$configuredPolicies
|
Policies = [pscustomobject]$configuredPolicies
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ $ErrorActionPreference = 'Stop'
|
|||||||
$policyKey = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\System'
|
$policyKey = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Policies\System'
|
||||||
$policyValueName = 'DisableLockWorkstation'
|
$policyValueName = 'DisableLockWorkstation'
|
||||||
$desktopPolicyKey = 'HKCU\Software\Policies\Microsoft\Windows\Control Panel\Desktop'
|
$desktopPolicyKey = 'HKCU\Software\Policies\Microsoft\Windows\Control Panel\Desktop'
|
||||||
|
$themeKey = 'HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize'
|
||||||
|
|
||||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||||
@@ -83,6 +84,19 @@ if ($PSCmdlet.ShouldProcess($GpoName, 'Prevent SGU users from manually locking w
|
|||||||
-Type String `
|
-Type String `
|
||||||
-Value '0' | Out-Null
|
-Value '0' | Out-Null
|
||||||
|
|
||||||
|
# Apply the native Windows dark theme at user logon. Both values are required:
|
||||||
|
# one controls the shell and the other controls supported applications.
|
||||||
|
foreach ($themeValueName in 'AppsUseLightTheme', 'SystemUsesLightTheme') {
|
||||||
|
Set-GPRegistryValue `
|
||||||
|
-Name $GpoName `
|
||||||
|
-Domain $domainName `
|
||||||
|
-Server $DomainController `
|
||||||
|
-Key $themeKey `
|
||||||
|
-ValueName $themeValueName `
|
||||||
|
-Type DWord `
|
||||||
|
-Value 0 | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
if ($WallpaperPath) {
|
if ($WallpaperPath) {
|
||||||
Set-GPRegistryValue `
|
Set-GPRegistryValue `
|
||||||
-Name $GpoName `
|
-Name $GpoName `
|
||||||
@@ -115,6 +129,18 @@ $screenSaverValue = Get-GPRegistryValue `
|
|||||||
-Server $DomainController `
|
-Server $DomainController `
|
||||||
-Key $desktopPolicyKey `
|
-Key $desktopPolicyKey `
|
||||||
-ValueName 'ScreenSaveActive'
|
-ValueName 'ScreenSaveActive'
|
||||||
|
$appsThemeValue = Get-GPRegistryValue `
|
||||||
|
-Name $GpoName `
|
||||||
|
-Domain $domainName `
|
||||||
|
-Server $DomainController `
|
||||||
|
-Key $themeKey `
|
||||||
|
-ValueName 'AppsUseLightTheme'
|
||||||
|
$systemThemeValue = Get-GPRegistryValue `
|
||||||
|
-Name $GpoName `
|
||||||
|
-Domain $domainName `
|
||||||
|
-Server $DomainController `
|
||||||
|
-Key $themeKey `
|
||||||
|
-ValueName 'SystemUsesLightTheme'
|
||||||
$link = @(Get-GPInheritance -Target $TargetOuDn -Domain $domainName -Server $DomainController).GpoLinks |
|
$link = @(Get-GPInheritance -Target $TargetOuDn -Domain $domainName -Server $DomainController).GpoLinks |
|
||||||
Where-Object DisplayName -eq $GpoName |
|
Where-Object DisplayName -eq $GpoName |
|
||||||
Select-Object -First 1
|
Select-Object -First 1
|
||||||
@@ -138,5 +164,6 @@ if ($WallpaperPath) {
|
|||||||
LinkEnabled = [bool]$linkEnabled
|
LinkEnabled = [bool]$linkEnabled
|
||||||
DisableLockWorkstation = [int]$configuredValue.Value
|
DisableLockWorkstation = [int]$configuredValue.Value
|
||||||
ScreenSaverDisabled = [string]$screenSaverValue.Value -eq '0'
|
ScreenSaverDisabled = [string]$screenSaverValue.Value -eq '0'
|
||||||
|
DarkMode = ([int]$appsThemeValue.Value -eq 0) -and ([int]$systemThemeValue.Value -eq 0)
|
||||||
Wallpaper = $configuredWallpaper
|
Wallpaper = $configuredWallpaper
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
using System.Net.Security;
|
using System.Net.Security;
|
||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using Microsoft.AspNetCore.Server.Kestrel.Https;
|
using Microsoft.AspNetCore.Server.Kestrel.Https;
|
||||||
|
using SGU.AuthBroker;
|
||||||
using SGU.AuthBroker.Contracts;
|
using SGU.AuthBroker.Contracts;
|
||||||
using SGU.AuthBroker.Core.Authentication;
|
using SGU.AuthBroker.Core.Authentication;
|
||||||
using SGU.AuthBroker.Core.Directory;
|
using SGU.AuthBroker.Core.Directory;
|
||||||
@@ -10,6 +12,16 @@ using SGU.AuthBroker.Services;
|
|||||||
|
|
||||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||||
builder.Host.UseWindowsService(options => options.ServiceName = "SGU Authentication Broker");
|
builder.Host.UseWindowsService(options => options.ServiceName = "SGU Authentication Broker");
|
||||||
|
if (builder.Configuration.GetValue("Broker:Diagnostics:UseDedicatedEventLog", false))
|
||||||
|
{
|
||||||
|
builder.Logging.ClearProviders();
|
||||||
|
builder.Logging.AddEventLog(settings =>
|
||||||
|
{
|
||||||
|
settings.LogName = "SGU Auth Broker";
|
||||||
|
settings.SourceName = "SGU.AuthBroker.Operational";
|
||||||
|
settings.Filter = (_, level) => level >= LogLevel.Information;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
BrokerOptions brokerOptions = builder.Configuration
|
BrokerOptions brokerOptions = builder.Configuration
|
||||||
.GetSection(BrokerOptions.SectionName)
|
.GetSection(BrokerOptions.SectionName)
|
||||||
@@ -56,6 +68,12 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
ILogger auditLogger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||||
|
.CreateLogger("SGU.AuthBroker.Audit");
|
||||||
|
auditLogger.LogInformation(
|
||||||
|
BrokerEventIds.BrokerStarted,
|
||||||
|
"SGU Authentication Broker started with dedicated operational diagnostics enabled={DedicatedDiagnosticsEnabled}.",
|
||||||
|
builder.Configuration.GetValue("Broker:Diagnostics:UseDedicatedEventLog", false));
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
app.Use(async (context, next) =>
|
app.Use(async (context, next) =>
|
||||||
{
|
{
|
||||||
@@ -75,16 +93,58 @@ app.MapPost("/v1/authenticate", async (
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Password) || request.Password.Length > 256)
|
if (string.IsNullOrWhiteSpace(request.Password) || request.Password.Length > 256)
|
||||||
{
|
{
|
||||||
|
auditLogger.LogInformation(
|
||||||
|
BrokerEventIds.AuthenticationInvalidRequest,
|
||||||
|
"Authentication request rejected before validation for {InstitutionalUser}: password was missing or outside the supported length.",
|
||||||
|
SafeUserName(request.Clave));
|
||||||
request.ReleasePasswordReference();
|
request.ReleasePasswordReference();
|
||||||
return Results.BadRequest(new ErrorResponse("MISSING_PASSWORD", "La contraseña es requerida."));
|
return Results.BadRequest(new ErrorResponse("MISSING_PASSWORD", "La contraseña es requerida."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Stopwatch elapsed = Stopwatch.StartNew();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
AuthenticationFlowResult result = await workflow
|
AuthenticationFlowResult result = await workflow
|
||||||
.AuthenticateAsync(request.Clave, request.Password, cancellationToken)
|
.AuthenticateAsync(request.Clave, request.Password, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
string institutionalUser = result.Identity?.UserName ?? SafeUserName(request.Clave);
|
||||||
|
switch (result.Outcome)
|
||||||
|
{
|
||||||
|
case AuthenticationFlowOutcome.Authorized:
|
||||||
|
auditLogger.LogInformation(
|
||||||
|
BrokerEventIds.AuthenticationAuthorized,
|
||||||
|
"Authentication completed for {InstitutionalUser} with role {Role} in {ElapsedMilliseconds} ms. AD created={Created}; moved={Moved}.",
|
||||||
|
institutionalUser,
|
||||||
|
result.Identity!.Role,
|
||||||
|
elapsed.ElapsedMilliseconds,
|
||||||
|
result.Directory!.Created,
|
||||||
|
result.Directory.Moved);
|
||||||
|
break;
|
||||||
|
case AuthenticationFlowOutcome.InvalidCredentials:
|
||||||
|
auditLogger.LogInformation(
|
||||||
|
BrokerEventIds.AuthenticationRejected,
|
||||||
|
"Authentication was rejected for {InstitutionalUser} with code {ErrorCode} after {ElapsedMilliseconds} ms.",
|
||||||
|
institutionalUser,
|
||||||
|
result.ErrorCode,
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
break;
|
||||||
|
case AuthenticationFlowOutcome.Unavailable:
|
||||||
|
auditLogger.LogWarning(
|
||||||
|
BrokerEventIds.AuthenticationUnavailable,
|
||||||
|
"Authentication was unavailable for {InstitutionalUser} with code {ErrorCode} after {ElapsedMilliseconds} ms.",
|
||||||
|
institutionalUser,
|
||||||
|
result.ErrorCode,
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
auditLogger.LogInformation(
|
||||||
|
BrokerEventIds.AuthenticationInvalidRequest,
|
||||||
|
"Authentication request had an invalid institutional user format after {ElapsedMilliseconds} ms.",
|
||||||
|
elapsed.ElapsedMilliseconds);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
return result.Outcome switch
|
return result.Outcome switch
|
||||||
{
|
{
|
||||||
AuthenticationFlowOutcome.Authorized => Results.Ok(new AuthenticationResponse(
|
AuthenticationFlowOutcome.Authorized => Results.Ok(new AuthenticationResponse(
|
||||||
@@ -125,3 +185,11 @@ static IResult Unavailable(HttpContext context, string? errorCode)
|
|||||||
|
|
||||||
static string NormalizeThumbprint(string value) =>
|
static string NormalizeThumbprint(string value) =>
|
||||||
value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant();
|
value.Replace(" ", string.Empty, StringComparison.Ordinal).ToUpperInvariant();
|
||||||
|
|
||||||
|
static string SafeUserName(string? value)
|
||||||
|
{
|
||||||
|
string candidate = value?.Trim().ToUpperInvariant() ?? string.Empty;
|
||||||
|
return candidate.Length is > 0 and <= 16 && candidate.All(char.IsAsciiLetterOrDigit)
|
||||||
|
? candidate
|
||||||
|
: "<invalid-format>";
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ using SGU.AuthBroker.Options;
|
|||||||
|
|
||||||
namespace SGU.AuthBroker.Services;
|
namespace SGU.AuthBroker.Services;
|
||||||
|
|
||||||
public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActiveDirectorySynchronizer
|
public sealed class ActiveDirectorySynchronizer(
|
||||||
|
BrokerOptions options,
|
||||||
|
ILogger<ActiveDirectorySynchronizer> logger) : IActiveDirectorySynchronizer
|
||||||
{
|
{
|
||||||
private const int AccountDisabled = 0x0002;
|
private const int AccountDisabled = 0x0002;
|
||||||
private const int NormalAccount = 0x0200;
|
private const int NormalAccount = 0x0200;
|
||||||
@@ -26,12 +28,29 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
|||||||
{
|
{
|
||||||
SemaphoreSlim gate = userLocks.GetOrAdd(identity.UserName, static _ => new SemaphoreSlim(1, 1));
|
SemaphoreSlim gate = userLocks.GetOrAdd(identity.UserName, static _ => new SemaphoreSlim(1, 1));
|
||||||
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await Task.Run(
|
return await Task.Run(
|
||||||
() => Synchronize(identity, profile, password),
|
() => Synchronize(identity, profile, password),
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(
|
||||||
|
BrokerEventIds.DirectorySynchronizationFailure,
|
||||||
|
exception,
|
||||||
|
"Active Directory synchronization failed for {InstitutionalUser} with role {Role}.",
|
||||||
|
identity.UserName,
|
||||||
|
identity.Role);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
gate.Release();
|
gate.Release();
|
||||||
@@ -100,8 +119,8 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
|||||||
user.Properties["pwdLastSet"].Value = -1;
|
user.Properties["pwdLastSet"].Value = -1;
|
||||||
user.CommitChanges();
|
user.CommitChanges();
|
||||||
|
|
||||||
TryApplyProfile(user, identity, profile, options.DefaultCompany);
|
TryApplyProfile(user, identity, profile, options.DefaultCompany, logger);
|
||||||
TryEnsureRemoteDesktopGroupMembership(user);
|
TryEnsureRemoteDesktopGroupMembership(user, identity.UserName);
|
||||||
|
|
||||||
return new DirectorySyncResult(
|
return new DirectorySyncResult(
|
||||||
options.DomainNetbios,
|
options.DomainNetbios,
|
||||||
@@ -120,7 +139,8 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
|||||||
DirectoryEntry user,
|
DirectoryEntry user,
|
||||||
UserIdentity identity,
|
UserIdentity identity,
|
||||||
InstitutionalProfile? profile,
|
InstitutionalProfile? profile,
|
||||||
string defaultCompany)
|
string defaultCompany,
|
||||||
|
ILogger logger)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -146,10 +166,15 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
|||||||
|
|
||||||
user.CommitChanges();
|
user.CommitChanges();
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
// Metadata is intentionally best-effort. User creation, password sync,
|
// Metadata is intentionally best-effort. User creation, password sync,
|
||||||
// and account enablement have already committed successfully.
|
// and account enablement have already committed successfully.
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.DirectoryOptionalMetadataFailure,
|
||||||
|
exception,
|
||||||
|
"Optional Active Directory profile metadata could not be committed for {InstitutionalUser}; password synchronization remains completed.",
|
||||||
|
identity.UserName);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
user.RefreshCache();
|
user.RefreshCache();
|
||||||
@@ -170,7 +195,7 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user)
|
private void TryEnsureRemoteDesktopGroupMembership(DirectoryEntry user, string institutionalUser)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn))
|
if (string.IsNullOrWhiteSpace(options.RemoteDesktopGroupDn))
|
||||||
{
|
{
|
||||||
@@ -194,10 +219,15 @@ public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActive
|
|||||||
group.CommitChanges();
|
group.CommitChanges();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
// Remote access is lab policy and must not invalidate a completed
|
// Remote access is lab policy and must not invalidate a completed
|
||||||
// password synchronization if the optional group is unavailable.
|
// password synchronization if the optional group is unavailable.
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.DirectoryGroupMembershipFailure,
|
||||||
|
exception,
|
||||||
|
"Optional remote-desktop group membership could not be updated for {InstitutionalUser}; password synchronization remains completed.",
|
||||||
|
institutionalUser);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
string password,
|
string password,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
using IDisposable? logScope = logger.BeginScope(
|
||||||
|
"InstitutionalUser={InstitutionalUser}; InstitutionalRole={InstitutionalRole}",
|
||||||
|
identity.UserName,
|
||||||
|
identity.Role);
|
||||||
Uri authenticationUri = new(
|
Uri authenticationUri = new(
|
||||||
new Uri(options.Endpoint, UriKind.Absolute),
|
new Uri(options.Endpoint, UriKind.Absolute),
|
||||||
options.AuthenticationPath);
|
options.AuthenticationPath);
|
||||||
@@ -155,6 +159,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
|
BrokerEventIds.SguAuthenticationAccepted,
|
||||||
"SGU accepted credentials after an explicit NTLM challenge in {ElapsedMilliseconds} ms.",
|
"SGU accepted credentials after an explicit NTLM challenge in {ElapsedMilliseconds} ms.",
|
||||||
elapsed.ElapsedMilliseconds);
|
elapsed.ElapsedMilliseconds);
|
||||||
return (null, continuationUri);
|
return (null, continuationUri);
|
||||||
@@ -184,6 +189,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.SguAuthenticationTimeout,
|
||||||
"SGU NTLM authentication timed out after {ElapsedMilliseconds} ms.",
|
"SGU NTLM authentication timed out after {ElapsedMilliseconds} ms.",
|
||||||
elapsed.ElapsedMilliseconds);
|
elapsed.ElapsedMilliseconds);
|
||||||
return (NtlmValidationResult.Unavailable("NTLM_TIMEOUT"), null);
|
return (NtlmValidationResult.Unavailable("NTLM_TIMEOUT"), null);
|
||||||
@@ -191,6 +197,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
catch (HttpRequestException exception)
|
catch (HttpRequestException exception)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.SguAuthenticationNetworkFailure,
|
||||||
exception,
|
exception,
|
||||||
"SGU NTLM authentication failed after {ElapsedMilliseconds} ms.",
|
"SGU NTLM authentication failed after {ElapsedMilliseconds} ms.",
|
||||||
elapsed.ElapsedMilliseconds);
|
elapsed.ElapsedMilliseconds);
|
||||||
@@ -347,6 +354,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
if (profile is null)
|
if (profile is null)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileHtmlUnexpected,
|
||||||
"SGU returned a profile page for role {Role}, but no supported profile fields were found after {ElapsedMilliseconds} ms.",
|
"SGU returned a profile page for role {Role}, but no supported profile fields were found after {ElapsedMilliseconds} ms.",
|
||||||
identity.Role,
|
identity.Role,
|
||||||
elapsed.ElapsedMilliseconds);
|
elapsed.ElapsedMilliseconds);
|
||||||
@@ -354,8 +362,10 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"SGU profile enrichment completed for role {Role} in {ElapsedMilliseconds} ms.",
|
BrokerEventIds.ProfileEnrichmentCompleted,
|
||||||
|
"SGU profile enrichment completed for role {Role} with {ProfileFieldCount} supported fields in {ElapsedMilliseconds} ms.",
|
||||||
identity.Role,
|
identity.Role,
|
||||||
|
CountProfileFields(profile),
|
||||||
elapsed.ElapsedMilliseconds);
|
elapsed.ElapsedMilliseconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +392,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileEnrichmentTimeout,
|
||||||
"SGU profile request for role {Role} timed out after {ElapsedMilliseconds} ms.",
|
"SGU profile request for role {Role} timed out after {ElapsedMilliseconds} ms.",
|
||||||
identity.Role,
|
identity.Role,
|
||||||
elapsed.ElapsedMilliseconds);
|
elapsed.ElapsedMilliseconds);
|
||||||
@@ -389,6 +400,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileEnrichmentFailure,
|
||||||
exception,
|
exception,
|
||||||
"SGU profile enrichment failed for role {Role} after {ElapsedMilliseconds} ms.",
|
"SGU profile enrichment failed for role {Role} after {ElapsedMilliseconds} ms.",
|
||||||
identity.Role,
|
identity.Role,
|
||||||
@@ -423,11 +435,33 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
GetProfileUri(path),
|
GetProfileUri(path),
|
||||||
allowedHosts,
|
allowedHosts,
|
||||||
timeoutToken).ConfigureAwait(false);
|
timeoutToken).ConfigureAwait(false);
|
||||||
profile = profile.Overlay(html is null ? null : parser(html));
|
if (html is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfilePageUnavailable,
|
||||||
|
"Optional SGU profile page {Path} did not return usable HTML for role {Role}; preserving fields already collected.",
|
||||||
|
path,
|
||||||
|
role);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
InstitutionalProfile? pageProfile = parser(html);
|
||||||
|
if (pageProfile is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileHtmlUnexpected,
|
||||||
|
"Optional SGU profile page {Path} returned HTML without its supported field IDs for role {Role}; preserving fields already collected.",
|
||||||
|
path,
|
||||||
|
role);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
profile = profile.Overlay(pageProfile);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileEnrichmentTimeout,
|
||||||
"SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
"SGU optional staff profile enrichment for role {Role} reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||||
role,
|
role,
|
||||||
elapsed.ElapsedMilliseconds);
|
elapsed.ElapsedMilliseconds);
|
||||||
@@ -436,6 +470,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileEnrichmentFailure,
|
||||||
exception,
|
exception,
|
||||||
"An optional SGU staff profile page for role {Role} failed after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
"An optional SGU staff profile page for role {Role} failed after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||||
role,
|
role,
|
||||||
@@ -487,6 +522,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfilePageUnavailable,
|
||||||
"Optional SGU profile page {Path} returned HTTP {StatusCode}.",
|
"Optional SGU profile page {Path} returned HTTP {StatusCode}.",
|
||||||
requestedUri.AbsolutePath,
|
requestedUri.AbsolutePath,
|
||||||
statusCode);
|
statusCode);
|
||||||
@@ -494,6 +530,7 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfilePageUnavailable,
|
||||||
"Optional SGU profile page {Path} exceeded the redirect limit.",
|
"Optional SGU profile page {Path} exceeded the redirect limit.",
|
||||||
requestedUri.AbsolutePath);
|
requestedUri.AbsolutePath);
|
||||||
return null;
|
return null;
|
||||||
@@ -550,6 +587,23 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
right.AbsolutePath.TrimEnd('/'),
|
right.AbsolutePath.TrimEnd('/'),
|
||||||
StringComparison.OrdinalIgnoreCase);
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static int CountProfileFields(InstitutionalProfile profile) =>
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
profile.EmployeeNumber,
|
||||||
|
profile.DisplayName,
|
||||||
|
profile.GivenName,
|
||||||
|
profile.Surname,
|
||||||
|
profile.Email,
|
||||||
|
profile.EmployeeType,
|
||||||
|
profile.JobTitle,
|
||||||
|
profile.Department,
|
||||||
|
profile.StreetAddress,
|
||||||
|
profile.City,
|
||||||
|
profile.State,
|
||||||
|
profile.PostalCode
|
||||||
|
}.Count(value => !string.IsNullOrWhiteSpace(value));
|
||||||
|
|
||||||
private static async Task DrainResponseAsync(
|
private static async Task DrainResponseAsync(
|
||||||
HttpResponseMessage response,
|
HttpResponseMessage response,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -604,17 +658,31 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
|||||||
response.Content,
|
response.Content,
|
||||||
options.MaxProfileBytes,
|
options.MaxProfileBytes,
|
||||||
timeoutToken).ConfigureAwait(false);
|
timeoutToken).ConfigureAwait(false);
|
||||||
return identity.Role switch
|
InstitutionalProfile? profile;
|
||||||
|
switch (identity.Role)
|
||||||
{
|
{
|
||||||
InstitutionalRole.Administrative =>
|
case InstitutionalRole.Administrative:
|
||||||
SguProfileParser.ParseAdministrative(html, identity.NumericId) ??
|
profile = SguProfileParser.ParseAdministrative(html, identity.NumericId);
|
||||||
SguProfileParser.ParseMenu(html),
|
break;
|
||||||
InstitutionalRole.Student =>
|
case InstitutionalRole.Student:
|
||||||
SguProfileParser.ParseStudent(html, identity.NumericId) ??
|
profile = SguProfileParser.ParseStudent(html, identity.NumericId);
|
||||||
SguProfileParser.ParseMenu(html),
|
break;
|
||||||
InstitutionalRole.Professor => SguProfileParser.ParseMenu(html),
|
case InstitutionalRole.Professor:
|
||||||
_ => null
|
return SguProfileParser.ParseMenu(html);
|
||||||
};
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile is not null)
|
||||||
|
{
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogWarning(
|
||||||
|
BrokerEventIds.ProfileHtmlUnexpected,
|
||||||
|
"The primary SGU profile HTML did not contain the supported field IDs for role {Role}; attempting the menu-name fallback.",
|
||||||
|
identity.Role);
|
||||||
|
return SguProfileParser.ParseMenu(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<string> ReadLimitedStringAsync(
|
private static async Task<string> ReadLimitedStringAsync(
|
||||||
|
|||||||
@@ -1,35 +1,32 @@
|
|||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Drawing.Drawing2D;
|
|
||||||
using System.Drawing.Imaging;
|
using System.Drawing.Imaging;
|
||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
|
||||||
namespace SGU.CredentialProvider;
|
namespace SGU.CredentialProvider;
|
||||||
|
|
||||||
internal static class ProviderTileIcon
|
internal static class ProviderTileIcon
|
||||||
{
|
{
|
||||||
public const int Size = 72;
|
// LogonUI enlarges the dedicated-tile artwork. Supply a dense source image
|
||||||
|
// so the mascot remains crisp at the large sign-in surface.
|
||||||
|
public const int Size = 256;
|
||||||
|
private const string MascotResourceName = "SGU.CredentialProvider.Branding.LaSalleMascot.png";
|
||||||
|
|
||||||
public static Bitmap Create()
|
public static Bitmap Create()
|
||||||
{
|
{
|
||||||
Bitmap bitmap = new(Size, Size, PixelFormat.Format32bppArgb);
|
Bitmap bitmap = new(Size, Size, PixelFormat.Format32bppArgb);
|
||||||
using Graphics graphics = Graphics.FromImage(bitmap);
|
using Graphics graphics = Graphics.FromImage(bitmap);
|
||||||
graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
graphics.CompositingQuality = CompositingQuality.HighQuality;
|
||||||
|
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
||||||
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||||
graphics.Clear(Color.Transparent);
|
graphics.Clear(Color.Transparent);
|
||||||
|
|
||||||
using SolidBrush background = new(Color.FromArgb(0, 83, 155));
|
using Stream sourceStream = typeof(ProviderTileIcon).Assembly.GetManifestResourceStream(MascotResourceName)
|
||||||
graphics.FillEllipse(background, 1, 1, Size - 2, Size - 2);
|
?? throw new InvalidOperationException($"The branded Credential Provider logo '{MascotResourceName}' is unavailable.");
|
||||||
|
using Bitmap mascot = new(sourceStream);
|
||||||
using Pen key = new(Color.White, 5.5f)
|
using GraphicsPath circularMask = new();
|
||||||
{
|
circularMask.AddEllipse(0, 0, Size, Size);
|
||||||
StartCap = LineCap.Round,
|
graphics.SetClip(circularMask);
|
||||||
EndCap = LineCap.Round,
|
graphics.DrawImage(mascot, new Rectangle(0, 0, Size, Size));
|
||||||
LineJoin = LineJoin.Round
|
|
||||||
};
|
|
||||||
|
|
||||||
graphics.DrawEllipse(key, 14, 14, 25, 25);
|
|
||||||
graphics.DrawLine(key, 35, 35, 57, 57);
|
|
||||||
graphics.DrawLine(key, 47, 47, 55, 39);
|
|
||||||
graphics.DrawLine(key, 53, 53, 61, 45);
|
|
||||||
|
|
||||||
return bitmap;
|
return bitmap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,11 @@
|
|||||||
<ProjectReference Include="..\SGU.AuthBroker.Core\SGU.AuthBroker.Core.csproj" />
|
<ProjectReference Include="..\SGU.AuthBroker.Core\SGU.AuthBroker.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="..\..\assets\branding\lasalle-mexico-provider.png"
|
||||||
|
LogicalName="SGU.CredentialProvider.Branding.LaSalleMascot.png" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||||
<_Parameter1>SGU.CredentialProvider.Tests</_Parameter1>
|
<_Parameter1>SGU.CredentialProvider.Tests</_Parameter1>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace SGU.CredentialProvider.Tests;
|
|||||||
public sealed class ProviderTileIconTests
|
public sealed class ProviderTileIconTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ProviderPublishesASeventyTwoPixelLogoForSignInOptions()
|
public void ProviderPublishesAHighResolutionLogoForSignInOptions()
|
||||||
{
|
{
|
||||||
SguCredentialProvider provider = new();
|
SguCredentialProvider provider = new();
|
||||||
|
|
||||||
@@ -20,22 +20,27 @@ public sealed class ProviderTileIconTests
|
|||||||
Assert.Equal(0, logo.Bitmap.GetPixel(ProviderTileIcon.Size - 1, 0).A);
|
Assert.Equal(0, logo.Bitmap.GetPixel(ProviderTileIcon.Size - 1, 0).A);
|
||||||
Assert.Equal(0, logo.Bitmap.GetPixel(0, ProviderTileIcon.Size - 1).A);
|
Assert.Equal(0, logo.Bitmap.GetPixel(0, ProviderTileIcon.Size - 1).A);
|
||||||
Assert.Equal(0, logo.Bitmap.GetPixel(ProviderTileIcon.Size - 1, ProviderTileIcon.Size - 1).A);
|
Assert.Equal(0, logo.Bitmap.GetPixel(ProviderTileIcon.Size - 1, ProviderTileIcon.Size - 1).A);
|
||||||
Assert.Equal(
|
int redPixels = 0;
|
||||||
Color.FromArgb(0, 83, 155).ToArgb(),
|
int navyPixels = 0;
|
||||||
logo.Bitmap.GetPixel(6, ProviderTileIcon.Size / 2).ToArgb());
|
|
||||||
int lightPixels = 0;
|
|
||||||
for (int x = 0; x < logo.Bitmap.Width; x++)
|
for (int x = 0; x < logo.Bitmap.Width; x++)
|
||||||
{
|
{
|
||||||
for (int y = 0; y < logo.Bitmap.Height; y++)
|
for (int y = 0; y < logo.Bitmap.Height; y++)
|
||||||
{
|
{
|
||||||
if (logo.Bitmap.GetPixel(x, y).GetBrightness() > 0.7f)
|
Color pixel = logo.Bitmap.GetPixel(x, y);
|
||||||
|
if (pixel.R > 160 && pixel.G < 100 && pixel.B < 100)
|
||||||
{
|
{
|
||||||
lightPixels++;
|
redPixels++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pixel.B > pixel.R && pixel.B > pixel.G && pixel.R < 70)
|
||||||
|
{
|
||||||
|
navyPixels++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Assert.InRange(lightPixels, 200, 2_000);
|
Assert.InRange(redPixels, 1_000, 30_000);
|
||||||
|
Assert.InRange(navyPixels, 1_000, 50_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user