diff --git a/CLAUDE.md b/CLAUDE.md index 98aea3c..c9250c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,20 +38,23 @@ itential.iag5/ │ ├── ansible-lint.yml # CI: lint on push/PR to main │ └── publish_ansible_collection.yml # CI: publish to Galaxy on release ├── docs/ -│ ├── reference_guide.md # Full variable reference (100+ vars) -│ └── verify_cert_README.md # TLS verification guide +│ ├── reference_guide.md # Full variable reference (100+ vars) +│ ├── certify.md # Post-deployment TLS verification guide +│ └── verify.md # Pre-flight environment verification guide ├── example_inventories/ # Five reference inventory files ├── playbooks/ │ ├── site.yml # Meta playbook — imports all others in order │ ├── servers.yml │ ├── runners.yml │ ├── clients.yml -│ └── verify_cert.yml +│ ├── certify.yml # Post-deployment TLS verification (standalone) +│ └── verify.yml # Pre-flight checks — run before install or upgrade ├── roles/ │ ├── gateway/ # Common variables only (no tasks) │ ├── gateway_client/ # IAG5 client install + configure │ ├── gateway_server/ # IAG5 server/runner install + configure -│ └── verify_cert_common/ # Shared TLS verification task files +│ ├── certify_common/ # Shared TLS verification task files +│ └── verify_common/ # Shared pre-flight check task files └── scripts/ └── changelog.py # Generates CHANGELOG.md from git tags ``` @@ -76,16 +79,19 @@ Deploys server **or** runner depending on `gateway_application_mode`. Task execu 3. `install_python.yml` — optional Python 3.12 install (controlled by `gateway_server_features_python`) 4. `install_tofu.yml` — optional OpenTofu install (controlled by `gateway_server_features_opentofu`) 5. `upload_certs.yml` — uploads TLS cert/key and CA cert -6. `configure_gateway.yml` — renders `server.conf.j2` or `runner.conf.j2` to `/etc/gateway/gateway.conf` +6. `configure_gateway.yml` — renders `server.conf.j2` or `runner.conf.j2` to `/etc/gateway/gateway.conf`; also renders `iagctl.service.j2` to `/usr/lib/systemd/system/iagctl.service` and `iagctl.env.j2` to `/etc/gateway/iagctl.env` (mode 0600) when `gateway_server_store_backend == 'dynamodb'` or proxy is enabled 7. `configure_firewalld.yml` — opens ports in firewalld (optional) -8. `verify_cert.yml` — live TLS handshake tests post-deployment +8. `certify.yml` — live TLS handshake tests post-deployment Handler: `restart iagctl` — restarts the `iagctl` systemd service (4 retries, 5s delay, validates `ActiveState == "active"`). Defaults are split by domain: `install.yml`, `server.yml`, `store.yml`, `connect.yml`, `features.yml`, `registry.yml`, `pki.yml`, `secrets.yml`, `log.yml`, `runner.yml`, -`common.yml`, `terminal.yml`. +`common.yml`, `terminal.yml`, `specs.yml`. + +`specs.yml` defines minimum hardware requirements for servers and runners used by the +`verify` pre-flight check. ### gateway\_client @@ -95,19 +101,29 @@ Deploys the IAG5 CLI client. Task execution order: 2. `install_gateway_client.yml` — creates user/dirs, downloads + unpacks tarball 3. `upload_certs.yml` — uploads TLS material 4. `configure_gateway_client.yml` — renders `gateway.conf.j2` to `~/.gateway.d/gateway.conf` -5. `verify_cert.yml` +5. `certify.yml` Defaults split by domain: `install.yml`, `server.yml`, `pki.yml`, `secrets.yml`, `log.yml`, `terminal.yml`. -### verify\_cert\_common +### certify\_common + +Provides shared task files (not called directly) for post-deployment TLS certification: + +- `certify_cluster_server_to_runner.yml` +- `certify_cluster_client_to_server.yml` +- `certify_connect_server_to_gwm.yml` +- `summary.yml` (renders a Markdown report via `certify-report.md.j2`) + +### verify\_common -Provides shared task files (not called directly) for TLS verification: +Provides shared task files (not called directly) for pre-flight environment verification. +All TLS tasks run `delegate_to: localhost` since cert files reside on the control node. -- `verify_cert_cluster_server_to_runner.yml` -- `verify_cert_cluster_client_to_server.yml` -- `verify_cert_connect_server_to_gwm.yml` -- `summary.yml` (renders a Markdown report via `verify-cert-report.md.j2`) +- `verify-os.yml` — asserts RHEL/Rocky 8/9, x86\_64 +- `verify-specs.yml` — asserts CPU, RAM, and disk against documented minimums +- `verify-tls-files.yml` — 14 checks on TLS source files (existence, PEM validity, expiry, + cert/key match, CA chain, EKU, SANs) ## Running the Collection @@ -165,7 +181,7 @@ ansible-playbook itential.iag5.runners -i inventories/production ansible-playbook itential.iag5.clients -i inventories/production # Post-deployment TLS verification only -ansible-playbook itential.iag5.verify_cert -i inventories/production +ansible-playbook itential.iag5.certify -i inventories/production ``` ### Ansible Tags @@ -177,7 +193,7 @@ Tasks are tagged for selective execution: | `install` | Package download and installation | | `configure` | Configuration file rendering | | `upload_certs` | TLS certificate upload | -| `verify_cert` | Post-deployment TLS verification | +| `certify` | Post-deployment TLS verification | ```bash ansible-playbook itential.iag5.site -i inventories/production --tags configure @@ -243,6 +259,10 @@ The full variable reference (100+ variables with types, defaults, and descriptio - `gateway_server_features_python` — install Python (default: `false`) - `gateway_server_features_opentofu` — install OpenTofu (default: `false`) - `gateway_application_mode` — set by playbook: `server` or `runner` +- `gateway_server_connect_proxy_enabled` — enable outbound proxy for Gateway Manager connection (default: `false`); always writes `GATEWAY_CONNECT_PROXY_*` vars to `iagctl.env` +- `gateway_server_connect_proxy_url` — proxy URL; required when proxy is enabled +- `gateway_server_connect_proxy_username` / `gateway_server_connect_proxy_password` — optional proxy credentials +- `gateway_server_service_env_file` — path to the systemd environment file (default: `/etc/gateway/iagctl.env`) ## Key Conventions @@ -255,3 +275,6 @@ The full variable reference (100+ variables with types, defaults, and descriptio - **Backup on configure:** All `ansible.builtin.template` tasks set `backup: true`. - **Idempotent restarts:** The handler validates `ActiveState == "active"` before declaring success; it retries up to 4 times with a 5-second delay. +- **Sensitive env vars in env file:** DynamoDB credentials and proxy credentials are written to + `/etc/gateway/iagctl.env` (mode 0600) and loaded via `EnvironmentFile=` in the systemd unit, + keeping secrets out of the unit file and `systemctl show` output. diff --git a/README.md b/README.md index 792be54..c510732 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ 1. [Clients Playbook](#clients-playbook) 2. [Servers Playbook](#servers-playbook) 3. [Runners Playbook](#runners-playbook) + 4. [Verify Environment Playbook](#verify-playbook) + 5. [Certify IAG5 Playbook](#certify-iag5-playbook) + 6. [TLS Certification Playbook](#tls-certification-playbook) 6. [Sample Inventories](#sample-inventories) 1. [All-in-one Single Node Inventory](#all-in-one-single-node-inventory) 2. [All-in-one Active/Standby High Availability Inventory](#all-in-one-activestandby-high-availability-inventory) @@ -422,6 +425,19 @@ all: - ``` +### Verify the Environment + +Before running the installation, run the pre-flight verification playbook to confirm that all +target nodes meet OS, hardware, and TLS requirements: + +```bash +cd +ansible-playbook itential.iag5.verify -i inventories/ +``` + +All checks must pass before proceeding. See the [verify guide](docs/verify.md) +for a full description of checks and how to interpret failures. + ### Run the IAG5 Site Playbook Navigate to the working directory and execute the following run command. @@ -469,6 +485,80 @@ cd ansible-playbook itential.iag5.runners -i inventories/dev ``` +#### Verify Environment Playbook + +Verifies OS, hardware, and TLS file requirements across all node types before installation or +upgrade. Can be run at any time — does not require IAG5 to be installed. + +```bash +cd +ansible-playbook itential.iag5.verify -i inventories/dev +``` + +See the [verify guide](docs/verify.md) for full details. + +#### Certify IAG5 Playbook + +Runs a full post-deployment certification of the IAG5 installation. Run this playbook manually after +a deployment completes. It performs two certifications in sequence per node: + +1. **Installation certification** — forensic, read-only checks covering service state, config file + properties, TLS file existence and certificate metadata, runtime versions, host information, and + network connectivity (server→runner TCP, server→GWM TCP). +2. **TLS certification** — deep validation covering certificate chain, cert/key matching, SAN + validation, mTLS enforcement, proxy exclusion checks, and live TLS handshake tests. + +A markdown report is generated per node and fetched to `./certify_reports/` on the control node. + +```bash +cd +ansible-playbook itential.iag5.certify -i inventories/dev +``` + +##### Tag Reference + +Each certification can be run independently using tags: + +| Tag | What runs | +|-----|-----------| +| `certify-iag5` | Both installation + TLS certification (default) | +| `certify-install` | Installation certification only | +| `certify` | TLS certification only | + +```bash +# Installation certification only +ansible-playbook itential.iag5.certify -i inventories/dev --tags certify-install + +# TLS certification only +ansible-playbook itential.iag5.certify -i inventories/dev --tags certify +``` + +##### Report Output + +Reports are fetched to `./certify_reports/` relative to the directory where `ansible-playbook` is +run. Two report files are produced per node: + +| Report | Remote path | Control node filename | +|--------|-------------|----------------------| +| Installation | `/tmp/certify-iag5--report-.md` | `certify-iag5--report-.md` | +| TLS | `/tmp/certify-tls-.md` | `certify-tls-.md` | + +See the [certify guide](docs/certify-tls.md) for a full description of all checks and how to interpret +results. + +#### TLS Certification Playbook + +Runs deep TLS certificate validation across all node types without the installation forensics. +Validates certificate chains, cert/key matching, SANs, mTLS enforcement, and live handshake tests. +Can be run standalone at any time after deployment. + +```bash +cd +ansible-playbook itential.iag5.certify-tls -i inventories/dev +``` + +See the [certify guide](docs/certify-tls.md) for full details. + ## Sample Inventories Below are simplified sample host files that describe the basic configurations to produce the diff --git a/docs/verify_cert_README.md b/docs/certify-tls.md similarity index 83% rename from docs/verify_cert_README.md rename to docs/certify-tls.md index fffde4c..fcfd3fd 100644 --- a/docs/verify_cert_README.md +++ b/docs/certify-tls.md @@ -1,4 +1,4 @@ -# verify_cert — IAG5 TLS Certificate Verification +# certify-tls — IAG5 TLS Certificate Verification An Ansible playbook suite that verifies TLS certificate configuration across all IAG5 node types after deployment. Runs against live nodes, reads actual `gateway.conf` files, and performs live TLS handshakes to confirm that mTLS is working end-to-end — not just that files exist. @@ -34,32 +34,32 @@ IAG5 uses three distinct TLS connection paths, each with different certificate r > **EKU (Extended Key Usage)** — an X.509 certificate extension that defines the purposes the certificate may be used for. `serverAuth` allows the cert to authenticate a server; `clientAuth` allows it to authenticate a client. mTLS requires both on every node cert. -verify_cert covers all three paths. For each path it runs on both sides of the connection independently, so a misconfiguration on either end is caught. +certify covers all three paths. For each path it runs on both sides of the connection independently, so a misconfiguration on either end is caught. --- ## Architecture -verify_cert is colocated with the roles being verified. Each gateway role has a `tasks/verify_cert.yml` orchestrator that runs the appropriate check suites. A shared `verify_cert_common` role provides the check task files and report template. +certify is colocated with the roles being verified. Each gateway role has a `tasks/certify-tls.yml` orchestrator that runs the appropriate check suites. A shared `certify_common` role provides the check task files and report template. ``` itential.iag5/ ├── playbooks/ -│ ├── site.yml Main playbook — imports verify_cert at end -│ └── verify_cert.yml 2-play verify_cert playbook +│ ├── site.yml Main playbook — imports certify at end +│ └── certify-tls.yml 2-play certify playbook └── roles/ ├── gateway_server/ - │ └── tasks/verify_cert.yml Orchestrator — runs SUITE 1, 2, 3 conditionally + │ └── tasks/certify-tls.yml Orchestrator — runs SUITE 1, 2, 3 conditionally ├── gateway_client/ - │ └── tasks/verify_cert.yml Orchestrator — runs SUITE 1 (client side) - └── verify_cert_common/ + │ └── tasks/certify-tls.yml Orchestrator — runs SUITE 1 (client side) + └── certify_common/ ├── defaults/main.yml Report path defaults and os_ca_bundle ├── tasks/ │ ├── summary.yml Shared summary printer and report generator - │ ├── verify_cert_cluster_server_to_runner.yml 27 checks — server and runner nodes - │ ├── verify_cert_cluster_client_to_server.yml 26 checks — client and server nodes - │ └── verify_cert_connect_server_to_gwm.yml 20 checks — server → GWM - └── templates/verify-cert-report.md.j2 Markdown report template + │ ├── certify_cluster_server_to_runner.yml 27 checks — server and runner nodes + │ ├── certify_cluster_client_to_server.yml 26 checks — client and server nodes + │ └── certify_connect_server_to_gwm.yml 20 checks — server → GWM + └── templates/certify-report.md.j2 Markdown report template ``` ### How node identity is determined @@ -72,7 +72,7 @@ node_section: "{{ gateway_application_mode | default('server') }}" ### Test suite selection -The `gateway_server/tasks/verify_cert.yml` orchestrator targets `iag5_servers:iag5_runners` and selects suites automatically from inventory topology — no extra variables required: +The `gateway_server/tasks/certify-tls.yml` orchestrator targets `iag5_servers:iag5_runners` and selects suites automatically from inventory topology — no extra variables required: | Suite | Condition | |-------|-----------| @@ -80,7 +80,7 @@ The `gateway_server/tasks/verify_cert.yml` orchestrator targets `iag5_servers:ia | SUITE 2 — Cluster client ↔ server (server side) | Server nodes only when `iag5_clients` group has hosts | | SUITE 3 — Connect server → GWM | Always on server nodes (GWM is always present) | -The `gateway_client/tasks/verify_cert.yml` orchestrator runs SUITE 1 (client side) on all `iag5_clients` hosts. The play itself only runs when those hosts exist, so no guard is needed. +The `gateway_client/tasks/certify-tls.yml` orchestrator runs SUITE 1 (client side) on all `iag5_clients` hosts. The play itself only runs when those hosts exist, so no guard is needed. ### EKU gating @@ -112,33 +112,33 @@ Any check that requires a working mTLS connection carries `when: eku_valid | def itential.iag5/ ├── playbooks/ │ ├── site.yml -│ └── verify_cert.yml +│ └── certify-tls.yml └── roles/ ├── gateway_server/ │ └── tasks/ - │ └── verify_cert.yml Orchestrator (INIT + 3 suites) + │ └── certify-tls.yml Orchestrator (INIT + 3 suites) ├── gateway_client/ │ └── tasks/ - │ └── verify_cert.yml Orchestrator (INIT + 1 suite) - └── verify_cert_common/ + │ └── certify-tls.yml Orchestrator (INIT + 1 suite) + └── certify_common/ ├── defaults/main.yml ├── tasks/ │ ├── summary.yml - │ ├── verify_cert_cluster_server_to_runner.yml - │ ├── verify_cert_cluster_client_to_server.yml - │ └── verify_cert_connect_server_to_gwm.yml - └── templates/verify-cert-report.md.j2 + │ ├── certify_cluster_server_to_runner.yml + │ ├── certify_cluster_client_to_server.yml + │ └── certify_connect_server_to_gwm.yml + └── templates/certify-report.md.j2 ``` --- ## Inventory -verify_cert uses the deployer's existing inventory — no separate inventory file is required. The host groups `iag5_servers`, `iag5_runners`, and `iag5_clients` are already defined by the deployer and are used directly by the verify_cert roles. +certify uses the deployer's existing inventory — no separate inventory file is required. The host groups `iag5_servers`, `iag5_runners`, and `iag5_clients` are already defined by the deployer and are used directly by the certify roles. ### Host IP resolution -verify_cert derives each node's IP from `ansible_default_ipv4.address`, which Ansible gathers directly from the host's default route interface. This means `private_ip` does **not** need to be set in the inventory. +certify derives each node's IP from `ansible_default_ipv4.address`, which Ansible gathers directly from the host's default route interface. This means `private_ip` does **not** need to be set in the inventory. The resolved IP is used for: @@ -162,32 +162,32 @@ iag5_servers: ### Run all checks ```bash -ansible-playbook itential.iag5.verify_cert -i inventories/dev/hosts +ansible-playbook itential.iag5.certify-tls -i inventories/dev/hosts ``` ### Run a specific connection path only ```bash # Server ↔ Runner mTLS checks -ansible-playbook itential.iag5.verify_cert -i inventories/dev/hosts --tags cluster_server_to_runner +ansible-playbook itential.iag5.certify-tls -i inventories/dev/hosts --tags cluster_server_to_runner # Client ↔ Server mTLS checks -ansible-playbook itential.iag5.verify_cert -i inventories/dev/hosts --tags cluster_client_to_server +ansible-playbook itential.iag5.certify-tls -i inventories/dev/hosts --tags cluster_client_to_server # Server → Gateway Manager WebSocket TLS checks -ansible-playbook itential.iag5.verify_cert -i inventories/dev/hosts --tags connect_server_to_gwm +ansible-playbook itential.iag5.certify-tls -i inventories/dev/hosts --tags connect_server_to_gwm ``` ### Run on a single node ```bash -ansible-playbook itential.iag5.verify_cert -i inventories/dev/hosts --limit gateway_runner +ansible-playbook itential.iag5.certify-tls -i inventories/dev/hosts --limit gateway_runner ``` ### Increase verbosity to see raw openssl output ```bash -ansible-playbook itential.iag5.verify_cert -i inventories/dev/hosts -v +ansible-playbook itential.iag5.certify-tls -i inventories/dev/hosts -v ``` --- @@ -395,28 +395,28 @@ All plays use `ignore_errors: true` so a failure in one check does not abort the ## Integration with itential.iag5 Deployer -verify_cert can run integrated into the `itential.iag5` deployer so that TLS verification happens automatically after every deployment. +certify can run integrated into the `itential.iag5` deployer so that TLS verification happens automatically after every deployment. ### What changes in the deployer ``` itential.iag5/ ├── playbooks/ -│ ├── site.yml ← Add verify_cert import at end -│ └── verify_cert.yml ← 2-play playbook +│ ├── site.yml ← Add certify import at end +│ └── certify-tls.yml ← 2-play playbook └── roles/ ├── gateway_server/ - │ └── tasks/verify_cert.yml ← Orchestrator (INIT + 3 suites) + │ └── tasks/certify-tls.yml ← Orchestrator (INIT + 3 suites) ├── gateway_client/ - │ └── tasks/verify_cert.yml ← Orchestrator (INIT + 1 suite) - └── verify_cert_common/ + │ └── tasks/certify-tls.yml ← Orchestrator (INIT + 1 suite) + └── certify_common/ ├── defaults/main.yml ├── tasks/ │ ├── summary.yml - │ ├── verify_cert_cluster_server_to_runner.yml - │ ├── verify_cert_cluster_client_to_server.yml - │ └── verify_cert_connect_server_to_gwm.yml - └── templates/verify-cert-report.md.j2 + │ ├── certify_cluster_server_to_runner.yml + │ ├── certify_cluster_client_to_server.yml + │ └── certify_connect_server_to_gwm.yml + └── templates/certify-report.md.j2 ``` ### Run conditions @@ -431,16 +431,16 @@ Suites are selected automatically from inventory topology — no variables requi If TLS is explicitly disabled in your inventory, the individual cert checks record themselves as `⏭ SKIPPED — TLS disabled` in the summary. -To skip verify_cert entirely: +To skip certify entirely: ```bash -# Skip all verify_cert -ansible-playbook itential.iag5.site -i inventories/dev/hosts --skip-tags verify_cert +# Skip all certify +ansible-playbook itential.iag5.site -i inventories/dev/hosts --skip-tags certify ``` -### How deployer variables map to verify_cert variables +### How deployer variables map to certify variables -| verify_cert variable | Derived from deployer variable | +| certify variable | Derived from deployer variable | |--------------------|-------------------------------| | `gateway_conf` (server/runner) | `{{ gateway_server_config_dir }}/gateway.conf` | | `gateway_conf` (client) | `{{ gateway_client_working_dir }}/gateway.conf` | @@ -449,15 +449,15 @@ ansible-playbook itential.iag5.site -i inventories/dev/hosts --skip-tags verify_ | `service_name` | `iagctl` (hardcoded — matches deployer systemd unit) | | `private_ip` | `{{ ansible_default_ipv4.address \| default(ansible_host) }}` — gathered from the host; no inventory entry needed | -### Running verify_cert standalone against the deployer inventory +### Running certify standalone against the deployer inventory ```bash -ansible-playbook itential.iag5.verify_cert -i inventories/dev/hosts +ansible-playbook itential.iag5.certify-tls -i inventories/dev/hosts ``` -### Running the full deployer with verify_cert +### Running the full deployer with certify -When `verify_cert.yml` is imported at the end of `site.yml`, verify_cert runs automatically after every full deployment: +When `certify-tls.yml` is imported at the end of `site.yml`, certify runs automatically after every full deployment: ```bash ansible-playbook itential.iag5.site -i inventories/dev/hosts diff --git a/docs/reference_guide.md b/docs/reference_guide.md index 45fc8f9..cbc6f43 100644 --- a/docs/reference_guide.md +++ b/docs/reference_guide.md @@ -3,7 +3,7 @@ ## Common Variables The variables in this section are common to the client, server and runner roles. They can be -overridden in the `iag5_clients`, `iag5_servers` or `iag_runners` group vars. +overridden in the `iag5_clients`, `iag5_servers` or `iag5_runners` group vars. | Variable | Type | Description | Default Value | | :------- | :--- | :---------- | :------------ | @@ -32,7 +32,7 @@ The variables in this section may be overridden in the inventory in the `iag5_cl | `gateway_client_host` | String | The hostname or IP of the IAG5 server the client will connect to. | N/A (must be defined in inventory) | | `gateway_client_port` | Integer | The port of the IAG5 server the client will connect to. | 50051 | | `gateway_client_log_level` | String | The client logging level. | INFO | -| `gateway_client_use_tls` | Boolena | Flag for enabling/disabling TLS. | true | +| `gateway_client_use_tls` | Boolean | Flag for enabling/disabling TLS. | true | | `gateway_client_pki_dir` | String | Path to the client TLS certificates and keys. | "{{ gateway_client_working_dir }}/ssl" | | `gateway_client_pki_key_file` | String | The name of the client TLS key file. | "{{ inventory_hostname }}{{ gateway_pki_key_suffix }}" | | `gateway_client_pki_key_src` | String | The path to the source client TLS key file on the control node. | "{{ gateway_pki_src_dir }}/{{ gateway_client_pki_key_file }}" | @@ -48,10 +48,18 @@ The variables in this section may be overridden in the inventory in the `iag5_cl If `gateway_client_packages` contains links to artifacts in the Itential Nexus repository, the `repository_username`/`repository_password` must be defined. +The role also configures the following environment variables in `~/.bash_profile` for the +`gateway_client_user` account: + +| Variable | Value | +| :------- | :---- | +| `GATEWAY_CONFIG` | `{{ gateway_client_working_dir }}/gateway.conf` | +| `PATH` | Appends `{{ gateway_client_install_dir }}` | + # Common Server/Runner Variables The variables in this section are common to the server and runner roles. They can be overridden in -the `iag5_servers` or `iag_runners` group vars. +the `iag5_servers` or `iag5_runners` group vars. | Variable | Type | Description | Default Value | | :------- | :--- | :---------- | :------------ | @@ -63,11 +71,13 @@ the `iag5_servers` or `iag_runners` group vars. | `gateway_server_group` | String | The server group. | itential | | `gateway_server_config_dir` | String | The directory containing the server configuration files. | /etc/gateway | | `gateway_server_data_dir` | String | The directory containing the server data files. | /var/lib/gateway | -| `gateway_server_python_packages` | List of String | The list of Python packages to install. | - python3.12
- python3.12-pip | +| `gateway_server_cache_dir` | String | The directory used for the venv cache. | /var/cache/gateway | +| `gateway_server_python_packages` | List of String | The list of Python packages to install. | - python3.11
- python3.11-pip
- python3.12
- python3.12-pip | | `gateway_server_python_executable` | String | The path to the Python executable. | /usr/bin/python3.12 | | `gateway_server_pip_executable` | String | The path to the Pip executable. | /usr/bin/pip3.12 | | `gateway_server_local_bin_dir` | String | The server local binnary directory. | "/home/{{ gateway_server_user }}/.local/bin" | | `gateway_server_opentofu_packages` | List of String | The list of OpenTofu packages to install. | - tofu | +| `gateway_server_service_env_file` | String | Path to the systemd service environment file. Sensitive credentials (DynamoDB, proxy) are written here instead of the unit file to keep them out of `systemctl show` output. | "{{ gateway_server_config_dir }}/iagctl.env" | | `gateway_server_log_console_json` | Boolean | Flag for enabling/disabling logging to the console in JSON format. | false | | `gateway_server_log_file_enabled` | Boolean | Flag for enabling/disabling logging. | true | | `gateway_server_log_file_json` | Boolean | Flag for enabling/disabling logging in JSON format. | false | @@ -86,16 +96,20 @@ the `iag5_servers` or `iag_runners` group vars. | `gateway_server_pki_ca_cert_src` | String | The path to the source server TLS CA certificate on the control node. | "{{ gateway_pki_src_dir }}/{{ gateway_server_pki_ca_file }}" | | `gateway_server_pki_ca_cert_dest` | String | The path to the server TLS CA certificate. | "{{ gateway_server_pki_dir }}/{{ gateway_server_pki_ca_file }}" | | `gateway_server_registry_default_overridable` | Boolean | Controls whether users can override the default PyPI or Ansible Galaxy registries when creating a Python or Ansible service. | true | -| `gateway_server_store_backend` | String | Sets the backend type for persistent data storage.
Valid values are 'local', 'memory', 'etc' and 'dynamodb' | local | +| `gateway_server_store_backend` | String | Sets the backend type for persistent data storage.
Valid values are 'local', 'memory', 'etcd' and 'dynamodb' | local | | `gateway_server_store_etcd_hosts` | String | Sets the etcd hosts that the gateway connects to for backend storage.
A host entry consists of an address and port: hostname:port.
If there are multiple etcd hosts, enter them as a space separated list: hostname1:port hostname2:port. | localhost:2379 | | `gateway_server_store_etcd_use_tls` | Boolean | Flag for enabling/disabling TLS connections to Etcd. | true | | `gateway_server_store_etcd_client_cert_auth` | Boolean | Flag for determining the TLS authentication method used when connecting to an Etcd store backend and gateway_server_store_etcd_use_tls is set to 'true'. | true | +| `gateway_server_store_etcd_ca_certificate_filename` | String | The path to the CA certificate used to authenticate to the Etcd store backend. | "{{ gateway_server_pki_ca_cert_dest }}" | +| `gateway_server_store_etcd_certificate_filename` | String | The path to the client certificate used to authenticate to the Etcd store backend. | "{{ gateway_server_pki_cert_dest }}" | +| `gateway_server_store_etcd_private_key_filename` | String | The path to the client private key used to authenticate to the Etcd store backend. | "{{ gateway_server_pki_key_dest }}" | | `gateway_server_store_dynamodb_table_name` | String | Sets the Amazon DynamoDB table name that the gateway connects to for backend storage. | itential.gateway5.store | -| `gateway_server_store_dynamodb_aws_access_key_id` | String | The AWS access key when using DynamoDB. | N/A | -| `gateway_server_store_dynamodb_aws_secret_access_key` | String | The AWS secret access key when using DynamoDB. | N/A | -| `gateway_server_store_dynamodb_aws_session_token` | String | The AWS session token when using DynamoDB. | N/A | -| `gateway_server_store_dynamodb_aws_region` | String | The AWS region when using DynamoDB. | N/A | +| `gateway_server_store_dynamodb_aws_access_key_id` | String | The AWS access key when using DynamoDB, written to `iagctl.env`. | N/A | +| `gateway_server_store_dynamodb_aws_secret_access_key` | String | The AWS secret access key when using DynamoDB, written to `iagctl.env`. | N/A | +| `gateway_server_store_dynamodb_aws_session_token` | String | The AWS session token when using DynamoDB, written to `iagctl.env`. | N/A | +| `gateway_server_store_dynamodb_aws_region` | String | The AWS region when using DynamoDB, written to `iagctl.env`. | N/A | | `gateway_server_terminal_no_color` | Boolean | Determines whether the console outputs and logs display in color. | false | +| `gateway_server_environment` | Dictionary | Free-form key/value pairs written directly to the systemd environment file (`iagctl.env`). Use to set system-level environment variables such as `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`. | {} | If `gateway_server_packages` contains links to artifacts in the Itential Nexus repository, the `repository_username`/`repository_password` must be defined. @@ -109,11 +123,17 @@ The variables in this section may be overridden in the inventory in the `iag5_se | `gateway_server_distributed_execution` | Boolean | Flag for enabling/disabling distributed execution.
Set to 'true' when deploying an architecture utilizing runners. | false | | `gateway_server_api_key_expiration` | Integer | The amount of time (in minutes) before a user API key expires. | 1440 | | `gateway_server_connect_enabled` | Boolean | Flag for enabling/disabling the connection to Gateway Manager | true | +| `gateway_server_connect_hosts` | String | The Gateway Manager host and port (host:port) the server connects to. Must be defined in the inventory when `gateway_server_connect_enabled` is `true`. | N/A (must be defined in inventory) | +| `gateway_server_hw_specs` | Dictionary | Minimum hardware requirements (`cpu_min`, `ram_min_gb`, `disk_min_gb`) asserted by the `verify` pre-flight playbook for server nodes. | cpu_min: 1
ram_min_gb: 2
disk_min_gb: 10 | | `gateway_server_connect_server_ha_enabled` | Boolean | Enable this configuration variable when you have multiple all in one or core nodes for a particular GATEWAY_APPLICATION_CLUSTER_ID. When you enable High Availability (HA), the system runs in active/standby mode. One server connects to Gateway Manager while the others remain in standby mode. If the active node goes down, a standby node connects to Gateway Manager and begins serving requests. | false | | `gateway_server_connect_server_ha_is_primary` | Boolean | When you set GATEWAY_CONNECT_SERVER_HA_ENABLED to true, use this configuration variable to designate one node as the primary. When all nodes are online, this node takes the highest precedence and connects to Gateway Manager. Only one core HA node can connect to Gateway Manager at a time. If this node loses connection to Gateway Manager or the database, a standby node takes its place. | false | | `gateway_server_connect_insecure_tls` | Boolean | Determines whether the gateway verifies TLS certificates when it connects to Itential Platform. When set to true, the gateway skips TLS certificate verification. We strongly recommend enabling TLS certificate verification in production environments. | false | | `gateway_server_connect_certificate_file` | String | Specifies the full path to the certificate file used to establish a secure connection to Gateway Manager. | "{{ gateway_server_pki_cert_dest }}" | | `gateway_server_connect_private_key_file` | String | Specifies the full path to the private key file that the gateway uses to connect to Gateway Manager. | "{{ gateway_server_pki_key_dest }}" | +| `gateway_server_connect_proxy_enabled` | Boolean | Flag for enabling/disabling outbound proxy configuration for the Gateway Manager connection. When enabled, `GATEWAY_CONNECT_PROXY_*` variables are written to the systemd environment file (`iagctl.env`). | false | +| `gateway_server_connect_proxy_url` | String | The proxy URL (e.g. `http://proxy.example.com:8080`), written to `iagctl.env`. Required when `gateway_server_connect_proxy_enabled` is `true`. | N/A | +| `gateway_server_connect_proxy_username` | String | The proxy username, written to `iagctl.env`. Optional. | N/A | +| `gateway_server_connect_proxy_password` | String | The proxy password, written to `iagctl.env`. Optional. | N/A | | `gateway_server_features_ansible_enabled` | Boolean | Enables or disables all Ansible features. When you set this variable to false, the gateway disables the management of Ansible playbooks and the execution of Ansible services. | true | | `gateway_server_features_hostkeys_enabled` | Boolean | Enables or disables the hostkeys feature. When you set this variable to false, the gateway disables the hostkeys managment commands. | true | | `gateway_server_features_opentofu_enabled` | Boolean | Enables or disables all OpenTofu features. When you set this variable to false, the gateway disables the management of OpenTofu plans and the execution of OpenTofu services. | true | @@ -126,3 +146,15 @@ The variables in this section may be overridden in the inventory in the `iag5_ru | Variable | Type | Description | Default Value | | :------- | :--- | :---------- | :------------ | | `gateway_server_runner_announcement_address` | IP Address | Sets the address that a gateway runner registers to its cluster when it comes online. When a gateway core server sends a service execution request to a gateway runner, it sends the request to this address. If you don't explicitly set this variable, the gateway runner identifies its own IP address and registers it to the cluster. | N/A (must be defined in inventory when runners are used.) | +| `gateway_runner_hw_specs` | Dictionary | Minimum hardware requirements (`cpu_min`, `ram_min_gb`, `disk_min_gb`) asserted by the `verify` pre-flight playbook for runner nodes. | cpu_min: 4
ram_min_gb: 8
disk_min_gb: 20 | + +## Certify Variables + +The variables in this section are used by the `certify` playbook (post-deployment TLS +verification) and may be overridden in any group vars. + +| Variable | Type | Description | Default Value | +| :------- | :--- | :---------- | :------------ | +| `os_ca_bundle` | String | The OS CA bundle used to verify the Gateway Manager server certificate during the connect certification checks. Override if your OS stores trusted CA certs in a different location. | /etc/pki/tls/certs/ca-bundle.crt | +| `certify_report_file` | String | The path on the remote host where the certify report is written. | "/tmp/certify-tls-{{ inventory_hostname }}.md" | +| `certify_report_dir_local` | String | The directory on the control node where certify reports are fetched to. | ./certify_reports | diff --git a/docs/verify.md b/docs/verify.md new file mode 100644 index 0000000..548075e --- /dev/null +++ b/docs/verify.md @@ -0,0 +1,225 @@ +# verify — IAG5 Pre-Flight Environment Verification + +An Ansible playbook that verifies target hosts are ready for an IAG5 installation or upgrade +before any software is deployed. All checks run either on the control node (TLS file checks) or +against the managed node via SSH (OS and hardware checks). No IAG5 software needs to be installed +for this playbook to run. + +--- + +## Table of Contents + +- [Overview](#overview) +- [Architecture](#architecture) +- [Prerequisites](#prerequisites) +- [Usage](#usage) +- [Check Reference](#check-reference) + - [OS Checks](#os-checks) + - [Hardware Spec Checks](#hardware-spec-checks) + - [TLS File Checks](#tls-file-checks) +- [Variables Reference](#variables-reference) +- [Relationship to verify\_cert](#relationship-to-certify) + +--- + +## Overview + +`verify` catches environment problems before they surface during an install or upgrade: + +| Category | What is checked | Where it runs | +|----------|----------------|---------------| +| OS | Distribution, version, and architecture | Managed node | +| Hardware | CPU, RAM, and disk against documented minimums | Managed node (servers and runners only) | +| TLS files | Cert existence, PEM validity, expiry, cert/key match, CA chain, EKU, SANs | Control node | + +The playbook targets three host groups: `iag5_servers`, `iag5_runners`, and `iag5_clients`. Any +group absent from your inventory is silently skipped. + +--- + +## Architecture + +`verify` follows the same structure as `certify`. Each gateway role has a +`tasks/verify.yml` orchestrator that delegates to shared task files in the +`verify_common` role. + +``` +itential.iag5/ +├── playbooks/ +│ └── verify.yml 3-play standalone playbook +└── roles/ + ├── gateway_server/ + │ ├── defaults/main/specs.yml Hardware minimums for servers and runners + │ └── tasks/verify.yml Orchestrator — OS, specs, TLS + ├── gateway_client/ + │ └── tasks/verify.yml Orchestrator — OS, TLS (no specs) + └── verify_common/ + ├── defaults/main.yml + └── tasks/ + ├── verify-os.yml OS distribution, version, architecture + ├── verify-specs.yml CPU, RAM, disk + └── verify-tls-files.yml 14 TLS file checks (control node) +``` + +### How node type is determined + +`gateway_application_mode` is set by the playbook (`server` or `runner`) and controls which +hardware spec defaults are used for the specs check. + +### TLS check delegation + +All tasks in `verify-tls-files.yml` run `delegate_to: localhost` because the TLS source files +reside on the control node (in `gateway_pki_src_dir`) before upload. No SSH connection to the +managed node is needed for TLS checks. + +--- + +## Prerequisites + +- Ansible installed on the control node +- SSH access to all IAG5 target nodes +- `openssl` available on the control node (standard on macOS and RHEL/Rocky) +- TLS source files present at `gateway_pki_src_dir` (if `gateway_pki_upload: true`) + +--- + +## Usage + +Run before an installation or upgrade: + +```bash +ansible-playbook itential.iag5.verify -i inventories/ +``` + +Run against a single node: + +```bash +ansible-playbook itential.iag5.verify -i inventories/ --limit ip-10-222-0-187.ec2.internal +``` + +Run only the OS and hardware checks (skip TLS): + +```bash +ansible-playbook itential.iag5.verify -i inventories/ -e "gateway_pki_upload=false" +``` + +--- + +## Check Reference + +### OS Checks + +Runs on: all node types (`iag5_servers`, `iag5_runners`, `iag5_clients`). + +Requires: `gather_facts: true` (default). + +| Check | Description | Hard fail? | +|-------|-------------|-----------| +| Supported OS | `ansible_distribution` must be `RedHat` or `Rocky`, major version must be `8` or `9` | Yes | +| Supported architecture | `ansible_architecture` must be `x86_64` | Yes | + +--- + +### Hardware Spec Checks + +Runs on: `iag5_servers` and `iag5_runners` only. Clients have no documented hardware minimums +and are skipped. + +Hardware failures are collected across all three dimensions and reported together in a single +final assertion, so all failures are visible in one run. + +| Check | Server minimum | Runner minimum | Hard fail? | +|-------|---------------|----------------|-----------| +| CPU count | 1 vCPU | 4 vCPUs | Yes (collected) | +| RAM | 2 GB | 8 GB | Yes (collected) | +| Disk (root partition) | 10 GB | 20 GB | Yes (collected) | + +Minimums are sourced from the IAG5 hardware documentation and stored in +`roles/gateway_server/defaults/main/specs.yml`. Override per host or group in your inventory: + +```yaml +iag5_runners: + vars: + gateway_runner_hw_specs: + cpu_min: 8 + ram_min_gb: 16 + disk_min_gb: 40 +``` + +--- + +### TLS File Checks + +Runs on: all node types when `gateway_pki_upload: true` (default). + +All tasks delegate to the control node (`delegate_to: localhost`). Variables required: +`gateway_pki_src_dir`, and the PKI path defaults from the role (`gateway_server_pki_cert_src`, +`gateway_server_pki_key_src`, `gateway_server_pki_ca_cert_src`). + +| Check | Description | Hard fail? | +|-------|-------------|-----------| +| Cert file exists | Cert file present at `gateway_pki_src_dir/.crt` | Yes | +| Key file exists | Key file present at `gateway_pki_src_dir/.key` | Yes | +| CA cert file exists | CA cert present at `gateway_pki_src_dir/ca.crt` | Yes | +| Cert is valid PEM | `openssl x509 -noout -in ` exits 0 | Yes | +| CA cert is valid PEM | `openssl x509 -noout -in ` exits 0 | Yes | +| Key is parseable | `openssl pkey -noout -in ` exits 0 | Yes | +| Cert is not expired | `openssl x509 -checkend 0` exits 0 | Yes | +| Cert not expiring within 30 days | `openssl x509 -checkend 2592000` | Warn | +| Cert and key are a matched pair | Public key extracted from cert matches public key derived from private key | Yes | +| CA cert has `CA:TRUE` | Basic Constraints extension includes `CA:TRUE` | Yes | +| Cert is signed by CA | `openssl verify -CAfile ` returns `OK` | Yes | +| Cert is not self-signed | Subject hash does not equal issuer hash | Yes | +| Cert has `serverAuth` in EKU | Extended Key Usage includes TLS Web Server Authentication | Warn | +| Cert has `clientAuth` in EKU | Extended Key Usage includes TLS Web Client Authentication | Warn | +| SANs present (servers and runners only) | Subject Alternative Name extension present in cert | Yes | +| `inventory_hostname` in SANs (servers and runners only) | Hostname appears in SAN DNS entries | Warn | +| `ansible_host` in SANs (servers and runners only) | Connection address appears in SAN entries | Warn | + +> **EKU note:** The `itential.tls` collection does not add Extended Key Usage to generated certs +> by default. EKU checks are warnings rather than hard failures to remain compatible with certs +> generated this way. For best security, update cert generation to include `serverAuth` and +> `clientAuth`. + +--- + +## Variables Reference + +### Hardware spec variables (gateway\_server role) + +| Variable | Default | Description | +|----------|---------|-------------| +| `gateway_server_hw_specs.cpu_min` | `1` | Minimum vCPUs for server nodes | +| `gateway_server_hw_specs.ram_min_gb` | `2` | Minimum RAM (GB) for server nodes | +| `gateway_server_hw_specs.disk_min_gb` | `10` | Minimum root disk (GB) for server nodes | +| `gateway_runner_hw_specs.cpu_min` | `4` | Minimum vCPUs for runner nodes | +| `gateway_runner_hw_specs.ram_min_gb` | `8` | Minimum RAM (GB) for runner nodes | +| `gateway_runner_hw_specs.disk_min_gb` | `20` | Minimum root disk (GB) for runner nodes | + +### Inventory variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `gateway_pki_src_dir` | Yes (when `gateway_pki_upload: true`) | Local directory on the control node containing TLS cert files | +| `gateway_pki_upload` | No (default: `true`) | Set to `false` to skip TLS file checks | +| `ansible_host` | Yes | Address Ansible uses to SSH into the node | +| `ansible_user` | Yes | SSH user | +| `ansible_ssh_private_key_file` | Yes (or equivalent auth) | SSH key path | + +--- + +## Relationship to verify\_cert + +`verify` and `certify` are complementary, not overlapping: + +| | verify\_environment | verify\_cert | +|-|---------------------|-------------| +| When to run | Before install or upgrade | After install or upgrade | +| TLS checks target | Source files on control node | Deployed certs on managed nodes | +| Reads `gateway.conf` | No | Yes | +| Live TLS handshakes | No | Yes | +| Hardware checks | Yes | No | +| OS checks | Yes | No | + +Run `verify` first to confirm the environment is ready, then `certify` after +deployment to confirm mTLS is working end-to-end. diff --git a/playbooks/verify_cert.yml b/playbooks/certify-tls.yml similarity index 67% rename from playbooks/verify_cert.yml rename to playbooks/certify-tls.yml index 9ea365e..c039beb 100644 --- a/playbooks/verify_cert.yml +++ b/playbooks/certify-tls.yml @@ -1,49 +1,49 @@ # Copyright (c) 2025, Itential, Inc # GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) --- -# IAG5 TLS Certificate Verification +# IAG5 TLS Certificate Certification # -# Runs after deployment to verify that all TLS certificates are correctly +# Runs after deployment to certify that all TLS certificates are correctly # configured across all IAG5 node types. All variables are derived from the -# deployer's existing inventory — no separate verify_cert inventory required. +# deployer's existing inventory — no separate certify inventory required. # # Runs automatically as the final step of site.yml. # Can also be run standalone: # -# ansible-playbook itential.iag5.verify_cert -i +# ansible-playbook itential.iag5.certify-tls -i # ----------------------------------------------------------------------- # PLAY 1 — Gateway server and runner nodes # ----------------------------------------------------------------------- -- name: "Gateway server and runner TLS certificate verification" +- name: "Gateway server and runner TLS certificate certification" hosts: iag5_servers:iag5_runners become: true - tags: [verify_cert] + tags: [certify] tasks: - name: "Load gateway shared variables" ansible.builtin.import_role: name: itential.iag5.gateway when: false - - name: "Run gateway server/runner cert verification" + - name: "Run gateway server/runner certify" ansible.builtin.include_role: name: itential.iag5.gateway_server - tasks_from: verify_cert.yml + tasks_from: certify-tls.yml # ----------------------------------------------------------------------- # PLAY 2 — Gateway client nodes (only runs when iag5_clients hosts exist) # ----------------------------------------------------------------------- -- name: "Gateway client TLS certificate verification" +- name: "Gateway client TLS certificate certification" hosts: iag5_clients become: true - tags: [verify_cert] + tags: [certify] tasks: - name: "Load gateway shared variables" ansible.builtin.import_role: name: itential.iag5.gateway when: false - - name: "Run gateway client cert verification" + - name: "Run gateway client certify" ansible.builtin.include_role: name: itential.iag5.gateway_client - tasks_from: verify_cert.yml + tasks_from: certify-tls.yml diff --git a/playbooks/certify.yml b/playbooks/certify.yml new file mode 100644 index 0000000..2c4b873 --- /dev/null +++ b/playbooks/certify.yml @@ -0,0 +1,89 @@ +# Copyright (c) 2026, Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +# IAG5 Installation Certification +# +# Forensic read-only certification that IAG5 has been successfully installed +# across all node types. Checks service state, config file properties, TLS +# file existence and certificate details, runtime versions, log directories, +# and network connectivity. A markdown report is generated per host and +# fetched to the control node under ./certify_reports/. +# +# Can be run standalone after deployment: +# +# ansible-playbook itential.iag5.certify -i +# +# Tag reference: +# certify-iag5 — run both forensic + TLS certification +# certify-install — run forensic installation certification only +# certify — run TLS certification only + +# ----------------------------------------------------------------------- +# PLAY 1 — Gateway server and runner nodes +# ----------------------------------------------------------------------- +- name: "IAG5 server and runner installation certification" + hosts: iag5_servers:iag5_runners + become: true + gather_facts: false + tags: [certify-iag5] + tasks: + - name: Gather facts + ansible.builtin.gather_facts: + tags: [always] + + - name: Load gateway shared variables + ansible.builtin.import_role: + name: itential.iag5.gateway + when: false + tags: [always] + + - name: Run IAG5 server/runner installation certification + ansible.builtin.include_role: + name: itential.iag5.gateway_server + tasks_from: certify.yml + apply: + tags: [certify-install] + tags: [certify-install] + + - name: Run IAG5 server/runner TLS certification + ansible.builtin.include_role: + name: itential.iag5.gateway_server + tasks_from: certify-tls.yml + apply: + tags: [certify] + tags: [certify] + +# ----------------------------------------------------------------------- +# PLAY 2 — Gateway client nodes +# ----------------------------------------------------------------------- +- name: "IAG5 client installation certification" + hosts: iag5_clients + become: true + gather_facts: false + tags: [certify-iag5] + tasks: + - name: Gather facts + ansible.builtin.gather_facts: + tags: [always] + + - name: Load gateway shared variables + ansible.builtin.import_role: + name: itential.iag5.gateway + when: false + tags: [always] + + - name: Run IAG5 client installation certification + ansible.builtin.include_role: + name: itential.iag5.gateway_client + tasks_from: certify.yml + apply: + tags: [certify-install] + tags: [certify-install] + + - name: Run IAG5 client TLS certification + ansible.builtin.include_role: + name: itential.iag5.gateway_client + tasks_from: certify-tls.yml + apply: + tags: [certify] + tags: [certify] diff --git a/playbooks/site.yml b/playbooks/site.yml index 2953839..7b2b8d1 100644 --- a/playbooks/site.yml +++ b/playbooks/site.yml @@ -9,6 +9,3 @@ - name: Install and configure Gateway5 clients import_playbook: itential.iag5.clients - -- name: Verify IAG5 TLS certificates post-deployment - import_playbook: itential.iag5.verify_cert diff --git a/playbooks/verify.yml b/playbooks/verify.yml new file mode 100644 index 0000000..e0a5876 --- /dev/null +++ b/playbooks/verify.yml @@ -0,0 +1,71 @@ +# Copyright (c) 2026, Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +# IAG5 Environment Verification +# +# Verifies that target hosts meet all requirements before installation or upgrade: +# - OS is a supported distribution and version (RHEL/Rocky 8/9, x86_64) +# - Hardware specs meet minimums (servers and runners only) +# - TLS source files are valid, unexpired, and correctly configured +# +# Run standalone before install or upgrade: +# +# ansible-playbook itential.iag5.verify -i + +# ----------------------------------------------------------------------- +# PLAY 1 — Gateway server nodes +# ----------------------------------------------------------------------- +- name: Verify IAG5 server environment + hosts: iag5_servers + become: true + gather_facts: true + tasks: + - name: Load gateway shared variables + ansible.builtin.import_role: + name: itential.iag5.gateway + when: false + + - name: Verify server environment + ansible.builtin.include_role: + name: itential.iag5.gateway_server + tasks_from: verify.yml + vars: + gateway_application_mode: server + +# ----------------------------------------------------------------------- +# PLAY 2 — Gateway runner nodes +# ----------------------------------------------------------------------- +- name: Verify IAG5 runner environment + hosts: iag5_runners + become: true + gather_facts: true + tasks: + - name: Load gateway shared variables + ansible.builtin.import_role: + name: itential.iag5.gateway + when: false + + - name: Verify runner environment + ansible.builtin.include_role: + name: itential.iag5.gateway_server + tasks_from: verify.yml + vars: + gateway_application_mode: runner + +# ----------------------------------------------------------------------- +# PLAY 3 — Gateway client nodes (only runs when iag5_clients hosts exist) +# ----------------------------------------------------------------------- +- name: Verify IAG5 client environment + hosts: iag5_clients + become: true + gather_facts: true + tasks: + - name: Load gateway shared variables + ansible.builtin.import_role: + name: itential.iag5.gateway + when: false + + - name: Verify client environment + ansible.builtin.include_role: + name: itential.iag5.gateway_client + tasks_from: verify.yml diff --git a/roles/verify_cert_common/defaults/main.yml b/roles/certify_common/defaults/main.yml similarity index 78% rename from roles/verify_cert_common/defaults/main.yml rename to roles/certify_common/defaults/main.yml index 512786d..8321d22 100644 --- a/roles/verify_cert_common/defaults/main.yml +++ b/roles/certify_common/defaults/main.yml @@ -6,7 +6,7 @@ os_ca_bundle: /etc/pki/tls/certs/ca-bundle.crt # Path where the report is written on the remote host -verify_cert_report_file: "/tmp/verify_cert_{{ inventory_hostname }}.md" +certify_report_file: "/tmp/certify-tls-{{ inventory_hostname }}.md" # Directory on the control node where reports are fetched to -verify_cert_report_dir_local: "./verify_cert_reports" +certify_report_dir_local: "./certify_reports" diff --git a/roles/verify_cert_common/tasks/verify_cert_cluster_client_to_server.yml b/roles/certify_common/tasks/certify_cluster_client_to_server.yml similarity index 100% rename from roles/verify_cert_common/tasks/verify_cert_cluster_client_to_server.yml rename to roles/certify_common/tasks/certify_cluster_client_to_server.yml diff --git a/roles/verify_cert_common/tasks/verify_cert_cluster_server_to_runner.yml b/roles/certify_common/tasks/certify_cluster_server_to_runner.yml similarity index 100% rename from roles/verify_cert_common/tasks/verify_cert_cluster_server_to_runner.yml rename to roles/certify_common/tasks/certify_cluster_server_to_runner.yml diff --git a/roles/verify_cert_common/tasks/verify_cert_connect_server_to_gwm.yml b/roles/certify_common/tasks/certify_connect_server_to_gwm.yml similarity index 96% rename from roles/verify_cert_common/tasks/verify_cert_connect_server_to_gwm.yml rename to roles/certify_common/tasks/certify_connect_server_to_gwm.yml index 2b57edf..4d3ee0f 100644 --- a/roles/verify_cert_common/tasks/verify_cert_connect_server_to_gwm.yml +++ b/roles/certify_common/tasks/certify_connect_server_to_gwm.yml @@ -272,8 +272,8 @@ - name: "CHECK 16 | Set GWM host and port" ansible.builtin.set_fact: - gwm_host: "{{ gwm_endpoint.split(':')[0] }}" - gwm_port: "{{ gwm_endpoint.split(':')[1] }}" + gwm_host: "{{ gwm_endpoint.split()[0].split(':')[0] }}" + gwm_port: "{{ gwm_endpoint.split()[0].split(':')[1] }}" when: connect_is_enabled and gwm_endpoint is defined and gwm_endpoint != '' - name: "CHECK 16 | Resolve GWM host from server" @@ -294,20 +294,25 @@ - name: "CHECK 17 | Test TCP connectivity SERVER → GWM" ansible.builtin.wait_for: - host: "{{ gwm_host }}" - port: "{{ gwm_port }}" + host: "{{ item.split(':')[0] }}" + port: "{{ item.split(':')[1] | int }}" timeout: 5 - register: tcp_check + state: started + register: gwm_tcp_checks ignore_errors: true - when: connect_is_enabled and gwm_host is defined and gwm_host != '' + loop: "{{ gwm_endpoint.split() }}" + when: connect_is_enabled and gwm_endpoint is defined and gwm_endpoint != '' - name: "CHECK 17 | Record result — TCP connectivity to GWM" ansible.builtin.set_fact: - check_results: "{{ check_results + [{'check': 'CHECK 17 — TCP connectivity SERVER → GWM (' + (gwm_host | default('N/A')) + ':' + (gwm_port | default('N/A') | string) + ')', + check_results: "{{ check_results + [{'check': 'CHECK 17 — TCP connectivity SERVER → GWM (' + item.item + ')', 'expected': 'Port reachable within 5 seconds', - 'actual': 'REACHABLE' if not tcp_check.failed else 'UNREACHABLE — check security groups', - 'status': '✅ PASS' if not tcp_check.failed else ('⏭ SKIPPED — Connect disabled' if not connect_is_enabled else '❌ FAIL')}] }}" - when: connect_is_enabled + 'actual': 'REACHABLE' if not item.failed else 'UNREACHABLE — check security groups', + 'status': '✅ PASS' if not item.failed else '❌ FAIL'}] }}" + loop: "{{ gwm_tcp_checks.results }}" + loop_control: + label: "{{ item.item }}" + when: connect_is_enabled and gwm_tcp_checks is defined - name: "CHECK 18 | Verify GWM server cert trusted by OS CA pool (Layer 1 one-way TLS)" ansible.builtin.shell: | diff --git a/roles/verify_cert_common/tasks/summary.yml b/roles/certify_common/tasks/summary.yml similarity index 81% rename from roles/verify_cert_common/tasks/summary.yml rename to roles/certify_common/tasks/summary.yml index 42eab3c..e8f7cd6 100644 --- a/roles/verify_cert_common/tasks/summary.yml +++ b/roles/certify_common/tasks/summary.yml @@ -35,8 +35,8 @@ - name: "Generate report on remote host" ansible.builtin.template: - src: "{{ verify_cert_report_template }}" - dest: "{{ verify_cert_report_file }}" + src: "{{ certify_report_template }}" + dest: "{{ certify_report_file }}" owner: "{{ report_file_owner }}" group: "{{ report_file_group }}" mode: '0644' @@ -44,14 +44,14 @@ - name: "Fetch report to control node" ansible.builtin.fetch: - src: "{{ verify_cert_report_file }}" - dest: "{{ verify_cert_report_dir_local }}/" + src: "{{ certify_report_file }}" + dest: "{{ certify_report_dir_local }}/" flat: true fail_on_missing: false - name: "Display completion message" ansible.builtin.debug: msg: - - "verify_cert complete for {{ inventory_hostname }}" + - "certify complete for {{ inventory_hostname }}" - "Overall Status: {{ 'PASSED ✓' if (check_results | selectattr('status', 'search', '❌') | list | length == 0) else 'FAILED ✗' }}" - - "Report saved to: {{ verify_cert_report_file }} (remote) and {{ verify_cert_report_dir_local }}/ (control node)" + - "Report saved to: {{ certify_report_file }} (remote) and {{ certify_report_dir_local }}/ (control node)" diff --git a/roles/verify_cert_common/templates/verify-cert-report.md.j2 b/roles/certify_common/templates/certify-report.md.j2 similarity index 100% rename from roles/verify_cert_common/templates/verify-cert-report.md.j2 rename to roles/certify_common/templates/certify-report.md.j2 diff --git a/roles/gateway_client/tasks/verify_cert.yml b/roles/gateway_client/tasks/certify-tls.yml similarity index 71% rename from roles/gateway_client/tasks/verify_cert.yml rename to roles/gateway_client/tasks/certify-tls.yml index 406ca5f..abe50de 100644 --- a/roles/gateway_client/tasks/verify_cert.yml +++ b/roles/gateway_client/tasks/certify-tls.yml @@ -1,6 +1,6 @@ --- # TLS certificate verification for gateway client nodes. -# Called via: include_role: name: itential.iag5.gateway_client tasks_from: verify_cert.yml +# Called via: include_role: name: itential.iag5.gateway_client tasks_from: certify-tls.yml # Requires: itential.iag5.gateway role to have been loaded (provides gateway_* vars). - name: "Set verification context" @@ -12,9 +12,9 @@ iag5_server_group: iag5_servers iag5_client_group: iag5_clients private_ip: "{{ ansible_default_ipv4.address | default(ansible_host) }}" - verify_cert_report_file: "/tmp/verify_cert_{{ inventory_hostname }}.md" - verify_cert_report_dir_local: "./verify_cert_reports" - verify_cert_report_template: "{{ role_path }}/../verify_cert_common/templates/verify-cert-report.md.j2" + certify_report_file: "/tmp/certify-tls-{{ inventory_hostname }}.md" + certify_report_dir_local: "./certify_reports" + certify_report_template: "{{ role_path }}/../certify_common/templates/certify-report.md.j2" report_file_owner: "{{ gateway_client_user }}" report_file_group: "{{ gateway_client_group }}" check_results: [] @@ -24,9 +24,9 @@ # ----------------------------------------------------------------------- - name: "SUITE 1 | Run cluster client↔server checks (client side)" - ansible.builtin.include_tasks: ../../verify_cert_common/tasks/verify_cert_cluster_client_to_server.yml + ansible.builtin.include_tasks: ../../certify_common/tasks/certify_cluster_client_to_server.yml - name: "SUITE 1 | Print summary" - ansible.builtin.include_tasks: ../../verify_cert_common/tasks/summary.yml + ansible.builtin.include_tasks: ../../certify_common/tasks/summary.yml vars: role_title: "CLUSTER TLS — CLIENT ↔ SERVER (gRPC mTLS) — CLIENT NODE" diff --git a/roles/gateway_client/tasks/certify.yml b/roles/gateway_client/tasks/certify.yml new file mode 100644 index 0000000..764a31e --- /dev/null +++ b/roles/gateway_client/tasks/certify.yml @@ -0,0 +1,324 @@ +# Copyright (c) 2026, Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +# Forensic installation certification for IAG5 gateway client nodes. +# Read-only — nothing is modified on the host. +# Called via: include_role: name: itential.iag5.gateway_client tasks_from: certify.yml + +# ============================================================================ +# SETUP +# ============================================================================ + +- name: Set certify context + ansible.builtin.set_fact: + iag5_node_type: client + iag5_gateway_conf: "{{ gateway_client_working_dir }}/gateway.conf" + iag5_certify_report_dir_remote: /tmp + iag5_certify_report_dir_local: ./certify_reports + +- name: Set report filename + ansible.builtin.set_fact: + iag5_certify_report_file: "{{ iag5_certify_report_dir_remote }}/certify-iag5-client-report-{{ inventory_hostname }}.md" + +# ============================================================================ +# PROCESS STATUS +# (Clients are CLI tools — no systemd service) +# ============================================================================ + +- name: Check iagctl process is running + ansible.builtin.shell: ps aux | grep -v grep | grep iagctl + register: iag5_process + ignore_errors: true + changed_when: false + +# ============================================================================ +# BINARY +# ============================================================================ + +- name: Get iagctl binary details + ansible.builtin.shell: > + ls -la {{ gateway_client_install_dir }}/iagctl 2>/dev/null || + which iagctl 2>/dev/null || + echo "not found" + register: iag5_iagctl_path + ignore_errors: true + changed_when: false + failed_when: false + +- name: Get iagctl version (as itential user) + ansible.builtin.shell: "{{ gateway_client_install_dir }}/iagctl version 2>/dev/null | awk '/^version/{print $2}'" + register: iag5_iagctl_version + ignore_errors: true + changed_when: false + failed_when: false + become: true + become_user: "{{ gateway_client_user }}" + become_flags: '-i' + +# ============================================================================ +# CONFIG FILE +# ============================================================================ + +- name: Check gateway config file exists + ansible.builtin.stat: + path: "{{ iag5_gateway_conf }}" + register: iag5_conf_file + +- name: Get gateway config file permissions + ansible.builtin.command: "ls -la {{ iag5_gateway_conf }}" + register: iag5_conf_permissions + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +# ============================================================================ +# PARSE CONFIG — [application] +# ============================================================================ + +- name: Parse config — application.mode + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('application','mode',fallback='').strip(chr(39)))" + register: iag5_conf_mode + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — application.cluster_id + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('application','cluster_id',fallback='').strip(chr(39)))" + register: iag5_conf_cluster_id + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — application.working_dir + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('application','working_dir',fallback='').strip(chr(39)))" + register: iag5_conf_working_dir + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — application.ca_certificate_file + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('application','ca_certificate_file',fallback='').strip(chr(39)))" + register: iag5_conf_ca_cert_file + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +# ============================================================================ +# PARSE CONFIG — [client] +# ============================================================================ + +- name: Parse config — client.host + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('client','host',fallback='').strip(chr(39)))" + register: iag5_conf_client_host + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — client.port + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('client','port',fallback='').strip(chr(39)))" + register: iag5_conf_client_port + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — client.use_tls + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('client','use_tls',fallback='').strip(chr(39)))" + register: iag5_conf_use_tls + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — client.certificate_file + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('client','certificate_file',fallback='').strip(chr(39)))" + register: iag5_conf_certificate_file + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — client.private_key_file + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('client','private_key_file',fallback='').strip(chr(39)))" + register: iag5_conf_private_key_file + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +# ============================================================================ +# PARSE CONFIG — [log], [secrets] +# ============================================================================ + +- name: Parse config — log.level + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('log','level',fallback='').strip(chr(39)))" + register: iag5_conf_log_level + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — secrets.encrypt_key_file + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('secrets','encrypt_key_file',fallback='').strip(chr(39)))" + register: iag5_conf_encrypt_key_file + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +# ============================================================================ +# TLS FILE CHECKS +# ============================================================================ + +- name: Check CA certificate file exists + ansible.builtin.stat: + path: "{{ iag5_conf_ca_cert_file.stdout }}" + register: iag5_ca_cert_stat + when: iag5_conf_ca_cert_file.stdout | default('') != '' + +- name: Check client certificate file exists + ansible.builtin.stat: + path: "{{ iag5_conf_certificate_file.stdout }}" + register: iag5_cert_stat + when: iag5_conf_certificate_file.stdout | default('') != '' + +- name: Check client private key file exists + ansible.builtin.stat: + path: "{{ iag5_conf_private_key_file.stdout }}" + register: iag5_key_stat + when: iag5_conf_private_key_file.stdout | default('') != '' + +- name: Check encryption key file exists + ansible.builtin.stat: + path: "{{ iag5_conf_encrypt_key_file.stdout }}" + register: iag5_encrypt_key_stat + when: iag5_conf_encrypt_key_file.stdout | default('') != '' + +# ============================================================================ +# TLS CERTIFICATE DETAILS +# ============================================================================ + +- name: Get CA certificate details + ansible.builtin.command: > + openssl x509 -noout -subject -issuer -enddate + -in {{ iag5_conf_ca_cert_file.stdout }} + register: iag5_ca_cert_details + ignore_errors: true + changed_when: false + when: + - iag5_conf_ca_cert_file.stdout | default('') != '' + - iag5_ca_cert_stat.stat.exists | default(false) + +- name: Get client certificate details (subject, issuer, expiry, SANs) + ansible.builtin.command: > + openssl x509 -noout -subject -issuer -enddate -ext subjectAltName + -in {{ iag5_conf_certificate_file.stdout }} + register: iag5_cert_details + ignore_errors: true + changed_when: false + when: + - iag5_conf_certificate_file.stdout | default('') != '' + - iag5_cert_stat.stat.exists | default(false) + +- name: Check client certificate is not expired + ansible.builtin.command: > + openssl x509 -checkend 0 + -in {{ iag5_conf_certificate_file.stdout }} + register: iag5_cert_not_expired + ignore_errors: true + changed_when: false + failed_when: false + when: + - iag5_conf_certificate_file.stdout | default('') != '' + - iag5_cert_stat.stat.exists | default(false) + +- name: Check client certificate expires within 30 days + ansible.builtin.command: > + openssl x509 -checkend 2592000 + -in {{ iag5_conf_certificate_file.stdout }} + register: iag5_cert_expires_soon + ignore_errors: true + changed_when: false + failed_when: false + when: + - iag5_conf_certificate_file.stdout | default('') != '' + - iag5_cert_stat.stat.exists | default(false) + +# ============================================================================ +# CONNECTIVITY +# ============================================================================ + +- name: Check TCP reachability to configured server + ansible.builtin.wait_for: + host: "{{ iag5_conf_client_host.stdout }}" + port: "{{ iag5_conf_client_port.stdout | int }}" + timeout: 5 + state: started + register: iag5_server_tcp_reachable + ignore_errors: true + changed_when: false + when: + - iag5_conf_client_host.stdout | default('') != '' + - iag5_conf_client_port.stdout | default('') != '' + +# ============================================================================ +# GENERATE REPORT +# ============================================================================ + +- name: Generate IAG5 client installation certification report + ansible.builtin.template: + src: certify-iag5-client-report.md.j2 + dest: "{{ iag5_certify_report_file }}" + mode: '0644' + +- name: Copy certification report to control node + ansible.builtin.fetch: + src: "{{ iag5_certify_report_file }}" + dest: "{{ iag5_certify_report_dir_local }}/" + flat: true + fail_on_missing: false + +- name: Display certification summary + ansible.builtin.debug: + msg: + - "IAG5 certification complete for {{ inventory_hostname }} (client)" + - "Overall Status: {{ 'PASSED ✓' if not (iag5_server_tcp_reachable.failed | default(true)) else 'FAILED ✗' }}" + - "Report: {{ iag5_certify_report_file }} (remote) → {{ iag5_certify_report_dir_local }}/ (control node)" diff --git a/roles/gateway_client/tasks/configure_gateway_client.yml b/roles/gateway_client/tasks/configure_gateway_client.yml index 9006ddb..e6e62af 100644 --- a/roles/gateway_client/tasks/configure_gateway_client.yml +++ b/roles/gateway_client/tasks/configure_gateway_client.yml @@ -18,3 +18,15 @@ mode: "0600" owner: "{{ gateway_client_user }}" group: "{{ gateway_client_group }}" + +- name: Configure gateway environment variables in .bash_profile + ansible.builtin.blockinfile: + path: "/home/{{ gateway_client_user }}/.bash_profile" + create: true + owner: "{{ gateway_client_user }}" + group: "{{ gateway_client_group }}" + mode: "0644" + marker: "# {mark} ANSIBLE MANAGED BLOCK - gateway" + block: | + export GATEWAY_CONFIG={{ gateway_client_working_dir }}/gateway.conf + export PATH=$PATH:{{ gateway_client_install_dir }} diff --git a/roles/gateway_client/tasks/verify.yml b/roles/gateway_client/tasks/verify.yml new file mode 100644 index 0000000..6063c44 --- /dev/null +++ b/roles/gateway_client/tasks/verify.yml @@ -0,0 +1,18 @@ +# Copyright (c) 2026, Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +- name: Verify OS compatibility + ansible.builtin.include_role: + name: itential.iag5.verify_common + tasks_from: verify-os.yml + +- name: Verify TLS source files + when: gateway_pki_upload | bool + ansible.builtin.include_role: + name: itential.iag5.verify_common + tasks_from: verify-tls-files.yml + vars: + pki_cert_src: "{{ gateway_client_pki_cert_src }}" + pki_key_src: "{{ gateway_client_pki_key_src }}" + pki_ca_cert_src: "{{ gateway_client_pki_ca_cert_src }}" + check_san: false diff --git a/roles/gateway_client/templates/certify-iag5-client-report.md.j2 b/roles/gateway_client/templates/certify-iag5-client-report.md.j2 new file mode 100644 index 0000000..ba3ce4c --- /dev/null +++ b/roles/gateway_client/templates/certify-iag5-client-report.md.j2 @@ -0,0 +1,149 @@ +# IAG5 CLIENT Installation Certification Report + +| Field | Value | +|---|---| +| **Host** | {{ inventory_hostname }} | +| **Node Type** | client | +| **Generated** | {{ ansible_date_time.iso8601 | default('Unknown') }} | +| **IP Address** | {{ ansible_default_ipv4.address | default('N/A') }} | +| **OS** | {{ ansible_distribution | default('Unknown') }} {{ ansible_distribution_version | default('') }} | +| **Overall Status** | {{ 'PASSED ✓' if not (iag5_server_tcp_reachable.failed | default(true)) else 'FAILED ✗' }} | + +--- + +## Host Details + +### Operating System +- **Distribution:** {{ ansible_distribution | default('Unknown') }} {{ ansible_distribution_version | default('') }} +- **OS Family:** {{ ansible_os_family | default('Unknown') }} +- **Kernel:** {{ ansible_kernel | default('Unknown') }} +- **Architecture:** {{ ansible_architecture | default('Unknown') }} +- **Hostname:** {{ ansible_hostname | default('Unknown') }} +- **FQDN:** {{ ansible_fqdn | default('Unknown') }} + +### Hardware +- **CPU Count:** {{ ansible_processor_count | default('Unknown') }} +- **CPU Cores:** {{ ansible_processor_cores | default('Unknown') }} +- **CPU vCPUs:** {{ ansible_processor_vcpus | default('Unknown') }} +- **Total Memory:** {{ ansible_memtotal_mb | default('Unknown') }} MB +- **Free Memory:** {{ ansible_memfree_mb | default('Unknown') }} MB +- **Swap Total:** {{ ansible_swaptotal_mb | default('Unknown') }} MB + +### Disk Mounts +{% if ansible_mounts is defined and ansible_mounts | length > 0 %} +{% for mount in ansible_mounts %} +- **{{ mount.mount }}:** {{ (mount.size_total / 1073741824) | round(1) }} GB total, {{ (mount.size_available / 1073741824) | round(1) }} GB free +{% endfor %} +{% else %} +- No disk information available +{% endif %} + +### Networking +- **Primary IP:** {{ ansible_default_ipv4.address | default('N/A') }} +- **Gateway:** {{ ansible_default_ipv4.gateway | default('N/A') }} +- **Interface:** {{ ansible_default_ipv4.interface | default('N/A') }} +- **MAC Address:** {{ ansible_default_ipv4.macaddress | default('N/A') }} + +### Security +{% if ansible_selinux is defined %} +- **SELinux Status:** {{ ansible_selinux.status | default('Unknown') }} +- **SELinux Mode:** {{ ansible_selinux.mode | default('Unknown') }} +{% else %} +- **SELinux:** Not available +{% endif %} + +--- + +## Process Status + +- **iagctl Process Running:** {{ 'YES ✓' if (iag5_process is defined and iag5_process.rc == 0) else 'NO (expected for idle client)' }} + +--- + +## Binary + +- **iagctl Path:** {{ iag5_iagctl_path.stdout | default('Unknown') }} +- **iagctl Version:** {{ iag5_iagctl_version.stdout | default('Unknown') }} + +--- + +## Configuration File + +- **Config File Exists:** {{ 'YES ✓' if (iag5_conf_file.stat.exists | default(false)) else 'NO ✗' }} +- **Config File Path:** `{{ iag5_gateway_conf }}` +{% if iag5_conf_file.stat.exists | default(false) %} +- **Permissions:** `{{ iag5_conf_permissions.stdout | default('Unknown') }}` +{% endif %} + +--- + +## Configuration Properties + +### [application] +- **mode:** {{ iag5_conf_mode.stdout | default('Unknown') }} +- **cluster_id:** {{ iag5_conf_cluster_id.stdout | default('Unknown') }} +- **working_dir:** {{ iag5_conf_working_dir.stdout | default('Unknown') }} +- **ca_certificate_file:** {{ iag5_conf_ca_cert_file.stdout | default('Unknown') }} + +### [client] +- **host:** {{ iag5_conf_client_host.stdout | default('Unknown') }} +- **port:** {{ iag5_conf_client_port.stdout | default('Unknown') }} +- **use_tls:** {{ iag5_conf_use_tls.stdout | default('Unknown') }} +- **certificate_file:** {{ iag5_conf_certificate_file.stdout | default('Unknown') }} +- **private_key_file:** {{ iag5_conf_private_key_file.stdout | default('Unknown') }} + +### [log] +- **level:** {{ iag5_conf_log_level.stdout | default('Unknown') }} + +### [secrets] +- **encrypt_key_file:** {{ iag5_conf_encrypt_key_file.stdout | default('Unknown') }} + +--- + +## TLS Files + +### Application CA Certificate (`application.ca_certificate_file`) +- **Path:** `{{ iag5_conf_ca_cert_file.stdout | default('Not configured') }}` +- **Exists:** {{ 'YES ✓' if (iag5_ca_cert_stat.stat.exists | default(false)) else 'NO ✗' }} +{% if iag5_ca_cert_stat.stat.exists | default(false) %} +- **Mode:** `{{ iag5_ca_cert_stat.stat.mode | default('Unknown') }}` +- **Details:** +``` +{{ iag5_ca_cert_details.stdout | default('N/A') }} +``` +{% endif %} + +### Client Certificate (`client.certificate_file`) +- **Path:** `{{ iag5_conf_certificate_file.stdout | default('Not configured') }}` +- **Exists:** {{ 'YES ✓' if (iag5_cert_stat.stat.exists | default(false)) else 'NO ✗' }} +{% if iag5_cert_stat.stat.exists | default(false) %} +- **Mode:** `{{ iag5_cert_stat.stat.mode | default('Unknown') }}` +- **Expired:** {{ 'NO ✓' if (iag5_cert_not_expired.rc | default(1) == 0) else 'YES ✗' }} +- **Expires within 30 days:** {{ 'NO ✓' if (iag5_cert_expires_soon.rc | default(1) == 0) else 'YES — renew soon ⚠' }} +- **Details:** +``` +{{ iag5_cert_details.stdout | default('N/A') }} +``` +{% endif %} + +### Client Private Key (`client.private_key_file`) +- **Path:** `{{ iag5_conf_private_key_file.stdout | default('Not configured') }}` +- **Exists:** {{ 'YES ✓' if (iag5_key_stat.stat.exists | default(false)) else 'NO ✗' }} +{% if iag5_key_stat.stat.exists | default(false) %} +- **Mode:** `{{ iag5_key_stat.stat.mode | default('Unknown') }}` *(expected: 0400)* +{% endif %} + +### Encryption Key File (`secrets.encrypt_key_file`) +- **Path:** `{{ iag5_conf_encrypt_key_file.stdout | default('Not configured') }}` +- **Exists:** {{ 'YES ✓' if (iag5_encrypt_key_stat.stat.exists | default(false)) else 'NO ✗' }} +{% if iag5_encrypt_key_stat.stat.exists | default(false) %} +- **Mode:** `{{ iag5_encrypt_key_stat.stat.mode | default('Unknown') }}` *(expected: 0600)* +{% endif %} + +--- + +## Connectivity + +### Server (`client.host`) +- **Configured Server:** {{ iag5_conf_client_host.stdout | default('Not configured') }}:{{ iag5_conf_client_port.stdout | default('Unknown') }} +- **TCP Reachable:** {{ 'YES ✓' if not (iag5_server_tcp_reachable.failed | default(true)) else 'NO ✗' }} diff --git a/roles/gateway_server/defaults/main/connect.yml b/roles/gateway_server/defaults/main/connect.yml index 197fdfa..e805baa 100644 --- a/roles/gateway_server/defaults/main/connect.yml +++ b/roles/gateway_server/defaults/main/connect.yml @@ -17,3 +17,8 @@ gateway_server_connect_server_ha_is_primary: false gateway_server_connect_insecure_tls: false gateway_server_connect_certificate_file: "{{ gateway_server_pki_cert_dest }}" gateway_server_connect_private_key_file: "{{ gateway_server_pki_key_dest }}" + +gateway_server_connect_proxy_enabled: false +gateway_server_connect_proxy_url: +gateway_server_connect_proxy_username: +gateway_server_connect_proxy_password: diff --git a/roles/gateway_server/defaults/main/environment.yml b/roles/gateway_server/defaults/main/environment.yml new file mode 100644 index 0000000..8e7020e --- /dev/null +++ b/roles/gateway_server/defaults/main/environment.yml @@ -0,0 +1,17 @@ +# Copyright (c) 2025, Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +############################### +# Gateway environment variables +############################### + +# Free-form key/value pairs written directly to the systemd environment file (iagctl.env). +# Use this to set system-level environment variables such as HTTP_PROXY, HTTPS_PROXY, and +# NO_PROXY for controlling outbound traffic from the gateway process. +# +# Example: +# gateway_server_environment: +# HTTP_PROXY: "http://proxy.example.com:3128" +# HTTPS_PROXY: "http://proxy.example.com:3128" +# NO_PROXY: "noproxy.example.com" +gateway_server_environment: {} diff --git a/roles/gateway_server/defaults/main/install.yml b/roles/gateway_server/defaults/main/install.yml index fc4ab2b..0bb8d80 100644 --- a/roles/gateway_server/defaults/main/install.yml +++ b/roles/gateway_server/defaults/main/install.yml @@ -16,9 +16,15 @@ gateway_server_group: itential # Gateway configuration and data directories gateway_server_config_dir: /etc/gateway gateway_server_data_dir: /var/lib/gateway +gateway_server_cache_dir: /var/cache/gateway + +# Gateway service environment file +gateway_server_service_env_file: "{{ gateway_server_config_dir }}/iagctl.env" # Gateway python installation gateway_server_python_packages: + - python3.11 + - python3.11-pip - python3.12 - python3.12-pip gateway_server_python_executable: /usr/bin/python3.12 diff --git a/roles/gateway_server/defaults/main/specs.yml b/roles/gateway_server/defaults/main/specs.yml new file mode 100644 index 0000000..da3af94 --- /dev/null +++ b/roles/gateway_server/defaults/main/specs.yml @@ -0,0 +1,18 @@ +# Copyright (c) 2026, Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +################################## +# Gateway hardware specifications +################################## + +# Minimum hardware requirements for IAG5 server nodes. +gateway_server_hw_specs: + cpu_min: 1 + ram_min_gb: 2 + disk_min_gb: 10 + +# Minimum hardware requirements for IAG5 runner nodes. +gateway_runner_hw_specs: + cpu_min: 4 + ram_min_gb: 8 + disk_min_gb: 20 diff --git a/roles/gateway_server/tasks/verify_cert.yml b/roles/gateway_server/tasks/certify-tls.yml similarity index 81% rename from roles/gateway_server/tasks/verify_cert.yml rename to roles/gateway_server/tasks/certify-tls.yml index 38db600..ad38c84 100644 --- a/roles/gateway_server/tasks/verify_cert.yml +++ b/roles/gateway_server/tasks/certify-tls.yml @@ -1,6 +1,6 @@ --- # TLS certificate verification for gateway server and runner nodes. -# Called via: include_role: name: itential.iag5.gateway_server tasks_from: verify_cert.yml +# Called via: include_role: name: itential.iag5.gateway_server tasks_from: certify-tls.yml # Requires: itential.iag5.gateway role to have been loaded (provides gateway_* vars). - name: "Set verification context" @@ -16,9 +16,9 @@ iag5_client_group: iag5_clients os_ca_bundle: /etc/pki/tls/certs/ca-bundle.crt private_ip: "{{ ansible_default_ipv4.address | default(ansible_host) }}" - verify_cert_report_file: "/tmp/verify_cert_{{ inventory_hostname }}.md" - verify_cert_report_dir_local: "./verify_cert_reports" - verify_cert_report_template: "{{ role_path }}/../verify_cert_common/templates/verify-cert-report.md.j2" + certify_report_file: "/tmp/certify-tls-{{ inventory_hostname }}.md" + certify_report_dir_local: "./certify_reports" + certify_report_template: "{{ role_path }}/../certify_common/templates/certify-report.md.j2" report_file_owner: "{{ gateway_server_user }}" report_file_group: "{{ gateway_server_group }}" check_results: [] @@ -31,13 +31,13 @@ # ----------------------------------------------------------------------- - name: "SUITE 1 | Run cluster server↔runner checks" - ansible.builtin.include_tasks: ../../verify_cert_common/tasks/verify_cert_cluster_server_to_runner.yml + ansible.builtin.include_tasks: ../../certify_common/tasks/certify_cluster_server_to_runner.yml when: > node_section == 'runner' or ('iag5_runners' in groups and groups['iag5_runners'] | length > 0) - name: "SUITE 1 | Print summary" - ansible.builtin.include_tasks: ../../verify_cert_common/tasks/summary.yml + ansible.builtin.include_tasks: ../../certify_common/tasks/summary.yml vars: role_title: "CLUSTER TLS — SERVER ↔ RUNNER (gRPC mTLS) — {{ node_section | upper }} NODE" when: > @@ -55,11 +55,11 @@ when: is_server_node | bool and 'iag5_clients' in groups and groups['iag5_clients'] | length > 0 - name: "SUITE 2 | Run cluster client↔server checks (server side)" - ansible.builtin.include_tasks: ../../verify_cert_common/tasks/verify_cert_cluster_client_to_server.yml + ansible.builtin.include_tasks: ../../certify_common/tasks/certify_cluster_client_to_server.yml when: is_server_node | bool and 'iag5_clients' in groups and groups['iag5_clients'] | length > 0 - name: "SUITE 2 | Print summary" - ansible.builtin.include_tasks: ../../verify_cert_common/tasks/summary.yml + ansible.builtin.include_tasks: ../../certify_common/tasks/summary.yml vars: role_title: "CLUSTER TLS — CLIENT ↔ SERVER (gRPC mTLS) — SERVER NODE" when: is_server_node | bool and 'iag5_clients' in groups and groups['iag5_clients'] | length > 0 @@ -78,11 +78,11 @@ when: is_server_node | bool - name: "SUITE 3 | Run connect server→GWM checks" - ansible.builtin.include_tasks: ../../verify_cert_common/tasks/verify_cert_connect_server_to_gwm.yml + ansible.builtin.include_tasks: ../../certify_common/tasks/certify_connect_server_to_gwm.yml when: is_server_node | bool - name: "SUITE 3 | Print summary" - ansible.builtin.include_tasks: ../../verify_cert_common/tasks/summary.yml + ansible.builtin.include_tasks: ../../certify_common/tasks/summary.yml vars: role_title: "CONNECT TLS — SERVER → GATEWAY MANAGER (one-way TLS + app-layer auth) — CONNECT NODE" when: is_server_node | bool diff --git a/roles/gateway_server/tasks/certify.yml b/roles/gateway_server/tasks/certify.yml new file mode 100644 index 0000000..3c987ef --- /dev/null +++ b/roles/gateway_server/tasks/certify.yml @@ -0,0 +1,591 @@ +# Copyright (c) 2026, Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +# Forensic installation certification for IAG5 gateway server and runner nodes. +# Read-only — nothing is modified on the host. +# Called via: include_role: name: itential.iag5.gateway_server tasks_from: certify.yml + +# ============================================================================ +# SETUP +# ============================================================================ + +- name: Set certify context + ansible.builtin.set_fact: + iag5_node_type: "{{ gateway_application_mode | default('server') }}" + iag5_gateway_conf: "{{ gateway_server_config_dir }}/gateway.conf" + iag5_certify_report_dir_remote: /tmp + iag5_certify_report_dir_local: ./certify_reports + +- name: Set report filename + ansible.builtin.set_fact: + iag5_certify_report_file: "{{ iag5_certify_report_dir_remote }}/certify-iag5-{{ iag5_node_type }}-report-{{ inventory_hostname }}.md" + +# ============================================================================ +# SERVICE & PROCESS STATUS +# ============================================================================ + +- name: Check iagctl systemd service status + ansible.builtin.systemd: + name: iagctl + register: iag5_service_status + ignore_errors: true + +- name: Check iagctl process is running + ansible.builtin.shell: ps aux | grep -v grep | grep iagctl + register: iag5_process + ignore_errors: true + changed_when: false + +- name: Check iagctl listening ports + ansible.builtin.shell: ss -tulpn | grep iagctl + register: iag5_ports + ignore_errors: true + changed_when: false + +- name: Check iagctl systemd unit file exists + ansible.builtin.stat: + path: /usr/lib/systemd/system/iagctl.service + register: iag5_systemd_file + +# ============================================================================ +# CONFIG FILE +# ============================================================================ + +- name: Check gateway config file exists + ansible.builtin.stat: + path: "{{ iag5_gateway_conf }}" + register: iag5_conf_file + +- name: Get gateway config file permissions + ansible.builtin.command: "ls -la {{ iag5_gateway_conf }}" + register: iag5_conf_permissions + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +# ============================================================================ +# PARSE CONFIG — [application] +# ============================================================================ + +- name: Parse config — application.mode + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('application','mode',fallback='').strip(chr(39)))" + register: iag5_conf_mode + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — application.cluster_id + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('application','cluster_id',fallback='').strip(chr(39)))" + register: iag5_conf_cluster_id + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — application.working_dir + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('application','working_dir',fallback='').strip(chr(39)))" + register: iag5_conf_working_dir + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — application.ca_certificate_file + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('application','ca_certificate_file',fallback='').strip(chr(39)))" + register: iag5_conf_ca_cert_file + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +# ============================================================================ +# PARSE CONFIG — [server] or [runner] +# ============================================================================ + +- name: Parse config — node listen_address + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('{{ iag5_node_type }}','listen_address',fallback='').strip(chr(39)))" + register: iag5_conf_listen_address + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — node port + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('{{ iag5_node_type }}','port',fallback='').strip(chr(39)))" + register: iag5_conf_port + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — node use_tls + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('{{ iag5_node_type }}','use_tls',fallback='').strip(chr(39)))" + register: iag5_conf_use_tls + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — node certificate_file + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('{{ iag5_node_type }}','certificate_file',fallback='').strip(chr(39)))" + register: iag5_conf_certificate_file + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — node private_key_file + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('{{ iag5_node_type }}','private_key_file',fallback='').strip(chr(39)))" + register: iag5_conf_private_key_file + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — server.distributed_execution + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('server','distributed_execution',fallback='').strip(chr(39)))" + register: iag5_conf_distributed_execution + ignore_errors: true + changed_when: false + when: + - iag5_conf_file.stat.exists + - iag5_node_type == 'server' + +- name: Parse config — server.api_key_expiration + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('server','api_key_expiration',fallback='').strip(chr(39)))" + register: iag5_conf_api_key_expiration + ignore_errors: true + changed_when: false + when: + - iag5_conf_file.stat.exists + - iag5_node_type == 'server' + +- name: Parse config — runner.announcement_address + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('runner','announcement_address',fallback='').strip(chr(39)))" + register: iag5_conf_announcement_address + ignore_errors: true + changed_when: false + when: + - iag5_conf_file.stat.exists + - iag5_node_type == 'runner' + +# ============================================================================ +# PARSE CONFIG — [connect] (server only) +# ============================================================================ + +- name: Parse config — connect.enabled + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('connect','enabled',fallback='false').strip(chr(39)))" + register: iag5_conf_connect_enabled + ignore_errors: true + changed_when: false + when: + - iag5_conf_file.stat.exists + - iag5_node_type == 'server' + +- name: Parse config — connect.hosts + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('connect','hosts',fallback='').strip(chr(39)))" + register: iag5_conf_connect_hosts + ignore_errors: true + changed_when: false + when: + - iag5_conf_file.stat.exists + - iag5_node_type == 'server' + +- name: Parse config — connect.certificate_file + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('connect','certificate_file',fallback='').strip(chr(39)))" + register: iag5_conf_connect_cert_file + ignore_errors: true + changed_when: false + when: + - iag5_conf_file.stat.exists + - iag5_node_type == 'server' + +- name: Parse config — connect.private_key_file + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('connect','private_key_file',fallback='').strip(chr(39)))" + register: iag5_conf_connect_key_file + ignore_errors: true + changed_when: false + when: + - iag5_conf_file.stat.exists + - iag5_node_type == 'server' + +- name: Parse config — connect.insecure_tls + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('connect','insecure_tls',fallback='').strip(chr(39)))" + register: iag5_conf_connect_insecure_tls + ignore_errors: true + changed_when: false + when: + - iag5_conf_file.stat.exists + - iag5_node_type == 'server' + +# ============================================================================ +# PARSE CONFIG — [log], [store], [secrets] +# ============================================================================ + +- name: Parse config — log.level + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('log','level',fallback='').strip(chr(39)))" + register: iag5_conf_log_level + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — log.file_enabled + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('log','file_enabled',fallback='').strip(chr(39)))" + register: iag5_conf_log_file_enabled + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — log.server_dir + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('log','server_dir',fallback='').strip(chr(39)))" + register: iag5_conf_log_dir + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — store.backend + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('store','backend',fallback='').strip(chr(39)))" + register: iag5_conf_store_backend + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +- name: Parse config — secrets.encrypt_key_file + ansible.builtin.shell: + cmd: >- + python3 -c "import configparser; c=configparser.RawConfigParser(); + c.read('{{ iag5_gateway_conf }}'); + print(c.get('secrets','encrypt_key_file',fallback='').strip(chr(39)))" + register: iag5_conf_encrypt_key_file + ignore_errors: true + changed_when: false + when: iag5_conf_file.stat.exists + +# ============================================================================ +# TLS FILE CHECKS +# ============================================================================ + +- name: Check CA certificate file exists + ansible.builtin.stat: + path: "{{ iag5_conf_ca_cert_file.stdout }}" + register: iag5_ca_cert_stat + when: iag5_conf_ca_cert_file.stdout | default('') != '' + +- name: Check node certificate file exists + ansible.builtin.stat: + path: "{{ iag5_conf_certificate_file.stdout }}" + register: iag5_cert_stat + when: iag5_conf_certificate_file.stdout | default('') != '' + +- name: Check node private key file exists + ansible.builtin.stat: + path: "{{ iag5_conf_private_key_file.stdout }}" + register: iag5_key_stat + when: iag5_conf_private_key_file.stdout | default('') != '' + +- name: Check connect certificate file exists + ansible.builtin.stat: + path: "{{ iag5_conf_connect_cert_file.stdout }}" + register: iag5_connect_cert_stat + when: + - iag5_node_type == 'server' + - iag5_conf_connect_cert_file.stdout | default('') != '' + +- name: Check connect private key file exists + ansible.builtin.stat: + path: "{{ iag5_conf_connect_key_file.stdout }}" + register: iag5_connect_key_stat + when: + - iag5_node_type == 'server' + - iag5_conf_connect_key_file.stdout | default('') != '' + +- name: Check encryption key file exists + ansible.builtin.stat: + path: "{{ iag5_conf_encrypt_key_file.stdout }}" + register: iag5_encrypt_key_stat + when: iag5_conf_encrypt_key_file.stdout | default('') != '' + +# ============================================================================ +# TLS CERTIFICATE DETAILS +# ============================================================================ + +- name: Get CA certificate details + ansible.builtin.command: > + openssl x509 -noout -subject -issuer -enddate + -in {{ iag5_conf_ca_cert_file.stdout }} + register: iag5_ca_cert_details + ignore_errors: true + changed_when: false + when: + - iag5_conf_ca_cert_file.stdout | default('') != '' + - iag5_ca_cert_stat.stat.exists | default(false) + +- name: Get node certificate details (subject, issuer, expiry, SANs) + ansible.builtin.command: > + openssl x509 -noout -subject -issuer -enddate -ext subjectAltName + -in {{ iag5_conf_certificate_file.stdout }} + register: iag5_cert_details + ignore_errors: true + changed_when: false + when: + - iag5_conf_certificate_file.stdout | default('') != '' + - iag5_cert_stat.stat.exists | default(false) + +- name: Check node certificate is not expired + ansible.builtin.command: > + openssl x509 -checkend 0 + -in {{ iag5_conf_certificate_file.stdout }} + register: iag5_cert_not_expired + ignore_errors: true + changed_when: false + failed_when: false + when: + - iag5_conf_certificate_file.stdout | default('') != '' + - iag5_cert_stat.stat.exists | default(false) + +- name: Check node certificate expires within 30 days + ansible.builtin.command: > + openssl x509 -checkend 2592000 + -in {{ iag5_conf_certificate_file.stdout }} + register: iag5_cert_expires_soon + ignore_errors: true + changed_when: false + failed_when: false + when: + - iag5_conf_certificate_file.stdout | default('') != '' + - iag5_cert_stat.stat.exists | default(false) + +- name: Get connect certificate details (subject, issuer, expiry, SANs) + ansible.builtin.command: > + openssl x509 -noout -subject -issuer -enddate -ext subjectAltName + -in {{ iag5_conf_connect_cert_file.stdout }} + register: iag5_connect_cert_details + ignore_errors: true + changed_when: false + when: + - iag5_node_type == 'server' + - iag5_conf_connect_cert_file.stdout | default('') != '' + - iag5_connect_cert_stat.stat.exists | default(false) + +- name: Check connect certificate is not expired + ansible.builtin.command: > + openssl x509 -checkend 0 + -in {{ iag5_conf_connect_cert_file.stdout }} + register: iag5_connect_cert_not_expired + ignore_errors: true + changed_when: false + failed_when: false + when: + - iag5_node_type == 'server' + - iag5_conf_connect_cert_file.stdout | default('') != '' + - iag5_connect_cert_stat.stat.exists | default(false) + +- name: Check connect certificate expires within 30 days + ansible.builtin.command: > + openssl x509 -checkend 2592000 + -in {{ iag5_conf_connect_cert_file.stdout }} + register: iag5_connect_cert_expires_soon + ignore_errors: true + changed_when: false + failed_when: false + when: + - iag5_node_type == 'server' + - iag5_conf_connect_cert_file.stdout | default('') != '' + - iag5_connect_cert_stat.stat.exists | default(false) + +# ============================================================================ +# RUNTIME +# ============================================================================ + +- name: Get iagctl version + ansible.builtin.shell: >- + (/opt/gateway/iagctl version 2>/dev/null || + iagctl version 2>/dev/null || + echo "version: not found") | awk '/^version/{print $2}' + register: iag5_iagctl_version + ignore_errors: true + changed_when: false + failed_when: false + +- name: Get iagctl binary path + ansible.builtin.shell: > + ls -la /opt/gateway/iagctl 2>/dev/null || + which iagctl 2>/dev/null || + echo "not found" + register: iag5_iagctl_path + ignore_errors: true + changed_when: false + failed_when: false + +- name: Get Python version (as itential user) + ansible.builtin.command: python3 --version + register: iag5_python_version + ignore_errors: true + changed_when: false + failed_when: false + become: true + become_user: "{{ gateway_server_user }}" + become_flags: '-i' + +- name: Get pip version (as itential user) + ansible.builtin.command: python3 -m pip --version + register: iag5_pip_version + ignore_errors: true + changed_when: false + failed_when: false + become: true + become_user: "{{ gateway_server_user }}" + become_flags: '-i' + +- name: Get installed Python packages (as itential user) + ansible.builtin.command: python3 -m pip list + register: iag5_python_packages + ignore_errors: true + changed_when: false + failed_when: false + become: true + become_user: "{{ gateway_server_user }}" + become_flags: '-i' + +# ============================================================================ +# LOG FILES +# ============================================================================ + +- name: Check log directory exists + ansible.builtin.stat: + path: "{{ iag5_conf_log_dir.stdout }}" + register: iag5_log_dir_stat + when: iag5_conf_log_dir.stdout | default('') != '' + +# ============================================================================ +# CONNECTIVITY +# ============================================================================ + +- name: Check TCP reachability to each runner (server only) + ansible.builtin.wait_for: + host: "{{ hostvars[item]['ansible_host'] | default(item) }}" + port: "{{ gateway_server_port | int }}" + timeout: 5 + state: started + register: iag5_runner_tcp_checks + ignore_errors: true + changed_when: false + loop: "{{ groups['iag5_runners'] | default([]) }}" + when: + - iag5_node_type == 'server' + - groups['iag5_runners'] | default([]) | length > 0 + +- name: Check TCP reachability to each GWM host (server only) + ansible.builtin.wait_for: + host: "{{ item.split(':')[0] }}" + port: "{{ item.split(':')[1] | int }}" + timeout: 5 + state: started + register: iag5_gwm_tcp_checks + ignore_errors: true + changed_when: false + loop: "{{ iag5_conf_connect_hosts.stdout.split() }}" + when: + - iag5_node_type == 'server' + - iag5_conf_connect_hosts.stdout | default('') != '' + - iag5_conf_connect_enabled.stdout | default('false') | bool + +# ============================================================================ +# GENERATE REPORT +# ============================================================================ + +- name: Generate IAG5 installation certification report + ansible.builtin.template: + src: certify-iag5-server-report.md.j2 + dest: "{{ iag5_certify_report_file }}" + mode: '0644' + +- name: Copy certification report to control node + ansible.builtin.fetch: + src: "{{ iag5_certify_report_file }}" + dest: "{{ iag5_certify_report_dir_local }}/" + flat: true + fail_on_missing: false + +- name: Display certification summary + ansible.builtin.debug: + msg: + - "IAG5 certification complete for {{ inventory_hostname }} ({{ iag5_node_type }})" + - "Overall Status: {{ 'PASSED ✓' if (iag5_process is defined and iag5_process.rc == 0) else 'FAILED ✗' }}" + - "Report: {{ iag5_certify_report_file }} (remote) → {{ iag5_certify_report_dir_local }}/ (control node)" diff --git a/roles/gateway_server/tasks/configure_gateway.yml b/roles/gateway_server/tasks/configure_gateway.yml index 684bc52..aab13e3 100644 --- a/roles/gateway_server/tasks/configure_gateway.yml +++ b/roles/gateway_server/tasks/configure_gateway.yml @@ -31,6 +31,20 @@ group: root mode: "0644" +- name: Create the gateway systemd environment file + ansible.builtin.template: + src: iagctl.env.j2 + dest: "{{ gateway_server_service_env_file }}" + mode: "0600" + owner: "{{ gateway_server_user }}" + group: "{{ gateway_server_group }}" + lstrip_blocks: true + backup: true + when: >- + gateway_server_store_backend == 'dynamodb' or + gateway_server_environment | length > 0 or + gateway_server_connect_proxy_enabled | bool + - name: Create the gateway encryption key file ansible.builtin.copy: content: "{{ gateway_secrets_encrypt_key }}" diff --git a/roles/gateway_server/tasks/install_gateway.yml b/roles/gateway_server/tasks/install_gateway.yml index 827946c..f97656b 100644 --- a/roles/gateway_server/tasks/install_gateway.yml +++ b/roles/gateway_server/tasks/install_gateway.yml @@ -22,6 +22,7 @@ with_items: - "{{ gateway_server_config_dir }}" - "{{ gateway_server_data_dir }}" + - "{{ gateway_server_cache_dir }}" - "{{ gateway_secrets_encrypt_key_dir }}" - "{{ gateway_server_log_server_dir }}" diff --git a/roles/gateway_server/tasks/verify.yml b/roles/gateway_server/tasks/verify.yml new file mode 100644 index 0000000..60dae91 --- /dev/null +++ b/roles/gateway_server/tasks/verify.yml @@ -0,0 +1,26 @@ +# Copyright (c) 2026, Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +- name: Verify OS compatibility + ansible.builtin.include_role: + name: itential.iag5.verify_common + tasks_from: verify-os.yml + +- name: Verify hardware specifications + ansible.builtin.include_role: + name: itential.iag5.verify_common + tasks_from: verify-specs.yml + vars: + component_name: "{{ 'Runner' if gateway_application_mode == 'runner' else 'Server' }}" + hw_specs: "{{ gateway_runner_hw_specs if gateway_application_mode == 'runner' else gateway_server_hw_specs }}" + +- name: Verify TLS source files + when: gateway_pki_upload | bool + ansible.builtin.include_role: + name: itential.iag5.verify_common + tasks_from: verify-tls-files.yml + vars: + pki_cert_src: "{{ gateway_server_pki_cert_src }}" + pki_key_src: "{{ gateway_server_pki_key_src }}" + pki_ca_cert_src: "{{ gateway_server_pki_ca_cert_src }}" + check_san: true diff --git a/roles/gateway_server/templates/certify-iag5-server-report.md.j2 b/roles/gateway_server/templates/certify-iag5-server-report.md.j2 new file mode 100644 index 0000000..4be5f43 --- /dev/null +++ b/roles/gateway_server/templates/certify-iag5-server-report.md.j2 @@ -0,0 +1,257 @@ +# IAG5 {{ iag5_node_type | upper }} Installation Certification Report + +| Field | Value | +|---|---| +| **Host** | {{ inventory_hostname }} | +| **Node Type** | {{ iag5_node_type }} | +| **Generated** | {{ ansible_date_time.iso8601 | default('Unknown') }} | +| **IP Address** | {{ ansible_default_ipv4.address | default('N/A') }} | +| **OS** | {{ ansible_distribution | default('Unknown') }} {{ ansible_distribution_version | default('') }} | +| **Overall Status** | {{ 'PASSED ✓' if (iag5_process is defined and iag5_process.rc == 0) else 'FAILED ✗' }} | + +--- + +## Host Details + +### Operating System +- **Distribution:** {{ ansible_distribution | default('Unknown') }} {{ ansible_distribution_version | default('') }} +- **OS Family:** {{ ansible_os_family | default('Unknown') }} +- **Kernel:** {{ ansible_kernel | default('Unknown') }} +- **Architecture:** {{ ansible_architecture | default('Unknown') }} +- **Hostname:** {{ ansible_hostname | default('Unknown') }} +- **FQDN:** {{ ansible_fqdn | default('Unknown') }} + +### Hardware +- **CPU Count:** {{ ansible_processor_count | default('Unknown') }} +- **CPU Cores:** {{ ansible_processor_cores | default('Unknown') }} +- **CPU vCPUs:** {{ ansible_processor_vcpus | default('Unknown') }} +- **Total Memory:** {{ ansible_memtotal_mb | default('Unknown') }} MB +- **Free Memory:** {{ ansible_memfree_mb | default('Unknown') }} MB +- **Swap Total:** {{ ansible_swaptotal_mb | default('Unknown') }} MB + +### Disk Mounts +{% if ansible_mounts is defined and ansible_mounts | length > 0 %} +{% for mount in ansible_mounts %} +- **{{ mount.mount }}:** {{ (mount.size_total / 1073741824) | round(1) }} GB total, {{ (mount.size_available / 1073741824) | round(1) }} GB free +{% endfor %} +{% else %} +- No disk information available +{% endif %} + +### Networking +- **Primary IP:** {{ ansible_default_ipv4.address | default('N/A') }} +- **Gateway:** {{ ansible_default_ipv4.gateway | default('N/A') }} +- **Interface:** {{ ansible_default_ipv4.interface | default('N/A') }} +- **MAC Address:** {{ ansible_default_ipv4.macaddress | default('N/A') }} + +### Security +{% if ansible_selinux is defined %} +- **SELinux Status:** {{ ansible_selinux.status | default('Unknown') }} +- **SELinux Mode:** {{ ansible_selinux.mode | default('Unknown') }} +{% else %} +- **SELinux:** Not available +{% endif %} + +--- + +## Service Status + +{% if iag5_service_status.status is defined %} +- **Service State:** {{ iag5_service_status.status.ActiveState | default('Unknown') }} +- **Service SubState:** {{ iag5_service_status.status.SubState | default('Unknown') }} +- **Service Enabled:** {{ iag5_service_status.status.UnitFileState | default('Unknown') }} +{% else %} +- **Service Status:** Could not determine — service may not exist +{% endif %} +- **Systemd Unit File Exists:** {{ 'YES ✓' if (iag5_systemd_file.stat.exists | default(false)) else 'NO ✗' }} +- **Process Running:** {{ 'YES ✓' if (iag5_process is defined and iag5_process.rc == 0) else 'NO ✗' }} +{% if iag5_process is defined and iag5_process.rc == 0 %} + +**Process Details:** +``` +{{ iag5_process.stdout | default('N/A') }} +``` +{% endif %} + +**Listening Ports:** +``` +{{ iag5_ports.stdout | default('None') }} +``` + +--- + +## Configuration File + +- **Config File Exists:** {{ 'YES ✓' if (iag5_conf_file.stat.exists | default(false)) else 'NO ✗' }} +- **Config File Path:** `{{ iag5_gateway_conf }}` +{% if iag5_conf_file.stat.exists | default(false) %} +- **Permissions:** `{{ iag5_conf_permissions.stdout | default('Unknown') }}` +{% endif %} + +--- + +## Configuration Properties + +### [application] +- **mode:** {{ iag5_conf_mode.stdout | default('Unknown') }} +- **cluster_id:** {{ iag5_conf_cluster_id.stdout | default('Unknown') }} +- **working_dir:** {{ iag5_conf_working_dir.stdout | default('Unknown') }} +- **ca_certificate_file:** {{ iag5_conf_ca_cert_file.stdout | default('Unknown') }} + +### [{{ iag5_node_type }}] +- **listen_address:** {{ iag5_conf_listen_address.stdout | default('Unknown') }} +- **port:** {{ iag5_conf_port.stdout | default('Unknown') }} +- **use_tls:** {{ iag5_conf_use_tls.stdout | default('Unknown') }} +- **certificate_file:** {{ iag5_conf_certificate_file.stdout | default('Unknown') }} +- **private_key_file:** {{ iag5_conf_private_key_file.stdout | default('Unknown') }} +{% if iag5_node_type == 'server' %} +- **distributed_execution:** {{ iag5_conf_distributed_execution.stdout | default('Unknown') }} +- **api_key_expiration:** {{ iag5_conf_api_key_expiration.stdout | default('Unknown') }} minutes +{% endif %} +{% if iag5_node_type == 'runner' %} +- **announcement_address:** {{ (iag5_conf_announcement_address.stdout | default('')) or '(auto — uses default interface IP)' }} +{% endif %} + +### [connect] *(server only)* +{% if iag5_node_type == 'server' %} +- **enabled:** {{ iag5_conf_connect_enabled.stdout | default('Unknown') }} +- **hosts:** {{ iag5_conf_connect_hosts.stdout | default('Unknown') }} +- **certificate_file:** {{ iag5_conf_connect_cert_file.stdout | default('Unknown') }} +- **private_key_file:** {{ iag5_conf_connect_key_file.stdout | default('Unknown') }} +- **insecure_tls:** {{ iag5_conf_connect_insecure_tls.stdout | default('Unknown') }} +{% else %} +- N/A (runner node) +{% endif %} + +### [log] +- **level:** {{ iag5_conf_log_level.stdout | default('Unknown') }} +- **file_enabled:** {{ iag5_conf_log_file_enabled.stdout | default('Unknown') }} +- **server_dir:** {{ iag5_conf_log_dir.stdout | default('Unknown') }} + +### [store] +- **backend:** {{ iag5_conf_store_backend.stdout | default('Unknown') }} + +### [secrets] +- **encrypt_key_file:** {{ iag5_conf_encrypt_key_file.stdout | default('Unknown') }} + +--- + +## TLS Files + +### Application CA Certificate (`application.ca_certificate_file`) +- **Path:** `{{ iag5_conf_ca_cert_file.stdout | default('Not configured') }}` +- **Exists:** {{ 'YES ✓' if (iag5_ca_cert_stat.stat.exists | default(false)) else 'NO ✗' }} +{% if iag5_ca_cert_stat.stat.exists | default(false) %} +- **Mode:** `{{ iag5_ca_cert_stat.stat.mode | default('Unknown') }}` +- **Details:** +``` +{{ iag5_ca_cert_details.stdout | default('N/A') }} +``` +{% endif %} + +### Node Certificate (`{{ iag5_node_type }}.certificate_file`) +- **Path:** `{{ iag5_conf_certificate_file.stdout | default('Not configured') }}` +- **Exists:** {{ 'YES ✓' if (iag5_cert_stat.stat.exists | default(false)) else 'NO ✗' }} +{% if iag5_cert_stat.stat.exists | default(false) %} +- **Mode:** `{{ iag5_cert_stat.stat.mode | default('Unknown') }}` +- **Expired:** {{ 'NO ✓' if (iag5_cert_not_expired.rc | default(1) == 0) else 'YES ✗' }} +- **Expires within 30 days:** {{ 'NO ✓' if (iag5_cert_expires_soon.rc | default(1) == 0) else 'YES — renew soon ⚠' }} +- **Details:** +``` +{{ iag5_cert_details.stdout | default('N/A') }} +``` +{% endif %} + +### Node Private Key (`{{ iag5_node_type }}.private_key_file`) +- **Path:** `{{ iag5_conf_private_key_file.stdout | default('Not configured') }}` +- **Exists:** {{ 'YES ✓' if (iag5_key_stat.stat.exists | default(false)) else 'NO ✗' }} +{% if iag5_key_stat.stat.exists | default(false) %} +- **Mode:** `{{ iag5_key_stat.stat.mode | default('Unknown') }}` *(expected: 0400)* +{% endif %} + +{% if iag5_node_type == 'server' %} +### Connect Certificate (`connect.certificate_file`) +- **Path:** `{{ iag5_conf_connect_cert_file.stdout | default('Not configured') }}` +- **Exists:** {{ 'YES ✓' if (iag5_connect_cert_stat.stat.exists | default(false)) else 'NO ✗' }} +{% if iag5_connect_cert_stat.stat.exists | default(false) %} +- **Mode:** `{{ iag5_connect_cert_stat.stat.mode | default('Unknown') }}` +- **Expired:** {{ 'NO ✓' if (iag5_connect_cert_not_expired.rc | default(1) == 0) else 'YES ✗' }} +- **Expires within 30 days:** {{ 'NO ✓' if (iag5_connect_cert_expires_soon.rc | default(1) == 0) else 'YES — renew soon ⚠' }} +- **Details:** +``` +{{ iag5_connect_cert_details.stdout | default('N/A') }} +``` +{% endif %} + +### Connect Private Key (`connect.private_key_file`) +- **Path:** `{{ iag5_conf_connect_key_file.stdout | default('Not configured') }}` +- **Exists:** {{ 'YES ✓' if (iag5_connect_key_stat.stat.exists | default(false)) else 'NO ✗' }} +{% if iag5_connect_key_stat.stat.exists | default(false) %} +- **Mode:** `{{ iag5_connect_key_stat.stat.mode | default('Unknown') }}` *(expected: 0400)* +{% endif %} +{% endif %} + +### Encryption Key File (`secrets.encrypt_key_file`) +- **Path:** `{{ iag5_conf_encrypt_key_file.stdout | default('Not configured') }}` +- **Exists:** {{ 'YES ✓' if (iag5_encrypt_key_stat.stat.exists | default(false)) else 'NO ✗' }} +{% if iag5_encrypt_key_stat.stat.exists | default(false) %} +- **Mode:** `{{ iag5_encrypt_key_stat.stat.mode | default('Unknown') }}` *(expected: 0600)* +{% endif %} + +--- + +## Runtime + +- **iagctl Version:** {{ iag5_iagctl_version.stdout | default('Unknown') }} +- **iagctl Path:** {{ iag5_iagctl_path.stdout | default('Unknown') }} +- **Python Version:** {{ iag5_python_version.stdout | default('Unknown') }} +- **Pip Version:** {{ iag5_pip_version.stdout | default('Unknown') }} + +**Installed Python Packages:** +``` +{{ iag5_python_packages.stdout | default('Unknown') }} +``` + +--- + +## Log Files + +- **Log Directory:** `{{ iag5_conf_log_dir.stdout | default('Not configured') }}` +- **Log Directory Exists:** {{ 'YES ✓' if (iag5_log_dir_stat.stat.exists | default(false)) else 'NO ✗' }} + +--- + +## Connectivity + +{% if iag5_node_type == 'server' -%} + +### Runner Nodes (port {{ gateway_server_port }}) +{% if iag5_runner_tcp_checks.results is defined and iag5_runner_tcp_checks.results | length > 0 %} + +| Runner | Address | TCP Reachable | +|--------|---------|---------------| +{% for result in iag5_runner_tcp_checks.results %} +| {{ result.item }} | {{ hostvars[result.item]['ansible_host'] | default(result.item) }}:{{ gateway_server_port }} | {{ 'YES ✓' if not result.failed else 'NO ✗' }} | +{% endfor %} +{% else %} +- No runners in inventory +{% endif %} + +### Gateway Manager (`connect.hosts`) +- **connect.enabled:** {{ iag5_conf_connect_enabled.stdout | default('Unknown') }} +- **connect.insecure_tls:** {{ iag5_conf_connect_insecure_tls.stdout | default('Unknown') }} +{% if iag5_gwm_tcp_checks.results is defined and iag5_gwm_tcp_checks.results | length > 0 %} + +| GWM Host | TCP Reachable | +|----------|--------------| +{% for result in iag5_gwm_tcp_checks.results %} +| {{ result.item }} | {{ 'YES ✓' if not result.failed else 'NO ✗' }} | +{% endfor %} +{% else %} +- connect.enabled = false or no hosts configured +{% endif %} +{% else -%} + +### Connectivity Note +Runner TCP reachability (port {{ gateway_server_port }}) is validated from the server side. See the server node report. +{% endif %} diff --git a/roles/gateway_server/templates/iagctl.env.j2 b/roles/gateway_server/templates/iagctl.env.j2 new file mode 100644 index 0000000..c18734b --- /dev/null +++ b/roles/gateway_server/templates/iagctl.env.j2 @@ -0,0 +1,22 @@ +# Copyright (c) 2025, Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +{% if gateway_server_store_backend == 'dynamodb' %} +AWS_ACCESS_KEY_ID={{ gateway_server_store_dynamodb_aws_access_key_id }} +AWS_SECRET_ACCESS_KEY={{ gateway_server_store_dynamodb_aws_secret_access_key }} +AWS_SESSION_TOKEN={{ gateway_server_store_dynamodb_aws_session_token }} +AWS_REGION={{ gateway_server_store_dynamodb_aws_region }} +{% endif %} +{% if gateway_server_connect_proxy_enabled | bool %} +GATEWAY_CONNECT_PROXY_URL={{ gateway_server_connect_proxy_url }} +{% if gateway_server_connect_proxy_username %} +GATEWAY_CONNECT_PROXY_USERNAME={{ gateway_server_connect_proxy_username }} +{% endif %} +{% if gateway_server_connect_proxy_password %} +GATEWAY_CONNECT_PROXY_PASSWORD={{ gateway_server_connect_proxy_password }} +{% endif %} +{% endif %} +{% if gateway_server_environment %} +{% for key, value in gateway_server_environment.items() %} +{{ key }}={{ value }} +{% endfor %} +{% endif %} diff --git a/roles/gateway_server/templates/iagctl.service.j2 b/roles/gateway_server/templates/iagctl.service.j2 index 11f7d06..de110f6 100644 --- a/roles/gateway_server/templates/iagctl.service.j2 +++ b/roles/gateway_server/templates/iagctl.service.j2 @@ -5,13 +5,11 @@ After=network.target [Service] User={{ gateway_server_user }} Group={{ gateway_server_group }} +Delegate=yes ExecStart=/opt/gateway/iagctl {{ gateway_application_mode }} --config {{ gateway_server_config_dir }}/gateway.conf Restart=on-failure -{% if gateway_server_store_backend == 'dynamodb' %} -Environment=AWS_ACCESS_KEY_ID={{ gateway_server_store_dynamodb_aws_access_key_id }} -Environment=AWS_SECRET_ACCESS_KEY={{ gateway_server_store_dynamodb_aws_secret_access_key }} -Environment=AWS_SESSION_TOKEN={{ gateway_server_store_dynamodb_aws_session_token }} -Environment=AWS_REGION={{ gateway_server_store_dynamodb_aws_region }} +{% if gateway_server_store_backend == 'dynamodb' or gateway_server_environment or gateway_server_connect_proxy_enabled | bool %} +EnvironmentFile={{ gateway_server_service_env_file }} {% endif %} [Install] diff --git a/roles/verify_common/defaults/main.yml b/roles/verify_common/defaults/main.yml new file mode 100644 index 0000000..8db850c --- /dev/null +++ b/roles/verify_common/defaults/main.yml @@ -0,0 +1,3 @@ +# Copyright (c) 2026, Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +--- diff --git a/roles/verify_common/tasks/verify-os.yml b/roles/verify_common/tasks/verify-os.yml new file mode 100644 index 0000000..7720370 --- /dev/null +++ b/roles/verify_common/tasks/verify-os.yml @@ -0,0 +1,20 @@ +# Copyright (c) 2026, Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +- name: Assert supported OS + ansible.builtin.assert: + that: + - ansible_distribution in ['RedHat', 'Rocky'] + - ansible_distribution_major_version in ['8', '9'] + fail_msg: >- + {{ ansible_distribution }} {{ ansible_distribution_version }} is not supported. + Supported OS: RHEL 8/9, Rocky Linux 8/9. + success_msg: "OS check passed: {{ ansible_distribution }} {{ ansible_distribution_version }}" + +- name: Assert supported architecture + ansible.builtin.assert: + that: ansible_architecture == 'x86_64' + fail_msg: >- + {{ ansible_architecture }} is not a supported architecture. + Supported architecture: x86_64. + success_msg: "Architecture check passed: {{ ansible_architecture }}" diff --git a/roles/verify_common/tasks/verify-specs.yml b/roles/verify_common/tasks/verify-specs.yml new file mode 100644 index 0000000..b7bcf9f --- /dev/null +++ b/roles/verify_common/tasks/verify-specs.yml @@ -0,0 +1,66 @@ +# Copyright (c) 2026, Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +# Validates that the managed node meets minimum hardware requirements. +# Variables required: +# component_name: display name used in messages (e.g. "Server", "Runner") +# hw_specs: dict with keys cpu_min (int), ram_min_gb (int), disk_min_gb (int) + +- name: Initialize specs validation errors + ansible.builtin.set_fact: + specs_validation_errors: [] + +- name: Get root partition disk size + ansible.builtin.set_fact: + root_disk_size_gb: >- + {{ (ansible_mounts | selectattr('mount', 'equalto', '/') | map(attribute='size_total') | first / 1024 / 1024 / 1024) | round(2) }} + when: ansible_mounts | selectattr('mount', 'equalto', '/') | list | length > 0 + +- name: Validate CPU count + ansible.builtin.assert: + that: ansible_processor_vcpus >= hw_specs.cpu_min | int + fail_msg: "CPU: requires {{ hw_specs.cpu_min }} vCPU(s), found {{ ansible_processor_vcpus }}" + quiet: true + ignore_errors: true + register: specs_cpu_check + +- name: Record CPU failure + ansible.builtin.set_fact: + specs_validation_errors: >- + {{ specs_validation_errors + ['CPU: requires ' ~ hw_specs.cpu_min ~ ' vCPU(s), found ' ~ ansible_processor_vcpus] }} + when: specs_cpu_check is failed + +- name: Validate RAM + ansible.builtin.assert: + that: (ansible_memtotal_mb / 1024) >= hw_specs.ram_min_gb | float + fail_msg: "RAM: requires {{ hw_specs.ram_min_gb }} GB, found {{ (ansible_memtotal_mb / 1024) | round(2) }} GB" + quiet: true + ignore_errors: true + register: specs_ram_check + +- name: Record RAM failure + ansible.builtin.set_fact: + specs_validation_errors: >- + {{ specs_validation_errors + ['RAM: requires ' ~ hw_specs.ram_min_gb ~ ' GB, found ' ~ (ansible_memtotal_mb / 1024) | round(2) ~ ' GB'] }} + when: specs_ram_check is failed + +- name: Validate disk size + ansible.builtin.assert: + that: root_disk_size_gb | float >= hw_specs.disk_min_gb | float + fail_msg: "Disk: requires {{ hw_specs.disk_min_gb }} GB, found {{ root_disk_size_gb }} GB" + quiet: true + ignore_errors: true + register: specs_disk_check + when: root_disk_size_gb is defined + +- name: Record disk failure + ansible.builtin.set_fact: + specs_validation_errors: >- + {{ specs_validation_errors + ['Disk: requires ' ~ hw_specs.disk_min_gb ~ ' GB, found ' ~ root_disk_size_gb ~ ' GB'] }} + when: root_disk_size_gb is defined and specs_disk_check is failed + +- name: Assert all hardware spec checks passed + ansible.builtin.assert: + that: specs_validation_errors | length == 0 + fail_msg: "{{ component_name }} hardware check failed: {{ specs_validation_errors | join('; ') }}" + success_msg: "{{ component_name }} hardware check passed" diff --git a/roles/verify_common/tasks/verify-tls-files.yml b/roles/verify_common/tasks/verify-tls-files.yml new file mode 100644 index 0000000..f24eebc --- /dev/null +++ b/roles/verify_common/tasks/verify-tls-files.yml @@ -0,0 +1,282 @@ +# Copyright (c) 2026, Itential, Inc +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +# Validates TLS source files on the Ansible control node before deployment. +# All tasks run delegate_to: localhost since files reside on the control node. +# +# Variables required: +# pki_cert_src: local path to the node certificate file +# pki_key_src: local path to the node private key file +# pki_ca_cert_src: local path to the CA certificate file +# check_san: true to assert SANs are present (required for server/runner nodes) + +# ----------------------------------------------------------------------- +# File existence +# ----------------------------------------------------------------------- + +- name: Stat cert file + ansible.builtin.stat: + path: "{{ pki_cert_src }}" + register: tls_cert_stat + delegate_to: localhost + become: false + +- name: Assert cert file exists + ansible.builtin.assert: + that: tls_cert_stat.stat.exists and tls_cert_stat.stat.isreg + fail_msg: "Cert file not found: {{ pki_cert_src }}" + quiet: true + +- name: Stat key file + ansible.builtin.stat: + path: "{{ pki_key_src }}" + register: tls_key_stat + delegate_to: localhost + become: false + +- name: Assert key file exists + ansible.builtin.assert: + that: tls_key_stat.stat.exists and tls_key_stat.stat.isreg + fail_msg: "Key file not found: {{ pki_key_src }}" + quiet: true + +- name: Stat CA cert file + ansible.builtin.stat: + path: "{{ pki_ca_cert_src }}" + register: tls_ca_stat + delegate_to: localhost + become: false + +- name: Assert CA cert file exists + ansible.builtin.assert: + that: tls_ca_stat.stat.exists and tls_ca_stat.stat.isreg + fail_msg: "CA cert file not found: {{ pki_ca_cert_src }}" + quiet: true + +# ----------------------------------------------------------------------- +# PEM format validation +# ----------------------------------------------------------------------- + +- name: Validate cert is parseable PEM + ansible.builtin.command: + cmd: openssl x509 -noout -in {{ pki_cert_src }} + register: tls_cert_parse + changed_when: false + delegate_to: localhost + become: false + +- name: Assert cert is valid PEM + ansible.builtin.assert: + that: tls_cert_parse.rc == 0 + fail_msg: "Cert is not valid PEM: {{ pki_cert_src }}" + quiet: true + +- name: Validate CA cert is parseable PEM + ansible.builtin.command: + cmd: openssl x509 -noout -in {{ pki_ca_cert_src }} + register: tls_ca_parse + changed_when: false + delegate_to: localhost + become: false + +- name: Assert CA cert is valid PEM + ansible.builtin.assert: + that: tls_ca_parse.rc == 0 + fail_msg: "CA cert is not valid PEM: {{ pki_ca_cert_src }}" + quiet: true + +- name: Validate private key is parseable + ansible.builtin.command: + cmd: openssl pkey -noout -in {{ pki_key_src }} + register: tls_key_parse + changed_when: false + delegate_to: localhost + become: false + +- name: Assert private key is valid + ansible.builtin.assert: + that: tls_key_parse.rc == 0 + fail_msg: "Key file is not a valid private key: {{ pki_key_src }}" + quiet: true + +# ----------------------------------------------------------------------- +# Expiry +# ----------------------------------------------------------------------- + +- name: Check cert is not expired + ansible.builtin.command: + cmd: openssl x509 -noout -checkend 0 -in {{ pki_cert_src }} + register: tls_cert_expiry + changed_when: false + delegate_to: localhost + become: false + +- name: Assert cert is not expired + ansible.builtin.assert: + that: tls_cert_expiry.rc == 0 + fail_msg: "Cert has expired: {{ pki_cert_src }}" + quiet: true + +- name: Check cert does not expire within 30 days + ansible.builtin.command: + cmd: openssl x509 -noout -checkend 2592000 -in {{ pki_cert_src }} + register: tls_cert_expiry_warn + changed_when: false + ignore_errors: true + delegate_to: localhost + become: false + +- name: Warn if cert expires within 30 days + ansible.builtin.debug: + msg: "WARNING: cert expires within 30 days: {{ pki_cert_src }}" + when: tls_cert_expiry_warn.rc != 0 + +# ----------------------------------------------------------------------- +# Cert/key matched pair +# ----------------------------------------------------------------------- + +- name: Extract public key from cert + ansible.builtin.command: + cmd: openssl x509 -noout -pubkey -in {{ pki_cert_src }} + register: tls_cert_pubkey + changed_when: false + delegate_to: localhost + become: false + +- name: Extract public key from private key + ansible.builtin.command: + cmd: openssl pkey -pubout -in {{ pki_key_src }} + register: tls_key_pubkey + changed_when: false + delegate_to: localhost + become: false + +- name: Assert cert and key are a matched pair + ansible.builtin.assert: + that: tls_cert_pubkey.stdout | trim == tls_key_pubkey.stdout | trim + fail_msg: "Cert and key are not a matched pair: {{ pki_cert_src }}, {{ pki_key_src }}" + quiet: true + +# ----------------------------------------------------------------------- +# CA validation +# ----------------------------------------------------------------------- + +- name: Check CA cert has CA:TRUE basic constraint + ansible.builtin.shell: + cmd: openssl x509 -noout -text -in {{ pki_ca_cert_src }} | grep "CA:TRUE" + args: + executable: /bin/bash + register: tls_ca_flag + changed_when: false + ignore_errors: true + delegate_to: localhost + become: false + +- name: Assert CA cert has CA:TRUE + ansible.builtin.assert: + that: tls_ca_flag.rc == 0 + fail_msg: "CA cert does not have CA:TRUE basic constraint: {{ pki_ca_cert_src }}" + quiet: true + +- name: Verify cert is signed by CA + ansible.builtin.command: + cmd: openssl verify -CAfile {{ pki_ca_cert_src }} {{ pki_cert_src }} + register: tls_cert_verify + changed_when: false + ignore_errors: true + delegate_to: localhost + become: false + +- name: Assert cert is signed by CA + ansible.builtin.assert: + that: "'OK' in tls_cert_verify.stdout" + fail_msg: "Cert is not signed by the provided CA: {{ pki_cert_src }}" + quiet: true + +# ----------------------------------------------------------------------- +# Cert properties — self-signed and Extended Key Usage +# ----------------------------------------------------------------------- + +- name: Get cert subject hash + ansible.builtin.command: + cmd: openssl x509 -noout -subject_hash -in {{ pki_cert_src }} + register: tls_cert_subject_hash + changed_when: false + delegate_to: localhost + become: false + +- name: Get cert issuer hash + ansible.builtin.command: + cmd: openssl x509 -noout -issuer_hash -in {{ pki_cert_src }} + register: tls_cert_issuer_hash + changed_when: false + delegate_to: localhost + become: false + +- name: Assert cert is not self-signed + ansible.builtin.assert: + that: tls_cert_subject_hash.stdout | trim != tls_cert_issuer_hash.stdout | trim + fail_msg: "Cert is self-signed (subject hash == issuer hash). Provide a CA-signed cert: {{ pki_cert_src }}" + quiet: true + +- name: Get cert Extended Key Usage + ansible.builtin.shell: + cmd: openssl x509 -noout -text -in {{ pki_cert_src }} | grep -A3 "Extended Key Usage" + args: + executable: /bin/bash + register: tls_cert_eku + changed_when: false + ignore_errors: true + delegate_to: localhost + become: false + +- name: Warn if cert is missing serverAuth in Extended Key Usage + ansible.builtin.debug: + msg: "WARNING: cert is missing serverAuth in Extended Key Usage (recommended for mTLS): {{ pki_cert_src }}" + when: "'Server Authentication' not in tls_cert_eku.stdout" + +- name: Warn if cert is missing clientAuth in Extended Key Usage + ansible.builtin.debug: + msg: "WARNING: cert is missing clientAuth in Extended Key Usage (recommended for mTLS): {{ pki_cert_src }}" + when: "'Client Authentication' not in tls_cert_eku.stdout" + +# ----------------------------------------------------------------------- +# Subject Alternative Names (servers and runners only) +# ----------------------------------------------------------------------- + +- name: Subject Alternative Name checks + when: check_san | bool + block: + - name: Get cert Subject Alternative Names + ansible.builtin.shell: + cmd: openssl x509 -noout -text -in {{ pki_cert_src }} | grep -A2 "Subject Alternative Name" + args: + executable: /bin/bash + register: tls_cert_sans + changed_when: false + ignore_errors: true + delegate_to: localhost + become: false + + - name: Assert cert has Subject Alternative Name extension + ansible.builtin.assert: + that: tls_cert_sans.stdout != '' + fail_msg: "Cert has no Subject Alternative Name extension (required for server mTLS): {{ pki_cert_src }}" + quiet: true + + - name: Warn if inventory_hostname not found in SANs + ansible.builtin.debug: + msg: >- + WARNING: '{{ inventory_hostname }}' not found in cert SANs. + Clients connecting by hostname may fail TLS verification. + when: tls_cert_sans.stdout != '' and inventory_hostname not in tls_cert_sans.stdout + + - name: Warn if ansible_host not found in SANs + ansible.builtin.debug: + msg: >- + WARNING: ansible_host '{{ ansible_host }}' not found in cert SANs. + Clients connecting by IP may fail TLS verification. + when: >- + tls_cert_sans.stdout != '' and + ansible_host != inventory_hostname and + ansible_host not in tls_cert_sans.stdout