Review 1.5: grant-windows-server.ps1 backs up, removes exactly what it gave, and never more than read
The grant script changes SCM and WMI security on customer servers. Before, a re-run replaced every ACE of the account on a WMI namespace (also a customer's Deny), -Remove took the account out of groups and ACEs it had before, only the SCM SDDL was backed up (and never used), Remote Management Users also opened PowerShell Remoting, and -EnableFirewall narrowed Windows' built-in rules for every other tool and was never undone. - Checks everything before changing anything: elevation, the account resolves (broad groups refused), Deny ACEs stop the script, -EnableFirewall needs -AgentAddress. - Backs up the original SDDLs (SCM, WMI namespaces, WinRM), group memberships and firewall rules before the first change; records what it added in a state file in an administrators-only folder; -Remove removes only that. - SDDL handled with RawSecurityDescriptor (GetSD/SetSD, sc sdshow/sdset): exactly one ACE added in canonical order, verified before writing and after; aliases (NS) work. - No Remote Management Users: -WinRM adds GENERIC_READ (no Execute) to WinRM's RootSDDL; DCOM is the default. - Firewall: its own "SQLInsight Agent: ..." rules only from the agent's address, removed again with -Remove; Windows' rules untouched. - -WhatIf, groups by SID, Danish messages, PowerShell 2.0 syntax. - ServerGrantScript.For does not fill in an account that could end the PowerShell string (typographic quotes, control characters); tests run the SDDL logic in Windows PowerShell against real default SDDLs. - docs/windows-servere.md: rights, safety net, firewall. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cb99778a3f
commit
697d243658
6 changed files with 660 additions and 136 deletions
|
|
@ -3,14 +3,18 @@
|
|||
=====================================================================================
|
||||
|
||||
Giver SQLInsight-agentens konto præcis den læseadgang, den skal bruge for at overvåge denne server:
|
||||
CPU, hukommelse, diske, tjenester, hændelseslog og planlagte opgaver. Aldrig lokal administrator.
|
||||
Scriptet kan køres igen uden skade. Det ændrer kun adgangsrettigheder: det starter, stopper eller
|
||||
ændrer ingen tjenester, opgaver eller indstillinger, og det læser ingen data.
|
||||
CPU, hukommelse, diske, tjenester, hændelseslog og planlagte opgaver. Aldrig lokal administrator, og
|
||||
aldrig ret til at køre kode på serveren (heller ikke PowerShell Remoting).
|
||||
Scriptet ændrer kun adgangsrettigheder: det starter, stopper eller ændrer ingen tjenester, opgaver eller
|
||||
indstillinger, og det læser ingen data.
|
||||
|
||||
Afprøv det først på en server, der ikke er i produktion.
|
||||
|
||||
Kør som administrator på den server, der skal overvåges (Windows PowerShell 2.0 eller nyere):
|
||||
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -WhatIf # vis, hvad der ville ske; ændrer intet
|
||||
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$'
|
||||
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -EnableFirewall -AgentAddress 10.0.0.15
|
||||
Fjern rettighederne igen:
|
||||
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -WinRM -EnableFirewall -AgentAddress 10.0.0.15
|
||||
Fjern præcis det, scriptet har givet, igen:
|
||||
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -Remove
|
||||
|
||||
Agentens konto: kører agenten som NT SERVICE\SQLInsight Agent, LocalSystem eller NETWORK SERVICE, kommer den
|
||||
|
|
@ -18,131 +22,446 @@
|
|||
|
||||
Hvad der gives, og hvorfor:
|
||||
Performance Monitor Users ydelsestællere: CPU, hukommelse, paging
|
||||
Event Log Readers System- og Application-loggen (aldrig Security-loggen)
|
||||
Event Log Readers hændelseslogge, der tillader gruppen (System, Application m.fl.; aldrig Security)
|
||||
Distributed COM Users WMI/CIM over DCOM (RPC)
|
||||
Remote Management Users WMI/CIM over WinRM (WS-Man), Windows Server 2012 og nyere
|
||||
WMI: Enable Account + Remote Enable på root\cimv2 og root\Microsoft\Windows\TaskScheduler (ikke arvet)
|
||||
Service Control Manager: læse og liste tjenester (CC LC RP RC). Den oprindelige SDDL gemmes første gang i
|
||||
%ProgramData%\SQLInsight\scmanager-sddl-backup.txt
|
||||
WMI: Enable Account + Remote Enable (0x21) på root\cimv2 og root\Microsoft\Windows\TaskScheduler, kun på
|
||||
selve navnerummet (ikke arvet). Ingen skrive- eller metoderet ud over Windows'
|
||||
standard.
|
||||
Service Control Manager forbinde, liste tjenester, læse låsestatus og sikkerhed (CC LC RP RC) - det
|
||||
samme, som en lokalt logget på bruger har. Ingen ret til at starte eller stoppe.
|
||||
Kun med -WinRM læseret (GENERIC_READ, ingen Execute) i WinRM's RootSDDL, så WMI kan læses over
|
||||
WinRM (TCP 5985). Kontoen kommer IKKE i Remote Management Users, for den gruppe
|
||||
giver også PowerShell Remoting. Uden -WinRM bruger agenten DCOM.
|
||||
|
||||
Hvad der IKKE gives: lokal administrator, ret til at starte/stoppe tjenester, skrive i WMI, læse filer
|
||||
eller Security-loggen.
|
||||
Hvad der IKKE gives: lokal administrator, PowerShell Remoting/WinRS, ret til at starte/stoppe tjenester,
|
||||
skrive i WMI, læse filer eller Security-loggen.
|
||||
|
||||
Firewall (kun med -EnableFirewall, Windows Server 2012 og nyere): slår Windows' indbyggede regler til for
|
||||
WMI (DCOM), WinRM (TCP 5985) og Remote Event Log Management, eventuelt kun fra agentens adresse.
|
||||
Firewall (kun med -EnableFirewall og -AgentAddress, Windows Server 2012 og nyere): opretter scriptets egne regler
|
||||
"SQLInsight Agent: ..." for RPC (WMI over DCOM og hændelseslog) og med -WinRM for TCP 5985, kun fra agentens
|
||||
adresse. Windows' indbyggede regler røres ikke.
|
||||
|
||||
Sikkerhedsnet:
|
||||
- Intet ændres, før alt er læst og kontrolleret (konto, grupper, sikkerhed, firewall). Fejler kontrollen, er
|
||||
intet ændret.
|
||||
- Før første ændring gemmes de oprindelige indstillinger (SDDL for Service Control Manager, WMI-navnerummene og
|
||||
WinRM, gruppemedlemskaber og firewallregler) i %ProgramData%\SQLInsight\grant-windows-server\<SID>-<tid>-foer.xml.
|
||||
- Hvad scriptet selv har tilføjet, står i %ProgramData%\SQLInsight\grant-windows-server\<SID>.xml (kun
|
||||
administratorer har adgang til mappen). -Remove fjerner kun det; intet, der fandtes før.
|
||||
- Har kontoen allerede en rettighed, røres den ikke (og fjernes ikke med -Remove). Har den en "Nægt"-regel,
|
||||
stopper scriptet uden at ændre noget.
|
||||
- Hvert skridt kontrolleres bagefter. Fejler et skridt, står det, der allerede er gjort, i tilstandsfilen, så
|
||||
scriptet kan køres igen eller med -Remove.
|
||||
- Manuel gendannelse af Service Control Manager: sc.exe sdset scmanager "<SDDL fra -foer.xml>".
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||
param(
|
||||
[string] $Account = 'DOMÆNE\AGENTSERVER$', # <== agentens konto
|
||||
[switch] $Remove,
|
||||
[switch] $WinRM,
|
||||
[switch] $EnableFirewall,
|
||||
[string] $AgentAddress
|
||||
)
|
||||
|
||||
Set-StrictMode -Version 2.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$sid = (New-Object System.Security.Principal.NTAccount($Account)).Translate([System.Security.Principal.SecurityIdentifier]).Value
|
||||
if ($Remove) { $verb = 'Fjerner' } else { $verb = 'Giver' }
|
||||
Write-Host "$verb rettigheder til $Account ($sid) på $env:COMPUTERNAME"
|
||||
$placeholder = 'DOMÆNE\AGENTSERVER$'
|
||||
$whatIf = [bool] $WhatIfPreference
|
||||
|
||||
# 1. Lokale grupper. Fundet via deres SID, så det virker på dansk, engelsk og andre sprog.
|
||||
# ---- 1. Kontrol: intet ændres, før alt her er i orden ---------------------------------------------------------
|
||||
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw 'Kør scriptet i en PowerShell, der er startet som administrator ("Kør som administrator"). Intet er ændret.'
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrEmpty($Account) -or $Account -eq $placeholder) {
|
||||
throw "Angiv agentens konto, fx -Account 'DOMÆNE\AGENTSERVER`$' (SQLInsight Web viser den under serveren). Intet er ændret."
|
||||
}
|
||||
foreach ($c in $Account.ToCharArray()) {
|
||||
if ([char]::IsControl($c)) { throw 'Kontonavnet indeholder et kontroltegn. Intet er ændret.' }
|
||||
}
|
||||
|
||||
try {
|
||||
$sidObj = (New-Object Security.Principal.NTAccount($Account)).Translate([Security.Principal.SecurityIdentifier])
|
||||
} catch {
|
||||
throw "Kontoen '$Account' kunne ikke slås op (findes den, og kan serveren nå domænet?). Intet er ændret."
|
||||
}
|
||||
$sid = $sidObj.Value
|
||||
|
||||
if ($sid -eq 'S-1-5-18') {
|
||||
Write-Host 'Agenten kører som LocalSystem på denne server og har allerede adgang. Intet at gøre.'
|
||||
return
|
||||
}
|
||||
# Grupper med mange medlemmer: rettighederne skal gives til agentens egen konto, aldrig til alle.
|
||||
$broad = @('S-1-1-0', 'S-1-5-7', 'S-1-5-2', 'S-1-5-4', 'S-1-5-11', 'S-1-5-32-544', 'S-1-5-32-545', 'S-1-5-32-546')
|
||||
if (($broad -contains $sid) -or ($sid -match '^S-1-5-21-.+-(512|513|514|515|516|519)$')) {
|
||||
throw "'$Account' er en bred gruppe. Giv rettighederne til agentens egen konto (gMSA, domænekonto eller computerkonto). Intet er ændret."
|
||||
}
|
||||
|
||||
if ($EnableFirewall -and -not $Remove) {
|
||||
if ([string]::IsNullOrEmpty($AgentAddress)) {
|
||||
throw 'Med -EnableFirewall skal -AgentAddress angives (agentens IP-adresse eller net, fx 10.0.0.15 eller 10.0.0.0/24), så portene kun åbnes for agenten. Intet er ændret.'
|
||||
}
|
||||
foreach ($a in $AgentAddress.Split(',')) {
|
||||
$ip = $null
|
||||
if (-not [Net.IPAddress]::TryParse($a.Trim().Split('/')[0], [ref] $ip)) {
|
||||
throw "-AgentAddress '$a' er ikke en IP-adresse eller et net (fx 10.0.0.15 eller 10.0.0.0/24). Intet er ændret."
|
||||
}
|
||||
}
|
||||
if (-not (Get-Command New-NetFirewallRule -ErrorAction SilentlyContinue)) {
|
||||
throw '-EnableFirewall kræver Windows Server 2012 eller nyere. Åbn portene manuelt (se docs/windows-servere.md). Intet er ændret.'
|
||||
}
|
||||
} elseif ($AgentAddress -and -not $Remove) {
|
||||
Write-Host ' (-AgentAddress bruges kun sammen med -EnableFirewall)'
|
||||
}
|
||||
|
||||
$stateDir = Join-Path $env:ProgramData 'SQLInsight\grant-windows-server'
|
||||
$statePath = Join-Path $stateDir "$sid.xml"
|
||||
if (Test-Path $statePath) {
|
||||
$state = Import-Clixml $statePath
|
||||
} else {
|
||||
$state = @{ Sid = $sid; Account = $Account; Groups = @(); Wmi = @(); Scm = $false; WinRMRoot = $false; Firewall = @() }
|
||||
}
|
||||
|
||||
if ($Remove) { $verb = 'Fjerner det, scriptet har givet' } else { $verb = 'Giver rettigheder til' }
|
||||
Write-Host "$verb $Account ($sid) på $env:COMPUTERNAME"
|
||||
if ($Remove -and -not (Test-Path $statePath)) {
|
||||
Write-Host "Intet at fjerne: scriptet har ikke givet $Account noget på denne server ($statePath findes ikke)."
|
||||
return
|
||||
}
|
||||
|
||||
# ---- Hjælpefunktioner (sikkerhedsbeskrivelser som SDDL, ændret via .NET's RawSecurityDescriptor) ---------------
|
||||
|
||||
$accessAllowed = [Security.AccessControl.AceQualifier]::AccessAllowed
|
||||
$accessDenied = [Security.AccessControl.AceQualifier]::AccessDenied
|
||||
$allSections = [Security.AccessControl.AccessControlSections]::All
|
||||
|
||||
function Get-Aces([string] $Sddl) {
|
||||
$raw = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList $Sddl
|
||||
if ($raw.DiscretionaryAcl -eq $null) { return @() }
|
||||
return @($raw.DiscretionaryAcl | Where-Object { $_ -is [Security.AccessControl.CommonAce] -and $_.SecurityIdentifier -eq $sidObj })
|
||||
}
|
||||
|
||||
# Har kontoen en Nægt-regel? Så stopper scriptet: den er sat med vilje.
|
||||
function Test-Denied([string] $Sddl) {
|
||||
return @(Get-Aces $Sddl | Where-Object { $_.AceQualifier -eq $accessDenied }).Count -gt 0
|
||||
}
|
||||
|
||||
# Har kontoen allerede (mindst) disse rettigheder fra en Tillad-regel?
|
||||
function Test-Allowed([string] $Sddl, [int] $Mask) {
|
||||
return @(Get-Aces $Sddl | Where-Object { $_.AceQualifier -eq $accessAllowed -and (($_.AccessMask -band $Mask) -eq $Mask) }).Count -gt 0
|
||||
}
|
||||
|
||||
# Fjerner præcis én Tillad-regel for kontoen med netop denne maske, ikke arvet og uden flag (den, scriptet satte).
|
||||
function Remove-Ace([string] $Sddl, [int] $Mask) {
|
||||
$raw = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList $Sddl
|
||||
if ($raw.DiscretionaryAcl -eq $null) { return $Sddl }
|
||||
for ($i = 0; $i -lt $raw.DiscretionaryAcl.Count; $i++) {
|
||||
$ace = $raw.DiscretionaryAcl[$i]
|
||||
if ($ace -is [Security.AccessControl.CommonAce] -and $ace.SecurityIdentifier -eq $sidObj -and $ace.AceQualifier -eq $accessAllowed `
|
||||
-and $ace.AccessMask -eq $Mask -and $ace.AceFlags -eq [Security.AccessControl.AceFlags]::None) {
|
||||
$raw.DiscretionaryAcl.RemoveAce($i)
|
||||
return $raw.GetSddlForm($allSections)
|
||||
}
|
||||
}
|
||||
return $Sddl
|
||||
}
|
||||
|
||||
# Indsætter præcis én Tillad-regel (ikke arvet) efter de eksplicitte regler og før de arvede (kanonisk orden).
|
||||
function Add-Ace([string] $Sddl, [int] $Mask) {
|
||||
$raw = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList $Sddl
|
||||
if ($raw.DiscretionaryAcl -eq $null) { throw 'Sikkerhedsbeskrivelsen har ingen DACL; den ændres ikke.' }
|
||||
$ace = New-Object Security.AccessControl.CommonAce -ArgumentList @([Security.AccessControl.AceFlags]::None, $accessAllowed, $Mask, $sidObj, $false, $null)
|
||||
$at = 0
|
||||
while ($at -lt $raw.DiscretionaryAcl.Count -and -not $raw.DiscretionaryAcl[$at].IsInherited) { $at++ }
|
||||
$raw.DiscretionaryAcl.InsertAce($at, $ace)
|
||||
$new = $raw.GetSddlForm($allSections)
|
||||
# Kontrol: fjernes den nye regel igen, er resten præcis som før.
|
||||
$before = (New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList $Sddl).GetSddlForm($allSections)
|
||||
if ((Remove-Ace $new $Mask) -ne $before) {
|
||||
throw 'Intern kontrol fejlede: den nye sikkerhedsbeskrivelse adskiller sig fra den gamle med mere end én regel.'
|
||||
}
|
||||
return $new
|
||||
}
|
||||
|
||||
function Read-NamespaceSddl([string] $Namespace) {
|
||||
$security = Get-WmiObject -Namespace $Namespace -Class __SystemSecurity
|
||||
$binary = @($null)
|
||||
$rc = $security.PsBase.InvokeMethod('GetSD', $binary)
|
||||
if ($rc -ne 0) { throw "Kunne ikke læse sikkerheden på WMI-navnerummet $Namespace ($rc)." }
|
||||
return (New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList @($binary[0], 0)).GetSddlForm($allSections)
|
||||
}
|
||||
|
||||
function Write-NamespaceSddl([string] $Namespace, [string] $Sddl) {
|
||||
$raw = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList $Sddl
|
||||
$bytes = New-Object byte[] $raw.BinaryLength
|
||||
$raw.GetBinaryForm($bytes, 0)
|
||||
$security = Get-WmiObject -Namespace $Namespace -Class __SystemSecurity
|
||||
$rc = $security.PsBase.InvokeMethod('SetSD', @(, $bytes))
|
||||
if ($rc -ne 0) { throw "Kunne ikke sætte sikkerheden på WMI-navnerummet $Namespace ($rc)." }
|
||||
}
|
||||
|
||||
function Read-ScmSddl {
|
||||
$sddl = ((& sc.exe sdshow scmanager) | Where-Object { $_ -match '\S' }) -join ''
|
||||
if ($LASTEXITCODE -ne 0 -or $sddl -notmatch '^D:') { throw "Kunne ikke læse Service Control Managers sikkerhed: $sddl" }
|
||||
return $sddl
|
||||
}
|
||||
|
||||
function Write-ScmSddl([string] $Sddl) {
|
||||
$output = & sc.exe sdset scmanager $Sddl
|
||||
if ($LASTEXITCODE -ne 0) { throw "sc.exe sdset scmanager fejlede ($LASTEXITCODE): $output" }
|
||||
}
|
||||
|
||||
function Get-GroupMembers($Group) {
|
||||
return @($Group.Invoke('Members') | ForEach-Object {
|
||||
$bytes = $_.GetType().InvokeMember('objectSid', 'GetProperty', $null, $_, $null)
|
||||
(New-Object Security.Principal.SecurityIdentifier($bytes, 0)).Value
|
||||
})
|
||||
}
|
||||
|
||||
# Medlemmer tilføjes og fjernes via deres SID (virker også for NT SERVICE\..., gMSA og computerkonti), ellers via navnet.
|
||||
function Set-GroupMember($Group, [bool] $Add) {
|
||||
$paths = @("WinNT://$sid", 'WinNT://' + $Account.Replace('\', '/'))
|
||||
foreach ($path in $paths) {
|
||||
try {
|
||||
if ($Add) { $Group.Add($path) } else { $Group.Remove($path) }
|
||||
return
|
||||
} catch {
|
||||
if ($path -eq $paths[-1]) { throw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Save-State {
|
||||
if ($whatIf) { return }
|
||||
if (-not (Test-Path $stateDir)) { New-Item -ItemType Directory -Force -Path $stateDir | Out-Null }
|
||||
# Kun administratorer og SYSTEM: filen styrer, hvad -Remove fjerner.
|
||||
$acl = New-Object Security.AccessControl.DirectorySecurity
|
||||
$acl.SetAccessRuleProtection($true, $false)
|
||||
foreach ($who in 'S-1-5-32-544', 'S-1-5-18') {
|
||||
$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule((New-Object Security.Principal.SecurityIdentifier($who)), 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow')))
|
||||
}
|
||||
Set-Acl -Path $stateDir -AclObject $acl
|
||||
$state | Export-Clixml -Path $statePath
|
||||
}
|
||||
|
||||
# ---- 2. Læs nuværende tilstand og læg en plan (ændrer intet) ---------------------------------------------------
|
||||
|
||||
$wmiMask = 0x21 # Enable Account (0x1) + Remote Enable (0x20)
|
||||
$scmMask = 0x20015 # CC (0x1) + LC (0x4) + RP (0x10) + RC (READ_CONTROL 0x20000)
|
||||
$winrmMask = [int] 0x80000000 # GENERIC_READ (GR), ingen Execute (GX)
|
||||
$plan = New-Object Collections.ArrayList
|
||||
$original = @{ Time = (Get-Date).ToString('o'); Account = $Account; Sid = $sid; Groups = @{}; Wmi = @{}; Scm = $null; WinRMRoot = $null; Firewall = @{} }
|
||||
|
||||
# Lokale grupper, fundet via deres SID, så det virker på dansk, engelsk og andre sprog.
|
||||
$groups = @(
|
||||
@('S-1-5-32-558', 'ydelsestællere (CPU, hukommelse, paging)'),
|
||||
@('S-1-5-32-573', 'System- og Application-loggen'),
|
||||
@('S-1-5-32-562', 'WMI over DCOM'),
|
||||
@('S-1-5-32-580', 'WMI over WinRM (WS-Man)')
|
||||
@('S-1-5-32-573', 'hændelseslogge (aldrig Security)'),
|
||||
@('S-1-5-32-562', 'WMI over DCOM')
|
||||
)
|
||||
foreach ($g in $groups) {
|
||||
try {
|
||||
$groupName = (New-Object System.Security.Principal.SecurityIdentifier($g[0])).Translate([System.Security.Principal.NTAccount]).Value.Split('\')[1]
|
||||
$groupName = (New-Object Security.Principal.SecurityIdentifier($g[0])).Translate([Security.Principal.NTAccount]).Value.Split('\')[1]
|
||||
} catch {
|
||||
Write-Host " - gruppen $($g[0]) ($($g[1])) findes ikke på denne Windows-version; springes over"
|
||||
continue
|
||||
}
|
||||
$group = [ADSI]("WinNT://$env:COMPUTERNAME/$groupName,group")
|
||||
$members = @($group.Invoke('Members') | ForEach-Object {
|
||||
$bytes = $_.GetType().InvokeMember('objectSid', 'GetProperty', $null, $_, $null)
|
||||
(New-Object System.Security.Principal.SecurityIdentifier($bytes, 0)).Value
|
||||
})
|
||||
$isMember = $members -contains $sid
|
||||
$path = 'WinNT://' + $Account.Replace('\', '/')
|
||||
if ($Remove -and $isMember) {
|
||||
$group.Remove($path)
|
||||
Write-Host " - fjernet fra $groupName"
|
||||
} elseif (-not $Remove -and -not $isMember) {
|
||||
$group.Add($path)
|
||||
Write-Host " + medlem af $groupName ($($g[1]))"
|
||||
$isMember = (Get-GroupMembers $group) -contains $sid
|
||||
$original.Groups[$g[0]] = $isMember
|
||||
$added = @($state.Groups) -contains $g[0]
|
||||
if ($Remove) {
|
||||
if ($added -and $isMember) { [void] $plan.Add(@{ Kind = 'GroupRemove'; Sid = $g[0]; Name = $groupName; Text = "fjern fra gruppen $groupName" }) }
|
||||
elseif ($added) { $state.Groups = @($state.Groups | Where-Object { $_ -ne $g[0] }); Write-Host " = ${groupName}: allerede fjernet" }
|
||||
elseif ($isMember) { Write-Host " = ${groupName}: var medlem før scriptet; bevares" }
|
||||
} elseif ($isMember) {
|
||||
Write-Host " = ${groupName}: allerede medlem"
|
||||
} else {
|
||||
Write-Host " = ${groupName}: uændret"
|
||||
[void] $plan.Add(@{ Kind = 'GroupAdd'; Sid = $g[0]; Name = $groupName; Text = "medlem af $groupName ($($g[1]))" })
|
||||
}
|
||||
}
|
||||
|
||||
# 2. WMI-navnerum: Enable Account (0x1) + Remote Enable (0x20), kun på selve navnerummet.
|
||||
function Set-NamespaceAccess([string] $Namespace) {
|
||||
$security = Get-WmiObject -Namespace $Namespace -Class __SystemSecurity
|
||||
$descriptor = $security.PsBase.InvokeMethod('GetSecurityDescriptor', $null).Descriptor
|
||||
$dacl = @($descriptor.DACL | Where-Object { $_.Trustee.SidString -ne $sid })
|
||||
if (-not $Remove) {
|
||||
$trustee = ([WMIClass]'Win32_Trustee').CreateInstance()
|
||||
$trustee.SidString = $sid
|
||||
$ace = ([WMIClass]'Win32_ACE').CreateInstance()
|
||||
$ace.AccessMask = 0x21
|
||||
$ace.AceFlags = 0
|
||||
$ace.AceType = 0
|
||||
$ace.Trustee = $trustee
|
||||
$dacl += $ace.PsObject.ImmediateBaseObject
|
||||
}
|
||||
$descriptor.DACL = $dacl
|
||||
$result = $security.PsBase.InvokeMethod('SetSecurityDescriptor', @($descriptor))
|
||||
if ($result.ReturnValue -ne 0) { throw "SetSecurityDescriptor på $Namespace fejlede ($($result.ReturnValue))" }
|
||||
if ($Remove) { Write-Host " - WMI $Namespace" } else { Write-Host " + WMI $Namespace (Enable Account, Remote Enable)" }
|
||||
}
|
||||
Set-NamespaceAccess 'root\cimv2'
|
||||
# WMI-navnerum.
|
||||
$namespaces = @('root\cimv2')
|
||||
if (Get-WmiObject -Namespace 'root\Microsoft\Windows' -Class __Namespace -Filter "Name='TaskScheduler'" -ErrorAction SilentlyContinue) {
|
||||
Set-NamespaceAccess 'root\Microsoft\Windows\TaskScheduler'
|
||||
} else {
|
||||
$namespaces += 'root\Microsoft\Windows\TaskScheduler'
|
||||
} elseif (-not $Remove) {
|
||||
Write-Host ' - root\Microsoft\Windows\TaskScheduler findes ikke (før Windows Server 2012): planlagte opgaver kan ikke måles'
|
||||
}
|
||||
|
||||
# 3. Service Control Manager: læse og liste tjenester. Over netværket må ellers kun administratorer liste tjenester.
|
||||
$scmAce = "(A;;CCLCRPRC;;;$sid)"
|
||||
$sddl = ((& sc.exe sdshow scmanager) | Where-Object { $_ -match '\S' }) -join ''
|
||||
if ($sddl -notmatch '^D:') { throw "Kunne ikke læse Service Control Managers sikkerhed: $sddl" }
|
||||
$backup = Join-Path $env:ProgramData 'SQLInsight\scmanager-sddl-backup.txt'
|
||||
if (-not (Test-Path $backup)) {
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $backup) | Out-Null
|
||||
Set-Content -Path $backup -Value $sddl
|
||||
}
|
||||
$clean = $sddl.Replace($scmAce, '')
|
||||
if ($Remove) {
|
||||
$new = $clean
|
||||
} else {
|
||||
$at = $clean.IndexOf('S:')
|
||||
if ($at -lt 0) { $new = $clean + $scmAce } else { $new = $clean.Substring(0, $at) + $scmAce + $clean.Substring($at) }
|
||||
}
|
||||
if ($new -ne $sddl) {
|
||||
& sc.exe sdset scmanager $new | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "sc.exe sdset scmanager fejlede ($LASTEXITCODE). Den oprindelige SDDL står i $backup" }
|
||||
if ($Remove) { Write-Host ' - Service Control Manager' } else { Write-Host ' + Service Control Manager (CC LC RP RC: forbinde, liste, læse)' }
|
||||
} else {
|
||||
Write-Host ' = Service Control Manager: uændret'
|
||||
}
|
||||
|
||||
# 4. Firewall (valgfrit): Windows' indbyggede regler, fundet på deres faste (ikke oversatte) navne.
|
||||
if ($EnableFirewall -and -not $Remove) {
|
||||
if (-not (Get-Command Enable-NetFirewallRule -ErrorAction SilentlyContinue)) {
|
||||
Write-Host ' - Enable-NetFirewallRule findes ikke (før Windows Server 2012): slå reglerne for WMI og Remote Event Log Management til manuelt'
|
||||
foreach ($ns in $namespaces) {
|
||||
$sddl = Read-NamespaceSddl $ns
|
||||
$original.Wmi[$ns] = $sddl
|
||||
if ($Remove) {
|
||||
if (@($state.Wmi) -contains $ns) { [void] $plan.Add(@{ Kind = 'WmiRemove'; Namespace = $ns; Text = "fjern WMI-adgang på $ns" }) }
|
||||
} elseif (Test-Denied $sddl) {
|
||||
throw "Kontoen har en Nægt-regel på WMI-navnerummet $ns. Den er sat med vilje; fjern den først, hvis agenten skal læse herfra. Intet er ændret."
|
||||
} elseif (Test-Allowed $sddl $wmiMask) {
|
||||
Write-Host " = WMI ${ns}: har allerede adgang"
|
||||
} else {
|
||||
foreach ($rule in 'WMI-WINMGMT-In-TCP', 'WMI-RPCSS-In-TCP', 'WMI-ASYNC-In-TCP', 'WINRM-HTTP-In-TCP',
|
||||
'RemoteEventLogSvc-In-TCP', 'RemoteEventLogSvc-NP-In-TCP', 'RemoteEventLogSvc-RPCSS-In-TCP') {
|
||||
$found = Get-NetFirewallRule -Name $rule -ErrorAction SilentlyContinue
|
||||
if (-not $found) {
|
||||
Write-Host " - firewallreglen $rule findes ikke; springes over"
|
||||
continue
|
||||
}
|
||||
if ($AgentAddress) { $found | Set-NetFirewallRule -RemoteAddress $AgentAddress }
|
||||
$found | Enable-NetFirewallRule
|
||||
if ($AgentAddress) { Write-Host " + firewall: $rule (kun fra $AgentAddress)" } else { Write-Host " + firewall: $rule" }
|
||||
[void] (Add-Ace $sddl $wmiMask) # kontrollerer, at ændringen er præcis én regel
|
||||
[void] $plan.Add(@{ Kind = 'WmiAdd'; Namespace = $ns; Text = "WMI $ns (Enable Account, Remote Enable)" })
|
||||
}
|
||||
}
|
||||
|
||||
# Service Control Manager: over netværket må ellers kun administratorer liste tjenester.
|
||||
$scm = Read-ScmSddl
|
||||
$original.Scm = $scm
|
||||
if ($Remove) {
|
||||
if ($state.Scm) { [void] $plan.Add(@{ Kind = 'ScmRemove'; Text = 'fjern læseadgang til Service Control Manager' }) }
|
||||
} elseif (Test-Denied $scm) {
|
||||
throw 'Kontoen har en Nægt-regel på Service Control Manager. Den er sat med vilje; fjern den først, hvis agenten skal se tjenester. Intet er ændret.'
|
||||
} elseif (Test-Allowed $scm $scmMask) {
|
||||
Write-Host ' = Service Control Manager: har allerede læseadgang'
|
||||
} else {
|
||||
[void] (Add-Ace $scm $scmMask)
|
||||
[void] $plan.Add(@{ Kind = 'ScmAdd'; Text = 'Service Control Manager (CC LC RP RC: forbinde, liste, læse)' })
|
||||
}
|
||||
|
||||
# WinRM (kun med -WinRM, eller når -Remove skal fjerne det, scriptet gav).
|
||||
if (($WinRM -and -not $Remove) -or ($Remove -and $state.WinRMRoot)) {
|
||||
$service = Get-Service -Name WinRM -ErrorAction SilentlyContinue
|
||||
if (-not $service -or $service.Status -ne 'Running') {
|
||||
throw 'WinRM-tjenesten kører ikke. Start den (winrm quickconfig), eller kør uden -WinRM, så agenten bruger DCOM. Intet er ændret.'
|
||||
}
|
||||
$root = (Get-Item WSMan:\localhost\Service\RootSDDL).Value
|
||||
$original.WinRMRoot = $root
|
||||
if ($Remove) {
|
||||
[void] $plan.Add(@{ Kind = 'WinRMRemove'; Text = 'fjern læseadgang i WinRM (RootSDDL)' })
|
||||
} elseif (Test-Denied $root) {
|
||||
throw 'Kontoen har en Nægt-regel i WinRM (RootSDDL). Kør uden -WinRM, så agenten bruger DCOM. Intet er ændret.'
|
||||
} elseif (Test-Allowed $root $winrmMask) {
|
||||
Write-Host ' = WinRM: har allerede læseadgang'
|
||||
} else {
|
||||
[void] (Add-Ace $root $winrmMask)
|
||||
[void] $plan.Add(@{ Kind = 'WinRMAdd'; Text = 'WinRM (RootSDDL): læseret (GR), ingen Execute' })
|
||||
}
|
||||
}
|
||||
|
||||
# Firewall: scriptets egne regler, kun fra agentens adresse. Windows' indbyggede regler røres ikke.
|
||||
$rules = @(
|
||||
@{ Name = 'SQLInsight-Agent-RPC-EPMAP'; Display = 'SQLInsight Agent: RPC Endpoint Mapper (WMI over DCOM, hændelseslog)'; Port = 'RPCEPMap'; Service = 'rpcss' },
|
||||
@{ Name = 'SQLInsight-Agent-WMI-DCOM'; Display = 'SQLInsight Agent: WMI over DCOM'; Port = 'RPC'; Service = 'winmgmt' },
|
||||
@{ Name = 'SQLInsight-Agent-EventLog'; Display = 'SQLInsight Agent: hændelseslog (RPC)'; Port = 'RPC'; Service = 'eventlog' }
|
||||
)
|
||||
if ($WinRM) { $rules += @{ Name = 'SQLInsight-Agent-WinRM'; Display = 'SQLInsight Agent: WinRM (HTTP 5985)'; Port = '5985'; Service = $null } }
|
||||
if ($Remove -and @($state.Firewall).Count -gt 0 -and (Get-Command Get-NetFirewallRule -ErrorAction SilentlyContinue)) {
|
||||
foreach ($name in @($state.Firewall)) {
|
||||
$existing = Get-NetFirewallRule -Name $name -ErrorAction SilentlyContinue
|
||||
if ($existing) { $original.Firewall[$name] = @(($existing | Get-NetFirewallAddressFilter).RemoteAddress) }
|
||||
if ($existing) { [void] $plan.Add(@{ Kind = 'FirewallRemove'; Name = $name; Text = "fjern firewallreglen $name" }) }
|
||||
}
|
||||
} elseif ($EnableFirewall -and -not $Remove) {
|
||||
$wanted = @($AgentAddress.Split(',') | ForEach-Object { $_.Trim() })
|
||||
foreach ($r in $rules) {
|
||||
$existing = Get-NetFirewallRule -Name $r.Name -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
$current = @(($existing | Get-NetFirewallAddressFilter).RemoteAddress)
|
||||
$original.Firewall[$r.Name] = $current
|
||||
if (($current -join ',') -eq ($wanted -join ',')) { Write-Host " = firewall $($r.Name): uændret"; continue }
|
||||
[void] $plan.Add(@{ Kind = 'FirewallUpdate'; Rule = $r; Address = $wanted; Text = "firewallreglen $($r.Name): kun fra $AgentAddress" })
|
||||
} else {
|
||||
[void] $plan.Add(@{ Kind = 'FirewallAdd'; Rule = $r; Address = $wanted; Text = "firewall: $($r.Display) (TCP $($r.Port)), kun fra $AgentAddress" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host 'Færdig. Test forbindelsen fra SQLInsight (Servere > serveren > Test forbindelsen).'
|
||||
if ($plan.Count -eq 0) {
|
||||
if ($Remove -and -not $whatIf) { Save-State }
|
||||
Write-Host 'Alt er allerede på plads. Intet er ændret.'
|
||||
return
|
||||
}
|
||||
|
||||
# ---- 3. Gem det oprindelige, og udfør planen ét skridt ad gangen -----------------------------------------------
|
||||
|
||||
$backupPath = Join-Path $stateDir ("$sid-" + (Get-Date).ToString('yyyyMMdd-HHmmss') + '-foer.xml')
|
||||
if (-not $whatIf) {
|
||||
Save-State
|
||||
$original | Export-Clixml -Path $backupPath
|
||||
Write-Host " Oprindelige indstillinger gemt i $backupPath"
|
||||
}
|
||||
|
||||
foreach ($step in $plan) {
|
||||
if (-not $PSCmdlet.ShouldProcess($env:COMPUTERNAME, $step.Text)) { continue }
|
||||
try {
|
||||
switch ($step.Kind) {
|
||||
'GroupAdd' {
|
||||
$group = [ADSI]("WinNT://$env:COMPUTERNAME/$($step.Name),group")
|
||||
Set-GroupMember $group $true
|
||||
if (-not ((Get-GroupMembers $group) -contains $sid)) { throw "kontoen kom ikke i gruppen $($step.Name)" }
|
||||
$state.Groups = @($state.Groups) + $step.Sid
|
||||
}
|
||||
'GroupRemove' {
|
||||
$group = [ADSI]("WinNT://$env:COMPUTERNAME/$($step.Name),group")
|
||||
Set-GroupMember $group $false
|
||||
$state.Groups = @($state.Groups | Where-Object { $_ -ne $step.Sid })
|
||||
}
|
||||
'WmiAdd' {
|
||||
Write-NamespaceSddl $step.Namespace (Add-Ace (Read-NamespaceSddl $step.Namespace) $wmiMask)
|
||||
if (-not (Test-Allowed (Read-NamespaceSddl $step.Namespace) $wmiMask)) { throw "adgangen på $($step.Namespace) blev ikke sat" }
|
||||
$state.Wmi = @($state.Wmi) + $step.Namespace
|
||||
}
|
||||
'WmiRemove' {
|
||||
$sddl = Read-NamespaceSddl $step.Namespace
|
||||
$new = Remove-Ace $sddl $wmiMask
|
||||
if ($new -ne $sddl) { Write-NamespaceSddl $step.Namespace $new }
|
||||
$state.Wmi = @($state.Wmi | Where-Object { $_ -ne $step.Namespace })
|
||||
}
|
||||
'ScmAdd' {
|
||||
Write-ScmSddl (Add-Ace (Read-ScmSddl) $scmMask)
|
||||
if (-not (Test-Allowed (Read-ScmSddl) $scmMask)) { throw 'læseadgangen til Service Control Manager blev ikke sat' }
|
||||
$state.Scm = $true
|
||||
}
|
||||
'ScmRemove' {
|
||||
$sddl = Read-ScmSddl
|
||||
$new = Remove-Ace $sddl $scmMask
|
||||
if ($new -ne $sddl) { Write-ScmSddl $new }
|
||||
$state.Scm = $false
|
||||
}
|
||||
'WinRMAdd' {
|
||||
$new = Add-Ace (Get-Item WSMan:\localhost\Service\RootSDDL).Value $winrmMask
|
||||
Set-Item -Path WSMan:\localhost\Service\RootSDDL -Value $new -Force -WhatIf:$false -Confirm:$false
|
||||
if (-not (Test-Allowed (Get-Item WSMan:\localhost\Service\RootSDDL).Value $winrmMask)) { throw 'læseretten i WinRM blev ikke sat' }
|
||||
$state.WinRMRoot = $true
|
||||
}
|
||||
'WinRMRemove' {
|
||||
$sddl = (Get-Item WSMan:\localhost\Service\RootSDDL).Value
|
||||
$new = Remove-Ace $sddl $winrmMask
|
||||
if ($new -ne $sddl) { Set-Item -Path WSMan:\localhost\Service\RootSDDL -Value $new -Force -WhatIf:$false -Confirm:$false }
|
||||
$state.WinRMRoot = $false
|
||||
}
|
||||
'FirewallAdd' {
|
||||
$r = $step.Rule
|
||||
$arguments = @{ Name = $r.Name; DisplayName = $r.Display; Direction = 'Inbound'; Action = 'Allow'; Protocol = 'TCP'; LocalPort = $r.Port; RemoteAddress = $step.Address; Profile = 'Any' }
|
||||
if ($r.Service) { $arguments.Program = '%SystemRoot%\system32\svchost.exe'; $arguments.Service = $r.Service }
|
||||
New-NetFirewallRule @arguments -WhatIf:$false -Confirm:$false | Out-Null
|
||||
$state.Firewall = @($state.Firewall) + $r.Name
|
||||
}
|
||||
'FirewallUpdate' {
|
||||
Set-NetFirewallRule -Name $step.Rule.Name -RemoteAddress $step.Address -WhatIf:$false -Confirm:$false
|
||||
if (-not (@($state.Firewall) -contains $step.Rule.Name)) { $state.Firewall = @($state.Firewall) + $step.Rule.Name }
|
||||
}
|
||||
'FirewallRemove' {
|
||||
Remove-NetFirewallRule -Name $step.Name -WhatIf:$false -Confirm:$false
|
||||
$state.Firewall = @($state.Firewall | Where-Object { $_ -ne $step.Name })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Save-State
|
||||
throw "Fejlede ved '$($step.Text)': $($_.Exception.Message) Det, der allerede er gjort, står i $statePath; ret fejlen og kør scriptet igen, eller kør det med -Remove. De oprindelige indstillinger står i $backupPath."
|
||||
}
|
||||
Save-State
|
||||
if ($Remove) { Write-Host " - $($step.Text)" } else { Write-Host " + $($step.Text)" }
|
||||
}
|
||||
|
||||
if ($whatIf) { return }
|
||||
if ($Remove) {
|
||||
$left = @($state.Groups).Count + @($state.Wmi).Count + @($state.Firewall).Count
|
||||
if ($state.Scm) { $left++ }
|
||||
if ($state.WinRMRoot) { $left++ }
|
||||
if ($left -eq 0) {
|
||||
Move-Item -Path $statePath -Destination (Join-Path $stateDir ("$sid-" + (Get-Date).ToString('yyyyMMdd-HHmmss') + '-fjernet.xml'))
|
||||
}
|
||||
Write-Host 'Færdig. Det, scriptet gav, er fjernet; det, der fandtes før, er bevaret.'
|
||||
} else {
|
||||
Write-Host 'Færdig. Test forbindelsen fra SQLInsight (Servere > Windows-servere > serveren > Test forbindelsen).'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,45 +9,53 @@ SQLInsight **ændrer aldrig noget** på serverne: agenten læser kun. Den starte
|
|||
|
||||
## Sådan læser agenten serverne
|
||||
|
||||
Agenten læser serverne **som sin egen konto** (den samme som til SQL Server) med CIM – det samme, PowerShell's `Get-CimInstance` bruger:
|
||||
Agenten læser serverne **som sin egen konto** (den samme som til SQL Server) med CIM – det samme, PowerShells `Get-CimInstance` bruger:
|
||||
|
||||
1. **WinRM (WS-Man)**, TCP 5985, som er slået til på Windows Server 2012 og nyere. Foretrækkes.
|
||||
2. **DCOM (RPC)**, TCP 135 og dynamiske porte, hvis WinRM ikke svarer eller afviser kontoen.
|
||||
1. **WinRM (WS-Man)**, TCP 5985, når kontoen har læseret i WinRM (tildelingsscriptet med `-WinRM`, Windows Server 2012 og nyere).
|
||||
2. **DCOM (RPC)**, TCP 135 og dynamiske porte, hvis WinRM ikke svarer eller afviser kontoen. Det er standard med tildelingsscriptet.
|
||||
3. Agentens egen server læses lokalt, uden netværk.
|
||||
|
||||
`SQLInsight:Servers:Protocol` kan låse det til `WSMan` eller `DCOM` (standard `Auto`). Hændelsesloggen læses med hændelseslogtjenestens egen protokol (MS-EVEN6, over RPC), som filtrerer på serveren og derfor er hurtig; virker den ikke, bruges CIM (`Win32_NTLogEvent`), som er langsommere. Siden for serveren viser, hvordan den blev læst.
|
||||
`SQLInsight:Servers:Protocol` kan låse det til `WSMan` eller `DCOM` (standard `Auto`). WinRM over HTTPS (TCP 5986) understøttes ikke i 1.5. Hændelsesloggen læses med hændelseslogtjenestens egen protokol (MS-EVEN6, over RPC), som filtrerer på serveren og derfor er hurtig; virker den ikke, bruges CIM (`Win32_NTLogEvent`), som er langsommere. Siden for serveren viser, hvordan den blev læst.
|
||||
|
||||
Kører agenten som den virtuelle konto `NT SERVICE\SQLInsight Agent` (standard), LocalSystem eller NETWORK SERVICE, kommer den til andre servere som **computerkontoen** `DOMÆNE\AGENTSERVER$`. En gMSA eller domænekonto er den samme overalt. Siden viser den konto, scriptet nedenfor skal give rettigheder til.
|
||||
|
||||
## Rettigheder (aldrig lokal administrator)
|
||||
|
||||
Agenten skal **ikke** være lokal administrator. Den har brug for præcis dette, og intet andet:
|
||||
Agenten skal **ikke** være lokal administrator og får ingen ret til at køre kode på serverne. Den har brug for præcis dette, og intet andet:
|
||||
|
||||
| Rettighed | Bruges til |
|
||||
|---|---|
|
||||
| Lokal gruppe **Performance Monitor Users** | Ydelsestællere: CPU, hukommelse, paging, commit |
|
||||
| Lokal gruppe **Event Log Readers** | System- og Application-loggen (aldrig Security-loggen) |
|
||||
| Lokal gruppe **Event Log Readers** | Hændelseslogge, der tillader gruppen: System, Application m.fl. (aldrig Security-loggen) |
|
||||
| Lokal gruppe **Distributed COM Users** | CIM over DCOM |
|
||||
| Lokal gruppe **Remote Management Users** | CIM over WinRM (Windows Server 2012 og nyere) |
|
||||
| WMI: **Enable Account** + **Remote Enable** på `root\cimv2` | Styresystem, oppetid, diske, tjenester |
|
||||
| WMI: **Enable Account** + **Remote Enable** på `root\Microsoft\Windows\TaskScheduler` | Planlagte opgaver (Windows Server 2012 og nyere) |
|
||||
| **Service Control Manager**: forbinde, liste og læse (`CC LC RP RC`) | Over netværket må ellers kun administratorer liste tjenester |
|
||||
| **Service Control Manager**: forbinde, liste og læse (`CC LC RP RC`) | Over netværket må ellers kun administratorer liste tjenester. Det er det samme, en lokalt logget på bruger har; ingen ret til at starte eller stoppe |
|
||||
| Kun med `-WinRM`: **læseret i WinRM** (`GENERIC_READ` i RootSDDL, ingen Execute) | CIM over WinRM. Kontoen kommer *ikke* i Remote Management Users, for den gruppe giver også adgang til PowerShell Remoting |
|
||||
|
||||
Det hele gives af [`grant-windows-server.ps1`](../db/permissions/grant-windows-server.ps1). Webbrugerfladen viser scriptet udfyldt med agentens konto under **Rettigheder og forbindelse** på serverens side (kopiér eller hent som fil; API: `GET /api/v1/windows-servers/{id}/grant-script`). En administrator kører det på serveren:
|
||||
Det hele gives af [`grant-windows-server.ps1`](../db/permissions/grant-windows-server.ps1). Webbrugerfladen viser scriptet udfyldt med agentens konto under **Rettigheder og forbindelse** på serverens side (kopiér eller hent som fil; API: `GET /api/v1/windows-servers/{id}/grant-script`). Scriptet ændrer sikkerheden på kundens server, så **afprøv det først på en server, der ikke er i produktion**, og kør det med `-WhatIf` for at se, hvad det vil gøre. En administrator kører det på serveren:
|
||||
|
||||
```powershell
|
||||
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$'
|
||||
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -EnableFirewall -AgentAddress 10.0.0.15 # slår også Windows' egne firewallregler til
|
||||
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -Remove # fjerner det hele igen
|
||||
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -WhatIf # viser planen, ændrer intet
|
||||
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' # DCOM (standard)
|
||||
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -WinRM # også læseret i WinRM
|
||||
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -EnableFirewall -AgentAddress 10.0.0.15 # egne firewallregler, kun fra agenten
|
||||
.\grant-windows-server.ps1 -Account 'DOMÆNE\AGENTSERVER$' -Remove # fjerner præcis det, scriptet gav
|
||||
```
|
||||
|
||||
Scriptet kan køres igen uden skade og virker på Windows PowerShell 2.0 og nyere. Grupperne findes via deres SID, så det virker på dansk og engelsk Windows. Den oprindelige sikkerhed på Service Control Manager gemmes første gang i `%ProgramData%\SQLInsight\scmanager-sddl-backup.txt`.
|
||||
Scriptet virker på Windows PowerShell 2.0 og nyere og skal køres som administrator. Grupperne findes via deres SID, så det virker på dansk og engelsk Windows. Sikkerhedsnettet:
|
||||
|
||||
- **Intet ændres, før alt er kontrolleret:** kontoen skal kunne slås op (brede grupper som Everyone, Authenticated Users eller Domain Users afvises), og har kontoen en *Nægt*-regel på WMI, Service Control Manager eller WinRM, stopper scriptet uden at ændre noget.
|
||||
- **Kun det nødvendige:** har kontoen allerede en rettighed, røres den ikke. Hver ændring er præcis én ny regel (kontrolleret, før den skrives), og hvert skridt kontrolleres bagefter.
|
||||
- **Backup:** før første ændring gemmes de oprindelige indstillinger (SDDL for Service Control Manager, WMI-navnerummene og WinRM, gruppemedlemskaber og firewallregler) i `%ProgramData%\SQLInsight\grant-windows-server\<SID>-<tid>-foer.xml`.
|
||||
- **Præcis fjernelse:** hvad scriptet selv har tilføjet, står i `%ProgramData%\SQLInsight\grant-windows-server\<SID>.xml` (kun administratorer har adgang til mappen). `-Remove` fjerner kun det – et gruppemedlemskab eller en rettighed, kontoen havde før, bevares. Fejler et skridt undervejs, står det, der allerede er gjort, i filen, så scriptet kan køres igen eller med `-Remove`.
|
||||
- Service Control Manager kan gendannes manuelt med `sc.exe sdset scmanager "<SDDL fra -foer.xml>"`.
|
||||
|
||||
Kendte begrænsninger med mindst mulige rettigheder:
|
||||
|
||||
- **Tjenester:** en tjeneste, hvis egen sikkerhed ikke lader kontoen læse den, ses ikke. Ser agenten slet ingen tjenester, er området "ikke målbart" med årsagen.
|
||||
- **Planlagte opgaver:** agenten ser kun opgaver, kontoen må læse. Seneste resultat læses med Task Schedulers `GetInfo` (et skrivebeskyttet CIM-metodekald, som `Get-ScheduledTaskInfo`); WinRM tillader kun metodekald for kontoen, hvis den har Execute-ret i WinRM. Mangler den, siger fundet det; brug DCOM (`SQLInsight:Servers:Protocol`), eller giv retten med `winrm configSDDL default`.
|
||||
- **Windows Server 2008 R2:** WinRM-gruppen og opgave-navnerummet findes ikke; brug DCOM, og planlagte opgaver er "ikke målbare".
|
||||
- **Planlagte opgaver:** agenten ser kun opgaver, kontoen må læse. Seneste resultat læses med Task Schedulers `GetInfoByName` (et skrivebeskyttet CIM-metodekald, som `Get-ScheduledTaskInfo`). Det kræver Windows' standardret *Execute Methods* på navnerummet (som Authenticated Users arver fra `root`); scriptet giver ikke mere. Over WinRM med kun læseret er metodekald ikke tilladt, så agenten læser opgaverne over DCOM (med `Protocol` = `Auto`); er protokollen låst til `WSMan`, er området "ikke målbart" med årsagen.
|
||||
- **Windows Server 2008 R2:** opgave-navnerummet findes ikke, så planlagte opgaver er "ikke målbare"; brug DCOM.
|
||||
|
||||
### Når en rettighed mangler
|
||||
|
||||
|
|
@ -61,13 +69,13 @@ Under overvågningen gælder det samme: et område, der ikke kan læses, gør ku
|
|||
|
||||
Fra agentens server til de overvågede servere:
|
||||
|
||||
| Port | Hvad | Windows' indbyggede regel |
|
||||
| Port | Hvad | Tildelingsscriptets regel (`-EnableFirewall`) |
|
||||
|---|---|---|
|
||||
| TCP 5985 | WinRM (WS-Man), foretrukket | Windows Remote Management (HTTP-In) |
|
||||
| TCP 135 + dynamiske RPC-porte (49152–65535) | DCOM/WMI, hvis WinRM ikke bruges | Windows Management Instrumentation (DCOM-In, WMI-In, ASync-In) |
|
||||
| TCP 135 + dynamiske RPC-porte, TCP 445 | Hændelsesloggen (MS-EVEN6) | Remote Event Log Management (RPC, NP-In, RPC-EPMAP) |
|
||||
| TCP 135 + dynamiske RPC-porte (49152–65535) | DCOM/WMI (standard) | *SQLInsight Agent: RPC Endpoint Mapper* og *SQLInsight Agent: WMI over DCOM* |
|
||||
| TCP 135 + dynamiske RPC-porte | Hændelsesloggen (MS-EVEN6) | *SQLInsight Agent: hændelseslog (RPC)* |
|
||||
| TCP 5985 | WinRM (WS-Man), kun med `-WinRM` | *SQLInsight Agent: WinRM (HTTP 5985)* |
|
||||
|
||||
`grant-windows-server.ps1 -EnableFirewall` slår disse indbyggede regler til (Windows Server 2012 og nyere), eventuelt kun for agentens adresse med `-AgentAddress`. Syntetiske tjek bruger de porte, de tjekker (se [syntetiske-tjek.md](syntetiske-tjek.md)).
|
||||
`grant-windows-server.ps1 -EnableFirewall -AgentAddress <agentens IP eller net>` opretter scriptets egne regler, kun for agentens adresse (Windows Server 2012 og nyere); `-AgentAddress` er påkrævet, så portene aldrig åbnes for alle. Windows' indbyggede regler røres ikke, og `-Remove` fjerner scriptets egne regler igen. Syntetiske tjek bruger de porte, de tjekker (se [syntetiske-tjek.md](syntetiske-tjek.md)).
|
||||
|
||||
## Hvad måles, og hvor ofte
|
||||
|
||||
|
|
|
|||
|
|
@ -8,14 +8,34 @@ namespace SQLInsight.Core.Servers;
|
|||
/// </summary>
|
||||
public static partial class ServerGrantScript
|
||||
{
|
||||
/// <summary>The script with <paramref name="account"/> as the default for -Account.</summary>
|
||||
/// <summary>
|
||||
/// The script with <paramref name="account"/> as the default for -Account. The account comes from what the agent
|
||||
/// reports, so it is put in a PowerShell single-quoted string safely: ' is doubled, and an account with a control
|
||||
/// character or a typographic single quote (which PowerShell also ends a string at) is not filled in at all; the
|
||||
/// script is then returned with its placeholder, which it refuses to run with.
|
||||
/// </summary>
|
||||
public static string For(string account)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(account);
|
||||
var template = Template();
|
||||
if (!IsSafeAccount(account))
|
||||
{
|
||||
return template;
|
||||
}
|
||||
|
||||
var literal = "'" + account.Trim().Replace("'", "''", StringComparison.Ordinal) + "'";
|
||||
return AccountPlaceholder().Replace(Template(), _ => "[string] $Account = " + literal + ", # <== agentens konto", 1);
|
||||
if (!AccountPlaceholder().IsMatch(template))
|
||||
{
|
||||
throw new InvalidOperationException("grant-windows-server.ps1 har ikke linjen med -Account, der skal udfyldes.");
|
||||
}
|
||||
|
||||
return AccountPlaceholder().Replace(template, _ => "[string] $Account = " + literal + ", # <== agentens konto", 1);
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="account"/> can be filled in (see <see cref="For"/>).</summary>
|
||||
public static bool IsSafeAccount(string? account) =>
|
||||
!string.IsNullOrWhiteSpace(account) && account.Length <= 256 && !account.Any(c => char.IsControl(c) || c is '‘' or '’' or '‚' or '‛');
|
||||
|
||||
/// <summary>The script as shipped, with the placeholder account.</summary>
|
||||
public static string Template()
|
||||
{
|
||||
|
|
|
|||
72
tests/SQLInsight.Core.Tests/ServerGrantScriptTests.cs
Normal file
72
tests/SQLInsight.Core.Tests/ServerGrantScriptTests.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
using System.Text.RegularExpressions;
|
||||
using SQLInsight.Core.Servers;
|
||||
|
||||
namespace SQLInsight.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// db/permissions/grant-windows-server.ps1 as SQLInsight Web shows it: the agent's account filled in safely, and the
|
||||
/// script itself only ever gives read rights (the SDDL logic is run in SQLInsight.Servers.Tests/GrantScriptTests).
|
||||
/// </summary>
|
||||
public sealed partial class ServerGrantScriptTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_account_is_filled_in()
|
||||
{
|
||||
var script = ServerGrantScript.For(@"FIRMA\AGENT01$");
|
||||
|
||||
Assert.Contains(@"[string] $Account = 'FIRMA\AGENT01$',", script, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("DOMÆNE\\AGENTSERVER$', #", script, StringComparison.Ordinal);
|
||||
Assert.Equal(script, ServerGrantScript.For(@"FIRMA\AGENT01$"));
|
||||
Assert.Contains("'O''Brien'", ServerGrantScript.For("O'Brien"), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("X’; Invoke-Evil; ’")] // PowerShell also ends a single-quoted string at ‘ ’ ‚ ‛
|
||||
[InlineData("X‘")]
|
||||
[InlineData("X‚")]
|
||||
[InlineData("X‛")]
|
||||
[InlineData("X\r\nInvoke-Evil")]
|
||||
public void An_account_that_could_break_out_of_the_string_is_not_filled_in(string account)
|
||||
{
|
||||
Assert.False(ServerGrantScript.IsSafeAccount(account));
|
||||
Assert.Equal(ServerGrantScript.Template(), ServerGrantScript.For(account));
|
||||
Assert.DoesNotContain("Invoke-Evil", ServerGrantScript.For(account), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_read_rights_are_given()
|
||||
{
|
||||
var script = ServerGrantScript.Template();
|
||||
|
||||
// Groups: Performance Monitor Users, Event Log Readers, Distributed COM Users. Never Administrators or Remote
|
||||
// Management Users (which also allows PowerShell Remoting).
|
||||
var groups = GroupBlock().Match(script);
|
||||
Assert.True(groups.Success);
|
||||
Assert.Equal(["S-1-5-32-558", "S-1-5-32-573", "S-1-5-32-562"], Sid().Matches(groups.Value).Select(m => m.Value));
|
||||
|
||||
// WMI Enable Account + Remote Enable; SCM connect, list, lock status, read control; WinRM read only.
|
||||
Assert.Contains("$wmiMask = 0x21 ", script, StringComparison.Ordinal);
|
||||
Assert.Contains("$scmMask = 0x20015 ", script, StringComparison.Ordinal);
|
||||
Assert.Contains("$winrmMask = [int] 0x80000000 ", script, StringComparison.Ordinal);
|
||||
|
||||
// Nothing that changes a service, a task, or runs WMI methods; Windows' own firewall rules are not touched.
|
||||
foreach (var forbidden in new[]
|
||||
{
|
||||
"Start-Service", "Stop-Service", "Restart-Service", "Set-Service", "sc.exe config", "sc.exe start", "sc.exe stop",
|
||||
"Invoke-WmiMethod", "Invoke-CimMethod", "Enable-NetFirewallRule", "Disable-NetFirewallRule", "Enable-PSRemoting",
|
||||
"Set-PSSessionConfiguration", "S-1-5-32-580", "Add-LocalGroupMember",
|
||||
})
|
||||
{
|
||||
Assert.DoesNotContain(forbidden, script, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
Assert.Contains("SupportsShouldProcess", script, StringComparison.Ordinal); // -WhatIf
|
||||
Assert.Contains("IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)", script, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\$groups = @\((?:.|\n|\r)*?\n\)")]
|
||||
private static partial Regex GroupBlock();
|
||||
|
||||
[GeneratedRegex(@"S-1-5-32-\d+")]
|
||||
private static partial Regex Sid();
|
||||
}
|
||||
|
|
@ -299,22 +299,6 @@ public class ServerConfigurationTests
|
|||
public void The_account_the_agent_arrives_as(string service, string host, string expected) =>
|
||||
Assert.Equal(expected, ServerGrantScript.AccountFor(service, host, "AGENT01", "FIRMA"));
|
||||
|
||||
[Fact]
|
||||
public void Grant_script_is_filled_in_and_never_makes_the_agent_administrator()
|
||||
{
|
||||
var script = ServerGrantScript.For(@"FIRMA\AGENT01$");
|
||||
|
||||
Assert.Contains(@"[string] $Account = 'FIRMA\AGENT01$',", script, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("DOMÆNE\\AGENTSERVER$', #", script, StringComparison.Ordinal);
|
||||
Assert.Contains("S-1-5-32-558", script, StringComparison.Ordinal); // Performance Monitor Users
|
||||
Assert.Contains("S-1-5-32-573", script, StringComparison.Ordinal); // Event Log Readers
|
||||
Assert.DoesNotContain("S-1-5-32-544", script, StringComparison.Ordinal); // Administrators
|
||||
Assert.Contains("0x21", script, StringComparison.Ordinal);
|
||||
Assert.Contains("CCLCRPRC", script, StringComparison.Ordinal);
|
||||
Assert.Equal(script, ServerGrantScript.For(@"FIRMA\AGENT01$"));
|
||||
Assert.Contains("'O''Brien'", ServerGrantScript.For("O'Brien"), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0u, false)]
|
||||
[InlineData(TaskResults.Running, false)]
|
||||
|
|
|
|||
121
tests/SQLInsight.Servers.Tests/GrantScriptTests.cs
Normal file
121
tests/SQLInsight.Servers.Tests/GrantScriptTests.cs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
|
||||
namespace SQLInsight.Servers.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The security descriptor logic of db/permissions/grant-windows-server.ps1, run in Windows PowerShell against real
|
||||
/// default SDDLs (Service Control Manager, a WMI namespace, WinRM's RootSDDL): exactly one ACE is added in canonical
|
||||
/// order, an account that already has the right or has a Deny ACE is left alone, and removing gives back the original
|
||||
/// SDDL character for character. Nothing on this computer is read or changed.
|
||||
/// </summary>
|
||||
public sealed class GrantScriptTests
|
||||
{
|
||||
private const string Harness = """
|
||||
param([string] $Script)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile($Script, [ref] $null, [ref] $null)
|
||||
foreach ($f in $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) {
|
||||
if ('Get-Aces', 'Test-Denied', 'Test-Allowed', 'Remove-Ace', 'Add-Ace' -contains $f.Name) { . ([scriptblock]::Create($f.Extent.Text)) }
|
||||
}
|
||||
$accessAllowed = [Security.AccessControl.AceQualifier]::AccessAllowed
|
||||
$accessDenied = [Security.AccessControl.AceQualifier]::AccessDenied
|
||||
$allSections = [Security.AccessControl.AccessControlSections]::All
|
||||
|
||||
$sidObj = New-Object Security.Principal.SecurityIdentifier('S-1-5-20')
|
||||
$scm = 'D:(A;;CC;;;AU)(A;;CCLCRPRC;;;IU)(A;;CCLCRPRC;;;SU)(A;;CCLCRPWPRC;;;SY)(A;;KA;;;BA)(A;;CC;;;AC)S:(AU;FA;KA;;;WD)(AU;OIIOFA;GA;;;WD)'
|
||||
$new = Add-Ace $scm 0x20015
|
||||
"scm-new=$new"
|
||||
"scm-allowed=$(Test-Allowed $new 0x20015)"
|
||||
"scm-back=$((Remove-Ace $new 0x20015) -eq $scm)"
|
||||
|
||||
$sidObj = New-Object Security.Principal.SecurityIdentifier('S-1-5-21-1-2-3-1105')
|
||||
$wmi = 'O:BAG:BAD:(A;CIID;CCDCLCSWRPWPRCWD;;;BA)(A;CIID;CCDCRP;;;NS)(A;CIID;CCDCRP;;;LS)(A;CIID;CCDCRP;;;AU)'
|
||||
$w = Add-Ace $wmi 0x21
|
||||
"wmi-new=$w"
|
||||
"wmi-back=$((Remove-Ace $w 0x21) -eq $wmi)"
|
||||
|
||||
$root = 'O:NSG:BAD:P(A;;GA;;;BA)(A;;GR;;;IU)S:P(AU;FA;GA;;;WD)(AU;SA;GXGW;;;WD)'
|
||||
$r = Add-Ace $root ([int] 0x80000000)
|
||||
"root-new=$r"
|
||||
"root-back=$((Remove-Ace $r ([int] 0x80000000)) -eq $root)"
|
||||
|
||||
$pre = 'D:(A;;CCLCRPRC;;;S-1-5-21-1-2-3-1105)(A;;CC;;;AU)'
|
||||
"pre-allowed=$(Test-Allowed $pre 0x20015)"
|
||||
"pre-remove-other-mask=$((Remove-Ace $pre 0x21) -eq $pre)"
|
||||
"deny=$(Test-Denied 'D:(D;;CC;;;S-1-5-21-1-2-3-1105)(A;;CC;;;AU)')"
|
||||
"no-deny=$(Test-Denied $scm)"
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task Exactly_one_ace_is_added_and_removing_it_restores_the_original()
|
||||
{
|
||||
var output = await RunAsync();
|
||||
|
||||
Assert.Equal("D:(A;;CC;;;AU)(A;;CCLCRPRC;;;IU)(A;;CCLCRPRC;;;SU)(A;;CCLCRPWPRC;;;SY)(A;;KA;;;BA)(A;;CC;;;AC)(A;;CCLCRPRC;;;NS)S:(AU;FA;KA;;;WD)(AU;OIIOFA;GA;;;WD)", output["scm-new"]);
|
||||
Assert.Equal("True", output["scm-allowed"]);
|
||||
Assert.Equal("True", output["scm-back"]);
|
||||
|
||||
// WMI: Enable Account (CC) + Remote Enable (WP), not inherited, before the inherited ACEs.
|
||||
Assert.Equal("O:BAG:BAD:(A;;CCWP;;;S-1-5-21-1-2-3-1105)(A;CIID;CCDCLCSWRPWPRCWD;;;BA)(A;CIID;CCDCRP;;;NS)(A;CIID;CCDCRP;;;LS)(A;CIID;CCDCRP;;;AU)", output["wmi-new"]);
|
||||
Assert.Equal("True", output["wmi-back"]);
|
||||
|
||||
// WinRM: read (GR), never execute (GX).
|
||||
Assert.Equal("O:NSG:BAD:P(A;;GA;;;BA)(A;;GR;;;IU)(A;;GR;;;S-1-5-21-1-2-3-1105)S:P(AU;FA;GA;;;WD)(AU;SA;GXGW;;;WD)", output["root-new"]);
|
||||
Assert.Equal("True", output["root-back"]);
|
||||
|
||||
Assert.Equal("True", output["pre-allowed"]); // already there: the script adds nothing (and -Remove takes nothing)
|
||||
Assert.Equal("True", output["pre-remove-other-mask"]); // only the script's own ACE is ever removed
|
||||
Assert.Equal("True", output["deny"]); // a Deny ACE stops the script
|
||||
Assert.Equal("False", output["no-deny"]);
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<string, string>> RunAsync()
|
||||
{
|
||||
var script = Path.Combine(RepositoryRoot(), "db", "permissions", "grant-windows-server.ps1");
|
||||
var harness = Path.Combine(Path.GetTempPath(), $"sqlinsight-grant-{Guid.NewGuid():N}.ps1");
|
||||
await File.WriteAllTextAsync(harness, Harness, new UTF8Encoding(true));
|
||||
try
|
||||
{
|
||||
var start = new ProcessStartInfo("powershell.exe")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
};
|
||||
foreach (var argument in new[] { "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", harness, "-Script", script })
|
||||
{
|
||||
start.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
using var process = Process.Start(start)!;
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2));
|
||||
var stdout = process.StandardOutput.ReadToEndAsync(timeout.Token);
|
||||
var stderr = process.StandardError.ReadToEndAsync(timeout.Token);
|
||||
await process.WaitForExitAsync(timeout.Token);
|
||||
Assert.True(process.ExitCode == 0, await stderr);
|
||||
return (await stdout).Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(l => l.Split('=', 2))
|
||||
.Where(p => p.Length == 2)
|
||||
.ToDictionary(p => p[0], p => p[1], StringComparer.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(harness);
|
||||
}
|
||||
}
|
||||
|
||||
private static string RepositoryRoot()
|
||||
{
|
||||
for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "SQLInsight.slnx")))
|
||||
{
|
||||
return directory.FullName;
|
||||
}
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("SQLInsight.slnx blev ikke fundet over testens mappe.");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue