Files
Ansible/scripts/src/aim/winrm/manager.py
T
2026-09-15 18:54:22 +02:00

647 lines
30 KiB
Python

from __future__ import annotations
import base64
import os
import tempfile
from pathlib import Path
from ruamel.yaml import YAML
from ruamel.yaml.scalarstring import LiteralScalarString
from aim.exceptions import ExternalCommandFailed
from aim.external import command_available, run_external
class WinRMManager:
"""Bootstrap and test Windows service-user access over preconfigured WinRM HTTPS.
AIM assumes WinRM HTTPS is already enabled and reachable. Credentials are written
only to a private temporary directory (0700) with files mode 0600 and removed when
the operation finishes. Passwords are never placed in subprocess arguments.
"""
def __init__(self, config):
self.config = config
@staticmethod
def _require_ansible(command: str) -> None:
if not command_available(command):
raise ExternalCommandFailed(f"Required command not found: {command}")
@staticmethod
def _dump_private_yaml(path: Path, data: dict) -> None:
yaml = YAML()
yaml.indent(mapping=2, sequence=4, offset=2)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
yaml.dump(data, stream)
except Exception:
try:
os.close(fd)
except OSError:
pass
raise
path.chmod(0o600)
def _connection_inventory(self, fqdn: str, ip: str, username: str, password: str) -> dict:
return {
"all": {
"hosts": {
fqdn: {
"ansible_host": ip,
"ansible_connection": "winrm",
"ansible_port": 5986,
"ansible_winrm_transport": "ntlm",
"ansible_winrm_server_cert_validation": "ignore",
"ansible_user": username,
"ansible_password": password,
}
}
}
}
def _run_ping(self, fqdn: str, ip: str, username: str, password: str) -> None:
self._require_ansible("ansible")
if not username:
raise ValueError("WinRM username is required")
if not password:
raise ValueError("WinRM password is required")
with tempfile.TemporaryDirectory(prefix="aim-winrm-") as tmp_name:
tmp = Path(tmp_name)
tmp.chmod(0o700)
inventory = tmp / "inventory.yml"
self._dump_private_yaml(inventory, self._connection_inventory(fqdn, ip, username, password))
result = run_external(
["ansible", "all", "-i", str(inventory), "-m", "ansible.windows.win_ping"],
cwd=self.config.root_dir, ui_mode="compact",
)
if result.returncode:
raise ExternalCommandFailed(f"WinRM connection test failed for {fqdn}")
def test_connection(self, fqdn: str, ip: str, username: str, password: str) -> None:
self._run_ping(fqdn, ip, username, password)
def test_inventory_service_user(self, customer: str, inventory: Path, fqdn: str) -> None:
"""Test WinRM using the inventory's normal vars and Vault credentials."""
self._require_ansible("ansible")
args = [
"ansible", fqdn, "-i", str(inventory),
"-m", "ansible.windows.win_ping",
]
vault = inventory.parent / "group_vars" / "all" / "vault.yml"
if vault.is_file():
args.extend(["--vault-id", f"{customer}@prompt"])
result = run_external(args, cwd=inventory.parent, ui_mode="compact")
if result.returncode:
raise ExternalCommandFailed(f"Service-user WinRM connection test failed for {fqdn}")
def bootstrap_service_user(
self,
fqdn: str,
ip: str,
bootstrap_user: str,
bootstrap_password: str,
service_password: str,
) -> None:
self._require_ansible("ansible-playbook")
if not bootstrap_user:
raise ValueError("Temporary administrator username is required")
if not bootstrap_password:
raise ValueError("Temporary administrator password is required")
if not service_password:
raise ValueError("Service-user password is required")
# Fail early with a clear connectivity/authentication result before changing anything.
self._run_ping(fqdn, ip, bootstrap_user, bootstrap_password)
with tempfile.TemporaryDirectory(prefix="aim-winrm-") as tmp_name:
tmp = Path(tmp_name)
tmp.chmod(0o700)
inventory = tmp / "inventory.yml"
playbook = tmp / "bootstrap.yml"
inv = self._connection_inventory(fqdn, ip, bootstrap_user, bootstrap_password)
inv["all"].setdefault("vars", {})["aim_service_user"] = self.config.service_user
inv["all"]["vars"]["aim_service_password"] = service_password
self._dump_private_yaml(inventory, inv)
playbook_data = [
{
"name": "Bootstrap AIM Windows service account",
"hosts": "all",
"gather_facts": False,
"tasks": [
{
"name": "Create or repair AIM service account",
"no_log": True,
"ansible.windows.win_user": {
"name": "{{ aim_service_user }}",
"password": "{{ aim_service_password }}",
"state": "present",
"groups": ["S-1-5-32-544"],
"groups_action": "add",
},
}
],
}
]
self._dump_private_yaml(playbook, playbook_data)
result = run_external(
["ansible-playbook", "-i", str(inventory), str(playbook)],
cwd=self.config.root_dir, ui_mode="compact",
)
if result.returncode:
raise ExternalCommandFailed(f"Windows service-user bootstrap failed for {fqdn}")
# Verify the account independently with its own credentials.
self._run_ping(fqdn, ip, self.config.service_user, service_password)
def bootstrap_domain_service_user(
self,
fqdn: str,
ip: str,
bootstrap_user: str,
bootstrap_password: str,
domain_dns: str,
service_password: str,
) -> str:
"""Create or repair the domain-scoped service account in Active Directory."""
self._require_ansible("ansible-playbook")
if not bootstrap_user:
raise ValueError("Temporary domain administrator username is required")
if not bootstrap_password:
raise ValueError("Temporary domain administrator password is required")
domain_dns = domain_dns.strip().lower()
if not domain_dns or "." not in domain_dns:
raise ValueError("A DNS domain such as bitformer.lan is required")
if not service_password:
raise ValueError("Service-user password is required")
self._run_ping(fqdn, ip, bootstrap_user, bootstrap_password)
service_upn = f"{self.config.service_user}@{domain_dns}"
with tempfile.TemporaryDirectory(prefix="aim-winrm-domain-") as tmp_name:
tmp = Path(tmp_name)
tmp.chmod(0o700)
inventory = tmp / "inventory.yml"
playbook = tmp / "bootstrap-domain.yml"
inv = self._connection_inventory(fqdn, ip, bootstrap_user, bootstrap_password)
inv["all"].setdefault("vars", {}).update({
"aim_service_user": self.config.service_user,
"aim_service_upn": service_upn,
"aim_domain_dns": domain_dns,
"aim_service_password_b64": base64.b64encode(service_password.encode("utf-8")).decode("ascii"),
})
self._dump_private_yaml(inventory, inv)
script = r"""$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.DirectoryServices.AccountManagement
$domain = '{{ aim_domain_dns }}'
$sam = '{{ aim_service_user }}'
$upn = '{{ aim_service_upn }}'
$password = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{ aim_service_password_b64 }}'))
if ([string]::IsNullOrEmpty($password)) { throw 'Service password was not supplied' }
$ctx = New-Object System.DirectoryServices.AccountManagement.PrincipalContext('Domain', $domain)
$user = [System.DirectoryServices.AccountManagement.UserPrincipal]::FindByIdentity($ctx, $sam)
if ($null -eq $user) {
$user = New-Object System.DirectoryServices.AccountManagement.UserPrincipal($ctx)
$user.SamAccountName = $sam
$user.UserPrincipalName = $upn
$user.Name = $sam
$user.DisplayName = $sam
$user.Enabled = $true
$user.SetPassword($password)
$user.Save()
} else {
$user.SetPassword($password)
$user.Enabled = $true
if ([string]::IsNullOrEmpty($user.UserPrincipalName)) { $user.UserPrincipalName = $upn }
$user.Save()
}
$adminsSid = 'S-1-5-32-544'
$admins = [System.DirectoryServices.AccountManagement.GroupPrincipal]::FindByIdentity(
$ctx,
[System.DirectoryServices.AccountManagement.IdentityType]::Sid,
$adminsSid
)
if ($null -eq $admins) {
throw "Could not resolve BUILTIN\Administrators ($adminsSid) in domain $domain"
}
if (-not $admins.Members.Contains($user)) {
$admins.Members.Add($user)
$admins.Save()
}
"""
playbook_data = [
{
"name": "Bootstrap AIM Windows domain service account",
"hosts": "all",
"gather_facts": False,
"tasks": [
{
"name": "Create or repair domain service account",
"no_log": True,
"ansible.windows.win_powershell": {
"script": LiteralScalarString(script),
},
}
],
}
]
self._dump_private_yaml(playbook, playbook_data)
result = run_external(
["ansible-playbook", "-i", str(inventory), str(playbook)],
cwd=self.config.root_dir, ui_mode="compact",
)
if result.returncode:
raise ExternalCommandFailed(f"Windows domain service-user bootstrap failed for {fqdn}")
return service_upn
def grant_domain_service_user_access_batch(
self,
targets: list[tuple[str, str]],
bootstrap_user: str,
bootstrap_password: str,
domain_dns: str,
) -> str:
"""Grant the domain service account local Administrators rights on member servers."""
self._require_ansible("ansible-playbook")
if not targets:
raise ValueError("At least one target server is required")
if not bootstrap_user:
raise ValueError("Temporary administrator username is required")
if not bootstrap_password:
raise ValueError("Temporary administrator password is required")
domain_dns = domain_dns.strip().lower()
if not domain_dns or "." not in domain_dns:
raise ValueError("A DNS domain such as bitformer.lan is required")
service_upn = f"{self.config.service_user}@{domain_dns}"
with tempfile.TemporaryDirectory(prefix="aim-winrm-domain-access-") as tmp_name:
tmp = Path(tmp_name)
tmp.chmod(0o700)
inventory = tmp / "inventory.yml"
playbook = tmp / "grant-domain-access.yml"
hosts = {}
for fqdn, ip in targets:
hosts[fqdn] = {
"ansible_host": ip,
"ansible_connection": "winrm",
"ansible_port": 5986,
"ansible_winrm_transport": "ntlm",
"ansible_winrm_server_cert_validation": "ignore",
"ansible_user": bootstrap_user,
"ansible_password": bootstrap_password,
}
inv = {
"all": {
"hosts": hosts,
"vars": {
"aim_service_user": self.config.service_user,
"aim_service_upn": service_upn,
},
}
}
self._dump_private_yaml(inventory, inv)
script = r"""$ErrorActionPreference = 'Stop'
$computerSystem = Get-CimInstance Win32_ComputerSystem
if ($computerSystem.DomainRole -in 4,5) {
$Ansible.Changed = $false
Write-Output 'Skipped: target is a domain controller.'
return
}
$upn = '{{ aim_service_upn }}'
$sam = '{{ aim_service_user }}'
$admins = Get-LocalGroup -SID 'S-1-5-32-544'
$current = Get-LocalGroupMember -Group $admins -ErrorAction SilentlyContinue | Where-Object {
$_.Name -ieq $upn -or $_.Name -match ('\\' + [regex]::Escape($sam) + '$')
}
if (-not $current) {
Add-LocalGroupMember -Group $admins -Member $upn
$Ansible.Changed = $true
} else {
$Ansible.Changed = $false
}
"""
playbook_data = [
{
"name": "Grant AIM domain service account access on member servers",
"hosts": "all",
"gather_facts": False,
"tasks": [
{
"name": "Grant domain service account local Administrator rights",
"ansible.windows.win_powershell": {
"script": LiteralScalarString(script),
},
}
],
}
]
self._dump_private_yaml(playbook, playbook_data)
result = run_external(
["ansible-playbook", "-i", str(inventory), str(playbook)],
cwd=self.config.root_dir, ui_mode="compact",
)
if result.returncode:
raise ExternalCommandFailed("Granting domain service-user access failed for one or more targets")
return service_upn
def _run_inventory_powershell(
self,
customer: str,
inventory: Path,
fqdn: str,
script: str,
*,
capture_output: bool = False,
):
"""Run PowerShell on an inventory host using configured WinRM/Vault credentials."""
self._require_ansible("ansible-playbook")
with tempfile.TemporaryDirectory(prefix="aim-winrm-domain-rollout-") as tmp_name:
tmp = Path(tmp_name)
tmp.chmod(0o700)
playbook = tmp / "domain-rollout.yml"
tasks = [
{
"name": "Run AIM domain rollout operation",
"ansible.windows.win_powershell": {
"script": LiteralScalarString(script),
},
"register": "aim_domain_rollout_result",
}
]
result_path = tmp / "powershell-output.txt"
if capture_output:
tasks.append(
{
"name": "Capture AIM domain rollout output",
"ansible.builtin.copy": {
"content": "{{ (aim_domain_rollout_result.output | default([])) | join('\n') }}",
"dest": str(result_path),
"mode": "0600",
},
"delegate_to": "localhost",
"become": False,
}
)
self._dump_private_yaml(
playbook,
[
{
"name": "AIM domain rollout",
"hosts": fqdn,
"gather_facts": False,
"tasks": tasks,
}
],
)
args = ["ansible-playbook", "-i", str(inventory), str(playbook)]
vault = inventory.parent / "group_vars" / "all" / "vault.yml"
if vault.is_file():
args.extend(["--vault-id", f"{customer}@prompt"])
result = run_external(args, cwd=inventory.parent, capture_output=capture_output, ui_mode="compact")
if capture_output and result.returncode == 0 and result_path.is_file():
result.stdout = result_path.read_text(encoding="utf-8")
result.stderr = result.stderr or ""
return result
def list_domain_ous(self, customer: str, inventory: Path, dc_fqdn: str) -> list[tuple[str, str]]:
"""Return (name, distinguishedName) pairs from the prepared domain controller."""
import json
import re
script = r"""$ErrorActionPreference = 'Stop'
Import-Module ActiveDirectory
$items = Get-ADOrganizationalUnit -Filter * -Properties DistinguishedName |
Sort-Object DistinguishedName |
Select-Object Name, DistinguishedName
$json = @($items) | ConvertTo-Json -Compress -Depth 3
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
$b64 = [Convert]::ToBase64String($bytes)
Write-Output ("AIM_OUS_B64=" + $b64)
"""
result = self._run_inventory_powershell(customer, inventory, dc_fqdn, script, capture_output=True)
if result.returncode:
detail = (result.stderr or result.stdout or "").strip()
raise ExternalCommandFailed(f"Could not query domain OUs from {dc_fqdn}: {detail}")
combined = (result.stdout or "") + "\n" + (result.stderr or "")
if not (result.stdout or "").strip():
raise ExternalCommandFailed(
"Domain OU query succeeded but returned no PowerShell output"
)
match = re.search(r'(?:^|\s)AIM_OUS_B64=([A-Za-z0-9+/=]+)(?:$|\s)', combined)
if not match:
preview = " ".join((result.stdout or "").split())[:240]
raise ExternalCommandFailed(
"Domain OU query succeeded but AIM could not parse the OU list. "
f"Sanitized output: {preview or '<empty>'}"
)
try:
import base64
decoded = base64.b64decode(match.group(1), validate=True).decode("utf-8")
data = json.loads(decoded)
except Exception as exc:
raise ExternalCommandFailed(f"Domain OU query returned unreadable data: {exc}") from exc
if isinstance(data, dict):
data = [data]
return [
(str(item.get("Name", "")), str(item.get("DistinguishedName", "")))
for item in data
if item.get("DistinguishedName")
]
def deploy_domain_winrm_gpo(
self,
customer: str,
inventory: Path,
dc_fqdn: str,
ou_dn: str,
additional_sans: list[str] | None = None,
) -> None:
"""Create/repair the AIM AD group and WinRM rollout GPO using the prepared DC."""
import json
payload = {
"ou_dn": ou_dn,
"group_name": "GG_bitformer_Ansible_Admins",
"gpo_name": "bitformer - Ansible WinRM",
"task_name": "bitformer - Configure Ansible WinRM",
"service_user": self.config.service_user,
"additional_sans": additional_sans or [],
}
payload_b64 = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("ascii")
script = r"""$ErrorActionPreference = 'Stop'
Import-Module ActiveDirectory
Import-Module GroupPolicy
$cfg = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__AIM_PAYLOAD_B64__')) | ConvertFrom-Json
$domain = Get-ADDomain
$ou = Get-ADOrganizationalUnit -Identity $cfg.ou_dn -Properties DistinguishedName
if ($ou.DistinguishedName -ieq $domain.DomainControllersContainer) {
throw 'The Domain Controllers OU is not a valid target for the member-server WinRM rollout.'
}
$serviceUser = Get-ADUser -Identity $cfg.service_user -ErrorAction Stop
$groupName = [string]$cfg.group_name
$groupFilterName = $groupName.Replace("'", "''")
$group = Get-ADGroup -Filter "SamAccountName -eq '$groupFilterName'" | Select-Object -First 1
if (-not $group) {
$group = New-ADGroup -Name $cfg.group_name -SamAccountName $cfg.group_name -GroupScope Global -GroupCategory Security -Path $domain.UsersContainer -PassThru
}
$member = Get-ADGroupMember -Identity $group -Recursive -ErrorAction SilentlyContinue | Where-Object { $_.DistinguishedName -eq $serviceUser.DistinguishedName }
if (-not $member) { Add-ADGroupMember -Identity $group -Members $serviceUser }
$gpoName = [string]$cfg.gpo_name
$gpo = Get-GPO -All | Where-Object { $_.DisplayName -eq $gpoName } | Select-Object -First 1
if (-not $gpo) {
$gpo = New-GPO -Name $cfg.gpo_name -Comment 'Managed by AIM: WinRM HTTPS bootstrap and Ansible local administrator access.'
}
$existingLink = (Get-GPInheritance -Target $ou.DistinguishedName).GpoLinks | Where-Object { $_.DisplayName -eq $cfg.gpo_name }
if (-not $existingLink) { New-GPLink -Name $cfg.gpo_name -Target $ou.DistinguishedName -LinkEnabled Yes | Out-Null }
$guid = '{' + $gpo.Id.Guid.ToString().ToUpperInvariant() + '}'
$policyRoot = "\\$($domain.DNSRoot)\SYSVOL\$($domain.DNSRoot)\Policies\$guid\Machine"
$preferences = Join-Path $policyRoot 'Preferences'
$groupsDir = Join-Path $preferences 'Groups'
$tasksDir = Join-Path $preferences 'ScheduledTasks'
$scriptsDir = Join-Path $policyRoot 'Scripts'
New-Item -ItemType Directory -Path $groupsDir,$tasksDir,$scriptsDir -Force | Out-Null
$netbios = $domain.NetBIOSName
$groupSid = $group.SID.Value
$groupUid = '{' + ([guid]::NewGuid().ToString().ToUpperInvariant()) + '}'
$changed = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$groupsXml = @"
<?xml version="1.0" encoding="utf-8"?>
<Groups clsid="{3125E937-EB16-4b4c-9934-544FC6D24D26}" disabled="0">
<Group clsid="{6D4A79E4-529C-4481-ABD0-F5BD7EA93BA7}" name="Administrators" image="2" changed="$changed" uid="$groupUid">
<Properties action="U" newName="" description="AIM-managed Ansible administrators" userAction="ADD" deleteAllUsers="0" deleteAllGroups="0" removeAccounts="0" groupName="Administrators" groupSid="S-1-5-32-544">
<Members><Member name="$netbios\$($cfg.group_name)" action="ADD" sid="$groupSid" /></Members>
</Properties>
</Group>
</Groups>
"@
[IO.File]::WriteAllText((Join-Path $groupsDir 'Groups.xml'), $groupsXml, [Text.UTF8Encoding]::new($false))
$extraSansLiteral = @($cfg.additional_sans | ForEach-Object { "'" + ($_ -replace "'", "''") + "'" }) -join ', '
$taskScript = @"
`$ErrorActionPreference = 'Stop'
`$taskName = '$($cfg.task_name)'
try {
Set-Service -Name WinRM -StartupType Automatic
Start-Service -Name WinRM
`$hostName = `$env:COMPUTERNAME
try { `$fqdn = [System.Net.Dns]::GetHostEntry(`$env:COMPUTERNAME).HostName } catch { `$fqdn = `$hostName }
`$hostIPs = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { `$_.IPAddress -notlike '127.*' -and `$_.IPAddress -notlike '169.254.*' } | Select-Object -ExpandProperty IPAddress -Unique)
`$additionalSans = @($extraSansLiteral)
`$sans = @(`$hostName, `$fqdn) + `$hostIPs + `$additionalSans | Where-Object { `$_ } | Select-Object -Unique
`$cert = Get-ChildItem Cert:\LocalMachine\My | Where-Object { `$_.FriendlyName -eq 'WinRM' -and `$_.NotAfter -gt (Get-Date).AddDays(7) } | Sort-Object NotAfter -Descending | Select-Object -First 1
if (-not `$cert) {
`$cert = New-SelfSignedCertificate -DnsName `$sans -TextExtension '2.5.29.37={text}1.3.6.1.5.5.7.3.1' -FriendlyName 'WinRM' -CertStoreLocation 'Cert:\LocalMachine\My'
}
`$httpsListener = Get-ChildItem WSMan:\localhost\Listener -ErrorAction SilentlyContinue | Where-Object { `$_.Keys -contains 'Transport=HTTPS' } | Select-Object -First 1
if (-not `$httpsListener) { New-Item -Path WSMan:\localhost\Listener -Transport HTTPS -Address * -CertificateThumbPrint `$cert.Thumbprint -Force | Out-Null }
`$rule = Get-NetFirewallRule -DisplayName 'Windows Remote Management (HTTPS-In)' -ErrorAction SilentlyContinue
if (-not `$rule) { New-NetFirewallRule -DisplayName 'Windows Remote Management (HTTPS-In)' -Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow -Program System | Out-Null } else { `$rule | Enable-NetFirewallRule | Out-Null }
`$listenerOk = @(Get-ChildItem WSMan:\localhost\Listener -ErrorAction SilentlyContinue | Where-Object { `$_.Keys -contains 'Transport=HTTPS' }).Count -gt 0
`$firewallOk = @(Get-NetFirewallRule -DisplayName 'Windows Remote Management (HTTPS-In)' -ErrorAction SilentlyContinue | Where-Object Enabled -eq 'True').Count -gt 0
`$serviceOk = (Get-Service WinRM).Status -eq 'Running'
if (-not (`$listenerOk -and `$firewallOk -and `$serviceOk)) { throw 'WinRM rollout verification failed' }
Disable-ScheduledTask -TaskName `$taskName -ErrorAction SilentlyContinue | Out-Null
exit 0
} catch { Write-Error `$_; exit 1 }
"@
$scriptPath = Join-Path $scriptsDir 'AIM-WinRM-Setup.ps1'
[IO.File]::WriteAllText($scriptPath, $taskScript, [Text.UTF8Encoding]::new($false))
$scriptUnc = "\\$($domain.DNSRoot)\SYSVOL\$($domain.DNSRoot)\Policies\$guid\Machine\Scripts\AIM-WinRM-Setup.ps1"
$taskUid = '{' + ([guid]::NewGuid().ToString().ToUpperInvariant()) + '}'
$escapedScript = [Security.SecurityElement]::Escape($scriptUnc)
$taskXml = @"
<?xml version="1.0" encoding="utf-8"?>
<ScheduledTasks clsid="{CC63F200-7309-4ba0-B154-A71CD118DBCC}" disabled="0">
<TaskV2 clsid="{D8896631-B747-47a7-84A6-C155337F3BC8}" name="$($cfg.task_name)" image="2" changed="$changed" uid="$taskUid" userContext="0" removePolicy="0" desc="AIM WinRM HTTPS bootstrap">
<Properties action="U" name="$($cfg.task_name)" runAs="NT AUTHORITY\SYSTEM" logonType="Service Account">
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo><Author>bitformer AIM</Author><Description>AIM WinRM HTTPS bootstrap</Description></RegistrationInfo>
<Principals><Principal id="Author"><UserId>S-1-5-18</UserId><RunLevel>HighestAvailable</RunLevel></Principal></Principals>
<Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><AllowHardTerminate>true</AllowHardTerminate><StartWhenAvailable>true</StartWhenAvailable><RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable><AllowStartOnDemand>true</AllowStartOnDemand><Enabled>true</Enabled><Hidden>false</Hidden><ExecutionTimeLimit>PT10M</ExecutionTimeLimit><Priority>7</Priority></Settings>
<Triggers>
<RegistrationTrigger><Enabled>true</Enabled><Repetition><Interval>PT5M</Interval><Duration>PT1H</Duration><StopAtDurationEnd>false</StopAtDurationEnd></Repetition></RegistrationTrigger>
<BootTrigger><Enabled>true</Enabled><Repetition><Interval>PT5M</Interval><Duration>PT1H</Duration><StopAtDurationEnd>false</StopAtDurationEnd></Repetition><Delay>PT2M</Delay></BootTrigger>
</Triggers>
<Actions><Exec><Command>%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe</Command><Arguments>-NoProfile -NonInteractive -ExecutionPolicy Bypass -File &quot;$escapedScript&quot;</Arguments></Exec></Actions>
</Task>
</Properties>
</TaskV2>
</ScheduledTasks>
"@
[IO.File]::WriteAllText((Join-Path $tasksDir 'ScheduledTasks.xml'), $taskXml, [Text.UTF8Encoding]::new($false))
$gpoDn = "CN=$guid,CN=Policies,CN=System,$($domain.DistinguishedName)"
$gpoAd = Get-ADObject -Identity $gpoDn -Properties gPCMachineExtensionNames
$ext = [string]$gpoAd.gPCMachineExtensionNames
$pairs = @(
'[{17D89FEC-5C44-4972-B12D-241CAEF74509}{79F92669-4224-476C-9C5C-6EFB4D87DF4A}]',
'[{AADCED64-746C-4633-A97C-D61349046527}{CAB54552-DEEA-4691-817E-ED4A4D1AFC72}]'
)
foreach ($pair in $pairs) {
$cse = $pair.Substring(1, 38)
if ($ext -notlike "*$cse*") { $ext += $pair }
}
Set-ADObject -Identity $gpoDn -Replace @{ gPCMachineExtensionNames = $ext }
$stamp = [int][DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
Set-GPRegistryValue -Name $cfg.gpo_name -Key 'HKLM\Software\Policies\bitformer\AIM' -ValueName 'WinRMRolloutRevision' -Type DWord -Value $stamp | Out-Null
# Verify that Group Policy Management can render both XML and HTML reports.
# The HTML renderer is stricter about malformed/unsupported preference payloads.
$reportBase = Join-Path $env:TEMP ("aim-gpo-report-" + [guid]::NewGuid().ToString())
$xmlReport = $reportBase + '.xml'
$htmlReport = $reportBase + '.html'
try {
Get-GPOReport -Guid $gpo.Id -ReportType Xml -Path $xmlReport -ErrorAction Stop
Get-GPOReport -Guid $gpo.Id -ReportType Html -Path $htmlReport -ErrorAction Stop
if (-not (Test-Path $htmlReport) -or (Get-Item $htmlReport).Length -eq 0) {
throw 'GPMC produced an empty HTML report.'
}
} catch {
throw "AIM created/repaired the GPO, but GPMC report validation failed: $($_.Exception.Message)"
} finally {
Remove-Item $xmlReport,$htmlReport -Force -ErrorAction SilentlyContinue
}
Write-Output "AIM_GPO_NAME=$($cfg.gpo_name)"
Write-Output "AIM_GROUP_NAME=$($cfg.group_name)"
Write-Output "AIM_OU_DN=$($ou.DistinguishedName)"
""".replace('__AIM_PAYLOAD_B64__', payload_b64)
result = self._run_inventory_powershell(customer, inventory, dc_fqdn, script)
if result.returncode:
raise ExternalCommandFailed(f"Domain WinRM GPO deployment failed on {dc_fqdn}")
def grant_domain_service_user_access(
self,
fqdn: str,
ip: str,
bootstrap_user: str,
bootstrap_password: str,
domain_dns: str,
) -> str:
"""Backward-compatible one-host wrapper."""
return self.grant_domain_service_user_access_batch(
[(fqdn, ip)], bootstrap_user, bootstrap_password, domain_dns
)