diff --git a/attack_range/managers/ansible_manager.py b/attack_range/managers/ansible_manager.py index 1ad8c08d..96acd821 100644 --- a/attack_range/managers/ansible_manager.py +++ b/attack_range/managers/ansible_manager.py @@ -1096,42 +1096,67 @@ def _get_required_roles_for_playbook(self, playbook_path: str) -> list: return list(set(required_roles)) # Return unique roles + def _roles_install_path(self) -> str: + """Directory where ansible-galaxy installs roles for this attack range.""" + return os.path.join(self.ansible_dir, "roles") + + def _get_local_role_overrides(self) -> dict[str, str]: + """ + Parse ATTACK_RANGE_LOCAL_ROLES env var (JSON map of galaxy role name -> local path). + + :return: Dict of role name to expanded local filesystem path + """ + raw = os.environ.get("ATTACK_RANGE_LOCAL_ROLES", "").strip() + if not raw: + return {} + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + self.logger.warning( + "ATTACK_RANGE_LOCAL_ROLES is not valid JSON; ignoring local role overrides" + ) + return {} + if not isinstance(parsed, dict): + self.logger.warning( + "ATTACK_RANGE_LOCAL_ROLES must be a JSON object; ignoring local role overrides" + ) + return {} + overrides: dict[str, str] = {} + for key, value in parsed.items(): + if isinstance(key, str) and isinstance(value, str) and key and value: + overrides[key] = os.path.expanduser(value) + return overrides + + def _resolve_role_dir(self, role_name: str) -> Optional[str]: + """ + Return the installed role directory for a Galaxy role name, if present. + + Checks terraform/ansible/roles and ~/.ansible/roles using dot and underscore + directory name variants. + """ + role_dir_underscore = role_name.replace(".", "_") + role_dir_dot = role_name + search_bases = [ + self._roles_install_path(), + os.path.expanduser("~/.ansible/roles"), + ] + for base in search_bases: + if not os.path.isdir(base): + continue + for variant in (role_dir_dot, role_dir_underscore): + candidate = os.path.join(base, variant) + if os.path.isdir(candidate): + return candidate + return None + def _is_role_installed(self, role_name: str) -> bool: """ Check if an Ansible Galaxy role is installed. - + :param role_name: Name of the role (e.g., 'p4t12ick.ar_wireguard_vpn') :return: True if role is installed, False otherwise """ - # Ansible Galaxy installs roles in roles/ directory with format: namespace.rolename - # The directory name is the role name with dots replaced or kept depending on version - roles_dir = os.path.join(self.ansible_dir, "roles") - - # Check if roles directory exists - if not os.path.exists(roles_dir): - return False - - # Try different possible directory names - # Format 1: namespace_rolename (dots replaced with underscores) - role_dir_underscore = role_name.replace('.', '_') - # Format 2: namespace.rolename (dots kept) - role_dir_dot = role_name - - # Check both formats - if os.path.exists(os.path.join(roles_dir, role_dir_underscore)): - return True - if os.path.exists(os.path.join(roles_dir, role_dir_dot)): - return True - - # Also check in ~/.ansible/roles (default location) - home_roles_dir = os.path.expanduser("~/.ansible/roles") - if os.path.exists(home_roles_dir): - if os.path.exists(os.path.join(home_roles_dir, role_dir_underscore)): - return True - if os.path.exists(os.path.join(home_roles_dir, role_dir_dot)): - return True - - return False + return self._resolve_role_dir(role_name) is not None def _patch_wireguard_allowed_ips(self) -> None: """ @@ -1139,7 +1164,10 @@ def _patch_wireguard_allowed_ips(self) -> None: can reach 10.0.2.* (e.g. Splunk). Fixes 10.0.1.1/24, 10.0.2.1/24 -> 10.0.1.0/24, 10.0.2.0/24. Idempotent: no-op if already correct. """ - path = os.path.expanduser("~/.ansible/roles/p4t12ick.ar_wireguard_vpn/templates/client.j2") + role_dir = self._resolve_role_dir(WIREGUARD_GALAXY_ROLE) + if not role_dir: + return + path = os.path.join(role_dir, "templates", "client.j2") if not os.path.exists(path): return try: @@ -1160,7 +1188,10 @@ def _patch_wireguard_server_config(self) -> None: client1 and shared clients can reach 10.0.2.*. PreDown for FORWARD uses || true so down does not abort if the rule is missing. Idempotent. """ - path = os.path.expanduser("~/.ansible/roles/p4t12ick.ar_wireguard_vpn/templates/wg0.j2") + role_dir = self._resolve_role_dir(WIREGUARD_GALAXY_ROLE) + if not role_dir: + return + path = os.path.join(role_dir, "templates", "wg0.j2") if not os.path.exists(path): return # Target: SaveConfig=false, NAT, FORWARD. FORWARD PreDown uses || true to avoid abort when rule missing. @@ -1223,29 +1254,49 @@ def install_ansible_galaxy_role(self, role_name: str, force: bool = True, max_re """ Install a specific Ansible Galaxy role with retry logic for transient SSL/network errors. + When ATTACK_RANGE_LOCAL_ROLES maps role_name to a local path, installs from that + path instead of Ansible Galaxy. + :param role_name: Name of the role to install (e.g., 'p4t12ick.ar_wireguard_vpn') :param force: If True, pass --force to overwrite existing; if False, skip when already installed. :param max_retries: Maximum number of retry attempts for transient errors (default: 3) :return: True if installation succeeded, False otherwise """ + local_overrides = self._get_local_role_overrides() + local_path = local_overrides.get(role_name) + if local_path is not None: + if not os.path.isdir(local_path): + self.logger.error( + f"Local role path for '{role_name}' does not exist or is not a directory: {local_path}" + ) + return False + install_target = f"{local_path},{role_name}" + self.logger.info( + f"Installing role '{role_name}' from local path '{local_path}' (ATTACK_RANGE_LOCAL_ROLES)" + ) + max_retries = 1 + else: + install_target = role_name + cwd = os.getcwd() + roles_path = self._roles_install_path() try: os.chdir(self.ansible_dir) - - cmd = ["ansible-galaxy", "install", role_name] + + cmd = ["ansible-galaxy", "install", install_target, "-p", roles_path] if force: cmd.append("--force") - - # Retry logic for transient SSL/network errors + + # Retry logic for transient SSL/network errors (Galaxy downloads only) for attempt in range(max_retries): if attempt > 0: # Exponential backoff: 2^attempt seconds (2, 4, 8 seconds) wait_time = 2 ** attempt self.logger.warning(f"Retrying installation of role '{role_name}' (attempt {attempt + 1}/{max_retries}) after {wait_time} seconds...") time.sleep(wait_time) - else: + elif local_path is None: self.logger.info(f"Installing role: {role_name}") - + result = subprocess.run( cmd, capture_output=True, @@ -1258,14 +1309,14 @@ def install_ansible_galaxy_role(self, role_name: str, force: bool = True, max_re if result.stdout: self.logger.debug(result.stdout) return True - + # Check if this is a transient SSL/network error that might benefit from retry error_output = result.stderr.lower() if result.stderr else "" is_transient_error = any(keyword in error_output for keyword in [ - "ssl", "unexpected_eof", "eof occurred", "connection", + "ssl", "unexpected_eof", "eof occurred", "connection", "timeout", "temporary failure", "network", "urlopen error" ]) - + if is_transient_error and attempt < max_retries - 1: # Log warning but continue to retry self.logger.warning(f"Transient error installing role '{role_name}' (attempt {attempt + 1}/{max_retries}): {result.stderr[:200]}") @@ -1276,7 +1327,7 @@ def install_ansible_galaxy_role(self, role_name: str, force: bool = True, max_re if result.stdout: self.logger.error(f"stdout: {result.stdout}") return False - + # Should not reach here, but just in case return False finally: diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 00000000..0bd26d2d --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,8 @@ +# Optional: use local Ansible role checkouts instead of Ansible Galaxy during development. +# Keys must match the Galaxy role name used in templates exactly. +# +# Example (also add a volume mount in docker-compose.yml or docker-compose.override.yml): +# volumes: +# - ~/projects/ludus_ar_splunk:/local_roles/ludus_ar_splunk:ro +# +# ATTACK_RANGE_LOCAL_ROLES={"P4T12ICK.ludus_ar_splunk": "/local_roles/ludus_ar_splunk"} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index aee152c6..272cfade 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -13,10 +13,14 @@ services: - ../ssh_keys:/attack_range/ssh_keys # Mount apps directory - playbooks may need to install files from apps folder - ../apps:/attack_range/apps + # Local role dev: mount a host role checkout, then set ATTACK_RANGE_LOCAL_ROLES (see docker/.env.example) + # - ~/projects/ludus_ar_splunk:/local_roles/ludus_ar_splunk:ro # Mount all cloud provider credentials - ${HOME}/.aws:/root/.aws - ${HOME}/.azure:/root/.azure - ${HOME}/.config/gcloud:/root/.config/gcloud + environment: + - ATTACK_RANGE_LOCAL_ROLES=${ATTACK_RANGE_LOCAL_ROLES:-} entrypoint: ["python3.12", "attack_range.py"] networks: - attack-range-network @@ -35,6 +39,8 @@ services: - ../templates:/app/templates:ro # Mount apps directory - playbooks may need to install files from apps folder - ../apps:/app/apps + # Local role dev: mount a host role checkout, then set ATTACK_RANGE_LOCAL_ROLES (see docker/.env.example) + # - ~/projects/ludus_ar_splunk:/local_roles/ludus_ar_splunk:ro # Mount all cloud provider credentials - ${HOME}/.aws:/root/.aws:ro # Azure CLI needs write access for session files, so mount as writable @@ -45,6 +51,7 @@ services: - FLASK_ENV=production - FLASK_DEBUG=0 - OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES + - ATTACK_RANGE_LOCAL_ROLES=${ATTACK_RANGE_LOCAL_ROLES:-} restart: unless-stopped networks: - attack-range-network diff --git a/docs/source/ansible-roles.md b/docs/source/ansible-roles.md index 23e9bd94..39ed9a27 100644 --- a/docs/source/ansible-roles.md +++ b/docs/source/ansible-roles.md @@ -71,9 +71,46 @@ At build time, Attack Range will install every listed Galaxy role and run them o This is why **any** Galaxy role can define your configuration: if it’s in the template’s **roles** list, it gets installed and executed. +## Local development with a role checkout + +When developing or testing an Ansible role locally, you can point Attack Range at a directory on disk instead of downloading from Ansible Galaxy. Templates still use the normal Galaxy role name (e.g. `P4T12ICK.ludus_ar_splunk`); only the install source changes. + +### `ATTACK_RANGE_LOCAL_ROLES` + +Set this environment variable to a JSON object mapping Galaxy role names to **local filesystem paths** (paths must exist where Attack Range runs): + +```bash +export ATTACK_RANGE_LOCAL_ROLES='{"P4T12ICK.ludus_ar_splunk": "/local_roles/ludus_ar_splunk"}' +``` + +Keys must match the role name in your template exactly. During build, overridden roles are installed with `ansible-galaxy install ,` instead of downloading from Galaxy. Each build uses `--force`, so edits to your mounted checkout are picked up on the next run. + +### Docker Compose workflow + +1. Clone your role repo on the host (e.g. `~/projects/ludus_ar_splunk`). +2. Mount it into the CLI or API container and set `ATTACK_RANGE_LOCAL_ROLES`. Example volume in `docker/docker-compose.yml` or a `docker-compose.override.yml`: + + ```yaml + volumes: + - ~/projects/ludus_ar_splunk:/local_roles/ludus_ar_splunk:ro + environment: + - ATTACK_RANGE_LOCAL_ROLES={"P4T12ICK.ludus_ar_splunk": "/local_roles/ludus_ar_splunk"} + ``` + + Paths in `ATTACK_RANGE_LOCAL_ROLES` are **container paths** (`/local_roles/...`), not host paths. See `docker/.env.example` for a template. + +3. Build as usual: + + ```bash + docker compose --profile cli -f docker/docker-compose.yml run --rm attack_range build -t aws/splunk_minimal_aws + ``` + +Roles not listed in `ATTACK_RANGE_LOCAL_ROLES` continue to install from Ansible Galaxy. + ## Summary - **Templates** define which Ansible roles run on which servers via the **roles** list under each server in `attack_range`. - **Any Ansible Galaxy role** can be used: list it by name (e.g. `namespace.role_name`). Attack Range installs it and runs it at build time. +- For **local role development**, set `ATTACK_RANGE_LOCAL_ROLES` to map Galaxy names to local paths (see above). - Use **vars** (dict form) to pass variables to a role. Use **inventory_name** when the role expects a specific host group. - To define a new configuration, create or edit a template, add the desired Galaxy roles (and vars), and build from that template. No code changes are required. diff --git a/terraform/ansible/ansible.cfg b/terraform/ansible/ansible.cfg index 86cfd2fd..061bcc9d 100644 --- a/terraform/ansible/ansible.cfg +++ b/terraform/ansible/ansible.cfg @@ -1,2 +1,3 @@ [defaults] -host_key_checking = False \ No newline at end of file +host_key_checking = False +roles_path = ./roles:~/.ansible/roles \ No newline at end of file diff --git a/tests/test_ansible_manager_local_roles.py b/tests/test_ansible_manager_local_roles.py new file mode 100644 index 00000000..f1031dc6 --- /dev/null +++ b/tests/test_ansible_manager_local_roles.py @@ -0,0 +1,115 @@ +import json +import logging +import os +from unittest.mock import MagicMock, patch + +import pytest + +from attack_range.managers.ansible_manager import AnsibleManager + + +@pytest.fixture +def ansible_manager(tmp_path): + ansible_dir = tmp_path / "ansible" + ansible_dir.mkdir() + (ansible_dir / "roles").mkdir() + return AnsibleManager( + ansible_dir=str(ansible_dir), + inventory_path=str(ansible_dir / "inventory.yaml"), + config={}, + cloud_provider="aws", + logger=logging.getLogger("test"), + ) + + +class TestGetLocalRoleOverrides: + def test_empty_when_unset(self, ansible_manager, monkeypatch): + monkeypatch.delenv("ATTACK_RANGE_LOCAL_ROLES", raising=False) + assert ansible_manager._get_local_role_overrides() == {} + + def test_parses_valid_json(self, ansible_manager, monkeypatch): + monkeypatch.setenv( + "ATTACK_RANGE_LOCAL_ROLES", + json.dumps({"P4T12ICK.ludus_ar_splunk": "/local_roles/ludus_ar_splunk"}), + ) + assert ansible_manager._get_local_role_overrides() == { + "P4T12ICK.ludus_ar_splunk": "/local_roles/ludus_ar_splunk", + } + + def test_expands_tilde(self, ansible_manager, monkeypatch): + monkeypatch.setenv( + "ATTACK_RANGE_LOCAL_ROLES", + json.dumps({"ns.role": "~/my-role"}), + ) + assert ansible_manager._get_local_role_overrides() == { + "ns.role": os.path.expanduser("~/my-role"), + } + + def test_ignores_invalid_json(self, ansible_manager, monkeypatch): + monkeypatch.setenv("ATTACK_RANGE_LOCAL_ROLES", "not-json") + assert ansible_manager._get_local_role_overrides() == {} + + def test_ignores_non_object_json(self, ansible_manager, monkeypatch): + monkeypatch.setenv("ATTACK_RANGE_LOCAL_ROLES", '["a"]') + assert ansible_manager._get_local_role_overrides() == {} + + +class TestResolveRoleDir: + def test_finds_role_with_dots(self, ansible_manager): + role_path = os.path.join(ansible_manager._roles_install_path(), "ns.role") + os.makedirs(role_path) + assert ansible_manager._resolve_role_dir("ns.role") == role_path + + def test_finds_role_with_underscores(self, ansible_manager): + role_path = os.path.join(ansible_manager._roles_install_path(), "ns_role") + os.makedirs(role_path) + assert ansible_manager._resolve_role_dir("ns.role") == role_path + + def test_returns_none_when_missing(self, ansible_manager): + assert ansible_manager._resolve_role_dir("missing.role") is None + + +class TestInstallAnsibleGalaxyRole: + @patch("attack_range.managers.ansible_manager.subprocess.run") + def test_galaxy_install_uses_roles_path(self, mock_run, ansible_manager, monkeypatch): + monkeypatch.delenv("ATTACK_RANGE_LOCAL_ROLES", raising=False) + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + assert ansible_manager.install_ansible_galaxy_role("geerlingguy.nginx") is True + + cmd = mock_run.call_args[0][0] + roles_path = ansible_manager._roles_install_path() + assert cmd == [ + "ansible-galaxy", + "install", + "geerlingguy.nginx", + "-p", + roles_path, + "--force", + ] + + @patch("attack_range.managers.ansible_manager.subprocess.run") + def test_local_override_installs_from_path(self, mock_run, ansible_manager, monkeypatch, tmp_path): + local_role = tmp_path / "my_role" + local_role.mkdir() + monkeypatch.setenv( + "ATTACK_RANGE_LOCAL_ROLES", + json.dumps({"P4T12ICK.ludus_ar_splunk": str(local_role)}), + ) + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + assert ansible_manager.install_ansible_galaxy_role("P4T12ICK.ludus_ar_splunk") is True + + cmd = mock_run.call_args[0][0] + roles_path = ansible_manager._roles_install_path() + assert cmd[0:2] == ["ansible-galaxy", "install"] + assert cmd[2] == f"{local_role},P4T12ICK.ludus_ar_splunk" + assert cmd[3:5] == ["-p", roles_path] + assert "--force" in cmd + + def test_local_override_missing_path_returns_false(self, ansible_manager, monkeypatch): + monkeypatch.setenv( + "ATTACK_RANGE_LOCAL_ROLES", + json.dumps({"ns.role": "/does/not/exist"}), + ) + assert ansible_manager.install_ansible_galaxy_role("ns.role") is False