added aim
This commit is contained in:
@@ -0,0 +1,646 @@
|
||||
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 "$escapedScript"</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
|
||||
)
|
||||
Reference in New Issue
Block a user