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 ''}" ) 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 = @" "@ [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 = @" bitformer AIMAIM WinRM HTTPS bootstrap S-1-5-18HighestAvailable IgnoreNewfalsefalsetruetruefalsetruetruefalsePT10M7 truePT5MPT1Hfalse truePT5MPT1HfalsePT2M %SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$escapedScript" "@ [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 )