diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml new file mode 100644 index 0000000..1834926 --- /dev/null +++ b/scripts/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "aim-inventory-manager" +version = "2.1.2" +description = "AIM - Ansible Inventory Manager" +requires-python = ">=3.11" +dependencies = [ + "ruamel.yaml>=0.18,<0.19", + "rich>=13,<15", +] + +[project.scripts] +aim = "aim.__main__:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/scripts/src/aim/__init__.py b/scripts/src/aim/__init__.py new file mode 100644 index 0000000..4eabd0b --- /dev/null +++ b/scripts/src/aim/__init__.py @@ -0,0 +1 @@ +__version__ = "2.1.2" diff --git a/scripts/src/aim/__main__.py b/scripts/src/aim/__main__.py new file mode 100644 index 0000000..cd2070c --- /dev/null +++ b/scripts/src/aim/__main__.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import argparse +import signal +import sys +from aim import __version__ + + +def main() -> None: + parser = argparse.ArgumentParser(prog='aim', description='AIM - Ansible Inventory Manager') + parser.add_argument('--version', action='version', version=f'AIM {__version__}') + parser.add_argument('--no-clear', action='store_true', help='Keep terminal scrollback between menus') + parser.add_argument('--plain', action='store_true', help='Use ASCII characters for limited terminals') + parser.add_argument('--live-output', action='store_true', help='Stream all external command output') + args = parser.parse_args() + from aim.exceptions import AIMError + from aim.ui.app import App + from ruamel.yaml.error import YAMLError + old_term = signal.getsignal(signal.SIGTERM) + def terminate(signum, frame): + # Unwind context managers so temporary files and AIM-owned agents clean up. + raise SystemExit(128 + signum) + signal.signal(signal.SIGTERM, terminate) + try: + try: + app = App(no_clear=args.no_clear, plain=args.plain, live_output=args.live_output) + except (AIMError, OSError, ValueError, YAMLError) as exc: + print(f'AIM startup error ({type(exc).__name__}). Check aim.yml and access permissions.', file=sys.stderr) + raise SystemExit(1) + try: + raise SystemExit(app.run()) + except KeyboardInterrupt: + raise SystemExit(130) from None + finally: + signal.signal(signal.SIGTERM, old_term) + + +if __name__ == '__main__': + main() diff --git a/scripts/src/aim/auth.py b/scripts/src/aim/auth.py new file mode 100644 index 0000000..c792c06 --- /dev/null +++ b/scripts/src/aim/auth.py @@ -0,0 +1,41 @@ +import grp +import os +from dataclasses import dataclass + +from aim.exceptions import AIMError + + +@dataclass(frozen=True) +class AuthorizationResult: + group_name: str + gid: int + + +class AuthorizationError(AIMError): + pass + + +def require_group_membership(group_name: str) -> AuthorizationResult: + """Require membership in the configured local/NSS-resolved group. + + The group may be provided by local files, SSSD, winbind, or another NSS + provider. Both the process primary GID and supplementary groups are checked. + """ + try: + group = grp.getgrnam(group_name) + except KeyError as exc: + raise AuthorizationError( + f"Required AIM group could not be resolved: {group_name!r}. " + "Check NSS/SSSD/winbind and the AIM configuration." + ) from exc + + required_gid = group.gr_gid + active_gids = {os.getgid(), os.getegid(), *os.getgroups()} + + if required_gid not in active_gids: + raise AuthorizationError( + f"AIM requires membership in {group_name!r} (GID {required_gid}). " + "Your current session does not have that group active." + ) + + return AuthorizationResult(group_name=group_name, gid=required_gid) diff --git a/scripts/src/aim/backup/__init__.py b/scripts/src/aim/backup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/src/aim/backup/session.py b/scripts/src/aim/backup/session.py new file mode 100644 index 0000000..2bd1221 --- /dev/null +++ b/scripts/src/aim/backup/session.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import os +import shutil +import stat +import tempfile +from pathlib import Path + +from aim.inventory.validator import validate_yaml +from aim.locking import InventoryLock + + +class SessionBackupManager: + """Keep one pre-change recovery copy per inventory for this AIM process.""" + + def __init__(self): + self._backed_up: set[Path] = set() + + @staticmethod + def backup_path(inventory: Path) -> Path: + return inventory.with_name(inventory.stem + ".aim-session.bak" + inventory.suffix) + + def backup_once(self, inventory: Path) -> Path | None: + inventory = inventory.resolve() + if inventory in self._backed_up or not inventory.exists(): + return None + backup = self.backup_path(inventory) + shutil.copy2(inventory, backup) + self._backed_up.add(inventory) + return backup + + def has_session_backup(self, inventory: Path) -> bool: + inventory = inventory.resolve() + return inventory in self._backed_up and self.backup_path(inventory).exists() + + def restore(self, inventory: Path, *, customer: str) -> Path: + inventory = inventory.resolve() + if not self.has_session_backup(inventory): + raise FileNotFoundError("No recovery backup was created for this inventory in the current AIM session") + backup = self.backup_path(inventory) + validate_yaml(backup) + with InventoryLock(inventory): + fd, temp_name = tempfile.mkstemp(prefix=".aim-restore-", suffix=".yml", dir=inventory.parent) + os.close(fd) + temp = Path(temp_name) + try: + shutil.copy2(backup, temp) + if inventory.exists(): + temp.chmod(stat.S_IMODE(inventory.stat().st_mode)) + os.replace(temp, inventory) + finally: + temp.unlink(missing_ok=True) + validate_yaml(inventory) + return backup diff --git a/scripts/src/aim/config.py b/scripts/src/aim/config.py new file mode 100644 index 0000000..1a044fd --- /dev/null +++ b/scripts/src/aim/config.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +import os +import tempfile +from pathlib import Path + +from ruamel.yaml import YAML + +from aim.exceptions import ConfigurationError +from aim.permissions import apply_managed_permissions + +DEFAULT_REQUIRED_GROUP = "srv_debsansible01_admins@bitformer.lan" +DEFAULT_PLATFORMS = ["linux", "windows", "sophosxgs", "pfsense"] + + +@dataclass +class Config: + root_dir: Path = Path("/etc/ansible") + service_user: str = "svc_bf-ansible" + required_group: str = DEFAULT_REQUIRED_GROUP + platform_groups: list[str] = field(default_factory=lambda: list(DEFAULT_PLATFORMS)) + + ui_clear_screen: bool = True + ui_ascii: bool = False + ui_output: str = "compact" + + @property + def inventory_dir(self) -> Path: + return self.root_dir / "inventories" + + @property + def config_path(self) -> Path: + return self.root_dir / "aim.yml" + + @classmethod + def load(cls, path: Path | None = None) -> "Config": + default = cls() + path = path or default.config_path + if not path.exists(): + return default + try: + yaml = YAML(typ="safe") + with path.open(encoding="utf-8") as stream: + data = yaml.load(stream) or {} + if not isinstance(data, dict): + raise ValueError("configuration root must be a YAML mapping") + root = Path(str(data.get("root_dir", default.root_dir))) + service_user = str(data.get("service_user", default.service_user)).strip() + required_group = str(data.get("required_group", default.required_group)).strip() + platform_groups = [str(x).strip() for x in data.get("platform_groups", default.platform_groups) if str(x).strip()] + if not service_user: + raise ValueError("service_user must not be empty") + if not required_group: + raise ValueError("required_group must not be empty") + if not platform_groups: + raise ValueError("platform_groups must contain at least one group") + ui = data.get("ui", {}) or {} + if not isinstance(ui, dict): + raise ValueError("ui must be a mapping") + output = str(ui.get("output", "compact")) + if output not in {"compact", "live"}: + raise ValueError("ui.output must be compact or live") + for setting in ("clear_screen", "ascii"): + if setting in ui and not isinstance(ui[setting], bool): + raise ValueError(f"ui.{setting} must be true or false") + return cls(root, service_user, required_group, platform_groups, + ui.get("clear_screen", True), ui.get("ascii", False), output) + except Exception as exc: + raise ConfigurationError(f"Could not load {path}: {exc}") from exc + + def save(self) -> None: + """Merge known settings; preserve comments and unknown customer additions.""" + self.root_dir.mkdir(parents=True, exist_ok=True) + yaml = YAML() + yaml.preserve_quotes = True + original = self.config_path.read_bytes() if self.config_path.exists() else None + data = yaml.load(original.decode("utf-8")) if original else {} + if data is None: + data = {} + if not isinstance(data, dict): + raise ConfigurationError("Existing configuration root is not a mapping") + data.update(root_dir=str(self.root_dir), service_user=self.service_user, + required_group=self.required_group, platform_groups=self.platform_groups) + ui = data.setdefault("ui", {}) + if not isinstance(ui, dict): + raise ConfigurationError("Existing ui configuration is not a mapping") + ui.update(clear_screen=self.ui_clear_screen, ascii=self.ui_ascii, output=self.ui_output) + fd, name = tempfile.mkstemp(prefix=".aim-config-", dir=self.root_dir) + tmp = Path(name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + yaml.dump(data, stream) + stream.flush() + os.fsync(stream.fileno()) + current = self.config_path.read_bytes() if self.config_path.exists() else None + if current != original: + raise ConfigurationError("Configuration changed externally; reload before saving") + apply_managed_permissions(tmp, self.required_group) + os.replace(tmp, self.config_path) + except OSError as exc: + raise ConfigurationError(f"Could not save {self.config_path}: {exc}") from exc + finally: + tmp.unlink(missing_ok=True) diff --git a/scripts/src/aim/customers/__init__.py b/scripts/src/aim/customers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/src/aim/customers/manager.py b/scripts/src/aim/customers/manager.py new file mode 100644 index 0000000..82b683a --- /dev/null +++ b/scripts/src/aim/customers/manager.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import os +import re +import shutil +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from ruamel.yaml import YAML + +from aim.exceptions import CustomerExists, CustomerNotFound, ValidationFailed +from aim.inventory.validator import validate +from aim.locking import InventoryLock +from aim.permissions import apply_managed_permissions +from aim.templates import group_var_templates + +CUSTOMER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") + +@dataclass +class CustomerDefaults: + domain_suffix: str = "" + network_address: str = "" + netmask: str = "" + ad_dns_domain: str = "" + ad_netbios_domain: str = "" + + +class CustomerManager: + def __init__(self, config): + self.config = config + + def path(self, name: str) -> Path: + return self.config.inventory_dir / name + + def list(self) -> list[str]: + root = self.config.inventory_dir + if not root.exists(): + return [] + return sorted(p.name for p in root.iterdir() if p.is_dir() and not p.name.startswith(".aim-")) + + def _validate_name(self, name: str) -> None: + if not CUSTOMER_RE.fullmatch(name): + raise ValueError("Invalid customer name. Use letters, numbers, dots, underscores, or hyphens.") + + def _write(self, path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + apply_managed_permissions(path, self.config.required_group) + + def defaults_path(self, customer: str) -> Path: + return self.require(customer) / ".aim.yml" + + def load_defaults(self, customer: str) -> CustomerDefaults: + path = self.defaults_path(customer) + if not path.exists(): + return CustomerDefaults() + yaml = YAML(typ="safe") + with path.open(encoding="utf-8") as stream: + data = yaml.load(stream) or {} + if not isinstance(data, dict): + raise ValueError(f"Customer defaults must be a YAML mapping: {path}") + return CustomerDefaults( + domain_suffix=str(data.get("domain_suffix", "") or "").strip().lstrip("."), + network_address=str(data.get("network_address", "") or "").strip(), + netmask=str(data.get("netmask", "") or "").strip(), + ad_dns_domain=str(data.get("ad_dns_domain", "") or "").strip().lstrip("."), + ad_netbios_domain=str(data.get("ad_netbios_domain", "") or "").strip(), + ) + + def save_defaults(self, customer: str, defaults: CustomerDefaults) -> Path: + path = self.defaults_path(customer) + yaml = YAML() + yaml.indent(mapping=2, sequence=4, offset=2) + original = path.read_bytes() if path.exists() else None + yaml.preserve_quotes = True + data = yaml.load(original.decode("utf-8")) if original else {} + if data is None: + data = {} + if not isinstance(data, dict): + raise ValidationFailed(f"Customer defaults must be a YAML mapping: {path}") + data.update({ + "domain_suffix": defaults.domain_suffix.strip().lstrip("."), + "network_address": defaults.network_address.strip(), + "netmask": defaults.netmask.strip(), + "ad_dns_domain": defaults.ad_dns_domain.strip().lstrip("."), + "ad_netbios_domain": defaults.ad_netbios_domain.strip(), + }) + fd, filename = tempfile.mkstemp(prefix=".aim-defaults-", dir=path.parent) + tmp = Path(filename) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + yaml.dump(data, stream) + stream.flush() + os.fsync(stream.fileno()) + current = path.read_bytes() if path.exists() else None + if current != original: + raise ValidationFailed("Customer defaults changed externally; reload before saving") + apply_managed_permissions(tmp, self.config.required_group) + os.replace(tmp, path) + finally: + tmp.unlink(missing_ok=True) + return path + + def create_structure(self, name: str) -> Path: + self._validate_name(name) + final = self.path(name) + if final.exists(): + raise CustomerExists(f"Customer already exists: {name}") + + self.config.inventory_dir.mkdir(parents=True, exist_ok=True) + try: + apply_managed_permissions(self.config.inventory_dir, self.config.required_group, directory=True) + except OSError: + pass + temp = Path(tempfile.mkdtemp(prefix=f".aim-{name}-", dir=self.config.inventory_dir)) + try: + apply_managed_permissions(temp, self.config.required_group, directory=True) + directories = ["group_vars/all", "host_vars"] + directories.extend(f"group_vars/{group}" for group in self.config.platform_groups) + if "linux" in self.config.platform_groups: + directories.append("group_vars/linux/.ssh") + for rel in directories: + directory = temp / rel + directory.mkdir(parents=True, exist_ok=True) + apply_managed_permissions(directory, self.config.required_group, directory=True) + + defaults: dict[str, str] = { + "group_vars/all/main.yml": f"# group_vars/all/main.yml for {name}\n", + } + for rel, (header, data) in group_var_templates(str(final), self.config.service_user).items(): + from io import StringIO + yaml = YAML() + yaml.default_flow_style = False + stream = StringIO() + stream.write(header) + # Keep the Sophos password hint as a hand-editable comment. + if rel == "group_vars/sophosxgs/main.yml": + stream.write('#ansible_password: "{{ ansible_password }}" # Passwort wird aus --ask-pass übernommen\n') + yaml.dump(dict(data), stream) + defaults[rel] = stream.getvalue() + for rel, content in defaults.items(): + if (temp / rel).parent.exists(): + self._write(temp / rel, content) + + hosts = temp / "hosts.yml" + self._write(hosts, f"all:\n children:\n {name}:\n children: {{}}\n") + validate(hosts, customer=name) + os.rename(temp, final) + return final + except Exception: + shutil.rmtree(temp, ignore_errors=True) + raise + + + def set_windows_ansible_user(self, customer: str, username: str) -> Path: + """Set the Windows ansible_user explicitly while preserving other group vars.""" + username = username.strip() + if not username: + raise ValueError("Windows ansible_user must not be empty") + + path = self.require(customer) / "group_vars" / "windows" / "main.yml" + path.parent.mkdir(parents=True, exist_ok=True) + apply_managed_permissions(path.parent, self.config.required_group, directory=True) + + yaml = YAML() + yaml.preserve_quotes = True + data = {} + if path.exists(): + try: + with path.open("r", encoding="utf-8") as stream: + data = yaml.load(stream) or {} + except Exception as exc: + raise ValidationFailed(f"Invalid YAML in {path}: {exc}") from exc + if not isinstance(data, dict): + raise ValidationFailed(f"Windows group vars must be a YAML mapping: {path}") + + if data.get("ansible_user") != username: + data["ansible_user"] = username + with path.open("w", encoding="utf-8") as stream: + yaml.dump(data, stream) + apply_managed_permissions(path, self.config.required_group) + return path + + def ensure_windows_service_user(self, customer: str) -> Path: + """Set the standard Windows service user when ansible_user is missing/blank. + + Existing non-empty custom usernames are preserved. + """ + path = self.require(customer) / "group_vars" / "windows" / "main.yml" + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + apply_managed_permissions(path.parent, self.config.required_group, directory=True) + self._write(path, f"ansible_user: {self.config.service_user}\n") + return path + + yaml = YAML() + yaml.preserve_quotes = True + try: + with path.open("r", encoding="utf-8") as stream: + data = yaml.load(stream) or {} + except Exception as exc: + raise ValidationFailed(f"Invalid YAML in {path}: {exc}") from exc + if not isinstance(data, dict): + raise ValidationFailed(f"Windows group vars must be a YAML mapping: {path}") + + current = data.get("ansible_user") + if current is None or (isinstance(current, str) and not current.strip()): + data["ansible_user"] = self.config.service_user + with path.open("w", encoding="utf-8") as stream: + yaml.dump(data, stream) + apply_managed_permissions(path, self.config.required_group) + return path + + + def set_windows_host_credential_model(self, customer: str, fqdn: str, model: str) -> Path: + """Write the selected Windows credential override into host_vars.""" + path = self.ensure_host_vars(customer, fqdn) + yaml = YAML() + yaml.preserve_quotes = True + data = {} + if path.stat().st_size: + with path.open("r", encoding="utf-8") as stream: + data = yaml.load(stream) or {} + if not isinstance(data, dict): + raise ValueError(f"Host vars must be a YAML mapping: {path}") + if model == "domain": + data.pop("ansible_user", None) + data.pop("ansible_password", None) + elif model == "shared_local": + data["ansible_user"] = self.config.service_user + data["ansible_password"] = "{{ vault_windows_local_ansible_password }}" + elif model == "host_local": + key = re.sub(r"[^a-z0-9]+", "_", fqdn.lower()).strip("_") + data["ansible_user"] = self.config.service_user + data["ansible_password"] = "{{ vault_ansible_password_" + key + " }}" + else: + raise ValueError("Unknown Windows credential model") + with path.open("w", encoding="utf-8") as stream: + yaml.dump(data, stream) + apply_managed_permissions(path, self.config.required_group) + return path + + def ensure_host_vars(self, customer: str, fqdn: str) -> Path: + if not CUSTOMER_RE.fullmatch(fqdn): + # FQDN characters are intentionally restricted to safe path components. + raise ValueError("Invalid host name for host_vars path") + directory = self.require(customer) / "host_vars" / fqdn + directory.mkdir(parents=True, exist_ok=True) + apply_managed_permissions(directory, self.config.required_group, directory=True) + path = directory / "main.yml" + if not path.exists(): + self._write(path, "") + return path + + + def ensure_sophos_host_vars(self, customer: str, fqdn: str) -> Path: + """Create a commented Sophos XGS starter template for a new/empty host_vars file. + + The template is intentionally comment-only. Operators can use it by hand, + while AIM's Sophos configuration flow appends active YAML below it. + Existing non-empty host_vars are preserved verbatim. + """ + path = self.ensure_host_vars(customer, fqdn) + if path.exists() and path.stat().st_size > 0: + return path + + hostname = fqdn.split(".", 1)[0] + content = f"""# host_vars for {hostname} +##################################### +# hostname: {hostname} +# network_objects: +# example1: +# name: "Example 1" # NATIVE VLAN +# network: "0.0.0.0" # NETWORK ADDRESS +# subnetmask: "255.255.255.0" +# +# example2: +# name: "Example 2" # OTHER VLAN +# network: "0.0.0.0" # NETWORK ADDRESS +# subnetmask: "255.255.255.0" +# +# add other network objects here +# ##################################### +# +# vlan_interfaces: +# example2: +# name: "Example 2" +# ip_address: "0.0.0.0" # INTERFACE IP NOT NETWORK ADDRESS +# subnetmask: "255.255.255.0" +# vlan_id: 101 +# zone_name: "Example 2" +# zone_type: "LAN" +# zone_description: "Example 2 Zone (LAN)" +# +# add other vlan interfaces here +# ##################################### +""" + self._write(path, content) + return path + + def remove_host_vars(self, customer: str, fqdn: str) -> bool: + directory = self.require(customer) / "host_vars" / fqdn + if directory.is_dir(): + shutil.rmtree(directory) + return True + return False + + def delete(self, name: str) -> None: + path = self.require(name) + inventory = path / "hosts.yml" + with InventoryLock(inventory): + shutil.rmtree(path) + + def require(self, name: str) -> Path: + path = self.path(name) + if not path.is_dir(): + raise CustomerNotFound(f"Customer not found: {name}") + return path diff --git a/scripts/src/aim/exceptions.py b/scripts/src/aim/exceptions.py new file mode 100644 index 0000000..058e9bf --- /dev/null +++ b/scripts/src/aim/exceptions.py @@ -0,0 +1,46 @@ +class AIMError(Exception): + """Base class for expected AIM errors.""" + + +class CustomerExists(AIMError): + pass + + +class CustomerNotFound(AIMError): + pass + + +class HostNotFound(AIMError): + pass + + +class InvalidInventory(AIMError): + pass + + +class InventoryConflict(AIMError): + pass + + +class InventoryLocked(AIMError): + pass + + +class ExternalCommandFailed(AIMError): + pass + + +class ValidationFailed(AIMError): + pass + + +class ValidationIncomplete(AIMError): + pass + + +class ConfigurationError(AIMError): + pass + + +class OperationCancelled(AIMError): + """User cancelled the current operation; return to its owning menu.""" diff --git a/scripts/src/aim/external.py b/scripts/src/aim/external.py new file mode 100644 index 0000000..d03359a --- /dev/null +++ b/scripts/src/aim/external.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +import os +import shutil +import signal +import subprocess +from pathlib import Path +from typing import Sequence + +from aim.exceptions import ExternalCommandFailed, OperationCancelled + +_presenter = ContextVar('aim_external_presenter', default=None) + + +@contextmanager +def external_presentation(presenter): + """Install presentation per application context, not in business managers.""" + token = _presenter.set(presenter) + try: + yield + finally: + _presenter.reset(token) + + +def stable_cwd(preferred: Path | None = None) -> Path: + candidates = [preferred, Path('/etc/ansible'), Path.home(), Path('/')] + for candidate in candidates: + if candidate is None: + continue + try: + if candidate.is_dir(): + return candidate + except OSError: + pass + return Path('/') + + +def command_available(command: str) -> bool: + return shutil.which(command) is not None + + +def stop_process(process: subprocess.Popen, *, group: bool = False) -> None: + """Reap a cancelled command; do not claim to undo work on remote systems.""" + if process.poll() is not None: + return + try: + if group and os.name == 'posix': + os.killpg(process.pid, signal.SIGTERM) + else: + process.terminate() + process.wait(timeout=3) + except subprocess.TimeoutExpired: + if group and os.name == 'posix': + os.killpg(process.pid, signal.SIGKILL) + else: + process.kill() + process.wait() + except ProcessLookupError: + process.wait() + + +def execute(args, kwargs): + """Default terminal behavior, including native password/editor interaction.""" + process = subprocess.Popen(list(args), **kwargs) + try: + stdout, stderr = process.communicate() + return subprocess.CompletedProcess(list(args), process.returncode, stdout, stderr) + finally: + # Runs on KeyboardInterrupt, SIGTERM/SystemExit, or a regular exception. + stop_process(process) + + +def run_external(args: Sequence[str], *, cwd: Path | None = None, + capture_output: bool = False, suppress_stdout: bool = False, + capture_stdout: bool = False, capture_stderr: bool = False, + text: bool = True, env: dict[str, str] | None = None, + ui_mode: str = 'auto') -> subprocess.CompletedProcess: + if (capture_output or capture_stdout) and suppress_stdout: + raise ValueError('capture_output and suppress_stdout are mutually exclusive') + if not args: + raise ValueError('An external command is required') + kwargs = {'cwd': stable_cwd(cwd), 'text': text} + if env is not None: + kwargs['env'] = env + if capture_output: + kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE) + elif capture_stdout: + kwargs['stdout'] = subprocess.PIPE + elif suppress_stdout: + kwargs['stdout'] = subprocess.DEVNULL + if capture_stderr: + kwargs['stderr'] = subprocess.PIPE + try: + presenter = _presenter.get() + if presenter: + return presenter(list(args), kwargs, capture_output=capture_output or capture_stdout or capture_stderr, + suppress_stdout=suppress_stdout, ui_mode=ui_mode) + return execute(args, kwargs) + except KeyboardInterrupt: + raise OperationCancelled('External command interrupted. Completed remote work is not rolled back.') from None + except FileNotFoundError as exc: + raise ExternalCommandFailed(f'Required command not found: {args[0]}') from exc + except OSError as exc: + raise ExternalCommandFailed(f'Could not execute {args[0]}: {exc}') from exc diff --git a/scripts/src/aim/inventory/__init__.py b/scripts/src/aim/inventory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/src/aim/inventory/hosts.py b/scripts/src/aim/inventory/hosts.py new file mode 100644 index 0000000..698e279 --- /dev/null +++ b/scripts/src/aim/inventory/hosts.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from collections import defaultdict + +from ruamel.yaml.comments import CommentedMap + +from aim.exceptions import HostNotFound, InventoryConflict +from aim.inventory.model import ChangeSet, Host + + +class InventoryEditor: + def __init__(self, document, customer: str): + self.doc = document + self.customer = customer + + def _customer_groups(self): + all_node = self.doc.data.setdefault("all", CommentedMap()) + if not isinstance(all_node, dict): + raise InventoryConflict("'all' must be a YAML mapping") + children = all_node.setdefault("children", CommentedMap()) + if not isinstance(children, dict): + raise InventoryConflict("all.children must be a YAML mapping") + customer = children.setdefault(self.customer, CommentedMap()) + if not isinstance(customer, dict): + raise InventoryConflict(f"Customer node {self.customer!r} must be a YAML mapping") + groups = customer.setdefault("children", CommentedMap()) + if not isinstance(groups, dict): + raise InventoryConflict(f"Customer {self.customer!r} children must be a YAML mapping") + return groups + + def _customer_node(self): + all_node = self.doc.data.get("all") + if not isinstance(all_node, dict): + return None + children = all_node.get("children") + if not isinstance(children, dict): + return None + node = children.get(self.customer) + return node if isinstance(node, dict) else None + + def _walk_host_occurrences(self, node=None, path=()): + node = self.doc.data if node is None else node + if not isinstance(node, dict): + return + hosts = node.get("hosts") + if isinstance(hosts, dict): + for fqdn, vars_ in list(hosts.items()): + yield str(fqdn), vars_, path, hosts + children = node.get("children") + if isinstance(children, dict): + for name, child in list(children.items()): + yield from self._walk_host_occurrences(child, path + (str(name),)) + + def _walk_customer_occurrences(self): + node = self._customer_node() + if node is not None: + yield from self._walk_host_occurrences(node, ()) + + def find_customer_host(self, fqdn: str): + return [item for item in self._walk_customer_occurrences() if item[0] == fqdn] + + def list_hosts(self) -> list[Host]: + grouped: dict[str, list] = defaultdict(list) + for occurrence in self._walk_customer_occurrences(): + grouped[occurrence[0]].append(occurrence) + + result: list[Host] = [] + for fqdn in sorted(grouped): + occurrences = grouped[fqdn] + ips = { + str(vars_.get("ansible_host")) + for _, vars_, _, _ in occurrences + if isinstance(vars_, dict) and vars_.get("ansible_host") is not None + } + ip = next(iter(ips)) if len(ips) == 1 else ("CONFLICT" if ips else "") + host = Host(fqdn=fqdn, ansible_host=ip) + for _, _, path, _ in occurrences: + if not path: + continue + group = path[0] + if len(path) >= 2: + host.add_subgroup(group, "/".join(path[1:])) + else: + host.add_group(group) + result.append(host) + return result + + @staticmethod + def _desired_labels(desired: Host) -> set[tuple[str, str | None]]: + labels: set[tuple[str, str | None]] = set() + for group, membership in desired.memberships.items(): + if membership.subgroups: + labels.update((group, subgroup) for subgroup in membership.subgroups) + else: + labels.add((group, None)) + return labels + + @staticmethod + def _prune_empty_managed_node(node: dict) -> None: + """Remove only empty structural keys; preserve vars/custom keys/comments.""" + hosts = node.get("hosts") + if isinstance(hosts, dict) and not hosts: + del node["hosts"] + children = node.get("children") + if isinstance(children, dict): + for key in list(children): + child = children[key] + if isinstance(child, dict): + InventoryEditor._prune_empty_managed_node(child) + if not children: + del node["children"] + + def _prune_customer(self) -> None: + node = self._customer_node() + if isinstance(node, dict): + self._prune_empty_managed_node(node) + # Keep an empty children mapping for AIM's canonical customer shape. + node.setdefault("children", CommentedMap()) + + def upsert_host(self, desired: Host) -> ChangeSet: + if not desired.fqdn or not desired.ansible_host: + raise ValueError("Host FQDN and IP are required") + if not desired.memberships: + raise ValueError("At least one group membership is required") + + changes = ChangeSet() + occurrences = self.find_customer_host(desired.fqdn) + ips = { + str(vars_.get("ansible_host")) + for _, vars_, _, _ in occurrences + if isinstance(vars_, dict) and vars_.get("ansible_host") is not None + } + if len(ips) > 1: + raise InventoryConflict( + f"{desired.fqdn} has conflicting ansible_host values in customer {self.customer}: {sorted(ips)}" + ) + if ips and desired.ansible_host not in ips: + changes.changed.append(f"ansible_host: {next(iter(ips))} -> {desired.ansible_host}") + + desired_labels = self._desired_labels(desired) + + # Reconcile canonical AIM memberships. Deeper or direct-customer host + # occurrences are valid arbitrary Ansible structures and are preserved. + for _, vars_, path, hosts in occurrences: + if not path: + changes.warnings.append( + f"{desired.fqdn} also exists directly below the customer node; AIM preserved that occurrence" + ) + if isinstance(vars_, dict) and vars_.get("ansible_host") != desired.ansible_host: + vars_["ansible_host"] = desired.ansible_host + continue + if len(path) > 2: + changes.warnings.append( + f"{desired.fqdn} also exists in custom nested path {'/'.join(path)}; AIM preserved that membership" + ) + if isinstance(vars_, dict) and vars_.get("ansible_host") != desired.ansible_host: + vars_["ansible_host"] = desired.ansible_host + continue + + label = (path[0], path[1] if len(path) == 2 else None) + if label not in desired_labels: + del hosts[desired.fqdn] + changes.removed.append("/".join(item for item in label if item)) + elif isinstance(vars_, dict) and vars_.get("ansible_host") != desired.ansible_host: + vars_["ansible_host"] = desired.ansible_host + + groups = self._customer_groups() + for group, membership in desired.memberships.items(): + group_node = groups.setdefault(group, CommentedMap()) + if not isinstance(group_node, dict): + raise InventoryConflict(f"Group {group!r} is not a YAML mapping and cannot be managed safely") + + if membership.subgroups: + child_nodes = group_node.setdefault("children", CommentedMap()) + if not isinstance(child_nodes, dict): + raise InventoryConflict(f"Group {group!r} has a non-mapping children value") + for subgroup in sorted(membership.subgroups): + if "/" in subgroup: + raise ValueError("Subgroup names entered in AIM must be one level deep") + subgroup_node = child_nodes.setdefault(subgroup, CommentedMap()) + if not isinstance(subgroup_node, dict): + raise InventoryConflict(f"Subgroup {group}/{subgroup} is not a YAML mapping") + hosts = subgroup_node.setdefault("hosts", CommentedMap()) + if not isinstance(hosts, dict): + raise InventoryConflict(f"Subgroup {group}/{subgroup} has a non-mapping hosts value") + current = hosts.get(desired.fqdn) + if current is None: + hosts[desired.fqdn] = CommentedMap(ansible_host=desired.ansible_host) + changes.added.append(f"{group}/{subgroup}") + elif not isinstance(current, dict): + raise InventoryConflict(f"Host {desired.fqdn} in {group}/{subgroup} is not a variable mapping") + elif current.get("ansible_host") != desired.ansible_host: + current["ansible_host"] = desired.ansible_host + else: + hosts = group_node.setdefault("hosts", CommentedMap()) + if not isinstance(hosts, dict): + raise InventoryConflict(f"Group {group!r} has a non-mapping hosts value") + current = hosts.get(desired.fqdn) + if current is None: + hosts[desired.fqdn] = CommentedMap(ansible_host=desired.ansible_host) + changes.added.append(group) + elif not isinstance(current, dict): + raise InventoryConflict(f"Host {desired.fqdn} in {group} is not a variable mapping") + elif current.get("ansible_host") != desired.ansible_host: + current["ansible_host"] = desired.ansible_host + + self._prune_customer() + return changes + + def remove_host(self, fqdn: str) -> ChangeSet: + occurrences = self.find_customer_host(fqdn) + if not occurrences: + raise HostNotFound(fqdn) + changes = ChangeSet() + for _, _, path, hosts in occurrences: + del hosts[fqdn] + changes.removed.append("/".join(path) or "customer/root") + self._prune_customer() + return changes diff --git a/scripts/src/aim/inventory/loader.py b/scripts/src/aim/inventory/loader.py new file mode 100644 index 0000000..f3e1292 --- /dev/null +++ b/scripts/src/aim/inventory/loader.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from pathlib import Path + +from ruamel.yaml import YAML +from ruamel.yaml.comments import CommentedMap + +from aim.exceptions import InvalidInventory + + +class InventoryDocument: + def __init__(self, path: Path): + self.path = path + self.yaml = YAML() + self.yaml.preserve_quotes = True + self.yaml.indent(mapping=2, sequence=4, offset=2) + try: + self.original_bytes = path.read_bytes() + with path.open(encoding="utf-8") as stream: + self.data = self.yaml.load(stream) + except Exception as exc: + raise InvalidInventory(f"Cannot parse {path}: {exc}") from exc + if self.data is None: + self.data = CommentedMap() + if not isinstance(self.data, dict): + raise InvalidInventory("Inventory root must be a YAML mapping") + + def custom_structure_notes(self, customer: str) -> list[str]: + notes: list[str] = [] + root_extra = [str(key) for key in self.data if key != "all"] + if root_extra: + notes.append("Additional top-level inventory keys are present: " + ", ".join(root_extra)) + + all_node = self.data.get("all") + if not isinstance(all_node, dict): + notes.append("The 'all' inventory node is missing or not a mapping.") + return notes + all_children = all_node.get("children") + if not isinstance(all_children, dict): + notes.append("all.children is missing or not a mapping.") + return notes + customer_node = all_children.get(customer) + if not isinstance(customer_node, dict): + notes.append(f"Customer node {customer!r} is missing or not a mapping.") + return notes + + custom_customer_keys = [str(key) for key in customer_node if key not in {"children", "hosts", "vars"}] + if custom_customer_keys: + notes.append("Customer inventory contains custom keys: " + ", ".join(custom_customer_keys)) + if customer_node.get("hosts"): + notes.append("Customer inventory contains hosts directly below the customer node.") + if customer_node.get("vars"): + notes.append("Customer inventory contains customer-level vars.") + + groups = customer_node.get("children") + if isinstance(groups, dict): + for group, group_node in groups.items(): + if not isinstance(group_node, dict): + notes.append(f"Group {group!r} is not a mapping and will not be modified safely.") + continue + children = group_node.get("children") + if isinstance(children, dict): + for subgroup, subgroup_node in children.items(): + if isinstance(subgroup_node, dict) and isinstance(subgroup_node.get("children"), dict): + notes.append(f"Custom nested structure exists below {group}/{subgroup}; AIM will preserve it.") + return notes diff --git a/scripts/src/aim/inventory/model.py b/scripts/src/aim/inventory/model.py new file mode 100644 index 0000000..851dafb --- /dev/null +++ b/scripts/src/aim/inventory/model.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass, field + + +@dataclass +class GroupMembership: + group: str + subgroups: set[str] = field(default_factory=set) + + +@dataclass +class Host: + fqdn: str + ansible_host: str + memberships: dict[str, GroupMembership] = field(default_factory=dict) + + def add_group(self, group: str) -> None: + self.memberships.setdefault(group, GroupMembership(group)) + + def add_subgroup(self, group: str, subgroup: str) -> None: + self.add_group(group) + self.memberships[group].subgroups.add(subgroup) + + def membership_labels(self) -> list[str]: + labels: list[str] = [] + for group in sorted(self.memberships): + subs = self.memberships[group].subgroups + if subs: + labels.extend(f"{group}/{sub}" for sub in sorted(subs)) + else: + labels.append(group) + return labels + + +@dataclass +class ChangeSet: + added: list[str] = field(default_factory=list) + changed: list[str] = field(default_factory=list) + removed: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + @property + def changed_anything(self) -> bool: + return bool(self.added or self.changed or self.removed) diff --git a/scripts/src/aim/inventory/validator.py b/scripts/src/aim/inventory/validator.py new file mode 100644 index 0000000..68b87cd --- /dev/null +++ b/scripts/src/aim/inventory/validator.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from ruamel.yaml import YAML + +from aim.exceptions import ValidationFailed +from aim.external import command_available, run_external + + +@dataclass(frozen=True) +class ValidationResult: + yaml_ok: bool = True + ansible_ok: bool = False + vault_used: bool = False + + +def _vault_path_for_inventory(path: Path) -> Path: + return path.parent / "group_vars" / "all" / "vault.yml" + + +def validate_yaml(path: Path) -> ValidationResult: + """Validate YAML locally without invoking Ansible or Vault prompts.""" + try: + with path.open(encoding="utf-8") as stream: + data = YAML(typ="safe").load(stream) + if data is not None and not isinstance(data, dict): + raise ValueError("inventory root must be a YAML mapping") + except Exception as exc: + raise ValidationFailed(f"YAML validation failed: {exc}") from exc + return ValidationResult(yaml_ok=True, ansible_ok=False, vault_used=False) + + +def validate(path: Path, *, customer: str | None = None) -> ValidationResult: + """Run full YAML + ansible-inventory validation, including Vault when present.""" + validate_yaml(path) + + if not command_available("ansible-inventory"): + raise ValidationFailed("Required command not found: ansible-inventory") + + customer = customer or path.parent.name + args = ["ansible-inventory", "-i", str(path), "--list"] + vault = _vault_path_for_inventory(path) + vault_used = vault.is_file() + if vault_used: + args.extend(["--vault-id", f"{customer}@prompt"]) + + result = run_external(args, cwd=path.parent, suppress_stdout=True, capture_stderr=True) + if result.returncode: + import re + detail = result.stderr or "" + location = re.search(r"Origin:\s*([^\r\n]+)", detail) + hint = f" Location: {location.group(1)}" if location else "" + raise ValidationFailed("ansible-inventory validation failed." + hint + + " Check YAML syntax, variable references and Vault access. Raw diagnostics were not displayed because they can contain secrets.") + return ValidationResult(yaml_ok=True, ansible_ok=True, vault_used=vault_used) diff --git a/scripts/src/aim/inventory/writer.py b/scripts/src/aim/inventory/writer.py new file mode 100644 index 0000000..f9f0b06 --- /dev/null +++ b/scripts/src/aim/inventory/writer.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import os +import stat +import tempfile +from pathlib import Path + +from aim.exceptions import InventoryConflict +from aim.inventory.validator import validate_yaml +from aim.locking import InventoryLock + + +class InventoryWriter: + def __init__(self, backups): + self.backups = backups + + def commit(self, document, *, customer: str | None = None) -> bool: + path = document.path + path.parent.mkdir(parents=True, exist_ok=True) + customer = customer or path.parent.name + + with InventoryLock(path): + if path.exists() and path.read_bytes() != document.original_bytes: + raise InventoryConflict( + f"{path.name} changed after AIM loaded it. Reload and repeat the operation; nothing was overwritten." + ) + + fd, temp_name = tempfile.mkstemp(prefix=".aim-", suffix=".yml", dir=path.parent) + os.close(fd) + temp = Path(temp_name) + try: + with temp.open("w", encoding="utf-8") as stream: + document.yaml.dump(document.data, stream) + + if path.exists(): + temp.chmod(stat.S_IMODE(path.stat().st_mode)) + + validate_yaml(temp) + candidate = temp.read_bytes() + if path.exists() and path.read_bytes() == candidate: + return False + + if path.exists() and path.read_bytes() != document.original_bytes: + raise InventoryConflict( + f"{path.name} changed while AIM was validating the candidate. Nothing was overwritten." + ) + + self.backups.backup_once(path) + os.replace(temp, path) + document.original_bytes = path.read_bytes() + return True + finally: + temp.unlink(missing_ok=True) diff --git a/scripts/src/aim/locking.py b/scripts/src/aim/locking.py new file mode 100644 index 0000000..33b4e16 --- /dev/null +++ b/scripts/src/aim/locking.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import fcntl +import os +from pathlib import Path + +from aim.exceptions import InventoryLocked + + +class InventoryLock: + """Non-blocking advisory lock for one customer inventory. + + The lock file is removed after the protected operation when no other AIM + process has acquired it. A process that acquires the lock in the meantime + owns cleanup of the file when its operation completes. + """ + + def __init__(self, inventory: Path): + self.path = inventory.parent / ".aim.lock" + self._stream = None + + def __enter__(self): + self.path.parent.mkdir(parents=True, exist_ok=True) + self._stream = self.path.open("a+", encoding="utf-8") + try: + fcntl.flock(self._stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + self._stream.close() + self._stream = None + raise InventoryLocked( + f"Inventory is currently being modified by another AIM process: {self.path.parent.name}" + ) from exc + return self + + def __exit__(self, exc_type, exc, tb): + if self._stream is not None: + fcntl.flock(self._stream.fileno(), fcntl.LOCK_UN) + self._stream.close() + self._stream = None + self._cleanup_unused_lock_file() + + def _cleanup_unused_lock_file(self) -> None: + """Remove an idle lock file without interfering with another AIM process.""" + try: + stream = self.path.open("a+", encoding="utf-8") + except FileNotFoundError: + return + try: + try: + fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return + + try: + fd_stat = os.fstat(stream.fileno()) + path_stat = self.path.stat() + except FileNotFoundError: + return + if (fd_stat.st_dev, fd_stat.st_ino) == (path_stat.st_dev, path_stat.st_ino): + self.path.unlink(missing_ok=True) + finally: + try: + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) + except OSError: + pass + stream.close() diff --git a/scripts/src/aim/permissions.py b/scripts/src/aim/permissions.py new file mode 100644 index 0000000..a661f80 --- /dev/null +++ b/scripts/src/aim/permissions.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import grp +import os +from pathlib import Path + +from aim.exceptions import ConfigurationError + + +def configured_gid(group_name: str) -> int: + try: + return grp.getgrnam(group_name).gr_gid + except KeyError as exc: + raise ConfigurationError(f"Configured group cannot be resolved: {group_name}") from exc + + +def apply_managed_permissions(path: Path, group_name: str, *, directory: bool = False, secret: bool = False) -> None: + """Apply AIM's group ownership when permitted by the OS. + + Files are group-readable/writable unless secret=True. Directories are setgid + so newly created content keeps the configured admin group. + """ + gid = configured_gid(group_name) + try: + os.chown(path, -1, gid) + except PermissionError: + # A member of the group may be able to create content but not chown on + # every filesystem/NSS setup. The caller's inherited group remains valid. + pass + if directory: + path.chmod(0o2770) + elif secret: + path.chmod(0o660) + else: + path.chmod(0o660) diff --git a/scripts/src/aim/playbooks/__init__.py b/scripts/src/aim/playbooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/src/aim/playbooks/manager.py b/scripts/src/aim/playbooks/manager.py new file mode 100644 index 0000000..d494c27 --- /dev/null +++ b/scripts/src/aim/playbooks/manager.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from aim.exceptions import ExternalCommandFailed +from aim.external import command_available, run_external + + +@dataclass(frozen=True) +class PlaybookSpec: + key: str + name: str + filename: str + category: str + platforms: tuple[str, ...] + extra_vars: tuple[str, ...] = () + customer_specific: bool = False + ask_pass: bool = False + require_vault: bool = False + + +PLAYBOOKS: tuple[PlaybookSpec, ...] = ( + PlaybookSpec("checkmk_cleanup", "Cleanup CheckMK", "checkmk_cleanup.yml", "CheckMK", ("linux", "windows"), ("checkmk_cleanup_enabled=true",)), + PlaybookSpec("checkmk_install_agent", "Install CheckMK Agent", "checkmk_install_agent.yml", "CheckMK", ("linux", "windows")), + PlaybookSpec("checkmk_update_config", "Update CheckMK Config", "checkmk_update_config.yml", "CheckMK", ("linux", "windows")), + PlaybookSpec("debug_ping", "Ping", "debug_ping.yml", "Debug", ("linux", "windows")), + PlaybookSpec("debug_server_role_selection", "Server Role Selection", "debug_server_role_selection.yml", "Debug", ("linux", "windows")), + PlaybookSpec("debug_disk_usage", "Disk Usage", "debug_disk_usage.yml", "Debug", ("windows",)), + PlaybookSpec("patch_os", "Patch OS", "patch_os.yml", "Maintenance", ("linux", "windows")), + PlaybookSpec("reboot_system", "Reboot System", "reboot_system.yml", "Maintenance", ("linux", "windows")), + PlaybookSpec("backup_eventlog", "Backup Event Log", "backup_eventlog.yml", "Maintenance", ("windows",)), + PlaybookSpec("start_stopped_services", "Start Stopped Services", "start_stopped_services.yml", "Maintenance", ("windows",)), + PlaybookSpec("configure_sophos_initial", "Initial Bitformer Config", "configure_sophos_initial_bitformer_config.yml", "Sophos XGS", ("sophosxgs",), ask_pass=True, require_vault=True), + PlaybookSpec("configure_sophosxgs", "Configure Sophos XGS", "configure_sophosxgs.yml", "Sophos XGS", ("sophosxgs",), customer_specific=True, ask_pass=True, require_vault=True), +) + +CATEGORY_ORDER = ("CheckMK", "Debug", "Maintenance", "Sophos XGS") + + +class PlaybookManager: + def __init__(self, customers, config): + self.customers = customers + self.config = config + + @property + def root(self) -> Path: + return self.config.root_dir / "playbooks" + + def categories(self) -> list[str]: + return list(CATEGORY_ORDER) + + def specs_for_category(self, category: str) -> list[PlaybookSpec]: + return [spec for spec in PLAYBOOKS if spec.category == category] + + def path(self, spec: PlaybookSpec, customer: str | None = None) -> Path: + if spec.customer_specific: + if not customer: + raise ValueError(f"Customer is required for playbook {spec.name}") + return self.root / "customers" / customer / spec.filename + return self.root / spec.filename + + def available(self, spec: PlaybookSpec, customer: str | None = None) -> bool: + return self.path(spec, customer).is_file() + + def run(self, customer: str, inventory: Path, spec: PlaybookSpec, *, limit: str | None = None) -> None: + playbook = self.path(spec, customer) + if not playbook.is_file(): + raise FileNotFoundError(playbook) + if not inventory.is_file(): + raise FileNotFoundError(inventory) + if not command_available("ansible-playbook"): + raise ExternalCommandFailed("Required command not found: ansible-playbook") + + args = ["ansible-playbook", "-i", str(inventory), str(playbook)] + vault = inventory.parent / "group_vars" / "all" / "vault.yml" + if spec.require_vault and not vault.is_file(): + raise FileNotFoundError(f"Required customer vault not found: {vault}") + if vault.is_file(): + args.extend(["--vault-id", f"{customer}@prompt"]) + if spec.ask_pass: + args.append("--ask-pass") + if limit: + args.extend(["--limit", limit]) + for extra_var in spec.extra_vars: + args.extend(["-e", extra_var]) + + result = run_external(args, cwd=self.config.root_dir) + if result.returncode: + raise ExternalCommandFailed(f"Playbook failed with exit code {result.returncode}") diff --git a/scripts/src/aim/sophos/__init__.py b/scripts/src/aim/sophos/__init__.py new file mode 100644 index 0000000..b2c8eed --- /dev/null +++ b/scripts/src/aim/sophos/__init__.py @@ -0,0 +1 @@ +"""Sophos XGS host configuration helpers.""" diff --git a/scripts/src/aim/sophos/config.py b/scripts/src/aim/sophos/config.py new file mode 100644 index 0000000..f459526 --- /dev/null +++ b/scripts/src/aim/sophos/config.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import os +import stat +import tempfile +from pathlib import Path +from typing import Any + +from ruamel.yaml import YAML +from ruamel.yaml.comments import CommentedMap + +from aim.permissions import apply_managed_permissions + + +class SophosConfigManager: + """Merge AIM-managed Sophos variables into a host_vars YAML file. + + Unknown/manual YAML and comments are preserved. AIM only creates/updates the + hostname, network_objects and vlan_interfaces entries explicitly supplied by + the operator. + """ + + def __init__(self, customers): + self.customers = customers + + def path(self, customer: str, fqdn: str) -> Path: + return self.customers.ensure_host_vars(customer, fqdn) + + @staticmethod + def default_network_object_name(customer: str, key: str) -> str: + """Build the conventional Sophos object name: net_kunde_objectkey.""" + def normalize(value: str) -> str: + return "_".join(part for part in __import__("re").split(r"[^A-Za-z0-9]+", value) if part).lower() + + customer_part = normalize(customer) + key_part = normalize(key) + return f"net_{customer_part}_{key_part}" + + @staticmethod + def _yaml() -> YAML: + yaml = YAML() + yaml.preserve_quotes = True + yaml.indent(mapping=2, sequence=4, offset=2) + return yaml + + def load(self, customer: str, fqdn: str) -> CommentedMap: + path = self.path(customer, fqdn) + yaml = self._yaml() + if not path.exists() or path.stat().st_size == 0: + return CommentedMap() + with path.open("r", encoding="utf-8") as stream: + data = yaml.load(stream) + if data is None: + return CommentedMap() + if not isinstance(data, dict): + raise ValueError(f"{path} must contain a YAML mapping") + return data + + def existing_keys(self, customer: str, fqdn: str) -> tuple[set[str], set[str]]: + data = self.load(customer, fqdn) + network = data.get("network_objects") or {} + vlans = data.get("vlan_interfaces") or {} + return set(network) if isinstance(network, dict) else set(), set(vlans) if isinstance(vlans, dict) else set() + + def apply( + self, + customer: str, + fqdn: str, + *, + hostname: str, + network_objects: dict[str, dict[str, Any]], + vlan_interfaces: dict[str, dict[str, Any]], + ) -> Path: + path = self.path(customer, fqdn) + yaml = self._yaml() + original_text = path.read_text(encoding="utf-8") if path.exists() else "" + parsed_original = yaml.load(original_text) if original_text.strip() else None + data = self.load(customer, fqdn) + + data["hostname"] = hostname + + if network_objects: + existing = data.get("network_objects") + if existing is None: + existing = CommentedMap() + data["network_objects"] = existing + if not isinstance(existing, dict): + raise ValueError("network_objects exists but is not a YAML mapping; edit it manually before using AIM") + for key, value in network_objects.items(): + existing[key] = CommentedMap(value) + + if vlan_interfaces: + existing = data.get("vlan_interfaces") + if existing is None: + existing = CommentedMap() + data["vlan_interfaces"] = existing + if not isinstance(existing, dict): + raise ValueError("vlan_interfaces exists but is not a YAML mapping; edit it manually before using AIM") + for key, value in vlan_interfaces.items(): + existing[key] = CommentedMap(value) + + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=".aim-sophos-", suffix=".yml", dir=path.parent) + os.close(fd) + tmp = Path(tmp_name) + try: + with tmp.open("w", encoding="utf-8") as stream: + if parsed_original is None and original_text.strip(): + stream.write(original_text.rstrip() + "\n\n# AIM-generated Sophos XGS configuration\n") + yaml.dump(data, stream) + # Parse the emitted candidate before replacing the live file. + with tmp.open("r", encoding="utf-8") as stream: + candidate = yaml.load(stream) + if candidate is not None and not isinstance(candidate, dict): + raise ValueError("Generated Sophos host_vars are not a YAML mapping") + + if path.exists(): + tmp.chmod(stat.S_IMODE(path.stat().st_mode)) + if tmp.read_bytes() == path.read_bytes(): + return path + else: + apply_managed_permissions(tmp, self.customers.config.required_group) + + os.replace(tmp, path) + apply_managed_permissions(path, self.customers.config.required_group) + return path + finally: + tmp.unlink(missing_ok=True) diff --git a/scripts/src/aim/ssh/__init__.py b/scripts/src/aim/ssh/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/src/aim/ssh/keys.py b/scripts/src/aim/ssh/keys.py new file mode 100644 index 0000000..b2ee521 --- /dev/null +++ b/scripts/src/aim/ssh/keys.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +import atexit +import base64 +import os +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from aim.exceptions import ExternalCommandFailed +from aim.external import command_available, run_external + + +@dataclass(frozen=True) +class SSHKeyStatus: + private_key: Path + public_key: Path + complete: bool + partial: bool + + +class SSHManager: + def __init__(self, customers, config, vault=None): + self.customers = customers + self.config = config + self.vault = vault + self._agent_started = False + + def key_path(self, customer: str) -> Path: + return self.customers.require(customer) / f"group_vars/linux/.ssh/{self.config.service_user}" + + def status(self, customer: str) -> SSHKeyStatus: + private = self.key_path(customer) + public = Path(str(private) + ".pub") + return SSHKeyStatus(private, public, private.exists() and public.exists(), private.exists() != public.exists()) + + def ensure_key(self, customer: str) -> bool: + status = self.status(customer) + if status.complete: + return False + if status.partial: + raise ExternalCommandFailed( + f"Incomplete SSH keypair at {status.private_key}; resolve it before AIM generates a new key." + ) + if not command_available("ssh-keygen"): + raise ExternalCommandFailed("Required command not found: ssh-keygen") + status.private_key.parent.mkdir(parents=True, exist_ok=True) + result = run_external( + ["ssh-keygen", "-t", "ed25519", "-f", str(status.private_key), "-C", f"{self.config.service_user}@{customer}"], + cwd=self.customers.require(customer), + ) + if result.returncode: + raise ExternalCommandFailed("ssh-keygen failed") + status.private_key.chmod(0o600) + status.public_key.chmod(0o644) + return True + + def fingerprint(self, customer: str) -> str: + status = self.status(customer) + if not status.public_key.exists(): + raise FileNotFoundError(status.public_key) + result = run_external(["ssh-keygen", "-lf", str(status.public_key)], cwd=self.customers.require(customer), capture_output=True) + if result.returncode: + raise ExternalCommandFailed(result.stderr.strip() or "Could not read SSH key fingerprint") + return result.stdout.strip() + + def create_service_user(self, ip: str, bootstrap_user: str) -> None: + if not bootstrap_user: + raise ValueError("Bootstrap user is required") + target = self.config.service_user + remote = f'''set -e +TARGET_USER={target!r} +if ! id "$TARGET_USER" >/dev/null 2>&1; then + sudo useradd -m -s /bin/bash "$TARGET_USER" +fi +sudo mkdir -p "/home/$TARGET_USER/.ssh" +sudo chown "$TARGET_USER:$TARGET_USER" "/home/$TARGET_USER/.ssh" +sudo chmod 700 "/home/$TARGET_USER/.ssh" +if command -v usermod >/dev/null 2>&1; then + sudo usermod -aG sudo "$TARGET_USER" 2>/dev/null || true + sudo usermod -aG wheel "$TARGET_USER" 2>/dev/null || true +fi +echo "$TARGET_USER ALL=(ALL) NOPASSWD:ALL" | sudo tee "/etc/sudoers.d/$TARGET_USER" >/dev/null +sudo chmod 0440 "/etc/sudoers.d/$TARGET_USER" +''' + result = run_external(["ssh", "-t", f"{bootstrap_user}@{ip}", remote], cwd=self.config.root_dir) + if result.returncode: + raise ExternalCommandFailed("Remote service-user creation failed") + + def install_key(self, customer: str, ip: str, bootstrap_user: str) -> None: + if not bootstrap_user: + raise ValueError("Bootstrap user is required") + self.ensure_key(customer) + status = self.status(customer) + pub = status.public_key.read_text(encoding="utf-8").strip() + encoded = base64.b64encode(pub.encode("utf-8")).decode("ascii") + target = self.config.service_user + remote = f'''set -e +TARGET_USER={target!r} +PUBKEY=$(printf %s {encoded!r} | base64 -d) +if ! id "$TARGET_USER" >/dev/null 2>&1; then + echo "Target user $TARGET_USER does not exist." >&2 + exit 1 +fi +sudo mkdir -p "/home/$TARGET_USER/.ssh" +sudo touch "/home/$TARGET_USER/.ssh/authorized_keys" +if ! sudo grep -qxF "$PUBKEY" "/home/$TARGET_USER/.ssh/authorized_keys"; then + printf '%s\\n' "$PUBKEY" | sudo tee -a "/home/$TARGET_USER/.ssh/authorized_keys" >/dev/null +fi +sudo chown -R "$TARGET_USER:$TARGET_USER" "/home/$TARGET_USER/.ssh" +sudo chmod 700 "/home/$TARGET_USER/.ssh" +sudo chmod 600 "/home/$TARGET_USER/.ssh/authorized_keys" + +# Ensure public-key authentication is effectively enabled without changing unrelated SSH settings. +SSHD=$(command -v sshd || true) +if [ -n "$SSHD" ]; then + if ! sudo "$SSHD" -T 2>/dev/null | grep -qi '^pubkeyauthentication yes$'; then + if [ -d /etc/ssh/sshd_config.d ]; then + printf '%s\\n' 'PubkeyAuthentication yes' | sudo tee /etc/ssh/sshd_config.d/90-aim.conf >/dev/null + else + if sudo grep -qiE '^\\s*PubkeyAuthentication\\s+' /etc/ssh/sshd_config; then + sudo sed -i -E 's/^\\s*#?\\s*PubkeyAuthentication\\s+.*/PubkeyAuthentication yes/I' /etc/ssh/sshd_config + else + printf '%s\\n' 'PubkeyAuthentication yes' | sudo tee -a /etc/ssh/sshd_config >/dev/null + fi + fi + sudo "$SSHD" -t + sudo systemctl reload sshd 2>/dev/null || sudo systemctl reload ssh 2>/dev/null || sudo service sshd reload 2>/dev/null || sudo service ssh reload + fi +fi +''' + result = run_external(["ssh", "-t", f"{bootstrap_user}@{ip}", remote], cwd=self.config.root_dir) + if result.returncode: + raise ExternalCommandFailed("SSH key installation / PubkeyAuthentication preparation failed") + + def _ensure_agent(self) -> None: + if os.environ.get("SSH_AUTH_SOCK"): + return + if not command_available("ssh-agent"): + raise ExternalCommandFailed("Required command not found: ssh-agent") + result = run_external(["ssh-agent", "-s"], capture_output=True) + if result.returncode: + raise ExternalCommandFailed(result.stderr.strip() or "Could not start ssh-agent") + for chunk in result.stdout.split(";"): + chunk = chunk.strip() + if chunk.startswith("SSH_AUTH_SOCK=") or chunk.startswith("SSH_AGENT_PID="): + key, value = chunk.split("=", 1) + os.environ[key] = value + if not os.environ.get("SSH_AUTH_SOCK"): + raise ExternalCommandFailed("ssh-agent did not provide SSH_AUTH_SOCK") + self._agent_started = True + atexit.register(self._shutdown_agent) + + + def _shutdown_agent(self) -> None: + if not self._agent_started: + return + pid = os.environ.get("SSH_AGENT_PID", "") + if pid.isdigit(): + try: + os.kill(int(pid), 15) + except OSError: + pass + self._agent_started = False + + def ensure_key_loaded(self, customer: str) -> bool: + """Load the customer's encrypted key into ssh-agent using its Vault passphrase.""" + key = self.key_path(customer) + if not key.exists(): + raise FileNotFoundError(key) + if not command_available("ssh-add"): + raise ExternalCommandFailed("Required command not found: ssh-add") + self._ensure_agent() + pub = Path(str(key) + ".pub") + if pub.exists(): + fingerprint = self.fingerprint(customer).split()[1] + listed = run_external(["ssh-add", "-l"], capture_output=True) + if listed.returncode == 0 and fingerprint in listed.stdout: + return False + if self.vault is None: + raise ExternalCommandFailed("Vault integration is not available for SSH key loading") + passphrase = self.vault.get_value(customer, "vault_linux_ssh_key_passphrase") + if not passphrase: + raise ExternalCommandFailed( + "Private SSH key is encrypted, but vault_linux_ssh_key_passphrase is empty or missing in the customer Vault" + ) + with tempfile.TemporaryDirectory(prefix="aim-askpass-") as tmp_name: + helper = Path(tmp_name) / "askpass.sh" + helper.write_text('#!/bin/sh\nprintf "%s\\n" "$AIM_SSH_KEY_PASSPHRASE"\n', encoding="utf-8") + helper.chmod(0o700) + env = os.environ.copy() + env.update({ + "SSH_ASKPASS": str(helper), + "SSH_ASKPASS_REQUIRE": "force", + "DISPLAY": env.get("DISPLAY", ":0"), + "AIM_SSH_KEY_PASSPHRASE": passphrase, + }) + result = run_external(["ssh-add", str(key)], cwd=self.customers.require(customer), capture_output=True, env=env) + if result.returncode: + raise ExternalCommandFailed(result.stderr.strip() or "Could not load SSH key into ssh-agent") + return True + + def test_connection(self, customer: str, ip: str) -> None: + key = self.key_path(customer) + if not key.exists(): + raise FileNotFoundError(key) + self.ensure_key_loaded(customer) + result = run_external( + ["ssh", "-i", str(key), "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", f"{self.config.service_user}@{ip}", "true"], + cwd=self.customers.require(customer), + capture_output=True, + ) + if result.returncode: + detail = (result.stderr or result.stdout or "").strip() + raise ExternalCommandFailed(detail or "SSH connection test failed") diff --git a/scripts/src/aim/structure.py b/scripts/src/aim/structure.py new file mode 100644 index 0000000..caeb068 --- /dev/null +++ b/scripts/src/aim/structure.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from io import StringIO +from pathlib import Path +import re + +from ruamel.yaml import YAML + +from aim.exceptions import ValidationFailed +from aim.permissions import apply_managed_permissions +from aim.templates import group_var_templates, VAULT_TEMPLATE_KEYS, VAULT_TEMPLATE_SECTIONS + + +@dataclass +class DiffLine: + marker: str + text: str + line: int | None = None + kind: str = "info" + + +@dataclass +class FileDiff: + path: Path + lines: list[DiffLine] = field(default_factory=list) + syntax_error: str | None = None + + @property + def has_deviations(self) -> bool: + return bool(self.syntax_error or self.lines) + + +def _key_lines(text: str) -> dict[str, int]: + result: dict[str, int] = {} + for no, line in enumerate(text.splitlines(), 1): + if not line or line.lstrip().startswith("#") or line[:1].isspace(): + continue + match = re.match(r"^([A-Za-z0-9_.-]+)\s*:", line) + if match and match.group(1) not in result: + result[match.group(1)] = no + return result + + +def compare_plain_yaml(path: Path, expected_header: str, expected: dict[str, object]) -> FileDiff: + diff = FileDiff(path) + if not path.exists(): + diff.lines.append(DiffLine("+", "file missing; AIM template would create it", None, "missing")) + return diff + text = path.read_text(encoding="utf-8") + try: + data = YAML(typ="safe").load(text) or {} + if not isinstance(data, dict): + raise ValueError("YAML root is not a mapping") + except Exception as exc: + diff.syntax_error = str(exc) + return diff + lines = _key_lines(text) + header = expected_header.strip() + if header and header not in text: + diff.lines.append(DiffLine("+", header, None, "comment")) + for key, expected_value in expected.items(): + if key not in data: + diff.lines.append(DiffLine("+", f"{key}: {expected_value!r}", None, "missing")) + continue + current = data[key] + if current != expected_value: + no = lines.get(key) + diff.lines.append(DiffLine("-", f"{key}: {expected_value!r}", no, "deviation")) + diff.lines.append(DiffLine("+", f"{key}: {current!r}", no, "deviation")) + for key, value in data.items(): + if key not in expected: + diff.lines.append(DiffLine("+", f"{key}: {value!r} # custom, retained", lines.get(key), "custom")) + return diff + + +def consolidate_plain_yaml(path: Path, expected_header: str, expected: dict[str, object], group: str) -> bool: + yaml = YAML() + yaml.preserve_quotes = True + data = {} + original = "" + if path.exists(): + original = path.read_text(encoding="utf-8") + try: + data = yaml.load(original) or {} + except Exception as exc: + raise ValidationFailed(f"Invalid YAML in {path}: {exc}") from exc + if not isinstance(data, dict): + raise ValidationFailed(f"YAML root must be a mapping: {path}") + changed = False + for key, value in expected.items(): + if key not in data: + data[key] = value + changed = True + header = expected_header.strip() + need_header = bool(header and header not in original) + if not changed and not need_header: + return False + out = StringIO() + if need_header: + out.write(expected_header.rstrip() + "\n") + yaml.dump(data, out) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(out.getvalue(), encoding="utf-8") + apply_managed_permissions(path, group) + return True + + +def customer_template_diffs(customer_root: Path, service_user: str) -> list[FileDiff]: + templates = group_var_templates(str(customer_root), service_user) + return [compare_plain_yaml(customer_root / rel, header, dict(data)) for rel, (header, data) in templates.items()] + + +def consolidate_customer_templates(customer_root: Path, service_user: str, group: str) -> tuple[list[Path], list[str]]: + changed: list[Path] = [] + skipped: list[str] = [] + for rel, (header, data) in group_var_templates(str(customer_root), service_user).items(): + path = customer_root / rel + if not path.parent.exists(): + continue + try: + if consolidate_plain_yaml(path, header, dict(data), group): + changed.append(path) + except ValidationFailed as exc: + skipped.append(str(exc)) + return changed, skipped + + +def vault_structure_from_text(text: str) -> tuple[dict[str, bool], list[str], set[str]]: + """Parse YAML correctly; expose only variable names and empty/non-empty state.""" + data = YAML().load(text) + if data is None: + data = {} + if not isinstance(data, dict): + raise ValidationFailed("Vault content must be a YAML mapping; check spaces after mapping colons") + states = {str(key): value is not None and value != "" for key, value in data.items()} + known_comments = {comment for comment, _ in VAULT_TEMPLATE_SECTIONS} + comments = {line.strip() for line in text.splitlines() if line.strip() in known_comments} + return states, list(states), comments + + +def compare_vault_structure(path: Path, decrypted_text: str) -> FileDiff: + diff = FileDiff(path) + states, order, comments = vault_structure_from_text(decrypted_text) + parsed = YAML().load(decrypted_text) + locations = {str(k): parsed.lc.key(k)[0] + 1 for k in parsed} if isinstance(parsed, dict) and hasattr(parsed, "lc") else {} + for comment, key in VAULT_TEMPLATE_SECTIONS: + if comment not in comments: + diff.lines.append(DiffLine("+", comment, None, "comment")) + if key: + if key not in states: + diff.lines.append(DiffLine("+", f'{key}: "" # missing', None, "missing")) + elif states[key]: + # Set is expected/valid; no secret is rendered. + pass + else: + diff.lines.append(DiffLine("!", f'{key}: "" # present but not set', locations.get(key), "unset")) + for key in order: + if key not in VAULT_TEMPLATE_KEYS: + status = "set" if states.get(key) else "not set" + diff.lines.append(DiffLine("+", f'{key}: "********" # custom, {status}, retained', locations.get(key), "custom")) + return diff diff --git a/scripts/src/aim/templates.py b/scripts/src/aim/templates.py new file mode 100644 index 0000000..2b8a070 --- /dev/null +++ b/scripts/src/aim/templates.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from collections import OrderedDict + + +def group_var_templates(customer_path: str, service_user: str) -> dict[str, tuple[str, OrderedDict[str, object]]]: + return { + "group_vars/linux/main.yml": ( + "# Linux / SSH variables\n", + OrderedDict([ + ("ansible_connection", "ssh"), + ("ansible_user", service_user), + ("ansible_private_key_file", f"{customer_path}/group_vars/linux/.ssh/{service_user}"), + ("ansible_ssh_pass", "{{ ansible_vault_linux_ssh_pass }}"), + ("ansible_become_method", "sudo"), + ]), + ), + "group_vars/windows/main.yml": ( + "# Windows / WinRM variables\n", + OrderedDict([ + ("ansible_connection", "winrm"), + ("ansible_port", 5986), + ("ansible_winrm_transport", "ntlm"), + ("ansible_winrm_server_cert_validation", "ignore"), + ("ansible_user", service_user), + ("ansible_password", "{{ vault_windows_ansible_password }}"), + ]), + ), + "group_vars/sophosxgs/main.yml": ( + "# SophosXGS-specific variables\n", + OrderedDict([ + ("ansible_user", "admin"), + ("ansible_connection", "ansible.netcommon.httpapi"), + ("ansible_httpapi_validate_certs", False), + ("ansible_httpapi_port", 4444), + ("ansible_network_os", "sophos.sophos_firewall.sfos"), + ]), + ), + "group_vars/pfsense/main.yml": ("# pfSense-specific variables\n", OrderedDict()), + } + + +VAULT_TEMPLATE_SECTIONS = ( + ("# Windows - domain service account", "vault_windows_ansible_password"), + ("# Windows - optional shared local service account", "vault_windows_local_ansible_password"), + ("# Windows - per-host local account overrides", None), + ('# Example: vault_ansible_password_server01_example_lan: ""', None), + ("# Linux - legacy SSH password", "ansible_vault_linux_ssh_pass"), + ("# Linux - private-key passphrase", "vault_linux_ssh_key_passphrase"), +) + +VAULT_TEMPLATE_KEYS = tuple(key for _, key in VAULT_TEMPLATE_SECTIONS if key) diff --git a/scripts/src/aim/ui/__init__.py b/scripts/src/aim/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/src/aim/ui/access.py b/scripts/src/aim/ui/access.py new file mode 100644 index 0000000..94fe0d7 --- /dev/null +++ b/scripts/src/aim/ui/access.py @@ -0,0 +1,286 @@ +"""Operator workflows for SSH, WinRM and domain policy rollout. + +Credentials remain in the existing managers. UI reviews show identities, never +passwords, and normal connection tests use Inventory/Vault credentials. +""" +from __future__ import annotations + +from pathlib import Path +import re +from ruamel.yaml import YAML +from ruamel.yaml.error import YAMLError +from aim.exceptions import AIMError, OperationCancelled +from aim.ui.components import MenuItem +from aim.ui.selection import Choice + + +class AccessScreens: + def access_menu(self, customer: str, inventory_path: Path) -> None: + while True: + self.clear() + self.header(self.breadcrumb(customer, 'Access')) + choice = self.menu([ + MenuItem('1', 'Linux / SSH', 'Key management, user preparation and access tests'), + MenuItem('2', 'Windows / WinRM', 'Local, domain and GPO workflows'), + ]) + if choice == '0': + return + self.call(self.linux_ssh_menu if choice == '1' else self.winrm_menu, customer, inventory_path) + + def winrm_menu(self, customer: str, inventory_path: Path) -> None: + actions = { + '1': self.windows_temporary, '2': self.windows_local, '3': self.windows_domain, + '4': self.windows_grant, '5': self.windows_rollout, '6': self.windows_test, + } + while True: + self.clear() + self.header(self.breadcrumb(customer, 'Access > Windows / WinRM'), 'NTLM / HTTPS :5986. WinRM must already be prepared on bootstrap targets.') + choice = self.menu([ + MenuItem('1', 'Test temporary access', 'Existing administrator credentials'), + MenuItem('2', 'Prepare local service account', 'Standalone hosts; host-specific credentials'), + MenuItem('3', 'Prepare domain service account', 'One DC per domain; privileged AD change'), + MenuItem('4', 'Enable access on member servers', 'Manual fallback; existing domain identity'), + MenuItem('5', 'Deploy / repair WinRM GPO', 'Prepared DC; uses configured service account'), + MenuItem('6', 'Test Ansible WinRM access', 'Configured inventory + Vault; no admin login prompt'), + ]) + if choice == '0': + return + self.call(actions[choice], customer, inventory_path) + + def _windows_targets(self, customer: str, inventory_path: Path, title: str): + return self.select_targets(customer, inventory_path, ('windows',), title=title, require_address=True) + + def _domain_identity(self, customer: str): + defaults = self.customers.load_defaults(customer) + domain = self.required_value('AD DNS domain', defaults.ad_dns_domain or defaults.domain_suffix or None).lower().rstrip('.') + if not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?', domain): + raise ValueError('AD DNS domain must be a DNS name, not a UPN or DOMAIN\\user.') + return domain, f'{self.config.service_user}@{domain}' + + def _bootstrap_domain_credentials(self, customer: str, domain: str): + defaults = self.customers.load_defaults(customer) + # Respect the operator's configured down-level domain, rather than guessing + # its NetBIOS name from the first DNS label. + default_user = f'{defaults.ad_netbios_domain}\\Administrator' if defaults.ad_netbios_domain else f'Administrator@{domain}' + username = self.required_value('Temporary domain administrator (UPN or DOMAIN\\user)', default_user) + if '@' not in username and '\\' not in username: + username = f'{defaults.ad_netbios_domain}\\{username}' if defaults.ad_netbios_domain else f'{username}@{domain}' + self.ui.details([('Bootstrap identity', username)]) + password = self.ask('Temporary administrator password', password=True) + return username, password + + def _new_service_password(self): + password = self.ask(f'Password for {self.config.service_user}', password=True) + if not password: + raise ValueError('The service-account password cannot be empty.') + if password != self.ask('Confirm service-account password', password=True): + raise ValueError('Service-account passwords do not match.') + return password + + def _access_batch(self, title, targets, operation): + results = [] + for index, host in enumerate(targets, 1): + self.ui.result(f'{title} [{index}/{len(targets)}] {host.fqdn}', level='info') + try: + operation(host) + except OperationCancelled: + raise + except (AIMError, OSError, ValueError, YAMLError) as exc: + results.append((host.fqdn, 'Failed')) + self.show_error(exc) + else: + results.append((host.fqdn, 'Succeeded')) + self.ui.table(['Host', 'Result'], results) + failed = sum(state == 'Failed' for _, state in results) + self.ui.result(f'{len(results)-failed} succeeded; {failed} failed.', + level='warning' if failed else 'success', title=title) + self.pause() + + def windows_temporary(self, customer: str, inventory_path: Path) -> None: + targets, label = self._windows_targets(customer, inventory_path, 'Access > Windows > Test temporary access') + if not targets: + return + username = self.required_value('Temporary administrator (UPN, DOMAIN\\user or local user)') + password = self.ask('Temporary administrator password', password=True) + self.ui.review('Test temporary access', [('Targets', '\n'.join(h.fqdn for h in targets)), ('Identity', username), ('Transport', 'NTLM / HTTPS :5986')]) + if self.confirm('Test these hosts?', default=False): + self._access_batch('Temporary WinRM access', targets, lambda h: self.winrm.test_connection(h.fqdn, h.ansible_host, username, password)) + + def windows_local(self, customer: str, inventory_path: Path) -> None: + targets, label = self._windows_targets(customer, inventory_path, 'Access > Windows > Prepare local account') + if not targets: + return + bootstrap = self.required_value('Temporary administrator', 'Administrator') + bootstrap_password = self.ask('Temporary administrator password', password=True) + service_password = self._new_service_password() + self.ui.result('Existing host credential overrides are preserved unless you explicitly replace them below.', level='info') + update_vars = self.confirm('Set selected hosts to the shared-local Vault credential model?', default=False) + self.ui.review('Prepare local service account', [ + ('Targets', '\n'.join(h.fqdn for h in targets)), ('Bootstrap identity', bootstrap), + ('Local account', self.config.service_user), ('Group', 'Local Administrators'), + ('Host variables', 'Set shared-local credential references' if update_vars else 'Retain existing references'), + ('Vault variable', 'vault_windows_local_ansible_password'), + ], warning='Existing service-account passwords may be reset. Domain-wide group variables are not changed. Store the matching password in Vault.') + if not self.confirm('Prepare these local accounts?', default=False): + return + def prepare(host): + self.winrm.bootstrap_service_user(host.fqdn, host.ansible_host, bootstrap, bootstrap_password, service_password) + if update_vars: + self.customers.set_windows_host_credential_model(customer, host.fqdn, 'shared_local') + self._access_batch('Local account preparation', targets, prepare) + + def windows_domain(self, customer: str, inventory_path: Path) -> None: + self.clear() + self.header(self.breadcrumb(customer, 'Access > Windows > Prepare domain account')) + host = self.pick_one_host(self.inventory_hosts(customer, inventory_path, ('windows',), require_address=True), 'Select administration DC (one host)') + if not host: + return + domain, service_upn = self._domain_identity(customer) + bootstrap, bootstrap_password = self._bootstrap_domain_credentials(customer, domain) + service_password = self._new_service_password() + # Validate the local destination before making the remote AD change. + variables = self.customers.require(customer) / 'group_vars/windows/main.yml' + if variables.exists(): + with variables.open(encoding='utf-8') as stream: + data = YAML().load(stream) + if data is not None and not isinstance(data, dict): + raise ValueError(f'Windows group variables must be a YAML mapping: {variables}') + self.ui.review('Prepare domain service account', [ + ('Administration DC', host.fqdn), ('AD DNS domain', domain), ('Bootstrap identity', bootstrap), + ('Service identity', service_upn), ('AD membership', 'BUILTIN\\Administrators'), ('Inventory update', variables), + ], warning='This grants privileged access to domain controllers. An existing password may be reset. It is not merely member-server access.') + if not self.confirm('Create / repair this domain account?', default=False): + return + service_upn = self.winrm.bootstrap_domain_service_user(host.fqdn, host.ansible_host, bootstrap, bootstrap_password, domain, service_password) + self.ui.result('AD account preparation completed. Updating the inventory reference next.', level='info') + try: + self.customers.set_windows_ansible_user(customer, service_upn) + except (AIMError, OSError, ValueError, YAMLError) as exc: + self.ui.result('The AD operation succeeded, but the inventory reference was not saved. Do not repeat the password reset just to fix YAML.', level='warning') + raise + self.ui.result(f'Domain service account prepared: {service_upn}', title='Domain account ready') + self.ui.result('Store the matching password in vault_windows_ansible_password. Use GPO rollout or the manual member-server action for member access.', level='info') + self.pause() + + def windows_grant(self, customer: str, inventory_path: Path) -> None: + targets, label = self.pick_windows_member_targets(customer, inventory_path) + if not targets: + return + domain, service_upn = self._domain_identity(customer) + bootstrap, bootstrap_password = self._bootstrap_domain_credentials(customer, domain) + self.ui.review('Enable member-server access', [ + ('Targets', '\n'.join(h.fqdn for h in targets)), ('Domain account', service_upn), + ('Bootstrap identity', bootstrap), ('Change', 'Add the existing account to local Administrators'), + ], warning='Domain controllers are detected and skipped by the grant operation. Standalone hosts may reject domain credentials; other targets continue.') + if not self.confirm('Grant access on these member servers?', default=False): + return + self.winrm.grant_domain_service_user_access_batch([(h.fqdn, h.ansible_host) for h in targets], bootstrap, bootstrap_password, domain) + self.ui.result('Member-server batch completed. Check the Ansible recap for skipped targets.', title='Access rollout processed') + self.pause() + + def windows_rollout(self, customer: str, inventory_path: Path) -> None: + self.clear() + self.header(self.breadcrumb(customer, 'Access > Windows > WinRM GPO rollout'), 'Uses the existing inventory / Vault service account. No temporary administrator credentials.') + dc = self.pick_one_host(self.inventory_hosts(customer, inventory_path, ('windows',), require_address=True), 'Select prepared administration DC (one host)') + if not dc: + return + self.ui.result('Checking configured WinRM access to the DC...', level='info') + self.winrm.test_inventory_service_user(customer, inventory_path, dc.fqdn) + self.ui.result('Querying organizational units...', level='info') + ous = self.winrm.list_domain_ous(customer, inventory_path, dc.fqdn) + selected = self.selector.select('Select GPO link target', ['OU', 'Distinguished name'], + [Choice((name, dn), (name, dn), (dn,)) for name, dn in ous]) + if not selected: + return + _, ou_dn = selected + extra = self.ask('Additional certificate DNS/IP SANs (comma-separated; optional)', default='', show_default=False) + extra_sans = [item.strip() for item in extra.split(',') if item.strip()] + self.ui.review('Deploy / repair domain WinRM GPO', [ + ('Administration DC', dc.fqdn), ('Credentials', 'Configured inventory + Vault'), + ('Target OU', ou_dn), ('Security group', 'GG_bitformer_Ansible_Admins'), + ('GPO', 'bitformer - Ansible WinRM'), ('Certificate', 'Self-signed'), + ('Additional SANs', ', '.join(extra_sans) or 'None'), + ('Task timing', 'Immediate registration; 2-minute boot delay; 5-minute retries for 1 hour'), + ], warning='This links a computer policy to the selected OU. It grants the Ansible group local administrative access on applicable member servers.') + self.ui.footer('review') + if not self.confirm('Deploy / repair this policy?', default=False): + return + self.winrm.deploy_domain_winrm_gpo(customer, inventory_path, dc.fqdn, ou_dn, extra_sans) + self.ui.result('Policy created / repaired and linked to the selected OU.', title='GPO deployment completed') + self.ui.result('Policy propagation is not immediate. Run gpupdate /force on a member to accelerate processing. The task starts on registration, retries if needed, and disables after verification. AIM has not verified client application or connectivity.', level='warning') + self.pause() + + def windows_test(self, customer: str, inventory_path: Path) -> None: + targets, label = self._windows_targets(customer, inventory_path, 'Access > Windows > Test Ansible access') + if not targets: + return + self.ui.review('Test Ansible WinRM access', [('Targets', '\n'.join(h.fqdn for h in targets)), + ('Credentials', 'Existing group_vars / host_vars / Vault; no temporary admin credentials')]) + if not self.confirm('Run the configured connection test?', default=False): + return + # One ad-hoc invocation means one Vault prompt for the selected batch. + self.winrm.test_inventory_service_user(customer, inventory_path, ':'.join(h.fqdn for h in targets)) + self.ui.result('Configured WinRM test succeeded for the selected hosts.', title='Connection test completed') + self.pause() + + def linux_ssh_menu(self, customer: str, inventory_path: Path) -> None: + while True: + self.clear() + status = self.ssh.status(customer) + state = 'Keypair present' if status.complete else 'Incomplete keypair' if status.partial else 'Not created' + self.header(self.breadcrumb(customer, 'Access > Linux / SSH'), f'Customer SSH key: {state}') + choice = self.menu([ + MenuItem('1', 'Inspect keypair', 'Paths and public-key fingerprint'), + MenuItem('2', 'Create keypair', 'Preserve existing keys'), + MenuItem('3', 'Prepare service user and key', 'Create user, grant sudo, install authorized key'), + MenuItem('4', 'Install key only', 'Existing service account required'), + MenuItem('5', 'Test Ansible SSH access', 'Configured customer key and service user'), + ]) + if choice == '0': + return + self.call(self.linux_action, customer, inventory_path, choice) + + def linux_action(self, customer: str, inventory_path: Path, choice: str) -> None: + status = self.ssh.status(customer) + if choice == '1': + self.clear() + self.header(self.breadcrumb(customer, 'Access > Linux / SSH > Keypair')) + rows = [('Private key', status.private_key), ('Public key', status.public_key), ('Status', 'Present' if status.complete else 'Incomplete / absent')] + if status.complete: + rows.append(('Fingerprint', self.ssh.fingerprint(customer))) + self.ui.details(rows) + self.pause() + return + if choice == '2': + if status.complete: + self.ui.result('Keypair already exists. It was not changed.', level='info') + else: + self.ui.review('Create SSH keypair', [('Private key', status.private_key), ('Public key', status.public_key)]) + if not self.confirm('Create this keypair?', default=False): + return + self.ssh.ensure_key(customer) + self.ui.result('SSH keypair created.', title='Keypair ready') + self.pause() + return + targets, label = self.select_targets(customer, inventory_path, ('linux',), title='Access > Linux / SSH > Select targets', require_address=True) + if not targets: + return + bootstrap = None if choice == '5' else self.required_value('One-time SSH login user (sudo-capable)') + self.ui.review('Review SSH operation', [ + ('Operation', {'3': 'Prepare user and key', '4': 'Install key only', '5': 'Test key authentication'}[choice]), + ('Targets', '\n'.join(h.fqdn for h in targets)), ('Service account', self.config.service_user), + ('Login', bootstrap or 'Customer key / ssh-agent'), ('Private key', status.private_key), + ], warning='User preparation grants passwordless sudo. Native SSH/sudo prompts remain interactive.' if choice == '3' else '') + if not self.confirm('Proceed with this SSH operation?', default=False): + return + if choice in {'3', '4'}: + self.ssh.ensure_key(customer) + def operation(host): + if choice == '3': + self.ssh.create_service_user(host.ansible_host, bootstrap) + self.ssh.install_key(customer, host.ansible_host, bootstrap) + elif choice == '4': + self.ssh.install_key(customer, host.ansible_host, bootstrap) + else: + self.ssh.test_connection(customer, host.ansible_host) + self._access_batch('SSH operation', targets, operation) diff --git a/scripts/src/aim/ui/administration.py b/scripts/src/aim/ui/administration.py new file mode 100644 index 0000000..7d559ce --- /dev/null +++ b/scripts/src/aim/ui/administration.py @@ -0,0 +1,283 @@ +"""Read-only inspection, explicit consolidation and application settings.""" +from __future__ import annotations + +from copy import deepcopy +from dataclasses import fields +from pathlib import Path +import os +from ruamel.yaml import YAML +from ruamel.yaml.error import YAMLError + +from aim.auth import require_group_membership +from aim.exceptions import AIMError +from aim.inventory.validator import validate +from aim.structure import customer_template_diffs, consolidate_customer_templates, compare_vault_structure +from aim.ui.components import MenuItem, text +from aim.ui.selection import Choice + + +class AdministrationScreens: + def variables_menu(self, customer: str, kind: str, title: str) -> None: + while True: + files = self.variables_viewer.files(customer, kind) + choices = [Choice(p, (str(self.variables_viewer.relative(customer, p)),)) for p in files] + path = self.selector.select(f'{title} > Select file', ['YAML file (read-only)'], choices) + if path is None: + return + with path.open('rb') as stream: + encrypted = stream.read(14).startswith(b'$ANSIBLE_VAULT') + if encrypted: + self.ui.result('Encrypted Vault file. Contents are not decrypted in the variable viewer. Use Vault > Edit to edit it.', level='info') + self.pause() + else: + self.view_text(str(self.variables_viewer.relative(customer, path)), path.read_text(encoding='utf-8')) + + def vault_menu(self, customer: str) -> None: + while True: + self.clear() + self.header(self.breadcrumb(customer, 'Vault'), 'Secret input is hidden; the dashboard never decrypts this file.') + choice = self.menu([ + MenuItem('1', 'Inspect Vault status', 'Path, Vault identity and editor'), + MenuItem('2', 'Create Vault', 'Template prompts; encrypt before use'), + MenuItem('3', 'Edit Vault', 'Open the configured editor'), + MenuItem('4', 'Delete Vault', 'Remove the encrypted file', True), + ]) + if choice == '0': + return + self.call(self.vault_action, customer, choice) + + def create_vault(self, customer: str) -> None: + if self.vault.status(customer).exists: + self.ui.result('Vault already exists. No secrets were requested and no file was changed.', level='info') + return + values = self.prompt_vault_template_values() + try: + self.ui.review('Create encrypted Vault', [ + ('File', self.vault.path(customer)), ('Vault identity', f'{customer}@prompt'), + ('Values supplied', sum(bool(v) for v in values.values())), + ('Values left empty', sum(not bool(v) for v in values.values())), + ]) + if self.confirm('Create and encrypt the Vault?', default=False): + created = self.vault.create(customer, values) + self.ui.result('Vault created and encrypted.' if created else 'Vault already exists; no change.', title='Vault result') + finally: + values.clear() + + def vault_action(self, customer: str, choice: str) -> None: + status = self.vault.status(customer) + if choice == '1': + self.ui.details([('Path', status.path), ('Status', self.snapshot(customer)['vault']), + ('Vault identity', f'{customer}@prompt'), + ('Editor', os.environ.get('VISUAL') or os.environ.get('EDITOR') or 'nano')], title='Vault information') + elif choice == '2': + self.create_vault(customer) + elif choice == '3': + self.ui.result('Opening the Vault editor. The editor displays decrypted content for this explicit edit action.', level='info') + self.vault.edit(customer) + self.ui.result('Vault editor closed successfully.', title='Vault editor') + elif choice == '4': + self.ui.review('Delete Vault', [('File', status.path)], warning='This removes all secrets in this Vault. The hosts.yml session backup does not restore it.') + if self.confirm('Delete this encrypted Vault?', default=False): + self.vault.delete(customer) + self.ui.result('Vault deleted.', title='Vault removed') + self.pause() + + def administration_menu(self, customer: str, path: Path) -> None: + while True: + self.clear() + state = 'Available for this session' if self.backups.has_session_backup(path) else 'Not created in this session' + self.header(self.breadcrumb(customer, 'Administration'), f'hosts.yml recovery: {state}') + choice = self.menu([ + MenuItem('1', 'Validate inventory and templates', 'Read-only; values in Vault diagnostics are masked'), + MenuItem('2', 'Add missing template structure', 'Preview and explicitly confirm consolidation'), + MenuItem('3', 'Restore hosts.yml session backup', 'Replace current inventory only', True), + MenuItem('4', 'Customer defaults', 'Hostname, network and AD defaults'), + ]) + if choice == '0': + return + func = {'1': self.validate_inventory, '2': self.consolidate_templates, '3': self.restore, '4': self.customer_defaults_screen}[choice] + self.call(func, customer, path) + + def customer_defaults_screen(self, customer: str, path: Path) -> None: + self.clear() + self.header(self.breadcrumb(customer, 'Administration > Customer defaults')) + self.configure_customer_defaults(customer) + + def _render_template_diff(self, title: str, lines) -> None: + if not lines: + self.ui.result(f'{title}: no template deviations detected.') + return + self.console.print(text(title, 'bold')) + self.console.print(text('Missing entries are proposed additions; differing/custom values are retained.', 'aim.muted')) + for item in lines: + location = f'L{item.line}' if item.line else '--' + style = 'aim.warning' if item.kind in {'missing', 'unset', 'deviation'} else 'aim.context' + self.console.print(text(f'{item.marker} {location:>5} {item.text}', style)) + + def validate_inventory(self, customer: str, path: Path) -> None: + self.clear() + self.header(self.breadcrumb(customer, 'Administration > Validate'), 'Read-only: syntax, Ansible parsing and template comparison. No host connection test.') + root = path.parent + vault = self.vault.path(customer) + failures = 0 + checked = 0 + encrypted = 0 + for candidate in sorted(set(root.rglob('*.yml')) | set(root.rglob('*.yaml'))): + try: + with candidate.open('rb') as stream: + if stream.read(14).startswith(b'$ANSIBLE_VAULT'): + encrypted += 1 + continue + with candidate.open(encoding='utf-8') as stream: + YAML().load(stream) + checked += 1 + except YAMLError as exc: + failures += 1 + mark = getattr(exc, 'problem_mark', None) + where = f'line {mark.line+1}, column {mark.column+1}' if mark else 'unknown location' + self.ui.result(f'{candidate.relative_to(root)}: YAML syntax error at {where}. Source values are not printed.', level='error') + except OSError as exc: + failures += 1 + self.show_error(exc) + self.ui.result(f'Local syntax: {checked} readable YAML files passed; {failures} failed; {encrypted} encrypted files deferred to Ansible.', level='warning' if failures else 'success') + self.ui.result('Running full Ansible inventory validation. Vault passwords may be requested by Ansible.', level='info') + try: + validate(path, customer=customer) + except (AIMError, OSError, ValueError, YAMLError) as exc: + failures += 1 + self.show_error(exc) + else: + self.ui.result('Ansible inventory parsing passed.') + for diff in customer_template_diffs(root, self.config.service_user): + if diff.syntax_error: + self.ui.result(f'{diff.path.relative_to(root)}: template comparison skipped because YAML is invalid.', level='warning') + else: + self._render_template_diff(str(diff.path.relative_to(root)), diff.lines) + if vault.exists(): + self.ui.result('Reading Vault structure for a masked comparison. This may request the Vault password again.', level='info') + value = None + try: + value = self.vault.view_text(customer) + diff = compare_vault_structure(vault, value) + except (AIMError, OSError, ValueError, YAMLError) as exc: + failures += 1 + self.show_error(exc) + else: + value = None + self._render_template_diff('group_vars/all/vault.yml (values masked)', diff.lines) + finally: + value = None + self.ui.result('Read-only checks finished. Template differences are informational; syntax/Ansible errors need attention.' if failures else 'Read-only checks finished. No files were changed.', + level='warning' if failures else 'success', title='Validation finished') + self.pause() + + def consolidate_templates(self, customer: str, path: Path) -> None: + self.clear() + self.header(self.breadcrumb(customer, 'Administration > Consolidate'), 'Add missing keys/comments; preserve existing values and custom variables.') + root = path.parent + for diff in customer_template_diffs(root, self.config.service_user): + if diff.syntax_error: + self.ui.result(f'{diff.path.relative_to(root)}: invalid YAML; this file will be skipped without changes.', level='warning') + else: + self._render_template_diff(str(diff.path.relative_to(root)), diff.lines) + include_vault = self.vault.path(customer).exists() and self.confirm('Preview and include the encrypted Vault template?', default=False) + if include_vault: + value = self.vault.view_text(customer) + try: + diff = compare_vault_structure(self.vault.path(customer), value) + finally: + value = None + self._render_template_diff('group_vars/all/vault.yml (values masked)', diff.lines) + self.ui.review('Apply consolidation', [('Customer', customer), ('Plaintext templates', 'Add missing keys/comments only'), + ('Vault', 'Include after re-encryption' if include_vault else 'Leave unchanged')], + warning='Existing values are not normalized. Malformed plaintext files are skipped. This is not a repair of invalid YAML syntax.') + if not self.confirm('Apply non-destructive consolidation?', default=False): + return + changed, skipped = consolidate_customer_templates(root, self.config.service_user, self.config.required_group) + for item in changed: + self.ui.result(f'Consolidated {item.relative_to(root)}.') + # Avoid printing stored parser snippets which may contain secret values. + for item in skipped: + self.ui.result(item.split(':', 1)[0] + ': skipped; invalid YAML (file left untouched).', level='warning') + vault_changed = self.vault.consolidate_template(customer) if include_vault else False + if vault_changed: + self.ui.result('Consolidated and re-encrypted group_vars/all/vault.yml.') + self.ui.result(f'{len(changed) + int(vault_changed)} files changed; {len(skipped)} skipped.', + level='warning' if skipped else 'success', title='Consolidation finished') + self.pause() + + def restore(self, customer: str, path: Path) -> None: + self.clear() + self.header(self.breadcrumb(customer, 'Administration > Recover hosts.yml')) + if not self.backups.has_session_backup(path): + self.ui.result('No hosts.yml recovery backup was created in this AIM session.', level='info') + self.pause() + return + self.ui.review('Restore session backup', [('Backup', self.backups.backup_path(path)), ('Destination', path)], + warning='This replaces hosts.yml with its pre-change session copy. It does not restore host_vars, Vaults or remote system changes.') + if self.confirm('Restore this inventory backup?', default=False): + self.backups.restore(path, customer=customer) + self.ui.result('hosts.yml restored and locally YAML-validated. Run full validation next.', title='Recovery completed') + self.pause() + + def config_menu(self) -> None: + draft = deepcopy(self.config) + while True: + self.clear() + dirty = draft != self.config + self.header('AIM > Settings', 'Unsaved changes' if dirty else str(self.config.config_path)) + choice = self.menu([ + MenuItem('1', 'Ansible root directory', str(draft.root_dir)), + MenuItem('2', 'Service account name', draft.service_user), + MenuItem('3', 'Authorized AIM group', draft.required_group), + MenuItem('4', 'Platform groups', ', '.join(draft.platform_groups)), + MenuItem('5', 'Save settings', 'Apply the pending values'), + MenuItem('6', 'Screen clearing', 'Enabled' if draft.ui_clear_screen else 'Disabled'), + MenuItem('7', 'Character style', 'ASCII' if draft.ui_ascii else 'Unicode (terminal-dependent)'), + MenuItem('8', 'Command output', draft.ui_output), + ]) + if choice == '0': + if not dirty or self.confirm('Discard unsaved settings and go back?', default=False): + return + continue + try: + if choice == '1': + value = Path(self.required_value('Ansible root directory', str(draft.root_dir))).expanduser() + if not value.is_absolute(): + raise ValueError('Use an absolute Ansible root path.') + draft.root_dir = value + elif choice == '2': + draft.service_user = self.required_value('Service account name', draft.service_user) + elif choice == '3': + value = self.required_value('Authorized AIM group', draft.required_group) + require_group_membership(value) + draft.required_group = value + elif choice == '4': + raw = self.required_value('Platform groups (comma-separated)', ', '.join(draft.platform_groups)) + groups = list(dict.fromkeys(i.strip() for i in raw.split(',') if i.strip())) + if not groups: + raise ValueError('At least one platform group is required.') + draft.platform_groups = groups + elif choice == '6': + draft.ui_clear_screen = not draft.ui_clear_screen + elif choice == '7': + draft.ui_ascii = not draft.ui_ascii + elif choice == '8': + draft.ui_output = 'live' if draft.ui_output == 'compact' else 'compact' + elif choice == '5': + self.ui.review('Save application settings', [('File', draft.config_path), ('Root directory', draft.root_dir), + ('Service account', draft.service_user), ('Authorized group', draft.required_group)], + warning='Changing defaults does not migrate existing inventory variables, users or SSH keys.') + if self.confirm('Save these settings?', default=False): + draft.save() + for field in fields(draft): + setattr(self.config, field.name, deepcopy(getattr(draft, field.name))) + self.ui.clear_screen = self.config.ui_clear_screen + self.ui.ascii = self.config.ui_ascii or os.environ.get('TERM') == 'dumb' + self.command_ui.live_output = self.config.ui_output == 'live' + self._snapshot_key = None + self.ui.result('Settings saved.', title='Saved') + self.pause() + except (AIMError, OSError, ValueError, YAMLError) as exc: + self.show_error(exc) + self.pause() diff --git a/scripts/src/aim/ui/app.py b/scripts/src/aim/ui/app.py new file mode 100644 index 0000000..48e273e --- /dev/null +++ b/scripts/src/aim/ui/app.py @@ -0,0 +1,304 @@ +"""Application composition and common operator-console behavior.""" +from __future__ import annotations + +from collections import Counter +import re + +from rich.console import Console +from rich.prompt import Confirm, Prompt +from rich.syntax import Syntax +from ruamel.yaml.error import YAMLError + +from aim import __version__ +from aim.auth import require_group_membership +from aim.backup.session import SessionBackupManager +from aim.config import Config +from aim.customers.manager import CustomerManager +from aim.exceptions import AIMError, OperationCancelled +from aim.external import external_presentation +from aim.inventory.hosts import InventoryEditor +from aim.inventory.loader import InventoryDocument +from aim.inventory.writer import InventoryWriter +from aim.playbooks.manager import PlaybookManager +from aim.ssh.keys import SSHManager +from aim.sophos.config import SophosConfigManager +from aim.variables.viewer import VariableViewer +from aim.vault.manager import VaultManager +from aim.winrm.manager import WinRMManager +from aim.ui.components import OperatorUI, MenuItem, text +from aim.ui.selection import Selector +from aim.ui.execution import CommandUI +from aim.ui.customers import CustomerScreens +from aim.ui.hosts import HostScreens +from aim.ui.access import AccessScreens +from aim.ui.playbooks import PlaybookScreens +from aim.ui.administration import AdministrationScreens +from aim.ui.targets import TargetScreens + +_STYLE = re.compile(r'\[/?(?:bold|dim|red|green|yellow|cyan|magenta)(?: [^\]]+)?\]') + + +class App(CustomerScreens, HostScreens, AccessScreens, PlaybookScreens, + AdministrationScreens, TargetScreens): + def __init__(self, config: Config | None = None, *, console: Console | None = None, + no_clear: bool = False, plain: bool = False, live_output: bool = False): + self.config = config or Config.load() + self.ui = OperatorUI(console, ascii_only=plain or self.config.ui_ascii, + clear_screen=not no_clear and self.config.ui_clear_screen) + self.console = self.ui.console + self.customers = CustomerManager(self.config) + self.backups = SessionBackupManager() + self.writer = InventoryWriter(self.backups) + self.vault = VaultManager(self.customers) + self.variables_viewer = VariableViewer(self.customers) + self.ssh = SSHManager(self.customers, self.config, self.vault) + self.sophos = SophosConfigManager(self.customers) + self.winrm = WinRMManager(self.config) + self.playbooks = PlaybookManager(self.customers, self.config) + self.current_customer: str | None = None + self.current_section = 'Home' + self._snapshot_key = None + self._snapshot = None + self.command_ui = CommandUI(self.ui, live_output=live_output or self.config.ui_output == 'live') + self.selector = Selector(self.ui, self._selection_header) + + def clear(self) -> None: + self.ui.clear() + + @staticmethod + def breadcrumb(customer: str | None = None, section: str | None = None) -> str: + return ' > '.join(part for part in ['AIM', customer, section] if part) + + def _selection_header(self, title: str) -> None: + self.header(self.breadcrumb(self.current_customer, title)) + + def snapshot(self, customer: str) -> dict: + """Local metadata only. YAML OK does not mean Ansible/runtime validated.""" + root = self.customers.path(customer) + inventory = root / 'hosts.yml' + key = None + try: + stat = inventory.stat() + key = (str(inventory), stat.st_mtime_ns, stat.st_size) + except OSError: + pass + if key is not None and key == self._snapshot_key: + data = dict(self._snapshot) + else: + data = {'inventory': 'Missing', 'hosts': None, 'counts': {}} + if key is not None: + try: + doc = InventoryDocument(inventory) + hosts = InventoryEditor(doc, customer).list_hosts() + data = {'inventory': 'YAML OK', 'hosts': len(hosts), + 'counts': dict(Counter(g for h in hosts for g in h.memberships)), + 'notes': doc.custom_structure_notes(customer)} + except (AIMError, ValueError, TypeError, YAMLError): + data['inventory'] = 'Invalid YAML / structure' + self._snapshot_key, self._snapshot = key, data + vault = root / 'group_vars/all/vault.yml' + try: + with vault.open('rb') as stream: + data['vault'] = 'Encrypted file present' if stream.read(14).startswith(b'$ANSIBLE_VAULT') else 'Unencrypted file present' + except FileNotFoundError: + data['vault'] = 'Not created' + except OSError: + data['vault'] = 'Not readable' + return data + + def header(self, title: str, subtitle: str | None = None) -> None: + crumbs = [p.strip() for p in title.split(' > ') if p.strip()] + if not crumbs or crumbs[0] != 'AIM': + crumbs.insert(0, 'AIM') + # Navigation calls supply the exact customer context; never infer an AD domain. + if self.current_customer and self.current_customer not in crumbs: + crumbs.insert(1, self.current_customer) + self.current_section = crumbs[-1] + context = [] + if self.current_customer: + data = self.snapshot(self.current_customer) + context = [('Customer', self.current_customer), ('Inventory', data['inventory']), ('Vault', data['vault'])] + self.ui.header(__version__, crumbs, subtitle or '', context) + + def menu(self, items: list[MenuItem], *, back: str = 'Back') -> str: + self.ui.menu(items, back=back) + self.ui.footer('menu', exit_menu=back == 'Exit', back=back) + return self.menu_choice(['0'] + [item.key for item in items]) + + def menu_choice(self, choices: list[str]) -> str: + while True: + try: + choice = Prompt.ask('Choose', console=self.console, default='0', show_default=False).strip() or '0' + except KeyboardInterrupt: + return '0' + if choice in choices: + return choice + self.ui.result('Choose a listed number, or press Enter / 0 to go back.', level='warning') + + def ask(self, prompt: str, **kwargs) -> str: + try: + # Hide default values for every password field, even when set by a caller. + if kwargs.get('password'): + kwargs['show_default'] = False + return Prompt.ask(text(prompt), console=self.console, **kwargs) + except KeyboardInterrupt: + raise OperationCancelled('Operation cancelled.') from None + + def confirm(self, prompt: str, *, default: bool = False, **kwargs) -> bool: + try: + return Confirm.ask(text(prompt), console=self.console, default=default, **kwargs) + except KeyboardInterrupt: + raise OperationCancelled('Operation cancelled.') from None + + def optional_value(self, prompt: str, current: str = '') -> str: + value = self.ask(prompt + ' (- to clear)', default=current, show_default=bool(current)).strip() + return '' if value == '-' else value + + def required_value(self, prompt: str, default: str | None = None) -> str: + while True: + value = self.ask(prompt, default=default, show_default=bool(default)).strip() + if value: + return value + self.ui.result('A value is required. Ctrl+C cancels this operation.', level='warning') + + def message(self, *objects, **kwargs) -> None: + """Compatibility renderer for workflow text while keeping data literal.""" + if len(objects) != 1 or not isinstance(objects[0], str): + self.console.print(*objects, **kwargs) + return + raw = objects[0] + clean = _STYLE.sub('', raw) + stripped = raw.lstrip() + level = ('success' if stripped.startswith('[green]') else 'warning' if stripped.startswith('[yellow]') + else 'error' if stripped.startswith('[red]') else None) + if level: + self.ui.result(clean.lstrip('\n'), level=level) + else: + self.console.print(text(clean), **kwargs) + + def pause(self, message: str = 'Return to menu') -> None: + available = bool(self.command_ui.last_output) + self.ui.footer('result', extra='v View last command output' if available else '') + while True: + try: + answer = self.ask(message, default='', show_default=False).strip().lower() + except OperationCancelled: + return + if answer in {'', '0'}: + return + if answer == 'v' and available: + self.view_text('Last command output', self.command_ui.last_output, language='text') + else: + self.ui.result('Press Enter/0 to return' + (' or v to view output.' if available else '.'), level='info') + + def view_text(self, title: str, content: str, *, language: str = 'yaml') -> None: + lines = content.splitlines() or [''] + page = 0 + while True: + size = max(4, self.console.height - 13) + pages = max(1, (len(lines) + size - 1) // size) + page = min(page, pages - 1) + start = page * size + self.clear() + self.header(self.breadcrumb(self.current_customer, title), 'Read-only') + # Rich Syntax treats file contents as literal source, never console markup. + self.console.print(Syntax('\n'.join(lines[start:start+size]), language, + theme='ansi_dark', line_numbers=True, start_line=start+1, + word_wrap=True, background_color='default')) + self.console.print(text(f'Lines {start+1}-{min(start+size, len(lines))} of {len(lines)} | Page {page+1}/{pages}', 'aim.muted')) + self.ui.footer('viewer') + try: + raw = self.ask('Page', default='', show_default=False).strip().lower() + except OperationCancelled: + return + if raw in {'', '0'}: + return + if raw in {'n', 'p'}: + page = max(0, min(pages-1, page + (1 if raw == 'n' else -1))) + + def show_error(self, exc: Exception) -> None: + if isinstance(exc, OperationCancelled): + self.ui.result('Operation cancelled. Completed steps are not rolled back.', level='warning') + return + cause = exc + seen = set() + while cause.__cause__ is not None and id(cause) not in seen: + seen.add(id(cause)) + cause = cause.__cause__ + if isinstance(cause, YAMLError): + mark = getattr(cause, 'problem_mark', None) + location = f'Line {mark.line + 1}, column {mark.column + 1}' if mark else 'Location unavailable' + name = getattr(mark, 'name', '') + safe_name = name if name and name not in {'', ''} else 'YAML document' + # Parser text/snippets may contain secrets; do not render them. + detail = f'{safe_name}\n{location}: YAML could not be parsed.\nFile contents are not displayed. Correct the syntax and retry.' + else: + detail = str(exc) + self.ui.result(detail, level='error', title='Operation could not be completed') + + def call(self, func, *args, **kwargs): + self.command_ui.last_output = '' + try: + return func(*args, **kwargs) + except (AIMError, OSError, ValueError, YAMLError) as exc: + self.show_error(exc) + self.pause() + except KeyboardInterrupt: + self.show_error(OperationCancelled()) + self.pause() + + def prompt_vault_template_values(self) -> dict[str, str]: + self.ui.result('Secret input is hidden. Enter leaves an optional value empty.', level='info') + fields = [ + ('vault_windows_ansible_password', 'Windows domain account password'), + ('vault_windows_local_ansible_password', 'Windows shared local account password'), + ('ansible_vault_linux_ssh_pass', 'Linux legacy account password'), + ('vault_linux_ssh_key_passphrase', 'Linux private-key passphrase'), + ] + return {key: self.ask(label, password=True, default='', show_default=False) for key, label in fields} + + def run(self) -> int: + try: + auth = require_group_membership(self.config.required_group) + except AIMError as exc: + self.show_error(exc) + return 1 + try: + with external_presentation(self.command_ui): + while True: + self.current_customer = None + self.clear() + self.header('AIM > Home', f'Authorized group: {auth.group_name} | GID {auth.gid}') + choice = self.menu([ + MenuItem('1', 'Create customer', 'New inventory and optional defaults'), + MenuItem('2', 'Open customer', 'Hosts, access, Vault and playbooks'), + MenuItem('3', 'Browse customers', 'Local inventory overview'), + MenuItem('4', 'Delete customer', 'Remove the complete customer inventory', True), + MenuItem('5', 'Settings', 'Application and display preferences'), + ], back='Exit') + if choice == '0': + return 0 + if choice == '1': + self.call(self.create_customer) + elif choice == '2': + self.call(self.open_customer) + elif choice == '3': + self.call(self.list_customers_screen) + elif choice == '4': + self.call(self.delete_customer) + elif choice == '5': + self.call(self.config_menu) + except EOFError: + return 0 + finally: + self.ssh._shutdown_agent() + self.command_ui.last_output = '' + + def open_customer(self) -> None: + customer = self.pick_customer() + if customer: + self.current_customer = customer + try: + self.customer_menu(customer) + finally: + self.current_customer = None diff --git a/scripts/src/aim/ui/components.py b/scripts/src/aim/ui/components.py new file mode 100644 index 0000000..c98492b --- /dev/null +++ b/scripts/src/aim/ui/components.py @@ -0,0 +1,154 @@ +"""Shared, side-effect-free presentation for AIM's operator console. + +Rendering a screen never invokes Ansible, decrypts a Vault or probes a host. +""" +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from typing import Iterable, Sequence + +from rich import box +from rich.console import Console, Group +from rich.panel import Panel +from rich.table import Table +from rich.text import Text +from rich.theme import Theme + +THEME = Theme({ + 'aim.title': 'bold #ff7a00', 'aim.context': '#ff7a00', + 'aim.success': 'green', 'aim.warning': 'yellow', 'aim.error': 'bold red', + 'aim.muted': 'dim', 'aim.vault': 'bold #ff7a00', 'aim.border': '#ff7a00', +}) +_CONTROL = re.compile(r'\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))|[\x00-\x08\x0b-\x1f\x7f]') + + +def text(value: object, style: str = '') -> Text: + """Never interpret names, paths or external errors as Rich markup/ANSI.""" + return Text(_CONTROL.sub('', str(value)), style=style, overflow='fold') + + +@dataclass(frozen=True) +class MenuItem: + key: str + label: str + detail: str = '' + danger: bool = False + + +class OperatorUI: + def __init__(self, console: Console | None = None, *, ascii_only: bool = False, + clear_screen: bool = True): + self.console = console or Console(theme=THEME, highlight=False) + # Also install styles for a caller-supplied/testing Console. + self.console.push_theme(THEME) + self.ascii = ascii_only or os.environ.get('TERM') == 'dumb' + try: + '\u2713\u2717\u203a'.encode(self.console.encoding) + except (UnicodeEncodeError, LookupError): + self.ascii = True + self.clear_screen = clear_screen + + @property + def border(self): + return box.ASCII if self.ascii else box.ROUNDED + + def clear(self) -> None: + if self.clear_screen and self.console.is_terminal and not self.console.is_dumb_terminal: + self.console.clear() + + def header(self, version: str, crumbs: Sequence[str], subtitle: str = '', + context: Sequence[tuple[str, str]] = ()) -> None: + brand = Table.grid(expand=True) + brand.add_column(ratio=1, vertical='middle') + brand.add_column(justify='right', vertical='middle') + identity = Text() + identity.append('bitformer', style='aim.title') + identity.append(' · ', style='aim.muted') + identity.append('AIM', style='aim.title') + identity.append(' · Ansible Inventory Manager') + brand.add_row(identity, text(f'v{version}', 'aim.muted')) + content: list = [brand] + if context: + row = Text() + for index, (key, value) in enumerate(context): + if index: + row.append(' | ', style='aim.muted') + row.append(f'{key}: ', style='aim.muted') + row.append(text(value, 'aim.vault' if key == 'Vault' else 'aim.context')) + content.append(row) + self.console.print(Panel(Group(*content), box=self.border, border_style='aim.border', padding=(0, 1))) + separator = ' > ' if self.ascii else ' \u203a ' + self.console.print(text(separator.join(crumbs), 'bold')) + if subtitle: + self.console.print(text(subtitle, 'aim.muted')) + self.console.print() + + def menu(self, items: Iterable[MenuItem], *, back: str = 'Back') -> None: + table = Table.grid(padding=(0, 2)) + table.add_column(justify='right', width=3) + table.add_column() + table.add_column(style='aim.muted') + for item in items: + table.add_row(text(item.key, 'aim.context'), + text(item.label, 'red' if item.danger else ''), text(item.detail, 'aim.muted')) + table.add_row('', '', '') + table.add_row(text('0', 'aim.muted'), text(back, 'aim.muted'), '') + self.console.print(table) + + def table(self, columns: Sequence[str], rows: Iterable[Sequence[object]], *, title: str | None = None) -> None: + table = Table(box=box.ASCII if self.ascii else box.SIMPLE_HEAD, expand=True, + show_edge=False, padding=(0, 1), header_style='bold', title=text(title) if title else None) + for i, column in enumerate(columns): + table.add_column(column, no_wrap=column in {'#', 'Selected'}, overflow='fold', + justify='right' if column == '#' else 'left') + for row in rows: + table.add_row(*(cell if isinstance(cell, Text) else text(cell) for cell in row)) + self.console.print(table) + + def details(self, rows: Iterable[tuple[str, object]], *, title: str | None = None) -> None: + table = Table.grid(padding=(0, 2), expand=True) + table.add_column(style='aim.muted', ratio=1, max_width=24) + table.add_column(ratio=3, overflow='fold') + for key, value in rows: + table.add_row(text(key, 'aim.muted'), text(value)) + if title: + self.console.print(Panel(table, title=text(title), title_align='left', + box=self.border, border_style='aim.border')) + else: + self.console.print(table) + + def result(self, message: str, *, level: str = 'success', title: str | None = None) -> None: + symbols = {'success': 'OK' if self.ascii else '\u2713', 'error': 'X' if self.ascii else '\u2717', + 'warning': '!', 'info': 'i'} + style = {'success': 'aim.success', 'warning': 'aim.warning', 'error': 'aim.error', 'info': 'aim.context'}[level] + if title: + self.console.print(Panel(text(message), title=text(f'{symbols[level]} {title}', style), + title_align='left', border_style=style, box=self.border)) + else: + line = text(f'{symbols[level]} ', style) + line.append(text(message)) + self.console.print(line) + + def review(self, title: str, rows: Iterable[tuple[str, object]], *, warning: str = '') -> None: + self.details(rows, title=title) + if warning: + self.result(warning, level='warning') + + def footer(self, mode: str = 'menu', *, extra: str = '', exit_menu: bool = False, back: str = 'Back') -> None: + hints = { + 'menu': 'Enter / 0 Back | Ctrl+C Back', + 'select': 'Enter / 0 Cancel | /text Filter | p / n Page | Ctrl+C Cancel', + 'multi': 'Number Toggle | Enter Review selection | 0 Cancel | /text Filter | p / n Page', + 'form': 'Enter Accept shown default | Ctrl+C Cancel operation', + 'review': 'y Confirm | Enter / n Cancel | Ctrl+C Cancel', + 'result': 'Enter / 0 Return', + 'viewer': 'Enter / 0 Back | p / n Page | Ctrl+C Back', + } + hint = 'Enter / 0 Exit | Ctrl+C Exit' if exit_menu else hints[mode] + if mode == 'menu' and not exit_menu and back != 'Back': + hint = f'Enter / 0 {back} | Ctrl+C Back' + self.console.print() + self.console.rule(style='aim.muted') + self.console.print(text(hint + (f' | {extra}' if extra else ''), 'aim.muted')) diff --git a/scripts/src/aim/ui/customers.py b/scripts/src/aim/ui/customers.py new file mode 100644 index 0000000..360e47d --- /dev/null +++ b/scripts/src/aim/ui/customers.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import ipaddress + +from ruamel.yaml.error import YAMLError + +from aim.customers.manager import CustomerDefaults +from aim.exceptions import AIMError + + +from aim.ui.components import MenuItem +from aim.ui.selection import Choice +from aim.exceptions import OperationCancelled + +class CustomerScreens: + def pick_customer(self) -> str | None: + choices = [Choice(name, (name, str(self.customers.path(name) / 'hosts.yml')), (name,)) + for name in self.customers.list()] + return self.selector.select('Select customer', ['Customer', 'Inventory'], choices) + + + def list_customers_screen(self) -> None: + choices = [] + for name in self.customers.list(): + data = self.snapshot(name) + choices.append(Choice(name, (name, data['inventory'], str(data['hosts'] if data['hosts'] is not None else '-'), data['vault']), (name,))) + selected = self.selector.select('Browse customers', ['Customer', 'Inventory', 'Hosts', 'Vault'], choices) + if selected: + old = self.current_customer + self.current_customer = selected + try: + self.customer_menu(selected) + finally: + self.current_customer = old + + + def create_customer(self) -> None: + self.clear() + self.header('AIM > Create customer', 'Creates a new inventory. Existing customer directories are never replaced.') + name = self.ask('Customer name (Enter / 0 to cancel)', default='', show_default=False).strip() + if name in {'', '0'}: + return + self.customers._validate_name(name) + if self.customers.path(name).exists(): + self.ui.result('This customer already exists. Open it to make changes; it was not overwritten.', level='warning') + self.pause() + return + self.ui.review('Create customer', [('Name', name), ('Inventory directory', self.customers.path(name))]) + if not self.confirm('Create this customer?', default=False): + return + path = self.customers.create_structure(name) + self.current_customer = name + try: + self.ui.result(f'Customer created: {path}', title='Customer ready') + self.configure_customer_defaults(name, pause=False) + defaults = self.customers.load_defaults(name) + if defaults.ad_dns_domain: + service_upn = f'{self.config.service_user}@{defaults.ad_dns_domain.lower()}' + self.customers.set_windows_ansible_user(name, service_upn) + self.ui.result(f'New customer Windows default: {service_upn}', level='info') + if self.confirm('Create an encrypted Vault now?', default=False): + self.create_vault(name) + self.pause() + finally: + self.current_customer = None + + + def configure_customer_defaults(self, customer: str, *, pause: bool = True) -> None: + current = self.customers.load_defaults(customer) + self.ui.result('Enter keeps a displayed value. Use - to clear it. Existing hosts are not changed.', level='info') + domain = self.optional_value('Default hostname domain suffix', current.domain_suffix).lstrip('.') + network_address = self.optional_value('Default server network address', current.network_address) + netmask = '' + if network_address: + while True: + netmask = self.required_value('Network netmask', current.netmask or '255.255.255.0') + try: + network = ipaddress.IPv4Network((network_address, netmask), strict=False) + network_address, netmask = str(network.network_address), str(network.netmask) + break + except ValueError: + self.ui.result('Invalid IPv4 network/netmask. Re-enter the network; - disables it.', level='warning') + network_address = self.optional_value('Default server network address', network_address) + if not network_address: + netmask = '' + break + ad_dns_domain = self.optional_value('AD DNS domain', current.ad_dns_domain).lstrip('.') + ad_netbios_domain = self.optional_value('AD NetBIOS domain', current.ad_netbios_domain) + desired = CustomerDefaults(domain, network_address, netmask, ad_dns_domain, ad_netbios_domain) + if desired == current: + self.ui.result('Defaults are unchanged.', level='info') + else: + self.ui.review('Save customer defaults', [ + ('Hostname suffix', domain or '(not set)'), ('Server network', f'{network_address} / {netmask}' if network_address else '(not set)'), + ('AD DNS domain', ad_dns_domain or '(not set)'), ('AD NetBIOS domain', ad_netbios_domain or '(not set)'), + ], warning='These are suggestions for new operations. Existing hosts and credentials are not migrated.') + if self.confirm('Save these defaults?', default=False): + saved = self.customers.save_defaults(customer, desired) + self.ui.result(f'Defaults saved: {saved}', title='Saved') + if pause: + self.pause() + + + def _customer_network(self, customer: str) -> ipaddress.IPv4Network | None: + defaults = self.customers.load_defaults(customer) + if not defaults.network_address or not defaults.netmask: + return None + try: + return ipaddress.IPv4Network((defaults.network_address, defaults.netmask), strict=False) + except ValueError as exc: + raise ValueError(f"Invalid customer default network: {exc}") from exc + + + def prompt_host_fqdn(self, customer: str) -> str: + defaults = self.customers.load_defaults(customer) + hostname = self.ask('Hostname or FQDN (Enter / 0 to cancel)', default='', show_default=False).strip() + if hostname in {'', '0'}: + raise OperationCancelled('Host entry cancelled.') + if '.' in hostname: + return hostname.rstrip('.') + domain = self.optional_value('Domain suffix', defaults.domain_suffix).lstrip('.') + return f'{hostname}.{domain}' if domain else hostname + + + def prompt_host_ip(self, customer: str, *, existing_ip: str | None = None) -> str: + network = self._customer_network(customer) + if network is None: + if existing_ip: + return self.ask("Host IP", default=existing_ip).strip() + return self.ask("Host IP").strip() + + default_value = None + if existing_ip: + try: + existing_addr = ipaddress.IPv4Address(existing_ip) + if existing_addr in network: + default_value = str(int(existing_addr) - int(network.network_address)) + else: + default_value = existing_ip + except ValueError: + default_value = existing_ip + + self.message( + f"[dim]Default network: {network.network_address} / {network.netmask} ({network.with_prefixlen})[/dim]" + ) + raw = self.ask( + "Host number or full IP", + default=default_value, + show_default=default_value is not None, + ).strip() + if not raw: + raise ValueError("Host address is required") + + if "." in raw: + try: + return str(ipaddress.IPv4Address(raw)) + except ValueError as exc: + raise ValueError(f"Invalid host IP: {raw}") from exc + + try: + host_number = int(raw, 10) + except ValueError as exc: + raise ValueError("Enter a numeric host number or a full IPv4 address") from exc + if host_number < 0: + raise ValueError("Host number cannot be negative") + address_int = int(network.network_address) + host_number + if address_int > int(network.broadcast_address): + raise ValueError(f"Host number {host_number} is outside {network.with_prefixlen}") + address = ipaddress.IPv4Address(address_int) + if address not in network: + raise ValueError(f"Calculated address {address} is outside {network.with_prefixlen}") + if network.prefixlen <= 30 and address in (network.network_address, network.broadcast_address): + raise ValueError(f"{address} is not a usable host address in {network.with_prefixlen}") + return str(address) + + + def delete_customer(self) -> None: + self.clear() + self.header(self.breadcrumb(section="Delete Customer")) + customer = self.pick_customer() + if not customer: + return + path = self.customers.require(customer) + self.message(f"[red]This permanently deletes the customer directory:[/red]\n{path}") + answer = self.ask(f"Type '{customer}' to confirm", default="", show_default=False) + if answer != customer: + self.message("Cancelled. Nothing was changed.") + else: + self.customers.delete(customer) + self.message(f"[green]Deleted customer {customer}.[/green]") + self.pause() + + + def customer_menu(self, customer: str) -> None: + path = self.customers.require(customer) / 'hosts.yml' + while True: + self.clear() + self.header(self.breadcrumb(customer, 'Overview')) + data = self.snapshot(customer) + try: + defaults = self.customers.load_defaults(customer) + domain = defaults.ad_dns_domain or 'Not configured' + suffix = defaults.domain_suffix or 'Not configured' + except (AIMError, OSError, ValueError, YAMLError): + domain = suffix = 'Invalid customer defaults; review .aim.yml' + counts = data['counts'] + if data.get('notes'): + self.ui.result('Custom inventory structure detected. AIM will preserve it; review it before structural changes.', level='info') + self.ui.details([ + ('Inventory', f"{data['inventory']} (local check only)"), + ('Hosts', str(data['hosts']) if data['hosts'] is not None else 'Unavailable'), + ('Platform memberships', ' | '.join(f'{g}: {counts.get(g, 0)}' for g in self.config.platform_groups)), + ('AD domain', domain), ('Hostname suffix', suffix), + ], title='Customer overview') + if data['inventory'] != 'YAML OK': + self.ui.result('Inventory cannot be parsed. Administration and file viewing remain available.', level='warning') + choice = self.menu([ + MenuItem('1', 'Hosts', 'Add, inspect or remove inventory hosts'), + MenuItem('2', 'Access', 'SSH and WinRM setup / connection tests'), + MenuItem('3', 'Vault', 'Create, edit or inspect Vault status'), + MenuItem('4', 'Group variables', 'Read-only YAML viewer'), + MenuItem('5', 'Host variables', 'Read-only YAML viewer'), + MenuItem('6', 'Playbooks', 'Select targets and run approved playbooks'), + MenuItem('7', 'Administration', 'Validate, consolidate, recover and set defaults'), + ]) + if choice == '0': + return + actions = { + '1': (self.hosts_menu, (customer, path)), '2': (self.access_menu, (customer, path)), + '3': (self.vault_menu, (customer,)), '4': (self.variables_menu, (customer, 'group_vars', 'Group variables')), + '5': (self.variables_menu, (customer, 'host_vars', 'Host variables')), + '6': (self.playbooks_menu, (customer, path)), '7': (self.administration_menu, (customer, path)), + } + func, args = actions[choice] + self.call(func, *args) + diff --git a/scripts/src/aim/ui/execution.py b/scripts/src/aim/ui/execution.py new file mode 100644 index 0000000..23eea06 --- /dev/null +++ b/scripts/src/aim/ui/execution.py @@ -0,0 +1,114 @@ +"""Compact output for known noninteractive jobs; native terminal for everything else. + +Never parse callback output to determine command success or application data. +Exit codes and the managers' structured result files remain authoritative. +""" +from __future__ import annotations + +from collections import deque +from contextlib import nullcontext +import re +import subprocess +from pathlib import Path + +from aim.external import execute, stop_process +from aim.ui.components import text + +_ANSI = re.compile(r'\x1b\[[0-?]*[ -/]*[@-~]') +_SECRET = re.compile(r'(?i)(?:password|passphrase|secret|token|private[_ -]?key)\s*["\']?\s*[:=]') + + +def safe_output(output: str) -> str: + """Defensive display filter, in addition to Ansible's no_log handling. + +Vault plaintext is never sent through this component at all. It is not a +replacement for no_log on playbooks which intentionally print credentials. +""" + cleaned = _ANSI.sub('', output) + result = [] + private = False + for line in cleaned.splitlines(): + if '-----BEGIN ' in line and 'PRIVATE KEY-----' in line: + private = True + result.append('[private key omitted]') + if private: + if '-----END ' in line and 'PRIVATE KEY-----' in line: + private = False + continue + result.append('[line with credential field omitted]' if _SECRET.search(line) else line) + return '\n'.join(result) + + +class CommandUI: + def __init__(self, ui, *, live_output: bool = False): + self.ui = ui + self.live_output = live_output + self.last_output = '' + + def __call__(self, args, kwargs, *, capture_output, suppress_stdout, ui_mode): + command = Path(args[0]).name + interactive = command in {'ansible-vault', 'ssh', 'ssh-keygen', 'ssh-copy-id'} or any( + item in {'--ask-pass', '-k', '--ask-become-pass', '-K', '--ask-vault-pass'} or '@prompt' in item + for item in args + ) + # A curated customer playbook may itself contain pause/interactive tasks. + # Only callers that own all tasks can opt into compact capture. + compact = (ui_mode == 'compact' and not interactive and not capture_output + and not suppress_stdout and kwargs.get('text') and not self.live_output) + if compact: + return self._compact(args, kwargs) + if not capture_output and not suppress_stdout: + self.ui.console.print(text(f'Running {command} | live terminal output', 'aim.muted')) + self.ui.console.print(text('Ctrl+C interrupts the command. Remote changes already made are not rolled back.', 'aim.muted')) + animated = (capture_output and not interactive and self.ui.console.is_terminal) + status = self.ui.console.status(text(f'Running {command}...', 'aim.context'), + spinner='line' if self.ui.ascii else 'dots') if animated else nullcontext() + with status: + return execute(args, kwargs) + + def _compact(self, args, kwargs): + options = dict(kwargs) + options.update(stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True) + command = Path(args[0]).name + tail: deque[str] = deque(maxlen=500) + last_task = f'Running {command}' + recap = [] + private_key_block = False + self.last_output = '' + process = subprocess.Popen(args, **options) + status = self.ui.console.status(text(last_task, 'aim.context'), spinner='line' if self.ui.ascii else 'dots') + context = status if self.ui.console.is_terminal else nullcontext() + try: + with context: + assert process.stdout is not None + for line in process.stdout: + # Keep only a bounded, filtered, in-memory diagnostic tail. + raw = _ANSI.sub('', line.rstrip()) + if '-----BEGIN ' in raw and 'PRIVATE KEY-----' in raw: + private_key_block = True + tail.append('[private key omitted]') + if private_key_block: + if '-----END ' in raw and 'PRIVATE KEY-----' in raw: + private_key_block = False + continue + safe = safe_output(raw)[:2000] + tail.append(safe) + if safe.startswith(('TASK [', 'PLAY [')): + last_task = safe.rstrip(' *') + if self.ui.console.is_terminal: + status.update(text(last_task, 'aim.context')) + if re.search(r'\bok=\d+.*\bfailed=\d+', safe): + recap.append(safe) + code = process.wait() + self.last_output = '\n'.join(tail) + if recap: + self.ui.details([(str(i+1), line) for i, line in enumerate(recap)], title='Ansible recap') + if code: + self.ui.result(f'{command} exited with code {code}. Use v on the result screen for command output.', level='error') + else: + self.ui.result(f'{command} finished successfully.', level='success') + return subprocess.CompletedProcess(args, code) + finally: + stop_process(process, group=True) + if process.stdout is not None: + process.stdout.close() diff --git a/scripts/src/aim/ui/hosts.py b/scripts/src/aim/ui/hosts.py new file mode 100644 index 0000000..72351d6 --- /dev/null +++ b/scripts/src/aim/ui/hosts.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +from pathlib import Path + + +from aim.inventory.hosts import InventoryEditor +from aim.inventory.loader import InventoryDocument +from aim.inventory.model import ChangeSet, Host + + +from aim.ui.components import MenuItem + +class HostScreens: + def hosts_menu(self, customer: str, path: Path) -> None: + while True: + self.clear() + self.header(self.breadcrumb(customer, 'Hosts')) + choice = self.menu([ + MenuItem('1', 'Add / update host', 'Preview changes before saving'), + MenuItem('2', 'Browse hosts', 'Addresses and group memberships'), + MenuItem('3', 'View hosts.yml', 'Read-only, with line numbers'), + MenuItem('4', 'Remove host', 'Also removes its host_vars directory', True), + ]) + if choice == '0': + return + action = {'1': self.upsert_host, '2': self.list_hosts, '3': self.view_inventory, '4': self.remove_host}[choice] + self.call(action, customer, path) + + + def prompt_memberships(self) -> dict[str, set[str]]: + self.message("\n[bold]Group memberships[/bold]") + self.message("Enter one group at a time; empty group input finishes. At least one group is required.") + self.message(f"[dim]Default main groups: {', '.join(self.config.platform_groups)}[/dim]") + result: dict[str, set[str]] = {} + while True: + group = self.ask("Group (empty to finish)", default="", show_default=False).strip() + if not group: + if result: + return result + self.message("[yellow]At least one group is required.[/yellow]") + continue + if "/" in group: + self.message("[yellow]Enter the group and subgroup separately.[/yellow]") + continue + subs: set[str] = set() + while True: + subgroup = self.ask(f"Subgroup under {group} (empty to finish this group)", default="", show_default=False).strip() + if not subgroup: + break + if "/" in subgroup: + self.message("[yellow]AIM-created subgroups are one level deep.[/yellow]") + continue + subs.add(subgroup) + result.setdefault(group, set()).update(subs) + + + @staticmethod + def host_from_memberships(fqdn: str, ip: str, memberships: dict[str, set[str]]) -> Host: + host = Host(fqdn=fqdn, ansible_host=ip) + for group, subgroups in memberships.items(): + if subgroups: + for subgroup in sorted(subgroups): + host.add_subgroup(group, subgroup) + else: + host.add_group(group) + return host + + + def find_host(self, customer: str, path: Path, fqdn: str) -> Host | None: + editor = InventoryEditor(InventoryDocument(path), customer) + return next((host for host in editor.list_hosts() if host.fqdn == fqdn), None) + + + def print_host_state(self, host: Host) -> None: + self.ui.details([('Host', host.fqdn), ('Address', host.ansible_host or '(not set)'), + ('Memberships', '\n'.join(host.membership_labels()) or '(none)')], title='Host details') + + + def print_changes(self, changes: ChangeSet) -> None: + rows = [] + rows.extend(('Update', v) for v in changes.changed) + rows.extend(('Add', v) for v in changes.added) + rows.extend(('Remove', v) for v in changes.removed) + if rows: + self.ui.table(['Planned change', 'Details'], rows) + for item in changes.warnings: + self.ui.result(item, level='warning') + + + def upsert_host(self, customer: str, path: Path) -> None: + self.clear() + self.header(self.breadcrumb(customer, "Add or Update Host")) + fqdn = self.prompt_host_fqdn(customer) + existing = self.find_host(customer, path, fqdn) + was_new = existing is None + if existing: + self.print_host_state(existing) + if existing.ansible_host == "CONFLICT": + raise ValueError("Host has conflicting ansible_host values; resolve them manually before AIM can update it") + ip = self.prompt_host_ip(customer, existing_ip=existing.ansible_host) + if self.confirm("Keep current AIM-manageable memberships?", default=True): + memberships = {} + for group, membership in existing.memberships.items(): + simple_subgroups = {sub for sub in membership.subgroups if "/" not in sub} + if simple_subgroups: + memberships[group] = simple_subgroups + elif not membership.subgroups: + memberships[group] = set() + if not memberships: + self.message( + "[yellow]This host currently has no one-level AIM-manageable membership. " + "Its custom memberships will be preserved; choose at least one AIM membership.[/yellow]" + ) + memberships = self.prompt_memberships() + else: + memberships = self.prompt_memberships() + else: + ip = self.prompt_host_ip(customer) + memberships = self.prompt_memberships() + + document = InventoryDocument(path) + editor = InventoryEditor(document, customer) + desired = self.host_from_memberships(fqdn, ip, memberships) + changes = editor.upsert_host(desired) + if not changes.changed_anything and not changes.changed: + # May still need to ensure the default host_vars file. + if "sophosxgs" in desired.memberships: + self.customers.ensure_sophos_host_vars(customer, fqdn) + else: + self.customers.ensure_host_vars(customer, fqdn) + self.message("\n[green]Host already matches the requested state. No inventory changes required.[/green]") + self.pause() + return + + self.ui.review('Save host', [('Host', fqdn), ('Address', ip), ('Memberships', ', '.join(desired.membership_labels()))]) + self.print_changes(changes) + if not self.confirm("Apply these changes?", default=False): + self.message("Cancelled. Nothing was changed.") + self.pause() + return + changed = self.writer.commit(document, customer=customer) + if "sophosxgs" in desired.memberships: + host_vars_path = self.customers.ensure_sophos_host_vars(customer, fqdn) + else: + host_vars_path = self.customers.ensure_host_vars(customer, fqdn) + self.message("[green]Inventory updated and YAML-validated.[/green]" if changed else "No inventory changes required.") + if was_new: + self.message(f"\n[yellow]Host variables:[/yellow] {host_vars_path}") + if "sophosxgs" in desired.memberships: + self.message("A Sophos XGS network-object/VLAN template was created for this host.") + else: + self.message( + "Use this file for host-specific variables such as a local Windows " + "ansible_password override when the domain credential does not apply." + ) + if "windows" in desired.memberships: + self.message("\n[bold]Windows credential model[/bold]") + model_choice = self.menu([ + MenuItem('1', 'Domain service account', 'Use Windows group defaults'), + MenuItem('2', 'Shared local service account', 'Use shared-local Vault password'), + MenuItem('3', 'Host-specific local account', 'Prepare a host-specific Vault reference'), + ], back='Keep existing configuration') + if model_choice == '0': + self.pause() + return + model = {"1": "domain", "2": "shared_local", "3": "host_local"}[model_choice] + host_vars_path = self.customers.set_windows_host_credential_model(customer, fqdn, model) + if model == "host_local": + import re as _re + key = _re.sub(r"[^a-z0-9]+", "_", fqdn.lower()).strip("_") + self.message(f"Vault variable to add: vault_ansible_password_{key}") + elif model == "shared_local": + self.message("Uses Vault variable: vault_windows_local_ansible_password") + self.pause() + + + def list_hosts(self, customer: str, path: Path) -> None: + while True: + hosts = self.inventory_hosts(customer, path) + host = self.pick_one_host(hosts, 'Hosts > Browse') + if host is None: + return + self.clear() + self.header(self.breadcrumb(customer, 'Hosts > Details')) + self.print_host_state(host) + self.ui.details([('Host variables', self.customers.path(customer) / 'host_vars' / host.fqdn / 'main.yml')]) + self.pause() + + + def pick_host(self, customer: str, path: Path, *, linux_only=False, windows_only=False, sophos_only=False) -> Host | None: + platforms = ('linux',) if linux_only else ('windows',) if windows_only else ('sophosxgs',) if sophos_only else () + return self.pick_one_host(self.inventory_hosts(customer, path, platforms)) + + + def configure_sophos(self, customer: str, path: Path) -> None: + self.clear() + self.header(self.breadcrumb(customer, "Configure Sophos XGS")) + host = self.pick_host(customer, path, sophos_only=True) + if not host: + return + + hostname = host.fqdn.split(".", 1)[0] + existing_network, existing_vlans = self.sophos.existing_keys(customer, host.fqdn) + network_objects: dict[str, dict[str, object]] = {} + vlan_interfaces: dict[str, dict[str, object]] = {} + + self.message("\n[bold]Network objects[/bold]") + self.message("[dim]Enter an object key; empty input finishes this section.[/dim]") + while True: + key = self.ask("Object key (empty to finish)", default="", show_default=False).strip() + if not key: + break + if (key in existing_network or key in network_objects) and not self.confirm(f"{key} already exists. Replace it?", default=False): + continue + default_name = self.sophos.default_network_object_name(customer, key) + name = self.ask("Name", default=default_name).strip() + network = self.ask("Network address").strip() + subnetmask = self.ask("Subnet mask", default="255.255.255.0").strip() + network_objects[key] = { + "name": name, + "network": network, + "subnetmask": subnetmask, + } + + self.message("\n[bold]VLAN interfaces[/bold]") + self.message("[dim]Enter a VLAN key; empty input finishes this section.[/dim]") + while True: + key = self.ask("VLAN key (empty to finish)", default="", show_default=False).strip() + if not key: + break + if (key in existing_vlans or key in vlan_interfaces) and not self.confirm(f"{key} already exists. Replace it?", default=False): + continue + name = self.ask("Name", default=key).strip() + ip_address = self.ask("Interface IP").strip() + subnetmask = self.ask("Subnet mask", default="255.255.255.0").strip() + while True: + raw_vlan = self.ask("VLAN ID").strip() + try: + vlan_id = int(raw_vlan) + except ValueError: + self.message("[yellow]VLAN ID must be a number from 1 to 4094.[/yellow]") + continue + if 1 <= vlan_id <= 4094: + break + self.message("[yellow]VLAN ID must be a number from 1 to 4094.[/yellow]") + zone_name = self.ask("Zone name", default=name).strip() + zone_type = self.ask("Zone type", default="LAN").strip() + zone_description = self.ask( + "Zone description", default=f"{zone_name} Zone ({zone_type})" + ).strip() + vlan_interfaces[key] = { + "name": name, + "ip_address": ip_address, + "subnetmask": subnetmask, + "vlan_id": vlan_id, + "zone_name": zone_name, + "zone_type": zone_type, + "zone_description": zone_description, + } + + if not network_objects and not vlan_interfaces: + self.message("No Sophos configuration entries supplied. Nothing changed.") + self.pause() + return + + self.message(f"\n[bold]Host:[/bold] {host.fqdn}") + self.message(f"Network objects: {', '.join(network_objects) if network_objects else '-'}") + self.message(f"VLAN interfaces: {', '.join(vlan_interfaces) if vlan_interfaces else '-'}") + if not self.confirm("Write these entries to host_vars?", default=False): + self.message("Cancelled. Nothing was changed.") + self.pause() + return + + host_vars = self.sophos.apply( + customer, + host.fqdn, + hostname=hostname, + network_objects=network_objects, + vlan_interfaces=vlan_interfaces, + ) + self.message(f"[green]Sophos XGS host configuration updated.[/green]\n{host_vars}") + self.pause() + + + def view_inventory(self, customer: str, path: Path) -> None: + self.view_text('Hosts > hosts.yml', path.read_text(encoding='utf-8')) + + + def remove_host(self, customer: str, path: Path) -> None: + host = self.pick_host(customer, path) + if not host: + return + self.ui.review('Remove host', [('Host', host.fqdn), ('Inventory', path), + ('Also delete', path.parent / 'host_vars' / host.fqdn)], + warning='The hosts.yml recovery backup does not restore deleted host_vars. Save any needed host-specific files separately.') + if not self.confirm('Remove this host and its host variables?', default=False): + return + document = InventoryDocument(path) + changes = InventoryEditor(document, customer).remove_host(host.fqdn) + self.writer.commit(document, customer=customer) + removed_vars = self.customers.remove_host_vars(customer, host.fqdn) + self.ui.result('Host removed; inventory YAML validation passed.', title='Host removed') + if removed_vars: + self.ui.result('The related host_vars directory was also removed.', level='info') + self.pause() + diff --git a/scripts/src/aim/ui/playbooks.py b/scripts/src/aim/ui/playbooks.py new file mode 100644 index 0000000..7516f6c --- /dev/null +++ b/scripts/src/aim/ui/playbooks.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from pathlib import Path + + +from aim.inventory.hosts import InventoryEditor +from aim.inventory.loader import InventoryDocument +from aim.inventory.model import Host +from aim.playbooks.manager import PlaybookSpec + + +from aim.ui.components import MenuItem, text +from aim.ui.selection import Choice + +class PlaybookScreens: + def playbooks_menu(self, customer: str, inventory_path: Path) -> None: + categories = self.playbooks.categories() + while True: + self.clear() + self.header(self.breadcrumb(customer, 'Playbooks'), f'Playbook directory: {self.playbooks.root}') + items = [] + for i, category in enumerate(categories, 1): + specs = self.playbooks.specs_for_category(category) + count = sum(self.playbooks.available(s, customer) for s in specs) + items.append(MenuItem(str(i), category, f'{count}/{len(specs)} playbooks available')) + choice = self.menu(items) + if choice == '0': + return + self.call(self.playbook_category_menu, customer, inventory_path, categories[int(choice)-1]) + + + def playbook_category_menu(self, customer: str, inventory_path: Path, category: str) -> None: + specs = self.playbooks.specs_for_category(category) + while True: + self.clear() + self.header(self.breadcrumb(customer, f'Playbooks > {category}')) + offset = 2 if category == 'Sophos XGS' else 1 + rows = [] + if offset == 2: + rows.append(('1', 'Prepare host configuration', 'Host variables', 'Available')) + for i, spec in enumerate(specs, offset): + state = text('Available', 'aim.success') if self.playbooks.available(spec, customer) else text('File missing', 'aim.warning') + rows.append((str(i), spec.name, ', '.join(spec.platforms), state)) + self.ui.table(['#', 'Action', 'Scope / type', 'Status'], rows) + if category == 'Sophos XGS': + self.ui.result('Prepare variables, run the initial baseline, then apply the customer override. Playbooks are not chained automatically.', level='info') + self.message('0 Back') + self.ui.footer('menu') + raw = self.menu_choice([str(i) for i in range(len(specs) + offset)]) + if raw == '0': + return + if offset == 2 and raw == '1': + self.call(self.configure_sophos, customer, inventory_path) + else: + self.call(self.run_playbook_flow, customer, inventory_path, specs[int(raw)-offset]) + + + def compatible_hosts(self, customer: str, inventory_path: Path, spec: PlaybookSpec) -> list[Host]: + hosts = InventoryEditor(InventoryDocument(inventory_path), customer).list_hosts() + allowed = set(spec.platforms) + return [host for host in hosts if allowed.intersection(host.memberships)] + + + def compatible_group_limits(self, customer: str, inventory_path: Path, spec: PlaybookSpec) -> list[tuple[str, str]]: + hosts = self.compatible_hosts(customer, inventory_path, spec) + allowed = set(spec.platforms) + labels: dict[str, str] = {} + for host in hosts: + for group, membership in host.memberships.items(): + if group not in allowed: + continue + labels[group] = group + for subgroup in membership.subgroups: + if "/" in subgroup: + continue + labels[f"{group} / {subgroup}"] = subgroup + return sorted(labels.items(), key=lambda item: item[0].lower()) + + + def _pick_playbook_host(self, customer: str, inventory_path: Path, spec: PlaybookSpec) -> Host | None: + return self.pick_one_host(self.compatible_hosts(customer, inventory_path, spec)) + + + def _pick_playbook_group(self, customer: str, inventory_path: Path, spec: PlaybookSpec): + groups = self.compatible_group_limits(customer, inventory_path, spec) + return self.selector.select('Select group', ['Group'], [Choice(g, (g[0],), (g[0],)) for g in groups]) + + + def run_playbook_flow(self, customer: str, inventory_path: Path, spec: PlaybookSpec) -> None: + playbook_path = self.playbooks.path(spec, customer) + if not playbook_path.is_file(): + self.ui.result(f'This playbook is not installed: {playbook_path}', level='warning', title='Playbook unavailable') + self.pause() + return + targets, label = self.select_targets(customer, inventory_path, spec.platforms, title=f'Playbooks > {spec.category} > {spec.name}') + if not targets: + return + limit = ':'.join(host.fqdn for host in targets) + self.clear() + self.header(self.breadcrumb(customer, f'Playbooks > {spec.category} > Review')) + vault = inventory_path.parent / 'group_vars/all/vault.yml' + rows = [('Playbook', spec.name), ('File', playbook_path), ('Inventory', inventory_path), + ('Selection', label), ('Targets', '\n'.join(h.fqdn for h in targets)), + ('Vault', f'{customer}@prompt' if vault.is_file() else 'Not present'), + ('Connection password', 'Prompted by Ansible (--ask-pass)' if spec.ask_pass else 'Inventory / Vault')] + if spec.extra_vars: + rows.append(('Extra variables', ', '.join(spec.extra_vars))) + self.ui.review('Run playbook', rows, warning='The playbook may change the selected hosts. No other hosts are included in --limit.') + self.ui.footer('review') + if not self.confirm('Run this playbook?', default=False): + return + # Do not unlock a Linux key for a Windows-only target selection. + if any('linux' in h.memberships for h in targets) and self.ssh.status(customer).complete and vault.is_file(): + if self.ssh.ensure_key_loaded(customer): + self.ui.result('Customer SSH key loaded into ssh-agent.') + self.ui.result('Playbook output remains live so native password prompts, pauses and diagnostics remain usable.', level='info') + self.playbooks.run(customer, inventory_path, spec, limit=limit) + self.ui.result(f'{spec.name} completed for the selected scope.', title='Playbook finished') + self.pause() + diff --git a/scripts/src/aim/ui/selection.py b/scripts/src/aim/ui/selection.py new file mode 100644 index 0000000..1b043fe --- /dev/null +++ b/scripts/src/aim/ui/selection.py @@ -0,0 +1,87 @@ +"""One selection model for customers, hosts, groups, files and AD OUs.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Generic, Sequence, TypeVar + +from rich.prompt import Prompt +from aim.exceptions import OperationCancelled +from aim.ui.components import OperatorUI, text + +T = TypeVar('T') + + +@dataclass(frozen=True) +class Choice(Generic[T]): + value: T + cells: tuple[str, ...] + aliases: tuple[str, ...] = () + + +class Selector: + def __init__(self, ui: OperatorUI, draw_header): + self.ui = ui + self.draw_header = draw_header + + def select(self, title: str, columns: Sequence[str], choices: Sequence[Choice[T]], + *, multiple: bool = False) -> list[T] | T | None: + if not choices: + self.ui.result('No matching items. Nothing was selected.', level='info') + return [] if multiple else None + selected: set[int] = set() + page = 0 + query = '' + feedback = '' + while True: + indices = [i for i, choice in enumerate(choices) if query.casefold() in ' '.join(choice.cells).casefold()] + # Keep stable indices even across filtering and pages. + page_size = max(3, min(12, (self.ui.console.height - 14) // 2)) + pages = max(1, (len(indices) + page_size - 1) // page_size) + page = min(page, pages - 1) + visible = indices[page * page_size:(page + 1) * page_size] + self.ui.clear() + self.draw_header(title) + rows = [] + for i in visible: + mark = text('[x]' if self.ui.ascii else '[\u2713]', 'aim.success') if i in selected else text('[ ]', 'aim.muted') + row = [mark, str(i + 1), *choices[i].cells] if multiple else [str(i + 1), *choices[i].cells] + rows.append(row) + self.ui.table((['Selected', '#'] if multiple else ['#']) + list(columns), rows) + status = f'Page {page + 1}/{pages} | {len(indices)} matching / {len(choices)} total' + if multiple: + status += f' | Selected: {len(selected)}' + if query: + status += f' | Filter: {query}' + self.ui.console.print(text(status, 'aim.muted')) + if feedback: + self.ui.result(feedback, level='warning') + self.ui.footer('multi' if multiple else 'select', extra='/' + ' Clear filter' if query else '') + try: + raw = Prompt.ask('Select', console=self.ui.console, default='', show_default=False).strip() + except KeyboardInterrupt: + raise OperationCancelled('Selection cancelled.') from None + feedback = '' + if raw == '0': + return [] if multiple else None + if not raw: + if not multiple: + return None + return [choices[i].value for i in sorted(selected)] + if raw.startswith('/'): + query, page = raw[1:].strip(), 0 + continue + if raw.casefold() in {'n', 'p'}: + page = max(0, min(pages - 1, page + (1 if raw.casefold() == 'n' else -1))) + continue + index = int(raw) - 1 if raw.isdigit() else -1 + if not raw.isdigit(): + matches = [i for i, choice in enumerate(choices) if raw in choice.aliases] + if len(matches) == 1: + index = matches[0] + if index not in indices: + feedback = 'Use a listed number, an exact name, or /text to filter.' + continue + if multiple: + selected.symmetric_difference_update({index}) + else: + return choices[index].value diff --git a/scripts/src/aim/ui/targets.py b/scripts/src/aim/ui/targets.py new file mode 100644 index 0000000..52c9b16 --- /dev/null +++ b/scripts/src/aim/ui/targets.py @@ -0,0 +1,79 @@ +"""Reusable, explicit target selection; group paths only build candidate sets.""" +from __future__ import annotations + +from pathlib import Path +from aim.inventory.hosts import InventoryEditor +from aim.inventory.loader import InventoryDocument +from aim.inventory.model import Host +from aim.ui.components import MenuItem +from aim.ui.selection import Choice + + +class TargetScreens: + def inventory_hosts(self, customer: str, path: Path, platforms=(), *, require_address=False) -> list[Host]: + hosts = InventoryEditor(InventoryDocument(path), customer).list_hosts() + hosts = sorted(hosts, key=lambda h: h.fqdn.casefold()) + if platforms: + hosts = [h for h in hosts if set(platforms).intersection(h.memberships)] + if require_address: + hosts = [h for h in hosts if h.ansible_host and h.ansible_host != 'CONFLICT'] + return hosts + + @staticmethod + def host_choices(hosts: list[Host]): + return [Choice(h, (h.fqdn, h.ansible_host or '-', ', '.join(h.membership_labels()) or '-'), (h.fqdn,)) for h in hosts] + + def pick_multiple_hosts(self, hosts: list[Host], title: str = 'Select hosts') -> list[Host]: + chosen = self.selector.select(title, ['Host', 'Address', 'Memberships'], self.host_choices(hosts), multiple=True) + if not chosen: + return [] + self.ui.review('Review selection', [('Hosts', '\n'.join(h.fqdn for h in chosen)), ('Count', len(chosen))]) + return chosen if self.confirm('Use this selection?', default=False) else [] + + def pick_one_host(self, hosts: list[Host], title: str = 'Select host') -> Host | None: + return self.selector.select(title, ['Host', 'Address', 'Memberships'], self.host_choices(hosts)) + + def select_targets(self, customer: str, path: Path, platforms=(), *, title='Select targets', require_address=False): + candidates = self.inventory_hosts(customer, path, platforms, require_address=require_address) + if not candidates: + self.ui.result('No compatible hosts with usable inventory data.', level='warning') + return [], '' + while True: + self.clear() + self.header(self.breadcrumb(customer, title), 'Platforms: ' + (', '.join(platforms) or 'all')) + scope = self.menu([ + MenuItem('1', 'All compatible hosts', f'{len(candidates)} hosts; reviewed before execution'), + MenuItem('2', 'From a group or subgroup', 'Filter first, then select hosts'), + MenuItem('3', 'Select multiple hosts', 'Toggle one number at a time'), + MenuItem('4', 'Select one host', 'Single target'), + ], back='Cancel') + if scope == '0': + return [], '' + if scope == '1': + return candidates, f'All compatible hosts ({len(candidates)})' + if scope == '3': + chosen = self.pick_multiple_hosts(candidates, title) + return chosen, f'{len(chosen)} selected hosts' + if scope == '4': + chosen = self.pick_one_host(candidates, title) + return ([chosen], chosen.fqdn) if chosen else ([], '') + groups = {} + for h in candidates: + for platform, membership in h.memberships.items(): + if platforms and platform not in platforms: + continue + groups.setdefault((platform, ''), []).append(h) + for sub in membership.subgroups: + groups.setdefault((platform, sub), []).append(h) + group_choices = [Choice(pair, (f'{pair[0]} / {pair[1]}' if pair[1] else pair[0], str(len(hosts)))) + for pair, hosts in sorted(groups.items())] + pair = self.selector.select('Select group or subgroup', ['Group path', 'Hosts'], group_choices) + if pair is None: + continue + label = f'{pair[0]} / {pair[1]}' if pair[1] else pair[0] + chosen = self.pick_multiple_hosts(groups[pair], f'Select hosts from {label}') + return chosen, f'{label} ({len(chosen)} selected)' + + def pick_windows_member_targets(self, customer: str, inventory_path: Path): + return self.select_targets(customer, inventory_path, ('windows',), + title='Enable member-server access', require_address=True) diff --git a/scripts/src/aim/variables/__init__.py b/scripts/src/aim/variables/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/src/aim/variables/viewer.py b/scripts/src/aim/variables/viewer.py new file mode 100644 index 0000000..e7bef19 --- /dev/null +++ b/scripts/src/aim/variables/viewer.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from pathlib import Path + + +class VariableViewer: + def __init__(self, customers): + self.customers = customers + + def files(self, customer: str, kind: str) -> list[Path]: + if kind not in {"group_vars", "host_vars"}: + raise ValueError("Unsupported variable directory") + root = self.customers.require(customer) / kind + if not root.exists(): + return [] + return sorted(p for p in root.rglob("*.yml") if p.is_file()) + + def relative(self, customer: str, path: Path) -> Path: + return path.relative_to(self.customers.require(customer)) diff --git a/scripts/src/aim/vault/__init__.py b/scripts/src/aim/vault/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/src/aim/vault/manager.py b/scripts/src/aim/vault/manager.py new file mode 100644 index 0000000..7433d8e --- /dev/null +++ b/scripts/src/aim/vault/manager.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from dataclasses import dataclass +import os +import subprocess +from pathlib import Path + +from ruamel.yaml import YAML +from ruamel.yaml.scalarstring import DoubleQuotedScalarString + +from aim.exceptions import ExternalCommandFailed +from aim.external import command_available, run_external, stable_cwd +from aim.permissions import apply_managed_permissions +from aim.templates import VAULT_TEMPLATE_SECTIONS +from aim.structure import vault_structure_from_text + + +@dataclass(frozen=True) +class VaultStatus: + path: Path + exists: bool + + +class VaultManager: + def __init__(self, customers): + self.customers = customers + + def path(self, customer: str) -> Path: + return self.customers.require(customer) / "group_vars/all/vault.yml" + + def status(self, customer: str) -> VaultStatus: + path = self.path(customer) + return VaultStatus(path, path.is_file()) + + def _require_command(self) -> None: + if not command_available("ansible-vault"): + raise ExternalCommandFailed("Required command not found: ansible-vault") + + @staticmethod + def template_data(values: dict[str, str] | None = None) -> dict[str, str]: + values = values or {} + # Secrets are always emitted as quoted YAML strings. This prevents values + # such as true, null, numeric-only passwords, leading # characters, or + # values containing YAML punctuation from changing type or syntax. + return { + "vault_windows_ansible_password": DoubleQuotedScalarString(values.get("vault_windows_ansible_password", "")), + "vault_windows_local_ansible_password": DoubleQuotedScalarString(values.get("vault_windows_local_ansible_password", "")), + "ansible_vault_linux_ssh_pass": DoubleQuotedScalarString(values.get("ansible_vault_linux_ssh_pass", "")), + "vault_linux_ssh_key_passphrase": DoubleQuotedScalarString(values.get("vault_linux_ssh_key_passphrase", "")), + } + + def create(self, customer: str, values: dict[str, str] | None = None) -> bool: + import tempfile + path = self.path(customer) + if path.exists(): + return False + self._require_command() + path.parent.mkdir(parents=True, exist_ok=True) + yaml = YAML() + data = self.template_data(values) + with tempfile.TemporaryDirectory(prefix=".aim-vault-", dir=path.parent) as directory: + tmp = Path(directory) / "vault.yml" + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as stream: + for comment, key in VAULT_TEMPLATE_SECTIONS: + stream.write(comment + "\n") + if key: + yaml.dump({key: data[key]}, stream) + stream.write("\n") + with tmp.open(encoding="utf-8") as stream: + if not isinstance(YAML(typ="safe").load(stream), dict): + raise ValueError("Vault template must be a YAML mapping") + result = run_external(["ansible-vault", "encrypt", "--vault-id", f"{customer}@prompt", str(tmp)], + cwd=self.customers.require(customer)) + if result.returncode or not tmp.read_bytes().startswith(b"$ANSIBLE_VAULT;"): + raise ExternalCommandFailed("Vault encryption failed; no plaintext Vault was published") + apply_managed_permissions(tmp, self.customers.config.required_group) + try: + # Link, rather than replace, makes concurrent creation non-destructive. + os.link(tmp, path) + except FileExistsError: + return False + return True + + def view_data(self, customer: str) -> dict: + path = self.path(customer) + if not path.exists(): + raise FileNotFoundError(path) + self._require_command() + text = self.view_text(customer) + data = YAML(typ="safe").load(text) or {} + if not isinstance(data, dict): + raise ValueError("Vault content must be a YAML mapping") + return data + + def get_value(self, customer: str, key: str) -> str: + value = self.view_data(customer).get(key, "") + return str(value or "") + + + def view_text(self, customer: str) -> str: + path = self.path(customer) + if not path.exists(): + raise FileNotFoundError(path) + self._require_command() + result = run_external( + ["ansible-vault", "view", "--vault-id", f"{customer}@prompt", str(path)], + cwd=self.customers.require(customer), capture_stdout=True, + ) + if result.returncode: + raise ExternalCommandFailed("Vault read failed") + return result.stdout + + def consolidate_template(self, customer: str) -> bool: + """Add missing AIM template keys/comments while preserving all existing values/custom keys.""" + path = self.path(customer) + if not path.exists(): + raise FileNotFoundError(path) + self._require_command() + original = path.read_bytes() + text = self.view_text(customer) + yaml = YAML() + yaml.preserve_quotes = True + data = yaml.load(text) or {} + if not isinstance(data, dict): + raise ValueError("Vault content must be a YAML mapping") + changed = False + for _, key in VAULT_TEMPLATE_SECTIONS: + if key and key not in data: + data[key] = DoubleQuotedScalarString("") + changed = True + # Rebuild with canonical comments while preserving all existing values/custom keys. + canonical_comments = [comment for comment, _ in VAULT_TEMPLATE_SECTIONS] + if not all(comment in text for comment in canonical_comments): + changed = True + if not changed: + return False + fd, tmp_name = __import__('tempfile').mkstemp(prefix='.aim-vault-', suffix='.yml', dir=str(path.parent)) + os.fchmod(fd, 0o600) + tmp = Path(tmp_name) + try: + with os.fdopen(fd, 'w', encoding='utf-8') as stream: + emitted = set() + for comment, key in VAULT_TEMPLATE_SECTIONS: + stream.write(comment + "\n") + if key: + yaml.dump({key: data[key]}, stream) + emitted.add(key) + stream.write("\n") + for key, value in data.items(): + if key in emitted: + continue + yaml.dump({key: value}, stream) + result = run_external( + ["ansible-vault", "encrypt", "--vault-id", f"{customer}@prompt", str(tmp)], + cwd=self.customers.require(customer), + ) + if result.returncode: + raise ExternalCommandFailed("Vault encryption failed during consolidation") + if not tmp.read_bytes().startswith(b"$ANSIBLE_VAULT;"): + raise ExternalCommandFailed("Encryption did not produce a Vault document") + if path.read_bytes() != original: + raise ExternalCommandFailed("Vault changed externally; consolidation was not applied") + os.replace(tmp, path) + apply_managed_permissions(path, self.customers.config.required_group) + return True + finally: + tmp.unlink(missing_ok=True) + + def edit(self, customer: str) -> None: + path = self.path(customer) + if not path.exists(): + raise FileNotFoundError(path) + self._require_command() + env = os.environ.copy() + env.setdefault("EDITOR", "nano") + env.setdefault("VISUAL", env["EDITOR"]) + result = run_external( + ["ansible-vault", "edit", "--vault-id", f"{customer}@prompt", str(path)], + cwd=self.customers.require(customer), + env=env, + ) + if result.returncode: + raise ExternalCommandFailed("Vault edit failed") + apply_managed_permissions(path, self.customers.config.required_group) + + def delete(self, customer: str) -> None: + path = self.path(customer) + if not path.exists(): + raise FileNotFoundError(path) + path.unlink() diff --git a/scripts/src/aim/winrm/__init__.py b/scripts/src/aim/winrm/__init__.py new file mode 100644 index 0000000..422a039 --- /dev/null +++ b/scripts/src/aim/winrm/__init__.py @@ -0,0 +1 @@ +"""Windows WinRM access management.""" diff --git a/scripts/src/aim/winrm/manager.py b/scripts/src/aim/winrm/manager.py new file mode 100644 index 0000000..a5d2f4b --- /dev/null +++ b/scripts/src/aim/winrm/manager.py @@ -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 ''}" + ) + 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 + )