From 3dfc80b7828796c983c019387b2b3f7d0d9b46c8 Mon Sep 17 00:00:00 2001
From: admin_rb
Date: Tue, 22 Sep 2026 19:23:17 +0200
Subject: [PATCH] aim-web2.1.0rc9
---
...ectory.sh.example => .aim-core-deploy.lock | 0
.gitignore | 4 +-
README.md | 128 +--
aim.yml | 12 -
deploy/README.md | 205 ++++
deploy/deploy.py | 593 ++++++++++
.../desq_gaming/.aim.lock | 0
.../group_vars/linux/.ssh/svc_ansible | 8 -
.../group_vars/linux/.ssh/svc_ansible.pub | 1 -
.../group_vars/linux/.ssh/svc_bf-ansible | 8 +
.../group_vars/linux/.ssh/svc_bf-ansible.pub | 1 +
.../desq_gaming/group_vars/linux/main.yml | 4 +-
.../desq_gaming/group_vars/windows/main.yml | 2 +-
.../DESKTOP-ROBERT.desq-gaming.lan/main.yml | 3 +
.../desq_gaming/hosts.aim-session.bak.yml | 97 +-
inventories/desq_gaming/hosts.yml | 10 +-
playbooks/aim_catalog.yml | 1012 +++++++++++++++++
playbooks/checkmk_cleanup.yml | 64 --
playbooks/checkmk_cleanup_scripts.yml | 75 ++
playbooks/checkmk_deploy.yml | 10 -
playbooks/checkmk_install_agent.yml | 136 +++
playbooks/checkmk_read_windows_config.yml | 115 ++
playbooks/checkmk_update_config.yml | 8 -
playbooks/checkmk_update_scripts.yml | 8 -
playbooks/checkmk_update_scripts_config.yml | 135 +++
.../bluuunit/sophos_apply_customer.yml | 96 ++
.../formicon/sophos_apply_customer.yml | 91 ++
.../gebhardt_stahl/sophos_apply_customer.yml | 91 ++
.../sophos_apply_customer.yml | 92 ++
.../sophos_apply_customer.yml | 93 ++
playbooks/debug_detect_host_roles.yml | 62 +
playbooks/debug_disk_usage.yml | 38 -
playbooks/debug_ping.yml | 13 -
playbooks/debug_server_role_selection.yml | 87 --
playbooks/debug_show_disk_usage.yml | 185 +++
playbooks/debug_test_connection.yml | 61 +
.../__pycache__/aim_reports.cpython-313.pyc | Bin 0 -> 21234 bytes
playbooks/filter_plugins/aim_reports.py | 398 +++++++
playbooks/maintenance_backup_event_log.yml | 74 --
playbooks/maintenance_export_event_logs.yml | 37 +
playbooks/maintenance_patch_os.yml | 99 +-
playbooks/maintenance_reboot.yml | 15 -
playbooks/maintenance_reboot_hosts.yml | 68 ++
.../maintenance_start_stopped_services.yml | 115 +-
playbooks/pfsense_apply_baseline.yml | 63 +
playbooks/schemas/checkmk_agent_config_v1.yml | 77 ++
playbooks/schemas/checkmk_agent_state_v1.yml | 74 ++
playbooks/schemas/checkmk_user_config_v1.yml | 40 +
playbooks/schemas/event_log_export_v1.yml | 30 +
playbooks/schemas/filesystem_usage_v1.yml | 66 ++
playbooks/schemas/host_capabilities_v1.yml | 22 +
.../schemas/managed_cleanup_preview_v1.yml | 43 +
playbooks/schemas/patch_summary_v1.yml | 190 ++++
.../schemas/service_start_summary_v1.yml | 113 ++
playbooks/sophos_apply_baseline.yml | 63 +
requirements-controller.txt | 2 +
requirements.yml | 10 +
roles/checkmk_agent/README.md | 23 +
roles/checkmk_agent/defaults/main.yml | 16 +-
roles/checkmk_agent/files/README.md | 6 +-
roles/checkmk_agent/handlers/main.yml | 55 -
roles/checkmk_agent/meta/main.yml | 6 -
roles/checkmk_agent/tasks/debian.yml | 11 -
roles/checkmk_agent/tasks/linux_debian.yml | 25 +
roles/checkmk_agent/tasks/linux_redhat.yml | 25 +
roles/checkmk_agent/tasks/main.yml | 23 +-
roles/checkmk_agent/tasks/redhat.yml | 12 -
roles/checkmk_agent/tasks/windows.yml | 31 +-
roles/checkmk_agent_config/meta/main.yml | 6 -
roles/checkmk_agent_config/tasks/main.yml | 20 -
.../templates/windows_check_mk.user.yml.j2 | 130 ---
roles/checkmk_cleanup_scripts/README.md | 36 +
.../checkmk_cleanup_scripts/defaults/main.yml | 22 +
roles/checkmk_cleanup_scripts/tasks/linux.yml | 15 +
roles/checkmk_cleanup_scripts/tasks/main.yml | 42 +
.../checkmk_cleanup_scripts/tasks/windows.yml | 36 +
roles/checkmk_configure_agent/README.md | 33 +
.../defaults/main.yml | 5 +-
roles/checkmk_configure_agent/tasks/main.yml | 196 ++++
.../templates/windows_plugins_section.yml.j2 | 72 ++
roles/checkmk_deploy_scripts/README.md | 48 +
.../checkmk_deploy_scripts/defaults/main.yml | 32 +
roles/checkmk_deploy_scripts/tasks/linux.yml | 40 +
roles/checkmk_deploy_scripts/tasks/main.yml | 10 +
.../tasks/preflight.yml | 57 +
.../checkmk_deploy_scripts/tasks/windows.yml | 68 ++
.../templates/unifi.cfg.j2 | 13 +
roles/checkmk_manage_service/README.md | 23 +
.../checkmk_manage_service/defaults/main.yml | 9 +
.../checkmk_manage_service/handlers/main.yml | 9 +
roles/checkmk_manage_service/tasks/linux.yml | 42 +
roles/checkmk_manage_service/tasks/main.yml | 8 +
.../checkmk_manage_service/tasks/windows.yml | 24 +
roles/checkmk_report/README.md | 9 +
roles/checkmk_report/tasks/main.yml | 79 ++
roles/checkmk_script_plan/README.md | 27 +
roles/checkmk_script_plan/defaults/main.yml | 21 +
roles/checkmk_script_plan/tasks/main.yml | 27 +
roles/checkmk_script_plan/vars/main.yml | 49 +
roles/checkmk_scripts/defaults/main.yml | 12 -
roles/checkmk_scripts/files/README.md | 16 -
.../local/veeam_o365_status.ps1.example | 0
.../local/veeam_surebackup_status.ps1.example | 0
.../Windows/local/windows-backup.ps1.example | 0
roles/checkmk_scripts/meta/main.yml | 6 -
roles/checkmk_scripts/tasks/linux.yml | 36 -
roles/checkmk_scripts/tasks/main.yml | 8 -
roles/checkmk_scripts/tasks/windows.yml | 47 -
roles/checkmk_windows_acl/README.md | 16 +
roles/checkmk_windows_acl/defaults/main.yml | 15 +
roles/checkmk_windows_acl/tasks/main.yml | 30 +
roles/maintenance_export_event_logs/README.md | 23 +
.../defaults/main.yml | 9 +
.../tasks/main.yml | 63 +
roles/maintenance_patch_os/README.md | 48 +
roles/maintenance_patch_os/defaults/main.yml | 16 +
.../tasks/linux_debian.yml | 144 +++
.../tasks/linux_redhat.yml | 167 +++
roles/maintenance_patch_os/tasks/main.yml | 33 +
roles/maintenance_patch_os/tasks/windows.yml | 162 +++
.../tasks/windows_cycle.yml | 114 ++
.../tasks/windows_update_one.yml | 87 ++
.../tasks/windows_wave.yml | 124 ++
roles/maintenance_reboot_hosts/README.md | 12 +
.../defaults/main.yml | 6 +
roles/maintenance_reboot_hosts/tasks/main.yml | 26 +
.../README.md | 19 +
.../defaults/main.yml | 5 +
.../tasks/main.yml | 55 +
roles/pfsense_apply_baseline/README.md | 3 +
roles/pfsense_apply_baseline/tasks/main.yml | 153 +++
roles/pfsense_install_prerequisites/README.md | 3 +
.../tasks/main.yml | 7 +
roles/server_role_selection/defaults/main.yml | 12 -
roles/server_role_selection/meta/main.yml | 6 -
roles/server_role_selection/tasks/main.yml | 111 --
roles/sophos_apply_baseline/README.md | 3 +
roles/sophos_apply_baseline/tasks/main.yml | 500 ++++++++
roles/sophos_customer_bluuunit/README.md | 3 +
roles/sophos_customer_bluuunit/tasks/main.yml | 527 +++++++++
roles/sophos_customer_formicon/README.md | 3 +
roles/sophos_customer_formicon/tasks/main.yml | 231 ++++
.../sophos_customer_gebhardt_stahl/README.md | 3 +
.../tasks/main.yml | 195 ++++
.../README.md | 3 +
.../tasks/main.yml | 268 +++++
.../README.md | 3 +
.../tasks/main.yml | 407 +++++++
roles/system_detect_roles/README.md | 31 +
roles/system_detect_roles/defaults/main.yml | 17 +
roles/system_detect_roles/tasks/main.yml | 132 +++
scripts/AIM-WinRM-OneTime.ps1 | 198 ++++
scripts/addons/webgui/.aim-web-managed | 1 +
scripts/addons/webgui/.credentials | 1 +
scripts/addons/webgui/ADDON-INSTALLATION.md | 688 +++++++++++
scripts/addons/webgui/AGENTS.md | 97 ++
scripts/addons/webgui/CHANGELOG.md | 556 +++++++++
scripts/addons/webgui/MANIFEST.sha256 | 160 +++
scripts/addons/webgui/README.md | 84 ++
scripts/addons/webgui/constraints.txt | 25 +
scripts/addons/webgui/deploy/deploy.py | 890 +++++++++++++++
scripts/addons/webgui/deploy/fetch_assets.py | 78 ++
.../addons/webgui/deploy/nginx.example.conf | 12 +
.../webgui/deploy/profiles/direct-npm.toml | 65 ++
.../addons/webgui/deploy/webgui.example.toml | 75 ++
scripts/addons/webgui/docs/API.md | 31 +
scripts/addons/webgui/docs/ARCHITECTURE.md | 17 +
.../addons/webgui/docs/CONTROLLER-PILOT.md | 37 +
scripts/addons/webgui/docs/CORE-3.1-REVIEW.md | 90 ++
.../webgui/docs/CORE-3.2.1RC1-REVIEW.md | 20 +
.../webgui/docs/CORE-3.2.1RC2-REVIEW.md | 16 +
scripts/addons/webgui/docs/CORE-3.3-REVIEW.md | 15 +
.../webgui/docs/CORE-3.3.0RC3-REVIEW.md | 84 ++
.../webgui/docs/CORE-3.3.0RC8-REVIEW.md | 50 +
scripts/addons/webgui/docs/CREDENTIALS.md | 66 ++
scripts/addons/webgui/docs/DEPLOYMENT.md | 131 +++
scripts/addons/webgui/docs/EXECUTION.md | 21 +
scripts/addons/webgui/docs/JOURNAL.md | 43 +
scripts/addons/webgui/docs/MIGRATION-3.1.md | 105 ++
scripts/addons/webgui/docs/PATCH-WAVES.md | 72 ++
.../addons/webgui/docs/PERMISSIONS-ROLLOUT.md | 42 +
.../addons/webgui/docs/PRODUCT-COMPARISON.md | 96 ++
.../webgui/docs/READ-ONLY-EXPERIENCE.md | 46 +
scripts/addons/webgui/docs/REPORTS.md | 62 +
scripts/addons/webgui/docs/ROADMAP.md | 7 +
scripts/addons/webgui/docs/RUN-COMFORT.md | 43 +
scripts/addons/webgui/docs/SECURITY.md | 23 +
scripts/addons/webgui/docs/VERIFICATION.md | 57 +
.../webgui/docs/verification-results.json | 64 ++
scripts/addons/webgui/pyproject.toml | 40 +
scripts/addons/webgui/requirements-test.txt | 3 +
.../addons/webgui/src/aim_webgui/__init__.py | 4 +
.../addons/webgui/src/aim_webgui/__main__.py | 2 +
.../addons/webgui/src/aim_webgui/activity.py | 193 ++++
.../src/aim_webgui/adapters/__init__.py | 0
.../webgui/src/aim_webgui/adapters/core_v1.py | 160 +++
scripts/addons/webgui/src/aim_webgui/app.py | 406 +++++++
.../addons/webgui/src/aim_webgui/assets.py | 19 +
.../webgui/src/aim_webgui/auth/__init__.py | 0
.../webgui/src/aim_webgui/auth/service.py | 277 +++++
scripts/addons/webgui/src/aim_webgui/cli.py | 199 ++++
.../addons/webgui/src/aim_webgui/config.py | 221 ++++
.../addons/webgui/src/aim_webgui/console.py | 175 +++
.../webgui/src/aim_webgui/core/__init__.py | 1 +
.../webgui/src/aim_webgui/core/client.py | 66 ++
.../webgui/src/aim_webgui/core/executor.py | 119 ++
.../webgui/src/aim_webgui/core/jsonio.py | 26 +
.../webgui/src/aim_webgui/core/limits.py | 11 +
.../webgui/src/aim_webgui/core/process.py | 139 +++
.../webgui/src/aim_webgui/core/protocol.py | 279 +++++
.../webgui/src/aim_webgui/core/reports.py | 171 +++
.../src/aim_webgui/credentials/__init__.py | 1 +
.../aim_webgui/credentials/presentation.py | 77 ++
.../src/aim_webgui/credentials/service.py | 67 ++
.../webgui/src/aim_webgui/credentials/wire.py | 78 ++
.../webgui/src/aim_webgui/db/__init__.py | 0
.../aim_webgui/db/migrations/0001_initial.sql | 27 +
.../db/migrations/0002_workflows.sql | 54 +
.../db/migrations/0003_credentials.sql | 3 +
.../aim_webgui/db/migrations/0004_core_v1.sql | 19 +
.../db/migrations/0005_job_evidence.sql | 36 +
.../addons/webgui/src/aim_webgui/db/store.py | 149 +++
.../webgui/src/aim_webgui/diagnostics.py | 45 +
.../addons/webgui/src/aim_webgui/errors.py | 5 +
.../webgui/src/aim_webgui/evidence_views.py | 102 ++
.../addons/webgui/src/aim_webgui/explorer.py | 130 +++
.../addons/webgui/src/aim_webgui/journal.py | 235 ++++
scripts/addons/webgui/src/aim_webgui/names.py | 10 +
.../webgui/src/aim_webgui/patch_view.py | 90 ++
.../webgui/src/aim_webgui/read_views.py | 68 ++
.../addons/webgui/src/aim_webgui/reports.py | 197 ++++
.../webgui/src/aim_webgui/routes/__init__.py | 0
.../webgui/src/aim_webgui/routes/workflows.py | 280 +++++
.../addons/webgui/src/aim_webgui/security.py | 61 +
.../webgui/src/aim_webgui/static/css/aim.css | 200 ++++
.../static/css/bootstrap-overrides.css | 65 ++
.../src/aim_webgui/static/css/credentials.css | 88 ++
.../src/aim_webgui/static/css/evidence.css | 49 +
.../src/aim_webgui/static/css/experience.css | 162 +++
.../src/aim_webgui/static/css/tokens.css | 92 ++
.../webgui/src/aim_webgui/static/js/aim.js | 227 ++++
.../src/aim_webgui/static/js/credentials.js | 348 ++++++
.../src/aim_webgui/static/js/evidence.js | 123 ++
.../src/aim_webgui/static/js/experience.js | 47 +
.../webgui/src/aim_webgui/static/js/theme.js | 45 +
.../aim_webgui/static/vendor/THIRD-PARTY.txt | 38 +
.../static/vendor/bootstrap.min.css | 6 +
.../src/aim_webgui/static/vendor/htmx.min.js | 1 +
.../aim_webgui/templates/layouts/base.html | 59 +
.../aim_webgui/templates/pages/activity.html | 23 +
.../src/aim_webgui/templates/pages/audit.html | 5 +
.../templates/pages/credentials.html | 7 +
.../aim_webgui/templates/pages/customers.html | 3 +
.../src/aim_webgui/templates/pages/error.html | 1 +
.../aim_webgui/templates/pages/explorer.html | 27 +
.../src/aim_webgui/templates/pages/hosts.html | 9 +
.../aim_webgui/templates/pages/insights.html | 15 +
.../src/aim_webgui/templates/pages/job.html | 7 +
.../src/aim_webgui/templates/pages/jobs.html | 8 +
.../src/aim_webgui/templates/pages/login.html | 12 +
.../aim_webgui/templates/pages/overview.html | 9 +
.../aim_webgui/templates/pages/password.html | 8 +
.../src/aim_webgui/templates/pages/plan.html | 98 ++
.../templates/pages/plan_detail.html | 7 +
.../src/aim_webgui/templates/pages/plans.html | 7 +
.../aim_webgui/templates/pages/playbooks.html | 3 +
.../aim_webgui/templates/pages/preflight.html | 1 +
.../aim_webgui/templates/pages/progress.html | 7 +
.../aim_webgui/templates/pages/report.html | 31 +
.../src/aim_webgui/templates/pages/setup.html | 13 +
.../aim_webgui/templates/pages/system.html | 5 +
.../src/aim_webgui/templates/pages/users.html | 10 +
.../templates/partials/activity_macros.html | 35 +
.../templates/partials/attention.html | 8 +
.../templates/partials/credential_dialog.html | 4 +
.../templates/partials/credential_panel.html | 48 +
.../templates/partials/customers.html | 4 +
.../aim_webgui/templates/partials/error.html | 1 +
.../templates/partials/grant_fields.html | 23 +
.../aim_webgui/templates/partials/hosts.html | 5 +
.../aim_webgui/templates/partials/job.html | 15 +
.../templates/partials/navigation.html | 17 +
.../templates/partials/patch_report.html | 14 +
.../templates/partials/preflight.html | 25 +
.../templates/partials/progress.html | 21 +
.../templates/partials/reports.html | 9 +
.../addons/webgui/src/aim_webgui/worker.py | 340 ++++++
.../addons/webgui/src/aim_webgui/workflows.py | 448 ++++++++
.../addons/webgui/tests/benchmark_journal.py | 46 +
.../webgui/tests/benchmark_read_history.py | 65 ++
.../webgui/tests/browser_credentials_qa.py | 200 ++++
.../webgui/tests/browser_evidence_qa.py | 136 +++
.../addons/webgui/tests/browser_patch_qa.py | 137 +++
scripts/addons/webgui/tests/browser_qa.py | 191 ++++
scripts/addons/webgui/tests/conftest.py | 57 +
.../addons/webgui/tests/evidence_fixtures.py | 68 ++
.../tests/fixtures/patch-summary-rc1.json | 214 ++++
scripts/addons/webgui/tests/patch_fixtures.py | 24 +
.../addons/webgui/tests/run_release_tests.py | 53 +
scripts/addons/webgui/tests/test_auth.py | 178 +++
.../addons/webgui/tests/test_core_contract.py | 134 +++
.../webgui/tests/test_core_execution.py | 76 ++
.../webgui/tests/test_core_rc8_patch.py | 236 ++++
.../addons/webgui/tests/test_core_reports.py | 39 +
.../addons/webgui/tests/test_credential_ux.py | 289 +++++
.../addons/webgui/tests/test_deployment_v2.py | 131 +++
.../addons/webgui/tests/test_executor_unix.py | 75 ++
.../addons/webgui/tests/test_journal_v5.py | 138 +++
.../addons/webgui/tests/test_migration_v4.py | 41 +
.../addons/webgui/tests/test_migration_v5.py | 30 +
.../webgui/tests/test_read_experience.py | 228 ++++
.../webgui/tests/test_report_transport.py | 88 ++
.../addons/webgui/tests/test_reports_v33.py | 130 +++
scripts/addons/webgui/tests/test_routes_v2.py | 91 ++
scripts/addons/webgui/tests/test_transport.py | 84 ++
scripts/addons/webgui/tests/test_worker_v2.py | 27 +
.../addons/webgui/tests/test_workflows_v2.py | 104 ++
scripts/aim.yml | 33 +
scripts/aimctl.py | 8 +
scripts/config/webgui.toml | 75 ++
scripts/config/webgui.toml.valid | 54 +
scripts/docs/ADDON_AGENTS.md | 105 ++
scripts/docs/ADDON_API.md | 153 +++
scripts/docs/ADDON_SUPPORT.md | 49 +
scripts/docs/AGENTS.md | 99 ++
scripts/docs/CHANGELOG.md | 268 +++++
scripts/docs/CHECKMK.md | 247 ++++
scripts/docs/DETAILED_PROGRESS.md | 284 +++++
scripts/docs/EXECUTOR_STAGING.md | 176 +++
scripts/docs/INSTALLATION.md | 484 ++++++++
scripts/docs/INVENTORY_HIERARCHY.md | 69 ++
scripts/docs/OPERATION_RESULTS.md | 276 +++++
scripts/docs/PLAYBOOKS.md | 83 ++
scripts/docs/README.md | 34 +
scripts/docs/RELEASE_HANDOFF.md | 91 ++
scripts/docs/RELEASE_NOTES.md | 72 ++
scripts/docs/SANITY.md | 105 ++
scripts/docs/TARGET_OUTCOMES.md | 108 ++
scripts/docs/VALIDATION.md | 64 ++
scripts/docs/addon-support-v1.json | 135 +++
scripts/pyproject.toml | 6 +-
scripts/src/aim/__init__.py | 2 +-
scripts/src/aim/__main__.py | 2 +-
.../aim/__pycache__/__init__.cpython-313.pyc | Bin 163 -> 0 bytes
.../aim/__pycache__/__main__.cpython-313.pyc | Bin 2629 -> 0 bytes
.../src/aim/__pycache__/auth.cpython-313.pyc | Bin 2050 -> 0 bytes
.../aim/__pycache__/config.cpython-313.pyc | Bin 9174 -> 0 bytes
.../__pycache__/exceptions.cpython-313.pyc | Bin 2358 -> 0 bytes
.../aim/__pycache__/external.cpython-313.pyc | Bin 5730 -> 0 bytes
.../aim/__pycache__/locking.cpython-313.pyc | Bin 4953 -> 0 bytes
.../__pycache__/permissions.cpython-313.pyc | Bin 1606 -> 0 bytes
.../aim/__pycache__/structure.cpython-313.pyc | Bin 10677 -> 0 bytes
.../aim/__pycache__/templates.cpython-313.pyc | Bin 2219 -> 0 bytes
.../__pycache__/__init__.cpython-313.pyc | Bin 144 -> 0 bytes
.../__pycache__/session.cpython-313.pyc | Bin 3744 -> 0 bytes
scripts/src/aim/backup/session.py | 14 +-
scripts/src/aim/config.py | 131 ++-
scripts/src/aim/ctl.py | 135 +++
.../__pycache__/__init__.cpython-313.pyc | Bin 147 -> 0 bytes
.../__pycache__/manager.cpython-313.pyc | Bin 20499 -> 0 bytes
scripts/src/aim/customers/manager.py | 108 +-
scripts/src/aim/exceptions.py | 12 +
scripts/src/aim/integrations/__init__.py | 1 +
scripts/src/aim/integrations/ansible.cfg | 3 +
.../aim/integrations/callbacks/__init__.py | 1 +
.../integrations/callbacks/aim_safe_events.py | 359 ++++++
scripts/src/aim/integrations/event_policy.py | 90 ++
scripts/src/aim/integrations/output_policy.py | 136 +++
.../__pycache__/__init__.cpython-313.pyc | Bin 147 -> 0 bytes
.../__pycache__/hosts.cpython-313.pyc | Bin 14499 -> 0 bytes
.../__pycache__/loader.cpython-313.pyc | Bin 4773 -> 0 bytes
.../__pycache__/model.cpython-313.pyc | Bin 3205 -> 0 bytes
.../__pycache__/validator.cpython-313.pyc | Bin 3607 -> 0 bytes
.../__pycache__/writer.cpython-313.pyc | Bin 3506 -> 0 bytes
scripts/src/aim/inventory/hierarchy.py | 69 ++
scripts/src/aim/inventory/writer.py | 4 +-
scripts/src/aim/locking.py | 107 +-
.../__pycache__/__init__.cpython-313.pyc | Bin 201 -> 0 bytes
.../__pycache__/checkmk.cpython-313.pyc | Bin 8108 -> 0 bytes
.../__pycache__/repository.cpython-313.pyc | Bin 5183 -> 0 bytes
scripts/src/aim/permissions.py | 103 +-
.../__pycache__/__init__.cpython-313.pyc | Bin 147 -> 0 bytes
.../__pycache__/manager.cpython-313.pyc | Bin 6262 -> 0 bytes
scripts/src/aim/playbooks/catalog.py | 224 ++++
scripts/src/aim/playbooks/manager.py | 286 +++--
scripts/src/aim/playbooks/planning.py | 33 +
scripts/src/aim/runtime/__init__.py | 1 +
scripts/src/aim/runtime/agent.py | 81 ++
scripts/src/aim/runtime/ansible.py | 129 +++
scripts/src/aim/runtime/events.py | 235 ++++
scripts/src/aim/runtime/outputs.py | 97 ++
scripts/src/aim/runtime/process.py | 96 ++
scripts/src/aim/runtime/secrets.py | 120 ++
scripts/src/aim/runtime/staging.py | 270 +++++
scripts/src/aim/services/__init__.py | 1 +
scripts/src/aim/services/v1/__init__.py | 16 +
scripts/src/aim/services/v1/credentials.py | 95 ++
scripts/src/aim/services/v1/models.py | 129 +++
scripts/src/aim/services/v1/service.py | 447 ++++++++
.../__pycache__/__init__.cpython-313.pyc | Bin 197 -> 0 bytes
.../sophos/__pycache__/config.cpython-313.pyc | Bin 8218 -> 0 bytes
scripts/src/aim/sophos/config.py | 5 +-
.../ssh/__pycache__/__init__.cpython-313.pyc | Bin 141 -> 0 bytes
.../aim/ssh/__pycache__/keys.cpython-313.pyc | Bin 14117 -> 0 bytes
scripts/src/aim/ssh/keys.py | 111 +-
scripts/src/aim/structure.py | 102 +-
scripts/src/aim/templates.py | 1 +
.../ui/__pycache__/__init__.cpython-313.pyc | Bin 140 -> 0 bytes
.../aim/ui/__pycache__/access.cpython-313.pyc | Bin 26873 -> 0 bytes
.../administration.cpython-313.pyc | Bin 27362 -> 0 bytes
.../aim/ui/__pycache__/app.cpython-313.pyc | Bin 22523 -> 0 bytes
.../ui/__pycache__/components.cpython-313.pyc | Bin 11765 -> 0 bytes
.../ui/__pycache__/customers.cpython-313.pyc | Bin 16650 -> 0 bytes
.../ui/__pycache__/execution.cpython-313.pyc | Bin 8081 -> 0 bytes
.../aim/ui/__pycache__/hosts.cpython-313.pyc | Bin 19679 -> 0 bytes
.../__pycache__/maintenance.cpython-313.pyc | Bin 7471 -> 0 bytes
.../ui/__pycache__/playbooks.cpython-313.pyc | Bin 10993 -> 0 bytes
.../ui/__pycache__/selection.cpython-313.pyc | Bin 5967 -> 0 bytes
.../ui/__pycache__/targets.cpython-313.pyc | Bin 7294 -> 0 bytes
scripts/src/aim/ui/access.py | 129 ++-
scripts/src/aim/ui/administration.py | 95 +-
scripts/src/aim/ui/app.py | 32 +-
scripts/src/aim/ui/components.py | 2 +-
scripts/src/aim/ui/hosts.py | 25 +-
scripts/src/aim/ui/maintenance.py | 49 +-
scripts/src/aim/ui/playbooks.py | 558 +++++++--
scripts/src/aim/ui/selection.py | 10 +
scripts/src/aim/ui/targets.py | 27 +-
scripts/src/aim/ui/vault_credentials.py | 19 +
.../__pycache__/__init__.cpython-313.pyc | Bin 147 -> 0 bytes
.../__pycache__/viewer.cpython-313.pyc | Bin 1770 -> 0 bytes
.../__pycache__/__init__.cpython-313.pyc | Bin 143 -> 0 bytes
.../vault/__pycache__/manager.cpython-313.pyc | Bin 12543 -> 0 bytes
scripts/src/aim/vault/manager.py | 69 +-
scripts/src/aim/vault/session.py | 122 ++
.../__pycache__/__init__.cpython-313.pyc | Bin 190 -> 0 bytes
.../winrm/__pycache__/manager.cpython-313.pyc | Bin 32034 -> 0 bytes
scripts/src/aim/winrm/manager.py | 38 +-
438 files changed, 31613 insertions(+), 1510 deletions(-)
rename roles/checkmk_scripts/files/Linux/local/check_certificate_directory.sh.example => .aim-core-deploy.lock (100%)
delete mode 100644 aim.yml
create mode 100644 deploy/README.md
create mode 100644 deploy/deploy.py
rename roles/checkmk_scripts/files/Linux/local/check_unifi-controller.sh.example => inventories/desq_gaming/.aim.lock (100%)
delete mode 100644 inventories/desq_gaming/group_vars/linux/.ssh/svc_ansible
delete mode 100644 inventories/desq_gaming/group_vars/linux/.ssh/svc_ansible.pub
create mode 100644 inventories/desq_gaming/group_vars/linux/.ssh/svc_bf-ansible
create mode 100644 inventories/desq_gaming/group_vars/linux/.ssh/svc_bf-ansible.pub
create mode 100644 inventories/desq_gaming/host_vars/DESKTOP-ROBERT.desq-gaming.lan/main.yml
create mode 100644 playbooks/aim_catalog.yml
delete mode 100644 playbooks/checkmk_cleanup.yml
create mode 100644 playbooks/checkmk_cleanup_scripts.yml
delete mode 100644 playbooks/checkmk_deploy.yml
create mode 100644 playbooks/checkmk_install_agent.yml
create mode 100644 playbooks/checkmk_read_windows_config.yml
delete mode 100644 playbooks/checkmk_update_config.yml
delete mode 100644 playbooks/checkmk_update_scripts.yml
create mode 100644 playbooks/checkmk_update_scripts_config.yml
create mode 100644 playbooks/customers/bluuunit/sophos_apply_customer.yml
create mode 100644 playbooks/customers/formicon/sophos_apply_customer.yml
create mode 100644 playbooks/customers/gebhardt_stahl/sophos_apply_customer.yml
create mode 100644 playbooks/customers/hungeling_und_toechter/sophos_apply_customer.yml
create mode 100644 playbooks/customers/koenig_holding_gmbh/sophos_apply_customer.yml
create mode 100644 playbooks/debug_detect_host_roles.yml
delete mode 100644 playbooks/debug_disk_usage.yml
delete mode 100644 playbooks/debug_ping.yml
delete mode 100644 playbooks/debug_server_role_selection.yml
create mode 100644 playbooks/debug_show_disk_usage.yml
create mode 100644 playbooks/debug_test_connection.yml
create mode 100644 playbooks/filter_plugins/__pycache__/aim_reports.cpython-313.pyc
create mode 100644 playbooks/filter_plugins/aim_reports.py
delete mode 100644 playbooks/maintenance_backup_event_log.yml
create mode 100644 playbooks/maintenance_export_event_logs.yml
delete mode 100644 playbooks/maintenance_reboot.yml
create mode 100644 playbooks/maintenance_reboot_hosts.yml
create mode 100644 playbooks/pfsense_apply_baseline.yml
create mode 100644 playbooks/schemas/checkmk_agent_config_v1.yml
create mode 100644 playbooks/schemas/checkmk_agent_state_v1.yml
create mode 100644 playbooks/schemas/checkmk_user_config_v1.yml
create mode 100644 playbooks/schemas/event_log_export_v1.yml
create mode 100644 playbooks/schemas/filesystem_usage_v1.yml
create mode 100644 playbooks/schemas/host_capabilities_v1.yml
create mode 100644 playbooks/schemas/managed_cleanup_preview_v1.yml
create mode 100644 playbooks/schemas/patch_summary_v1.yml
create mode 100644 playbooks/schemas/service_start_summary_v1.yml
create mode 100644 playbooks/sophos_apply_baseline.yml
create mode 100644 requirements-controller.txt
create mode 100644 requirements.yml
create mode 100644 roles/checkmk_agent/README.md
delete mode 100644 roles/checkmk_agent/handlers/main.yml
delete mode 100644 roles/checkmk_agent/meta/main.yml
delete mode 100644 roles/checkmk_agent/tasks/debian.yml
create mode 100644 roles/checkmk_agent/tasks/linux_debian.yml
create mode 100644 roles/checkmk_agent/tasks/linux_redhat.yml
delete mode 100644 roles/checkmk_agent/tasks/redhat.yml
delete mode 100644 roles/checkmk_agent_config/meta/main.yml
delete mode 100644 roles/checkmk_agent_config/tasks/main.yml
delete mode 100644 roles/checkmk_agent_config/templates/windows_check_mk.user.yml.j2
create mode 100644 roles/checkmk_cleanup_scripts/README.md
create mode 100644 roles/checkmk_cleanup_scripts/defaults/main.yml
create mode 100644 roles/checkmk_cleanup_scripts/tasks/linux.yml
create mode 100644 roles/checkmk_cleanup_scripts/tasks/main.yml
create mode 100644 roles/checkmk_cleanup_scripts/tasks/windows.yml
create mode 100644 roles/checkmk_configure_agent/README.md
rename roles/{checkmk_agent_config => checkmk_configure_agent}/defaults/main.yml (50%)
create mode 100644 roles/checkmk_configure_agent/tasks/main.yml
create mode 100644 roles/checkmk_configure_agent/templates/windows_plugins_section.yml.j2
create mode 100644 roles/checkmk_deploy_scripts/README.md
create mode 100644 roles/checkmk_deploy_scripts/defaults/main.yml
create mode 100644 roles/checkmk_deploy_scripts/tasks/linux.yml
create mode 100644 roles/checkmk_deploy_scripts/tasks/main.yml
create mode 100644 roles/checkmk_deploy_scripts/tasks/preflight.yml
create mode 100644 roles/checkmk_deploy_scripts/tasks/windows.yml
create mode 100644 roles/checkmk_deploy_scripts/templates/unifi.cfg.j2
create mode 100644 roles/checkmk_manage_service/README.md
create mode 100644 roles/checkmk_manage_service/defaults/main.yml
create mode 100644 roles/checkmk_manage_service/handlers/main.yml
create mode 100644 roles/checkmk_manage_service/tasks/linux.yml
create mode 100644 roles/checkmk_manage_service/tasks/main.yml
create mode 100644 roles/checkmk_manage_service/tasks/windows.yml
create mode 100644 roles/checkmk_report/README.md
create mode 100644 roles/checkmk_report/tasks/main.yml
create mode 100644 roles/checkmk_script_plan/README.md
create mode 100644 roles/checkmk_script_plan/defaults/main.yml
create mode 100644 roles/checkmk_script_plan/tasks/main.yml
create mode 100644 roles/checkmk_script_plan/vars/main.yml
delete mode 100644 roles/checkmk_scripts/defaults/main.yml
delete mode 100644 roles/checkmk_scripts/files/README.md
delete mode 100644 roles/checkmk_scripts/files/Windows/local/veeam_o365_status.ps1.example
delete mode 100644 roles/checkmk_scripts/files/Windows/local/veeam_surebackup_status.ps1.example
delete mode 100644 roles/checkmk_scripts/files/Windows/local/windows-backup.ps1.example
delete mode 100644 roles/checkmk_scripts/meta/main.yml
delete mode 100644 roles/checkmk_scripts/tasks/linux.yml
delete mode 100644 roles/checkmk_scripts/tasks/main.yml
delete mode 100644 roles/checkmk_scripts/tasks/windows.yml
create mode 100644 roles/checkmk_windows_acl/README.md
create mode 100644 roles/checkmk_windows_acl/defaults/main.yml
create mode 100644 roles/checkmk_windows_acl/tasks/main.yml
create mode 100644 roles/maintenance_export_event_logs/README.md
create mode 100644 roles/maintenance_export_event_logs/defaults/main.yml
create mode 100644 roles/maintenance_export_event_logs/tasks/main.yml
create mode 100644 roles/maintenance_patch_os/README.md
create mode 100644 roles/maintenance_patch_os/defaults/main.yml
create mode 100644 roles/maintenance_patch_os/tasks/linux_debian.yml
create mode 100644 roles/maintenance_patch_os/tasks/linux_redhat.yml
create mode 100644 roles/maintenance_patch_os/tasks/main.yml
create mode 100644 roles/maintenance_patch_os/tasks/windows.yml
create mode 100644 roles/maintenance_patch_os/tasks/windows_cycle.yml
create mode 100644 roles/maintenance_patch_os/tasks/windows_update_one.yml
create mode 100644 roles/maintenance_patch_os/tasks/windows_wave.yml
create mode 100644 roles/maintenance_reboot_hosts/README.md
create mode 100644 roles/maintenance_reboot_hosts/defaults/main.yml
create mode 100644 roles/maintenance_reboot_hosts/tasks/main.yml
create mode 100644 roles/maintenance_start_stopped_services/README.md
create mode 100644 roles/maintenance_start_stopped_services/defaults/main.yml
create mode 100644 roles/maintenance_start_stopped_services/tasks/main.yml
create mode 100644 roles/pfsense_apply_baseline/README.md
create mode 100644 roles/pfsense_apply_baseline/tasks/main.yml
create mode 100644 roles/pfsense_install_prerequisites/README.md
create mode 100644 roles/pfsense_install_prerequisites/tasks/main.yml
delete mode 100644 roles/server_role_selection/defaults/main.yml
delete mode 100644 roles/server_role_selection/meta/main.yml
delete mode 100644 roles/server_role_selection/tasks/main.yml
create mode 100644 roles/sophos_apply_baseline/README.md
create mode 100644 roles/sophos_apply_baseline/tasks/main.yml
create mode 100644 roles/sophos_customer_bluuunit/README.md
create mode 100644 roles/sophos_customer_bluuunit/tasks/main.yml
create mode 100644 roles/sophos_customer_formicon/README.md
create mode 100644 roles/sophos_customer_formicon/tasks/main.yml
create mode 100644 roles/sophos_customer_gebhardt_stahl/README.md
create mode 100644 roles/sophos_customer_gebhardt_stahl/tasks/main.yml
create mode 100644 roles/sophos_customer_hungeling_und_toechter/README.md
create mode 100644 roles/sophos_customer_hungeling_und_toechter/tasks/main.yml
create mode 100644 roles/sophos_customer_koenig_holding_gmbh/README.md
create mode 100644 roles/sophos_customer_koenig_holding_gmbh/tasks/main.yml
create mode 100644 roles/system_detect_roles/README.md
create mode 100644 roles/system_detect_roles/defaults/main.yml
create mode 100644 roles/system_detect_roles/tasks/main.yml
create mode 100644 scripts/AIM-WinRM-OneTime.ps1
create mode 100644 scripts/addons/webgui/.aim-web-managed
create mode 120000 scripts/addons/webgui/.credentials
create mode 100644 scripts/addons/webgui/ADDON-INSTALLATION.md
create mode 100644 scripts/addons/webgui/AGENTS.md
create mode 100644 scripts/addons/webgui/CHANGELOG.md
create mode 100644 scripts/addons/webgui/MANIFEST.sha256
create mode 100644 scripts/addons/webgui/README.md
create mode 100644 scripts/addons/webgui/constraints.txt
create mode 100644 scripts/addons/webgui/deploy/deploy.py
create mode 100644 scripts/addons/webgui/deploy/fetch_assets.py
create mode 100644 scripts/addons/webgui/deploy/nginx.example.conf
create mode 100644 scripts/addons/webgui/deploy/profiles/direct-npm.toml
create mode 100644 scripts/addons/webgui/deploy/webgui.example.toml
create mode 100644 scripts/addons/webgui/docs/API.md
create mode 100644 scripts/addons/webgui/docs/ARCHITECTURE.md
create mode 100644 scripts/addons/webgui/docs/CONTROLLER-PILOT.md
create mode 100644 scripts/addons/webgui/docs/CORE-3.1-REVIEW.md
create mode 100644 scripts/addons/webgui/docs/CORE-3.2.1RC1-REVIEW.md
create mode 100644 scripts/addons/webgui/docs/CORE-3.2.1RC2-REVIEW.md
create mode 100644 scripts/addons/webgui/docs/CORE-3.3-REVIEW.md
create mode 100644 scripts/addons/webgui/docs/CORE-3.3.0RC3-REVIEW.md
create mode 100644 scripts/addons/webgui/docs/CORE-3.3.0RC8-REVIEW.md
create mode 100644 scripts/addons/webgui/docs/CREDENTIALS.md
create mode 100644 scripts/addons/webgui/docs/DEPLOYMENT.md
create mode 100644 scripts/addons/webgui/docs/EXECUTION.md
create mode 100644 scripts/addons/webgui/docs/JOURNAL.md
create mode 100644 scripts/addons/webgui/docs/MIGRATION-3.1.md
create mode 100644 scripts/addons/webgui/docs/PATCH-WAVES.md
create mode 100644 scripts/addons/webgui/docs/PERMISSIONS-ROLLOUT.md
create mode 100644 scripts/addons/webgui/docs/PRODUCT-COMPARISON.md
create mode 100644 scripts/addons/webgui/docs/READ-ONLY-EXPERIENCE.md
create mode 100644 scripts/addons/webgui/docs/REPORTS.md
create mode 100644 scripts/addons/webgui/docs/ROADMAP.md
create mode 100644 scripts/addons/webgui/docs/RUN-COMFORT.md
create mode 100644 scripts/addons/webgui/docs/SECURITY.md
create mode 100644 scripts/addons/webgui/docs/VERIFICATION.md
create mode 100644 scripts/addons/webgui/docs/verification-results.json
create mode 100644 scripts/addons/webgui/pyproject.toml
create mode 100644 scripts/addons/webgui/requirements-test.txt
create mode 100644 scripts/addons/webgui/src/aim_webgui/__init__.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/__main__.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/activity.py
rename roles/checkmk_scripts/files/Linux/local/unifi.cfg.example => scripts/addons/webgui/src/aim_webgui/adapters/__init__.py (100%)
create mode 100644 scripts/addons/webgui/src/aim_webgui/adapters/core_v1.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/app.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/assets.py
rename roles/checkmk_scripts/files/Windows/local/check-ping.ps1.example => scripts/addons/webgui/src/aim_webgui/auth/__init__.py (100%)
create mode 100644 scripts/addons/webgui/src/aim_webgui/auth/service.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/cli.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/config.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/console.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/core/__init__.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/core/client.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/core/executor.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/core/jsonio.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/core/limits.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/core/process.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/core/protocol.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/core/reports.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/credentials/__init__.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/credentials/presentation.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/credentials/service.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/credentials/wire.py
rename roles/checkmk_scripts/files/Windows/local/citrix_sessions_customized.ps1.example => scripts/addons/webgui/src/aim_webgui/db/__init__.py (100%)
create mode 100644 scripts/addons/webgui/src/aim_webgui/db/migrations/0001_initial.sql
create mode 100644 scripts/addons/webgui/src/aim_webgui/db/migrations/0002_workflows.sql
create mode 100644 scripts/addons/webgui/src/aim_webgui/db/migrations/0003_credentials.sql
create mode 100644 scripts/addons/webgui/src/aim_webgui/db/migrations/0004_core_v1.sql
create mode 100644 scripts/addons/webgui/src/aim_webgui/db/migrations/0005_job_evidence.sql
create mode 100644 scripts/addons/webgui/src/aim_webgui/db/store.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/diagnostics.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/errors.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/evidence_views.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/explorer.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/journal.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/names.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/patch_view.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/read_views.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/reports.py
rename roles/checkmk_scripts/files/Windows/local/veeam_config_backup_status.ps1.example => scripts/addons/webgui/src/aim_webgui/routes/__init__.py (100%)
create mode 100644 scripts/addons/webgui/src/aim_webgui/routes/workflows.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/security.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/static/css/aim.css
create mode 100644 scripts/addons/webgui/src/aim_webgui/static/css/bootstrap-overrides.css
create mode 100644 scripts/addons/webgui/src/aim_webgui/static/css/credentials.css
create mode 100644 scripts/addons/webgui/src/aim_webgui/static/css/evidence.css
create mode 100644 scripts/addons/webgui/src/aim_webgui/static/css/experience.css
create mode 100644 scripts/addons/webgui/src/aim_webgui/static/css/tokens.css
create mode 100644 scripts/addons/webgui/src/aim_webgui/static/js/aim.js
create mode 100644 scripts/addons/webgui/src/aim_webgui/static/js/credentials.js
create mode 100644 scripts/addons/webgui/src/aim_webgui/static/js/evidence.js
create mode 100644 scripts/addons/webgui/src/aim_webgui/static/js/experience.js
create mode 100644 scripts/addons/webgui/src/aim_webgui/static/js/theme.js
create mode 100644 scripts/addons/webgui/src/aim_webgui/static/vendor/THIRD-PARTY.txt
create mode 100644 scripts/addons/webgui/src/aim_webgui/static/vendor/bootstrap.min.css
create mode 100644 scripts/addons/webgui/src/aim_webgui/static/vendor/htmx.min.js
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/layouts/base.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/activity.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/audit.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/credentials.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/customers.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/error.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/explorer.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/hosts.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/insights.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/job.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/jobs.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/login.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/overview.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/password.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/plan.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/plan_detail.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/plans.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/playbooks.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/preflight.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/progress.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/report.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/setup.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/system.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/pages/users.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/partials/activity_macros.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/partials/attention.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/partials/credential_dialog.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/partials/credential_panel.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/partials/customers.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/partials/error.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/partials/grant_fields.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/partials/hosts.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/partials/job.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/partials/navigation.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/partials/patch_report.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/partials/preflight.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/partials/progress.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/templates/partials/reports.html
create mode 100644 scripts/addons/webgui/src/aim_webgui/worker.py
create mode 100644 scripts/addons/webgui/src/aim_webgui/workflows.py
create mode 100644 scripts/addons/webgui/tests/benchmark_journal.py
create mode 100644 scripts/addons/webgui/tests/benchmark_read_history.py
create mode 100644 scripts/addons/webgui/tests/browser_credentials_qa.py
create mode 100644 scripts/addons/webgui/tests/browser_evidence_qa.py
create mode 100644 scripts/addons/webgui/tests/browser_patch_qa.py
create mode 100644 scripts/addons/webgui/tests/browser_qa.py
create mode 100644 scripts/addons/webgui/tests/conftest.py
create mode 100644 scripts/addons/webgui/tests/evidence_fixtures.py
create mode 100644 scripts/addons/webgui/tests/fixtures/patch-summary-rc1.json
create mode 100644 scripts/addons/webgui/tests/patch_fixtures.py
create mode 100644 scripts/addons/webgui/tests/run_release_tests.py
create mode 100644 scripts/addons/webgui/tests/test_auth.py
create mode 100644 scripts/addons/webgui/tests/test_core_contract.py
create mode 100644 scripts/addons/webgui/tests/test_core_execution.py
create mode 100644 scripts/addons/webgui/tests/test_core_rc8_patch.py
create mode 100644 scripts/addons/webgui/tests/test_core_reports.py
create mode 100644 scripts/addons/webgui/tests/test_credential_ux.py
create mode 100644 scripts/addons/webgui/tests/test_deployment_v2.py
create mode 100644 scripts/addons/webgui/tests/test_executor_unix.py
create mode 100644 scripts/addons/webgui/tests/test_journal_v5.py
create mode 100644 scripts/addons/webgui/tests/test_migration_v4.py
create mode 100644 scripts/addons/webgui/tests/test_migration_v5.py
create mode 100644 scripts/addons/webgui/tests/test_read_experience.py
create mode 100644 scripts/addons/webgui/tests/test_report_transport.py
create mode 100644 scripts/addons/webgui/tests/test_reports_v33.py
create mode 100644 scripts/addons/webgui/tests/test_routes_v2.py
create mode 100644 scripts/addons/webgui/tests/test_transport.py
create mode 100644 scripts/addons/webgui/tests/test_worker_v2.py
create mode 100644 scripts/addons/webgui/tests/test_workflows_v2.py
create mode 100644 scripts/aim.yml
create mode 100644 scripts/aimctl.py
create mode 100644 scripts/config/webgui.toml
create mode 100644 scripts/config/webgui.toml.valid
create mode 100644 scripts/docs/ADDON_AGENTS.md
create mode 100644 scripts/docs/ADDON_API.md
create mode 100644 scripts/docs/ADDON_SUPPORT.md
create mode 100644 scripts/docs/AGENTS.md
create mode 100644 scripts/docs/CHANGELOG.md
create mode 100644 scripts/docs/CHECKMK.md
create mode 100644 scripts/docs/DETAILED_PROGRESS.md
create mode 100644 scripts/docs/EXECUTOR_STAGING.md
create mode 100644 scripts/docs/INSTALLATION.md
create mode 100644 scripts/docs/INVENTORY_HIERARCHY.md
create mode 100644 scripts/docs/OPERATION_RESULTS.md
create mode 100644 scripts/docs/PLAYBOOKS.md
create mode 100644 scripts/docs/README.md
create mode 100644 scripts/docs/RELEASE_HANDOFF.md
create mode 100644 scripts/docs/RELEASE_NOTES.md
create mode 100644 scripts/docs/SANITY.md
create mode 100644 scripts/docs/TARGET_OUTCOMES.md
create mode 100644 scripts/docs/VALIDATION.md
create mode 100644 scripts/docs/addon-support-v1.json
delete mode 100644 scripts/src/aim/__pycache__/__init__.cpython-313.pyc
delete mode 100644 scripts/src/aim/__pycache__/__main__.cpython-313.pyc
delete mode 100644 scripts/src/aim/__pycache__/auth.cpython-313.pyc
delete mode 100644 scripts/src/aim/__pycache__/config.cpython-313.pyc
delete mode 100644 scripts/src/aim/__pycache__/exceptions.cpython-313.pyc
delete mode 100644 scripts/src/aim/__pycache__/external.cpython-313.pyc
delete mode 100644 scripts/src/aim/__pycache__/locking.cpython-313.pyc
delete mode 100644 scripts/src/aim/__pycache__/permissions.cpython-313.pyc
delete mode 100644 scripts/src/aim/__pycache__/structure.cpython-313.pyc
delete mode 100644 scripts/src/aim/__pycache__/templates.cpython-313.pyc
delete mode 100644 scripts/src/aim/backup/__pycache__/__init__.cpython-313.pyc
delete mode 100644 scripts/src/aim/backup/__pycache__/session.cpython-313.pyc
create mode 100644 scripts/src/aim/ctl.py
delete mode 100644 scripts/src/aim/customers/__pycache__/__init__.cpython-313.pyc
delete mode 100644 scripts/src/aim/customers/__pycache__/manager.cpython-313.pyc
create mode 100644 scripts/src/aim/integrations/__init__.py
create mode 100644 scripts/src/aim/integrations/ansible.cfg
create mode 100644 scripts/src/aim/integrations/callbacks/__init__.py
create mode 100644 scripts/src/aim/integrations/callbacks/aim_safe_events.py
create mode 100644 scripts/src/aim/integrations/event_policy.py
create mode 100644 scripts/src/aim/integrations/output_policy.py
delete mode 100644 scripts/src/aim/inventory/__pycache__/__init__.cpython-313.pyc
delete mode 100644 scripts/src/aim/inventory/__pycache__/hosts.cpython-313.pyc
delete mode 100644 scripts/src/aim/inventory/__pycache__/loader.cpython-313.pyc
delete mode 100644 scripts/src/aim/inventory/__pycache__/model.cpython-313.pyc
delete mode 100644 scripts/src/aim/inventory/__pycache__/validator.cpython-313.pyc
delete mode 100644 scripts/src/aim/inventory/__pycache__/writer.cpython-313.pyc
create mode 100644 scripts/src/aim/inventory/hierarchy.py
delete mode 100644 scripts/src/aim/maintenance/__pycache__/__init__.cpython-313.pyc
delete mode 100644 scripts/src/aim/maintenance/__pycache__/checkmk.cpython-313.pyc
delete mode 100644 scripts/src/aim/maintenance/__pycache__/repository.cpython-313.pyc
delete mode 100644 scripts/src/aim/playbooks/__pycache__/__init__.cpython-313.pyc
delete mode 100644 scripts/src/aim/playbooks/__pycache__/manager.cpython-313.pyc
create mode 100644 scripts/src/aim/playbooks/catalog.py
create mode 100644 scripts/src/aim/playbooks/planning.py
create mode 100644 scripts/src/aim/runtime/__init__.py
create mode 100644 scripts/src/aim/runtime/agent.py
create mode 100644 scripts/src/aim/runtime/ansible.py
create mode 100644 scripts/src/aim/runtime/events.py
create mode 100644 scripts/src/aim/runtime/outputs.py
create mode 100644 scripts/src/aim/runtime/process.py
create mode 100644 scripts/src/aim/runtime/secrets.py
create mode 100644 scripts/src/aim/runtime/staging.py
create mode 100644 scripts/src/aim/services/__init__.py
create mode 100644 scripts/src/aim/services/v1/__init__.py
create mode 100644 scripts/src/aim/services/v1/credentials.py
create mode 100644 scripts/src/aim/services/v1/models.py
create mode 100644 scripts/src/aim/services/v1/service.py
delete mode 100644 scripts/src/aim/sophos/__pycache__/__init__.cpython-313.pyc
delete mode 100644 scripts/src/aim/sophos/__pycache__/config.cpython-313.pyc
delete mode 100644 scripts/src/aim/ssh/__pycache__/__init__.cpython-313.pyc
delete mode 100644 scripts/src/aim/ssh/__pycache__/keys.cpython-313.pyc
delete mode 100644 scripts/src/aim/ui/__pycache__/__init__.cpython-313.pyc
delete mode 100644 scripts/src/aim/ui/__pycache__/access.cpython-313.pyc
delete mode 100644 scripts/src/aim/ui/__pycache__/administration.cpython-313.pyc
delete mode 100644 scripts/src/aim/ui/__pycache__/app.cpython-313.pyc
delete mode 100644 scripts/src/aim/ui/__pycache__/components.cpython-313.pyc
delete mode 100644 scripts/src/aim/ui/__pycache__/customers.cpython-313.pyc
delete mode 100644 scripts/src/aim/ui/__pycache__/execution.cpython-313.pyc
delete mode 100644 scripts/src/aim/ui/__pycache__/hosts.cpython-313.pyc
delete mode 100644 scripts/src/aim/ui/__pycache__/maintenance.cpython-313.pyc
delete mode 100644 scripts/src/aim/ui/__pycache__/playbooks.cpython-313.pyc
delete mode 100644 scripts/src/aim/ui/__pycache__/selection.cpython-313.pyc
delete mode 100644 scripts/src/aim/ui/__pycache__/targets.cpython-313.pyc
create mode 100644 scripts/src/aim/ui/vault_credentials.py
delete mode 100644 scripts/src/aim/variables/__pycache__/__init__.cpython-313.pyc
delete mode 100644 scripts/src/aim/variables/__pycache__/viewer.cpython-313.pyc
delete mode 100644 scripts/src/aim/vault/__pycache__/__init__.cpython-313.pyc
delete mode 100644 scripts/src/aim/vault/__pycache__/manager.cpython-313.pyc
create mode 100644 scripts/src/aim/vault/session.py
delete mode 100644 scripts/src/aim/winrm/__pycache__/__init__.cpython-313.pyc
delete mode 100644 scripts/src/aim/winrm/__pycache__/manager.cpython-313.pyc
diff --git a/roles/checkmk_scripts/files/Linux/local/check_certificate_directory.sh.example b/.aim-core-deploy.lock
similarity index 100%
rename from roles/checkmk_scripts/files/Linux/local/check_certificate_directory.sh.example
rename to .aim-core-deploy.lock
diff --git a/.gitignore b/.gitignore
index 359d707..76bfb02 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
**/bak.yml
-**/vault.yml
\ No newline at end of file
+**/vault.yml
+**/__pycache__
+**/tests
\ No newline at end of file
diff --git a/README.md b/README.md
index f29215c..81896f9 100644
--- a/README.md
+++ b/README.md
@@ -1,76 +1,72 @@
-# AIM --- Ansible Inventory Manager
+# AIM: Ansible Inventory Manager
-AIM is an operator-focused Python application for managing Ansible
-customer inventories, access configuration, Vaults and curated playbook
-execution.
+**Current candidate: 3.3.0rc4. Canonical Ansible Core: 2.19.11. Service/wire/event API: 1.0.**
-## Source layout
+AIM is an independent controller product with a built-in terminal (`aim`), a machine
+interface (`aimctl`) and an additive Python facade (`aim.services.v1`). Add-ons consume
+Core contracts; they do not patch Core, emulate its console or own execution semantics.
-``` text
-/etc/ansible/scripts/
-├── README.md
-├── install.md
-├── CHANGELOG.md
-├── docs/
-├── pyproject.toml
-└── src/
- └── aim/
+## This release
+
+The new `aim_output_v1` publisher convention and catalog-owned schemas expose purposeful
+operation data independently of task progress and per-target outcomes. Nine operations
+now publish reports: role detection, disk usage, event exports, service recovery, OS
+patching, Checkmk cleanup, user-config reading, agent installation and config updating.
+
+Results are finite typed data, not raw Ansible stdout/debug/module dictionaries. Existing
+nonreporting operations keep `operation_result: null`. Both summary and detail clients
+receive final reports. The terminal retains native output; native Ansible retains its
+own execution behavior and does not become a Core API consumer.
+
+3.3.0rc4 makes Windows patching wave-based around the native `ansible.windows.win_updates`
+orchestration. AIM submits the currently selected category set as one Windows Update wave
+with `reboot: false`, lets the module/WUA process that wave, and evaluates reboot policy only
+after the wave returns. A reboot ends the run by default; another post-reboot wave requires
+explicit `os_patching_rescan_after_reboot: true`. AIM no longer implements a per-update
+scheduler. Per-update result/HRESULT evidence is still normalized into `patch_summary_v1`.
+
+## Install or update
+
+Distribute the complete `AIM-Ansible-3.3.0rc4.zip` and matching `.zip.sha256` from a trusted
+channel. A checksum checks integrity, not publisher authenticity. No Git or patch workflow.
+
+```bash
+cd /var/tmp
+sha256sum -c AIM-Ansible-3.3.0rc4.zip.sha256
+unzip AIM-Ansible-3.3.0rc4.zip
+cd aim-core-3.3.0rc4
+sudo python3 deploy/deploy.py update --dry-run
+# Review the plan, then stop active jobs and source writers before applying.
+sudo python3 deploy/deploy.py update --apply --quiesced
+hash -r
+aim --version
+aimctl --version
+aimctl capabilities
```
-AIM uses `/etc/ansible/inventories//hosts.yml` as the
-inventory source of truth.
+The deployer discovers the existing AIM interpreter from its recognized launcher. Only
+supply `--aim-python /absolute/venv/bin/python` when discovery needs an explicit known path;
+never pass an empty shell variable. Provision AIM's declared dependencies separately for
+fresh installation, then use `install` instead of `update`. The deployer does not install
+packages, modify services or enable external execution.
-## Supported platform groups
+Existing `scripts/aim.yml`, inventories, Vaults, keys, environments, add-ons and customer
+assets stay. The six explicitly retired Core documents listed in the deployment guide
+are removed with recovery copies; unknown operator documents are not purged.
-- `linux`
-- `windows`
-- `sophosxgs`
-- `pfsense`
+## Canonical documentation
-A host can belong to multiple groups/subgroups. Platform groups remain
-the top-level groups because they define Ansible connection semantics.
+Start at [the documentation index](scripts/docs/README.md). There is one current document
+per topic, one current validation record and one current sanity checklist. Historical
+changes remain only in [CHANGELOG](scripts/CHANGELOG.md), not competing release guides.
-## Quick start
+- [Release notes](scripts/docs/RELEASE_NOTES.md) and [validation](scripts/docs/VALIDATION.md)
+- [Fresh installation](scripts/docs/INSTALLATION.md), [deployment/recovery](deploy/README.md), and [controller sanity tests](scripts/docs/SANITY.md)
+- [Authoritative Core guide](AGENTS.md) and [add-on guide](ADDON_AGENTS.md)
+- [API contract](scripts/docs/ADDON_API.md), [operation results](scripts/docs/OPERATION_RESULTS.md), [handoff](scripts/docs/RELEASE_HANDOFF.md)
+- [Playbooks](scripts/docs/PLAYBOOKS.md), [Checkmk settings](scripts/docs/CHECKMK.md), [executor staging](scripts/docs/EXECUTOR_STAGING.md)
-See [install.md](install.md) for installation, virtual-environment
-setup, authorization-group configuration and recovery of Git-ignored
-runtime data.
-
-Start AIM with:
-
-``` bash
-aim
-```
-
-Useful UI troubleshooting modes:
-
-``` bash
-aim --no-clear
-aim --plain
-aim --live-output
-```
-
-## Documentation
-
-- [Installation](install.md)
-- [Operations](docs/OPERATIONS.md)
-- [Inventory](docs/INVENTORY.md)
-- [Windows / WinRM / AD](docs/WINDOWS.md)
-- [Recovery](docs/RECOVERY.md)
-- [Security and sensitive data](docs/SECURITY.md)
-- [Development](docs/DEVELOPMENT.md)
-- [Changelog](CHANGELOG.md)
-
-## Important operational rules
-
-AIM does not use `.hosts.tsv` as inventory state. `hosts.yml` is
-authoritative.
-
-Routine host changes use local YAML validation and do not unnecessarily
-prompt for the Vault password. Explicit inventory validation may invoke
-`ansible-inventory` and request the customer Vault password when a Vault
-exists.
-
-Existing customer-specific values and arbitrary valid YAML structures
-should be preserved unless an operator explicitly requests an operation
-that changes them.
+**Acceptance:** implementation/local tests do not certify this candidate on Windows,
+Linux package managers or a deployed service sandbox. Previous controller successes
+are historical evidence, not new test passes. External execution is still opt-in and
+requires the authorized execution account, collection access and writable staging.
diff --git a/aim.yml b/aim.yml
deleted file mode 100644
index 2df302f..0000000
--- a/aim.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-root_dir: /etc/ansible
-service_user: svc_ansible
-required_group: root
-platform_groups:
-- linux
-- windows
-- sophosxgs
-- pfsense
-ui:
- clear_screen: true
- ascii: false
- output: compact
diff --git a/deploy/README.md b/deploy/README.md
new file mode 100644
index 0000000..d74d9a4
--- /dev/null
+++ b/deploy/README.md
@@ -0,0 +1,205 @@
+# AIM core ZIP deployment - 3.3.0rc8
+
+This standard-library operational helper installs a complete source release and
+its two source-bound command launchers. No Git, patch files, release manifest,
+package installation, add-on inspection or account/group migration is used.
+Python 3.11+ on Linux is required. Development validators are not distributed.
+
+## Verify and preview
+
+Obtain the ZIP and its SHA-256 sidecar through a trusted channel. The sidecar is an
+integrity check, not a publisher signature. Stage outside the installation tree:
+
+```bash
+cd /var/tmp
+sha256sum -c AIM-Ansible-3.3.0rc8.zip.sha256
+unzip AIM-Ansible-3.3.0rc8.zip
+cd aim-core-3.3.0rc8
+sudo python3 deploy/deploy.py update --dry-run
+```
+
+The target defaults to `/etc/ansible`. `update` requires existing core source;
+`install` is for a fresh tree. Preview is the default without `--apply`.
+Do not extract over the live installation. Source and target must not overlap;
+symlink paths and unexpected source files are rejected.
+
+3.3.0rc8 checks the existing **AIM** Python interpreter before making changes. It can
+infer it only from an unambiguous installed `aim` Python shebang. It does not assume
+that `sudo python3`, the Ansible interpreter or an add-on environment contains AIM's
+dependencies. It preserves a virtual environment's Python path without resolving
+its symlink to the system Python.
+
+When discovery is unavailable (for example sudo has a different PATH), provide the
+already identified AIM interpreter explicitly. In the operator test session,
+`AIM_PYTHON` is the interpreter that successfully ran `scripts/aimctl.py`:
+
+```bash
+test -x "$AIM_PYTHON" || { echo 'Set AIM_PYTHON to the existing AIM interpreter first.'; exit 1; }
+sudo python3 deploy/deploy.py update --aim-python "$AIM_PYTHON" --dry-run
+```
+
+Shell wrappers and `#!/usr/bin/env ...` shebangs are not guessed. The interpreter
+must be an absolute executable Python 3.11+ path with no spaces (up to 120 characters),
+and must already contain the dependencies from `scripts/pyproject.toml`.
+
+## Command directory and multiple installations
+
+The preview prints the chosen interpreter, command directory, source operations
+and `@launchers/aim` / `@launchers/aimctl` operations. `@launchers` is a journal
+identifier, not a directory shipped in the source archive.
+
+By default the command directory is the existing `aim` command's parent directory,
+or `/usr/local/bin` when no command exists and an interpreter was supplied. Override
+with `--bin-dir /absolute/command/directory`. A nondefault `--target` **requires** its
+own explicit `--bin-dir` so development deployment cannot silently replace production
+commands. Use the same chosen interpreter/directory on preview and apply.
+
+Only recognized AIM Python entry scripts or AIM-managed launchers for this target
+may be replaced. Unrelated programs, unsafe symlink command destinations and launchers for
+another installation are refused. There is no automatic force-overwrite escape hatch.
+Choose a reviewed unused command directory when the existing layout is nonstandard.
+Ensure the selected directory is on the intended operator's PATH; aliases and another
+installation earlier on PATH remain the operator's responsibility.
+
+## Apply while quiesced
+
+Stop new jobs and exit active AIM/Ansible sessions. `--quiesced` acknowledges that
+source writers/runners have been stopped; it does not discover, kill or pause jobs.
+
+```bash
+sudo python3 deploy/deploy.py update --apply --quiesced
+# Include the same --aim-python and --bin-dir options used in the preview, if any.
+hash -r
+command -v aim
+command -v aimctl
+aim --version
+aimctl --version
+aimctl capabilities
+```
+
+The helper verifies the installed `aim --version` and `aimctl capabilities` against
+the source release before reporting success. It uses an explicit installed config
+path for its capability check. It does not invoke Ansible or contact managed hosts.
+Both launchers import the deployed `scripts/src/aim` source with the selected AIM
+Python. Old launchers and core source are included in protected recovery data.
+
+A stable deployment lock prevents another cooperating deployment. Each source file
+is replaced atomically, preserving existing UID/GID/mode/extended attributes.
+New source files use 0644; new launchers use 0755, and recognized existing launchers
+retain their metadata with executable bits enabled. Directories use normal caller
+ownership/inheritance. This is not a recursive ownership-policy migration.
+
+Recovery defaults to `/var/backups/aim-core`; the helper prints the exact private
+0700 recovery directory. `--backup-dir /private/path` selects another root outside
+source and installation. Keep sufficient space and apply your retention policy.
+
+Obsolete files inside `scripts/src/aim/` and the explicitly retired Core documents below are pruned. Unknown customer files in
+other locations are retained. Add-on code must use its own namespace, not the core
+Python namespace.
+
+**Preserved when present:** operator `scripts/aim.yml`, customer `.aim.yml`, inventories,
+Vaults, SSH keys, add-ons and their state/configuration, environments, external assets,
+unknown playbooks/roles and staged agent files. No dependencies, services, accounts,
+LDAP/local group memberships, remote credentials or add-on version gates are modified.
+Missing new settings are not automatically inserted into an operator's existing YAML.
+
+## Python package and runtime scope
+
+These launchers are source-bound entry points, not a pip reinstall. The helper does
+not change installed wheel/distribution metadata or upgrade packages. Machine clients
+calling the installed `aimctl` reach the deployed source. In-process Python clients
+must also resolve `aim` to this source (for example an existing editable installation
+or an explicitly configured source import path), not an old separately installed
+wheel. Verify `aim.__file__` and `aim.__version__` in that client's own environment.
+No add-on's Python environment is changed by core deployment.
+
+For a fresh installation, provision an AIM Python 3.11+ environment and its declared
+`ruamel.yaml`/`rich` dependencies first, then pass that interpreter to `install`.
+Provide the separate canonical Ansible Core **2.19.11** runtime and approved collections
+from `requirements-controller.txt` / `requirements.yml` explicitly. The helper does
+not install from the network or mutate a system-managed Ansible installation.
+
+For a nondefault controller root, maintain that installation's operator configuration
+and use `aimctl --config /absolute/root/scripts/aim.yml ...` when necessary. Existing
+terminal configuration discovery is unchanged; creating another launcher does not
+silently redirect a terminal's global configuration.
+
+## Opt-in API execution
+
+Follow `scripts/docs/SANITY.md` (historical evidence is in
+`scripts/docs/VALIDATION.md`). External execution remains disabled by
+default and is not enabled by deployment, readiness or the launcher smoke test.
+Preserve the existing YAML and merge only deliberately approved settings:
+
+```yaml
+addons:
+ execution_enabled: true
+runtime:
+ ansible_playbook: /usr/bin/ansible-playbook
+```
+
+The executable path is the operator's approved native runtime, not an assumption
+for every installation. Worker authorization, filesystem/key access, collections,
+connection dependencies and same-UID execution still apply. Do not make private keys
+group-readable to bypass an unsupported cross-user deployment.
+
+## Recovery and interruption
+
+Ordinary application/launcher-check failures attempt to restore touched source and
+launchers. The update is not a whole-tree atomic transaction: power loss or SIGKILL
+can leave partial source. Keep jobs stopped until recovery/version checks complete.
+Recovery journals contain intent, hashes and original metadata; they are local
+recovery records, not a distributed release manifest or inventory backup.
+
+Use this 3.3.0rc8 deployer and the printed recovery directory; include the same target
+for a nondefault installation. Launcher paths are recorded in the journal.
+
+```bash
+sudo python3 deploy/deploy.py rollback --from-backup /var/backups/aim-core/RECOVERY-DIRECTORY --dry-run
+sudo python3 deploy/deploy.py rollback --from-backup /var/backups/aim-core/RECOVERY-DIRECTORY --apply --quiesced
+hash -r
+aim --version
+```
+
+Rollback validates installed/recovery hashes and refuses to overwrite subsequently
+modified source or launchers. Existing aim.yml is never rolled back or removed,
+even after a fresh install. Empty directories may remain. A newly created aimctl
+launcher is removed when restoring a previous release that had no such launcher.
+
+No remote Ansible action, dependency installation, account/group change or add-on
+state is reversed. Keep independent backups of runtime/customer data.
+
+## Required executor staging (independently provisioned units)
+
+Read `scripts/docs/EXECUTOR_STAGING.md` in the installed tree. New add-on executor
+installers should provision owner-only staging and a narrow directory write
+exception by default, then run `aimctl staging-check` inside the actual unit at
+startup. Ordinary `sudo -u` success does not reproduce mount/syscall restrictions.
+
+The source deployer does not inspect/edit/restart services. It preserves the
+working exception already applied by the operator. The automatic Core preflight
+is on by default, but cannot make a read-only mount writable. Do not remove the
+exception, broaden home write access or recursively chown anything during this
+update. The new startup command is available after the normal launcher refresh.
+
+## Documentation consolidation in 3.3.0rc8
+
+Current docs have stable topic names with one validation record and one acceptance
+checklist. Upgrade removes only these explicitly retired Core filenames (with
+protected rollback copies), including locally modified versions of those exact files:
+
+- scripts/docs/RC19_HANDOFF.md
+- scripts/docs/SANITY_3.2.0.md
+- scripts/docs/SANITY_3.2.1rc2.md
+- scripts/docs/VERIFICATION.md
+- scripts/docs/LOCAL_VALIDATION.md
+- scripts/docs/CONTROLLER_ACCEPTANCE.md
+
+Review REMOVE entries before applying. Move any operator notes out of these retired
+Core-owned names before deployment. Unknown Markdown files and other operator
+documents are preserved. Rollback restores retired document bytes/metadata through
+the existing recovery journal. No recursive docs purge or runtime deletion occurs.
+
+The new playbooks/filter_plugins/*.py files and playbooks/schemas/*.yml files are
+Core-owned reporting source. They are deployed with the playbooks; no add-on Python
+environment or collection is modified.
diff --git a/deploy/deploy.py b/deploy/deploy.py
new file mode 100644
index 0000000..7970469
--- /dev/null
+++ b/deploy/deploy.py
@@ -0,0 +1,593 @@
+#!/usr/bin/env python3
+"""Operational full-source install/update, not a Git patcher or release validator.
+
+Python 3.11+, standard library only. Does not install dependencies, change groups,
+start services, touch inventory/add-on data, or replace operator configuration.
+"""
+from __future__ import annotations
+import argparse
+from dataclasses import dataclass
+import base64
+from contextlib import contextmanager
+from datetime import datetime, timezone
+import fcntl
+import hashlib
+import json
+import os
+from pathlib import Path
+import shutil
+import stat
+import subprocess
+import tomllib
+import sys
+import tempfile
+
+
+class DeploymentError(Exception):
+ pass
+
+
+def no_symlink(path: Path):
+ for part in (path, *path.parents):
+ if part.is_symlink():
+ raise DeploymentError('Refusing a symlink in a deployment path: ' + str(part))
+
+
+def no_symlink_parents(path: Path):
+ for part in path.parents:
+ if part.is_symlink():
+ raise DeploymentError('Refusing a symlink in a deployment parent path: ' + str(part))
+
+
+def canonical(path: Path) -> Path:
+ # Check before normalization so an intermediate symlink cannot disappear.
+ no_symlink(path.absolute())
+ return Path(os.path.abspath(path))
+
+
+@contextmanager
+def deployment_lock(target: Path):
+ lock = target / '.aim-core-deploy.lock'
+ fd = os.open(lock, os.O_WRONLY | os.O_NONBLOCK | os.O_CREAT | os.O_CLOEXEC | getattr(os, 'O_NOFOLLOW', 0), 0o600)
+ try:
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
+ raise DeploymentError('Deployment lock is not a regular file.')
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ yield
+ finally:
+ os.close(fd)
+
+
+def allowed(relative: Path) -> bool:
+ parts = relative.parts
+ if relative.as_posix() in {'requirements.yml', 'requirements-controller.txt', 'deploy/deploy.py', 'deploy/README.md'}:
+ return True
+ if len(parts) >= 3 and parts[:3] == ('scripts', 'src', 'aim'):
+ return relative.suffix == '.py' or relative.as_posix() == 'scripts/src/aim/integrations/ansible.cfg'
+ if len(parts) >= 3 and parts[:2] == ('scripts', 'docs'):
+ return relative.suffix in ('.md', '.json')
+ if len(parts) == 2 and parts[0] == 'scripts':
+ return parts[1] in {'pyproject.toml', 'aim.yml', 'AIM-WinRM-OneTime.ps1', 'aimctl.py'}
+ if len(parts) == 3 and parts[:2] == ('playbooks', 'filter_plugins'):
+ return relative.suffix == '.py'
+ if len(parts) >= 2 and parts[0] == 'playbooks':
+ return relative.suffix in ('.yml', '.yaml', '.md')
+ if len(parts) >= 3 and parts[0] == 'roles':
+ if relative.name == 'README.md':
+ return True
+ return len(parts) >= 4 and parts[2] in ('tasks', 'defaults', 'handlers', 'meta', 'vars', 'templates') and relative.suffix in ('.yml', '.yaml', '.j2')
+ return False
+
+
+def source_files(source: Path) -> dict[str, Path]:
+ result = {}
+ for parent, dirs, files in os.walk(source, followlinks=False):
+ dirs[:] = sorted(d for d in dirs if d != '__pycache__')
+ for d in dirs:
+ no_symlink(Path(parent) / d)
+ for name in sorted(files):
+ path = Path(parent) / name
+ relative = path.relative_to(source)
+ if path.suffix == '.pyc':
+ continue
+ if path.is_symlink() or not path.is_file() or not allowed(relative):
+ raise DeploymentError('Unexpected source entry; refusing deployment: ' + str(relative))
+ result[relative.as_posix()] = path
+ required = {'scripts/src/aim/__init__.py', 'scripts/pyproject.toml', 'scripts/docs/AGENTS.md', 'scripts/docs/ADDON_AGENTS.md', 'playbooks/aim_catalog.yml'}
+ if not required.issubset(result):
+ raise DeploymentError('Not a complete AIM replacement source tree.')
+ return result
+
+
+def digest(path: Path | bytes) -> str:
+ if isinstance(path, bytes):
+ return hashlib.sha256(path).hexdigest()
+ with path.open('rb') as stream:
+ return hashlib.file_digest(stream, 'sha256').hexdigest()
+
+
+LAUNCHER_MARKER = '# AIM core managed launcher v1'
+
+
+def _launcher_digest(path: Path) -> str | None:
+ if path.is_symlink():
+ return digest(('symlink:' + os.readlink(path)).encode('utf-8'))
+ if path.exists():
+ return digest(path)
+ return None
+
+
+def _read_launcher_text(path: Path) -> str:
+ candidate = path.resolve(strict=True) if path.is_symlink() else path
+ if not candidate.is_file() or candidate.stat().st_size > 65536:
+ raise DeploymentError('A non-launcher occupies ' + str(path))
+ try:
+ return candidate.read_text(encoding='utf-8')
+ except UnicodeDecodeError:
+ raise DeploymentError('Refusing to replace an unrecognized launcher: ' + str(path)) from None
+
+
+def _restore_symlink(destination: Path, target: str):
+ no_symlink_parents(destination)
+ temporary = destination.parent / ('.aim-link-' + next(tempfile._get_candidate_names()))
+ try:
+ os.symlink(target, temporary)
+ os.replace(temporary, destination)
+ dfd = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY)
+ try:
+ os.fsync(dfd)
+ finally:
+ os.close(dfd)
+ finally:
+ temporary.unlink(missing_ok=True)
+
+
+def _launcher_path(item, target: Path, launcher_dir: Path | None = None):
+ relative = Path(item['path'])
+ if item.get('kind') == 'launcher':
+ if launcher_dir is None or relative.parts not in (('@launchers', 'aim'), ('@launchers', 'aimctl')):
+ raise DeploymentError('Invalid launcher recovery path.')
+ directory = canonical(launcher_dir)
+ if not directory.is_absolute() or directory == Path('/'):
+ raise DeploymentError('Invalid launcher directory.')
+ destination = directory / relative.name
+ else:
+ if relative.is_absolute() or '..' in relative.parts or relative.parts[:1] == ('@launchers',):
+ raise DeploymentError('Invalid core source path.')
+ destination = target / relative
+ if item.get('kind') == 'launcher':
+ no_symlink_parents(destination)
+ else:
+ no_symlink(destination)
+ return destination
+
+
+def _command(args, *, timeout=20):
+ env = os.environ.copy()
+ for name in ('PYTHONPATH', 'PYTHONHOME', 'PYTHONSTARTUP', 'PYTHONINSPECT'):
+ env.pop(name, None)
+ env['PYTHONDONTWRITEBYTECODE'] = '1'
+ try:
+ result = subprocess.run(args, stdin=subprocess.DEVNULL, capture_output=True,
+ text=True, timeout=timeout, env=env, cwd='/')
+ except (OSError, subprocess.TimeoutExpired):
+ raise DeploymentError('AIM interpreter/launcher check could not complete; no dependency installation is attempted.') from None
+ if result.returncode:
+ raise DeploymentError('AIM interpreter/launcher check failed. Supply the existing AIM environment with --aim-python; ensure its Python 3.11+, ruamel.yaml and rich dependencies and operator configuration are usable.')
+ return result.stdout
+
+
+@dataclass(frozen=True)
+class EntryPoints:
+ python: Path
+ directory: Path
+ target: Path
+
+ def contents(self):
+ result = {}
+ for name, module in (('aim', 'aim.__main__'), ('aimctl', 'aim.ctl')):
+ metadata = json.dumps({'target': str(self.target), 'python': str(self.python), 'command': name}, sort_keys=True)
+ content = (f'#!{self.python}\n{LAUNCHER_MARKER}\n# {metadata}\n'
+ 'import sys\n'
+ 'sys.dont_write_bytecode = True\n'
+ f'sys.path.insert(0, {str(self.target / "scripts/src")!r})\n'
+ f'from {module} import main\n'
+ 'if __name__ == "__main__":\n raise SystemExit(main())\n')
+ result['@launchers/' + name] = content.encode('utf-8')
+ return result
+
+ def verify_python(self, source):
+ # An explicit interpreter is an administrator-selected executable, not
+ # user-controlled input to an elevated web wrapper. Preserve venv symlinks.
+ expected = tomllib.loads((source / 'scripts/pyproject.toml').read_text())['project']['version']
+ code = ('import sys,json; assert sys.version_info >= (3,11); '
+ f'sys.path.insert(0, {str(source / "scripts/src")!r}); '
+ 'import ruamel.yaml,rich,aim; from aim.ui.app import App; '
+ 'from aim.services.v1 import AimService; from aim.ctl import main; '
+ 'print(json.dumps({"version":aim.__version__}))')
+ value = json.loads(_command([str(self.python), '-I', '-B', '-c', code]))
+ if value.get('version') != expected:
+ raise DeploymentError('Extracted source/package versions disagree.')
+ return expected
+
+ def verify_installed(self, version):
+ got = _command([str(self.directory / 'aim'), '--version']).strip()
+ value = json.loads(_command([str(self.directory / 'aimctl'), '--config',
+ str(self.target / 'scripts/aim.yml'), 'capabilities']))
+ if got != 'AIM ' + version or not value.get('ok') or value.get('result', {}).get('core_version') != version:
+ raise DeploymentError('Installed AIM/aimctl launchers do not report the deployed core version.')
+ print('PASS installed aim --version and aimctl capabilities (' + version + ').')
+
+
+def entry_points(target: Path, python: Path | None, directory: Path | None) -> EntryPoints:
+ existing = shutil.which('aim')
+ if target != Path('/etc/ansible') and directory is None:
+ raise DeploymentError('A nondefault --target requires an explicit --bin-dir to avoid replacing another installation\'s commands.')
+ if python is None:
+ if not existing:
+ raise DeploymentError('Cannot discover the AIM interpreter. Supply --aim-python /absolute/path/to/the/existing/AIM/bin/python.')
+ with Path(existing).open(encoding='utf-8') as stream:
+ first = stream.readline().strip()
+ if not first.startswith('#!/') or len(first[2:].split()) != 1 or Path(first[2:]).name not in ('python', 'python3', 'python3.11', 'python3.12', 'python3.13', 'python3.14'):
+ raise DeploymentError('The existing aim launcher has no unambiguous Python shebang. Supply --aim-python explicitly; shell/env wrappers are not guessed.')
+ python = Path(first[2:])
+ if not python.is_absolute() or not python.is_file() or not os.access(python, os.X_OK) or any(c.isspace() for c in str(python)) or len(str(python)) > 120:
+ raise DeploymentError('--aim-python must be an executable absolute, space-free Python path (maximum 120 characters). Venv symlinks are supported.')
+ python = Path(os.path.abspath(python)) # do NOT resolve a venv symlink to the system Python
+ directory = directory if directory is not None else (Path(existing).parent if existing else Path('/usr/local/bin'))
+ if not directory.is_absolute():
+ raise DeploymentError('--bin-dir must be absolute.')
+ directory = canonical(directory)
+ if directory == Path('/') or directory == target or directory.is_relative_to(target / 'scripts/src'):
+ raise DeploymentError('Use a dedicated command directory, not the root or core source namespace.')
+ if directory.exists() and not directory.is_dir():
+ raise DeploymentError('The command directory is not a directory.')
+ for name, module in (('aim', 'aim.__main__'), ('aimctl', 'aim.ctl')):
+ dest = directory / name
+ no_symlink_parents(dest)
+ if dest.exists() or dest.is_symlink():
+ if dest.is_symlink():
+ try:
+ resolved = dest.resolve(strict=True)
+ except (OSError, RuntimeError):
+ raise DeploymentError('Refusing a broken launcher symlink: ' + str(dest)) from None
+ if not resolved.is_file():
+ raise DeploymentError('Launcher symlink does not resolve to a regular file: ' + str(dest))
+ text = _read_launcher_text(dest)
+ if LAUNCHER_MARKER in text:
+ try:
+ info = json.loads(text.splitlines()[2][2:])
+ except (ValueError, IndexError):
+ raise DeploymentError('Invalid AIM launcher metadata: ' + str(dest)) from None
+ if info.get('target') != str(target):
+ raise DeploymentError('AIM launcher belongs to another installation; choose its own --bin-dir.')
+ elif f'from {module} import main' not in text:
+ raise DeploymentError('Refusing to replace an unrecognized launcher. Choose a reviewed --bin-dir: ' + str(dest))
+ return EntryPoints(python, directory, target)
+
+
+def plan(source, target, mode, *, entrypoints=None):
+ source, target = canonical(source), canonical(target)
+ no_symlink(source)
+ no_symlink(target)
+ if target == Path('/') or source == target or source.is_relative_to(target) or target.is_relative_to(source):
+ raise DeploymentError('Use separate extracted-source and installation directories; never / as target.')
+ if mode == 'update' and not (target / 'scripts/src/aim/__init__.py').is_file():
+ raise DeploymentError('Existing AIM source was not found; use install for a fresh tree.')
+ files = source_files(source)
+ operations = []
+ for relative, src in sorted(files.items()):
+ dest = target / relative
+ no_symlink(dest)
+ if dest.exists() and not dest.is_file():
+ raise DeploymentError('A non-file occupies a core destination: ' + relative)
+ if relative == 'scripts/aim.yml' and dest.exists():
+ continue # operator-owned, always preserved, even on first install
+ if dest.exists() and digest(src) == digest(dest):
+ continue
+ operations.append({'path': relative, 'action': 'replace' if dest.exists() else 'create',
+ 'old_sha256': digest(dest) if dest.exists() else None,
+ 'new_sha256': digest(src)})
+ # Only the Python core namespace is an authoritative replaceable directory.
+ # Other unlisted playbooks/roles/files remain operator-owned additions.
+ core = target / 'scripts/src/aim'
+ if core.exists():
+ for parent, dirs, names in os.walk(core, followlinks=False):
+ for d in dirs:
+ no_symlink(Path(parent) / d)
+ for name in names:
+ existing = Path(parent) / name
+ no_symlink(existing)
+ relative = existing.relative_to(target).as_posix()
+ if relative not in files:
+ if not existing.is_file():
+ raise DeploymentError('Unsupported core namespace entry: ' + relative)
+ operations.append({'path': relative, 'action': 'remove', 'old_sha256': digest(existing), 'new_sha256': None})
+ # Explicitly retired Core document names only; unknown operator documents stay.
+ # Removal is previewed and recorded in the same protected rollback journal.
+ retired_docs = (
+ # Root/scripts-root AIM-owned docs moved into scripts/docs.
+ # Component-local docs (deploy/README.md, role READMEs, playbook docs) stay beside their code.
+ 'AGENTS.md', 'ADDON_AGENTS.md', 'scripts/CHANGELOG.md',
+ 'scripts/docs/RC19_HANDOFF.md', 'scripts/docs/SANITY_3.2.0.md',
+ 'scripts/docs/SANITY_3.2.1rc2.md', 'scripts/docs/VERIFICATION.md',
+ 'scripts/docs/LOCAL_VALIDATION.md', 'scripts/docs/CONTROLLER_ACCEPTANCE.md',
+ )
+ for relative in retired_docs:
+ existing = target / relative
+ no_symlink(existing)
+ if existing.exists() and relative not in files:
+ if not existing.is_file():
+ raise DeploymentError('Non-file occupies a retired document: ' + relative)
+ operations.append({'path': relative, 'action': 'remove',
+ 'old_sha256': digest(existing), 'new_sha256': None})
+ if entrypoints is not None:
+ if entrypoints.target != target or entrypoints.directory == source or entrypoints.directory.is_relative_to(source):
+ raise DeploymentError('Launcher target/directory conflicts with the extracted source.')
+ for relative, content in entrypoints.contents().items():
+ dest = _launcher_path({'path': relative, 'kind': 'launcher'}, target, entrypoints.directory)
+ files[relative] = content
+ current = _launcher_digest(dest)
+ if not dest.is_symlink() and dest.exists() and current == digest(content) and os.access(dest, os.X_OK):
+ continue
+ operations.append({'path': relative, 'kind': 'launcher', 'action': 'replace' if (dest.exists() or dest.is_symlink()) else 'create',
+ 'old_sha256': current, 'new_sha256': digest(content)})
+ return files, operations
+
+
+def atomic_file(source: Path | bytes, destination: Path, *, metadata=None, executable=False, allow_replace_symlink=False):
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ if allow_replace_symlink:
+ no_symlink_parents(destination)
+ else:
+ no_symlink(destination)
+ fd, filename = tempfile.mkstemp(prefix='.aim-install-', dir=destination.parent)
+ staged = Path(filename)
+ try:
+ with os.fdopen(fd, 'wb') as stream:
+ if isinstance(source, bytes):
+ stream.write(source)
+ else:
+ with source.open('rb') as incoming:
+ shutil.copyfileobj(incoming, stream)
+ stream.flush()
+ os.fsync(stream.fileno())
+ if metadata is None and destination.exists() and not destination.is_symlink():
+ old = destination.stat()
+ os.chown(staged, old.st_uid, old.st_gid)
+ # Preserve ACLs/attributes, not the old file timestamp.
+ for key in os.listxattr(destination):
+ os.setxattr(staged, key, os.getxattr(destination, key))
+ staged.chmod(stat.S_IMODE(old.st_mode) | (0o111 if executable else 0))
+ elif metadata is not None:
+ os.chown(staged, metadata['uid'], metadata['gid'])
+ for key, value in metadata.get('xattrs', {}).items():
+ os.setxattr(staged, key, base64.b64decode(value, validate=True))
+ staged.chmod(metadata['mode'])
+ else:
+ staged.chmod(0o755 if executable else 0o644)
+ os.replace(staged, destination)
+ dfd = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY)
+ try:
+ os.fsync(dfd)
+ finally:
+ os.close(dfd)
+ finally:
+ staged.unlink(missing_ok=True)
+
+
+def apply(source, target, mode, backup_root, *, entrypoints=None):
+ source, target, backup_root = canonical(source), canonical(target), canonical(backup_root)
+ files, operations = plan(source, target, mode, entrypoints=entrypoints)
+ version = entrypoints.verify_python(source) if entrypoints else None
+ launcher_dir = entrypoints.directory if entrypoints else None
+ if not operations:
+ if entrypoints:
+ entrypoints.verify_installed(version)
+ print('AIM source and selected entry points are already current; operator configuration was preserved.')
+ return None
+ no_symlink(backup_root)
+ if backup_root == target or backup_root.is_relative_to(target) or backup_root == source or backup_root.is_relative_to(source):
+ raise DeploymentError('Backups must be outside the installation and extracted source trees.')
+ backup_root.mkdir(parents=True, exist_ok=True)
+ backup = Path(tempfile.mkdtemp(prefix=datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ-'), dir=backup_root))
+ backup.chmod(0o700)
+ record = {'target': str(target), 'state': 'preparing', 'entries': [], 'created_directories': [],
+ 'format': 2, 'launcher_dir': str(launcher_dir) if launcher_dir else None}
+ for operation in operations:
+ dest = _launcher_path(operation, target, launcher_dir)
+ item = dict(operation)
+ if dest.is_symlink():
+ item['old_kind'] = 'symlink'
+ item['link_target'] = os.readlink(dest)
+ elif dest.exists():
+ st = dest.stat()
+ item['old_kind'] = 'file'
+ item['metadata'] = dict(uid=st.st_uid, gid=st.st_gid, mode=stat.S_IMODE(st.st_mode),
+ xattrs={key: base64.b64encode(os.getxattr(dest, key)).decode('ascii') for key in os.listxattr(dest)})
+ recovery = backup / 'files' / operation['path']
+ recovery.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(dest, recovery)
+ recovery.chmod(0o600)
+ record['entries'].append(item)
+ def save_record():
+ fd, temporary = tempfile.mkstemp(prefix='.recovery-', dir=backup)
+ try:
+ with os.fdopen(fd, 'w', encoding='utf-8') as stream:
+ json.dump(record, stream, indent=2)
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary, backup / 'recovery.json')
+ dfd = os.open(backup, os.O_RDONLY | os.O_DIRECTORY)
+ try:
+ os.fsync(dfd)
+ finally:
+ os.close(dfd)
+ finally:
+ Path(temporary).unlink(missing_ok=True)
+ save_record()
+ try:
+ record['state'] = 'applying'
+ save_record()
+ for item in record['entries']:
+ dest = _launcher_path(item, target, launcher_dir)
+ current = _launcher_digest(dest) if item.get('kind') == 'launcher' else (digest(dest) if dest.exists() else None)
+ if current != item['old_sha256']:
+ raise DeploymentError('Source changed during installation; stop writers and retry.')
+ item['started'] = True
+ save_record() # persist intent before changing any installed file
+ if item['action'] == 'remove':
+ dest.unlink()
+ else:
+ if digest(files[item['path']]) != item['new_sha256']:
+ raise DeploymentError('Extracted release source changed during installation.')
+ missing_dirs = []
+ parent = dest.parent
+ while not parent.exists():
+ missing_dirs.append(str(parent))
+ parent = parent.parent
+ record['created_directories'].extend(missing_dirs)
+ atomic_file(files[item['path']], dest, executable=item.get('kind') == 'launcher',
+ allow_replace_symlink=item.get('kind') == 'launcher')
+ item['applied'] = True
+ save_record()
+ if entrypoints:
+ entrypoints.verify_installed(version)
+ record['state'] = 'completed'
+ save_record()
+ print('AIM core source installed. Recovery directory: ' + str(backup))
+ print('Preserved existing scripts/aim.yml, inventories, Vaults, keys, add-ons, virtual environments and unlisted customer files.')
+ print('No dependencies, OS identities, permissions policy or services were provisioned.')
+ if entrypoints:
+ print('AIM and aimctl launchers: ' + str(entrypoints.directory))
+ print('Ensure this directory is in the operator PATH; use hash -r in existing shells.')
+ return backup
+ except BaseException:
+ # Ordinary failures can restore the source files already touched. A power
+ # loss/kill -9 is not an atomic whole-tree transaction: retain recovery data.
+ for item in reversed(record['entries']):
+ if not item.get('started') or item['path'] == 'scripts/aim.yml':
+ continue
+ dest = _launcher_path(item, target, launcher_dir)
+ now = _launcher_digest(dest) if item.get('kind') == 'launcher' else (digest(dest) if dest.exists() else None)
+ if now == item['old_sha256'] and not (item.get('kind') == 'launcher' and dest.exists() and
+ stat.S_IMODE(dest.stat().st_mode) != item.get('metadata', {}).get('mode')):
+ continue
+ if now != item['new_sha256']:
+ raise DeploymentError('A concurrently modified file prevented rollback; use the protected recovery directory.')
+ if item['old_sha256'] is None:
+ dest.unlink(missing_ok=True)
+ elif item.get('old_kind') == 'symlink':
+ _restore_symlink(dest, item['link_target'])
+ else:
+ atomic_file(backup / 'files' / item['path'], dest, metadata=item['metadata'])
+ record['state'] = 'rolled_back_after_error'
+ save_record()
+ raise
+
+
+def rollback(backup: Path, target: Path, *, execute: bool):
+ backup, target = canonical(backup), canonical(target)
+ path = backup / 'recovery.json'
+ if path.is_symlink() or not path.is_file():
+ raise DeploymentError('Recovery metadata is missing or unsafe.')
+ record = json.loads(path.read_text(encoding='utf-8'))
+ if record.get('target') != str(target) or record.get('state') not in ('completed', 'applying'):
+ raise DeploymentError('Recovery target/state does not match this installation.')
+ actions = []
+ launcher_dir = Path(record['launcher_dir']) if record.get('launcher_dir') else None
+ for item in record['entries']:
+ if not (item.get('started') or item.get('applied')):
+ continue
+ relative = Path(item['path'])
+ if relative.is_absolute() or '..' in relative.parts or relative.as_posix() == 'scripts/aim.yml':
+ # Initial install may have created aim.yml: never delete an operator's
+ # subsequent configuration through rollback. Always preserve this file.
+ if relative.as_posix() == 'scripts/aim.yml':
+ continue
+ raise DeploymentError('Unsafe recovery path.')
+ if item.get('kind') != 'launcher' and not allowed(relative) and relative.parts[:3] != ('scripts', 'src', 'aim'):
+ raise DeploymentError('Recovery may only address the core source namespace.')
+ dest = _launcher_path(item, target, launcher_dir)
+ now = _launcher_digest(dest) if item.get('kind') == 'launcher' else (digest(dest) if dest.exists() else None)
+ if now == item['old_sha256'] and not (item.get('kind') == 'launcher' and dest.exists() and
+ stat.S_IMODE(dest.stat().st_mode) != item.get('metadata', {}).get('mode')):
+ continue # interruption before publication, or an already restored entry
+ if now != item['new_sha256']:
+ raise DeploymentError('Installed core source changed since deployment; refusing to overwrite it during rollback: ' + str(relative))
+ if item['old_sha256'] is not None and item.get('old_kind') != 'symlink':
+ recovered = backup / 'files' / relative
+ no_symlink(recovered)
+ if digest(recovered) != item['old_sha256']:
+ raise DeploymentError('Recovery file checksum mismatch.')
+ actions.append(item)
+ print('Rollback source files: ' + str(len(actions)))
+ if not execute:
+ print('Dry run only. Use --apply --quiesced to restore these source files.')
+ return
+ for item in reversed(actions):
+ dest = _launcher_path(item, target, launcher_dir)
+ if item['old_sha256'] is None:
+ dest.unlink(missing_ok=True)
+ elif item.get('old_kind') == 'symlink':
+ _restore_symlink(dest, item['link_target'])
+ else:
+ atomic_file(backup / 'files' / item['path'], dest, metadata=item['metadata'])
+ print('Core source and recorded launchers restored; no remote Ansible work was reversed. Use hash -r and verify aim/aimctl for the restored release.')
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('operation', choices=('install', 'update', 'rollback'))
+ parser.add_argument('--target', type=Path, default=Path('/etc/ansible'))
+ parser.add_argument('--backup-dir', type=Path, default=Path('/var/backups/aim-core'))
+ parser.add_argument('--from-backup', type=Path)
+ parser.add_argument('--aim-python', type=Path, help='Existing AIM interpreter; inferred only from an unambiguous installed aim Python shebang')
+ parser.add_argument('--bin-dir', type=Path, help='Install aim and aimctl here; defaults to the existing aim command directory or /usr/local/bin')
+ group = parser.add_mutually_exclusive_group()
+ group.add_argument('--apply', action='store_true', help='Apply the planned core-source replacement')
+ group.add_argument('--dry-run', action='store_true', help='Preview only (the default)')
+ parser.add_argument('--quiesced', action='store_true', help='Confirm CLI/add-on jobs and other source writers have been stopped')
+ args = parser.parse_args(argv)
+ try:
+ if not args.target.is_absolute():
+ raise DeploymentError('An absolute non-root installation directory is required.')
+ args.target = canonical(args.target)
+ if args.target == Path('/'):
+ raise DeploymentError('An absolute non-root installation directory is required.')
+ if args.apply and not args.quiesced:
+ raise DeploymentError('Stop active CLI/add-on jobs and retry with --apply --quiesced.')
+ if args.operation == 'rollback':
+ if not args.from_backup:
+ raise DeploymentError('rollback requires --from-backup.')
+ if args.apply:
+ with deployment_lock(args.target):
+ rollback(args.from_backup, args.target, execute=True)
+ else:
+ rollback(args.from_backup, args.target, execute=False)
+ return 0
+ source = Path(__file__).absolute().parents[1]
+ entrypoints = entry_points(args.target, args.aim_python, args.bin_dir)
+ entrypoints.verify_python(source)
+ _, operations = plan(source, args.target, args.operation, entrypoints=entrypoints)
+ print('Target: ' + str(args.target))
+ print('AIM interpreter: ' + str(entrypoints.python))
+ print('Command directory: ' + str(entrypoints.directory))
+ for operation in operations:
+ print(operation['action'].upper() + ' ' + operation['path'])
+ print('Planned file operations: ' + str(len(operations)))
+ print('KEEP existing scripts/aim.yml and all unlisted runtime/add-on/customer files.')
+ if not args.apply:
+ print('Dry run only. Apply from this extracted archive with --apply --quiesced.')
+ return 0
+ args.target.mkdir(parents=True, exist_ok=True)
+ with deployment_lock(args.target):
+ apply(source, args.target, args.operation, args.backup_dir.absolute(), entrypoints=entrypoints)
+ return 0
+ except (DeploymentError, OSError, ValueError, KeyError, TypeError) as exc:
+ print('AIM deployment stopped: ' + str(exc), file=sys.stderr)
+ return 1
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())
diff --git a/roles/checkmk_scripts/files/Linux/local/check_unifi-controller.sh.example b/inventories/desq_gaming/.aim.lock
similarity index 100%
rename from roles/checkmk_scripts/files/Linux/local/check_unifi-controller.sh.example
rename to inventories/desq_gaming/.aim.lock
diff --git a/inventories/desq_gaming/group_vars/linux/.ssh/svc_ansible b/inventories/desq_gaming/group_vars/linux/.ssh/svc_ansible
deleted file mode 100644
index 3111a57..0000000
--- a/inventories/desq_gaming/group_vars/linux/.ssh/svc_ansible
+++ /dev/null
@@ -1,8 +0,0 @@
------BEGIN OPENSSH PRIVATE KEY-----
-b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABCzzsHfog
-Wv9erYAv5gNSMrAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIFEjnIIVsyXXaoqd
-VGOGwSPXthf8vzZ7L//SZP3OuJjxAAAAoPoC23Ps5HmvCpJrlOsubdcAjq0Ol65xhOb8VQ
-OolhyCPFPj/eH1z21w/PvXhL1S8tDsBut27qW8+5dSwT2gqjtt/x2SiOQYMHt6EjGGAISu
-NNy9L+vRcOFIB4Lo1IE/BMeZRUpgW2GXRFcQH9KgZXh/IWMDqcS5q8GbIT69OUxVUeBg3x
-Wa5f3MyCUMEmIMkBcdbJrebWXLjDvVYDI3myE=
------END OPENSSH PRIVATE KEY-----
diff --git a/inventories/desq_gaming/group_vars/linux/.ssh/svc_ansible.pub b/inventories/desq_gaming/group_vars/linux/.ssh/svc_ansible.pub
deleted file mode 100644
index 7b6b399..0000000
--- a/inventories/desq_gaming/group_vars/linux/.ssh/svc_ansible.pub
+++ /dev/null
@@ -1 +0,0 @@
-ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFEjnIIVsyXXaoqdVGOGwSPXthf8vzZ7L//SZP3OuJjx svc_ansible@desq_gaming
diff --git a/inventories/desq_gaming/group_vars/linux/.ssh/svc_bf-ansible b/inventories/desq_gaming/group_vars/linux/.ssh/svc_bf-ansible
new file mode 100644
index 0000000..deec246
--- /dev/null
+++ b/inventories/desq_gaming/group_vars/linux/.ssh/svc_bf-ansible
@@ -0,0 +1,8 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABDCE2LLoV
+73yy1kzqzlRMRAAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIFdQAWVjarXDVETr
+Lk4DepHUkaDNVJl41IZuCUCDKLM9AAAAoJwWeclw1w1YC5uWBbb1JaMH2q9fa1YDSvg4Gs
+bNuZM8UEmh2pYkOBBPmL3mbTkcq2igF5IbJarhTzfebKCj9hMI3tXPyK9c6torPwA5uOiy
+NuQ1jUcAuAJ+wN9jzKwYpE54GaOAJiGEVippJREkF1X49iGwtB51zWJ9qFXotJmHOO55ap
+cjwWPtAwuqHIO9b2gfikiFlF4IJqKHFBz7m/Q=
+-----END OPENSSH PRIVATE KEY-----
diff --git a/inventories/desq_gaming/group_vars/linux/.ssh/svc_bf-ansible.pub b/inventories/desq_gaming/group_vars/linux/.ssh/svc_bf-ansible.pub
new file mode 100644
index 0000000..74ed2df
--- /dev/null
+++ b/inventories/desq_gaming/group_vars/linux/.ssh/svc_bf-ansible.pub
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFdQAWVjarXDVETrLk4DepHUkaDNVJl41IZuCUCDKLM9 svc_bf-ansible@desq_gaming
diff --git a/inventories/desq_gaming/group_vars/linux/main.yml b/inventories/desq_gaming/group_vars/linux/main.yml
index 90f94ab..90c473f 100644
--- a/inventories/desq_gaming/group_vars/linux/main.yml
+++ b/inventories/desq_gaming/group_vars/linux/main.yml
@@ -1,7 +1,7 @@
# Linux / SSH variables
ansible_connection: ssh
-ansible_user: svc_ansible
+ansible_user: svc_bf-ansible
ansible_private_key_file:
- /etc/ansible/inventories/desq_gaming/group_vars/linux/.ssh/svc_ansible
+ /etc/ansible/inventories/desq_gaming/group_vars/linux/.ssh/svc_bf-ansible
ansible_ssh_pass: '{{ ansible_vault_linux_ssh_pass }}'
ansible_become_method: sudo
diff --git a/inventories/desq_gaming/group_vars/windows/main.yml b/inventories/desq_gaming/group_vars/windows/main.yml
index a8bfa4b..67f18ff 100644
--- a/inventories/desq_gaming/group_vars/windows/main.yml
+++ b/inventories/desq_gaming/group_vars/windows/main.yml
@@ -3,5 +3,5 @@ ansible_connection: winrm
ansible_port: 5986
ansible_winrm_transport: ntlm
ansible_winrm_server_cert_validation: ignore
-ansible_user: svc_ansible
+ansible_user: svc_bf-ansible
ansible_password: '{{ vault_windows_ansible_password }}'
diff --git a/inventories/desq_gaming/host_vars/DESKTOP-ROBERT.desq-gaming.lan/main.yml b/inventories/desq_gaming/host_vars/DESKTOP-ROBERT.desq-gaming.lan/main.yml
new file mode 100644
index 0000000..4f8fcb1
--- /dev/null
+++ b/inventories/desq_gaming/host_vars/DESKTOP-ROBERT.desq-gaming.lan/main.yml
@@ -0,0 +1,3 @@
+# AIM-managed host-specific Windows local service account credentials.
+ansible_user: svc_bf-ansible
+ansible_password: '{{ vault_ansible_password_desktop_robert_desq_gaming_lan }}'
diff --git a/inventories/desq_gaming/hosts.aim-session.bak.yml b/inventories/desq_gaming/hosts.aim-session.bak.yml
index dc1aef2..3dd7983 100644
--- a/inventories/desq_gaming/hosts.aim-session.bak.yml
+++ b/inventories/desq_gaming/hosts.aim-session.bak.yml
@@ -1,4 +1,99 @@
all:
children:
desq_gaming:
- children: {}
+ children:
+ linux:
+ children:
+ networking:
+ hosts:
+ dewenpm01.desq-gaming.lan:
+ ansible_host: 192.168.20.3
+ dewedns02.desq-gaming.lan:
+ ansible_host: 192.168.20.2
+ dewesrv-unifi02.desq-gaming.lan:
+ ansible_host: 192.168.99.5
+
+ backup:
+ hosts:
+ dewepbs01.desq-gaming.lan:
+ ansible_host: 192.168.99.32
+
+ proxmox:
+ hosts:
+ dewepbs01.desq-gaming.lan:
+ ansible_host: 192.168.99.32
+ dewepve01.desq-gaming.lan:
+ ansible_host: 192.168.99.31
+
+ hosting:
+ hosts:
+ dewepve01.desq-gaming.lan:
+ ansible_host: 192.168.99.31
+
+ management:
+ hosts:
+ dewesrv-ansible01.desq-gaming.lan:
+ ansible_host: 192.168.20.46
+ dewesrv-patch01.desq-gaming.lan:
+ ansible_host: 192.168.20.45
+
+ applications:
+ hosts:
+ dewesrv-budget02.desq-gaming.lan:
+ ansible_host: 192.168.20.44
+ dewesrv-cache02.desq-gaming.lan:
+ ansible_host: 192.168.20.24
+ dewesrv-cloud01.desq-gaming.lan:
+ ansible_host: 192.168.20.14
+ dewesrv-crafty02.desq-gaming.lan:
+ ansible_host: 192.168.20.19
+ dewesrv-db01.desq-gaming.lan:
+ ansible_host: 192.168.20.21
+ dewesrv-db02.desq-gaming.lan:
+ ansible_host: 192.168.20.23
+ dewesrv-docker02.desq-gaming.lan:
+ ansible_host: 192.168.20.30
+ dewesrv-git01.desq-gaming.lan:
+ ansible_host: 192.168.20.38
+ dewesrv-grafana01.desq-gaming.lan:
+ ansible_host: 192.168.20.22
+ dewesrv-ha01.desq-gaming.lan:
+ ansible_host: 192.168.30.10
+ dewesrv-homarr01.desq-gaming.lan:
+ ansible_host: 192.168.20.35
+ dewesrv-mail01.desq-gaming.lan:
+ ansible_host: 192.168.20.40
+ dewesrv-nodejs01.desq-gaming.lan:
+ ansible_host: 192.168.20.20
+ dewesrv-omv01.desq-gaming.lan:
+ ansible_host: 192.168.20.15
+ dewesrv-overseerr01.desq-gaming.lan:
+ ansible_host: 192.168.20.18
+ dewesrv-plex02.desq-gaming.lan:
+ ansible_host: 192.168.20.39
+ dewesrv-puppet01.desq-gaming.lan:
+ ansible_host: 192.168.20.25
+ dewesrv-recipe01.desq-gaming.lan:
+ ansible_host: 192.168.20.37
+ dewesrv-rust02.desq-gaming.lan:
+ ansible_host: 192.168.20.27
+ dewesrv-speed01.desq-gaming.lan:
+ ansible_host: 192.168.20.16
+ dewesrv-steam01.desq-gaming.lan:
+ ansible_host: 192.168.20.12
+ dewesrv-support01.desq-gaming.lan:
+ ansible_host: 192.168.20.28
+ dewesrv-tautulli01.desq-gaming.lan:
+ ansible_host: 192.168.20.17
+ dewesrv-tv01.desq-gaming.lan:
+ ansible_host: 192.168.20.43
+ dewesrv-uptime01.desq-gaming.lan:
+ ansible_host: 192.168.20.11
+ dewesrv-vault01.desq-gaming.lan:
+ ansible_host: 192.168.20.34
+ dewesrv-wallos01.desq-gaming.lan:
+ ansible_host: 192.168.20.29
+ dewesrv-wazuh01.desq-gaming.lan:
+ ansible_host: 192.168.20.13
+ dewesrv-wiki01.desq-gaming.lan:
+ ansible_host: 192.168.20.36
\ No newline at end of file
diff --git a/inventories/desq_gaming/hosts.yml b/inventories/desq_gaming/hosts.yml
index 3dd7983..b4c7e91 100644
--- a/inventories/desq_gaming/hosts.yml
+++ b/inventories/desq_gaming/hosts.yml
@@ -29,7 +29,7 @@ all:
hosts:
dewepve01.desq-gaming.lan:
ansible_host: 192.168.99.31
-
+
management:
hosts:
dewesrv-ansible01.desq-gaming.lan:
@@ -96,4 +96,10 @@ all:
dewesrv-wazuh01.desq-gaming.lan:
ansible_host: 192.168.20.13
dewesrv-wiki01.desq-gaming.lan:
- ansible_host: 192.168.20.36
\ No newline at end of file
+ ansible_host: 192.168.20.36
+ windows:
+ children:
+ client:
+ hosts:
+ DESKTOP-ROBERT.desq-gaming.lan:
+ ansible_host: 192.168.10.11
diff --git a/playbooks/aim_catalog.yml b/playbooks/aim_catalog.yml
new file mode 100644
index 0000000..090d3a8
--- /dev/null
+++ b/playbooks/aim_catalog.yml
@@ -0,0 +1,1012 @@
+# AIM playbook catalog, not an executable playbook. No credentials belong in this file.
+# Only explicit run-time overrides are passed by AIM; inventory and role defaults remain authoritative.
+schema_version: 1
+release: 3.3.0rc8
+categories:
+- Checkmk
+- Debug
+- Maintenance
+- Sophos XGS
+- pfSense
+playbooks:
+- key: checkmk_install_agent
+ name: Install Checkmk agent
+ filename: checkmk_install_agent.yml
+ category: Checkmk
+ platforms:
+ - linux
+ - windows
+ description: Install staged agent packages, deploy selected checks, render
+ Windows settings and ensure the agent is running.
+ inputs:
+ - name: aim_debug
+ label: Safe diagnostics
+ type: bool
+ default_hint: 'false'
+ help: Only selected non-secret diagnostics; normal outcomes stay visible.
+ - name: checkmk_unifi_mode
+ label: UniFi application
+ type: choice
+ default_hint: auto
+ help: Automatic detection prefers UniFi OS when present; only one local
+ check/config is deployed.
+ platforms:
+ - linux
+ choices:
+ - auto
+ - network
+ - os
+ - disabled
+ - name: checkmk_unifi_username
+ label: UniFi monitoring username
+ type: text
+ default_hint: bf-monitoring
+ help: ''
+ platforms:
+ - linux
+ - name: checkmk_unifi_password
+ label: UniFi password variable
+ type: secret_ref
+ default_hint: vault_checkmk_unifi_password
+ help: Enter a Vault variable name, never its password. Required when a UniFi
+ check is selected.
+ platforms:
+ - linux
+ - name: checkmk_unifi_baseurl
+ label: UniFi controller URL
+ type: url
+ default_hint: 'network: https://127.0.0.1:8443; os: https://127.0.0.1:11443'
+ help: An explicit URL overrides the mode-specific default.
+ platforms:
+ - linux
+ - name: checkmk_unifi_curl_options
+ label: UniFi curl options
+ type: text
+ default_hint: ' --insecure --tlsv1.2'
+ help: Preserves the supplied TLS options. The shell configuration is safely
+ quoted.
+ platforms:
+ - linux
+ - name: want_linux_check_certificate
+ label: Certificate directory check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - linux
+ - name: want_windows_citrix
+ label: Citrix sessions check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: want_windows_surebackup
+ label: Veeam SureBackup check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: want_windows_backup
+ label: Windows Backup check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: want_windows_nsp_mailqueue
+ label: NSP mail queue check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: want_windows_certificate
+ label: Windows certificate check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: want_windows_veeam_cloud_connect
+ label: Veeam Cloud Connect check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: want_windows_veeam_backup
+ label: Repository Veeam backup plugin
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: checkmk_unifi_status_provisioning
+ label: 'UniFi status: provisioning'
+ type: int
+ default_hint: '1'
+ help: 0 OK, 1 WARN, 2 CRIT, 3 UNKNOWN.
+ platforms:
+ - linux
+ minimum: 0
+ maximum: 3
+ - name: checkmk_unifi_status_upgrading
+ label: 'UniFi status: upgrading'
+ type: int
+ default_hint: '1'
+ help: 0 OK, 1 WARN, 2 CRIT, 3 UNKNOWN.
+ platforms:
+ - linux
+ minimum: 0
+ maximum: 3
+ - name: checkmk_unifi_status_upgradable
+ label: 'UniFi status: upgradable'
+ type: int
+ default_hint: '0'
+ help: 0 OK, 1 WARN, 2 CRIT, 3 UNKNOWN.
+ platforms:
+ - linux
+ minimum: 0
+ maximum: 3
+ - name: checkmk_unifi_status_heartbeat_missed
+ label: 'UniFi status: heartbeat_missed'
+ type: int
+ default_hint: '1'
+ help: 0 OK, 1 WARN, 2 CRIT, 3 UNKNOWN.
+ platforms:
+ - linux
+ minimum: 0
+ maximum: 3
+ - name: checkmk_unifi_status_noautobackup
+ label: 'UniFi status: noautobackup'
+ type: int
+ default_hint: '0'
+ help: 0 OK, 1 WARN, 2 CRIT, 3 UNKNOWN.
+ platforms:
+ - linux
+ minimum: 0
+ maximum: 3
+ - name: checkmk_windows_updates_timeout
+ label: Windows Updates timeout
+ type: int
+ default_hint: '3600'
+ help: Seconds. Unchanged options continue to inherit inventory/defaults.
+ platforms:
+ - windows
+ minimum: 0
+ - name: checkmk_windows_updates_cache
+ label: Windows Updates cache
+ type: int
+ default_hint: '43200'
+ help: Seconds. Unchanged options continue to inherit inventory/defaults.
+ platforms:
+ - windows
+ minimum: 0
+ - name: checkmk_mk_inventory_timeout
+ label: Inventory plugin timeout
+ type: int
+ default_hint: '120'
+ help: Seconds. Unchanged options continue to inherit inventory/defaults.
+ platforms:
+ - windows
+ minimum: 0
+ - name: checkmk_plugins_default_timeout
+ label: Plugin default timeout
+ type: int
+ default_hint: '120'
+ help: Seconds. Unchanged options continue to inherit inventory/defaults.
+ platforms:
+ - windows
+ minimum: 0
+ - name: checkmk_plugins_default_cache
+ label: Plugin default cache
+ type: int
+ default_hint: '600'
+ help: Seconds. Unchanged options continue to inherit inventory/defaults.
+ platforms:
+ - windows
+ minimum: 0
+ - name: checkmk_extra_plugin_patterns
+ label: Extra Windows plugin rules
+ type: sequence
+ default_hint: '[]'
+ help: YAML/JSON list of rule mappings; see role documentation.
+ platforms:
+ - windows
+ become_platforms:
+ - linux
+ warning: Installs packages and replaces AIM-managed script files. On Windows,
+ AIM replaces only the marked plugins section in check_mk.user.yml; other
+ user-config sections are preserved. UniFi deployment also removes the
+ alternative UniFi local check; no other cleanup is performed.
+ requirements:
+ - ansible.windows
+ result:
+ protocol: aim_output_v1
+ schema: checkmk_agent_state_v1
+ scope: per_host
+ required: true
+ sensitivity: safe
+ max_bytes_per_host: 1048576
+ schema_file: checkmk_agent_state_v1.yml
+- key: checkmk_update_scripts_config
+ name: Update Checkmk scripts and configuration
+ filename: checkmk_update_scripts_config.yml
+ category: Checkmk
+ platforms:
+ - linux
+ - windows
+ description: Deploy selected checks and Windows configuration without
+ installing agent packages.
+ inputs:
+ - name: aim_debug
+ label: Safe diagnostics
+ type: bool
+ default_hint: 'false'
+ help: Only selected non-secret diagnostics; normal outcomes stay visible.
+ - name: checkmk_unifi_mode
+ label: UniFi application
+ type: choice
+ default_hint: auto
+ help: Automatic detection prefers UniFi OS when present; only one local
+ check/config is deployed.
+ platforms:
+ - linux
+ choices:
+ - auto
+ - network
+ - os
+ - disabled
+ - name: checkmk_unifi_username
+ label: UniFi monitoring username
+ type: text
+ default_hint: bf-monitoring
+ help: ''
+ platforms:
+ - linux
+ - name: checkmk_unifi_password
+ label: UniFi password variable
+ type: secret_ref
+ default_hint: vault_checkmk_unifi_password
+ help: Enter a Vault variable name, never its password. Required when a UniFi
+ check is selected.
+ platforms:
+ - linux
+ - name: checkmk_unifi_baseurl
+ label: UniFi controller URL
+ type: url
+ default_hint: 'network: https://127.0.0.1:8443; os: https://127.0.0.1:11443'
+ help: An explicit URL overrides the mode-specific default.
+ platforms:
+ - linux
+ - name: checkmk_unifi_curl_options
+ label: UniFi curl options
+ type: text
+ default_hint: ' --insecure --tlsv1.2'
+ help: Preserves the supplied TLS options. The shell configuration is safely
+ quoted.
+ platforms:
+ - linux
+ - name: want_linux_check_certificate
+ label: Certificate directory check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - linux
+ - name: want_windows_citrix
+ label: Citrix sessions check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: want_windows_surebackup
+ label: Veeam SureBackup check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: want_windows_backup
+ label: Windows Backup check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: want_windows_nsp_mailqueue
+ label: NSP mail queue check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: want_windows_certificate
+ label: Windows certificate check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: want_windows_veeam_cloud_connect
+ label: Veeam Cloud Connect check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: want_windows_veeam_backup
+ label: Repository Veeam backup plugin
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: checkmk_unifi_status_provisioning
+ label: 'UniFi status: provisioning'
+ type: int
+ default_hint: '1'
+ help: 0 OK, 1 WARN, 2 CRIT, 3 UNKNOWN.
+ platforms:
+ - linux
+ minimum: 0
+ maximum: 3
+ - name: checkmk_unifi_status_upgrading
+ label: 'UniFi status: upgrading'
+ type: int
+ default_hint: '1'
+ help: 0 OK, 1 WARN, 2 CRIT, 3 UNKNOWN.
+ platforms:
+ - linux
+ minimum: 0
+ maximum: 3
+ - name: checkmk_unifi_status_upgradable
+ label: 'UniFi status: upgradable'
+ type: int
+ default_hint: '0'
+ help: 0 OK, 1 WARN, 2 CRIT, 3 UNKNOWN.
+ platforms:
+ - linux
+ minimum: 0
+ maximum: 3
+ - name: checkmk_unifi_status_heartbeat_missed
+ label: 'UniFi status: heartbeat_missed'
+ type: int
+ default_hint: '1'
+ help: 0 OK, 1 WARN, 2 CRIT, 3 UNKNOWN.
+ platforms:
+ - linux
+ minimum: 0
+ maximum: 3
+ - name: checkmk_unifi_status_noautobackup
+ label: 'UniFi status: noautobackup'
+ type: int
+ default_hint: '0'
+ help: 0 OK, 1 WARN, 2 CRIT, 3 UNKNOWN.
+ platforms:
+ - linux
+ minimum: 0
+ maximum: 3
+ - name: checkmk_windows_updates_timeout
+ label: Windows Updates timeout
+ type: int
+ default_hint: '3600'
+ help: Seconds. Unchanged options continue to inherit inventory/defaults.
+ platforms:
+ - windows
+ minimum: 0
+ - name: checkmk_windows_updates_cache
+ label: Windows Updates cache
+ type: int
+ default_hint: '43200'
+ help: Seconds. Unchanged options continue to inherit inventory/defaults.
+ platforms:
+ - windows
+ minimum: 0
+ - name: checkmk_mk_inventory_timeout
+ label: Inventory plugin timeout
+ type: int
+ default_hint: '120'
+ help: Seconds. Unchanged options continue to inherit inventory/defaults.
+ platforms:
+ - windows
+ minimum: 0
+ - name: checkmk_plugins_default_timeout
+ label: Plugin default timeout
+ type: int
+ default_hint: '120'
+ help: Seconds. Unchanged options continue to inherit inventory/defaults.
+ platforms:
+ - windows
+ minimum: 0
+ - name: checkmk_plugins_default_cache
+ label: Plugin default cache
+ type: int
+ default_hint: '600'
+ help: Seconds. Unchanged options continue to inherit inventory/defaults.
+ platforms:
+ - windows
+ minimum: 0
+ - name: checkmk_extra_plugin_patterns
+ label: Extra Windows plugin rules
+ type: sequence
+ default_hint: '[]'
+ help: YAML/JSON list of rule mappings; see role documentation.
+ platforms:
+ - windows
+ become_platforms:
+ - linux
+ warning: Deploys AIM-managed script files and, on Windows, replaces only the
+ marked plugins section in check_mk.user.yml. Other user-config sections are
+ preserved. Only the opposite UniFi check is removed during a UniFi mode
+ transition.
+ requirements:
+ - ansible.windows
+ result:
+ protocol: aim_output_v1
+ schema: checkmk_agent_config_v1
+ scope: per_host
+ required: true
+ sensitivity: safe
+ max_bytes_per_host: 1048576
+ schema_file: checkmk_agent_config_v1.yml
+- key: checkmk_read_windows_config
+ name: Read Windows Checkmk config
+ filename: checkmk_read_windows_config.yml
+ category: Checkmk
+ platforms:
+ - windows
+ description: Display the current Windows check_mk.user.yml, including its path
+ and file metadata, without modifying the host.
+ inputs:
+ - name: aim_debug
+ label: Safe diagnostics
+ type: bool
+ default_hint: 'false'
+ help: Only selected non-secret diagnostics; normal outcomes stay visible.
+ - name: checkmk_windows_user_cfg
+ label: Checkmk user config path
+ type: text
+ default_hint: C:\ProgramData\checkmk\agent\check_mk.user.yml
+ help: Override only when the Windows agent uses a nonstandard
+ user-configuration path.
+ platforms:
+ - windows
+ requirements:
+ - ansible.windows
+ result:
+ protocol: aim_output_v1
+ schema: checkmk_user_config_v1
+ scope: per_host
+ required: true
+ sensitivity: safe
+ max_bytes_per_host: 1048576
+ schema_file: checkmk_user_config_v1.yml
+- key: checkmk_cleanup_scripts
+ name: Preview / clean up Checkmk scripts
+ filename: checkmk_cleanup_scripts.yml
+ category: Checkmk
+ platforms:
+ - linux
+ - windows
+ description: List obsolete managed script paths; remove them only with
+ explicit deletion approval.
+ inputs:
+ - name: aim_debug
+ label: Safe diagnostics
+ type: bool
+ default_hint: 'false'
+ help: Only selected non-secret diagnostics; normal outcomes stay visible.
+ - name: checkmk_cleanup_enabled
+ label: Permit managed-script deletion
+ type: bool
+ default_hint: 'false'
+ help: Required for cleanup execution. False previews candidate paths without
+ deleting them.
+ - name: checkmk_unifi_mode
+ label: UniFi application
+ type: choice
+ default_hint: auto
+ help: Automatic detection prefers UniFi OS when present; only one local
+ check/config is deployed.
+ platforms:
+ - linux
+ choices:
+ - auto
+ - network
+ - os
+ - disabled
+ - name: want_linux_check_certificate
+ label: Certificate directory check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - linux
+ - name: want_windows_citrix
+ label: Citrix sessions check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: want_windows_surebackup
+ label: Veeam SureBackup check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: want_windows_backup
+ label: Windows Backup check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: want_windows_nsp_mailqueue
+ label: NSP mail queue check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: want_windows_certificate
+ label: Windows certificate check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: want_windows_veeam_cloud_connect
+ label: Veeam Cloud Connect check
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ - name: want_windows_veeam_backup
+ label: Repository Veeam backup plugin
+ type: bool
+ default_hint: 'false'
+ help: Optional check. Script-specific setup remains in the maintained script
+ repository.
+ platforms:
+ - windows
+ become_platforms:
+ - linux
+ warning: Cleanup only touches the documented managed filenames. Preview is the
+ default; enabling deletion requires another confirmation.
+ requirements:
+ - ansible.windows
+ result:
+ protocol: aim_output_v1
+ schema: managed_cleanup_preview_v1
+ scope: per_host
+ required: true
+ sensitivity: safe
+ max_bytes_per_host: 1048576
+ schema_file: managed_cleanup_preview_v1.yml
+- key: debug_test_connection
+ name: Test Ansible connection
+ filename: debug_test_connection.yml
+ category: Debug
+ platforms:
+ - linux
+ - windows
+ description: Check Ansible manageability using ping or win_ping; this is not
+ an ICMP ping.
+ inputs:
+ - name: aim_debug
+ label: Safe diagnostics
+ type: bool
+ default_hint: 'false'
+ help: Only selected non-secret diagnostics; normal outcomes stay visible.
+ requirements:
+ - ansible.windows
+- key: debug_show_disk_usage
+ name: Show disk usage
+ filename: debug_show_disk_usage.yml
+ category: Debug
+ platforms:
+ - linux
+ - windows
+ description: Report attached Windows storage volumes and common operational Linux mounts, including network/storage filesystems.
+ inputs:
+ - name: aim_debug
+ label: Safe diagnostics
+ type: bool
+ default_hint: 'false'
+ help: Only selected non-secret diagnostics; normal outcomes stay visible.
+ requirements:
+ - ansible.windows
+ - community.windows
+ result:
+ protocol: aim_output_v1
+ schema: filesystem_usage_v1
+ scope: per_host
+ required: true
+ sensitivity: safe
+ max_bytes_per_host: 1048576
+ schema_file: filesystem_usage_v1.yml
+- key: debug_detect_host_roles
+ name: Detect host roles
+ filename: debug_detect_host_roles.yml
+ category: Debug
+ platforms:
+ - linux
+ - windows
+ description: Report detected AD, DHCP, Hyper-V, Veeam and UniFi capabilities
+ without changing inventory memberships.
+ inputs:
+ - name: aim_debug
+ label: Safe diagnostics
+ type: bool
+ default_hint: 'false'
+ help: Only selected non-secret diagnostics; normal outcomes stay visible.
+ requirements:
+ - ansible.windows
+ result:
+ protocol: aim_output_v1
+ schema: host_capabilities_v1
+ scope: per_host
+ required: true
+ sensitivity: safe
+ max_bytes_per_host: 1048576
+ schema_file: host_capabilities_v1.yml
+- key: maintenance_export_event_logs
+ name: Export Windows event logs
+ filename: maintenance_export_event_logs.yml
+ category: Maintenance
+ platforms:
+ - windows
+ description: Export selected event channels to EVTX files on the target;
+ existing event logs are not cleared.
+ inputs:
+ - name: aim_debug
+ label: Safe diagnostics
+ type: bool
+ default_hint: 'false'
+ help: Only selected non-secret diagnostics; normal outcomes stay visible.
+ - name: event_age_days
+ label: Event age in days
+ type: int
+ default_hint: '45'
+ help: Export events from the last N days, without clearing the logs.
+ minimum: 1
+ maximum: 36500
+ - name: export_folder
+ label: Target export folder
+ type: text
+ default_hint: C:\Logs
+ help: Directory on each Windows target, not on the Ansible controller.
+ - name: event_log_channels
+ label: Event channels
+ type: list
+ default_hint: Application, Security, System, Setup
+ help: Event channels to export.
+ requirements:
+ - ansible.windows
+ result:
+ protocol: aim_output_v1
+ schema: event_log_export_v1
+ scope: per_host
+ required: true
+ sensitivity: safe
+ max_bytes_per_host: 1048576
+ schema_file: event_log_export_v1.yml
+- key: maintenance_start_stopped_services
+ name: Start stopped automatic services
+ filename: maintenance_start_stopped_services.yml
+ category: Maintenance
+ platforms:
+ - windows
+ description: Start eligible stopped services, apply optional include/exclude
+ lists and report partial failures.
+ inputs:
+ - name: aim_debug
+ label: Safe diagnostics
+ type: bool
+ default_hint: 'false'
+ help: Only selected non-secret diagnostics; normal outcomes stay visible.
+ - name: maintenance_service_include
+ label: Service allowlist
+ type: list
+ default_hint: '[]'
+ help: Empty list selects all stopped automatic/delayed-start services; use
+ internal service names.
+ - name: maintenance_service_exclude
+ label: Service exclusions
+ type: list
+ default_hint: '[]'
+ help: Excluded internal service names are never started.
+ - name: maintenance_service_fail_on_error
+ label: Fail after partial failure
+ type: bool
+ default_hint: 'true'
+ help: Always reports individual failures; true makes the final task fail
+ when any start failed.
+ requirements:
+ - ansible.windows
+ result:
+ protocol: aim_output_v1
+ schema: service_start_summary_v1
+ scope: per_host
+ required: true
+ sensitivity: safe
+ max_bytes_per_host: 1048576
+ schema_file: service_start_summary_v1.yml
+- key: maintenance_patch_os
+ name: Patch operating systems
+ filename: maintenance_patch_os.yml
+ category: Maintenance
+ platforms:
+ - linux
+ - windows
+ description: Apply updates on Windows, Debian and RedHat-family systems;
+ optionally notify users and reboot when required.
+ inputs:
+ - name: aim_debug
+ label: Safe diagnostics
+ type: bool
+ default_hint: 'false'
+ help: Only selected non-secret diagnostics; normal outcomes stay visible.
+ - name: os_patching_reboot
+ label: Reboot when required
+ type: bool
+ default_hint: 'true'
+ help: Patching may reboot each selected host. False installs without an
+ automatic reboot.
+ - name: os_patching_windows_categories
+ label: Windows update categories
+ type: list
+ default_hint: SecurityUpdates, CriticalUpdates, UpdateRollups,
+ DefinitionUpdates, Updates
+ help: Enter a YAML/JSON list or comma-separated category names.
+ platforms:
+ - windows
+ choices:
+ - SecurityUpdates
+ - CriticalUpdates
+ - UpdateRollups
+ - DefinitionUpdates
+ - Updates
+ - Drivers
+ - FeaturePacks
+ - ServicePacks
+ - Tools
+ - Upgrades
+ - '*'
+ - name: os_patching_serial
+ label: Batch size
+ type: serial
+ default_hint: 100%
+ help: Positive host count or percentage. Applies independently to each
+ platform play.
+ - name: os_patching_reboot_timeout
+ label: Reboot timeout
+ type: int
+ default_hint: '600'
+ help: Seconds to wait for a Windows/Linux host to reboot and become
+ manageable again.
+ minimum: 1
+ - name: os_patching_reboot_delay_minutes
+ label: Reboot delay (minutes)
+ type: int
+ default_hint: '0'
+ help: Delay before an AIM-initiated reboot. Linux scheduling is
+ minute-granular; 0 requests immediate/platform-minimum reboot.
+ minimum: 0
+ maximum: 1440
+ - name: os_patching_reboot_message
+ label: Reboot message
+ type: text
+ default_hint: 'AIM maintenance: operating system patching requires a reboot.'
+ help: Message shown to logged-in users before an AIM-initiated Windows/Linux
+ reboot.
+ - name: os_patching_rescan_after_reboot
+ label: Continue patching after reboot
+ type: bool
+ default_hint: 'false'
+ help: Windows only. False stops after the first patch-triggered reboot so the next
+ patch wave requires a new operator-approved run. True rediscovers applicable
+ updates after reboot and starts another native Windows Update wave.
+ platforms:
+ - windows
+ become_platforms:
+ - linux
+ warning: Updates production operating systems. Windows uses the native win_updates wave behavior with AIM-controlled reboots. A reboot boundary stops the run by default; continuing into a newly discovered post-reboot wave requires explicit opt-in. If automatic reboot is disabled, a newly required reboot is reported as deferred.
+ requirements:
+ - ansible.windows
+ result:
+ protocol: aim_output_v1
+ schema: patch_summary_v1
+ scope: per_host
+ required: true
+ sensitivity: safe
+ max_bytes_per_host: 1048576
+ schema_file: patch_summary_v1.yml
+- key: maintenance_reboot_hosts
+ name: Reboot hosts
+ filename: maintenance_reboot_hosts.yml
+ category: Maintenance
+ platforms:
+ - linux
+ - windows
+ description: Reboot selected hosts in batches and wait for management
+ connectivity.
+ inputs:
+ - name: aim_debug
+ label: Safe diagnostics
+ type: bool
+ default_hint: 'false'
+ help: Only selected non-secret diagnostics; normal outcomes stay visible.
+ - name: maintenance_reboot_serial
+ label: Batch size
+ type: serial
+ default_hint: '10'
+ help: Positive host count or percentage.
+ - name: maintenance_reboot_timeout
+ label: Reboot timeout
+ type: int
+ default_hint: '1800'
+ help: Seconds.
+ minimum: 1
+ - name: maintenance_reboot_message
+ label: Reboot message
+ type: text
+ default_hint: Reboot initiated by debsansible01.bfmiglabor.lan (Ansible)
+ help: ''
+ - name: maintenance_reboot_pre_delay
+ label: Delay before reboot
+ type: int
+ default_hint: '0'
+ help: Seconds; Windows enforces a minimum of two seconds.
+ minimum: 0
+ - name: maintenance_reboot_post_delay
+ label: Delay after reboot
+ type: int
+ default_hint: '15'
+ help: Seconds.
+ minimum: 0
+ become_platforms:
+ - linux
+ warning: Every selected host will be rebooted. No reboot occurs before final
+ confirmation.
+ requirements:
+ - ansible.windows
+- key: sophos_apply_baseline
+ name: Apply bitformer Sophos baseline
+ filename: sophos_apply_baseline.yml
+ category: Sophos XGS
+ platforms:
+ - sophosxgs
+ description: Apply the supplied bitformer firewall baseline. Existing policy
+ values and action order are preserved.
+ inputs:
+ - name: aim_debug
+ label: Safe diagnostics
+ type: bool
+ default_hint: 'false'
+ help: Only selected non-secret diagnostics; normal outcomes stay visible.
+ ask_pass: true
+ require_vault: true
+ warning: Changes firewall management access, objects and rules, including rule
+ removal and a final drop rule. Policy values have NOT been redesigned.
+ requirements:
+ - ansible.netcommon
+ - sophos.sophos_firewall
+- key: sophos_apply_customer
+ name: Apply customer Sophos configuration
+ filename: sophos_apply_customer.yml
+ category: Sophos XGS
+ platforms:
+ - sophosxgs
+ description: Apply this customer profile using hostname, network_objects and
+ vlan_interfaces from inventory.
+ inputs:
+ - name: aim_debug
+ label: Safe diagnostics
+ type: bool
+ default_hint: 'false'
+ help: Only selected non-secret diagnostics; normal outcomes stay visible.
+ ask_pass: true
+ require_vault: true
+ customer_specific: true
+ warning: Changes customer firewall configuration. VLAN parent remains Port1 as
+ in the supplied playbooks. Only this customer profile is selected.
+ requirements:
+ - ansible.netcommon
+ - sophos.sophos_firewall
+- key: pfsense_apply_baseline
+ name: Apply bitformer pfSense baseline
+ filename: pfsense_apply_baseline.yml
+ category: pfSense
+ platforms:
+ - pfsense
+ description: Apply the supplied pfSense baseline without changing its
+ firewall/VPN policy.
+ inputs:
+ - name: aim_debug
+ label: Safe diagnostics
+ type: bool
+ default_hint: 'false'
+ help: Only selected non-secret diagnostics; normal outcomes stay visible.
+ become_platforms:
+ - pfsense
+ warning: Contains the original any-source WAN management rule for ports
+ 22/80/443. The original CA, VPN endpoint and client certificate reference
+ are unchanged; verify them before execution. Requires separately approved
+ pfsensible.core installation.
+ requirements:
+ - pfsensible.core
+sophos_profiles:
+ bluuunit:
+ required_network_keys:
+ - derz_lan
+ - derz_sslvpn
+ - facility
+ - guest
+ - lan_old
+ - management
+ - office
+ - server
+ - voip
+ vlan_parent: Port1
+ formicon:
+ required_network_keys:
+ - azuregwc_lan
+ - lan_old
+ - management
+ - office
+ vlan_parent: Port1
+ gebhardt_stahl:
+ required_network_keys:
+ - drucker
+ - guest
+ - office
+ - wlan
+ vlan_parent: Port1
+ hungeling_und_toechter:
+ required_network_keys:
+ - facility
+ - guest
+ - management
+ - office
+ - voip
+ vlan_parent: Port1
+ koenig_holding_gmbh:
+ required_network_keys:
+ - facility
+ - guest
+ - management
+ - office
+ - server
+ - voip
+ vlan_parent: Port1
diff --git a/playbooks/checkmk_cleanup.yml b/playbooks/checkmk_cleanup.yml
deleted file mode 100644
index 5a322e3..0000000
--- a/playbooks/checkmk_cleanup.yml
+++ /dev/null
@@ -1,64 +0,0 @@
----
-- name: "Checkmk | Cleanup agent"
- hosts: all
- gather_facts: true
-
- tasks:
- - name: "Windows | Remove installed Checkmk agent"
- when: ansible_facts['os_family'] == 'Windows'
- ansible.windows.win_powershell:
- script: |
- $Ansible.Changed = $false
- $uninstallRoots = @(
- 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
- 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
- )
-
- $products = foreach ($root in $uninstallRoots) {
- if (Test-Path -LiteralPath $root) {
- Get-ChildItem -LiteralPath $root -ErrorAction SilentlyContinue |
- ForEach-Object {
- $product = Get-ItemProperty -LiteralPath $_.PSPath -ErrorAction SilentlyContinue
- if ($product.DisplayName -like 'Check MK Agent*' -or
- $product.DisplayName -like 'Checkmk Agent*') {
- [PSCustomObject]@{
- ProductCode = $_.PSChildName
- DisplayName = $product.DisplayName
- }
- }
- }
- }
- }
-
- foreach ($product in $products) {
- if ($product.ProductCode -match '^\{[0-9A-Fa-f-]+\}$') {
- $process = Start-Process -FilePath 'msiexec.exe' `
- -ArgumentList "/x $($product.ProductCode) /qn /norestart" `
- -Wait -PassThru
-
- if ($process.ExitCode -notin @(0, 1605, 1614, 3010)) {
- throw "Failed to uninstall $($product.DisplayName). MSI exit code: $($process.ExitCode)"
- }
-
- if ($process.ExitCode -in @(0, 3010)) {
- $Ansible.Changed = $true
- }
- }
- }
-
- $Ansible.Result = @{
- removed_products = @($products.DisplayName)
- }
-
- - name: "Debian | Remove Checkmk agent"
- when: ansible_facts['os_family'] == 'Debian'
- ansible.builtin.apt:
- name: check-mk-agent
- state: absent
- purge: true
-
- - name: "RedHat | Remove Checkmk agent"
- when: ansible_facts['os_family'] == 'RedHat'
- ansible.builtin.dnf:
- name: check-mk-agent
- state: absent
diff --git a/playbooks/checkmk_cleanup_scripts.yml b/playbooks/checkmk_cleanup_scripts.yml
new file mode 100644
index 0000000..e81b62f
--- /dev/null
+++ b/playbooks/checkmk_cleanup_scripts.yml
@@ -0,0 +1,75 @@
+---
+# PURPOSE: Preview / clean up Checkmk scripts
+# DESCRIPTION: List obsolete managed script paths; remove them only with explicit deletion approval.
+# TARGETS: linux, windows
+# INPUTS (omitted values inherit inventory / role defaults):
+# aim_debug [bool]: false
+# checkmk_cleanup_enabled [bool]: false
+# checkmk_unifi_mode [choice]: auto
+# want_linux_check_certificate [bool]: false
+# want_windows_citrix [bool]: false
+# want_windows_surebackup [bool]: false
+# want_windows_backup [bool]: false
+# want_windows_nsp_mailqueue [bool]: false
+# want_windows_certificate [bool]: false
+# want_windows_veeam_cloud_connect [bool]: false
+# want_windows_veeam_backup [bool]: false
+# AUTH: existing inventory / Vault credentials; no embedded passwords.
+# CHANGES: Cleanup only touches the documented managed filenames. Preview is the default; enabling deletion requires another confirmation.
+# EXAMPLE: ansible-playbook -i inventories//hosts.yml
+# playbooks/checkmk_cleanup_scripts.yml --limit --vault-id @prompt
+- name: Checkmk | Preview or clean up linux
+ hosts: linux
+ gather_facts: true
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
+ 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ become: true
+ roles:
+ - role: system_detect_roles
+ - role: checkmk_script_plan
+ - role: checkmk_cleanup_scripts
+- name: Checkmk | Preview or clean up windows
+ hosts: windows
+ gather_facts: true
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
+ 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ roles:
+ - role: system_detect_roles
+ - role: checkmk_script_plan
+ - role: checkmk_cleanup_scripts
diff --git a/playbooks/checkmk_deploy.yml b/playbooks/checkmk_deploy.yml
deleted file mode 100644
index 2224998..0000000
--- a/playbooks/checkmk_deploy.yml
+++ /dev/null
@@ -1,10 +0,0 @@
----
-- name: "Checkmk | Deploy agent, scripts and configuration"
- hosts: all
- gather_facts: true
-
- roles:
- - checkmk_agent
- - server_role_selection
- - checkmk_scripts
- - checkmk_agent_config
diff --git a/playbooks/checkmk_install_agent.yml b/playbooks/checkmk_install_agent.yml
new file mode 100644
index 0000000..4836aa3
--- /dev/null
+++ b/playbooks/checkmk_install_agent.yml
@@ -0,0 +1,136 @@
+# PURPOSE: Install Checkmk agent
+# DESCRIPTION: Install staged agent packages, deploy selected checks, render Windows settings and ensure the agent is running.
+# TARGETS: linux, windows
+# INPUTS (omitted values inherit inventory / role defaults):
+# aim_debug [bool]: false
+# checkmk_unifi_mode [choice]: auto
+# checkmk_unifi_username [text]: bf-monitoring
+# checkmk_unifi_password [secret_ref]: vault_checkmk_unifi_password
+# checkmk_unifi_baseurl [url]: network: https://127.0.0.1:8443; os: https://127.0.0.1:11443
+# checkmk_unifi_curl_options [text]: --insecure --tlsv1.2
+# want_linux_check_certificate [bool]: false
+# want_windows_citrix [bool]: false
+# want_windows_surebackup [bool]: false
+# want_windows_backup [bool]: false
+# want_windows_nsp_mailqueue [bool]: false
+# want_windows_certificate [bool]: false
+# want_windows_veeam_cloud_connect [bool]: false
+# want_windows_veeam_backup [bool]: false
+# checkmk_unifi_status_provisioning [int]: 1
+# checkmk_unifi_status_upgrading [int]: 1
+# checkmk_unifi_status_upgradable [int]: 0
+# checkmk_unifi_status_heartbeat_missed [int]: 1
+# checkmk_unifi_status_noautobackup [int]: 0
+# checkmk_windows_updates_timeout [int]: 3600
+# checkmk_windows_updates_cache [int]: 43200
+# checkmk_mk_inventory_timeout [int]: 120
+# checkmk_plugins_default_timeout [int]: 120
+# checkmk_plugins_default_cache [int]: 600
+# checkmk_extra_plugin_patterns [sequence]: []
+# AUTH: existing inventory / Vault credentials; no embedded passwords.
+# CHANGES: Installs packages and AIM-managed script files. On Windows only the marked plugins section in check_mk.user.yml is replaced; all other user-config sections are preserved. UniFi deployment also removes the alternative UniFi local check; no other cleanup is performed.
+# EXAMPLE: ansible-playbook -i inventories//hosts.yml
+# playbooks/checkmk_install_agent.yml --limit --vault-id @prompt
+ - name: Checkmk | Install linux
+ hosts: linux
+ gather_facts: true
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in
+ ['true', 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ - name: Load system detect roles
+ ansible.builtin.include_role:
+ name: system_detect_roles
+ - name: Load checkmk script plan
+ ansible.builtin.include_role:
+ name: checkmk_script_plan
+ - name: Checkmk | Preflight scripts and credentials
+ ansible.builtin.include_role:
+ name: checkmk_deploy_scripts
+ tasks_from: preflight
+ become: true
+ roles:
+ - role: checkmk_agent
+ - role: checkmk_deploy_scripts
+ - role: checkmk_configure_agent
+ - role: checkmk_manage_service
+ post_tasks:
+ - name: Checkmk | Observe installed agent
+ ansible.builtin.include_role:
+ name: checkmk_report
+ - name: AIM | Publish operation result
+ ansible.builtin.set_stats:
+ per_host: true
+ aggregate: false
+ data:
+ aim_output:
+ protocol: aim_output_v1
+ schema: checkmk_agent_state_v1
+ data: '{{ _aim_checkmk_state }}'
+ - name: Checkmk | Install windows
+ hosts: windows
+ gather_facts: true
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in
+ ['true', 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ - name: Load system detect roles
+ ansible.builtin.include_role:
+ name: system_detect_roles
+ - name: Load checkmk script plan
+ ansible.builtin.include_role:
+ name: checkmk_script_plan
+ - name: Checkmk | Preflight scripts and credentials
+ ansible.builtin.include_role:
+ name: checkmk_deploy_scripts
+ tasks_from: preflight
+ roles:
+ - role: checkmk_agent
+ - role: checkmk_deploy_scripts
+ - role: checkmk_configure_agent
+ - role: checkmk_manage_service
+ post_tasks:
+ - name: Checkmk | Observe installed agent
+ ansible.builtin.include_role:
+ name: checkmk_report
+ - name: AIM | Publish operation result
+ ansible.builtin.set_stats:
+ per_host: true
+ aggregate: false
+ data:
+ aim_output:
+ protocol: aim_output_v1
+ schema: checkmk_agent_state_v1
+ data: '{{ _aim_checkmk_state }}'
diff --git a/playbooks/checkmk_read_windows_config.yml b/playbooks/checkmk_read_windows_config.yml
new file mode 100644
index 0000000..dfbe8d0
--- /dev/null
+++ b/playbooks/checkmk_read_windows_config.yml
@@ -0,0 +1,115 @@
+# PURPOSE: Read current Windows Checkmk user configuration
+# DESCRIPTION: Display the current check_mk.user.yml from Windows without modifying the host.
+# TARGETS: windows
+# INPUTS (omitted values inherit inventory / playbook defaults):
+# aim_debug [bool]: false
+# checkmk_windows_user_cfg [text]: C:\ProgramData\checkmk\agent\check_mk.user.yml
+# AUTH: existing inventory / Vault credentials; no embedded passwords.
+# CHANGES: none; this playbook is read-only.
+# EXAMPLE: ansible-playbook -i inventories//hosts.yml
+# playbooks/checkmk_read_windows_config.yml --limit --vault-id @prompt
+
+ - name: Checkmk | Read Windows user configuration
+ hosts: windows
+ gather_facts: false
+ tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in
+ ['true', 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+
+ - name: AIM | Validate Checkmk configuration path
+ ansible.builtin.assert:
+ that:
+ - (checkmk_windows_user_cfg | default('C:\\ProgramData\\checkmk\\agent\\check_mk.user.yml')) is
+ string
+ - (checkmk_windows_user_cfg | default('C:\\ProgramData\\checkmk\\agent\\check_mk.user.yml') | trim
+ | length) > 0
+ fail_msg: checkmk_windows_user_cfg must be a non-empty Windows path.
+ quiet: true
+
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ configuration: "{{ checkmk_windows_user_cfg | default('C:\\\\ProgramData\\\\checkmk\\\\agent\\\\check_mk.user.yml')
+ }}"
+ mode: Read-only; no Checkmk configuration is modified.
+ when: aim_debug | default(false) | bool
+
+ - name: Windows | Inspect current Checkmk user configuration
+ ansible.windows.win_stat:
+ path: "{{ checkmk_windows_user_cfg | default('C:\\ProgramData\\checkmk\\agent\\check_mk.user.yml') }}"
+ get_checksum: false
+ register: _checkmk_windows_user_config_stat
+ changed_when: false
+
+ - name: Windows | Require the approved Checkmk user filename
+ ansible.builtin.assert:
+ that:
+ - not (_checkmk_windows_user_config_stat.stat.exists | default(false)) or (_checkmk_windows_user_config_stat.stat.filename | lower) == 'check_mk.user.yml'
+ - not (_checkmk_windows_user_config_stat.stat.exists | default(false)) or (_checkmk_windows_user_config_stat.stat.isreg | default(false))
+ - (_checkmk_windows_user_config_stat.stat.size | default(0) | int) <= 524288
+ fail_msg: Only a regular check_mk.user.yml file up to 512 KiB may be read.
+ quiet: true
+
+ - name: Windows | Read current Checkmk user configuration
+ ansible.windows.slurp:
+ src: "{{ checkmk_windows_user_cfg | default('C:\\ProgramData\\checkmk\\agent\\check_mk.user.yml') }}"
+ register: _checkmk_windows_user_config_slurp
+ when: _checkmk_windows_user_config_stat.stat.exists | default(false)
+ no_log: true
+
+ - name: Windows | Build Checkmk user configuration result
+ ansible.builtin.set_fact:
+ _checkmk_windows_user_config:
+ exists: '{{ _checkmk_windows_user_config_stat.stat.exists | default(false) | bool }}'
+ path: "{{ _checkmk_windows_user_config_stat.stat.path | default(checkmk_windows_user_cfg | default('C:\\ProgramData\\checkmk\\agent\\check_mk.user.yml')) }}"
+ size_bytes: '{{ _checkmk_windows_user_config_stat.stat.size | default(0) | int }}'
+ last_write_time_utc: >-
+ {{ (_checkmk_windows_user_config_stat.stat.lastwritetime | aim_epoch_iso_utc)
+ if (_checkmk_windows_user_config_stat.stat.exists | default(false)) else none }}
+ content: >-
+ {{ (_checkmk_windows_user_config_slurp.content | b64decode)
+ if (_checkmk_windows_user_config_stat.stat.exists | default(false)) else '' }}
+ changed_when: false
+ no_log: true
+
+ - name: Windows | Report missing Checkmk user configuration
+ ansible.builtin.debug:
+ msg: |-
+ {{ inventory_hostname }}
+ Checkmk user configuration was not found.
+ Path: {{ _checkmk_windows_user_config.path }}
+ when: not (_checkmk_windows_user_config.exists | bool)
+
+ - name: Windows | Print current Checkmk user configuration
+ ansible.builtin.debug:
+ msg: |-
+ {{ inventory_hostname }}
+ Path: {{ _checkmk_windows_user_config.path }}
+ Size: {{ _checkmk_windows_user_config.size_bytes }} bytes
+ Last write (UTC): {{ _checkmk_windows_user_config.last_write_time_utc }}
+ ----- BEGIN check_mk.user.yml -----
+ {{ _checkmk_windows_user_config.content }}
+ ----- END check_mk.user.yml -----
+ when: _checkmk_windows_user_config.exists | bool
+ - name: AIM | Publish operation result
+ ansible.builtin.set_stats:
+ per_host: true
+ aggregate: false
+ data:
+ aim_output:
+ protocol: aim_output_v1
+ schema: checkmk_user_config_v1
+ data: '{{ _checkmk_windows_user_config | aim_report_checkmk_config }}'
diff --git a/playbooks/checkmk_update_config.yml b/playbooks/checkmk_update_config.yml
deleted file mode 100644
index 5efbb02..0000000
--- a/playbooks/checkmk_update_config.yml
+++ /dev/null
@@ -1,8 +0,0 @@
----
-- name: "Checkmk | Update agent configuration"
- hosts: all
- gather_facts: true
-
- roles:
- - server_role_selection
- - checkmk_agent_config
diff --git a/playbooks/checkmk_update_scripts.yml b/playbooks/checkmk_update_scripts.yml
deleted file mode 100644
index 0992f22..0000000
--- a/playbooks/checkmk_update_scripts.yml
+++ /dev/null
@@ -1,8 +0,0 @@
----
-- name: "Checkmk | Update monitoring scripts"
- hosts: all
- gather_facts: true
-
- roles:
- - server_role_selection
- - checkmk_scripts
diff --git a/playbooks/checkmk_update_scripts_config.yml b/playbooks/checkmk_update_scripts_config.yml
new file mode 100644
index 0000000..5ce99f3
--- /dev/null
+++ b/playbooks/checkmk_update_scripts_config.yml
@@ -0,0 +1,135 @@
+# PURPOSE: Update Checkmk scripts and configuration
+# DESCRIPTION: Deploy selected checks and Windows configuration without installing agent packages.
+# TARGETS: linux, windows
+# INPUTS (omitted values inherit inventory / role defaults):
+# aim_debug [bool]: false
+# checkmk_unifi_mode [choice]: auto
+# checkmk_unifi_username [text]: bf-monitoring
+# checkmk_unifi_password [secret_ref]: vault_checkmk_unifi_password
+# checkmk_unifi_baseurl [url]: network: https://127.0.0.1:8443; os: https://127.0.0.1:11443
+# checkmk_unifi_curl_options [text]: --insecure --tlsv1.2
+# want_linux_check_certificate [bool]: false
+# want_windows_citrix [bool]: false
+# want_windows_surebackup [bool]: false
+# want_windows_backup [bool]: false
+# want_windows_nsp_mailqueue [bool]: false
+# want_windows_certificate [bool]: false
+# want_windows_veeam_cloud_connect [bool]: false
+# want_windows_veeam_backup [bool]: false
+# checkmk_unifi_status_provisioning [int]: 1
+# checkmk_unifi_status_upgrading [int]: 1
+# checkmk_unifi_status_upgradable [int]: 0
+# checkmk_unifi_status_heartbeat_missed [int]: 1
+# checkmk_unifi_status_noautobackup [int]: 0
+# checkmk_windows_updates_timeout [int]: 3600
+# checkmk_windows_updates_cache [int]: 43200
+# checkmk_mk_inventory_timeout [int]: 120
+# checkmk_plugins_default_timeout [int]: 120
+# checkmk_plugins_default_cache [int]: 600
+# checkmk_extra_plugin_patterns [sequence]: []
+# AUTH: existing inventory / Vault credentials; no embedded passwords.
+# CHANGES: AIM-managed script files are updated. On Windows only the marked plugins section in check_mk.user.yml is replaced; all other user-config sections are preserved. Only the opposite UniFi check is removed during a UniFi mode transition.
+# EXAMPLE: ansible-playbook -i inventories//hosts.yml
+# playbooks/checkmk_update_scripts_config.yml --limit --vault-id @prompt
+ - name: Checkmk | Update linux
+ hosts: linux
+ gather_facts: true
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in
+ ['true', 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ - name: Load system detect roles
+ ansible.builtin.include_role:
+ name: system_detect_roles
+ - name: Load checkmk script plan
+ ansible.builtin.include_role:
+ name: checkmk_script_plan
+ - name: Checkmk | Preflight scripts and credentials
+ ansible.builtin.include_role:
+ name: checkmk_deploy_scripts
+ tasks_from: preflight
+ become: true
+ roles:
+ - role: checkmk_deploy_scripts
+ - role: checkmk_configure_agent
+ - role: checkmk_manage_service
+ post_tasks:
+ - &id001
+ name: Checkmk | Collect managed change summary
+ ansible.builtin.set_fact:
+ _aim_checkmk_changes: '{{ _aim_check_copies | default({}) | aim_report_checkmk_changes(_checkmk_selected_scripts,
+ _checkmk_plugins_update | default({}), _aim_unifi_write | default({}), _aim_opposite_remove | default({}),
+ _checkmk_unifi_effective, ansible_check_mode) }}'
+ - name: AIM | Publish operation result
+ ansible.builtin.set_stats:
+ per_host: true
+ aggregate: false
+ data:
+ aim_output:
+ protocol: aim_output_v1
+ schema: checkmk_agent_config_v1
+ data: '{{ _aim_checkmk_changes }}'
+ - name: Checkmk | Update windows
+ hosts: windows
+ gather_facts: true
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in
+ ['true', 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ - name: Load system detect roles
+ ansible.builtin.include_role:
+ name: system_detect_roles
+ - name: Load checkmk script plan
+ ansible.builtin.include_role:
+ name: checkmk_script_plan
+ - name: Checkmk | Preflight scripts and credentials
+ ansible.builtin.include_role:
+ name: checkmk_deploy_scripts
+ tasks_from: preflight
+ roles:
+ - role: checkmk_deploy_scripts
+ - role: checkmk_configure_agent
+ - role: checkmk_manage_service
+ post_tasks:
+ - *id001
+ - name: AIM | Publish operation result
+ ansible.builtin.set_stats:
+ per_host: true
+ aggregate: false
+ data:
+ aim_output:
+ protocol: aim_output_v1
+ schema: checkmk_agent_config_v1
+ data: '{{ _aim_checkmk_changes }}'
diff --git a/playbooks/customers/bluuunit/sophos_apply_customer.yml b/playbooks/customers/bluuunit/sophos_apply_customer.yml
new file mode 100644
index 0000000..d8fe02b
--- /dev/null
+++ b/playbooks/customers/bluuunit/sophos_apply_customer.yml
@@ -0,0 +1,96 @@
+---
+# PURPOSE: Apply customer Sophos configuration
+# DESCRIPTION: Apply this customer profile using hostname, network_objects and vlan_interfaces from inventory.
+# TARGETS: sophosxgs
+# REQUIRED NETWORK KEYS: derz_lan, derz_sslvpn, facility, guest, lan_old, management, office, server, voip
+# INPUTS (omitted values inherit inventory / role defaults):
+# aim_debug [bool]: false
+# AUTH: existing inventory / Vault credentials; no embedded passwords.
+# CHANGES: Changes customer firewall configuration. VLAN parent remains Port1 as in the supplied playbooks. Only this customer profile is selected.
+# EXAMPLE: ansible-playbook -i inventories//hosts.yml
+# playbooks/customers/bluuunit/sophos_apply_customer.yml --limit --vault-id @prompt --ask-pass
+- name: Sophos | Apply customer configuration | bluuunit
+ hosts: sophosxgs
+ gather_facts: false
+ any_errors_fatal: false
+ tasks:
+ - name: Apply customer firewall policy
+ ansible.builtin.import_role:
+ name: sophos_customer_bluuunit
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
+ 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ - name: Sophos | Require customer host configuration
+ ansible.builtin.assert:
+ that:
+ - hostname is defined
+ - hostname is string
+ - hostname | length > 0
+ - network_objects is defined
+ - network_objects is mapping
+ - vlan_interfaces is defined
+ - vlan_interfaces is mapping
+ fail_msg: Set hostname, network_objects and vlan_interfaces in this host inventory. AIM can prepare
+ these fields.
+ quiet: true
+ - name: Sophos | Require profile network keys
+ ansible.builtin.assert:
+ that:
+ - item in network_objects
+ fail_msg: A customer-required network_objects key is missing. See the loop item.
+ quiet: true
+ loop:
+ - derz_lan
+ - derz_sslvpn
+ - facility
+ - guest
+ - lan_old
+ - management
+ - office
+ - server
+ - voip
+ - name: Sophos | Validate network object shape
+ ansible.builtin.assert:
+ that:
+ - item.value is mapping
+ - item.value.name is defined
+ - item.value.network is defined
+ - item.value.subnetmask is defined
+ fail_msg: Every network object requires name, network and subnetmask.
+ quiet: true
+ loop: '{{ network_objects | dict2items }}'
+ loop_control:
+ label: '{{ item.key }}'
+ - name: Sophos | Validate VLAN shape
+ ansible.builtin.assert:
+ that:
+ - item.value is mapping
+ - item.value.name is defined
+ - item.value.ip_address is defined
+ - item.value.subnetmask is defined
+ - item.value.vlan_id is defined
+ - item.value.zone_name is defined
+ - item.value.zone_type is defined
+ - item.value.zone_description is defined
+ fail_msg: Each VLAN requires its full interface and zone mapping.
+ quiet: true
+ loop: '{{ vlan_interfaces | dict2items }}'
+ loop_control:
+ label: '{{ item.key }}'
diff --git a/playbooks/customers/formicon/sophos_apply_customer.yml b/playbooks/customers/formicon/sophos_apply_customer.yml
new file mode 100644
index 0000000..6275b1a
--- /dev/null
+++ b/playbooks/customers/formicon/sophos_apply_customer.yml
@@ -0,0 +1,91 @@
+---
+# PURPOSE: Apply customer Sophos configuration
+# DESCRIPTION: Apply this customer profile using hostname, network_objects and vlan_interfaces from inventory.
+# TARGETS: sophosxgs
+# REQUIRED NETWORK KEYS: azuregwc_lan, lan_old, management, office
+# INPUTS (omitted values inherit inventory / role defaults):
+# aim_debug [bool]: false
+# AUTH: existing inventory / Vault credentials; no embedded passwords.
+# CHANGES: Changes customer firewall configuration. VLAN parent remains Port1 as in the supplied playbooks. Only this customer profile is selected.
+# EXAMPLE: ansible-playbook -i inventories//hosts.yml
+# playbooks/customers/formicon/sophos_apply_customer.yml --limit --vault-id @prompt --ask-pass
+- name: Sophos | Apply customer configuration | formicon
+ hosts: sophosxgs
+ gather_facts: false
+ any_errors_fatal: false
+ tasks:
+ - name: Apply customer firewall policy
+ ansible.builtin.import_role:
+ name: sophos_customer_formicon
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
+ 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ - name: Sophos | Require customer host configuration
+ ansible.builtin.assert:
+ that:
+ - hostname is defined
+ - hostname is string
+ - hostname | length > 0
+ - network_objects is defined
+ - network_objects is mapping
+ - vlan_interfaces is defined
+ - vlan_interfaces is mapping
+ fail_msg: Set hostname, network_objects and vlan_interfaces in this host inventory. AIM can prepare
+ these fields.
+ quiet: true
+ - name: Sophos | Require profile network keys
+ ansible.builtin.assert:
+ that:
+ - item in network_objects
+ fail_msg: A customer-required network_objects key is missing. See the loop item.
+ quiet: true
+ loop:
+ - azuregwc_lan
+ - lan_old
+ - management
+ - office
+ - name: Sophos | Validate network object shape
+ ansible.builtin.assert:
+ that:
+ - item.value is mapping
+ - item.value.name is defined
+ - item.value.network is defined
+ - item.value.subnetmask is defined
+ fail_msg: Every network object requires name, network and subnetmask.
+ quiet: true
+ loop: '{{ network_objects | dict2items }}'
+ loop_control:
+ label: '{{ item.key }}'
+ - name: Sophos | Validate VLAN shape
+ ansible.builtin.assert:
+ that:
+ - item.value is mapping
+ - item.value.name is defined
+ - item.value.ip_address is defined
+ - item.value.subnetmask is defined
+ - item.value.vlan_id is defined
+ - item.value.zone_name is defined
+ - item.value.zone_type is defined
+ - item.value.zone_description is defined
+ fail_msg: Each VLAN requires its full interface and zone mapping.
+ quiet: true
+ loop: '{{ vlan_interfaces | dict2items }}'
+ loop_control:
+ label: '{{ item.key }}'
diff --git a/playbooks/customers/gebhardt_stahl/sophos_apply_customer.yml b/playbooks/customers/gebhardt_stahl/sophos_apply_customer.yml
new file mode 100644
index 0000000..2fd8564
--- /dev/null
+++ b/playbooks/customers/gebhardt_stahl/sophos_apply_customer.yml
@@ -0,0 +1,91 @@
+---
+# PURPOSE: Apply customer Sophos configuration
+# DESCRIPTION: Apply this customer profile using hostname, network_objects and vlan_interfaces from inventory.
+# TARGETS: sophosxgs
+# REQUIRED NETWORK KEYS: drucker, guest, office, wlan
+# INPUTS (omitted values inherit inventory / role defaults):
+# aim_debug [bool]: false
+# AUTH: existing inventory / Vault credentials; no embedded passwords.
+# CHANGES: Changes customer firewall configuration. VLAN parent remains Port1 as in the supplied playbooks. Only this customer profile is selected.
+# EXAMPLE: ansible-playbook -i inventories//hosts.yml
+# playbooks/customers/gebhardt_stahl/sophos_apply_customer.yml --limit --vault-id @prompt --ask-pass
+- name: Sophos | Apply customer configuration | gebhardt_stahl
+ hosts: sophosxgs
+ gather_facts: false
+ any_errors_fatal: false
+ tasks:
+ - name: Apply customer firewall policy
+ ansible.builtin.import_role:
+ name: sophos_customer_gebhardt_stahl
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
+ 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ - name: Sophos | Require customer host configuration
+ ansible.builtin.assert:
+ that:
+ - hostname is defined
+ - hostname is string
+ - hostname | length > 0
+ - network_objects is defined
+ - network_objects is mapping
+ - vlan_interfaces is defined
+ - vlan_interfaces is mapping
+ fail_msg: Set hostname, network_objects and vlan_interfaces in this host inventory. AIM can prepare
+ these fields.
+ quiet: true
+ - name: Sophos | Require profile network keys
+ ansible.builtin.assert:
+ that:
+ - item in network_objects
+ fail_msg: A customer-required network_objects key is missing. See the loop item.
+ quiet: true
+ loop:
+ - drucker
+ - guest
+ - office
+ - wlan
+ - name: Sophos | Validate network object shape
+ ansible.builtin.assert:
+ that:
+ - item.value is mapping
+ - item.value.name is defined
+ - item.value.network is defined
+ - item.value.subnetmask is defined
+ fail_msg: Every network object requires name, network and subnetmask.
+ quiet: true
+ loop: '{{ network_objects | dict2items }}'
+ loop_control:
+ label: '{{ item.key }}'
+ - name: Sophos | Validate VLAN shape
+ ansible.builtin.assert:
+ that:
+ - item.value is mapping
+ - item.value.name is defined
+ - item.value.ip_address is defined
+ - item.value.subnetmask is defined
+ - item.value.vlan_id is defined
+ - item.value.zone_name is defined
+ - item.value.zone_type is defined
+ - item.value.zone_description is defined
+ fail_msg: Each VLAN requires its full interface and zone mapping.
+ quiet: true
+ loop: '{{ vlan_interfaces | dict2items }}'
+ loop_control:
+ label: '{{ item.key }}'
diff --git a/playbooks/customers/hungeling_und_toechter/sophos_apply_customer.yml b/playbooks/customers/hungeling_und_toechter/sophos_apply_customer.yml
new file mode 100644
index 0000000..90997d3
--- /dev/null
+++ b/playbooks/customers/hungeling_und_toechter/sophos_apply_customer.yml
@@ -0,0 +1,92 @@
+---
+# PURPOSE: Apply customer Sophos configuration
+# DESCRIPTION: Apply this customer profile using hostname, network_objects and vlan_interfaces from inventory.
+# TARGETS: sophosxgs
+# REQUIRED NETWORK KEYS: facility, guest, management, office, voip
+# INPUTS (omitted values inherit inventory / role defaults):
+# aim_debug [bool]: false
+# AUTH: existing inventory / Vault credentials; no embedded passwords.
+# CHANGES: Changes customer firewall configuration. VLAN parent remains Port1 as in the supplied playbooks. Only this customer profile is selected.
+# EXAMPLE: ansible-playbook -i inventories//hosts.yml
+# playbooks/customers/hungeling_und_toechter/sophos_apply_customer.yml --limit --vault-id @prompt --ask-pass
+- name: Sophos | Apply customer configuration | hungeling_und_toechter
+ hosts: sophosxgs
+ gather_facts: false
+ any_errors_fatal: false
+ tasks:
+ - name: Apply customer firewall policy
+ ansible.builtin.import_role:
+ name: sophos_customer_hungeling_und_toechter
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
+ 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ - name: Sophos | Require customer host configuration
+ ansible.builtin.assert:
+ that:
+ - hostname is defined
+ - hostname is string
+ - hostname | length > 0
+ - network_objects is defined
+ - network_objects is mapping
+ - vlan_interfaces is defined
+ - vlan_interfaces is mapping
+ fail_msg: Set hostname, network_objects and vlan_interfaces in this host inventory. AIM can prepare
+ these fields.
+ quiet: true
+ - name: Sophos | Require profile network keys
+ ansible.builtin.assert:
+ that:
+ - item in network_objects
+ fail_msg: A customer-required network_objects key is missing. See the loop item.
+ quiet: true
+ loop:
+ - facility
+ - guest
+ - management
+ - office
+ - voip
+ - name: Sophos | Validate network object shape
+ ansible.builtin.assert:
+ that:
+ - item.value is mapping
+ - item.value.name is defined
+ - item.value.network is defined
+ - item.value.subnetmask is defined
+ fail_msg: Every network object requires name, network and subnetmask.
+ quiet: true
+ loop: '{{ network_objects | dict2items }}'
+ loop_control:
+ label: '{{ item.key }}'
+ - name: Sophos | Validate VLAN shape
+ ansible.builtin.assert:
+ that:
+ - item.value is mapping
+ - item.value.name is defined
+ - item.value.ip_address is defined
+ - item.value.subnetmask is defined
+ - item.value.vlan_id is defined
+ - item.value.zone_name is defined
+ - item.value.zone_type is defined
+ - item.value.zone_description is defined
+ fail_msg: Each VLAN requires its full interface and zone mapping.
+ quiet: true
+ loop: '{{ vlan_interfaces | dict2items }}'
+ loop_control:
+ label: '{{ item.key }}'
diff --git a/playbooks/customers/koenig_holding_gmbh/sophos_apply_customer.yml b/playbooks/customers/koenig_holding_gmbh/sophos_apply_customer.yml
new file mode 100644
index 0000000..27fb2d8
--- /dev/null
+++ b/playbooks/customers/koenig_holding_gmbh/sophos_apply_customer.yml
@@ -0,0 +1,93 @@
+---
+# PURPOSE: Apply customer Sophos configuration
+# DESCRIPTION: Apply this customer profile using hostname, network_objects and vlan_interfaces from inventory.
+# TARGETS: sophosxgs
+# REQUIRED NETWORK KEYS: facility, guest, management, office, server, voip
+# INPUTS (omitted values inherit inventory / role defaults):
+# aim_debug [bool]: false
+# AUTH: existing inventory / Vault credentials; no embedded passwords.
+# CHANGES: Changes customer firewall configuration. VLAN parent remains Port1 as in the supplied playbooks. Only this customer profile is selected.
+# EXAMPLE: ansible-playbook -i inventories//hosts.yml
+# playbooks/customers/koenig_holding_gmbh/sophos_apply_customer.yml --limit --vault-id @prompt --ask-pass
+- name: Sophos | Apply customer configuration | koenig_holding_gmbh
+ hosts: sophosxgs
+ gather_facts: false
+ any_errors_fatal: false
+ tasks:
+ - name: Apply customer firewall policy
+ ansible.builtin.import_role:
+ name: sophos_customer_koenig_holding_gmbh
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
+ 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ - name: Sophos | Require customer host configuration
+ ansible.builtin.assert:
+ that:
+ - hostname is defined
+ - hostname is string
+ - hostname | length > 0
+ - network_objects is defined
+ - network_objects is mapping
+ - vlan_interfaces is defined
+ - vlan_interfaces is mapping
+ fail_msg: Set hostname, network_objects and vlan_interfaces in this host inventory. AIM can prepare
+ these fields.
+ quiet: true
+ - name: Sophos | Require profile network keys
+ ansible.builtin.assert:
+ that:
+ - item in network_objects
+ fail_msg: A customer-required network_objects key is missing. See the loop item.
+ quiet: true
+ loop:
+ - facility
+ - guest
+ - management
+ - office
+ - server
+ - voip
+ - name: Sophos | Validate network object shape
+ ansible.builtin.assert:
+ that:
+ - item.value is mapping
+ - item.value.name is defined
+ - item.value.network is defined
+ - item.value.subnetmask is defined
+ fail_msg: Every network object requires name, network and subnetmask.
+ quiet: true
+ loop: '{{ network_objects | dict2items }}'
+ loop_control:
+ label: '{{ item.key }}'
+ - name: Sophos | Validate VLAN shape
+ ansible.builtin.assert:
+ that:
+ - item.value is mapping
+ - item.value.name is defined
+ - item.value.ip_address is defined
+ - item.value.subnetmask is defined
+ - item.value.vlan_id is defined
+ - item.value.zone_name is defined
+ - item.value.zone_type is defined
+ - item.value.zone_description is defined
+ fail_msg: Each VLAN requires its full interface and zone mapping.
+ quiet: true
+ loop: '{{ vlan_interfaces | dict2items }}'
+ loop_control:
+ label: '{{ item.key }}'
diff --git a/playbooks/debug_detect_host_roles.yml b/playbooks/debug_detect_host_roles.yml
new file mode 100644
index 0000000..b6e6173
--- /dev/null
+++ b/playbooks/debug_detect_host_roles.yml
@@ -0,0 +1,62 @@
+# PURPOSE: Detect host roles
+# DESCRIPTION: Report detected AD, DHCP, Hyper-V, Veeam and UniFi capabilities without changing inventory memberships.
+# TARGETS: linux, windows
+# INPUTS (omitted values inherit inventory / role defaults):
+# aim_debug [bool]: false
+# AUTH: existing inventory / Vault credentials; no embedded passwords.
+# EXAMPLE: ansible-playbook -i inventories//hosts.yml
+# playbooks/debug_detect_host_roles.yml --limit --vault-id @prompt
+ - name: Debug | Detected host roles
+ hosts: linux:windows
+ gather_facts: true
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in
+ ['true', 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ roles:
+ - role: system_detect_roles
+ tasks:
+ - name: Detection summary
+ ansible.builtin.debug:
+ msg:
+ is_dc: '{{ is_dc | default(false) }}'
+ is_dhcp_server: '{{ is_dhcp_server | default(false) }}'
+ is_hyperv_host: '{{ is_hyperv_host | default(false) }}'
+ has_veeam_vbr: '{{ has_veeam_vbr | default(false) }}'
+ has_veeam_vbo: '{{ has_veeam_vbo | default(false) }}'
+ has_veeam_em: '{{ has_veeam_em | default(false) }}'
+ is_unifi_controller: '{{ is_unifi_controller | default(false) }}'
+ is_unifi_os_server: '{{ is_unifi_os_server | default(false) }}'
+ - name: AIM | Publish operation result
+ ansible.builtin.set_stats:
+ per_host: true
+ aggregate: false
+ data:
+ aim_output:
+ protocol: aim_output_v1
+ schema: host_capabilities_v1
+ data:
+ is_dc: '{{ is_dc | default(false) | bool }}'
+ is_dhcp_server: '{{ is_dhcp_server | default(false) | bool }}'
+ is_hyperv_host: '{{ is_hyperv_host | default(false) | bool }}'
+ has_veeam_vbr: '{{ has_veeam_vbr | default(false) | bool }}'
+ has_veeam_vbo: '{{ has_veeam_vbo | default(false) | bool }}'
+ has_veeam_em: '{{ has_veeam_em | default(false) | bool }}'
+ is_unifi_controller: '{{ is_unifi_controller | default(false) | bool }}'
+ is_unifi_os_server: '{{ is_unifi_os_server | default(false) | bool }}'
diff --git a/playbooks/debug_disk_usage.yml b/playbooks/debug_disk_usage.yml
deleted file mode 100644
index a1c0e55..0000000
--- a/playbooks/debug_disk_usage.yml
+++ /dev/null
@@ -1,38 +0,0 @@
----
-- name: "Debug | Disk usage"
- hosts: all
- gather_facts: true
-
- tasks:
- - name: "Linux | Collect filesystem usage"
- when: ansible_facts['os_family'] != 'Windows'
- ansible.builtin.command:
- cmd: df -hP -x tmpfs -x devtmpfs
- register: disk_usage_linux
- changed_when: false
-
- - name: "Linux | Show filesystem usage"
- when: ansible_facts['os_family'] != 'Windows'
- ansible.builtin.debug:
- var: disk_usage_linux.stdout_lines
-
- - name: "Windows | Collect filesystem drive usage"
- when: ansible_facts['os_family'] == 'Windows'
- ansible.windows.win_powershell:
- script: |
- Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" |
- Select-Object DeviceID,
- @{Name='SizeGB';Expression={[math]::Round($_.Size / 1GB, 2)}},
- @{Name='FreeGB';Expression={[math]::Round($_.FreeSpace / 1GB, 2)}},
- @{Name='UsedPercent';Expression={
- if ($_.Size -gt 0) {
- [math]::Round((($_.Size - $_.FreeSpace) / $_.Size) * 100, 1)
- } else { 0 }
- }}
- register: disk_usage_windows
- changed_when: false
-
- - name: "Windows | Show filesystem drive usage"
- when: ansible_facts['os_family'] == 'Windows'
- ansible.builtin.debug:
- var: disk_usage_windows.output
diff --git a/playbooks/debug_ping.yml b/playbooks/debug_ping.yml
deleted file mode 100644
index 310489d..0000000
--- a/playbooks/debug_ping.yml
+++ /dev/null
@@ -1,13 +0,0 @@
----
-- name: "Debug | Ping hosts"
- hosts: all
- gather_facts: false
-
- tasks:
- - name: "Windows | WinRM ping"
- when: ansible_connection | default('') == 'winrm'
- ansible.windows.win_ping:
-
- - name: "Linux | Ansible ping"
- when: ansible_connection | default('ssh') != 'winrm'
- ansible.builtin.ping:
diff --git a/playbooks/debug_server_role_selection.yml b/playbooks/debug_server_role_selection.yml
deleted file mode 100644
index 0a28f59..0000000
--- a/playbooks/debug_server_role_selection.yml
+++ /dev/null
@@ -1,87 +0,0 @@
----
-- name: "Debug | Server Role Selection"
- hosts: all
- gather_facts: true
-
- roles:
- - server_role_selection
-
- tasks:
- - name: "Debug | Display detected server roles"
- ansible.builtin.debug:
- msg:
- host: "{{ inventory_hostname }}"
- os_family: "{{ ansible_facts['os_family'] | default('unknown') }}"
-
- windows_roles:
- domain_controller: "{{ is_dc | default(false) | bool }}"
- dhcp_server: "{{ is_dhcp_server | default(false) | bool }}"
- hyperv_host: "{{ is_hyperv_host | default(false) | bool }}"
-
- veeam:
- vbr: "{{ has_veeam_vbr | default(false) | bool }}"
- vbo: "{{ has_veeam_vbo | default(false) | bool }}"
- enterprise_manager: "{{ has_veeam_em | default(false) | bool }}"
-
- linux_roles:
- unifi_controller: "{{ is_unifi_controller | default(false) | bool }}"
-
- optional_features:
- linux_certificate_check: "{{ want_linux_check_certificate | default(false) | bool }}"
- windows_citrix: "{{ want_windows_citrix | default(false) | bool }}"
- windows_surebackup: "{{ want_windows_surebackup | default(false) | bool }}"
- windows_backup: "{{ want_windows_backup | default(false) | bool }}"
-
- - name: "Debug | Display Checkmk script deployment decisions"
- ansible.builtin.debug:
- msg:
- linux:
- unifi_controller:
- deploy: "{{ is_unifi_controller | default(false) | bool }}"
- reason: "UniFi Controller detected"
- scripts:
- - "check_unifi-controller.sh"
- - "unifi.cfg"
-
- certificate_directory:
- deploy: "{{ want_linux_check_certificate | default(false) | bool }}"
- reason: "Certificate directory monitoring explicitly enabled"
- scripts:
- - "check_certificate_directory.sh"
-
- windows:
- check_ping:
- deploy: "{{ is_dc | default(false) | bool }}"
- reason: "Domain Controller"
- scripts:
- - "check-ping.ps1"
-
- veeam_config_backup:
- deploy: "{{ has_veeam_vbr | default(false) | bool }}"
- reason: "Veeam Backup & Replication detected"
- scripts:
- - "veeam_config_backup_status.ps1"
-
- veeam_o365:
- deploy: "{{ has_veeam_vbo | default(false) | bool }}"
- reason: "Veeam Backup for Microsoft 365 detected"
- scripts:
- - "veeam_o365_status.ps1"
-
- citrix_sessions:
- deploy: "{{ want_windows_citrix | default(false) | bool }}"
- reason: "Citrix monitoring explicitly enabled"
- scripts:
- - "citrix_sessions_customized.ps1"
-
- veeam_surebackup:
- deploy: "{{ want_windows_surebackup | default(false) | bool }}"
- reason: "Veeam SureBackup monitoring explicitly enabled"
- scripts:
- - "veeam_surebackup_status.ps1"
-
- windows_backup:
- deploy: "{{ want_windows_backup | default(false) | bool }}"
- reason: "Windows Backup monitoring explicitly enabled"
- scripts:
- - "windows-backup.ps1"
\ No newline at end of file
diff --git a/playbooks/debug_show_disk_usage.yml b/playbooks/debug_show_disk_usage.yml
new file mode 100644
index 0000000..4b89a32
--- /dev/null
+++ b/playbooks/debug_show_disk_usage.yml
@@ -0,0 +1,185 @@
+# PURPOSE: Show disk usage
+# DESCRIPTION: Report attached Windows volumes and common operational Linux mounts.
+# TARGETS: windows, linux
+# INPUTS (omitted values inherit inventory / role defaults):
+# aim_debug [bool]: false
+# AUTH: existing inventory / Vault credentials; no embedded passwords.
+# NOTES:
+# Windows reports attached storage volumes from community.windows.win_disk_facts; mapped/network drives are excluded.
+# Linux excludes pseudo/system mounts and reports common operational paths plus network/storage filesystems.
+# EXAMPLE: ansible-playbook -i inventories//hosts.yml
+# playbooks/debug_show_disk_usage.yml --limit --vault-id @prompt
+
+ - name: Debug | Windows disk usage
+ hosts: windows
+ gather_facts: false
+ tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in
+ ['true', 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+
+ - name: Windows | Gather attached disk and volume facts
+ community.windows.win_disk_facts:
+ filter:
+ - partitions
+ - volumes
+ changed_when: false
+
+ - name: Windows | Normalize attached volume usage
+ ansible.builtin.set_fact:
+ _windows_disk_report: "{{ ansible_facts.disks | default([]) | aim_report_disks('windows') }}"
+
+ - name: Windows | Print disk usage
+ ansible.builtin.debug:
+ msg: |-
+ {{ inventory_hostname }}
+ {% for d in _windows_disk_report.filesystems | default([]) %}
+ {% if d.status == 'available' %}
+ {{ '%-18s' | format(d.name) }} {{ (d.used_bytes / 1073741824) | round(2) }} GB / {{ (d.total_bytes / 1073741824) | round(2) }} GB | {{ d.used_percent | round(1) }}% used | {{ (d.available_bytes / 1073741824) | round(2) }} GB free | {{ d.filesystem_type | default('unknown', true) }} | {{ d.mount }}
+ {% else %}
+ {{ '%-18s' | format(d.name) }} unavailable | {{ d.mount }}
+ {% endif %}
+ {% endfor %}
+
+ - name: AIM | Publish operation result
+ ansible.builtin.set_stats:
+ per_host: true
+ aggregate: false
+ data:
+ aim_output:
+ protocol: aim_output_v1
+ schema: filesystem_usage_v1
+ data: "{{ _windows_disk_report }}"
+ - name: Debug | Linux disk usage
+ hosts: linux
+ gather_facts: false
+ become: false
+ vars:
+ _disk_common_mounts:
+ - /
+ - /boot
+ - /boot/efi
+ - /home
+ - /var
+ - /var/log
+ - /tmp
+ - /opt
+ - /srv
+ _disk_network_storage_fstypes:
+ - nfs
+ - nfs4
+ - cifs
+ - smb3
+ - ceph
+ - glusterfs
+ - fuse.sshfs
+ - fuse.glusterfs
+ _disk_excluded_fstypes:
+ - proc
+ - sysfs
+ - devtmpfs
+ - tmpfs
+ - cgroup
+ - cgroup2
+ - overlay
+ - squashfs
+ - nsfs
+ - tracefs
+ - debugfs
+ - securityfs
+ - pstore
+ - configfs
+ - hugetlbfs
+ - mqueue
+ - rpc_pipefs
+ - autofs
+ - fusectl
+ - binfmt_misc
+ tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in
+ ['true', 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+
+ - name: Linux | Collect mount facts
+ ansible.builtin.setup:
+ filter:
+ - ansible_mounts
+ gather_subset:
+ - '!all'
+ - '!min'
+ changed_when: false
+
+ - name: Linux | Reset selected mount report
+ ansible.builtin.set_fact:
+ _linux_disk_mounts: []
+ - name: Linux | Select common operational mounts
+ ansible.builtin.set_fact:
+ _linux_disk_mounts: '{{ (_linux_disk_mounts | default([])) + [item] }}'
+ loop: '{{ ansible_facts.mounts | default(ansible_mounts | default([])) }}'
+ loop_control:
+ label: "{{ item.mount | default('?') }}"
+ when:
+ - item.fstype | default('') not in _disk_excluded_fstypes
+ - >-
+ (item.mount | default('')) in _disk_common_mounts
+ or (item.mount | default('')).startswith('/mnt/')
+ or (item.mount | default('')).startswith('/media/')
+ or (item.fstype | default('')) in _disk_network_storage_fstypes
+
+ - name: Linux | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ selected_mounts: "{{ (_linux_disk_mounts | default([])) | map(attribute='mount') | list }}"
+ diagnostics: Enabled; pseudo/system mounts are excluded.
+ when: aim_debug | default(false) | bool
+
+ - name: Linux | Print disk usage
+ ansible.builtin.debug:
+ msg: |-
+ {{ inventory_hostname }}
+ {% for m in (_linux_disk_mounts | default([]) | sort(attribute='mount')) %}
+ {% set total = m.size_total | default(0) | float %}
+ {% set free = m.size_available | default(0) | float %}
+ {% set used = total - free %}
+ {% set pct = ((used / total) * 100) if total > 0 else 0 %}
+ {{ '%-18s' | format(m.mount) }} {{ (used / 1073741824) | round(2) }} GB / {{ (total / 1073741824) | round(2) }} GB | {{ pct | round(1) }}% used | {{ (free / 1073741824) | round(2) }} GB free | {{ m.fstype }}
+ {% endfor %}
+ - name: AIM | Publish operation result
+ ansible.builtin.set_stats:
+ per_host: true
+ aggregate: false
+ data:
+ aim_output:
+ protocol: aim_output_v1
+ schema: filesystem_usage_v1
+ data: "{{ _linux_disk_mounts | aim_report_disks('linux') }}"
diff --git a/playbooks/debug_test_connection.yml b/playbooks/debug_test_connection.yml
new file mode 100644
index 0000000..0aa24f8
--- /dev/null
+++ b/playbooks/debug_test_connection.yml
@@ -0,0 +1,61 @@
+---
+# PURPOSE: Test Ansible connection
+# DESCRIPTION: Check Ansible manageability using ping or win_ping; this is not an ICMP ping.
+# TARGETS: linux, windows
+# INPUTS (omitted values inherit inventory / role defaults):
+# aim_debug [bool]: false
+# AUTH: existing inventory / Vault credentials; no embedded passwords.
+# EXAMPLE: ansible-playbook -i inventories//hosts.yml
+# playbooks/debug_test_connection.yml --limit --vault-id @prompt
+- name: Debug | Windows manageability
+ hosts: windows
+ gather_facts: false
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
+ 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ tasks:
+ - name: Windows | Test WinRM
+ ansible.windows.win_ping: {}
+- name: Debug | Linux manageability
+ hosts: linux
+ gather_facts: false
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
+ 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ tasks:
+ - name: Linux | Test SSH and Python
+ ansible.builtin.ping: {}
diff --git a/playbooks/filter_plugins/__pycache__/aim_reports.cpython-313.pyc b/playbooks/filter_plugins/__pycache__/aim_reports.cpython-313.pyc
new file mode 100644
index 0000000000000000000000000000000000000000..52caacef9c4986b87cf0aff637457b1bb291a9ee
GIT binary patch
literal 21234
zcmcJ1dvp}nnP*i$)K5v>dLRh|v_L$Xhr~k!gl!B)5-%f+N-YEenpSs7YSij(S9MEB
zCK)B3oxnJ|NXK?)GM-q(>{?8G5;@5xIQE_hCo{pB>`phOpq?6yH#3v{!+Xx2!^4?`
z*>iUHcW*tq1tLy%XYY}2-M)3-_q*Tw)`P-AJA<%j?AvFhEe!KdlJS1&AWE70_X%bBIX%@_Qnhq5lvCIV)%5
z?3{yha)n$`U4h`>ijnW+syWwU<}51|a&G)C;+AqH^mj40j4P!n7q^@%qbc`Eb6c%8
z%-nMY!RgVN+GbEvLfK}9Thz}ANBCd@RpZI-NI2-}3G>pX3NA&$u@&ejQBn8Z)&i)BmC@(8J0#OW|PEl;dZ_D98JRXkr1_29t
zFd~J2_Q4G#UT3=WcRpsDn(k6po?)J60!aIqTBggawU8^AdX2IvAtZU(ILwPOySd$Kmd!m_G+s7DV=}u*HbrAm
zUS?WGyebf2d$BiBPgtZ|9Po(AIDroAU?hxP2}IC_DKQ-9WfLhY8^wemnjU8d
zzd*rJtcwrJW6KTk82ai~Yj3mq%tT6&Xu~
zWT{Fot(PqIH%yP`H%gYq=~Z*~vUK@w$-XCT+VcP{GV?Z{AP&0ueMkU3uu;~>mNFd%
z(XfdT4W-QfWegKrkq;DY1YjNsJ_Bda4w|+pe#2o8!#EfZ+KC!!5i6-4#%pZ#T4i%s
z^oN3S5qb0l<9?AB0LAooA68r#^!LTYgk0Dc5dDKZ9~kftb_?@=#$;#iEk7WaqOD{!
z+!OW(W6^{Vi$u_1iJln~)lOQx2D;3$v6oMHjY27=Bb%dv0RlMvBAKAa;}Ob8wlf%r
z2fD+6hcGV+E0IOAMFQ5N%v}RpxSt(u{)a8wCC85MteQOf6@IMw@`3RKslY_JRJKMc
zT6^2M_T5z($BwjV$HUu>9ij;}zr3p66x0tDGK%nzZrKN$kbBzkI#bU?4L+un*~jh&
zzz;ZlEXVHb!~v>jn07~-dN5H-57+@Umox6%&oEJ=&(Q!h4uojs%%%E4OroE7bF`q{
z!4;4*jHiV>IVPTpT8_NfMW#=~6Z!%2e{`=uvyT1oI&nUS1~^-1$40=S)^4nsv+ew2
zGKVLO!GTdb$*8p&+V3ZnFVxnC%+Y6DtIjZFCG&oUjhgUpeuj;oJ+huFTFi7d`wAM2
zkFS}}tknzv3Vfz+H^X%4NUyVbqXX>~Z^Xbjmo~B^EXPFcK0}+1xYW+^=t6lVbe`*c
z&1ddBn;RQJw458YOSsZTt2TZQAmXr7Iai$}Tv;7XmbbiBwhV=%q1ccp7jQuMNl~`#
z84QFY0U)4x2cAIRRsv`(NbF));&{Fee2#;@2+Pg38x{gBAFFjg$h>a$Fo!FZ0rWE4
zjXmsjw)HwIc+ehDu%T{~umVZhC}1=MlsukM3T5hZPF5qMrnnwy01ATW4aES8Y%Vt9o2CrCko96-5MKXUKwUkoG(n5X>Iq&n0s>+TM-$f#
z!g?|$_MAw@rlKHWBho@0qBZ!ZtD`K=iQD^+Pj9~Z`8T);_ZuBIYG;=2LXNUJU#ecl
zeiB0YPv|Udxd}RwQT+$du!*2OVLM97#h_2TI1GMdz)y*uJ&CpTchkjn&^1$k6zeUh
z8BSUfSUU(#MJ^C240;W+6~r&m171sZhOxd8kLkEbaV
z4ko-#ikJo9GDa>?*dy808;jvR=}tD&A<3p5Y_+flrIcwHdPLb64<>{S$P#f@2r}2g
zZUq+26aWPuUM?o3HUM@0c9h9F3P)S!ipwq-vyRfSVaegiy2~zCj8~*8CHIPn{)~I`
zg{Hac&dlaxQg!FWBNv*+64}K|Q@vL^?Q477=L7W&ywE#Li%W%w1blx+Q(|=++|}&$CBeCX#Yaf&BK3
zghde?tshKkm=BF?*b@x$qUae4h~SP%mS?Dsk9q(TLITXE!efKJNURsDrZ%!FzvDmKuO9|Gy%8?l3xh5bL=g*BA7)%QO%|_V#D%RV
zhlfBaJv+ilyvK_|^~Jng*`9+p;S4P(TQvw5PAfHSK
zU&$_{0O=v4O9~KvNUn;g345ww>mshXt>eJnW`A>A+mSX-z`z*+To4uqBtI}Do9M~_
zT?fQiRJMSn76ZMYzC@NK)VCK4h~`C-1F~R@CI>`tVT^{L75rSN9ZkBzf`9^qM2TNV
z1T<}PUaGiQF}5@%zBzPdXyUxIyg{;U8*L)OQq#*LZGgi8^voAqM_Ybnb6(&s9lLmJ%$M3C6)ux(%O}heWog^$S=)w;ZG&X18*R#39A7+t
z-@ulI*$ei6s$6k#|AnS(ap}K3Kev42WH7UQ%XraP!Mn}(%uLnFy9JE1BvqSsEK8e~
zQOp?BPXNYCR)?O$`-o2)Pn%Ac=8M@JyO=UynxL#fc(fpojWGF)4Un-2FHe}Y$I4nZ
zF;Od#-1ff!HqPd=EhMGk+yN7ipmVVV{+EE0UAVkylg?i+*q$zcN9~<&>*r}N+RO3D
zq=)AjrZeTU6ZYkBo5K9~AX|1gbSV=IW$b;i+`ba7xXz@t?O_fND7%o8V&?BV=K|B~?(EH5OI?o1<#iDZNQ19HYdbH`
zqUBNfP=#Ngv-4wLaaXZcGq1e9HhFn^uNzTcYcE&ZSG+)8ZY<>d(%{o+;xnwa@*%9I
z2j^G&y`JGp8Vhx8hXdM<($;xonGj4WfkB$jmmWMsdRT~Ha{7=J6#yk#RR9j?aS7C^
zQpE~YsxT6n%xjX(AUX+Nwup%sQD;&Hxm8$)3ebKiVutvbm?1Aw5${66**?@2S0bX~
zN6Eo&^h~&aV1Cy;)%)F|X&|uT!t?(SE$uSo
z$xrQn_zEnzUivn1RBuSvYZDY5y4W88+kn+Gk=1L@m3b~N*FcU6t$8??tFCm1y3%I4
z(gi{i%?N{tK;H;|=N|xb0EWO6NG4*k1uEje@ca!h(G3uWNF59Gy^$oi2}?Li6I8l|
z)U1fjiu895CHLcB>_$Xo
z^}sgB^m9HLY=3$gdk!Adf2IDEJT{L$Dc-
zF6&X`KEUBoRSvG*U)wkK;>6(FBUeYJYTo+%R5-Kd*~{*6_t*A4d|*Yfm#e(&gw6W|
zp#J|kLkeTc?1VqYNS-huP*_IzGKnrAnzs>T1t{+M91`=iKj83U?)DtiLm8(pG>FS1
z`ikq!?-~Gy#zk=2>WaE|eC=3@omzCg_;Ss7&DWs5q04BiV#n|=yo`cSvrt(I`W!b!
zTTtT$G?mOq{R>b8Z=}1cOI5T~o`3||D%MSK7lbQf3aB759V|FeNSyva4^g|*2Ik9`
zs^VWQ=LsaJcp1-ir?g2eC}`HC0D1%?7C`$LAxOq<4)*cEe%TD&MP%4pD4WRfm)+qg
zRK|fwWEdS&3@lq=E9xaC0ofKv;Ml|=2_jQD{vo+A$`7HmIv64sha(ZavOO8qCJ03h
zT`j1wlu4;FG;}poZj_zsd-Yd04-E_vS%^ZbNLdW6Ew2s7gPJ_3N(Nea3{0d9w*a<{
z5rF=nqY?RpY>eDO=Tvh?qWEWZ?^>&gV&Vi9`e|3a=u9YA2XTLg}`F
zW7Q~_@E0i8
zLMr?SxcpyWN^zECN)BG_kxC9q&V!@-<_b%HO|aeRGSd-dLI9|7_hgZgSCEU75w3q(z%Q5T|#%
z|M{5{r_!f+=|s<5#gbI>t1rz~Y?@qs?d(09$+_h&V=`}fcz-G5d{!h9)rZ#Q`x@pO
zPDnPe8vDSCJ${{m7zDG)LR`UoW*o*5<3fx$r|WB@rkr`gr!&2rVJE3=?!b5bTFA?rD0?1J#)@yulNPBu;c~P|Lw038gp`
zS||p~XM~Sl3J+V$^ZJ!Xnb!7R0C=8v@%4to70*OF(06htOFeX5Q8{)gefv9ZL
zoAX-07R6ydqxNc?bdnaXqBWre5nl3%5Z@Dk@mrW68OA^;ngomG+uPa<-uukm0h-v+fxk^ep%)mMHhx%c{=5O<=JcN(i>Z)6-Uz5N3u0*Ggg!x
zTSIikoswhe#OlfQ(u!^Go`X_#)>@UZR;3zJ>#qgh;w9^bS?i{Zb<x9x^^su5XfDoYvP+J9}iG3npQP8QISaZTtXNd+M@h?LVwdKr5p>F|33B9Kw50-V9
z$;-L>yL6JMeJ3oFgBN9>Q}7$CkHp*}P<>T8B`
z_7#O2%8j9O<-B&xs0C$3p7^O_51%b3bP_d%&-S>IBCiD06Q5B6YKetP)I;g%Gv|#!
z7iM#UD^UY@m>j%7aCI~~fjgWqCMM%J*AQ90jpdRp0zVJ~Uv=H0oH~0LO2`DP>@co7
z$q59@0NO6_vNIM5!B(#z4cQ6Op8G}UKX8d`=9}vv=rLslyB?1C|l-2%!89G`2-!*jMFHzt;
z{&VA7((=RJ8}3x!l|z$D?o>DYu=k$R%Eb#EYfye-(60zjSE;R!NE@HprLtZN-8v;>5Q$j@<
z<{XnuSz~a@$pLSX@C{Pv8i@d$Dlh!kBy}5+Y*pGuFGidg=)n0Q;pgNnFdid(pA=yO
zGTSWzo^!;EQZM-Q2PIXAmjWcz-ZX~6;R&cT4XZL;VaLrXM
zzh<1+`*Y>{EEV)WTArk?sh
zd_O6zZ@v)77Q0?ajCW?8rI%VSw!YCY(LU8aotQc9PoD`$$Gd0ZgXy7jQhfO1+Ew4G
z86W;D_T44Z!|C=8m=UWx*?(r(!s9G>;>BA(wS##~RsP3SSBGvKOScAQ`eW(%IjMg*
z>#DdG%y`>AS|fSeq}6S6l}j>9THfoGmb6HfEi=B(dkO&IK>+B1@(Yg+KvmXNGO=RH
zKhtpnP$hMQW;=xRfG`sl?pch^1bde;niH%-ZGr9eP>K}gAI~FJ+-^Wef%RcNT9A+A
z*x#)b*Qg+<=ijT;vm-|%eFY2grkug|n1L%cxL`?)Or!Wvpj$;lJrElD0;EK(dHv`V
z{RP8emx2|eHkb$&?q8=+oEaW|t3Vrb3QnkKz^^Vq$#Z7A&Ljtl14>!ogNJB7wLDS<
zKKnE3qK>uEsBs-0hO_w!2%@G6ADkgy0cRta9!@l-&uS}2m2)cfE>H_4U~X2lXXorY
zKL8gGIPlp)twH%XXQRFbpz;4zrSU}CrQr#%pmP@5=rvAYJ)DZPbO02CrkD6qu8lEfhIednN+JPspy|dv7J(p@T%kd>o
zC|SmpYa`O?v4W
zX+WA3qT@1CMk`Na@-%rx7ltZB2tj$&zr~~4PZa{8j#&6dlpH0c|D2KtN)S?pG0Vi3
zR7K$)&>=55G}6(e(3BnmhK_XJ=0OZ@2o>p}zFleks&{Gn9TYiRQn+9wyB%
zX*K*anm=N=SIjKh_Nj|0Sv1C94v&W?tkNQ{1a|qJ1FfN$}|p*?w@lMr>efc?YiYgZ~90N2p6SVlWX3JOm9we?demY
zY~|87ZC7mR6w+_AJ
zN@4zwv|{MD&<^V%el=?)`(aWqrEmL5oLv%RQiX}SGgswf&T%avM;w7Jq3!mcc82-ht
zB7z%6?fSP5T|G2qyRl-X_8G}}=63BfsTY4*ezSafcy`axS5D2G@V|2E!qH2&5aN5JD9=%D-x3Z
zdew7vrtg>$y)q5$_YAw>mHKxL=VtpNRGI(et^h7U$z|8pZC_
zJ=@O@pI6mr%59PJ29zzqLX
z(!0{F>_DZk5Q~yG@G)1a(Hiq4H9I<>$NoGH;c4^fg434MR^q3RD?G5A`54YfStCR4
zibk6Wbsh=03}b@z8f}oE_*pE(N@>h1SD>po3F4sv{ue+!BtXr%pyw627NqqNHRh=w
zjBPq2rL;g|PWO4x+J*1KYpQZJ*Q>73d19|R6Q(09FjhXc4e_JFrz5K)VB^8!Q)Mzq
zbrC^;2A`>m7#L{KW}~$n!uiN!w2HKm^FX`<1zki*pwv^J0~VlUcEHS)`OLlopT%cw
zFp;ZLXRT-10c%&eS`6sC3kH29JYjA1S!#WDE%}TZE-16r9oOnSp>4PJzTLV4UhO#|
z(q2Hl&n?8qeB@{wAAlbjx%HHQk0$XO4td~+E5HmF1)6#aI^YnrJh0J`BUP^gwRQ_i
zu^6dWqZEr8WzrZ|1zY#{<)THJg=&vK~*9{LuM%K{lR*&U-fH^%d
z{?Qv(Vjo0moWW=Ur;2*kjhK^(o{c-g3}ah}^u-9Uf-pjF&TFJ1AttEJq>!S$bk
zF#MPmzt531T191|TAhI;yid`0z%!uUtHM#$13{$#D)vDuPUtSv>rA_iW1lfRLwzP)pkQJj}8@?Xxe>!BPWBZB&7y7?W<>
z$c+iot+B%-Lf#dLdPs5`#r^rM{j5+70XhG*9
zWiF8DbyU=*BNweb!X0Vrt*vJ0Ajxghkx8zsHqI`%P$+@yd1wpD>rxrn3)rnF{Qa&r
zI1A<0c79f0oRz}WYCtY7_z*~Mx3?`-yU%-Y_BL|ympl4L+(6wX~Sf?z@;mpYlD_*P;O}A
zgwngAIjGioY?ZU7XRwE1JK-bJR02XbeI7;dO4Lbmw1=Dlsr?Qr+tiP1(Nt1nyqdygDGyr;E^YAIyX*;p7R
z8klm}1Hu#v2vlW3wo`HcHupqgfrPhE5rh60X~GO2^hBS`h6SRYfJQqBR}nDHvJsYI
z+0=*2&2Se`o*s>1SSjj6p#s*V?@!^T`SO4gpv+2e&
zQe7auHZbQX9}B(FbgeA))U0C-6clsC?y=fe&cWPUy!_fodP`7R6H3>FvQF2f{TKJY
z)|N88+BxG~cGqApTsK#`G!?qmFk8AdQ@VDhE0pHLGhJtAHuvA|I-6-fmTvdGTRZh4
zd>b~uKX{|>y`|I1TSspW-Pv*^({=Xt=Ki}SOnKGiL*s|8873Ouvi_pXd)LjBSBx36
z%U8ZRa%JS}=g0Sr?VVe)Y9jc~%Go7#nI(1P;?Q^ZPZ_gSOH=%n#bZr#70WJvVf+gd
z!P$y+nTmCj!EcAJhrikXL8nyL{9kIJ4@q|(&u}MZxicB=jKp=PLuV!KY&s^)#?ED8
z=cL#OR3m=`5#j*|DjwOIjVa?5>*UC6{q9Wt?&;E>R^F_qmi9JEOB$zZZ#*?uTr$@1%5d6R`S8I?W=n+q
z?c+=QFCsyHH+QZ)Xk|WJy6T{z;Kv3d(m(d>t-|AHh5`~>s}I&1$rS}$5LE8Gl|}+$
z!aoH630xV(Eed}!5tJ*5XIVH3&np$Xg!};ccrxL&2;@KrETC5Kkf?^hFZ_{Ih%Zv0
zA8uMyUYE-VhYVEWSG_POQ|x6aYV#J!rs2RqL?|H*y-V692bSUi!9!A45rIRE()$)R
zG0?;NaW+GOom4nXN=%SQKf6#&681${P2SUiGDzX79XvRMB_y+&L`z9T4xUJ4FnTC=
zBPeRcqWNc0Sch_dj$$II`aegRe=-$kZABLbFD@l_F>GHx_oZ`V)>N5fS(XwqmKC#>
zwHeD=$+9lJ;aSP@>|9Z0y6QQpXm8rOH|um?IspHu@Pt8fu7qzEuDNf&wCm!o)EddL
zLb9x!*e_W&&>9D&qC;uxA;|x4AGms8vTwF_d!}~#g|bV_E-p)1W^A4tZBQ$a`x~hh
zW9NUHB7uX5ljvK#!zXly3Zi`bPEhr
zW9`?CqP4P$FP<~$}9bnpL=FFP-fpmf9{=r$G
zytCG`aB8t)?si?7Nlab#JohUbG3dZ~!I)?2g3_<^z0a)WoL>uu2s;tWqA-;Fo=V2W
z7{6md7ZrW4$tO~j@o8+92W}VtitQJ3u1DL?t*>lqln-Rii?&+-vA!W5QCa6$|s=C>lesFckKv0m!`g*CN(6`^P@x3RlN6x@a8Cvk
z8wc-7=#uia8o5xJqiWX@z#L%7;j$bPLHLx>Um^{_MFUr?vE+1(gnvVR68NE>yz_ZD
zLN^qWQLK{;Ivr8ig&Ma|Rs0GP;M%q}{$S_3J8y(<8D_Q~0!LD|&v1L|p~=ACBtJ?L
z1@IBSROX!tMbokPY%Gz9CDO^4q}cgSiFK#h@Ef9zY%!n+xx9eyZk(Md^Jcal8g0ci
z&=fUwWi7>DKL5q@V+Up}%QBW_6AkJ0Pf07E%5G|y3VqiOlTvy&mv*&Zu>5UHyX5qJ
z7@c;1r9HK8(mmy!DSY~7lxVGeS(D>dg;ad>XBE?XUNfbJCihGopK(3=QAMWsXvTDO
zbYIp|HfyQOSSnLhvzC<^kgR=gw_a`iUc*}uW)lM%@cLdU5A+trCS($*YTXC@cpkdnb{xf&NgXykN<^-QU{{>i_rmfQ2
z10Py%Y@Xct&eEIK%-RF#mxbD(1e9~c;CttcCZiS
z;-e(#J;;hgMt!}id5h^gD-hkRbRzz+w7hw{>BGA6=BEq((pA*Lx`H_0dT=2{sn~98
zX1=qc&!poM&@e&2$^%?(&N)khy3pIb
zKJ$X($kSeP_C^D!2^K`faOOO-kv`rWmgO99#4{*gBzfGW4{r*|qTyMjK8wkdb2Zv^
zgOeGU&)u4{EK%FH@BDWAOpXG<0yU}^O>pB|`A&hKeCq@hf?_!J
zlTHG;b+2sa5Gqr|*^1?t&ySx^*EUR@k}CH8?U63Yee!4LZ`Hh3o7$7=xpH7~&$pYeH&3m(
z;gvQt&6G5MbUx!gIagVo@=KK)f3o);Zj@o=fhKnw4wFZ#;h44$K*Qu69?f`UD_5mg
zZ_iZjxK)_$=*jHwMd5S6y?C@3I*5V&9DB?_JoY=0XEJvh9{!{Iq+-c7uX%2XD~LJi
z0TU&GeE{AGDB~mqCVZ9703O*$E;!>GbVkA4Yr>beoGtWwS%+hxWQ70ZV-%kU+ID16
z+|x}P>O!o9oGmQJ$>tQ}T~@suX@Q2Ei%?GK*X7dQa%`Oq77#-#F3VKpKHBBa|1y;9
z%6maSE+Uua<*0xn%8T+c6(0@ib}Cmsmal!#rzG!Qs7ccZmA-(fD
znFoH54Oe2e^m{1u{7|Pt;U7sJ2rWYeJxaMLpyG2i(B4FPl+T?hblw6s1l$7nlKfq(
zQ*+@^rJQ+y_PWpgeAP@(bk5r_^TMgQRU3Z3Y;NPzGo3sf^<2FmTSi;2A}>*&-Bxnq
z{C>jy`TerpPcByi9FTVU{V(E1k@}|C4@O83=`Dk3%$-n~NRMAWv5p6M_-sw5&XMsKJ63G-`%$*hfoyn^AK)*?}h*VNM9+TvyLA+eSg
z?_wy|{GnJ-8DCG5_(G!-f_kA*BcDMq4Zy-hMR_0`@>w#4`>P9d2L;Ndm6NgLwgC1j
zM!ADU6kLkmDM6dL9igV?IM`yLlnlOzL@p8$kvz9kbXJc{SGGQ_tl=)97lrZ1i2okZ
zT_ek~_Y5Z1^lLlAuKYX3LH}%jb&(U}B$R?-v=_#`{h;`?tv0irhTpi%Y%hDCAu-*x{{xEd
Bk|F>A
literal 0
HcmV?d00001
diff --git a/playbooks/filter_plugins/aim_reports.py b/playbooks/filter_plugins/aim_reports.py
new file mode 100644
index 0000000..3756c42
--- /dev/null
+++ b/playbooks/filter_plugins/aim_reports.py
@@ -0,0 +1,398 @@
+"""Report normalization owned by the shipped runbooks, not by the Core API.
+
+Only deliberately selected public fields leave these functions. Raw registered
+results, exception text, credentials and command lines are never returned.
+"""
+from __future__ import annotations
+import json
+import math
+import re
+from datetime import datetime, timezone
+from collections.abc import Mapping
+
+
+def _bool(value):
+ if type(value) is bool: return value
+ if str(value).lower() in ('true','yes','1'): return True
+ if str(value).lower() in ('false','no','0','none',''): return False
+ raise ValueError('Report boolean is not a supported literal')
+
+
+def epoch_iso_utc(value):
+ return datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat().replace('+00:00','Z')
+
+def capabilities(value):
+ names = ('is_dc','is_dhcp_server','is_hyperv_host','has_veeam_vbr','has_veeam_vbo',
+ 'has_veeam_em','is_unifi_controller','is_unifi_os_server')
+ return {k:_bool(value.get(k, False)) for k in names}
+
+
+def disks(value, platform):
+ result=[]
+ if platform == 'windows':
+ # community.windows.win_disk_facts returns disks -> partitions -> volumes.
+ # Report attached volumes rather than PowerShell-session/mapped drives.
+ for disk in value or []:
+ for partition in disk.get('partitions', []) or []:
+ drive_letter=partition.get('drive_letter')
+ for volume in partition.get('volumes', []) or []:
+ total=volume.get('size')
+ free=volume.get('size_remaining')
+ good=isinstance(total, (int, float)) and isinstance(free, (int, float)) and total >= 0 and free >= 0
+ if good:
+ total=int(total); free=int(free); used=max(0,total-free)
+ pct=round(used/total*100,2) if total else 0.0
+ else:
+ used=total=free=pct=None
+ native_path=volume.get('path') or volume.get('object_id') or ''
+ if drive_letter:
+ name=f'{drive_letter}:'
+ mount=f'{drive_letter}:\\'
+ else:
+ name=volume.get('label') or native_path or f"volume-disk{disk.get('number','?')}-part{partition.get('number','?')}"
+ mount=native_path or name
+ result.append(dict(name=str(name),mount=str(mount),filesystem_type=volume.get('type'),
+ used_bytes=used,total_bytes=total,available_bytes=free,used_percent=pct,
+ status='available' if good else 'unavailable'))
+ else:
+ for row in value:
+ total,free=int(row.get('size_total',0)),int(row.get('size_available',0))
+ used=max(0,total-free); good=total>0
+ name,mount,fs=row.get('device',row['mount']),row['mount'],row.get('fstype')
+ if not good: used=total=free=pct=None
+ else:
+ used,total,free=(int(x) if x is not None else None for x in (used,total,free))
+ pct=round(used/total*100,2) if total and used is not None else None
+ result.append(dict(name=name,mount=mount,filesystem_type=fs,used_bytes=used,total_bytes=total,
+ available_bytes=free,used_percent=pct,status='available' if good else 'unavailable'))
+ return {'platform':platform,'filesystems':result}
+
+
+SERVICE_ERRORS={
+ 5:('permission_denied','Access was denied when starting the service.'),
+ 1053:('start_timeout','The service did not respond to the start request in time.'),
+ 1058:('service_disabled','The service is disabled.'),
+ 1060:('service_not_found','The service no longer exists.'),
+ 1068:('dependency_failed','A required dependency service could not be started.'),
+ 1069:('logon_failed','The service account could not log on.'),
+}
+
+def service_error(raw):
+ code=raw.get('native_code',raw.get('error_code'))
+ if type(code) is not int: code=None
+ reason,message=SERVICE_ERRORS.get(code,('start_failed','The service start request failed.'))
+ # A bounded fallback for the existing win_service module; no raw text export.
+ text=str(raw.get('msg',''))[:4096].lower()
+ if code is None:
+ signatures=[('access is denied',5),('access denied',5),('dependency',1068),
+ ('disabled',1058),('timed out',1053),('does not exist',1060),('logon failure',1069)]
+ for term,num in signatures:
+ if term in text:
+ reason,message=SERVICE_ERRORS[num]; break
+ return reason,message,code
+
+
+def services(before, attempts, after, include=(), exclude=(), check=False):
+ stopped=sorted(s['name'] for s in before if s.get('state')=='stopped')
+ eligible=sorted(s['name'] for s in before if s.get('state')=='stopped'
+ and s.get('start_mode') in ('auto','delayed') and (not include or s['name'] in include)
+ and s['name'] not in exclude)
+ actual={}
+ for row in attempts:
+ if row.get('skipped'): continue
+ name=row.get('item',{}).get('name')
+ if name in eligible and not check: actual[name]=row
+ states={s['name']:s.get('state','unknown') for s in after}
+ newly=sorted(n for n in stopped if states.get(n)=='started')
+ still=sorted(n for n in stopped if states.get(n)=='stopped')
+ absent=sorted(n for n in stopped if n not in states)
+ failures=[]
+ for name, row in actual.items():
+ if states.get(name)=='started': continue
+ if row.get('failed'):
+ reason,message,code=service_error(row)
+ elif name not in states:
+ reason,message,code='state_unavailable','Post-start service state could not be observed.',None
+ else:
+ reason,message,code='not_running_after_start','The service is not running at the post-start observation.',None
+ failures.append({'name':name,'reason':reason,'message':message,'native_code':code})
+ return dict(mode='check' if check else 'apply',initially_stopped=stopped,eligible=eligible,
+ attempted=sorted(actual),excluded=sorted(set(stopped)-set(eligible)),newly_running=newly,
+ still_stopped=still,unobserved=absent,failed_to_start=failures,
+ started_count=len(set(newly)&set(actual)),failed_count=len(failures),before_count=len(stopped),
+ after_observed=bool(after) or not before)
+
+
+def package_snapshot(raw, platform):
+ """Normalize package_facts (preferred) or legacy textual snapshots."""
+ result={}
+ if isinstance(raw, Mapping):
+ for name, rows in raw.items():
+ if not isinstance(rows, list): continue
+ for row in rows:
+ if not isinstance(row, Mapping): continue
+ arch=str(row.get('arch') or 'unknown')
+ version=str(row.get('version') or '')
+ release=row.get('release')
+ epoch=row.get('epoch')
+ if release not in (None,''):
+ version=f'{version}-{release}'
+ if epoch not in (None,'','0',0):
+ version=f'{epoch}:{version}'
+ result.setdefault((str(name),arch),set()).add(version)
+ return result
+ for line in str(raw).splitlines():
+ columns=line.split('\t')
+ if len(columns)!=4: raise ValueError('Invalid package database record')
+ name,arch,version,status=columns
+ if platform=='debian' and status!='installed': continue
+ result.setdefault((name,arch),set()).add(version)
+ return result
+
+
+def _patch_reboot_fields(reboot_required, rebooted, preexisting, reboot_enabled, delay_minutes, blocked_reason=None):
+ observed = None if reboot_required is None else bool(reboot_required)
+ performed = bool(rebooted)
+ final_required = False if performed else observed
+ deferred = bool(final_required) and not bool(reboot_enabled)
+ return dict(
+ reboot_required=final_required,
+ reboot_required_before=bool(preexisting),
+ reboot_required_after=final_required,
+ reboot_performed=performed,
+ reboot_deferred=deferred,
+ reboot_delay_minutes=int(delay_minutes),
+ blocked_reason=blocked_reason,
+ )
+
+
+def _reboot_reasons(value):
+ rows=value if isinstance(value,list) else []
+ out=[]
+ for row in rows:
+ if not isinstance(row,Mapping): continue
+ source=str(row.get('source','unknown'))[:128]
+ desc=str(row.get('description',''))[:512]
+ out.append(dict(source=source,description=desc))
+ return out[:32]
+
+
+def patch_blocked(platform, check=False, delay_minutes=0, rescan_after_reboot=False, reboot_reasons=()):
+ result=dict(platform=str(platform), mode='check' if check else 'apply',
+ evidence='preflight_reboot_state', complete=False, updates=[], updated_count=0,
+ installed_count=0, removed_count=0, pending=[], failed_updates=[])
+ result.update(_patch_reboot_fields(True, False, True, False, delay_minutes,
+ 'preexisting_reboot_required'))
+ if str(platform) == 'windows':
+ result.update(rescan_after_reboot=bool(rescan_after_reboot), patch_cycles=0,
+ continuation_required=True, remaining_updates_known=False,
+ reboot_reasons_before=_reboot_reasons(reboot_reasons))
+ return result
+
+
+def patch_linux(before, after, platform, check=False, complete=True, reboot_required=None, rebooted=False,
+ preexisting=False, reboot_enabled=True, delay_minutes=0):
+ old,new=package_snapshot(before,platform),package_snapshot(after,platform)
+ updates=[]
+ if not check:
+ for name,arch in sorted(set(old)|set(new)):
+ a,b=old.get((name,arch),set()),new.get((name,arch),set())
+ if a==b: continue
+ updates.append(dict(name=name,identifier=None,architecture=arch,old_versions=sorted(a),new_versions=sorted(b),
+ action='updated' if a and b else 'installed' if b else 'removed',kb=[]))
+ result=dict(platform=platform,mode='check' if check else 'apply',evidence='package_snapshots',complete=bool(complete),
+ updates=updates,updated_count=sum(x['action']=='updated' for x in updates),
+ installed_count=sum(x['action']=='installed' for x in updates),removed_count=sum(x['action']=='removed' for x in updates),
+ pending=[],failed_updates=[])
+ result.update(_patch_reboot_fields(reboot_required, rebooted, preexisting, reboot_enabled, delay_minutes))
+ return result
+
+
+
+def _hresult_u32(code):
+ if type(code) is not int: return None
+ return code & 0xffffffff
+
+
+WINDOWS_UPDATE_FAILURES={
+ 0x80240009:('operation_in_progress','Another conflicting Windows Update operation is in progress.'),
+ 0x80240016:('install_not_allowed','Windows Update could not install this update because another installation was active or a mandatory reboot was pending.'),
+ 0x80240017:('not_applicable','The update is no longer applicable to this host.'),
+ 0x80240019:('exclusive_install_conflict','Windows Update reported an exclusive-install conflict.'),
+ 0x8024001B:('self_update_in_progress','The Windows Update Agent is updating itself.'),
+ 0x8024001F:('no_connection','Windows Update could not complete because its network connection was unavailable.'),
+ 0x80240021:('timeout','Windows Update did not complete before its operation timeout.'),
+}
+
+
+def _windows_failure(code):
+ normalized=_hresult_u32(code)
+ reason,message=WINDOWS_UPDATE_FAILURES.get(normalized,('update_failed','Windows Update failed to install this update.'))
+ return normalized,reason,message
+
+
+def windows_update_result_failed(value):
+ value=value if isinstance(value,Mapping) else {}
+ if value.get('failed') is True: return True
+ if int(value.get('failed_update_count',0) or 0)>0: return True
+ return any(isinstance(row,Mapping) and 'failure_hresult_code' in row for row in value.get('updates',{}).values())
+
+
+def windows_update_block_reason(value):
+ value=value if isinstance(value,Mapping) else {}
+ for row in value.get('updates',{}).values():
+ if isinstance(row,Mapping) and 'failure_hresult_code' in row:
+ return _windows_failure(row.get('failure_hresult_code'))[1]
+ # ansible_failed_result can carry only a generic module failure. Keep this bounded.
+ return 'update_failed'
+
+
+def _windows_pending_from_search(value):
+ value=value if isinstance(value,Mapping) else {}
+ pending=[]
+ for ident,row in value.get('updates',{}).items():
+ if not isinstance(row,Mapping): continue
+ pending.append(dict(name=str(row.get('title',ident)),identifier=str(ident),
+ kb=[str(x) for x in row.get('kb',[])]))
+ return pending
+
+
+def patch_windows_runs(runs, searches=(), check=False, preexisting=False, rebooted=False,
+ reboot_enabled=True, delay_minutes=0, rescan_after_reboot=False,
+ patch_cycles=0, continuation_required=False, remaining_updates_known=False,
+ reboot_deferred=False, reboot_required_after=False, blocked_reason=None,
+ complete_override=True, reboot_reasons_before=()):
+ runs=runs if isinstance(runs,list) else []
+ searches=searches if isinstance(searches,list) else []
+ installed={}; failed={}
+ for entry in runs:
+ if not isinstance(entry,Mapping): continue
+ requested=entry.get('requested',{}) if isinstance(entry.get('requested'),Mapping) else {}
+ value=entry.get('result',{}) if isinstance(entry.get('result'),Mapping) else {}
+ rows=value.get('updates',{}) if isinstance(value.get('updates'),Mapping) else {}
+ if not rows and entry.get('task_failed'):
+ wave=int(entry.get('wave',0) or 0)
+ ident=str(requested.get('identifier') or ('wave-%s' % wave if wave else 'windows-update-wave'))
+ failed[ident]=dict(name=str(requested.get('title') or 'Windows Update patch wave'),identifier=ident,
+ native_code=None,native_code_hex=None,reason='update_failed',
+ message='Windows Update failed before per-update failure details were available.')
+ for ident,row in rows.items():
+ if not isinstance(row,Mapping): continue
+ ident=str(ident); name=str(row.get('title',requested.get('title',ident)))
+ kb=[str(x) for x in row.get('kb',requested.get('kb',[]))]
+ if row.get('installed') is True and not check:
+ installed[ident]=dict(name=name,identifier=ident,architecture=None,old_versions=[],new_versions=[],
+ action='updated',kb=kb)
+ failed.pop(ident,None)
+ if 'failure_hresult_code' in row:
+ code,reason,message=_windows_failure(row.get('failure_hresult_code'))
+ failed[ident]=dict(name=name,identifier=ident,native_code=code,
+ native_code_hex=(f'0x{code:08X}' if code is not None else None),
+ reason=reason,message=message)
+ pending=[]
+ if remaining_updates_known and searches:
+ pending=_windows_pending_from_search(searches[-1])
+ # A final search is authoritative for remaining applicability; do not duplicate
+ # updates that it says are no longer pending.
+ final_required=bool(reboot_required_after)
+ performed=bool(rebooted)
+ deferred=bool(reboot_deferred) or (final_required and not bool(reboot_enabled))
+ complete=bool(complete_override) and not bool(failed)
+ result=dict(platform='windows',mode='check' if check else 'apply',evidence='windows_update_result',
+ complete=complete,updates=list(installed.values()),updated_count=len(installed),installed_count=0,
+ removed_count=0,pending=pending,failed_updates=list(failed.values()),
+ reboot_required=final_required,reboot_required_before=bool(preexisting),
+ reboot_required_after=final_required,reboot_performed=performed,reboot_deferred=deferred,
+ reboot_delay_minutes=int(delay_minutes),blocked_reason=blocked_reason,
+ rescan_after_reboot=bool(rescan_after_reboot),patch_cycles=int(patch_cycles),
+ continuation_required=bool(continuation_required),remaining_updates_known=bool(remaining_updates_known),
+ reboot_reasons_before=_reboot_reasons(reboot_reasons_before))
+ return result
+
+def cleanup(paths, directory, removed, unifi, unifi_result, enabled=False, check=False):
+ deleted=[r.get('item') for r in removed.get('results',[]) if r.get('changed') and not check]
+ mode='check' if check else 'apply' if enabled else 'preview'
+ state='retained'
+ if unifi=='disabled':
+ state='candidate' if not enabled or check else 'removed' if unifi_result.get('changed') else 'unchanged'
+ return dict(mode=mode,directory=directory,candidates=list(paths),removed=deleted,
+ unifi_configuration=state,complete=True)
+
+
+_SECRET=re.compile(r'password|passwd|passphrase|secret|token|credential|private.?key|authorization|community',re.I)
+_COMMAND={'cmd_line','command','command_line','arguments','args','environment','env','passphrase'}
+
+def checkmk_config(value):
+ """Read only the named user config; redact secret/command fields before publication."""
+ import yaml
+ path=value['path']
+ if re.split(r'[\\/]',path)[-1].lower()!='check_mk.user.yml':
+ raise ValueError('Only check_mk.user.yml can be published')
+ content=value.get('content','')
+ if len(content.encode('utf-8'))>512*1024:
+ raise ValueError('Checkmk user configuration exceeds report limit')
+ data=yaml.safe_load(content) if value.get('exists') else {}
+ if data is None: data={}
+ if not isinstance(data,dict): raise ValueError('Checkmk configuration must be a mapping')
+ redactions=[]; seen=set(); budget=[0]
+ def walk(item,path,depth=0):
+ budget[0]+=1
+ if depth>18 or budget[0]>50000: raise ValueError('Configuration structure exceeds limit')
+ if isinstance(item,(dict,list)):
+ if id(item) in seen: raise ValueError('Recursive configuration aliases are unsupported')
+ seen.add(id(item))
+ try:
+ if isinstance(item,list): return [walk(x,path+[str(i)],depth+1) for i,x in enumerate(item)]
+ out={}
+ for key,val in item.items():
+ if not isinstance(key,str): key=str(key)
+ here=path+[key]
+ if _SECRET.search(key) or key.lower() in _COMMAND or (path and path[0].lower()=='mrpe' and key.lower() in ('config','entries')):
+ out[key]='[REDACTED]'; redactions.append('.'.join(here)); continue
+ out[key]=walk(val,here,depth+1)
+ return out
+ finally:seen.remove(id(item))
+ if isinstance(item,str):
+ # Do not expose URL userinfo, inline password assignment, PEM key bodies.
+ if re.search(r'://[^/\s]+:[^/\s]+@|(?:password|passwd|token|secret)\s*[=:]|-----BEGIN .*PRIVATE KEY',item,re.I):
+ redactions.append('.'.join(path)); return '[REDACTED]'
+ # Multiline operational strings are represented by spaces, not terminal controls.
+ return ' '.join(item.splitlines())
+ if item is None or type(item) in (bool,int,float): return item
+ return str(item)
+ return dict(path=path,exists=bool(value.get('exists')),size_bytes=int(value.get('size_bytes',0)),
+ last_write_time_utc=value.get('last_write_time_utc'),sections=walk(data,[]),
+ redacted_paths=redactions,comment_preservation='not_in_structured_output')
+
+
+def checkmk_changes(copies, selected, plugin_update, unifi_result, opposite, mode, check=False):
+ changed_checks=[r['item']['filename'] for r in copies.get('results',[]) if r.get('changed') and isinstance(r.get('item'),Mapping)]
+ removed=[]
+ if opposite.get('changed') and mode in ('os','network'):
+ removed=['check_unifi-controller.sh' if mode=='os' else 'check_unifi-os.sh']
+ changes=[dict(component='check',name=n,action='updated') for n in changed_checks]
+ changes += [dict(component='check',name=n,action='removed') for n in removed]
+ if plugin_update.get('changed'):changes.append(dict(component='section',name='plugins',action='updated'))
+ if unifi_result.get('changed'):changes.append(dict(component='configuration',name='unifi.cfg',action='updated'))
+ return dict(mode='check' if check else 'apply',changed=bool(changes),managed_sections=['plugins'] if plugin_update else [],
+ changes=changes,deployed_checks=[s['filename'] for s in selected],changed_checks=changed_checks,
+ removed_checks=removed if not check else [],unknown_files_policy='untouched_not_enumerated')
+
+
+def checkmk_state(observed, service_rows, change_report, package_changed=False, check=False):
+ return dict(mode='check' if check else 'apply',installed=observed.get('installed'),version=observed.get('version'),
+ version_source=observed.get('version_source','unavailable'),
+ services=[dict(name=s['name'],state=s.get('state','unknown')) for s in service_rows],
+ package_changed=bool(package_changed),configuration_updated=any(c['component'] in ('section','configuration') for c in change_report['changes']),
+ checks_deployed=change_report['deployed_checks'],changed_checks=change_report['changed_checks'],removed_checks=change_report['removed_checks'])
+
+
+class FilterModule:
+ def filters(self):
+ return {'aim_epoch_iso_utc':epoch_iso_utc,'aim_report_capabilities':capabilities,'aim_report_disks':disks,'aim_report_services':services,
+ 'aim_report_patch_linux':patch_linux,'aim_report_patch_windows_runs':patch_windows_runs,
+ 'aim_report_patch_blocked':patch_blocked,
+ 'aim_windows_update_result_failed':windows_update_result_failed,
+ 'aim_windows_update_block_reason':windows_update_block_reason,
+ 'aim_report_cleanup':cleanup,'aim_report_checkmk_config':checkmk_config,
+ 'aim_report_checkmk_changes':checkmk_changes,'aim_report_checkmk_state':checkmk_state}
diff --git a/playbooks/maintenance_backup_event_log.yml b/playbooks/maintenance_backup_event_log.yml
deleted file mode 100644
index 3f4f3ff..0000000
--- a/playbooks/maintenance_backup_event_log.yml
+++ /dev/null
@@ -1,74 +0,0 @@
----
-- name: "Maintenance | Backup system logs"
- hosts: all
- gather_facts: true
-
- vars:
- windows_event_log_backup_dir: 'C:\\Windows\\Temp\\EventLogBackup'
- windows_event_logs_to_backup:
- - Application
- - System
- - Security
-
- linux_log_backup_dir: /var/backups/system-logs
- linux_journal_since: "-24h"
-
- tasks:
- - name: "Windows | Ensure event log backup directory exists"
- when: ansible_facts['os_family'] == 'Windows'
- ansible.windows.win_file:
- path: "{{ windows_event_log_backup_dir }}"
- state: directory
-
- - name: "Windows | Export event logs"
- when: ansible_facts['os_family'] == 'Windows'
- ansible.windows.win_command: >-
- wevtutil epl {{ item }} "{{ windows_event_log_backup_dir }}\\{{ item }}.evtx" /ow:true
- loop: "{{ windows_event_logs_to_backup }}"
- changed_when: true
-
- - name: "Linux | Ensure system log backup directory exists"
- when: ansible_facts['os_family'] != 'Windows'
- become: true
- ansible.builtin.file:
- path: "{{ linux_log_backup_dir }}"
- state: directory
- owner: root
- group: root
- mode: "0750"
-
- - name: "Linux | Check whether systemd journal is available"
- when: ansible_facts['os_family'] != 'Windows'
- ansible.builtin.command:
- cmd: journalctl --version
- register: journalctl_available
- changed_when: false
- failed_when: false
-
- - name: "Linux | Export systemd journal"
- when:
- - ansible_facts['os_family'] != 'Windows'
- - journalctl_available.rc == 0
- become: true
- ansible.builtin.shell: >-
- journalctl --since {{ linux_journal_since | quote }} --no-pager
- > {{ (linux_log_backup_dir ~ '/journal.log') | quote }}
- args:
- executable: /bin/sh
- changed_when: true
-
- - name: "Linux | Backup traditional system logs"
- when:
- - ansible_facts['os_family'] != 'Windows'
- - journalctl_available.rc != 0
- become: true
- ansible.builtin.shell: |
- set -e
- for file in /var/log/syslog /var/log/messages /var/log/auth.log /var/log/secure; do
- if [ -f "$file" ]; then
- cp -p "$file" "{{ linux_log_backup_dir }}/$(basename "$file")"
- fi
- done
- args:
- executable: /bin/sh
- changed_when: true
diff --git a/playbooks/maintenance_export_event_logs.yml b/playbooks/maintenance_export_event_logs.yml
new file mode 100644
index 0000000..f2262dc
--- /dev/null
+++ b/playbooks/maintenance_export_event_logs.yml
@@ -0,0 +1,37 @@
+---
+# PURPOSE: Export Windows event logs
+# DESCRIPTION: Export selected event channels to EVTX files on the target; existing event logs are not cleared.
+# TARGETS: windows
+# INPUTS (omitted values inherit inventory / role defaults):
+# aim_debug [bool]: false
+# event_age_days [int]: 45
+# export_folder [text]: C:\Logs
+# event_log_channels [list]: Application, Security, System, Setup
+# AUTH: existing inventory / Vault credentials; no embedded passwords.
+# EXAMPLE: ansible-playbook -i inventories//hosts.yml
+# playbooks/maintenance_export_event_logs.yml --limit --vault-id @prompt
+- name: Maintenance | Export Windows event logs
+ hosts: windows
+ gather_facts: false
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
+ 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ roles:
+ - role: maintenance_export_event_logs
diff --git a/playbooks/maintenance_patch_os.yml b/playbooks/maintenance_patch_os.yml
index faf9ab7..25a1517 100644
--- a/playbooks/maintenance_patch_os.yml
+++ b/playbooks/maintenance_patch_os.yml
@@ -1,35 +1,70 @@
---
-- name: "Maintenance | Patch operating system"
- hosts: all
+# PURPOSE: Patch operating systems
+# DESCRIPTION: Apply updates on Windows, Debian and RedHat-family systems; optionally reboot when required.
+# TARGETS: linux, windows
+# INPUTS (omitted values inherit inventory / role defaults):
+# aim_debug [bool]: false
+# os_patching_reboot [bool]: true
+# os_patching_windows_categories [list]: SecurityUpdates, CriticalUpdates, UpdateRollups, DefinitionUpdates, Updates
+# os_patching_serial [serial]: 100%
+# os_patching_reboot_timeout [int]: 600
+# os_patching_reboot_delay_minutes [int]: 0
+# os_patching_reboot_message [text]: AIM maintenance: operating system patching requires a reboot.
+# os_patching_rescan_after_reboot [bool]: false (Windows only; continue with a newly discovered patch wave after reboot)
+# AUTH: existing inventory / Vault credentials; no embedded passwords.
+# CHANGES: Updates production operating systems. Optional AIM-initiated reboots notify logged-in users and honor the configured delay.
+# EXAMPLE: ansible-playbook -i inventories//hosts.yml
+# playbooks/maintenance_patch_os.yml --limit --vault-id @prompt
+- name: Maintenance | Patch Linux
+ hosts: linux
gather_facts: true
-
- tasks:
- - name: "Debian | Update package cache and upgrade packages"
- when: ansible_facts['os_family'] == 'Debian'
- ansible.builtin.apt:
- update_cache: true
- upgrade: dist
-
- - name: "RedHat | Upgrade installed packages"
- when: ansible_facts['os_family'] == 'RedHat'
- ansible.builtin.dnf:
- name: '*'
- state: latest
-
- - name: "Windows | Install available updates"
- when: ansible_facts['os_family'] == 'Windows'
- ansible.windows.win_updates:
- category_names:
- - CriticalUpdates
- - SecurityUpdates
- - UpdateRollups
- - Updates
- reboot: false
- register: windows_updates
-
- - name: "Windows | Report reboot requirement"
- when:
- - ansible_facts['os_family'] == 'Windows'
- - windows_updates.reboot_required | default(false)
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
+ 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
ansible.builtin.debug:
- msg: "Windows updates were installed and a reboot is required. No reboot was performed."
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ become: true
+ serial: '{{ os_patching_serial | default(''100%'') }}'
+ roles:
+ - role: maintenance_patch_os
+- name: Maintenance | Patch Windows
+ hosts: windows
+ gather_facts: true
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
+ 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ serial: '{{ os_patching_serial | default(''100%'') }}'
+ roles:
+ - role: maintenance_patch_os
diff --git a/playbooks/maintenance_reboot.yml b/playbooks/maintenance_reboot.yml
deleted file mode 100644
index 303958e..0000000
--- a/playbooks/maintenance_reboot.yml
+++ /dev/null
@@ -1,15 +0,0 @@
----
-- name: "Maintenance | Reboot systems"
- hosts: all
- gather_facts: true
-
- tasks:
- - name: "Windows | Reboot system"
- when: ansible_facts['os_family'] == 'Windows'
- ansible.windows.win_reboot:
- reboot_timeout: 1800
-
- - name: "Linux | Reboot system"
- when: ansible_facts['os_family'] != 'Windows'
- ansible.builtin.reboot:
- reboot_timeout: 1800
diff --git a/playbooks/maintenance_reboot_hosts.yml b/playbooks/maintenance_reboot_hosts.yml
new file mode 100644
index 0000000..3db551b
--- /dev/null
+++ b/playbooks/maintenance_reboot_hosts.yml
@@ -0,0 +1,68 @@
+---
+# PURPOSE: Reboot hosts
+# DESCRIPTION: Reboot selected hosts in batches and wait for management connectivity.
+# TARGETS: linux, windows
+# INPUTS (omitted values inherit inventory / role defaults):
+# aim_debug [bool]: false
+# maintenance_reboot_serial [serial]: 10
+# maintenance_reboot_timeout [int]: 1800
+# maintenance_reboot_message [text]: Reboot initiated by debsansible01.bfmiglabor.lan (Ansible)
+# maintenance_reboot_pre_delay [int]: 0
+# maintenance_reboot_post_delay [int]: 15
+# AUTH: existing inventory / Vault credentials; no embedded passwords.
+# CHANGES: Every selected host will be rebooted. No reboot occurs before final confirmation.
+# EXAMPLE: ansible-playbook -i inventories//hosts.yml
+# playbooks/maintenance_reboot_hosts.yml --limit --vault-id @prompt
+- name: Maintenance | Reboot Linux
+ hosts: linux
+ gather_facts: true
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
+ 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ become: true
+ serial: '{{ maintenance_reboot_serial | default(10) }}'
+ roles:
+ - role: maintenance_reboot_hosts
+- name: Maintenance | Reboot Windows
+ hosts: windows
+ gather_facts: false
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
+ 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ serial: '{{ maintenance_reboot_serial | default(10) }}'
+ roles:
+ - role: maintenance_reboot_hosts
diff --git a/playbooks/maintenance_start_stopped_services.yml b/playbooks/maintenance_start_stopped_services.yml
index 7b75660..5dbc1af 100644
--- a/playbooks/maintenance_start_stopped_services.yml
+++ b/playbooks/maintenance_start_stopped_services.yml
@@ -1,82 +1,37 @@
---
-- name: "Maintenance | Start stopped automatic services"
- hosts: all
- gather_facts: true
-
- tasks:
- - name: "Windows | Start stopped automatic services"
- when: ansible_facts['os_family'] == 'Windows'
- ansible.windows.win_powershell:
- script: |
- $Ansible.Changed = $false
- $started = @()
-
- Get-CimInstance Win32_Service |
- Where-Object {
- $_.StartMode -eq 'Auto' -and
- $_.State -ne 'Running'
- } |
- ForEach-Object {
- try {
- Start-Service -Name $_.Name -ErrorAction Stop
- $started += $_.Name
- $Ansible.Changed = $true
- }
- catch {
- Write-Warning "Could not start service $($_.Name): $($_.Exception.Message)"
- }
- }
-
- $Ansible.Result = @{
- started_services = $started
- }
- register: started_services_windows
-
- - name: "Windows | Show started services"
- when: ansible_facts['os_family'] == 'Windows'
+# PURPOSE: Start stopped automatic services
+# DESCRIPTION: Start eligible stopped services, apply optional include/exclude lists and report partial failures.
+# TARGETS: windows
+# INPUTS (omitted values inherit inventory / role defaults):
+# aim_debug [bool]: false
+# maintenance_service_include [list]: []
+# maintenance_service_exclude [list]: []
+# maintenance_service_fail_on_error [bool]: true
+# AUTH: existing inventory / Vault credentials; no embedded passwords.
+# EXAMPLE: ansible-playbook -i inventories//hosts.yml
+# playbooks/maintenance_start_stopped_services.yml --limit --vault-id @prompt
+- name: Maintenance | Start automatic services
+ hosts: windows
+ gather_facts: false
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
+ 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
ansible.builtin.debug:
- var: started_services_windows.result.started_services
-
- - name: "Linux | Collect service facts"
- when: ansible_facts['os_family'] != 'Windows'
- ansible.builtin.service_facts:
-
- - name: "Linux | Start stopped enabled systemd services"
- when:
- - ansible_facts['os_family'] != 'Windows'
- - ansible_facts['service_mgr'] == 'systemd'
- - item.value.status | default('') == 'enabled'
- - item.value.state | default('') != 'running'
- become: true
- ansible.builtin.systemd:
- name: "{{ item.key }}"
- state: started
- loop: "{{ ansible_facts.services | dict2items }}"
- loop_control:
- label: "{{ item.key }}"
- register: started_services_linux
- failed_when: false
-
- - name: "Linux | Show services that were started"
- when:
- - ansible_facts['os_family'] != 'Windows'
- - ansible_facts['service_mgr'] == 'systemd'
- ansible.builtin.debug:
- msg: >-
- {{
- started_services_linux.results
- | default([])
- | selectattr('changed', 'defined')
- | selectattr('changed')
- | map(attribute='item.key')
- | list
- }}
-
- - name: "Linux | Report unsupported service manager"
- when:
- - ansible_facts['os_family'] != 'Windows'
- - ansible_facts['service_mgr'] != 'systemd'
- ansible.builtin.debug:
- msg: >-
- Automatic stopped-service recovery currently supports systemd hosts only.
- Detected service manager: {{ ansible_facts['service_mgr'] }}
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+ roles:
+ - role: maintenance_start_stopped_services
diff --git a/playbooks/pfsense_apply_baseline.yml b/playbooks/pfsense_apply_baseline.yml
new file mode 100644
index 0000000..11cf711
--- /dev/null
+++ b/playbooks/pfsense_apply_baseline.yml
@@ -0,0 +1,63 @@
+---
+# PURPOSE: Apply bitformer pfSense baseline
+# DESCRIPTION: Apply the supplied pfSense baseline without changing its firewall/VPN policy.
+# TARGETS: pfsense
+# INPUTS (omitted values inherit inventory / role defaults):
+# aim_debug [bool]: false
+# AUTH: existing inventory / Vault credentials; no embedded passwords.
+# CHANGES: Contains the original any-source WAN management rule for ports 22/80/443. The original CA, VPN endpoint and client certificate reference are unchanged; verify them before execution. Requires separately approved pfsensible.core installation.
+# EXAMPLE: ansible-playbook -i inventories//hosts.yml
+# playbooks/pfsense_apply_baseline.yml --limit --vault-id @prompt
+- name: Install pfSense sudo package
+ hosts: pfsense
+ tasks:
+ - name: Apply supplied firewall policy
+ ansible.builtin.import_role:
+ name: pfsense_install_prerequisites
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
+ 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
+- name: Initial pfSense bitformer config
+ hosts: pfsense
+ become: true
+ tasks:
+ - name: Apply supplied firewall policy
+ ansible.builtin.import_role:
+ name: pfsense_apply_baseline
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
+ 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
diff --git a/playbooks/schemas/checkmk_agent_config_v1.yml b/playbooks/schemas/checkmk_agent_config_v1.yml
new file mode 100644
index 0000000..8152db9
--- /dev/null
+++ b/playbooks/schemas/checkmk_agent_config_v1.yml
@@ -0,0 +1,77 @@
+# AIM public operation-data schema. Closed fields except explicitly redacted configuration sections.
+type: object
+properties:
+ mode:
+ type: string
+ maxLength: 128
+ enum:
+ - apply
+ - check
+ changed:
+ type: boolean
+ managed_sections:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+ changes:
+ type: array
+ items:
+ type: object
+ properties:
+ component:
+ type: string
+ maxLength: 128
+ enum:
+ - section
+ - check
+ - configuration
+ name:
+ type: string
+ maxLength: 1024
+ action:
+ type: string
+ maxLength: 128
+ enum:
+ - updated
+ - removed
+ required:
+ - component
+ - name
+ - action
+ additionalProperties: false
+ maxItems: 20000
+ deployed_checks:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+ changed_checks:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+ removed_checks:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+ unknown_files_policy:
+ type: string
+ maxLength: 128
+ enum:
+ - untouched_not_enumerated
+required:
+- mode
+- changed
+- managed_sections
+- changes
+- deployed_checks
+- changed_checks
+- removed_checks
+- unknown_files_policy
+additionalProperties: false
diff --git a/playbooks/schemas/checkmk_agent_state_v1.yml b/playbooks/schemas/checkmk_agent_state_v1.yml
new file mode 100644
index 0000000..88c2ac8
--- /dev/null
+++ b/playbooks/schemas/checkmk_agent_state_v1.yml
@@ -0,0 +1,74 @@
+# AIM public operation-data schema. Closed fields except explicitly redacted configuration sections.
+type: object
+properties:
+ mode:
+ type: string
+ maxLength: 128
+ enum:
+ - apply
+ - check
+ installed:
+ type:
+ - boolean
+ - 'null'
+ version:
+ type:
+ - string
+ - 'null'
+ maxLength: 1024
+ version_source:
+ type: string
+ maxLength: 128
+ enum:
+ - registry
+ - package_database
+ - unavailable
+ services:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ maxLength: 1024
+ state:
+ type: string
+ maxLength: 1024
+ required:
+ - name
+ - state
+ additionalProperties: false
+ maxItems: 20000
+ package_changed: &id001
+ type: boolean
+ configuration_updated: *id001
+ checks_deployed:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+ changed_checks:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+ removed_checks:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+required:
+- mode
+- installed
+- version
+- version_source
+- services
+- package_changed
+- configuration_updated
+- checks_deployed
+- changed_checks
+- removed_checks
+additionalProperties: false
diff --git a/playbooks/schemas/checkmk_user_config_v1.yml b/playbooks/schemas/checkmk_user_config_v1.yml
new file mode 100644
index 0000000..9dc0991
--- /dev/null
+++ b/playbooks/schemas/checkmk_user_config_v1.yml
@@ -0,0 +1,40 @@
+# AIM public operation-data schema. Closed fields except explicitly redacted configuration sections.
+type: object
+properties:
+ path:
+ type: string
+ maxLength: 1024
+ exists:
+ type: boolean
+ size_bytes:
+ type: integer
+ minimum: 0
+ last_write_time_utc:
+ type:
+ - string
+ - 'null'
+ maxLength: 1024
+ sections:
+ type: object
+ properties: {}
+ additionalProperties: true
+ redacted_paths:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+ comment_preservation:
+ type: string
+ maxLength: 128
+ enum:
+ - not_in_structured_output
+required:
+- path
+- exists
+- size_bytes
+- last_write_time_utc
+- sections
+- redacted_paths
+- comment_preservation
+additionalProperties: false
diff --git a/playbooks/schemas/event_log_export_v1.yml b/playbooks/schemas/event_log_export_v1.yml
new file mode 100644
index 0000000..4f75569
--- /dev/null
+++ b/playbooks/schemas/event_log_export_v1.yml
@@ -0,0 +1,30 @@
+# AIM public operation-data schema. Closed fields except explicitly redacted configuration sections.
+type: object
+properties:
+ mode:
+ type: string
+ maxLength: 128
+ enum:
+ - apply
+ - check
+ days:
+ type: integer
+ minimum: 0
+ files:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+ channels:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+required:
+- mode
+- days
+- files
+- channels
+additionalProperties: false
diff --git a/playbooks/schemas/filesystem_usage_v1.yml b/playbooks/schemas/filesystem_usage_v1.yml
new file mode 100644
index 0000000..9f2be0a
--- /dev/null
+++ b/playbooks/schemas/filesystem_usage_v1.yml
@@ -0,0 +1,66 @@
+# AIM public operation-data schema. Closed fields except explicitly redacted configuration sections.
+type: object
+properties:
+ platform:
+ type: string
+ maxLength: 128
+ enum:
+ - windows
+ - linux
+ filesystems:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ maxLength: 1024
+ mount:
+ type: string
+ maxLength: 1024
+ filesystem_type:
+ type:
+ - string
+ - 'null'
+ maxLength: 1024
+ used_bytes:
+ type:
+ - integer
+ - 'null'
+ minimum: 0
+ total_bytes:
+ type:
+ - integer
+ - 'null'
+ minimum: 0
+ available_bytes:
+ type:
+ - integer
+ - 'null'
+ minimum: 0
+ used_percent:
+ type:
+ - number
+ - 'null'
+ minimum: 0
+ status:
+ type: string
+ maxLength: 128
+ enum:
+ - available
+ - unavailable
+ required:
+ - name
+ - mount
+ - filesystem_type
+ - used_bytes
+ - total_bytes
+ - available_bytes
+ - used_percent
+ - status
+ additionalProperties: false
+ maxItems: 20000
+required:
+- platform
+- filesystems
+additionalProperties: false
diff --git a/playbooks/schemas/host_capabilities_v1.yml b/playbooks/schemas/host_capabilities_v1.yml
new file mode 100644
index 0000000..f0b28b4
--- /dev/null
+++ b/playbooks/schemas/host_capabilities_v1.yml
@@ -0,0 +1,22 @@
+# AIM public operation-data schema. Closed fields except explicitly redacted configuration sections.
+type: object
+properties:
+ is_dc: &id001
+ type: boolean
+ is_dhcp_server: *id001
+ is_hyperv_host: *id001
+ has_veeam_vbr: *id001
+ has_veeam_vbo: *id001
+ has_veeam_em: *id001
+ is_unifi_controller: *id001
+ is_unifi_os_server: *id001
+required:
+- is_dc
+- is_dhcp_server
+- is_hyperv_host
+- has_veeam_vbr
+- has_veeam_vbo
+- has_veeam_em
+- is_unifi_controller
+- is_unifi_os_server
+additionalProperties: false
diff --git a/playbooks/schemas/managed_cleanup_preview_v1.yml b/playbooks/schemas/managed_cleanup_preview_v1.yml
new file mode 100644
index 0000000..9227385
--- /dev/null
+++ b/playbooks/schemas/managed_cleanup_preview_v1.yml
@@ -0,0 +1,43 @@
+# AIM public operation-data schema. Closed fields except explicitly redacted configuration sections.
+type: object
+properties:
+ mode:
+ type: string
+ maxLength: 128
+ enum:
+ - preview
+ - apply
+ - check
+ directory:
+ type: string
+ maxLength: 1024
+ candidates:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+ removed:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+ unifi_configuration:
+ type: string
+ maxLength: 128
+ enum:
+ - retained
+ - candidate
+ - removed
+ - unchanged
+ complete:
+ type: boolean
+required:
+- mode
+- directory
+- candidates
+- removed
+- unifi_configuration
+- complete
+additionalProperties: false
diff --git a/playbooks/schemas/patch_summary_v1.yml b/playbooks/schemas/patch_summary_v1.yml
new file mode 100644
index 0000000..8d79f24
--- /dev/null
+++ b/playbooks/schemas/patch_summary_v1.yml
@@ -0,0 +1,190 @@
+# AIM public operation-data schema. Closed fields; no raw package-manager/module output.
+type: object
+properties:
+ platform:
+ type: string
+ maxLength: 128
+ enum:
+ - windows
+ - debian
+ - redhat
+ mode:
+ type: string
+ maxLength: 128
+ enum:
+ - apply
+ - check
+ evidence:
+ type: string
+ maxLength: 128
+ enum:
+ - package_snapshots
+ - windows_update_result
+ - preflight_reboot_state
+ complete:
+ type: boolean
+ updates:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ maxLength: 1024
+ identifier:
+ type: [string, 'null']
+ maxLength: 1024
+ architecture:
+ type: [string, 'null']
+ maxLength: 1024
+ old_versions:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 100
+ new_versions:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 100
+ action:
+ type: string
+ maxLength: 128
+ enum: [updated, installed, removed]
+ kb:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 100
+ required: [name, identifier, architecture, old_versions, new_versions, action, kb]
+ additionalProperties: false
+ maxItems: 20000
+ updated_count:
+ type: integer
+ minimum: 0
+ installed_count:
+ type: integer
+ minimum: 0
+ removed_count:
+ type: integer
+ minimum: 0
+ pending:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ maxLength: 1024
+ identifier:
+ type: [string, 'null']
+ maxLength: 1024
+ kb:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 100
+ required: [name, identifier, kb]
+ additionalProperties: false
+ maxItems: 20000
+ failed_updates:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ maxLength: 1024
+ identifier:
+ type: [string, 'null']
+ maxLength: 1024
+ native_code:
+ type: [integer, 'null']
+ minimum: 0
+ native_code_hex:
+ type: [string, 'null']
+ maxLength: 32
+ reason:
+ type: string
+ maxLength: 128
+ enum: [update_failed, operation_in_progress, install_not_allowed, not_applicable, exclusive_install_conflict, self_update_in_progress, no_connection, timeout]
+ message:
+ type: string
+ maxLength: 512
+ required: [name, identifier, native_code, native_code_hex, reason, message]
+ additionalProperties: false
+ maxItems: 20000
+ reboot_required:
+ type: [boolean, 'null']
+ description: Final observed/predicted pending reboot state after this run.
+ reboot_required_before:
+ type: boolean
+ description: A pending reboot was detected before patching began.
+ reboot_reasons_before:
+ type: array
+ description: Windows only. Native reboot sources reported by ansible.windows.win_reboot_info before patching.
+ items:
+ type: object
+ properties:
+ source:
+ type: string
+ maxLength: 128
+ description:
+ type: string
+ maxLength: 512
+ required: [source, description]
+ additionalProperties: false
+ maxItems: 32
+ reboot_required_after:
+ type: [boolean, 'null']
+ description: Final pending reboot state; false after an AIM-performed successful reboot.
+ reboot_performed:
+ type: boolean
+ reboot_deferred:
+ type: boolean
+ description: A reboot remains required because automatic reboot was disabled.
+ reboot_delay_minutes:
+ type: integer
+ minimum: 0
+ maximum: 1440
+ blocked_reason:
+ type: [string, 'null']
+ maxLength: 128
+ enum: [preexisting_reboot_required, update_failed, operation_in_progress, install_not_allowed, not_applicable, exclusive_install_conflict, self_update_in_progress, no_connection, timeout, cycle_limit_reached, null]
+ rescan_after_reboot:
+ type: boolean
+ description: Windows only. True permits a newly discovered patch wave after an AIM-performed reboot.
+ patch_cycles:
+ type: integer
+ minimum: 0
+ maximum: 12
+ description: Windows discovery/install cycles entered by this run.
+ continuation_required:
+ type: boolean
+ description: Another operator-approved patch run is recommended or required to continue patching.
+ remaining_updates_known:
+ type: boolean
+ description: True only when the report contains an authoritative post-wave discovery in pending.
+required:
+ - platform
+ - mode
+ - evidence
+ - complete
+ - updates
+ - updated_count
+ - installed_count
+ - removed_count
+ - pending
+ - failed_updates
+ - reboot_required
+ - reboot_required_before
+ - reboot_required_after
+ - reboot_performed
+ - reboot_deferred
+ - reboot_delay_minutes
+ - blocked_reason
+additionalProperties: false
diff --git a/playbooks/schemas/service_start_summary_v1.yml b/playbooks/schemas/service_start_summary_v1.yml
new file mode 100644
index 0000000..5ab2b1b
--- /dev/null
+++ b/playbooks/schemas/service_start_summary_v1.yml
@@ -0,0 +1,113 @@
+# AIM public operation-data schema. Closed fields except explicitly redacted configuration sections.
+type: object
+properties:
+ mode:
+ type: string
+ maxLength: 128
+ enum:
+ - apply
+ - check
+ initially_stopped:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+ eligible:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+ attempted:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+ excluded:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+ newly_running:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+ still_stopped:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+ unobserved:
+ type: array
+ items:
+ type: string
+ maxLength: 1024
+ maxItems: 20000
+ failed_to_start:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ maxLength: 1024
+ reason:
+ type: string
+ maxLength: 128
+ enum:
+ - dependency_failed
+ - permission_denied
+ - service_disabled
+ - start_timeout
+ - service_not_found
+ - logon_failed
+ - start_failed
+ - not_running_after_start
+ - state_unavailable
+ message:
+ type: string
+ maxLength: 1024
+ native_code:
+ type:
+ - integer
+ - 'null'
+ minimum: 0
+ required:
+ - name
+ - reason
+ - message
+ - native_code
+ additionalProperties: false
+ maxItems: 20000
+ started_count:
+ type: integer
+ minimum: 0
+ failed_count:
+ type: integer
+ minimum: 0
+ before_count:
+ type: integer
+ minimum: 0
+ after_observed:
+ type: boolean
+required:
+- mode
+- initially_stopped
+- eligible
+- attempted
+- excluded
+- newly_running
+- still_stopped
+- unobserved
+- failed_to_start
+- started_count
+- failed_count
+- before_count
+- after_observed
+additionalProperties: false
diff --git a/playbooks/sophos_apply_baseline.yml b/playbooks/sophos_apply_baseline.yml
new file mode 100644
index 0000000..2701f74
--- /dev/null
+++ b/playbooks/sophos_apply_baseline.yml
@@ -0,0 +1,63 @@
+---
+# PURPOSE: Apply bitformer Sophos baseline
+# DESCRIPTION: Apply the supplied bitformer firewall baseline. Existing policy values and action order are preserved.
+# TARGETS: sophosxgs
+# INPUTS (omitted values inherit inventory / role defaults):
+# aim_debug [bool]: false
+# AUTH: existing inventory / Vault credentials; no embedded passwords.
+# CHANGES: Changes firewall management access, objects and rules, including rule removal and a final drop rule. Policy values have NOT been redesigned.
+# EXAMPLE: ansible-playbook -i inventories//hosts.yml
+# playbooks/sophos_apply_baseline.yml --limit --vault-id @prompt --ask-pass
+- name: Grundkonfiguration der Sophos-Firewall nach bitformer Standard
+ hosts: sophosxgs
+ gather_facts: false
+ any_errors_fatal: false
+ vars:
+ network_hosts:
+ - name: bf_spn_network
+ network: 10.242.176.0
+ subnetmask: 255.255.255.0
+ - name: rfc_1918_a
+ network: 10.0.0.0
+ subnetmask: 255.0.0.0
+ - name: rfc_1918_b
+ network: 172.16.0.0
+ subnetmask: 255.240.0.0
+ - name: rfc_1918_c
+ network: 192.168.0.0
+ subnetmask: 255.255.0.0
+ - name: rfc_5735
+ network: 169.254.0.0
+ subnetmask: 255.255.0.0
+ firewall_rules_to_remove:
+ - '[example] Traffic to Internal Zones'
+ - '[example] Traffic to WAN'
+ - '[example] Traffic to DMZ'
+ wireless_networks_to_remove:
+ - GuestAP
+ - Sophos
+ tasks:
+ - name: Apply supplied firewall policy
+ ansible.builtin.import_role:
+ name: sophos_apply_baseline
+ tasks_from: main
+ pre_tasks:
+ - name: AIM | Validate diagnostics option
+ ansible.builtin.assert:
+ that:
+ - (aim_debug | default(false)) is boolean or (aim_debug | default(false) | string | lower) in ['true',
+ 'false']
+ fail_msg: aim_debug must be a YAML/JSON boolean.
+ quiet: true
+ - name: AIM | Reject mixed platform membership
+ ansible.builtin.assert:
+ that:
+ - (group_names | intersect(['linux', 'windows', 'sophosxgs', 'pfsense']) | length) <= 1
+ fail_msg: This host belongs to incompatible platform groups. Use globally unique subgroups.
+ quiet: true
+ - name: AIM | Execution context
+ ansible.builtin.debug:
+ msg:
+ host: '{{ inventory_hostname }}'
+ diagnostics: Enabled; secret values are never included by this task.
+ when: aim_debug | default(false) | bool
diff --git a/requirements-controller.txt b/requirements-controller.txt
new file mode 100644
index 0000000..74648cc
--- /dev/null
+++ b/requirements-controller.txt
@@ -0,0 +1,2 @@
+# AIM canonical Ansible runtime. Separate from the AIM/add-on environments.
+ansible-core==2.19.11
diff --git a/requirements.yml b/requirements.yml
new file mode 100644
index 0000000..3eaf21b
--- /dev/null
+++ b/requirements.yml
@@ -0,0 +1,10 @@
+# requirements.yml
+collections:
+ - name: ansible.netcommon
+ - name: ansible.windows
+ version: ">=3.8.0,<4.0.0" # win_reboot_info baseline; compatible with ansible-core 2.19.11
+ - name: ansible.posix
+ - name: community.windows
+ - name: community.general
+ - name: sophos.sophos_firewall
+ - name: pfsensible.core
diff --git a/roles/checkmk_agent/README.md b/roles/checkmk_agent/README.md
new file mode 100644
index 0000000..fdb400d
--- /dev/null
+++ b/roles/checkmk_agent/README.md
@@ -0,0 +1,23 @@
+# checkmk_agent
+
+Operator defaults are intentionally low-precedence; inventory and explicit run options may override them.
+
+```yaml
+---
+
+checkmk_linux_tmp_path: /tmp
+checkmk_windows_tmp_path: C:\Windows\Temp
+checkmk_deb_filename: check-mk-agent.deb
+checkmk_rpm_filename: check-mk-agent.rpm
+checkmk_msi_filename: check_mk_agent.msi
+checkmk_agent_files_dir: '{{ lookup(''ansible.builtin.env'', ''AIM_CHECKMK_AGENT_FILES_DIR'') | default(role_path
+ ~ ''/files'', true) }}'
+```
+
+## Structured result integration
+
+Current reporting behavior and field semantics are specified in
+[scripts/docs/OPERATION_RESULTS.md](../../scripts/docs/OPERATION_RESULTS.md).
+The calling catalog playbook owns publication; helper roles do not implicitly export
+arbitrary facts, module results or debug data. Existing defaults above retain their
+precedence. See the current validation/sanity documents before using the new candidate.
diff --git a/roles/checkmk_agent/defaults/main.yml b/roles/checkmk_agent/defaults/main.yml
index 5b80a83..ddca2bb 100644
--- a/roles/checkmk_agent/defaults/main.yml
+++ b/roles/checkmk_agent/defaults/main.yml
@@ -1,9 +1,9 @@
---
-checkmk_linux_tmp_path: "/tmp"
-checkmk_windows_tmp_path: "C:\\Windows\\Temp"
-checkmk_deb_filename: "check-mk-agent.deb"
-checkmk_rpm_filename: "check-mk-agent.rpm"
-checkmk_msi_filename: "check_mk_agent.msi"
-checkmk_linux_socket_name: "check-mk-agent.socket"
-checkmk_linux_service_name: "check-mk-agent"
-checkmk_windows_service_name: "CheckMkService"
+
+checkmk_linux_tmp_path: /tmp
+checkmk_windows_tmp_path: C:\Windows\Temp
+checkmk_deb_filename: check-mk-agent.deb
+checkmk_rpm_filename: check-mk-agent.rpm
+checkmk_msi_filename: check_mk_agent.msi
+checkmk_agent_files_dir: '{{ lookup(''ansible.builtin.env'', ''AIM_CHECKMK_AGENT_FILES_DIR'') | default(role_path
+ ~ ''/files'', true) }}'
diff --git a/roles/checkmk_agent/files/README.md b/roles/checkmk_agent/files/README.md
index e8b926b..4e1b11f 100644
--- a/roles/checkmk_agent/files/README.md
+++ b/roles/checkmk_agent/files/README.md
@@ -1,5 +1,3 @@
-Place the Checkmk agent packages here:
+# Staged agent packages
-- `check-mk-agent.deb`
-- `check-mk-agent.rpm`
-- `check_mk_agent.msi`
+AIM Maintenance populates `check_mk_agent.msi`, `check-mk-agent.deb` and `check-mk-agent.rpm` here by default. Binaries are not included or replaced by this source bundle. Existing package files must be retained.
diff --git a/roles/checkmk_agent/handlers/main.yml b/roles/checkmk_agent/handlers/main.yml
deleted file mode 100644
index fd756b1..0000000
--- a/roles/checkmk_agent/handlers/main.yml
+++ /dev/null
@@ -1,55 +0,0 @@
----
-- name: "Enable & start Checkmk socket (if present)"
- listen: "checkmk | linux | agent-ensure-running"
- when: ansible_facts['os_family'] != "Windows"
- ansible.builtin.systemd:
- name: "{{ checkmk_linux_socket_name }}"
- enabled: true
- state: started
- daemon_reload: true
- failed_when: false
-
-- name: "Enable & start Checkmk service (fallback/if present)"
- listen: "checkmk | linux | agent-ensure-running"
- when: ansible_facts['os_family'] != "Windows"
- ansible.builtin.systemd:
- name: "{{ checkmk_linux_service_name }}"
- enabled: true
- state: started
- daemon_reload: true
- failed_when: false
-
-- name: "Windows | Detect Checkmk service name"
- listen: "checkmk | windows | agent-ensure-running"
- when: ansible_facts['os_family'] == "Windows"
- ansible.windows.win_powershell:
- script: |
- $candidates = @('CheckMkService','Check_MK_Agent')
- foreach ($n in $candidates) {
- $svc = Get-Service -Name $n -ErrorAction SilentlyContinue
- if ($svc) { $svc.Name; break }
- }
- register: cmk_detect
- failed_when: false
-
-- name: "Windows | Set detected service name"
- listen: "checkmk | windows | agent-ensure-running"
- when: ansible_facts['os_family'] == "Windows"
- ansible.builtin.set_fact:
- cmk_service_name: >-
- {{
- (cmk_detect.output[0] | default('') | trim)
- if (cmk_detect.output | default([]) | length > 0)
- else (checkmk_windows_service_name | default('CheckMkService'))
- }}
-
-- name: "Windows | Ensure Checkmk agent service running"
- listen: "checkmk | windows | agent-ensure-running"
- when:
- - ansible_facts['os_family'] == "Windows"
- - (cmk_service_name | default('')) | length > 0
- ansible.windows.win_service:
- name: "{{ cmk_service_name }}"
- start_mode: auto
- state: started
- failed_when: false
diff --git a/roles/checkmk_agent/meta/main.yml b/roles/checkmk_agent/meta/main.yml
deleted file mode 100644
index a8400e1..0000000
--- a/roles/checkmk_agent/meta/main.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-galaxy_info:
- role_name: checkmk_agent
- description: Install Checkmk agent from local packages
- min_ansible_version: "2.18"
-dependencies: []
diff --git a/roles/checkmk_agent/tasks/debian.yml b/roles/checkmk_agent/tasks/debian.yml
deleted file mode 100644
index 5cfe19e..0000000
--- a/roles/checkmk_agent/tasks/debian.yml
+++ /dev/null
@@ -1,11 +0,0 @@
----
-- name: "Debian | Copy Checkmk agent package"
- ansible.builtin.copy:
- src: "{{ checkmk_deb_filename }}"
- dest: "{{ checkmk_linux_tmp_path }}/{{ checkmk_deb_filename }}"
- mode: "0644"
-
-- name: "Debian | Install Checkmk agent"
- ansible.builtin.apt:
- deb: "{{ checkmk_linux_tmp_path }}/{{ checkmk_deb_filename }}"
- notify: "checkmk | linux | agent-ensure-running"
diff --git a/roles/checkmk_agent/tasks/linux_debian.yml b/roles/checkmk_agent/tasks/linux_debian.yml
new file mode 100644
index 0000000..8876591
--- /dev/null
+++ b/roles/checkmk_agent/tasks/linux_debian.yml
@@ -0,0 +1,25 @@
+---
+
+- name: Checkmk | Verify staged package on controller
+ ansible.builtin.stat:
+ path: '{{ checkmk_agent_files_dir }}/{{ checkmk_deb_filename }}'
+ delegate_to: localhost
+ become: false
+ register: _checkmk_package
+- name: Checkmk | Require staged package
+ ansible.builtin.assert:
+ that:
+ - _checkmk_package.stat.isreg | default(false)
+ - _checkmk_package.stat.size | default(0) | int > 0
+ fail_msg: Required agent package is absent/empty. Use AIM Maintenance to download packages first.
+ quiet: true
+- name: Debian | Copy Checkmk agent package from role files
+ ansible.builtin.copy:
+ src: '{{ checkmk_agent_files_dir }}/{{ checkmk_deb_filename }}'
+ dest: '{{ checkmk_linux_tmp_path }}/{{ checkmk_deb_filename }}'
+ mode: '0644'
+- name: Debian | Install Checkmk agent from local .deb
+ ansible.builtin.apt:
+ deb: '{{ checkmk_linux_tmp_path }}/{{ checkmk_deb_filename }}'
+ state: present
+ register: _checkmk_package_install
diff --git a/roles/checkmk_agent/tasks/linux_redhat.yml b/roles/checkmk_agent/tasks/linux_redhat.yml
new file mode 100644
index 0000000..f03c7e4
--- /dev/null
+++ b/roles/checkmk_agent/tasks/linux_redhat.yml
@@ -0,0 +1,25 @@
+---
+
+- name: Checkmk | Verify staged package on controller
+ ansible.builtin.stat:
+ path: '{{ checkmk_agent_files_dir }}/{{ checkmk_rpm_filename }}'
+ delegate_to: localhost
+ become: false
+ register: _checkmk_package
+- name: Checkmk | Require staged package
+ ansible.builtin.assert:
+ that:
+ - _checkmk_package.stat.isreg | default(false)
+ - _checkmk_package.stat.size | default(0) | int > 0
+ fail_msg: Required agent package is absent/empty. Use AIM Maintenance to download packages first.
+ quiet: true
+- name: RedHat | Copy Checkmk agent package from role files
+ ansible.builtin.copy:
+ src: '{{ checkmk_agent_files_dir }}/{{ checkmk_rpm_filename }}'
+ dest: '{{ checkmk_linux_tmp_path }}/{{ checkmk_rpm_filename }}'
+ mode: '0644'
+- name: RedHat | Install Checkmk agent from local .rpm
+ ansible.builtin.dnf:
+ name: '{{ checkmk_linux_tmp_path }}/{{ checkmk_rpm_filename }}'
+ state: present
+ register: _checkmk_package_install
diff --git a/roles/checkmk_agent/tasks/main.yml b/roles/checkmk_agent/tasks/main.yml
index cd5e8fc..26b616f 100644
--- a/roles/checkmk_agent/tasks/main.yml
+++ b/roles/checkmk_agent/tasks/main.yml
@@ -1,12 +1,17 @@
---
-- name: Include Debian installation
- ansible.builtin.include_tasks: debian.yml
- when: ansible_facts['os_family'] == 'Debian'
-- name: Include RedHat installation
- ansible.builtin.include_tasks: redhat.yml
- when: ansible_facts['os_family'] == 'RedHat'
-
-- name: Include Windows installation
+- name: Checkmk | Supported installer
+ ansible.builtin.assert:
+ that:
+ - ansible_facts.os_family in ['Debian','RedHat','Windows']
+ fail_msg: Unsupported installer platform. No Python/package-manager bootstrap is performed.
+ quiet: true
+- name: Include Debian tasks
+ ansible.builtin.include_tasks: linux_debian.yml
+ when: ansible_facts['os_family'] == "Debian"
+- name: Include RedHat tasks
+ ansible.builtin.include_tasks: linux_redhat.yml
+ when: ansible_facts['os_family'] == "RedHat"
+- name: Include Windows tasks
ansible.builtin.include_tasks: windows.yml
- when: ansible_facts['os_family'] == 'Windows'
+ when: ansible_facts['os_family'] == "Windows"
diff --git a/roles/checkmk_agent/tasks/redhat.yml b/roles/checkmk_agent/tasks/redhat.yml
deleted file mode 100644
index bba0eb4..0000000
--- a/roles/checkmk_agent/tasks/redhat.yml
+++ /dev/null
@@ -1,12 +0,0 @@
----
-- name: "RedHat | Copy Checkmk agent package"
- ansible.builtin.copy:
- src: "{{ checkmk_rpm_filename }}"
- dest: "{{ checkmk_linux_tmp_path }}/{{ checkmk_rpm_filename }}"
- mode: "0644"
-
-- name: "RedHat | Install Checkmk agent"
- ansible.builtin.package:
- name: "{{ checkmk_linux_tmp_path }}/{{ checkmk_rpm_filename }}"
- state: present
- notify: "checkmk | linux | agent-ensure-running"
diff --git a/roles/checkmk_agent/tasks/windows.yml b/roles/checkmk_agent/tasks/windows.yml
index ee72015..f408c2d 100644
--- a/roles/checkmk_agent/tasks/windows.yml
+++ b/roles/checkmk_agent/tasks/windows.yml
@@ -1,11 +1,28 @@
---
-- name: "Windows | Copy Checkmk agent MSI"
- ansible.windows.win_copy:
- src: "{{ checkmk_msi_filename }}"
- dest: "{{ checkmk_windows_tmp_path }}\\{{ checkmk_msi_filename }}"
-- name: "Windows | Install Checkmk agent from local MSI"
+- name: Checkmk | Verify staged package on controller
+ ansible.builtin.stat:
+ path: '{{ checkmk_agent_files_dir }}/{{ checkmk_msi_filename }}'
+ delegate_to: localhost
+ become: false
+ register: _checkmk_package
+- name: Checkmk | Require staged package
+ ansible.builtin.assert:
+ that:
+ - _checkmk_package.stat.isreg | default(false)
+ - _checkmk_package.stat.size | default(0) | int > 0
+ fail_msg: Required agent package is absent/empty. Use AIM Maintenance to download packages first.
+ quiet: true
+- name: Windows | Ensure temp dir exists
+ ansible.windows.win_file:
+ path: '{{ checkmk_windows_tmp_path }}'
+ state: directory
+- name: Windows | Copy Checkmk agent MSI from role files
+ ansible.windows.win_copy:
+ src: '{{ checkmk_agent_files_dir }}/{{ checkmk_msi_filename }}'
+ dest: '{{ checkmk_windows_tmp_path }}\{{ checkmk_msi_filename }}'
+- name: Windows | Install Checkmk agent from local MSI
ansible.windows.win_package:
- path: "{{ checkmk_windows_tmp_path }}\\{{ checkmk_msi_filename }}"
+ path: '{{ checkmk_windows_tmp_path }}\{{ checkmk_msi_filename }}'
state: present
- notify: "checkmk | windows | agent-ensure-running"
+ register: _checkmk_package_install
diff --git a/roles/checkmk_agent_config/meta/main.yml b/roles/checkmk_agent_config/meta/main.yml
deleted file mode 100644
index cbb0724..0000000
--- a/roles/checkmk_agent_config/meta/main.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-galaxy_info:
- role_name: checkmk_agent_config
- description: Render Windows Checkmk agent configuration from detected server roles
- min_ansible_version: "2.18"
-dependencies: []
diff --git a/roles/checkmk_agent_config/tasks/main.yml b/roles/checkmk_agent_config/tasks/main.yml
deleted file mode 100644
index ba50c40..0000000
--- a/roles/checkmk_agent_config/tasks/main.yml
+++ /dev/null
@@ -1,20 +0,0 @@
----
-- name: "Debug detected role flags"
- when: ansible_facts['os_family'] == "Windows"
- ansible.builtin.debug:
- msg:
- dc: "{{ is_dc | default(false) }}"
- dhcp: "{{ is_dhcp_server | default(false) }}"
- vbr: "{{ has_veeam_vbr | default(false) }}"
- vbo: "{{ has_veeam_vbo | default(false) }}"
- em: "{{ has_veeam_em | default(false) }}"
- hv: "{{ is_hyperv_host | default(false) }}"
- timeout_updates: "{{ checkmk_windows_updates_timeout }}"
-
-- name: "Windows | Render check_mk.user.yml"
- when: ansible_facts['os_family'] == "Windows"
- ansible.windows.win_template:
- src: "windows_check_mk.user.yml.j2"
- dest: "{{ checkmk_windows_user_cfg }}"
- backup: true
- notify: "checkmk | windows | agent-ensure-running"
diff --git a/roles/checkmk_agent_config/templates/windows_check_mk.user.yml.j2 b/roles/checkmk_agent_config/templates/windows_check_mk.user.yml.j2
deleted file mode 100644
index e90d70c..0000000
--- a/roles/checkmk_agent_config/templates/windows_check_mk.user.yml.j2
+++ /dev/null
@@ -1,130 +0,0 @@
-# Managed by Ansible (checkmk_agent_config)
-# Windows Checkmk Agent user configuration built from detected roles.
-
-# $CUSTOM_PLUGINS_PATH$ -> ProgramData\checkmk\agent\plugins
-# $BUILTIN_PLUGINS_PATH$ -> Program Files (x86)\checkmk\service\plugins
-# $CUSTOM_AGENT_PATH$ -> ProgramData\checkmk\agent
-# $CUSTOM_LOCAL_PATH$ -> ProgramData\checkmk\agent\local
-
-global:
- _only_from:
- _realtime:
- enabled: yes
- timeout: 90
- port: 6559
- encrypted: no
- passphrase: this is my password
- run:
- - mem
- - df
- - winperf_processor
-
-winperf:
- counters:
- - MSExchangeTransport Queues: msx_queues
-
-_logfiles:
- enabled: no
-
-fileinfo:
- path: []
-
-logwatch:
- logfile: []
-
-plugins:
- execution:
- # --- Built-in defaults ---
- - pattern: '$BUILTIN_PLUGINS_PATH$\windows_updates.vbs'
- run: yes
- async: yes
- timeout: {{ checkmk_windows_updates_timeout }}
- cache_age: {{ checkmk_windows_updates_cache }}
- retry_count: 0
-
- - pattern: '$BUILTIN_PLUGINS_PATH$\mk_inventory.vbs'
- run: yes
- async: yes
- timeout: {{ checkmk_mk_inventory_timeout }}
- cache_age: 3600
-
- - pattern: '$BUILTIN_PLUGINS_PATH$\mssql.vbs'
- run: yes
- timeout: {{ checkmk_plugins_default_timeout }}
-
-{% if has_veeam_vbr | default(false) %}
- # --- Veeam Backup & Replication ---
- - pattern: '$BUILTIN_PLUGINS_PATH$\veeam_backup_status.ps1'
- run: yes
- async: yes
- timeout: {{ checkmk_plugins_default_timeout }}
- cache_age: {{ checkmk_plugins_default_cache }}
-{% endif %}
-
-{% if is_dc | default(false) %}
- # --- Domain Controller ---
- - pattern: '$BUILTIN_PLUGINS_PATH$\ad_replication.bat'
- run: yes
- timeout: {{ checkmk_plugins_default_timeout }}
-{% endif %}
-
-{% if is_dhcp_server | default(false) %}
- # --- DHCP Server ---
- - pattern: '$BUILTIN_PLUGINS_PATH$\win_dhcp_pools.bat'
- run: yes
- timeout: {{ checkmk_plugins_default_timeout }}
-{% endif %}
-
- # --- Generic patterns / precedence ---
- - pattern: '$CUSTOM_PLUGINS_PATH$\*.ps1'
- run: yes
- async: yes
- timeout: {{ checkmk_plugins_default_timeout }}
- cache_age: {{ checkmk_plugins_default_cache }}
-
- - pattern: '$CUSTOM_PLUGINS_PATH$\*.*'
- run: yes
- timeout: {{ checkmk_plugins_default_timeout }}
-
- - pattern: '$BUILTIN_PLUGINS_PATH$\*.*'
- run: no
- timeout: {{ checkmk_plugins_default_timeout }}
-
- - pattern: '*'
- run: no
-
-{% for p in (checkmk_extra_plugin_patterns | default([])) %}
- - pattern: '{{ p.pattern }}'
-{% if p.run is defined %}
- run: {{ p.run | bool }}
-{% endif %}
-{% if p.async is defined %}
- async: {{ p.async | bool }}
-{% endif %}
-{% if p.timeout is defined %}
- timeout: {{ p.timeout }}
-{% endif %}
-{% if p.cache_age is defined %}
- cache_age: {{ p.cache_age }}
-{% endif %}
-{% endfor %}
-
-local:
- _execution:
- - pattern: '*.*'
- run: yes
-{% for l in (checkmk_extra_local_patterns | default([])) %}
- - pattern: '{{ l.pattern }}'
-{% if l.run is defined %}
- run: {{ l.run | bool }}
-{% endif %}
-{% if l.async is defined %}
- async: {{ l.async | bool }}
-{% endif %}
-{% if l.timeout is defined %}
- timeout: {{ l.timeout }}
-{% endif %}
-{% endfor %}
-
-mrpe:
- config: []
diff --git a/roles/checkmk_cleanup_scripts/README.md b/roles/checkmk_cleanup_scripts/README.md
new file mode 100644
index 0000000..ffe4b94
--- /dev/null
+++ b/roles/checkmk_cleanup_scripts/README.md
@@ -0,0 +1,36 @@
+# checkmk_cleanup_scripts
+
+Operator defaults are intentionally low-precedence; inventory and explicit run options may override them.
+
+```yaml
+---
+
+checkmk_linux_plugin_dir: /usr/lib/check_mk_agent/plugins
+checkmk_linux_local_dir: /usr/lib/check_mk_agent/local
+checkmk_linux_config_dir: /etc/check_mk
+checkmk_windows_plugin_dir: C:\ProgramData\checkmk\agent\plugins
+checkmk_windows_builtin_plugin_dir: C:\Program Files (x86)\checkmk\service\plugins
+checkmk_windows_local_dir: C:\ProgramData\checkmk\agent\local
+checkmk_monitoring_scripts_dir: '{{ lookup(''ansible.builtin.env'', ''AIM_CHECKMK_MONITORING_SCRIPTS_DIR'')
+ | default(''/etc/checkmk_monitoring_scripts'', true) }}'
+checkmk_windows_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Windows'
+checkmk_linux_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Linux'
+checkmk_unifi_mode: auto
+want_linux_check_certificate: false
+want_windows_citrix: false
+want_windows_surebackup: false
+want_windows_backup: false
+want_windows_nsp_mailqueue: false
+want_windows_certificate: false
+want_windows_veeam_cloud_connect: false
+want_windows_veeam_backup: false
+checkmk_cleanup_enabled: false
+```
+
+## Structured result integration
+
+Current reporting behavior and field semantics are specified in
+[scripts/docs/OPERATION_RESULTS.md](../../scripts/docs/OPERATION_RESULTS.md).
+The calling catalog playbook owns publication; helper roles do not implicitly export
+arbitrary facts, module results or debug data. Existing defaults above retain their
+precedence. See the current validation/sanity documents before using the new candidate.
diff --git a/roles/checkmk_cleanup_scripts/defaults/main.yml b/roles/checkmk_cleanup_scripts/defaults/main.yml
new file mode 100644
index 0000000..eae3d43
--- /dev/null
+++ b/roles/checkmk_cleanup_scripts/defaults/main.yml
@@ -0,0 +1,22 @@
+---
+
+checkmk_linux_plugin_dir: /usr/lib/check_mk_agent/plugins
+checkmk_linux_local_dir: /usr/lib/check_mk_agent/local
+checkmk_linux_config_dir: /etc/check_mk
+checkmk_windows_plugin_dir: C:\ProgramData\checkmk\agent\plugins
+checkmk_windows_builtin_plugin_dir: C:\Program Files (x86)\checkmk\service\plugins
+checkmk_windows_local_dir: C:\ProgramData\checkmk\agent\local
+checkmk_monitoring_scripts_dir: '{{ lookup(''ansible.builtin.env'', ''AIM_CHECKMK_MONITORING_SCRIPTS_DIR'')
+ | default(''/etc/checkmk_monitoring_scripts'', true) }}'
+checkmk_windows_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Windows'
+checkmk_linux_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Linux'
+checkmk_unifi_mode: auto
+want_linux_check_certificate: false
+want_windows_citrix: false
+want_windows_surebackup: false
+want_windows_backup: false
+want_windows_nsp_mailqueue: false
+want_windows_certificate: false
+want_windows_veeam_cloud_connect: false
+want_windows_veeam_backup: false
+checkmk_cleanup_enabled: false
diff --git a/roles/checkmk_cleanup_scripts/tasks/linux.yml b/roles/checkmk_cleanup_scripts/tasks/linux.yml
new file mode 100644
index 0000000..298cbbf
--- /dev/null
+++ b/roles/checkmk_cleanup_scripts/tasks/linux.yml
@@ -0,0 +1,15 @@
+
+ - name: Cleanup | Remove obsolete managed local check
+ ansible.builtin.file:
+ path: '{{ checkmk_linux_local_dir }}/{{ item }}'
+ state: absent
+ loop: '{{ _checkmk_remove_paths }}'
+ register: _aim_cleanup_files
+ - name: Cleanup | Remove UniFi configuration only when UniFi is disabled
+ ansible.builtin.file:
+ path: '{{ checkmk_linux_config_dir }}/unifi.cfg'
+ state: absent
+ when: _checkmk_unifi_effective == 'disabled'
+ diff: false
+ no_log: true
+ register: _aim_cleanup_unifi
diff --git a/roles/checkmk_cleanup_scripts/tasks/main.yml b/roles/checkmk_cleanup_scripts/tasks/main.yml
new file mode 100644
index 0000000..dab72a7
--- /dev/null
+++ b/roles/checkmk_cleanup_scripts/tasks/main.yml
@@ -0,0 +1,42 @@
+
+ - name: Cleanup | Validate opt-in
+ ansible.builtin.assert:
+ that:
+ - (checkmk_cleanup_enabled) is boolean or (checkmk_cleanup_enabled | string | lower) in ['true',
+ 'false']
+ fail_msg: checkmk_cleanup_enabled must be boolean.
+ quiet: true
+ - name: Cleanup | Build obsolete paths
+ ansible.builtin.set_fact:
+ _checkmk_remove_paths: "{{ _checkmk_obsolete_scripts | map(attribute='filename') | list }}"
+ - name: Cleanup | Candidate files
+ ansible.builtin.debug:
+ msg:
+ directory: "{{ checkmk_windows_local_dir if ansible_facts.os_family == 'Windows' else checkmk_linux_local_dir
+ }}"
+ files: '{{ _checkmk_remove_paths }}'
+ unifi_config: "{{ 'remove only when disabled' if ansible_facts.os_family != 'Windows' and _checkmk_unifi_effective
+ == 'disabled' else 'retained' }}"
+ mode: "{{ 'DELETE APPROVED' if checkmk_cleanup_enabled | bool else 'PREVIEW ONLY' }}"
+ - name: Cleanup | linux
+ ansible.builtin.include_tasks: linux.yml
+ when:
+ - checkmk_cleanup_enabled | bool
+ - ansible_facts.os_family != 'Windows'
+ - name: Cleanup | windows
+ ansible.builtin.include_tasks: windows.yml
+ when:
+ - checkmk_cleanup_enabled | bool
+ - ansible_facts.os_family == 'Windows'
+ - name: AIM | Publish operation result
+ ansible.builtin.set_stats:
+ per_host: true
+ aggregate: false
+ data:
+ aim_output:
+ protocol: aim_output_v1
+ schema: managed_cleanup_preview_v1
+ data: "{{ _checkmk_remove_paths | aim_report_cleanup(checkmk_windows_local_dir if ansible_facts.os_family
+ == 'Windows' else checkmk_linux_local_dir, _aim_cleanup_files | default({}), _checkmk_unifi_effective
+ if ansible_facts.os_family != 'Windows' else 'not_applicable', _aim_cleanup_unifi | default({}), checkmk_cleanup_enabled
+ | bool, ansible_check_mode) }}"
diff --git a/roles/checkmk_cleanup_scripts/tasks/windows.yml b/roles/checkmk_cleanup_scripts/tasks/windows.yml
new file mode 100644
index 0000000..743e3d3
--- /dev/null
+++ b/roles/checkmk_cleanup_scripts/tasks/windows.yml
@@ -0,0 +1,36 @@
+---
+- name: Cleanup | Remove obsolete managed local check or legacy local plugin copy
+ ansible.windows.win_file:
+ path: '{{ checkmk_windows_local_dir }}\{{ item }}'
+ state: absent
+ loop: '{{ _checkmk_remove_paths }}'
+ register: _aim_cleanup_local_files
+
+- name: Cleanup | Remove obsolete managed custom plugin
+ ansible.windows.win_file:
+ path: '{{ checkmk_windows_plugin_dir }}\{{ item }}'
+ state: absent
+ loop: >-
+ {{ _checkmk_obsolete_scripts
+ | selectattr('destination', 'defined')
+ | selectattr('destination', 'equalto', 'custom_plugin')
+ | map(attribute='filename') | list }}
+ register: _aim_cleanup_custom_plugin_files
+
+- name: Cleanup | Remove obsolete known legacy built-in plugin copy
+ ansible.windows.win_file:
+ path: '{{ checkmk_windows_builtin_plugin_dir }}\{{ item }}'
+ state: absent
+ loop: >-
+ {{ _checkmk_remove_paths
+ | select('in', ['veeam_o365_status.ps1', 'veeam_backup_status.ps1'])
+ | list }}
+ register: _aim_cleanup_builtin_files
+
+- name: Cleanup | Combine Windows cleanup results
+ ansible.builtin.set_fact:
+ _aim_cleanup_files:
+ results: >-
+ {{ (_aim_cleanup_local_files.results | default([]))
+ + (_aim_cleanup_custom_plugin_files.results | default([]))
+ + (_aim_cleanup_builtin_files.results | default([])) }}
diff --git a/roles/checkmk_configure_agent/README.md b/roles/checkmk_configure_agent/README.md
new file mode 100644
index 0000000..d787cda
--- /dev/null
+++ b/roles/checkmk_configure_agent/README.md
@@ -0,0 +1,33 @@
+# checkmk_configure_agent
+
+On Windows, AIM owns only the top-level `plugins:` section of `check_mk.user.yml`.
+The role preserves all other top-level sections and comments, including `global`,
+`winperf`, `fileinfo`, `logwatch`, `local`, and `mrpe`.
+
+The first line is an AIM ownership notice. The managed `plugins:` section also gets
+its own ownership comment so operators can see the exact management boundary.
+
+Operator defaults are intentionally low-precedence; inventory and explicit run options may override them.
+
+```yaml
+---
+checkmk_windows_user_cfg: C:\ProgramData\checkmk\agent\check_mk.user.yml
+checkmk_windows_updates_timeout: 3600
+checkmk_windows_updates_cache: 43200
+checkmk_mk_inventory_timeout: 120
+checkmk_plugins_default_timeout: 120
+checkmk_plugins_default_cache: 600
+checkmk_extra_plugin_patterns: []
+```
+
+`checkmk_extra_plugin_patterns` entries are inserted before AIM's standard plugin
+rules. AIM does not manage `local:` or `mrpe:` from this role; existing host-specific
+configuration in those sections is left intact.
+
+## Structured result integration
+
+Current reporting behavior and field semantics are specified in
+[scripts/docs/OPERATION_RESULTS.md](../../scripts/docs/OPERATION_RESULTS.md).
+The calling catalog playbook owns publication; helper roles do not implicitly export
+arbitrary facts, module results or debug data. Existing defaults above retain their
+precedence. See the current validation/sanity documents before using the new candidate.
diff --git a/roles/checkmk_agent_config/defaults/main.yml b/roles/checkmk_configure_agent/defaults/main.yml
similarity index 50%
rename from roles/checkmk_agent_config/defaults/main.yml
rename to roles/checkmk_configure_agent/defaults/main.yml
index 409e815..444a5c5 100644
--- a/roles/checkmk_agent_config/defaults/main.yml
+++ b/roles/checkmk_configure_agent/defaults/main.yml
@@ -1,9 +1,10 @@
---
-checkmk_windows_user_cfg: "C:\\ProgramData\\checkmk\\agent\\check_mk.user.yml"
+# AIM owns only the top-level plugins section in the Windows user configuration.
+# All other sections and comments must remain untouched.
+checkmk_windows_user_cfg: C:\ProgramData\checkmk\agent\check_mk.user.yml
checkmk_windows_updates_timeout: 3600
checkmk_windows_updates_cache: 43200
checkmk_mk_inventory_timeout: 120
checkmk_plugins_default_timeout: 120
checkmk_plugins_default_cache: 600
checkmk_extra_plugin_patterns: []
-checkmk_extra_local_patterns: []
diff --git a/roles/checkmk_configure_agent/tasks/main.yml b/roles/checkmk_configure_agent/tasks/main.yml
new file mode 100644
index 0000000..83bf037
--- /dev/null
+++ b/roles/checkmk_configure_agent/tasks/main.yml
@@ -0,0 +1,196 @@
+---
+
+- name: Checkmk | Validate Windows plugin execution settings
+ ansible.builtin.assert:
+ that:
+ - checkmk_extra_plugin_patterns is sequence
+ - checkmk_extra_plugin_patterns is not string
+ - checkmk_windows_updates_timeout | int >= 0
+ - checkmk_windows_updates_cache | int >= 0
+ - checkmk_mk_inventory_timeout | int >= 0
+ - checkmk_plugins_default_timeout | int >= 0
+ - checkmk_plugins_default_cache | int >= 0
+ fail_msg: Invalid Checkmk plugin execution setting type or negative timeout.
+ quiet: true
+ when: ansible_facts.os_family == 'Windows'
+
+- name: Checkmk | Validate custom plugin rule fields
+ ansible.builtin.assert:
+ that:
+ - item is mapping
+ - item.pattern is defined
+ - item.pattern is string
+ - item.run is not defined or item.run is boolean
+ - item['async'] is not defined or item['async'] is boolean
+ - item.timeout is not defined or item.timeout | int >= 0
+ - item.cache_age is not defined or item.cache_age | int >= 0
+ - item.keys() | difference(['pattern','run','async','timeout','cache_age','retry_count']) | length == 0
+ fail_msg: Custom plugin rules require pattern and supported execution fields.
+ quiet: true
+ loop: '{{ checkmk_extra_plugin_patterns }}'
+ loop_control:
+ label: custom plugin execution rule
+ no_log: true
+ when: ansible_facts.os_family == 'Windows'
+
+- name: Windows | Render AIM-managed Checkmk plugins section
+ ansible.windows.win_template:
+ src: windows_plugins_section.yml.j2
+ dest: '{{ checkmk_windows_user_cfg }}.aim-plugins.tmp'
+ when: ansible_facts.os_family == 'Windows'
+ diff: false
+ changed_when: false
+
+- name: Windows | Replace only Checkmk plugins section
+ ansible.windows.win_shell: |
+ $ErrorActionPreference = 'Stop'
+
+ $configPath = '{{ checkmk_windows_user_cfg }}'
+ $fragmentPath = '{{ checkmk_windows_user_cfg }}.aim-plugins.tmp'
+ $header = '# Managed by Ansible (checkmk_configure_agent). Only AIM-marked sections are modified; all other content is preserved.'
+ $legacyHeader = '# Managed by Ansible (checkmk_configure_agent).'
+ $managedMarkerPrefix = '# AIM managed section: plugins (checkmk_configure_agent).'
+
+ if (-not (Test-Path -LiteralPath $fragmentPath)) {
+ throw "AIM Checkmk plugins fragment is missing: $fragmentPath"
+ }
+
+ $fragment = [System.IO.File]::ReadAllText($fragmentPath)
+ $fragment = $fragment.TrimEnd([char[]]"`r`n")
+
+ if (Test-Path -LiteralPath $configPath) {
+ $original = [System.IO.File]::ReadAllText($configPath)
+ }
+ else {
+ $original = ''
+ }
+
+ if ($original.Contains("`r`n")) {
+ $newline = "`r`n"
+ }
+ else {
+ $newline = "`n"
+ }
+
+ $hadFinalNewline = $original.EndsWith("`r`n") -or $original.EndsWith("`n") -or $original.EndsWith("`r")
+ $lines = @()
+ if ($original.Length -gt 0) {
+ $lines = @([regex]::Split($original, "`r`n|`n|`r"))
+ if ($hadFinalNewline -and $lines.Count -gt 0 -and $lines[$lines.Count - 1] -eq '') {
+ if ($lines.Count -eq 1) {
+ $lines = @()
+ }
+ else {
+ $lines = @($lines[0..($lines.Count - 2)])
+ }
+ }
+ }
+
+ # Keep the AIM ownership notice as line 1 without discarding the previous first line.
+ if ($lines.Count -eq 0) {
+ $lines = @($header)
+ }
+ elseif ($lines[0] -eq $legacyHeader -or $lines[0].StartsWith('# Managed by Ansible (checkmk_configure_agent).')) {
+ $lines[0] = $header
+ }
+ else {
+ $lines = @($header) + $lines
+ }
+
+ $pluginIndex = -1
+ for ($i = 0; $i -lt $lines.Count; $i++) {
+ if ($lines[$i] -match '^plugins\s*:\s*(?:#.*)?$') {
+ $pluginIndex = $i
+ break
+ }
+ }
+
+ $fragmentLines = @([regex]::Split($fragment, "`r`n|`n|`r"))
+
+ if ($pluginIndex -ge 0) {
+ $replaceStart = $pluginIndex
+ if ($pluginIndex -gt 0 -and $lines[$pluginIndex - 1].StartsWith($managedMarkerPrefix)) {
+ $replaceStart = $pluginIndex - 1
+ }
+
+ $nextTopLevelKey = $lines.Count
+ for ($i = $pluginIndex + 1; $i -lt $lines.Count; $i++) {
+ if ($lines[$i] -match '^[A-Za-z_][A-Za-z0-9_.-]*\s*:') {
+ $nextTopLevelKey = $i
+ break
+ }
+ }
+
+ # Preserve blank lines and top-level comments immediately before the next untouched section.
+ $replaceEnd = $nextTopLevelKey
+ while ($replaceEnd -gt ($pluginIndex + 1)) {
+ $candidate = $lines[$replaceEnd - 1]
+ if ([string]::IsNullOrWhiteSpace($candidate) -or $candidate.StartsWith('#')) {
+ $replaceEnd--
+ }
+ else {
+ break
+ }
+ }
+
+ $before = @()
+ if ($replaceStart -gt 0) {
+ $before = @($lines[0..($replaceStart - 1)])
+ }
+ $after = @()
+ if ($replaceEnd -lt $lines.Count) {
+ $after = @($lines[$replaceEnd..($lines.Count - 1)])
+ }
+ $lines = @($before + $fragmentLines + $after)
+ }
+ else {
+ if ($lines.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($lines[$lines.Count - 1])) {
+ $lines += ''
+ }
+ $lines += $fragmentLines
+ }
+
+ $updated = [string]::Join($newline, $lines)
+ if ($hadFinalNewline -or $original.Length -eq 0) {
+ $updated += $newline
+ }
+
+ if ($updated -ne $original) {
+ $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
+ [System.IO.File]::WriteAllText($configPath, $updated, $utf8NoBom)
+ Write-Output 'AIM_CHANGED=true'
+ }
+ else {
+ Write-Output 'AIM_CHANGED=false'
+ }
+ register: _checkmk_plugins_update
+ changed_when: "'AIM_CHANGED=true' in _checkmk_plugins_update.stdout"
+ when: ansible_facts.os_family == 'Windows'
+ notify: checkmk | windows | configuration-changed
+ diff: false
+
+- name: Windows | Normalize ACL on Checkmk user configuration
+ ansible.builtin.include_role:
+ name: checkmk_windows_acl
+ vars:
+ checkmk_windows_acl_paths:
+ - '{{ checkmk_windows_user_cfg }}'
+ when: ansible_facts.os_family == 'Windows'
+
+- name: Windows | Remove temporary Checkmk plugins fragment
+ ansible.windows.win_file:
+ path: '{{ checkmk_windows_user_cfg }}.aim-plugins.tmp'
+ state: absent
+ when: ansible_facts.os_family == 'Windows'
+ changed_when: false
+
+- name: Checkmk | Configuration summary
+ ansible.builtin.debug:
+ msg:
+ destination: '{{ checkmk_windows_user_cfg }}'
+ managed_section: plugins
+ extra_plugin_rules: '{{ checkmk_extra_plugin_patterns | length }}'
+ other_sections: preserved
+ when:
+ - ansible_facts.os_family == 'Windows'
+ - aim_debug | default(false) | bool
diff --git a/roles/checkmk_configure_agent/templates/windows_plugins_section.yml.j2 b/roles/checkmk_configure_agent/templates/windows_plugins_section.yml.j2
new file mode 100644
index 0000000..ac9044f
--- /dev/null
+++ b/roles/checkmk_configure_agent/templates/windows_plugins_section.yml.j2
@@ -0,0 +1,72 @@
+# AIM managed section: plugins (checkmk_configure_agent). Content under plugins: may be replaced by AIM.
+plugins:
+ execution:
+{% if checkmk_extra_plugin_patterns | length %}
+{{ checkmk_extra_plugin_patterns | to_nice_yaml(indent=2, sort_keys=false) | indent(4, true) }}
+{% endif %}
+ - pattern: '$BUILTIN_PLUGINS_PATH$\windows_updates.vbs'
+ run: true
+ async: true
+ timeout: {{ checkmk_windows_updates_timeout | int }}
+ cache_age: {{ checkmk_windows_updates_cache | int }}
+ retry_count: 0
+ - pattern: '$BUILTIN_PLUGINS_PATH$\mk_inventory.vbs'
+ run: true
+ async: true
+ timeout: {{ checkmk_mk_inventory_timeout | int }}
+ cache_age: 3600
+ - pattern: '$BUILTIN_PLUGINS_PATH$\mssql.vbs'
+ run: true
+ timeout: {{ checkmk_plugins_default_timeout | int }}
+{% if has_veeam_vbo | default(false) %}
+ - pattern: '$CUSTOM_PLUGINS_PATH$\veeam_o365_status.ps1'
+ run: true
+ async: true
+ timeout: {{ checkmk_plugins_default_timeout | int }}
+ cache_age: {{ checkmk_plugins_default_cache | int }}
+{% endif %}
+{% if want_windows_citrix | default(false) %}
+ - pattern: '$CUSTOM_PLUGINS_PATH$\citrix_sessions_customized.ps1'
+ run: true
+ async: true
+ timeout: {{ checkmk_plugins_default_timeout | int }}
+ cache_age: {{ checkmk_plugins_default_cache | int }}
+{% endif %}
+{% if want_windows_veeam_backup | default(false) %}
+ - pattern: '$CUSTOM_PLUGINS_PATH$\veeam_backup_status.ps1'
+ run: true
+ async: true
+ timeout: {{ checkmk_plugins_default_timeout | int }}
+ cache_age: {{ checkmk_plugins_default_cache | int }}
+{% endif %}
+{% if is_dc | default(false) %}
+ - pattern: '$BUILTIN_PLUGINS_PATH$\ad_replication.bat'
+ run: true
+ timeout: {{ checkmk_plugins_default_timeout | int }}
+{% endif %}
+{% if is_dhcp_server | default(false) %}
+ - pattern: '$BUILTIN_PLUGINS_PATH$\win_dhcp_pools.bat'
+ run: true
+ timeout: {{ checkmk_plugins_default_timeout | int }}
+{% endif %}
+{% if is_hyperv_host | default(false) %}
+ - pattern: '$BUILTIN_PLUGINS_PATH$\hyperv_vms.ps1'
+ run: true
+ timeout: {{ checkmk_plugins_default_timeout | int }}
+ - pattern: '$BUILTIN_PLUGINS_PATH$\hyperv_vms_guestinfos.ps1'
+ run: true
+ timeout: {{ checkmk_plugins_default_timeout | int }}
+{% endif %}
+ - pattern: '$CUSTOM_PLUGINS_PATH$\*.ps1'
+ run: true
+ async: true
+ timeout: {{ checkmk_plugins_default_timeout | int }}
+ cache_age: {{ checkmk_plugins_default_cache | int }}
+ - pattern: '$CUSTOM_PLUGINS_PATH$\*.*'
+ run: true
+ timeout: {{ checkmk_plugins_default_timeout | int }}
+ - pattern: '$BUILTIN_PLUGINS_PATH$\*.*'
+ run: false
+ timeout: {{ checkmk_plugins_default_timeout | int }}
+ - pattern: '*'
+ run: false
diff --git a/roles/checkmk_deploy_scripts/README.md b/roles/checkmk_deploy_scripts/README.md
new file mode 100644
index 0000000..cc75d5b
--- /dev/null
+++ b/roles/checkmk_deploy_scripts/README.md
@@ -0,0 +1,48 @@
+# checkmk_deploy_scripts
+
+Operator defaults are intentionally low-precedence; inventory and explicit run options may override them.
+
+```yaml
+---
+# unifi.cfg is role-managed and replaced, mode 0600. Password comes from Vault.
+# An absent/CHANGEME password fails before deployment; no credentials are logged.
+checkmk_linux_plugin_dir: /usr/lib/check_mk_agent/plugins
+checkmk_linux_local_dir: /usr/lib/check_mk_agent/local
+checkmk_linux_config_dir: /etc/check_mk
+checkmk_windows_plugin_dir: C:\ProgramData\checkmk\agent\plugins
+checkmk_windows_builtin_plugin_dir: C:\Program Files (x86)\checkmk\service\plugins
+checkmk_windows_local_dir: C:\ProgramData\checkmk\agent\local
+checkmk_monitoring_scripts_dir: '{{ lookup(''ansible.builtin.env'', ''AIM_CHECKMK_MONITORING_SCRIPTS_DIR'')
+ | default(''/etc/checkmk_monitoring_scripts'', true) }}'
+checkmk_windows_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Windows'
+checkmk_linux_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Linux'
+checkmk_unifi_mode: auto
+want_linux_check_certificate: false
+want_windows_citrix: false
+want_windows_surebackup: false
+want_windows_backup: false
+want_windows_nsp_mailqueue: false
+want_windows_certificate: false
+want_windows_veeam_cloud_connect: false
+want_windows_veeam_backup: false
+checkmk_unifi_username: bf-monitoring
+checkmk_unifi_password: '{{ vault_checkmk_unifi_password | default('''') }}'
+checkmk_unifi_baseurl: '{{ ''https://127.0.0.1:11443'' if _checkmk_unifi_effective == ''os'' else ''https://127.0.0.1:8443''
+ }}'
+checkmk_unifi_curl_options: ' --insecure --tlsv1.2'
+checkmk_unifi_status_provisioning: 1
+checkmk_unifi_status_upgrading: 1
+checkmk_unifi_status_upgradable: 0
+checkmk_unifi_status_heartbeat_missed: 1
+checkmk_unifi_status_noautobackup: 0
+```
+
+`unifi.cfg` is generated from the supplied schema, POSIX-shell quoted, mode `0600`, and protected with `no_log` and `diff: false`. Use a Vault reference for the password. `CHANGEME` and empty passwords fail before deployment. The active UniFi mode replaces the alternative local check. Other obsolete scripts are removed only by explicit cleanup.
+
+## Structured result integration
+
+Current reporting behavior and field semantics are specified in
+[scripts/docs/OPERATION_RESULTS.md](../../scripts/docs/OPERATION_RESULTS.md).
+The calling catalog playbook owns publication; helper roles do not implicitly export
+arbitrary facts, module results or debug data. Existing defaults above retain their
+precedence. See the current validation/sanity documents before using the new candidate.
diff --git a/roles/checkmk_deploy_scripts/defaults/main.yml b/roles/checkmk_deploy_scripts/defaults/main.yml
new file mode 100644
index 0000000..49e11da
--- /dev/null
+++ b/roles/checkmk_deploy_scripts/defaults/main.yml
@@ -0,0 +1,32 @@
+---
+# unifi.cfg is role-managed and replaced, mode 0600. Password comes from Vault.
+# An absent/CHANGEME password fails before deployment; no credentials are logged.
+checkmk_linux_plugin_dir: /usr/lib/check_mk_agent/plugins
+checkmk_linux_local_dir: /usr/lib/check_mk_agent/local
+checkmk_linux_config_dir: /etc/check_mk
+checkmk_windows_plugin_dir: C:\ProgramData\checkmk\agent\plugins
+checkmk_windows_builtin_plugin_dir: C:\Program Files (x86)\checkmk\service\plugins
+checkmk_windows_local_dir: C:\ProgramData\checkmk\agent\local
+checkmk_monitoring_scripts_dir: '{{ lookup(''ansible.builtin.env'', ''AIM_CHECKMK_MONITORING_SCRIPTS_DIR'')
+ | default(''/etc/checkmk_monitoring_scripts'', true) }}'
+checkmk_windows_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Windows'
+checkmk_linux_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Linux'
+checkmk_unifi_mode: auto
+want_linux_check_certificate: false
+want_windows_citrix: false
+want_windows_surebackup: false
+want_windows_backup: false
+want_windows_nsp_mailqueue: false
+want_windows_certificate: false
+want_windows_veeam_cloud_connect: false
+want_windows_veeam_backup: false
+checkmk_unifi_username: bf-monitoring
+checkmk_unifi_password: '{{ vault_checkmk_unifi_password | default('''') }}'
+checkmk_unifi_baseurl: '{{ ''https://127.0.0.1:11443'' if _checkmk_unifi_effective == ''os'' else ''https://127.0.0.1:8443''
+ }}'
+checkmk_unifi_curl_options: ' --insecure --tlsv1.2'
+checkmk_unifi_status_provisioning: 1
+checkmk_unifi_status_upgrading: 1
+checkmk_unifi_status_upgradable: 0
+checkmk_unifi_status_heartbeat_missed: 1
+checkmk_unifi_status_noautobackup: 0
diff --git a/roles/checkmk_deploy_scripts/tasks/linux.yml b/roles/checkmk_deploy_scripts/tasks/linux.yml
new file mode 100644
index 0000000..1518956
--- /dev/null
+++ b/roles/checkmk_deploy_scripts/tasks/linux.yml
@@ -0,0 +1,40 @@
+# UniFi mode replacement is the explicitly approved exception to separate cleanup.
+# Unknown files and the active UniFi check are never removed here.
+ - name: Linux | Ensure local check directory
+ ansible.builtin.file:
+ path: '{{ checkmk_linux_local_dir }}'
+ state: directory
+ mode: '0755'
+ - name: Linux | Ensure configuration directory
+ ansible.builtin.file:
+ path: '{{ checkmk_linux_config_dir }}'
+ state: directory
+ mode: '0755'
+ - name: Linux | Write the single UniFi configuration
+ ansible.builtin.template:
+ src: unifi.cfg.j2
+ dest: '{{ checkmk_linux_config_dir }}/unifi.cfg'
+ owner: root
+ group: root
+ mode: '0600'
+ validate: /bin/sh -n %s
+ when: _checkmk_unifi_effective in ['network','os']
+ no_log: true
+ diff: false
+ register: _aim_unifi_write
+ - name: Linux | Deploy selected monitoring checks
+ ansible.builtin.copy:
+ src: '{{ item.source }}'
+ dest: '{{ checkmk_linux_local_dir }}/{{ item.filename }}'
+ mode: '0755'
+ loop: '{{ _checkmk_selected_scripts }}'
+ loop_control:
+ label: '{{ item.filename }}'
+ register: _aim_check_copies
+ - name: Linux | Remove only the opposite UniFi local check
+ ansible.builtin.file:
+ path: "{{ checkmk_linux_local_dir }}/{{ 'check_unifi-controller.sh' if _checkmk_unifi_effective == 'os' else
+ 'check_unifi-os.sh' }}"
+ state: absent
+ when: _checkmk_unifi_effective in ['network','os']
+ register: _aim_opposite_remove
diff --git a/roles/checkmk_deploy_scripts/tasks/main.yml b/roles/checkmk_deploy_scripts/tasks/main.yml
new file mode 100644
index 0000000..e845010
--- /dev/null
+++ b/roles/checkmk_deploy_scripts/tasks/main.yml
@@ -0,0 +1,10 @@
+---
+
+- name: Checkmk | Validate controller sources and required parameters
+ ansible.builtin.import_tasks: preflight.yml
+- name: Checkmk | Deploy linux checks
+ ansible.builtin.include_tasks: linux.yml
+ when: ansible_facts.os_family != 'Windows'
+- name: Checkmk | Deploy windows checks
+ ansible.builtin.include_tasks: windows.yml
+ when: ansible_facts.os_family == 'Windows'
diff --git a/roles/checkmk_deploy_scripts/tasks/preflight.yml b/roles/checkmk_deploy_scripts/tasks/preflight.yml
new file mode 100644
index 0000000..6f394b6
--- /dev/null
+++ b/roles/checkmk_deploy_scripts/tasks/preflight.yml
@@ -0,0 +1,57 @@
+---
+
+- name: Checkmk | Inspect selected controller script sources
+ ansible.builtin.stat:
+ path: '{{ item.source }}'
+ delegate_to: localhost
+ become: false
+ loop: '{{ _checkmk_selected_scripts }}'
+ loop_control:
+ label: '{{ item.filename }}'
+ register: _checkmk_sources
+- name: Checkmk | Require selected controller files
+ ansible.builtin.assert:
+ that:
+ - item.stat.exists | default(false)
+ - item.stat.isreg | default(false)
+ fail_msg: A selected monitoring script is missing on the controller. Sync the monitoring repository first.
+ quiet: true
+ loop: '{{ _checkmk_sources.results }}'
+ loop_control:
+ label: '{{ item.item.filename }}'
+- name: Checkmk | Validate UniFi public settings
+ ansible.builtin.assert:
+ that:
+ - checkmk_unifi_username is string
+ - checkmk_unifi_username | length > 0
+ - checkmk_unifi_baseurl is match('^https?://[^\s]+$')
+ - checkmk_unifi_curl_options is string
+ - checkmk_unifi_status_provisioning | int in [0,1,2,3]
+ - checkmk_unifi_status_upgrading | int in [0,1,2,3]
+ - checkmk_unifi_status_upgradable | int in [0,1,2,3]
+ - checkmk_unifi_status_heartbeat_missed | int in [0,1,2,3]
+ - checkmk_unifi_status_noautobackup | int in [0,1,2,3]
+ fail_msg: Invalid UniFi username, base URL, curl options or status mapping.
+ quiet: true
+ when:
+ - ansible_facts.os_family != 'Windows'
+ - _checkmk_unifi_effective in ['network','os']
+- name: Checkmk | Require a real UniFi monitoring password
+ when:
+ - ansible_facts.os_family != 'Windows'
+ - _checkmk_unifi_effective in ['network','os']
+ block:
+ - name: Checkmk | Validate secret
+ ansible.builtin.assert:
+ that:
+ - checkmk_unifi_password is string
+ - checkmk_unifi_password | length > 0
+ - checkmk_unifi_password != 'CHANGEME'
+ fail_msg: A real UniFi password is required.
+ quiet: true
+ no_log: true
+ rescue:
+ - name: Checkmk | Explain missing UniFi secret
+ ansible.builtin.fail:
+ msg: Set vault_checkmk_unifi_password in the customer Vault, or override checkmk_unifi_password with
+ a host-specific Vault reference. Empty values and CHANGEME are refused. No secret was logged.
diff --git a/roles/checkmk_deploy_scripts/tasks/windows.yml b/roles/checkmk_deploy_scripts/tasks/windows.yml
new file mode 100644
index 0000000..eea030f
--- /dev/null
+++ b/roles/checkmk_deploy_scripts/tasks/windows.yml
@@ -0,0 +1,68 @@
+---
+- name: Windows | Ensure Checkmk local directory
+ ansible.windows.win_file:
+ path: '{{ checkmk_windows_local_dir }}'
+ state: directory
+
+- name: Windows | Ensure Checkmk custom plugin directory
+ ansible.windows.win_file:
+ path: '{{ checkmk_windows_plugin_dir }}'
+ state: directory
+ when: >-
+ {{ _checkmk_selected_scripts
+ | selectattr('destination', 'defined')
+ | selectattr('destination', 'equalto', 'custom_plugin')
+ | list | length > 0 }}
+
+- name: Windows | Deploy selected monitoring checks
+ ansible.windows.win_copy:
+ src: '{{ item.source }}'
+ dest: >-
+ {{ (checkmk_windows_plugin_dir
+ if item.destination | default('local') == 'custom_plugin'
+ else checkmk_windows_local_dir) }}\{{ item.filename }}
+ loop: '{{ _checkmk_selected_scripts }}'
+ loop_control:
+ label: '{{ item.filename }}'
+ register: _aim_check_copies
+
+- name: Windows | Normalize ACL on AIM-managed monitoring checks
+ ansible.builtin.include_role:
+ name: checkmk_windows_acl
+ vars:
+ checkmk_windows_acl_paths:
+ - >-
+ {{ (checkmk_windows_plugin_dir
+ if checkmk_acl_script.destination | default('local') == 'custom_plugin'
+ else checkmk_windows_local_dir) }}\{{ checkmk_acl_script.filename }}
+ loop: '{{ _checkmk_selected_scripts }}'
+ loop_control:
+ loop_var: checkmk_acl_script
+ label: '{{ checkmk_acl_script.filename }}'
+
+- name: Windows | Remove legacy local copies after custom plugin relocation
+ ansible.windows.win_file:
+ path: '{{ checkmk_windows_local_dir }}\{{ item.filename }}'
+ state: absent
+ loop: >-
+ {{ _checkmk_selected_scripts
+ | selectattr('destination', 'defined')
+ | selectattr('destination', 'equalto', 'custom_plugin')
+ | list }}
+ loop_control:
+ label: '{{ item.filename }}'
+ register: _aim_custom_plugin_legacy_local_remove
+
+- name: Windows | Remove known legacy built-in copies after custom plugin relocation
+ ansible.windows.win_file:
+ path: '{{ checkmk_windows_builtin_plugin_dir }}\{{ item.filename }}'
+ state: absent
+ loop: >-
+ {{ _checkmk_selected_scripts
+ | selectattr('destination', 'defined')
+ | selectattr('destination', 'equalto', 'custom_plugin')
+ | selectattr('filename', 'in', ['veeam_o365_status.ps1', 'veeam_backup_status.ps1'])
+ | list }}
+ loop_control:
+ label: '{{ item.filename }}'
+ register: _aim_custom_plugin_legacy_builtin_remove
diff --git a/roles/checkmk_deploy_scripts/templates/unifi.cfg.j2 b/roles/checkmk_deploy_scripts/templates/unifi.cfg.j2
new file mode 100644
index 0000000..4632837
--- /dev/null
+++ b/roles/checkmk_deploy_scripts/templates/unifi.cfg.j2
@@ -0,0 +1,13 @@
+# Managed by Ansible - checkmk_deploy_scripts. One UniFi variant per host.
+# Values are POSIX-shell quoted because the monitoring scripts source this file.
+USERNAME={{ checkmk_unifi_username | quote }}
+PASSWORD={{ checkmk_unifi_password | quote }}
+BASEURL={{ checkmk_unifi_baseurl | quote }}
+CURLOPTS={{ checkmk_unifi_curl_options | quote }}
+
+# 0 OK, 1 WARN, 2 CRIT, 3 UNKNOWN
+STATUS_PROVISIONING={{ checkmk_unifi_status_provisioning | int }}
+STATUS_UPGRADING={{ checkmk_unifi_status_upgrading | int }}
+STATUS_UPGRADABLE={{ checkmk_unifi_status_upgradable | int }}
+STATUS_HEARTBEAT_MISSED={{ checkmk_unifi_status_heartbeat_missed | int }}
+STATUS_NOAUTOBACKUP={{ checkmk_unifi_status_noautobackup | int }}
diff --git a/roles/checkmk_manage_service/README.md b/roles/checkmk_manage_service/README.md
new file mode 100644
index 0000000..e8d2e84
--- /dev/null
+++ b/roles/checkmk_manage_service/README.md
@@ -0,0 +1,23 @@
+# checkmk_manage_service
+
+Operator defaults are intentionally low-precedence; inventory and explicit run options may override them.
+
+```yaml
+---
+
+checkmk_linux_service_name: cmk-agent-ctl-daemon.service
+checkmk_linux_socket_name: check-mk-agent.socket
+checkmk_windows_service_name: ''
+checkmk_windows_service_candidates:
+ - Check_MK_Agent
+ - CheckmkService
+ - Checkmk Service
+```
+
+## Structured result integration
+
+Current reporting behavior and field semantics are specified in
+[scripts/docs/OPERATION_RESULTS.md](../../scripts/docs/OPERATION_RESULTS.md).
+The calling catalog playbook owns publication; helper roles do not implicitly export
+arbitrary facts, module results or debug data. Existing defaults above retain their
+precedence. See the current validation/sanity documents before using the new candidate.
diff --git a/roles/checkmk_manage_service/defaults/main.yml b/roles/checkmk_manage_service/defaults/main.yml
new file mode 100644
index 0000000..d3163cd
--- /dev/null
+++ b/roles/checkmk_manage_service/defaults/main.yml
@@ -0,0 +1,9 @@
+---
+
+checkmk_linux_service_name: cmk-agent-ctl-daemon.service
+checkmk_linux_socket_name: check-mk-agent.socket
+checkmk_windows_service_name: ''
+checkmk_windows_service_candidates:
+ - Check_MK_Agent
+ - CheckmkService
+ - Checkmk Service
diff --git a/roles/checkmk_manage_service/handlers/main.yml b/roles/checkmk_manage_service/handlers/main.yml
new file mode 100644
index 0000000..30f5f81
--- /dev/null
+++ b/roles/checkmk_manage_service/handlers/main.yml
@@ -0,0 +1,9 @@
+---
+
+- name: Checkmk | Restart changed Windows agent configuration
+ ansible.windows.win_service:
+ name: '{{ item }}'
+ state: restarted
+ listen: checkmk | windows | configuration-changed
+ loop: '{{ _checkmk_windows_services | default([]) }}'
+ when: ansible_facts.os_family == 'Windows'
diff --git a/roles/checkmk_manage_service/tasks/linux.yml b/roles/checkmk_manage_service/tasks/linux.yml
new file mode 100644
index 0000000..aa2921f
--- /dev/null
+++ b/roles/checkmk_manage_service/tasks/linux.yml
@@ -0,0 +1,42 @@
+---
+
+- name: Linux | Require systemd service management
+ ansible.builtin.assert:
+ that:
+ - ansible_facts.service_mgr == 'systemd'
+ fail_msg: This Checkmk service policy requires systemd. No alternate service system is configured.
+ quiet: true
+- name: Linux | Refresh systemd after package installation
+ ansible.builtin.systemd_service:
+ daemon_reload: true
+ when: _checkmk_package_install.changed | default(false) | bool
+- name: Linux | Detect configured Checkmk units independently of running state
+ ansible.builtin.command:
+ argv:
+ - systemctl
+ - show
+ - --property=LoadState
+ - --value
+ - '{{ item }}'
+ loop:
+ - '{{ checkmk_linux_socket_name }}'
+ - '{{ checkmk_linux_service_name }}'
+ register: _checkmk_units
+ changed_when: false
+ failed_when: 'false'
+ check_mode: false
+- name: Linux | Select the installed unit, preferring the socket
+ ansible.builtin.set_fact:
+ _checkmk_linux_units: '{{ _checkmk_units.results | selectattr(''stdout'', ''defined'') | selectattr(''stdout'',
+ ''equalto'', ''loaded'') | map(attribute=''item'') | list }}'
+- name: Linux | Require an installed Checkmk unit
+ ansible.builtin.assert:
+ that:
+ - _checkmk_linux_units | length > 0
+ fail_msg: No configured Checkmk socket/service unit was found. Verify installation and the unit names.
+ quiet: true
+- name: Linux | Ensure Checkmk is enabled and running
+ ansible.builtin.systemd_service:
+ name: '{{ _checkmk_linux_units | first }}'
+ enabled: true
+ state: started
diff --git a/roles/checkmk_manage_service/tasks/main.yml b/roles/checkmk_manage_service/tasks/main.yml
new file mode 100644
index 0000000..78f64a8
--- /dev/null
+++ b/roles/checkmk_manage_service/tasks/main.yml
@@ -0,0 +1,8 @@
+---
+
+- name: Checkmk | Ensure agent service on linux
+ ansible.builtin.include_tasks: linux.yml
+ when: ansible_facts.os_family != 'Windows'
+- name: Checkmk | Ensure agent service on windows
+ ansible.builtin.include_tasks: windows.yml
+ when: ansible_facts.os_family == 'Windows'
diff --git a/roles/checkmk_manage_service/tasks/windows.yml b/roles/checkmk_manage_service/tasks/windows.yml
new file mode 100644
index 0000000..47338c5
--- /dev/null
+++ b/roles/checkmk_manage_service/tasks/windows.yml
@@ -0,0 +1,24 @@
+---
+
+- name: Windows | Find installed Checkmk service
+ ansible.windows.win_service_info:
+ name: '{{ item }}'
+ loop: '{{ ([checkmk_windows_service_name] if checkmk_windows_service_name | length else checkmk_windows_service_candidates)
+ }}'
+ register: _checkmk_win_service_query
+- name: Windows | Record service names
+ ansible.builtin.set_fact:
+ _checkmk_windows_services: '{{ _checkmk_win_service_query.results | selectattr(''services'', ''defined'')
+ | map(attribute=''services'') | flatten | map(attribute=''name'') | unique | list }}'
+- name: Windows | Require installed Checkmk service
+ ansible.builtin.assert:
+ that:
+ - _checkmk_windows_services | length > 0
+ fail_msg: No Checkmk service was found. Check the installed package or set checkmk_windows_service_name.
+ quiet: true
+- name: Windows | Ensure Checkmk service is running
+ ansible.windows.win_service:
+ name: '{{ item }}'
+ start_mode: auto
+ state: started
+ loop: '{{ _checkmk_windows_services }}'
diff --git a/roles/checkmk_report/README.md b/roles/checkmk_report/README.md
new file mode 100644
index 0000000..d0c7c9c
--- /dev/null
+++ b/roles/checkmk_report/README.md
@@ -0,0 +1,9 @@
+# checkmk_report
+
+Reporting-only helper for the Checkmk installation playbooks. Queries installed version
+from Windows uninstall registry or Debian/RPM package database, and re-reads current
+service/unit state. Does not install packages or restart/configure services. Unknown or
+ambiguous versions are null, never inferred from the staged package filename.
+
+The calling playbook publishes checkmk_agent_state_v1 via set_stats. See
+[OPERATION_RESULTS.md](../../scripts/docs/OPERATION_RESULTS.md).
diff --git a/roles/checkmk_report/tasks/main.yml b/roles/checkmk_report/tasks/main.yml
new file mode 100644
index 0000000..b0dabd3
--- /dev/null
+++ b/roles/checkmk_report/tasks/main.yml
@@ -0,0 +1,79 @@
+ - name: Checkmk | Collect managed change summary
+ ansible.builtin.set_fact:
+ _aim_checkmk_changes: '{{ _aim_check_copies | default({}) | aim_report_checkmk_changes(_checkmk_selected_scripts,
+ _checkmk_plugins_update | default({}), _aim_unifi_write | default({}), _aim_opposite_remove | default({}),
+ _checkmk_unifi_effective, ansible_check_mode) }}'
+
+ # No supported Windows package-inventory module exposes arbitrary installed MSI/registry products.
+ # Keep this bounded read-only registry query until an official module covers that data.
+ - name: Checkmk | Query Windows installed version
+ ansible.windows.win_shell: |
+ $ErrorActionPreference = 'Stop'
+ $roots = @('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*')
+ $products = @(Get-ItemProperty -Path $roots -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -match '^(Check_MK|Checkmk|Check MK).*Agent' })
+ $versions = @($products | Select-Object -ExpandProperty DisplayVersion -Unique)
+ $version = $null
+ if ($versions.Count -eq 1) { $version = [string]$versions[0] }
+ @{ installed=($products.Count -gt 0); version=$version; version_source='registry' } | ConvertTo-Json -Compress
+ register: _aim_checkmk_version_raw
+ changed_when: false
+ check_mode: false
+ when: ansible_facts.os_family == 'Windows'
+
+ - name: Checkmk | Collect Linux package facts
+ ansible.builtin.package_facts:
+ manager: auto
+ when: ansible_facts.os_family != 'Windows'
+
+ - name: Checkmk | Record Linux installed package rows
+ ansible.builtin.set_fact:
+ _aim_checkmk_linux_package_rows: "{{ ansible_facts.packages.get('check-mk-agent', []) }}"
+ when: ansible_facts.os_family != 'Windows'
+
+ - name: Checkmk | Observe Windows service state
+ ansible.windows.win_service_info:
+ name: '{{ item }}'
+ loop: '{{ _checkmk_windows_services | default([]) }}'
+ register: _aim_checkmk_final_services
+ when: ansible_facts.os_family == 'Windows'
+
+ - name: Checkmk | Collect Linux service facts
+ ansible.builtin.service_facts:
+ when: ansible_facts.os_family != 'Windows'
+
+ - name: Checkmk | Observe Linux unit state
+ ansible.builtin.set_fact:
+ _aim_checkmk_unit_state: >-
+ {{ ansible_facts.services.get(_checkmk_linux_units | first,
+ {'name': _checkmk_linux_units | first, 'state': 'unknown'}) }}
+ when: ansible_facts.os_family != 'Windows'
+
+ - name: Checkmk | Build installed state report
+ ansible.builtin.set_fact:
+ _aim_checkmk_state: >-
+ {{
+ (
+ (_aim_checkmk_version_raw.stdout | from_json)
+ if ansible_facts.os_family == 'Windows'
+ else {
+ 'installed': (_aim_checkmk_linux_package_rows | default([]) | length) > 0,
+ 'version': (
+ (_aim_checkmk_linux_package_rows | first).version
+ if (_aim_checkmk_linux_package_rows | default([]) | length) == 1
+ else none
+ ),
+ 'version_source': 'package_facts'
+ }
+ )
+ | aim_report_checkmk_state(
+ (
+ _aim_checkmk_final_services.results
+ | selectattr('services', 'defined')
+ | map(attribute='services')
+ | flatten
+ ) if ansible_facts.os_family == 'Windows' else [_aim_checkmk_unit_state],
+ _aim_checkmk_changes,
+ _checkmk_package_install.changed | default(false),
+ ansible_check_mode
+ )
+ }}
diff --git a/roles/checkmk_script_plan/README.md b/roles/checkmk_script_plan/README.md
new file mode 100644
index 0000000..568e6e0
--- /dev/null
+++ b/roles/checkmk_script_plan/README.md
@@ -0,0 +1,27 @@
+# checkmk_script_plan
+
+Operator defaults are intentionally low-precedence; inventory and explicit run options may override them.
+
+```yaml
+---
+# Shared deployment and cleanup paths/optional switches. No secrets.
+checkmk_linux_plugin_dir: /usr/lib/check_mk_agent/plugins
+checkmk_linux_local_dir: /usr/lib/check_mk_agent/local
+checkmk_linux_config_dir: /etc/check_mk
+checkmk_windows_plugin_dir: C:\ProgramData\checkmk\agent\plugins
+checkmk_windows_builtin_plugin_dir: C:\Program Files (x86)\checkmk\service\plugins
+checkmk_windows_local_dir: C:\ProgramData\checkmk\agent\local
+checkmk_monitoring_scripts_dir: '{{ lookup(''ansible.builtin.env'', ''AIM_CHECKMK_MONITORING_SCRIPTS_DIR'')
+ | default(''/etc/checkmk_monitoring_scripts'', true) }}'
+checkmk_windows_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Windows'
+checkmk_linux_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Linux'
+checkmk_unifi_mode: auto
+want_linux_check_certificate: false
+want_windows_citrix: false
+want_windows_surebackup: false
+want_windows_backup: false
+want_windows_nsp_mailqueue: false
+want_windows_certificate: false
+want_windows_veeam_cloud_connect: false
+want_windows_veeam_backup: false
+```
diff --git a/roles/checkmk_script_plan/defaults/main.yml b/roles/checkmk_script_plan/defaults/main.yml
new file mode 100644
index 0000000..cbe6e8b
--- /dev/null
+++ b/roles/checkmk_script_plan/defaults/main.yml
@@ -0,0 +1,21 @@
+---
+# Shared deployment and cleanup paths/optional switches. No secrets.
+checkmk_linux_plugin_dir: /usr/lib/check_mk_agent/plugins
+checkmk_linux_local_dir: /usr/lib/check_mk_agent/local
+checkmk_linux_config_dir: /etc/check_mk
+checkmk_windows_plugin_dir: C:\ProgramData\checkmk\agent\plugins
+checkmk_windows_builtin_plugin_dir: C:\Program Files (x86)\checkmk\service\plugins
+checkmk_windows_local_dir: C:\ProgramData\checkmk\agent\local
+checkmk_monitoring_scripts_dir: '{{ lookup(''ansible.builtin.env'', ''AIM_CHECKMK_MONITORING_SCRIPTS_DIR'')
+ | default(''/etc/checkmk_monitoring_scripts'', true) }}'
+checkmk_windows_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Windows'
+checkmk_linux_scripts_dir: '{{ checkmk_monitoring_scripts_dir }}/Scripts Linux'
+checkmk_unifi_mode: auto
+want_linux_check_certificate: false
+want_windows_citrix: false
+want_windows_surebackup: false
+want_windows_backup: false
+want_windows_nsp_mailqueue: false
+want_windows_certificate: false
+want_windows_veeam_cloud_connect: false
+want_windows_veeam_backup: false
diff --git a/roles/checkmk_script_plan/tasks/main.yml b/roles/checkmk_script_plan/tasks/main.yml
new file mode 100644
index 0000000..63cd133
--- /dev/null
+++ b/roles/checkmk_script_plan/tasks/main.yml
@@ -0,0 +1,27 @@
+---
+
+- name: Checkmk | Validate UniFi mode
+ ansible.builtin.assert:
+ that:
+ - checkmk_unifi_mode in ['auto','network','os','disabled']
+ fail_msg: UniFi mode must be auto, network, os or disabled.
+ quiet: true
+- name: Checkmk | Resolve UniFi mode
+ ansible.builtin.set_fact:
+ _checkmk_unifi_effective: '{{ checkmk_unifi_mode if checkmk_unifi_mode != ''auto'' else (''os'' if is_unifi_os_server
+ | default(false) | bool else (''network'' if is_unifi_controller | default(false) | bool else ''disabled''))
+ }}'
+- name: Checkmk | Build managed script plan
+ ansible.builtin.set_fact:
+ _checkmk_script_catalog: '{{ _checkmk_windows_catalog if ansible_facts.os_family == ''Windows'' else _checkmk_linux_catalog
+ }}'
+- name: Checkmk | Resolve selected and obsolete checks
+ ansible.builtin.set_fact:
+ _checkmk_selected_scripts: '{{ _checkmk_script_catalog | selectattr(''enabled'') | list }}'
+ _checkmk_obsolete_scripts: '{{ _checkmk_script_catalog | rejectattr(''enabled'') | list }}'
+- name: Checkmk | Selected check plan
+ ansible.builtin.debug:
+ msg:
+ files: '{{ _checkmk_selected_scripts | map(attribute=''filename'') | list }}'
+ unifi_mode: '{{ _checkmk_unifi_effective }}'
+ when: aim_debug | default(false) | bool
diff --git a/roles/checkmk_script_plan/vars/main.yml b/roles/checkmk_script_plan/vars/main.yml
new file mode 100644
index 0000000..1d8eb5e
--- /dev/null
+++ b/roles/checkmk_script_plan/vars/main.yml
@@ -0,0 +1,49 @@
+---
+# Internal managed filenames. Repository scripts are not embedded or rewritten by AIM.
+_checkmk_windows_catalog:
+ - filename: check-ping.ps1
+ source: '{{ checkmk_windows_scripts_dir }}/check-ping.ps1'
+ enabled: '{{ is_dc | default(false) | bool }}'
+ - filename: veeam_config_backup_status.ps1
+ source: '{{ checkmk_windows_scripts_dir }}/veeam_config_backup_status.ps1'
+ enabled: '{{ has_veeam_vbr | default(false) | bool }}'
+ - filename: veeam_backup_license_status.ps1
+ source: '{{ checkmk_windows_scripts_dir }}/veeam_backup_license_status.ps1'
+ enabled: '{{ has_veeam_vbr | default(false) | bool }}'
+ - filename: veeam_o365_status.ps1
+ source: '{{ checkmk_windows_scripts_dir }}/veeam_o365_status.ps1'
+ destination: custom_plugin
+ enabled: '{{ has_veeam_vbo | default(false) | bool }}'
+ - filename: citrix_sessions_customized.ps1
+ source: '{{ checkmk_windows_scripts_dir }}/citrix_sessions_customized.ps1'
+ destination: custom_plugin
+ enabled: '{{ want_windows_citrix | default(false) | bool }}'
+ - filename: veeam_surebackup_status.ps1
+ source: '{{ checkmk_windows_scripts_dir }}/veeam_surebackup_status.ps1'
+ enabled: '{{ want_windows_surebackup | default(false) | bool }}'
+ - filename: windows-backup.ps1
+ source: '{{ checkmk_windows_scripts_dir }}/windows-backup.ps1'
+ enabled: '{{ want_windows_backup | default(false) | bool }}'
+ - filename: check-nsp-mailqueue.ps1
+ source: '{{ checkmk_windows_scripts_dir }}/check-nsp-mailqueue.ps1'
+ enabled: '{{ want_windows_nsp_mailqueue | default(false) | bool }}'
+ - filename: win_check_cert.ps1
+ source: '{{ checkmk_windows_scripts_dir }}/win_check_cert.ps1'
+ enabled: '{{ want_windows_certificate | default(false) | bool }}'
+ - filename: veeam_cloud_connect_status.ps1
+ source: '{{ checkmk_windows_scripts_dir }}/veeam_cloud_connect_status.ps1'
+ enabled: '{{ want_windows_veeam_cloud_connect | default(false) | bool }}'
+ - filename: veeam_backup_status.ps1
+ source: '{{ checkmk_windows_scripts_dir }}/veeam_backup_status.ps1'
+ destination: custom_plugin
+ enabled: '{{ want_windows_veeam_backup | default(false) | bool }}'
+_checkmk_linux_catalog:
+ - filename: check_unifi-controller.sh
+ source: '{{ checkmk_linux_scripts_dir }}/check_unifi-controller/check_unifi-controller.sh'
+ enabled: '{{ _checkmk_unifi_effective == ''network'' }}'
+ - filename: check_unifi-os.sh
+ source: '{{ checkmk_linux_scripts_dir }}/check_unifi-controller/check_unifi-os.sh'
+ enabled: '{{ _checkmk_unifi_effective == ''os'' }}'
+ - filename: check_certificate_directory.sh
+ source: '{{ checkmk_linux_scripts_dir }}/check_certificate_directory.sh'
+ enabled: '{{ want_linux_check_certificate | default(false) | bool }}'
diff --git a/roles/checkmk_scripts/defaults/main.yml b/roles/checkmk_scripts/defaults/main.yml
deleted file mode 100644
index 8803d80..0000000
--- a/roles/checkmk_scripts/defaults/main.yml
+++ /dev/null
@@ -1,12 +0,0 @@
----
-checkmk_linux_local_dir: "/usr/lib/check_mk_agent/local"
-checkmk_linux_config_dir: "/etc/check_mk"
-checkmk_windows_local_dir: "C:\\ProgramData\\checkmk\\agent\\local"
-checkmk_linux_scripts_dir: "Linux/local"
-checkmk_windows_scripts_dir: "Windows/local"
-
-# Optional checks, disabled until explicitly requested
-want_linux_check_certificate: false
-want_windows_citrix: false
-want_windows_surebackup: false
-want_windows_backup: false
diff --git a/roles/checkmk_scripts/files/README.md b/roles/checkmk_scripts/files/README.md
deleted file mode 100644
index a92fb3c..0000000
--- a/roles/checkmk_scripts/files/README.md
+++ /dev/null
@@ -1,16 +0,0 @@
-Place the actual monitoring scripts in these directories.
-
-Linux/local/
-- check_certificate_directory.sh
-- check_unifi-controller.sh
-- unifi.cfg
-
-Windows/local/
-- check-ping.ps1
-- citrix_sessions_customized.ps1
-- veeam_config_backup_status.ps1
-- veeam_o365_status.ps1
-- veeam_surebackup_status.ps1
-- windows-backup.ps1
-
-The ZIP intentionally does not invent script contents that were not provided.
diff --git a/roles/checkmk_scripts/files/Windows/local/veeam_o365_status.ps1.example b/roles/checkmk_scripts/files/Windows/local/veeam_o365_status.ps1.example
deleted file mode 100644
index e69de29..0000000
diff --git a/roles/checkmk_scripts/files/Windows/local/veeam_surebackup_status.ps1.example b/roles/checkmk_scripts/files/Windows/local/veeam_surebackup_status.ps1.example
deleted file mode 100644
index e69de29..0000000
diff --git a/roles/checkmk_scripts/files/Windows/local/windows-backup.ps1.example b/roles/checkmk_scripts/files/Windows/local/windows-backup.ps1.example
deleted file mode 100644
index e69de29..0000000
diff --git a/roles/checkmk_scripts/meta/main.yml b/roles/checkmk_scripts/meta/main.yml
deleted file mode 100644
index e4e1200..0000000
--- a/roles/checkmk_scripts/meta/main.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-galaxy_info:
- role_name: checkmk_scripts
- description: Deploy role-specific Checkmk local monitoring scripts
- min_ansible_version: "2.18"
-dependencies: []
diff --git a/roles/checkmk_scripts/tasks/linux.yml b/roles/checkmk_scripts/tasks/linux.yml
deleted file mode 100644
index 914212c..0000000
--- a/roles/checkmk_scripts/tasks/linux.yml
+++ /dev/null
@@ -1,36 +0,0 @@
----
-- name: "Linux | Ensure local dir exists"
- ansible.builtin.file:
- path: "{{ checkmk_linux_local_dir }}"
- state: directory
- mode: "0755"
-
-- name: "Linux | Ensure config dir exists"
- ansible.builtin.file:
- path: "{{ checkmk_linux_config_dir }}"
- state: directory
- mode: "0755"
-
-- name: "Linux | Deploy UniFi local check"
- when: is_unifi_controller | default(false) | bool
- ansible.builtin.copy:
- src: "{{ checkmk_linux_scripts_dir }}/check_unifi-controller.sh"
- dest: "{{ checkmk_linux_local_dir }}/check_unifi-controller.sh"
- mode: "0755"
- notify: "checkmk | linux | agent-ensure-running"
-
-- name: "Linux | Deploy unifi.cfg"
- when: is_unifi_controller | default(false) | bool
- ansible.builtin.copy:
- src: "{{ checkmk_linux_scripts_dir }}/unifi.cfg"
- dest: "{{ checkmk_linux_config_dir }}/unifi.cfg"
- mode: "0644"
- notify: "checkmk | linux | agent-ensure-running"
-
-- name: "Linux | Deploy certificate directory check"
- when: want_linux_check_certificate | default(false) | bool
- ansible.builtin.copy:
- src: "{{ checkmk_linux_scripts_dir }}/check_certificate_directory.sh"
- dest: "{{ checkmk_linux_local_dir }}/check_certificate_directory.sh"
- mode: "0755"
- notify: "checkmk | linux | agent-ensure-running"
diff --git a/roles/checkmk_scripts/tasks/main.yml b/roles/checkmk_scripts/tasks/main.yml
deleted file mode 100644
index 503ef04..0000000
--- a/roles/checkmk_scripts/tasks/main.yml
+++ /dev/null
@@ -1,8 +0,0 @@
----
-- name: Include Linux monitoring scripts
- ansible.builtin.include_tasks: linux.yml
- when: ansible_facts['os_family'] != 'Windows'
-
-- name: Include Windows monitoring scripts
- ansible.builtin.include_tasks: windows.yml
- when: ansible_facts['os_family'] == 'Windows'
diff --git a/roles/checkmk_scripts/tasks/windows.yml b/roles/checkmk_scripts/tasks/windows.yml
deleted file mode 100644
index d9ebb58..0000000
--- a/roles/checkmk_scripts/tasks/windows.yml
+++ /dev/null
@@ -1,47 +0,0 @@
----
-- name: "Windows | Ensure local dir exists"
- ansible.windows.win_file:
- path: "{{ checkmk_windows_local_dir }}"
- state: directory
-
-- name: "Windows | Deploy check-ping.ps1 (DC only)"
- when: is_dc | default(false) | bool
- ansible.windows.win_copy:
- src: "{{ checkmk_windows_scripts_dir }}/check-ping.ps1"
- dest: "{{ checkmk_windows_local_dir }}\\check-ping.ps1"
- notify: "checkmk | windows | agent-ensure-running"
-
-- name: "Windows | Deploy Veeam configuration backup status check (VBR only)"
- when: has_veeam_vbr | default(false) | bool
- ansible.windows.win_copy:
- src: "{{ checkmk_windows_scripts_dir }}/veeam_config_backup_status.ps1"
- dest: "{{ checkmk_windows_local_dir }}\\veeam_config_backup_status.ps1"
- notify: "checkmk | windows | agent-ensure-running"
-
-- name: "Windows | Deploy veeam_o365_status.ps1 (VBO only)"
- when: has_veeam_vbo | default(false) | bool
- ansible.windows.win_copy:
- src: "{{ checkmk_windows_scripts_dir }}/veeam_o365_status.ps1"
- dest: "{{ checkmk_windows_local_dir }}\\veeam_o365_status.ps1"
- notify: "checkmk | windows | agent-ensure-running"
-
-- name: "Windows | Deploy Citrix sessions check"
- when: want_windows_citrix | default(false) | bool
- ansible.windows.win_copy:
- src: "{{ checkmk_windows_scripts_dir }}/citrix_sessions_customized.ps1"
- dest: "{{ checkmk_windows_local_dir }}\\citrix_sessions_customized.ps1"
- notify: "checkmk | windows | agent-ensure-running"
-
-- name: "Windows | Deploy Veeam SureBackup check"
- when: want_windows_surebackup | default(false) | bool
- ansible.windows.win_copy:
- src: "{{ checkmk_windows_scripts_dir }}/veeam_surebackup_status.ps1"
- dest: "{{ checkmk_windows_local_dir }}\\veeam_surebackup_status.ps1"
- notify: "checkmk | windows | agent-ensure-running"
-
-- name: "Windows | Deploy Windows Backup check"
- when: want_windows_backup | default(false) | bool
- ansible.windows.win_copy:
- src: "{{ checkmk_windows_scripts_dir }}/windows-backup.ps1"
- dest: "{{ checkmk_windows_local_dir }}\\windows-backup.ps1"
- notify: "checkmk | windows | agent-ensure-running"
diff --git a/roles/checkmk_windows_acl/README.md b/roles/checkmk_windows_acl/README.md
new file mode 100644
index 0000000..59f4721
--- /dev/null
+++ b/roles/checkmk_windows_acl/README.md
@@ -0,0 +1,16 @@
+# checkmk_windows_acl
+
+Normalizes access on AIM-managed persistent Windows Checkmk files without recursively
+changing Checkmk directories or unknown/operator files.
+
+The role enables parent ACL inheritance and guarantees locale-independent well-known
+principals by SID:
+
+- SYSTEM (`S-1-5-18`): FullControl
+- local Administrators (`S-1-5-32-544`): FullControl
+- ALL APPLICATION PACKAGES (`S-1-15-2-1`): ReadAndExecute
+- ALL RESTRICTED APPLICATION PACKAGES (`S-1-15-2-2`): ReadAndExecute
+
+AIM does not add customer-specific administrator/user ACEs. Existing intentional parent
+or explicit ACEs are not blindly purged. Pass persistent AIM-owned file paths through
+`checkmk_windows_acl_paths`.
diff --git a/roles/checkmk_windows_acl/defaults/main.yml b/roles/checkmk_windows_acl/defaults/main.yml
new file mode 100644
index 0000000..c3bb3e4
--- /dev/null
+++ b/roles/checkmk_windows_acl/defaults/main.yml
@@ -0,0 +1,15 @@
+---
+# Locale-independent well-known SIDs used by the native Checkmk Windows tree.
+checkmk_windows_managed_acl_entries:
+ - sid: S-1-5-18
+ rights: FullControl
+ description: SYSTEM
+ - sid: S-1-5-32-544
+ rights: FullControl
+ description: Local Administrators
+ - sid: S-1-15-2-1
+ rights: ReadAndExecute
+ description: ALL APPLICATION PACKAGES
+ - sid: S-1-15-2-2
+ rights: ReadAndExecute
+ description: ALL RESTRICTED APPLICATION PACKAGES
diff --git a/roles/checkmk_windows_acl/tasks/main.yml b/roles/checkmk_windows_acl/tasks/main.yml
new file mode 100644
index 0000000..1360ce0
--- /dev/null
+++ b/roles/checkmk_windows_acl/tasks/main.yml
@@ -0,0 +1,30 @@
+---
+- name: Windows ACL | Validate managed paths
+ ansible.builtin.assert:
+ that:
+ - checkmk_windows_acl_paths is defined
+ - checkmk_windows_acl_paths is sequence
+ - checkmk_windows_acl_paths is not string
+ - checkmk_windows_acl_paths | length > 0
+ fail_msg: checkmk_windows_acl_paths must contain one or more AIM-managed Windows files.
+ quiet: true
+
+- name: Windows ACL | Ensure managed files inherit parent permissions
+ ansible.windows.win_acl_inheritance:
+ path: '{{ item }}'
+ state: present
+ reorganize: true
+ loop: '{{ checkmk_windows_acl_paths }}'
+ loop_control:
+ label: '{{ item }}'
+
+- name: Windows ACL | Ensure Checkmk-style administrative and application access
+ ansible.windows.win_acl:
+ path: '{{ item.0 }}'
+ user: '{{ item.1.sid }}'
+ rights: '{{ item.1.rights }}'
+ type: allow
+ state: present
+ loop: '{{ checkmk_windows_acl_paths | product(checkmk_windows_managed_acl_entries) | list }}'
+ loop_control:
+ label: '{{ item.0 }} | {{ item.1.description }}'
diff --git a/roles/maintenance_export_event_logs/README.md b/roles/maintenance_export_event_logs/README.md
new file mode 100644
index 0000000..cbe81de
--- /dev/null
+++ b/roles/maintenance_export_event_logs/README.md
@@ -0,0 +1,23 @@
+# maintenance_export_event_logs
+
+Operator defaults are intentionally low-precedence; inventory and explicit run options may override them.
+
+```yaml
+---
+
+event_age_days: 45
+export_folder: C:\Logs
+event_log_channels:
+ - Application
+ - Security
+ - System
+ - Setup
+```
+
+## Structured result integration
+
+Current reporting behavior and field semantics are specified in
+[scripts/docs/OPERATION_RESULTS.md](../../scripts/docs/OPERATION_RESULTS.md).
+The calling catalog playbook owns publication; helper roles do not implicitly export
+arbitrary facts, module results or debug data. Existing defaults above retain their
+precedence. See the current validation/sanity documents before using the new candidate.
diff --git a/roles/maintenance_export_event_logs/defaults/main.yml b/roles/maintenance_export_event_logs/defaults/main.yml
new file mode 100644
index 0000000..314d203
--- /dev/null
+++ b/roles/maintenance_export_event_logs/defaults/main.yml
@@ -0,0 +1,9 @@
+---
+
+event_age_days: 45
+export_folder: C:\Logs
+event_log_channels:
+ - Application
+ - Security
+ - System
+ - Setup
diff --git a/roles/maintenance_export_event_logs/tasks/main.yml b/roles/maintenance_export_event_logs/tasks/main.yml
new file mode 100644
index 0000000..709c5d6
--- /dev/null
+++ b/roles/maintenance_export_event_logs/tasks/main.yml
@@ -0,0 +1,63 @@
+
+ - name: Event logs | Validate input
+ ansible.builtin.assert:
+ that:
+ - event_age_days | int > 0
+ - event_age_days | int <= 36500
+ - export_folder is string
+ - export_folder | length > 0
+ - event_log_channels is sequence
+ - event_log_channels is not string
+ - event_log_channels | length > 0
+ fail_msg: Supply a positive age, a target folder and at least one event channel.
+ quiet: true
+ - name: Event logs | Ensure export directory
+ ansible.windows.win_file:
+ path: '{{ export_folder }}'
+ state: directory
+ - name: Event logs | Export selected channels
+ ansible.windows.win_powershell:
+ script: |
+ [CmdletBinding(SupportsShouldProcess)]
+ param([int]$EventAgeDays, [string]$ExportFolder, [string[]]$Channels)
+ $ErrorActionPreference = 'Stop'
+ $Ansible.Changed = $false
+ $date = Get-Date -Format 'yyyy-MM-dd_HHmmss'
+ $maxAgeMs = [int64]([timespan]::FromDays($EventAgeDays).TotalMilliseconds)
+ $q = "*[System[TimeCreated[timediff(@SystemTime) <= $maxAgeMs]]]"
+ $map = @{ Application='APP'; Security='SEC'; System='SYS'; Setup='INS' }
+ $files = @()
+ foreach ($log in $Channels) {
+ $suffix = if ($map.ContainsKey($log)) { $map[$log] } else { $log -replace '[^A-Za-z0-9_.-]', '_' }
+ $filename = Join-Path $ExportFolder "$date-$suffix.evtx"
+ if ($PSCmdlet.ShouldProcess($filename, "Export $log")) {
+ & wevtutil.exe epl $log $filename "/q:$q" | Out-Null
+ if ($LASTEXITCODE -ne 0) { throw "Event export failed for channel '$log'. Exit: $LASTEXITCODE" }
+ if (-not (Test-Path -LiteralPath $filename)) { throw "Event export file is missing: $filename" }
+ $Ansible.Changed = $true
+ }
+ $files += $filename
+ }
+ $Ansible.Result = @{ files=$files; channels=$Channels; days=$EventAgeDays }
+ parameters:
+ EventAgeDays: '{{ event_age_days | int }}'
+ ExportFolder: '{{ export_folder }}'
+ Channels: '{{ event_log_channels }}'
+ error_action: stop
+ register: _aim_event_exports
+ - name: Event logs | Export summary
+ ansible.builtin.debug:
+ msg: '{{ _aim_event_exports.result }}'
+ - name: AIM | Publish operation result
+ ansible.builtin.set_stats:
+ per_host: true
+ aggregate: false
+ data:
+ aim_output:
+ protocol: aim_output_v1
+ schema: event_log_export_v1
+ data:
+ mode: "{{ 'check' if ansible_check_mode else 'apply' }}"
+ days: '{{ event_age_days | int }}'
+ channels: '{{ event_log_channels }}'
+ files: '{{ [] if ansible_check_mode else _aim_event_exports.result.files | default([]) }}'
diff --git a/roles/maintenance_patch_os/README.md b/roles/maintenance_patch_os/README.md
new file mode 100644
index 0000000..2c586a0
--- /dev/null
+++ b/roles/maintenance_patch_os/README.md
@@ -0,0 +1,48 @@
+# maintenance_patch_os
+
+Operator defaults are intentionally low-precedence; inventory and explicit run options
+may override them.
+
+```yaml
+---
+os_patching_reboot: true
+os_patching_windows_categories:
+ - SecurityUpdates
+ - CriticalUpdates
+ - UpdateRollups
+ - DefinitionUpdates
+ - Updates
+os_patching_reboot_timeout: 600
+os_patching_reboot_delay_minutes: 0
+os_patching_reboot_message: 'AIM maintenance: operating system patching requires a reboot.'
+os_patching_rescan_after_reboot: false
+```
+
+`os_patching_reboot_delay_minutes` is shared across Windows/Linux so the public setting
+has one meaning. Linux reboot scheduling is minute-granular. Windows converts the value
+to seconds; a zero-minute reboot still observes the Windows reboot module's minimum delay.
+The message is displayed by the native reboot module when AIM actually initiates a reboot.
+
+When automatic reboot is disabled, newly installed updates can legitimately leave
+`reboot_deferred: true` while the patch run itself succeeds. A later run that detects the
+already-pending reboot fails before starting new patch work and tells the operator to
+reboot manually or enable the reboot option.
+
+## Structured result integration
+
+Current reporting behavior and field semantics are specified in
+[scripts/docs/OPERATION_RESULTS.md](../../scripts/docs/OPERATION_RESULTS.md).
+Runbook-owned filters normalize only reviewed fields; arbitrary package-manager/module
+results are not exported.
+
+## Windows patch waves
+
+Windows uses the native `ansible.windows.win_updates` batch/orchestration path for the
+currently selected categories. AIM calls it with `reboot: false`, so Windows Update may
+process all updates in that current wave while AIM retains control over the reviewed reboot
+message, delay and continuation policy. AIM does not create a per-update install queue.
+
+The default `os_patching_rescan_after_reboot: false` stops the run after any AIM-performed
+reboot boundary; a new operator-approved run discovers the next wave. Set it to true only
+when the operator explicitly wants AIM to start another wave after reboot. A deferred reboot
+always stops the run.
diff --git a/roles/maintenance_patch_os/defaults/main.yml b/roles/maintenance_patch_os/defaults/main.yml
new file mode 100644
index 0000000..5be2673
--- /dev/null
+++ b/roles/maintenance_patch_os/defaults/main.yml
@@ -0,0 +1,16 @@
+---
+# Operator defaults. Existing os_patching_* variable names are intentionally retained.
+os_patching_reboot: true
+os_patching_windows_categories:
+ - SecurityUpdates
+ - CriticalUpdates
+ - UpdateRollups
+ - DefinitionUpdates
+ - Updates
+os_patching_reboot_timeout: 600
+# Cross-platform delay before an AIM-initiated reboot. Linux reboot scheduling is
+# minute-granular, so this public setting is intentionally expressed in minutes.
+os_patching_reboot_delay_minutes: 0
+os_patching_reboot_message: 'AIM maintenance: operating system patching requires a reboot.'
+# Windows only. False preserves one operator-approved patch wave per run.
+os_patching_rescan_after_reboot: false
diff --git a/roles/maintenance_patch_os/tasks/linux_debian.yml b/roles/maintenance_patch_os/tasks/linux_debian.yml
new file mode 100644
index 0000000..3e5d963
--- /dev/null
+++ b/roles/maintenance_patch_os/tasks/linux_debian.yml
@@ -0,0 +1,144 @@
+- name: Patching | Initialize Debian report state
+ ansible.builtin.set_fact:
+ _aim_patch_action_failed: false
+ _aim_patch_pre_reboot_performed: false
+ _aim_patch_post_reboot_performed: false
+
+- name: Patching | Detect pending Debian reboot before patching
+ become: true
+ ansible.builtin.stat:
+ path: /var/run/reboot-required
+ register: _aim_patch_pre_reboot_probe
+
+- name: Patching | Record pre-existing Debian reboot state
+ ansible.builtin.set_fact:
+ _aim_patch_preexisting_reboot_required: '{{ _aim_patch_pre_reboot_probe.stat.exists | default(false) | bool }}'
+
+- name: Patching | Publish blocked Debian result when reboot is deferred
+ when:
+ - _aim_patch_preexisting_reboot_required | bool
+ - not (os_patching_reboot | bool)
+ block:
+ - name: Patching | Build blocked Debian patch report
+ ansible.builtin.set_fact:
+ _aim_patch_report: >-
+ {{ 'debian' | aim_report_patch_blocked(ansible_check_mode,
+ os_patching_reboot_delay_minutes | int) }}
+ - name: AIM | Publish blocked operation result
+ ansible.builtin.set_stats:
+ per_host: true
+ aggregate: false
+ data:
+ aim_output:
+ protocol: aim_output_v1
+ schema: patch_summary_v1
+ data: '{{ _aim_patch_report }}'
+ - name: Patching | Require reboot before continuing Debian patching
+ ansible.builtin.fail:
+ msg: >-
+ A reboot is already pending from a previous update or installation. Reboot the host first,
+ or rerun with "Reboot when required" enabled. No new package upgrade was started by this run.
+
+- name: Patching | Clear pre-existing Debian reboot before patching
+ become: true
+ ansible.builtin.reboot:
+ msg: '{{ os_patching_reboot_message }}'
+ pre_reboot_delay: '{{ (os_patching_reboot_delay_minutes | int) * 60 }}'
+ reboot_timeout: '{{ os_patching_reboot_timeout | int }}'
+ register: _aim_patch_pre_reboot
+ when:
+ - _aim_patch_preexisting_reboot_required | bool
+ - os_patching_reboot | bool
+ - not ansible_check_mode
+
+- name: Patching | Record pre-patch Debian reboot
+ ansible.builtin.set_fact:
+ _aim_patch_pre_reboot_performed: '{{ _aim_patch_pre_reboot.rebooted | default(false) | bool }}'
+
+- name: Patching | Collect installed package facts before operation
+ ansible.builtin.package_facts:
+ manager: auto
+
+- name: Patching | Snapshot installed package facts before operation
+ ansible.builtin.set_fact:
+ _aim_packages_before: '{{ ansible_facts.packages | default({}) }}'
+
+- name: Patching | Apply native Debian updates
+ block:
+ - name: Update Debian-based host
+ become: true
+ ansible.builtin.apt:
+ upgrade: safe
+ update_cache: true
+ cache_valid_time: 3600
+ autoremove: true
+ - name: Check if Debian-based host requires reboot
+ become: true
+ ansible.builtin.stat:
+ path: /var/run/reboot-required
+ register: os_patching_reboot_required
+ rescue:
+ - name: Patching | Retain failed action for reporting
+ ansible.builtin.set_fact:
+ _aim_patch_action_failed: true
+
+- name: Patching | Reboot Debian host after updates when required
+ become: true
+ ansible.builtin.reboot:
+ msg: '{{ os_patching_reboot_message }}'
+ pre_reboot_delay: '{{ (os_patching_reboot_delay_minutes | int) * 60 }}'
+ reboot_timeout: '{{ os_patching_reboot_timeout | int }}'
+ register: _aim_patch_post_reboot
+ when:
+ - os_patching_reboot | bool
+ - not ansible_check_mode
+ - os_patching_reboot_required.stat.exists | default(false) | bool
+
+- name: Patching | Record post-update Debian reboot
+ ansible.builtin.set_fact:
+ _aim_patch_post_reboot_performed: '{{ _aim_patch_post_reboot.rebooted | default(false) | bool }}'
+
+- name: Patching | Collect installed package facts after operation
+ ansible.builtin.package_facts:
+ manager: auto
+
+- name: Patching | Snapshot installed package facts after operation
+ ansible.builtin.set_fact:
+ _aim_packages_after: '{{ ansible_facts.packages | default({}) }}'
+
+- name: Patching | Compare package database snapshots
+ ansible.builtin.set_fact:
+ _aim_patch_report: >-
+ {{ _aim_packages_before |
+ aim_report_patch_linux(
+ _aim_packages_after,
+ 'debian',
+ ansible_check_mode,
+ not _aim_patch_action_failed,
+ os_patching_reboot_required.stat.exists | default(none),
+ (_aim_patch_pre_reboot_performed | bool) or (_aim_patch_post_reboot_performed | bool),
+ _aim_patch_preexisting_reboot_required | bool,
+ os_patching_reboot | bool,
+ os_patching_reboot_delay_minutes | int
+ ) }}
+
+- name: Patching | Package change summary
+ ansible.builtin.debug:
+ msg: '{{ _aim_patch_report }}'
+
+- name: AIM | Publish operation result
+ ansible.builtin.set_stats:
+ per_host: true
+ aggregate: false
+ data:
+ aim_output:
+ protocol: aim_output_v1
+ schema: patch_summary_v1
+ data: '{{ _aim_patch_report }}'
+
+- name: Patching | Preserve native operation failure
+ ansible.builtin.fail:
+ msg: >-
+ The native Debian patch operation failed. Available observed package changes and reboot state
+ are in the structured result. If a reboot is reported as required, reboot before retrying.
+ when: _aim_patch_action_failed | bool
diff --git a/roles/maintenance_patch_os/tasks/linux_redhat.yml b/roles/maintenance_patch_os/tasks/linux_redhat.yml
new file mode 100644
index 0000000..3ea9476
--- /dev/null
+++ b/roles/maintenance_patch_os/tasks/linux_redhat.yml
@@ -0,0 +1,167 @@
+- name: Patching | Initialize RedHat report state
+ ansible.builtin.set_fact:
+ _aim_patch_action_failed: false
+ _aim_patch_pre_reboot_performed: false
+ _aim_patch_post_reboot_performed: false
+ _aim_patch_preexisting_reboot_required: false
+
+- name: Patching | Detect existing needs-restarting command
+ ansible.builtin.command:
+ argv:
+ - /bin/sh
+ - -c
+ - command -v needs-restarting
+ register: _aim_needs_restarting_available
+ changed_when: false
+ failed_when: false
+ check_mode: false
+
+- name: Patching | Detect pending RedHat reboot before patching
+ become: true
+ ansible.builtin.command:
+ cmd: needs-restarting -r
+ register: _aim_patch_pre_reboot_probe
+ changed_when: false
+ failed_when: _aim_patch_pre_reboot_probe.rc not in [0, 1]
+ when: _aim_needs_restarting_available.rc == 0
+
+- name: Patching | Record pre-existing RedHat reboot state
+ ansible.builtin.set_fact:
+ _aim_patch_preexisting_reboot_required: >-
+ {{ (_aim_needs_restarting_available.rc == 0) and
+ (_aim_patch_pre_reboot_probe.rc | default(0) == 1) }}
+
+- name: Patching | Publish blocked RedHat result when reboot is deferred
+ when:
+ - _aim_patch_preexisting_reboot_required | bool
+ - not (os_patching_reboot | bool)
+ block:
+ - name: Patching | Build blocked RedHat patch report
+ ansible.builtin.set_fact:
+ _aim_patch_report: >-
+ {{ 'redhat' | aim_report_patch_blocked(ansible_check_mode,
+ os_patching_reboot_delay_minutes | int) }}
+ - name: AIM | Publish blocked operation result
+ ansible.builtin.set_stats:
+ per_host: true
+ aggregate: false
+ data:
+ aim_output:
+ protocol: aim_output_v1
+ schema: patch_summary_v1
+ data: '{{ _aim_patch_report }}'
+ - name: Patching | Require reboot before continuing RedHat patching
+ ansible.builtin.fail:
+ msg: >-
+ A reboot is already pending from a previous update or installation. Reboot the host first,
+ or rerun with "Reboot when required" enabled. No new package upgrade was started by this run.
+
+- name: Patching | Clear pre-existing RedHat reboot before patching
+ become: true
+ ansible.builtin.reboot:
+ msg: '{{ os_patching_reboot_message }}'
+ pre_reboot_delay: '{{ (os_patching_reboot_delay_minutes | int) * 60 }}'
+ reboot_timeout: '{{ os_patching_reboot_timeout | int }}'
+ register: _aim_patch_pre_reboot
+ when:
+ - _aim_patch_preexisting_reboot_required | bool
+ - os_patching_reboot | bool
+ - not ansible_check_mode
+
+- name: Patching | Record pre-patch RedHat reboot
+ ansible.builtin.set_fact:
+ _aim_patch_pre_reboot_performed: '{{ _aim_patch_pre_reboot.rebooted | default(false) | bool }}'
+
+- name: Patching | Collect installed package facts before operation
+ ansible.builtin.package_facts:
+ manager: auto
+
+- name: Patching | Snapshot installed package facts before operation
+ ansible.builtin.set_fact:
+ _aim_packages_before: '{{ ansible_facts.packages | default({}) }}'
+
+- name: Patching | Apply native RedHat updates
+ block:
+ - name: Update RHEL-based host
+ become: true
+ ansible.builtin.dnf:
+ name: '*'
+ state: latest
+ update_only: true
+ - name: Ensure needs-restarting binary is present (yum-utils)
+ become: true
+ ansible.builtin.dnf:
+ name: yum-utils
+ state: present
+ - name: Check if RHEL-based host requires reboot
+ become: true
+ ansible.builtin.command:
+ cmd: needs-restarting -r
+ register: os_patching_reboot_required
+ changed_when: false
+ failed_when: os_patching_reboot_required.rc not in [0, 1]
+ rescue:
+ - name: Patching | Retain failed action for reporting
+ ansible.builtin.set_fact:
+ _aim_patch_action_failed: true
+
+- name: Patching | Reboot RedHat host after updates when required
+ become: true
+ ansible.builtin.reboot:
+ msg: '{{ os_patching_reboot_message }}'
+ pre_reboot_delay: '{{ (os_patching_reboot_delay_minutes | int) * 60 }}'
+ reboot_timeout: '{{ os_patching_reboot_timeout | int }}'
+ register: _aim_patch_post_reboot
+ when:
+ - os_patching_reboot | bool
+ - not ansible_check_mode
+ - os_patching_reboot_required.rc | default(0) == 1
+
+- name: Patching | Record post-update RedHat reboot
+ ansible.builtin.set_fact:
+ _aim_patch_post_reboot_performed: '{{ _aim_patch_post_reboot.rebooted | default(false) | bool }}'
+
+- name: Patching | Collect installed package facts after operation
+ ansible.builtin.package_facts:
+ manager: auto
+
+- name: Patching | Snapshot installed package facts after operation
+ ansible.builtin.set_fact:
+ _aim_packages_after: '{{ ansible_facts.packages | default({}) }}'
+
+- name: Patching | Compare package database snapshots
+ ansible.builtin.set_fact:
+ _aim_patch_report: >-
+ {{ _aim_packages_before |
+ aim_report_patch_linux(
+ _aim_packages_after,
+ 'redhat',
+ ansible_check_mode,
+ not _aim_patch_action_failed,
+ (os_patching_reboot_required.rc == 1) if os_patching_reboot_required.rc is defined else none,
+ (_aim_patch_pre_reboot_performed | bool) or (_aim_patch_post_reboot_performed | bool),
+ _aim_patch_preexisting_reboot_required | bool,
+ os_patching_reboot | bool,
+ os_patching_reboot_delay_minutes | int
+ ) }}
+
+- name: Patching | Package change summary
+ ansible.builtin.debug:
+ msg: '{{ _aim_patch_report }}'
+
+- name: AIM | Publish operation result
+ ansible.builtin.set_stats:
+ per_host: true
+ aggregate: false
+ data:
+ aim_output:
+ protocol: aim_output_v1
+ schema: patch_summary_v1
+ data: '{{ _aim_patch_report }}'
+
+- name: Patching | Preserve native operation failure
+ ansible.builtin.fail:
+ msg: >-
+ The native RedHat patch operation failed. Available observed package changes and reboot state
+ are in the structured result. If a reboot is reported as required, reboot before retrying.
+ when: _aim_patch_action_failed | bool
diff --git a/roles/maintenance_patch_os/tasks/main.yml b/roles/maintenance_patch_os/tasks/main.yml
new file mode 100644
index 0000000..cb376da
--- /dev/null
+++ b/roles/maintenance_patch_os/tasks/main.yml
@@ -0,0 +1,33 @@
+---
+
+- name: Patching | Supported platform
+ ansible.builtin.assert:
+ that:
+ - ansible_facts.os_family in ['Windows', 'Debian', 'RedHat']
+ fail_msg: 'Supported patching families: Windows, Debian, RedHat. No legacy Python bootstrap is performed.'
+ quiet: true
+- name: Patching | Validate options
+ ansible.builtin.assert:
+ that:
+ - (os_patching_reboot) is boolean or (os_patching_reboot | string | lower) in ['true', 'false']
+ - (os_patching_rescan_after_reboot) is boolean or (os_patching_rescan_after_reboot | string | lower) in ['true', 'false']
+ - os_patching_reboot_timeout | int > 0
+ - os_patching_reboot_delay_minutes | int >= 0
+ - os_patching_reboot_delay_minutes | int <= 1440
+ - os_patching_reboot_message is string
+ - os_patching_reboot_message | length > 0
+ - os_patching_reboot_message | length <= 512
+ - os_patching_windows_categories is sequence
+ - os_patching_windows_categories is not string
+ - os_patching_windows_categories | length > 0
+ fail_msg: Invalid patching settings. Reboot/rescan flags must be boolean, reboot delay must be 0-1440 minutes and the reboot message must be 1-512 characters.
+ quiet: true
+- name: Patching | Windows
+ ansible.builtin.include_tasks: windows.yml
+ when: ansible_facts.os_family == 'Windows'
+- name: Patching | Debian
+ ansible.builtin.include_tasks: linux_debian.yml
+ when: ansible_facts.os_family == 'Debian'
+- name: Patching | RedHat
+ ansible.builtin.include_tasks: linux_redhat.yml
+ when: ansible_facts.os_family == 'RedHat'
diff --git a/roles/maintenance_patch_os/tasks/windows.yml b/roles/maintenance_patch_os/tasks/windows.yml
new file mode 100644
index 0000000..9be46c5
--- /dev/null
+++ b/roles/maintenance_patch_os/tasks/windows.yml
@@ -0,0 +1,162 @@
+---
+- name: Patching | Initialize Windows report state
+ ansible.builtin.set_fact:
+ _aim_patch_action_failed: false
+ _aim_patch_pre_reboot_performed: false
+ _aim_patch_any_reboot_performed: false
+ _aim_patch_preexisting_reboot_required: false
+ _aim_patch_preexisting_reboot_reasons: []
+ _aim_patch_reboot_deferred: false
+ _aim_patch_reboot_required_after: false
+ _aim_patch_blocked_reason: null
+ _aim_patch_continuation_required: false
+ _aim_patch_remaining_updates_known: false
+ _aim_patch_done: false
+ _aim_patch_cycles: 0
+ _aim_windows_update_runs: []
+ _aim_windows_searches: []
+
+- name: Patching | Detect pending Windows reboot before patching
+ ansible.windows.win_reboot_info:
+ register: _aim_patch_pre_reboot_probe
+
+- name: Patching | Record pre-existing Windows reboot state
+ ansible.builtin.set_fact:
+ _aim_patch_preexisting_reboot_required: '{{ _aim_patch_pre_reboot_probe.reboot_required | default(false) | bool }}'
+ _aim_patch_preexisting_reboot_reasons: '{{ _aim_patch_pre_reboot_probe.reboot_required_reasons | default([]) }}'
+
+- name: Patching | Publish blocked Windows result when reboot is deferred
+ when:
+ - _aim_patch_preexisting_reboot_required | bool
+ - not (os_patching_reboot | bool)
+ block:
+ - name: Patching | Build blocked Windows patch report
+ ansible.builtin.set_fact:
+ _aim_patch_report: >-
+ {{ 'windows' | aim_report_patch_blocked(ansible_check_mode,
+ os_patching_reboot_delay_minutes | int,
+ os_patching_rescan_after_reboot | bool,
+ _aim_patch_preexisting_reboot_reasons) }}
+ - name: AIM | Publish blocked operation result
+ ansible.builtin.set_stats:
+ per_host: true
+ aggregate: false
+ data:
+ aim_output:
+ protocol: aim_output_v1
+ schema: patch_summary_v1
+ data: '{{ _aim_patch_report }}'
+ - name: Patching | Require reboot before continuing Windows patching
+ ansible.builtin.fail:
+ msg: >-
+ A reboot is already pending from a previous update or installation. Reboot the host first,
+ or rerun with "Reboot when required" enabled. No new Windows updates were started by this run.
+
+- name: Patching | Clear pre-existing Windows reboot before patching
+ ansible.windows.win_reboot:
+ msg: '{{ os_patching_reboot_message }}'
+ pre_reboot_delay: '{{ [2, (os_patching_reboot_delay_minutes | int) * 60] | max }}'
+ reboot_timeout: '{{ os_patching_reboot_timeout | int }}'
+ register: _aim_patch_pre_reboot
+ when:
+ - _aim_patch_preexisting_reboot_required | bool
+ - os_patching_reboot | bool
+ - not ansible_check_mode
+
+- name: Patching | Record pre-patch Windows reboot
+ ansible.builtin.set_fact:
+ _aim_patch_pre_reboot_performed: '{{ _aim_patch_pre_reboot.rebooted | default(false) | bool }}'
+ _aim_patch_any_reboot_performed: '{{ _aim_patch_pre_reboot.rebooted | default(false) | bool }}'
+ _aim_patch_done: >-
+ {{ (_aim_patch_pre_reboot.rebooted | default(false) | bool) and
+ not (os_patching_rescan_after_reboot | bool) }}
+ _aim_patch_continuation_required: >-
+ {{ (_aim_patch_pre_reboot.rebooted | default(false) | bool) and
+ not (os_patching_rescan_after_reboot | bool) }}
+ _aim_patch_remaining_updates_known: false
+
+- name: Patching | Search Windows updates in check mode
+ ansible.windows.win_updates:
+ category_names: '{{ os_patching_windows_categories }}'
+ state: searched
+ reboot: false
+ register: _aim_windows_check_search
+ when:
+ - ansible_check_mode
+ - not (_aim_patch_done | bool)
+
+- name: Patching | Record Windows check-mode search
+ ansible.builtin.set_fact:
+ _aim_windows_searches: '{{ [_aim_windows_check_search] }}'
+ _aim_patch_remaining_updates_known: true
+ _aim_patch_continuation_required: '{{ (_aim_windows_check_search.found_update_count | default(0) | int) > 0 }}'
+ _aim_patch_done: true
+ when:
+ - ansible_check_mode
+ - _aim_windows_check_search is defined
+ - not (_aim_windows_check_search.skipped | default(false) | bool)
+
+- name: Patching | Process Windows patch waves
+ ansible.builtin.include_tasks: windows_wave.yml
+ loop: >-
+ {{ (range(1, 13) | list) if (os_patching_rescan_after_reboot | bool) else [1] }}
+ loop_control:
+ loop_var: _aim_patch_wave_number
+ label: 'Windows patch wave {{ _aim_patch_wave_number }}'
+ when:
+ - not ansible_check_mode
+ - not (_aim_patch_done | bool)
+
+- name: Patching | Guard automatic continuation wave limit
+ ansible.builtin.set_fact:
+ _aim_patch_action_failed: true
+ _aim_patch_blocked_reason: cycle_limit_reached
+ _aim_patch_continuation_required: true
+ when:
+ - not ansible_check_mode
+ - os_patching_rescan_after_reboot | bool
+ - not (_aim_patch_done | bool)
+
+- name: Patching | Normalize Windows update results
+ ansible.builtin.set_fact:
+ _aim_patch_report: >-
+ {{ _aim_windows_update_runs |
+ aim_report_patch_windows_runs(
+ _aim_windows_searches,
+ ansible_check_mode,
+ _aim_patch_preexisting_reboot_required | bool,
+ _aim_patch_any_reboot_performed | bool,
+ os_patching_reboot | bool,
+ os_patching_reboot_delay_minutes | int,
+ os_patching_rescan_after_reboot | bool,
+ _aim_patch_cycles | int,
+ _aim_patch_continuation_required | bool,
+ _aim_patch_remaining_updates_known | bool,
+ _aim_patch_reboot_deferred | bool,
+ _aim_patch_reboot_required_after,
+ _aim_patch_blocked_reason,
+ not (_aim_patch_action_failed | bool),
+ _aim_patch_preexisting_reboot_reasons
+ ) }}
+
+- name: Patching | Update summary
+ ansible.builtin.debug:
+ msg: '{{ _aim_patch_report }}'
+
+- name: AIM | Publish operation result
+ ansible.builtin.set_stats:
+ per_host: true
+ aggregate: false
+ data:
+ aim_output:
+ protocol: aim_output_v1
+ schema: patch_summary_v1
+ data: '{{ _aim_patch_report }}'
+
+- name: Patching | Preserve Windows update failure
+ ansible.builtin.fail:
+ msg: >-
+ Windows patching stopped before the approved patch wave completed. The structured result contains
+ successfully installed updates, bounded failed-update reasons when available, and whether another
+ operator-approved run is required. AIM did not replay the patch job automatically.
+ when: _aim_patch_action_failed | bool
diff --git a/roles/maintenance_patch_os/tasks/windows_cycle.yml b/roles/maintenance_patch_os/tasks/windows_cycle.yml
new file mode 100644
index 0000000..87a544a
--- /dev/null
+++ b/roles/maintenance_patch_os/tasks/windows_cycle.yml
@@ -0,0 +1,114 @@
+---
+- name: Patching | Start Windows patch cycle
+ ansible.builtin.set_fact:
+ _aim_patch_cycles: '{{ _aim_patch_cycle_number | int }}'
+ _aim_patch_cycle_stop: false
+ _aim_patch_cycle_reboot_performed: false
+
+- name: Patching | Search available Windows updates for this wave
+ ansible.windows.win_updates:
+ category_names: '{{ os_patching_windows_categories }}'
+ state: searched
+ reboot: false
+ register: _aim_windows_cycle_search
+
+- name: Patching | Record Windows update discovery
+ ansible.builtin.set_fact:
+ _aim_windows_searches: '{{ _aim_windows_searches + [_aim_windows_cycle_search] }}'
+ _aim_windows_update_queue: '{{ _aim_windows_cycle_search | aim_windows_update_queue }}'
+
+- name: Patching | Reboot when discovery itself reports a required reboot
+ ansible.windows.win_reboot:
+ msg: '{{ os_patching_reboot_message }}'
+ pre_reboot_delay: '{{ [2, (os_patching_reboot_delay_minutes | int) * 60] | max }}'
+ reboot_timeout: '{{ os_patching_reboot_timeout | int }}'
+ register: _aim_windows_search_reboot
+ when:
+ - _aim_windows_cycle_search.reboot_required | default(false) | bool
+ - os_patching_reboot | bool
+
+- name: Patching | Record discovery-time reboot boundary
+ ansible.builtin.set_fact:
+ _aim_patch_cycle_stop: true
+ _aim_patch_cycle_reboot_performed: '{{ _aim_windows_search_reboot.rebooted | default(false) | bool }}'
+ _aim_patch_any_reboot_performed: >-
+ {{ (_aim_patch_any_reboot_performed | bool) or
+ (_aim_windows_search_reboot.rebooted | default(false) | bool) }}
+ _aim_patch_reboot_required_after: >-
+ {{ false if (_aim_windows_search_reboot.rebooted | default(false) | bool) else true }}
+ when:
+ - _aim_windows_search_reboot is defined
+ - not (_aim_windows_search_reboot.skipped | default(false) | bool)
+
+- name: Patching | Defer discovery-time required reboot
+ ansible.builtin.set_fact:
+ _aim_patch_cycle_stop: true
+ _aim_patch_done: true
+ _aim_patch_reboot_deferred: true
+ _aim_patch_reboot_required_after: true
+ _aim_patch_continuation_required: true
+ _aim_patch_remaining_updates_known: true
+ when:
+ - _aim_windows_cycle_search.reboot_required | default(false) | bool
+ - not (os_patching_reboot | bool)
+
+- name: Patching | Finish when no updates are available
+ ansible.builtin.set_fact:
+ _aim_patch_done: true
+ _aim_patch_remaining_updates_known: true
+ _aim_patch_continuation_required: '{{ _aim_windows_cycle_search.reboot_required | default(false) | bool }}'
+ _aim_patch_reboot_required_after: '{{ _aim_windows_cycle_search.reboot_required | default(false) | bool }}'
+ when:
+ - (_aim_windows_update_queue | length) == 0
+ - not (_aim_patch_cycle_stop | bool)
+
+- name: Patching | Install discovered Windows updates sequentially
+ ansible.builtin.include_tasks: windows_update_one.yml
+ loop: '{{ _aim_windows_update_queue }}'
+ loop_control:
+ loop_var: _aim_windows_update
+ label: '{{ _aim_windows_update.title }}'
+ when:
+ - not (_aim_patch_done | bool)
+ - not (_aim_patch_cycle_stop | bool)
+
+- name: Patching | Final read-only discovery after completed non-reboot wave
+ ansible.windows.win_updates:
+ category_names: '{{ os_patching_windows_categories }}'
+ state: searched
+ reboot: false
+ register: _aim_windows_cycle_final_search
+ when:
+ - not (_aim_patch_done | bool)
+ - not (_aim_patch_cycle_stop | bool)
+ - not (_aim_patch_action_failed | bool)
+
+- name: Patching | Record final non-reboot wave state
+ ansible.builtin.set_fact:
+ _aim_windows_searches: '{{ _aim_windows_searches + [_aim_windows_cycle_final_search] }}'
+ _aim_patch_remaining_updates_known: true
+ _aim_patch_continuation_required: '{{ (_aim_windows_cycle_final_search.found_update_count | default(0) | int) > 0 }}'
+ _aim_patch_reboot_required_after: '{{ _aim_windows_cycle_final_search.reboot_required | default(false) | bool }}'
+ _aim_patch_done: true
+ when:
+ - _aim_windows_cycle_final_search is defined
+ - not (_aim_windows_cycle_final_search.skipped | default(false) | bool)
+
+- name: Patching | Stop after operator-approved reboot boundary by default
+ ansible.builtin.set_fact:
+ _aim_patch_done: true
+ _aim_patch_continuation_required: true
+ _aim_patch_remaining_updates_known: false
+ when:
+ - _aim_patch_cycle_reboot_performed | bool
+ - not (os_patching_rescan_after_reboot | bool)
+
+- name: Patching | Continue only when post-reboot rescan was explicitly enabled
+ ansible.builtin.debug:
+ msg: >-
+ AIM completed a reboot boundary and will start another Windows patch cycle because
+ os_patching_rescan_after_reboot is explicitly enabled.
+ when:
+ - _aim_patch_cycle_reboot_performed | bool
+ - os_patching_rescan_after_reboot | bool
+ - not (_aim_patch_action_failed | bool)
diff --git a/roles/maintenance_patch_os/tasks/windows_update_one.yml b/roles/maintenance_patch_os/tasks/windows_update_one.yml
new file mode 100644
index 0000000..44676c8
--- /dev/null
+++ b/roles/maintenance_patch_os/tasks/windows_update_one.yml
@@ -0,0 +1,87 @@
+---
+- name: Patching | Initialize single Windows update result
+ ansible.builtin.set_fact:
+ _aim_windows_single_result: {}
+ _aim_windows_single_task_failed: false
+
+- name: Patching | Install one Windows update
+ block:
+ - name: 'Patching | Install {{ _aim_windows_update.title }}'
+ ansible.windows.win_updates:
+ category_names: '{{ os_patching_windows_categories }}'
+ state: installed
+ reboot: false
+ accept_list:
+ - '{{ _aim_windows_update.selector }}'
+ register: _aim_windows_single_result
+ rescue:
+ - name: Patching | Retain failed single-update result
+ ansible.builtin.set_fact:
+ _aim_windows_single_result: '{{ ansible_failed_result | default({}) }}'
+ _aim_windows_single_task_failed: true
+
+- name: Patching | Append single-update evidence
+ ansible.builtin.set_fact:
+ _aim_windows_update_runs: >-
+ {{ _aim_windows_update_runs + [
+ {
+ 'requested': _aim_windows_update,
+ 'result': _aim_windows_single_result,
+ 'task_failed': _aim_windows_single_task_failed | bool
+ }
+ ] }}
+
+- name: Patching | Classify single-update execution state
+ ansible.builtin.set_fact:
+ _aim_windows_single_failed: >-
+ {{ (_aim_windows_single_task_failed | bool) or
+ (_aim_windows_single_result | aim_windows_update_result_failed) }}
+ _aim_patch_reboot_required_after: '{{ _aim_windows_single_result.reboot_required | default(false) | bool }}'
+
+- name: Patching | Stop this patch wave after an update failure
+ ansible.builtin.set_fact:
+ _aim_patch_action_failed: true
+ _aim_patch_cycle_stop: true
+ _aim_patch_done: true
+ _aim_patch_continuation_required: true
+ _aim_patch_remaining_updates_known: false
+ _aim_patch_blocked_reason: '{{ _aim_windows_single_result | aim_windows_update_block_reason }}'
+ when: _aim_windows_single_failed | bool
+
+- name: Patching | Reboot Windows at a sequential update boundary
+ ansible.windows.win_reboot:
+ msg: '{{ os_patching_reboot_message }}'
+ pre_reboot_delay: '{{ [2, (os_patching_reboot_delay_minutes | int) * 60] | max }}'
+ reboot_timeout: '{{ os_patching_reboot_timeout | int }}'
+ register: _aim_windows_single_reboot
+ when:
+ - not (_aim_windows_single_failed | bool)
+ - _aim_windows_single_result.reboot_required | default(false) | bool
+ - os_patching_reboot | bool
+
+- name: Patching | Record completed sequential reboot boundary
+ ansible.builtin.set_fact:
+ _aim_patch_cycle_stop: true
+ _aim_patch_cycle_reboot_performed: '{{ _aim_windows_single_reboot.rebooted | default(false) | bool }}'
+ _aim_patch_any_reboot_performed: >-
+ {{ (_aim_patch_any_reboot_performed | bool) or
+ (_aim_windows_single_reboot.rebooted | default(false) | bool) }}
+ _aim_patch_reboot_required_after: >-
+ {{ false if (_aim_windows_single_reboot.rebooted | default(false) | bool)
+ else (_aim_windows_single_result.reboot_required | default(false) | bool) }}
+ when:
+ - _aim_windows_single_reboot is defined
+ - not (_aim_windows_single_reboot.skipped | default(false) | bool)
+
+- name: Patching | Defer required reboot and stop the current patch wave
+ ansible.builtin.set_fact:
+ _aim_patch_cycle_stop: true
+ _aim_patch_done: true
+ _aim_patch_reboot_deferred: true
+ _aim_patch_reboot_required_after: true
+ _aim_patch_continuation_required: true
+ _aim_patch_remaining_updates_known: false
+ when:
+ - not (_aim_windows_single_failed | bool)
+ - _aim_windows_single_result.reboot_required | default(false) | bool
+ - not (os_patching_reboot | bool)
diff --git a/roles/maintenance_patch_os/tasks/windows_wave.yml b/roles/maintenance_patch_os/tasks/windows_wave.yml
new file mode 100644
index 0000000..aafdff3
--- /dev/null
+++ b/roles/maintenance_patch_os/tasks/windows_wave.yml
@@ -0,0 +1,124 @@
+---
+- name: Patching | Start Windows patch wave
+ ansible.builtin.set_fact:
+ _aim_patch_cycles: '{{ _aim_patch_wave_number | int }}'
+ _aim_patch_wave_task_failed: false
+ _aim_patch_wave_result: {}
+ _aim_patch_wave_failed: false
+ _aim_patch_wave_reboot_performed: false
+
+- name: Patching | Install current Windows update wave
+ block:
+ - name: Patching | Install all currently selected Windows updates
+ ansible.windows.win_updates:
+ category_names: '{{ os_patching_windows_categories }}'
+ state: installed
+ reboot: false
+ register: _aim_patch_wave_result
+ rescue:
+ - name: Patching | Retain failed Windows update wave result
+ ansible.builtin.set_fact:
+ _aim_patch_wave_result: '{{ ansible_failed_result | default({}) }}'
+ _aim_patch_wave_task_failed: true
+
+- name: Patching | Append Windows update wave evidence
+ ansible.builtin.set_fact:
+ _aim_windows_update_runs: >-
+ {{ _aim_windows_update_runs + [
+ {
+ 'result': _aim_patch_wave_result,
+ 'task_failed': _aim_patch_wave_task_failed | bool,
+ 'wave': _aim_patch_wave_number | int
+ }
+ ] }}
+
+- name: Patching | Classify Windows update wave
+ ansible.builtin.set_fact:
+ _aim_patch_wave_failed: >-
+ {{ (_aim_patch_wave_task_failed | bool) or
+ (_aim_patch_wave_result | aim_windows_update_result_failed) }}
+ _aim_patch_reboot_required_after: '{{ _aim_patch_wave_result.reboot_required | default(false) | bool }}'
+
+- name: Patching | Record Windows update wave failure
+ ansible.builtin.set_fact:
+ _aim_patch_action_failed: true
+ _aim_patch_done: true
+ _aim_patch_continuation_required: true
+ _aim_patch_remaining_updates_known: false
+ _aim_patch_blocked_reason: '{{ _aim_patch_wave_result | aim_windows_update_block_reason }}'
+ when: _aim_patch_wave_failed | bool
+
+- name: Patching | Reboot after the completed Windows update wave
+ ansible.windows.win_reboot:
+ msg: '{{ os_patching_reboot_message }}'
+ pre_reboot_delay: '{{ [2, (os_patching_reboot_delay_minutes | int) * 60] | max }}'
+ reboot_timeout: '{{ os_patching_reboot_timeout | int }}'
+ register: _aim_patch_wave_reboot
+ when:
+ - _aim_patch_wave_result.reboot_required | default(false) | bool
+ - os_patching_reboot | bool
+
+- name: Patching | Record completed Windows wave reboot boundary
+ ansible.builtin.set_fact:
+ _aim_patch_wave_reboot_performed: '{{ _aim_patch_wave_reboot.rebooted | default(false) | bool }}'
+ _aim_patch_any_reboot_performed: >-
+ {{ (_aim_patch_any_reboot_performed | bool) or
+ (_aim_patch_wave_reboot.rebooted | default(false) | bool) }}
+ _aim_patch_reboot_required_after: >-
+ {{ false if (_aim_patch_wave_reboot.rebooted | default(false) | bool)
+ else (_aim_patch_wave_result.reboot_required | default(false) | bool) }}
+ when:
+ - _aim_patch_wave_reboot is defined
+ - not (_aim_patch_wave_reboot.skipped | default(false) | bool)
+
+- name: Patching | Defer required reboot after Windows update wave
+ ansible.builtin.set_fact:
+ _aim_patch_done: true
+ _aim_patch_reboot_deferred: true
+ _aim_patch_reboot_required_after: true
+ _aim_patch_continuation_required: true
+ _aim_patch_remaining_updates_known: false
+ when:
+ - not (_aim_patch_wave_failed | bool)
+ - _aim_patch_wave_result.reboot_required | default(false) | bool
+ - not (os_patching_reboot | bool)
+
+- name: Patching | Stop after approved Windows reboot boundary by default
+ ansible.builtin.set_fact:
+ _aim_patch_done: true
+ _aim_patch_continuation_required: true
+ _aim_patch_remaining_updates_known: false
+ when:
+ - _aim_patch_wave_reboot_performed | bool
+ - not (os_patching_rescan_after_reboot | bool)
+
+- name: Patching | Stop automatic continuation after a failed Windows wave
+ ansible.builtin.set_fact:
+ _aim_patch_done: true
+ _aim_patch_continuation_required: true
+ _aim_patch_remaining_updates_known: false
+ when:
+ - _aim_patch_wave_failed | bool
+
+- name: Patching | Final read-only discovery after completed non-reboot Windows wave
+ ansible.windows.win_updates:
+ category_names: '{{ os_patching_windows_categories }}'
+ state: searched
+ reboot: false
+ register: _aim_windows_wave_final_search
+ when:
+ - not (_aim_patch_done | bool)
+ - not (_aim_patch_wave_reboot_performed | bool)
+ - not (_aim_patch_wave_result.reboot_required | default(false) | bool)
+ - not (_aim_patch_wave_failed | bool)
+
+- name: Patching | Record completed non-reboot Windows wave state
+ ansible.builtin.set_fact:
+ _aim_windows_searches: '{{ _aim_windows_searches + [_aim_windows_wave_final_search] }}'
+ _aim_patch_remaining_updates_known: true
+ _aim_patch_continuation_required: '{{ (_aim_windows_wave_final_search.found_update_count | default(0) | int) > 0 }}'
+ _aim_patch_reboot_required_after: '{{ _aim_windows_wave_final_search.reboot_required | default(false) | bool }}'
+ _aim_patch_done: true
+ when:
+ - _aim_windows_wave_final_search is defined
+ - not (_aim_windows_wave_final_search.skipped | default(false) | bool)
diff --git a/roles/maintenance_reboot_hosts/README.md b/roles/maintenance_reboot_hosts/README.md
new file mode 100644
index 0000000..86284d7
--- /dev/null
+++ b/roles/maintenance_reboot_hosts/README.md
@@ -0,0 +1,12 @@
+# maintenance_reboot_hosts
+
+Operator defaults are intentionally low-precedence; inventory and explicit run options may override them.
+
+```yaml
+---
+
+maintenance_reboot_timeout: 1800
+maintenance_reboot_message: Reboot initiated by debsansible01.bfmiglabor.lan (Ansible)
+maintenance_reboot_pre_delay: 0
+maintenance_reboot_post_delay: 15
+```
diff --git a/roles/maintenance_reboot_hosts/defaults/main.yml b/roles/maintenance_reboot_hosts/defaults/main.yml
new file mode 100644
index 0000000..4438a45
--- /dev/null
+++ b/roles/maintenance_reboot_hosts/defaults/main.yml
@@ -0,0 +1,6 @@
+---
+
+maintenance_reboot_timeout: 1800
+maintenance_reboot_message: Reboot initiated by debsansible01.bfmiglabor.lan (Ansible)
+maintenance_reboot_pre_delay: 0
+maintenance_reboot_post_delay: 15
diff --git a/roles/maintenance_reboot_hosts/tasks/main.yml b/roles/maintenance_reboot_hosts/tasks/main.yml
new file mode 100644
index 0000000..1caff95
--- /dev/null
+++ b/roles/maintenance_reboot_hosts/tasks/main.yml
@@ -0,0 +1,26 @@
+---
+
+- name: Reboot | Validate timing
+ ansible.builtin.assert:
+ that:
+ - maintenance_reboot_timeout | int > 0
+ - maintenance_reboot_pre_delay | int >= 0
+ - maintenance_reboot_post_delay | int >= 0
+ fail_msg: Reboot timeout must be positive; delays must be non-negative.
+ quiet: true
+- name: Reboot | linux
+ ansible.builtin.reboot:
+ msg: '{{ maintenance_reboot_message }}'
+ reboot_timeout: '{{ maintenance_reboot_timeout | int }}'
+ pre_reboot_delay: '{{ maintenance_reboot_pre_delay | int }}'
+ post_reboot_delay: '{{ maintenance_reboot_post_delay | int }}'
+ test_command: whoami
+ when: '''linux'' in group_names'
+- name: Reboot | windows
+ ansible.windows.win_reboot:
+ msg: '{{ maintenance_reboot_message }}'
+ reboot_timeout: '{{ maintenance_reboot_timeout | int }}'
+ pre_reboot_delay: '{{ [maintenance_reboot_pre_delay | int, 2] | max }}'
+ post_reboot_delay: '{{ maintenance_reboot_post_delay | int }}'
+ connect_timeout: 30
+ when: '''windows'' in group_names'
diff --git a/roles/maintenance_start_stopped_services/README.md b/roles/maintenance_start_stopped_services/README.md
new file mode 100644
index 0000000..af0c9b8
--- /dev/null
+++ b/roles/maintenance_start_stopped_services/README.md
@@ -0,0 +1,19 @@
+# maintenance_start_stopped_services
+
+Operator defaults are intentionally low-precedence; inventory and explicit run options may override them.
+
+```yaml
+---
+
+maintenance_service_include: []
+maintenance_service_exclude: []
+maintenance_service_fail_on_error: true
+```
+
+## Structured result integration
+
+Current reporting behavior and field semantics are specified in
+[scripts/docs/OPERATION_RESULTS.md](../../scripts/docs/OPERATION_RESULTS.md).
+The calling catalog playbook owns publication; helper roles do not implicitly export
+arbitrary facts, module results or debug data. Existing defaults above retain their
+precedence. See the current validation/sanity documents before using the new candidate.
diff --git a/roles/maintenance_start_stopped_services/defaults/main.yml b/roles/maintenance_start_stopped_services/defaults/main.yml
new file mode 100644
index 0000000..489b194
--- /dev/null
+++ b/roles/maintenance_start_stopped_services/defaults/main.yml
@@ -0,0 +1,5 @@
+---
+
+maintenance_service_include: []
+maintenance_service_exclude: []
+maintenance_service_fail_on_error: true
diff --git a/roles/maintenance_start_stopped_services/tasks/main.yml b/roles/maintenance_start_stopped_services/tasks/main.yml
new file mode 100644
index 0000000..31885f9
--- /dev/null
+++ b/roles/maintenance_start_stopped_services/tasks/main.yml
@@ -0,0 +1,55 @@
+ - name: Services | Validate selections
+ ansible.builtin.assert:
+ that:
+ - maintenance_service_include is sequence
+ - maintenance_service_include is not string
+ - maintenance_service_exclude is sequence
+ - maintenance_service_exclude is not string
+ - (maintenance_service_fail_on_error) is boolean or (maintenance_service_fail_on_error | string |
+ lower) in ['true', 'false']
+ fail_msg: Service selections must be lists of internal names; failure policy must be boolean.
+ quiet: true
+ - name: Services | Read current state
+ ansible.windows.win_service_info: {}
+ register: _aim_services
+ - name: Services | Start eligible stopped services
+ ansible.windows.win_service:
+ name: '{{ item.name }}'
+ state: started
+ loop: '{{ _aim_services.services }}'
+ loop_control:
+ label: '{{ item.name }}'
+ when:
+ - item.state == 'stopped'
+ - item.start_mode in ['auto', 'delayed']
+ - maintenance_service_include | length == 0 or item.name in maintenance_service_include
+ - item.name not in maintenance_service_exclude
+ register: _aim_service_starts
+ ignore_errors: true
+ - name: Services | Observe states after start attempts
+ ansible.windows.win_service_info: {}
+ register: _aim_services_after
+ - name: Services | Build before and after report
+ ansible.builtin.set_fact:
+ _aim_service_report: '{{ _aim_services.services | aim_report_services(_aim_service_starts.results | default([]),
+ _aim_services_after.services, maintenance_service_include, maintenance_service_exclude, ansible_check_mode)
+ }}'
+ - name: Services | Summary
+ ansible.builtin.debug:
+ msg: '{{ _aim_service_report }}'
+ - name: AIM | Publish operation result
+ ansible.builtin.set_stats:
+ per_host: true
+ aggregate: false
+ data:
+ aim_output:
+ protocol: aim_output_v1
+ schema: service_start_summary_v1
+ data: '{{ _aim_service_report }}'
+ - name: Services | Report partial failure
+ ansible.builtin.fail:
+ msg: One or more attempted services are not running. See failed_to_start in the structured report.
+ when:
+ - maintenance_service_fail_on_error | bool
+ - _aim_service_report.failed_count | int > 0
+ - not ansible_check_mode
diff --git a/roles/pfsense_apply_baseline/README.md b/roles/pfsense_apply_baseline/README.md
new file mode 100644
index 0000000..e92b90e
--- /dev/null
+++ b/roles/pfsense_apply_baseline/README.md
@@ -0,0 +1,3 @@
+# pfsense_apply_baseline
+
+Policy-preserving extraction from the uploaded production playbooks. Arguments, conditions, operation order, rule names and values were not changed. Invoke through the matching playbook.
diff --git a/roles/pfsense_apply_baseline/tasks/main.yml b/roles/pfsense_apply_baseline/tasks/main.yml
new file mode 100644
index 0000000..66fa555
--- /dev/null
+++ b/roles/pfsense_apply_baseline/tasks/main.yml
@@ -0,0 +1,153 @@
+---
+# Policy-preserving extraction from configure_pfsense_initial.yml. Do not change rule values without separate approval.
+- name: Check current bell state
+ ansible.builtin.raw: sysctl -n hw.syscons.bell
+ register: bell_state
+ changed_when: false
+- name: Disable startup/shutdown beep
+ ansible.builtin.raw: sysctl hw.syscons.bell=0
+ when: bell_state.stdout.strip() == "1"
+ changed_when: true
+- name: Create alias bf_wan_extern
+ pfsensible.core.pfsense_alias:
+ name: bf_wan_extern
+ descr: bitformer WAN IP Adressen
+ address: 217.13.70.132 87.138.207.238 80.152.155.27 217.13.174.202
+ type: host
+ state: present
+- name: Create alias bf_wartung
+ pfsensible.core.pfsense_alias:
+ name: bf_wartung
+ descr: bitformer Wartungs-IP
+ address: 10.240.0.1
+ type: host
+ state: present
+- name: Create alias rfc_1918_5735
+ pfsensible.core.pfsense_alias:
+ name: rfc_1918_5735
+ descr: RFC1918, RFC5735 (private IPs)
+ address: 10.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16
+ type: network
+ state: present
+- name: 'Add NAT outbound traffic rule (Port: 500)'
+ pfsensible.core.pfsense_nat_outbound:
+ descr: 'Outbound NAT for private IP space (Port: 500)'
+ interface: wan
+ address: null
+ source: rfc_1918_5735
+ destination: any:500
+ staticnatport: true
+ state: present
+- name: Add NAT outbound traffic rule
+ pfsensible.core.pfsense_nat_outbound:
+ descr: Outbound NAT for private IP space
+ interface: wan
+ address: null
+ source: rfc_1918_5735
+ destination: any
+ state: present
+- name: 'Add firewall rule: Allow Ping'
+ pfsensible.core.pfsense_rule:
+ name: Allow Ping
+ action: pass
+ interface: wan
+ ipprotocol: inet
+ protocol: icmp
+ icmptype: echoreq
+ source: any
+ destination: IP:wan
+ log: true
+ state: present
+- name: 'Add firewall rule: Allow Webinterface access'
+ pfsensible.core.pfsense_rule:
+ name: bitformer Webinterface access rule
+ action: pass
+ interface: wan
+ ipprotocol: inet
+ protocol: tcp
+ destination_port: 443
+ source: bf_wan_extern
+ destination: IP:wan
+ log: true
+ state: present
+- name: Create Anti-Lockout ports alias
+ pfsensible.core.pfsense_alias:
+ name: anti_lockout_ports
+ type: port
+ address: 22 80 443
+ descr: Ports for Anti-Lockout access
+- name: Add custom Anti-Lockout Rule
+ pfsensible.core.pfsense_rule:
+ name: Custom Anti-Lockout Rule
+ action: pass
+ interface: wan
+ ipprotocol: inet
+ protocol: tcp
+ destination_port: anti_lockout_ports
+ source: any
+ destination: IP:wan
+ log: true
+ state: present
+- name: Add bitconnect VPN CA 2021
+ pfsensible.core.pfsense_ca:
+ name: bitconnect VPN CA 2021
+ certificate: |
+ -----BEGIN CERTIFICATE-----
+ MIIFbDCCA1SgAwIBAgIUWV573JfAztSareWb4jnkNIdlEt8wDQYJKoZIhvcNAQEL
+ BQAwITEfMB0GA1UEAwwWYml0Y29ubmVjdCBWUE4gQ0EgMjAyMTAeFw0yMTEwMDgx
+ NjA1MzNaFw0zMTEwMDYxNjA1MzNaMCExHzAdBgNVBAMMFmJpdGNvbm5lY3QgVlBO
+ IENBIDIwMjEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDjjoWk81zG
+ vmdKCICW27cnQV6kmDwwXezC7cdmk8nVKnlcJAnVPExYV1M1wa9OYDgjb+Jc7vEV
+ UKKc7h/JtWt5OSg7aLq5eh6ZZminYG+lrKC7CFhyPnKuLPW4D6ye6iTVpwlhMMbv
+ fCiinuA2shniQtX21QYc8jxKdJnmfgv7/JJ0BxnWCyE8yn1xy6DyjPH9ystzKFGO
+ C3HH2wH2+sgKuiyk+8yxEF3hktMIDZ/D1gTyw8wsL4PgOs78EBSf0UKjAaBBjem8
+ rICxBo4yh7YvVJ+MMLc+2IMcyM3wpYPbMpA/0hXoWsAeYEOvrZR+MYQ08cQw8QEk
+ MHetIjbksd9D1OSdMfghr+A+dxVpQWTOnUnG48L84E/6RD0STyz/uJjNDJ5Qn4uV
+ 8e6ELHbbPiWVQWw+kg8tVetH6+WELXf/7pUnV9uYr7DvijEROP6l1IX/r92qC270
+ /QFiadYX0lroqaWdwk5z7DFnVVcCHqchygS97exzDg68LkSJ5pOZ6spIr9WfaGxD
+ 3dva4ekC/HEZUdau+NnBPnOCLD6EqOnh3RAJosgX6/eb5LHhk5agruM+1PZcWPiL
+ 5D0HvNjKOCGXW2yVd1fAdOy0onP4OQHK3gJPNFJ9m/LXnn5+CHvYct+3qDlxZcCR
+ qH9QBE7kC/8pRmHZqC1g0rn8yiQ012eCpwIDAQABo4GbMIGYMB0GA1UdDgQWBBQ4
+ pYw9Df2Ln2pUE/xRDjhebugWpTBcBgNVHSMEVTBTgBQ4pYw9Df2Ln2pUE/xRDjhe
+ bugWpaElpCMwITEfMB0GA1UEAwwWYml0Y29ubmVjdCBWUE4gQ0EgMjAyMYIUWV57
+ 3JfAztSareWb4jnkNIdlEt8wDAYDVR0TBAUwAwEB/zALBgNVHQ8EBAMCAQYwDQYJ
+ KoZIhvcNAQELBQADggIBAN1OOdv5rDgtBhYnmnId4iifvjzKQEQF5go4cJDueUo4
+ 86u6xJrv83sC53FKxaAcNZUxJEcFFBHKlCUTNADsKDdQxIPA2Ow6goIx914VbFO/
+ OmkFWS+53o4V8zRrwTxJi3Ps0R3sgp3pok5fQNL30tMZIf1Ug05jOqSCT8GBzIS8
+ wwSw6aNi/Zcs9sBuaEEap47faTowk53EJykUd8htzH/3E5ioi3hE8TsZYPKb4Frk
+ mMqRbX6N78fZ0xOIewh58S4eeGlRCGlRs45ciVxuryTaloFfDDBFFBR3V4q4Y/y9
+ dvjiRmDs4fod1ZGEFUfVyDPXjzWCUUQiMHxUoh9s06i5zO3YCB/mkh+11ohyE109
+ ciN495vhpTONQ8GzXLc87GjVUow7btn3lfaMf1sTfLhAueHNNbDHTnhRFkUtrdCx
+ 73+MYpiSlLpBxnyLkTM4umYXeYNN5Od0UjYZZqlLK9MNZrM5CwVxjRxJjgV6Nbap
+ 4Apnfqi+d3Ulnc6Zk8w0tiVcRfrLR6EVA3JEHqFQLuXbU1+K8C0N9YNFHy2iGAxF
+ Ysx7aJVvmzyoiB06/qQrHcjeizVJaqR7w6+1cuTKo+fdvp2KtyK8+pFtQt4n+unw
+ 3eLC1NdcEyWBtlKqG6sjSXCBCgc1z2wAOfR+sQsH64uVHqxND4rPo8X4uo28Jgv9
+ -----END CERTIFICATE-----
+ state: present
+- name: Add bitconnect VPN-Client
+ pfsensible.core.pfsense_openvpn_client:
+ name: bitformer Wartungs-VPN
+ ca: bitconnect VPN CA 2021
+ cert: bf-customers-vpn01
+ create_gw: both
+ data_ciphers:
+ - AES-256-GCM
+ - AES-256-CBC
+ - AES-128-GCM
+ - CHACHA20-POLY1305
+ data_ciphers_fallback: AES-256-CBC
+ dev_mode: tun
+ digest: SHA384
+ interface: any
+ mode: p2p_tls
+ protocol: UDP4
+ server_addr: vpngw01.bitformer.net
+ server_port: 42030
+ state: present
+- name: Print left over manual settings to configure
+ ansible.builtin.debug:
+ msg:
+ - 'Manuelle Einstellungen müssen vorgenommen werden:'
+ - 'Firewall Max Table Entries: 500000'
+ - Disable startup/shutdown beep
+ - Password protect the console
diff --git a/roles/pfsense_install_prerequisites/README.md b/roles/pfsense_install_prerequisites/README.md
new file mode 100644
index 0000000..6719746
--- /dev/null
+++ b/roles/pfsense_install_prerequisites/README.md
@@ -0,0 +1,3 @@
+# pfsense_install_prerequisites
+
+Policy-preserving extraction from the uploaded production playbooks. Arguments, conditions, operation order, rule names and values were not changed. Invoke through the matching playbook.
diff --git a/roles/pfsense_install_prerequisites/tasks/main.yml b/roles/pfsense_install_prerequisites/tasks/main.yml
new file mode 100644
index 0000000..028b897
--- /dev/null
+++ b/roles/pfsense_install_prerequisites/tasks/main.yml
@@ -0,0 +1,7 @@
+---
+# Policy-preserving extraction from configure_pfsense_initial.yml. Do not change rule values without separate approval.
+- name: Install package
+ ansible.builtin.package:
+ name:
+ - pfSense-pkg-sudo
+ state: present
diff --git a/roles/server_role_selection/defaults/main.yml b/roles/server_role_selection/defaults/main.yml
deleted file mode 100644
index 0dbd25c..0000000
--- a/roles/server_role_selection/defaults/main.yml
+++ /dev/null
@@ -1,12 +0,0 @@
----
-ad_ds_feature_name: "AD-Domain-Services"
-dhcp_windows_service_name: "DHCPServer"
-veeam_services:
- vbr: "VeeamBackupSvc"
- vbo: "Veeam.Archiver.Service"
- em: "VeeamEnterpriseManagerSvc"
-unifi_linux_services:
- - unifi
- - unifi.service
-unifi_linux_packages:
- - unifi
diff --git a/roles/server_role_selection/meta/main.yml b/roles/server_role_selection/meta/main.yml
deleted file mode 100644
index c5d1af7..0000000
--- a/roles/server_role_selection/meta/main.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-galaxy_info:
- role_name: server_role_selection
- description: Detect server roles used for Checkmk deployment decisions
- min_ansible_version: "2.18"
-dependencies: []
diff --git a/roles/server_role_selection/tasks/main.yml b/roles/server_role_selection/tasks/main.yml
deleted file mode 100644
index 4c67e66..0000000
--- a/roles/server_role_selection/tasks/main.yml
+++ /dev/null
@@ -1,111 +0,0 @@
----
-# ==========================
-# WINDOWS DETECTION
-# ==========================
-- name: "Windows | Detect AD DS feature (DC)"
- when: ansible_facts['os_family'] == "Windows"
- ansible.windows.win_feature_info:
- name: "{{ ad_ds_feature_name }}"
- register: _win_dc_feature
- failed_when: false
-
-- name: "Windows | Fallback: check NTDS service (DC)"
- when: ansible_facts['os_family'] == "Windows"
- ansible.windows.win_service_info:
- name: "NTDS"
- register: _win_ntds_svc
- failed_when: false
-
-- name: "Windows | Check DHCP service"
- when: ansible_facts['os_family'] == "Windows"
- ansible.windows.win_service_info:
- name: "{{ dhcp_windows_service_name }}"
- register: _win_dhcp_svc
- failed_when: false
-
-- name: "Windows | Check Veeam VBR service"
- when: ansible_facts['os_family'] == "Windows"
- ansible.windows.win_service_info:
- name: "{{ veeam_services.vbr }}"
- register: _veeam_vbr
- failed_when: false
-
-- name: "Windows | Check Veeam VBO service"
- when: ansible_facts['os_family'] == "Windows"
- ansible.windows.win_service_info:
- name: "{{ veeam_services.vbo }}"
- register: _veeam_vbo
- failed_when: false
-
-- name: "Windows | Check Veeam Enterprise Manager service"
- when: ansible_facts['os_family'] == "Windows"
- ansible.windows.win_service_info:
- name: "{{ veeam_services.em }}"
- register: _veeam_em
- failed_when: false
-
-- name: "Windows | Check Hyper-V service"
- when: ansible_facts['os_family'] == "Windows"
- ansible.windows.win_service_info:
- name: "vmms"
- register: _win_hyperv_svc
- failed_when: false
-
-- name: "Windows | Set detection booleans"
- when: ansible_facts['os_family'] == "Windows"
- ansible.builtin.set_fact:
- is_dc: >-
- {{
- (((_win_dc_feature.features | default([])) | selectattr('installed') | list | length) > 0)
- or (_win_ntds_svc.exists | default(false))
- }}
- is_dhcp_server: "{{ _win_dhcp_svc.exists | default(false) }}"
- has_veeam_vbr: "{{ _veeam_vbr.exists | default(false) }}"
- has_veeam_vbo: "{{ _veeam_vbo.exists | default(false) }}"
- has_veeam_em: "{{ _veeam_em.exists | default(false) }}"
- is_hyperv_host: "{{ _win_hyperv_svc.exists | default(false) }}"
-
-- name: "Windows | Debug summary"
- when: ansible_facts['os_family'] == "Windows"
- ansible.builtin.debug:
- msg:
- is_dc: "{{ is_dc }}"
- is_dhcp_server: "{{ is_dhcp_server }}"
- has_veeam_vbr: "{{ has_veeam_vbr }}"
- has_veeam_vbo: "{{ has_veeam_vbo }}"
- has_veeam_em: "{{ has_veeam_em }}"
- is_hyperv_host: "{{ is_hyperv_host }}"
-
-# ==========================
-# LINUX DETECTION
-# ==========================
-- name: "Linux | Collect service facts"
- when: ansible_facts['os_family'] != "Windows"
- ansible.builtin.service_facts:
-
-- name: "Linux | Collect package facts"
- when: ansible_facts['os_family'] != "Windows"
- ansible.builtin.package_facts:
- manager: auto
-
-- name: "Linux | Set UniFi flag"
- when: ansible_facts['os_family'] != "Windows"
- vars:
- svcs: "{{ ansible_facts.services | default({}) }}"
- pkgs: "{{ ansible_facts.packages | default({}) | list }}"
- ansible.builtin.set_fact:
- is_unifi_controller: >-
- {{
- (unifi_linux_services | select('in', svcs.keys()) | list | length > 0)
- or (pkgs | intersect(unifi_linux_packages) | length > 0)
- }}
-
-- name: "Normalize detection booleans"
- ansible.builtin.set_fact:
- is_dc: "{{ is_dc | default(false) }}"
- is_dhcp_server: "{{ is_dhcp_server | default(false) }}"
- has_veeam_vbr: "{{ has_veeam_vbr | default(false) }}"
- has_veeam_vbo: "{{ has_veeam_vbo | default(false) }}"
- has_veeam_em: "{{ has_veeam_em | default(false) }}"
- is_hyperv_host: "{{ is_hyperv_host | default(false) }}"
- is_unifi_controller: "{{ is_unifi_controller | default(false) }}"
diff --git a/roles/sophos_apply_baseline/README.md b/roles/sophos_apply_baseline/README.md
new file mode 100644
index 0000000..ffa7112
--- /dev/null
+++ b/roles/sophos_apply_baseline/README.md
@@ -0,0 +1,3 @@
+# sophos_apply_baseline
+
+Policy-preserving extraction from the uploaded production playbooks. Arguments, conditions, operation order, rule names and values were not changed. Invoke through the matching playbook.
diff --git a/roles/sophos_apply_baseline/tasks/main.yml b/roles/sophos_apply_baseline/tasks/main.yml
new file mode 100644
index 0000000..1de131a
--- /dev/null
+++ b/roles/sophos_apply_baseline/tasks/main.yml
@@ -0,0 +1,500 @@
+---
+# Policy-preserving extraction from configure_sophos_initial_bitformer_config.yml. Do not change rule values without separate approval.
+- name: Erstelle 'bf_wan' IP Liste
+ sophos.sophos_firewall.sfos_xmlapi:
+ xml_tag: IPHost
+ data: |
+
+ bf_wan
+ WAN-IPs von bitformer
+ IPList
+ 217.13.70.132,87.138.207.238,80.152.155.27,217.13.174.202
+
+ state: present
+- name: Erstelle 'bf_wartung' IP-Host
+ sophos.sophos_firewall.sfos_ip_host:
+ name: bf_wartung
+ ip_address: 10.240.0.1
+ state: present
+- name: Netzwerke als IP-Hosts in der Firewall anlegen
+ sophos.sophos_firewall.sfos_ip_host:
+ name: '{{ item.name }}'
+ network: '{{ item.network }}'
+ mask: '{{ item.subnetmask }}'
+ host_type: network
+ state: present
+ loop: '{{ network_hosts }}'
+- name: Erstelle 'rfc_1918_5735' Gruppe
+ sophos.sophos_firewall.sfos_ip_hostgroup:
+ name: rfc_1918_5735
+ description: rfc_1918_5735
+ host_list:
+ - rfc_1918_a
+ - rfc_1918_b
+ - rfc_1918_c
+ - rfc_5735
+ state: present
+- name: Erstelle 'OpenVPN' Service
+ sophos.sophos_firewall.sfos_service:
+ name: OpenVPN
+ type: tcporudp
+ service_list:
+ - protocol: udp
+ src_port: 1:65535
+ dst_port: 1194
+ state: present
+- name: Erstelle 'dgrp_wan_access_guests' Service Group
+ sophos.sophos_firewall.sfos_servicegroup:
+ name: dgrp_wan_access_guests
+ description: WAN Access Guests
+ service_list:
+ - PING
+ - HTTP
+ - HTTPS
+ - SMTPS_465
+ - SMTPS
+ - IMAP
+ - IMAPS
+ - POP3S
+ - IKE
+ - OpenVPN
+ state: present
+- name: Erstelle 'dgrp_wan_access_office' Service Group
+ sophos.sophos_firewall.sfos_servicegroup:
+ name: dgrp_wan_access_office
+ description: WAN Access Office
+ service_list:
+ - PING
+ - SSH
+ - HTTP
+ - HTTPS
+ state: present
+- name: Erstelle bitformer Management Access ACL Ausnahmeregel
+ sophos.sophos_firewall.sfos_service_acl_exception:
+ name: bitformer Management Access
+ description: Allow Management-Access to Webinterface, PING and SSH
+ position: bottom
+ source_zone: WAN
+ source_list:
+ - bf_wan
+ service_list:
+ - HTTPS
+ - SSH
+ - PING
+ action: accept
+ state: present
+- name: Erstelle bitformer Monitoring ACL Ausnahmeregel
+ sophos.sophos_firewall.sfos_service_acl_exception:
+ name: bitformer Monitoring
+ description: Allow Monitoring-Access
+ position: bottom
+ source_zone: VPN
+ source_list:
+ - bf_wartung
+ service_list:
+ - DNS
+ - HTTPS
+ - SSH
+ - PING
+ action: accept
+ state: present
+- name: Erstelle UserPortal Country-Restricted ACL Ausnahmeregel
+ sophos.sophos_firewall.sfos_service_acl_exception:
+ name: UserPortal Country-Restricted
+ description: Allow UserPort from Selected Countries
+ position: bottom
+ source_zone: WAN
+ source_list:
+ - Germany
+ - Austria
+ - Switzerland
+ service_list:
+ - UserPortal
+ action: accept
+ state: present
+- name: Erstelle 'bitformer' IPSec Profile
+ sophos.sophos_firewall.sfos_xmlapi:
+ xml_tag: VPNProfile
+ data: |
+
+ bitformer
+ IPSec Policy bitformer Wartungszugang
+ Automatic
+ Enable
+ 0
+ MainMode
+ Disable
+ Disable
+
+ AES256
+ SHA2_256
+
+
+
+
+
+ 16(DH4096)
+
+ 28800
+ 360
+ 50
+ Enable
+ 10
+ 25
+ ReInitiate
+
+
+ AES256
+ SHA2_256
+
+
+
+
+ SameasPhase-I
+ 3600
+
+ no
+ ikev2
+
+ state: present
+- name: Erstelle 'Mitarbeiter IPSec VPN' IPSec Profile
+ sophos.sophos_firewall.sfos_xmlapi:
+ xml_tag: VPNProfile
+ data: |
+
+ Mitarbeiter IPSec VPN
+ IPSec VPN für Mitarbeiter
+ Automatic
+ Enable
+ 0
+ MainMode
+ Disable
+
+ AES256
+ SHA2_256
+ AES256
+ SHA2_384
+
+
+
+ 14(DH2048)
+ 16(DH4096)
+ 18(DH8192)
+ 19(ecp256)
+ 21(ecp521)
+ 31(curve25519)
+
+ 36000
+ 360
+ 100
+ Enable
+ 60
+ 240
+ Disconnect
+
+
+ AES256
+ SHA2_256
+ AES256
+ SHA2_384
+
+
+ SameasPhase-I
+ 32400
+
+ no
+ ikev1
+
+ state: present
+- name: Update LAN Zone Admin Services
+ sophos.sophos_firewall.sfos_zone:
+ name: LAN
+ https: Enable
+ ssh: Enable
+ ad_sso: Enable
+ captive_portal: Enable
+ radius_sso: Disable
+ client_authen: Enable
+ chromebook_sso: Disable
+ ping: Enable
+ dns: Enable
+ ipsec: Disable
+ sslvpn: Disable
+ vpn_portal: Enable
+ red: Disable
+ wireless_protection: Disable
+ web_proxy: Disable
+ user_portal: Enable
+ smtp_relay: Disable
+ snmp: Disable
+ state: updated
+- name: Update WAN Zone Admin Services
+ sophos.sophos_firewall.sfos_zone:
+ name: WAN
+ https: Disable
+ ssh: Disable
+ ad_sso: Disable
+ captive_portal: Disable
+ radius_sso: Disable
+ client_authen: Disable
+ chromebook_sso: Disable
+ ping: Enable
+ dns: Disable
+ ipsec: Enable
+ sslvpn: Disable
+ vpn_portal: Disable
+ red: Disable
+ wireless_protection: Disable
+ web_proxy: Disable
+ user_portal: Disable
+ smtp_relay: Disable
+ snmp: Disable
+ state: updated
+- name: Update DMZ Zone Admin Services
+ sophos.sophos_firewall.sfos_zone:
+ name: DMZ
+ https: Disable
+ ssh: Enable
+ ad_sso: Disable
+ captive_portal: Disable
+ radius_sso: Disable
+ client_authen: Disable
+ chromebook_sso: Disable
+ ping: Enable
+ dns: Disable
+ ipsec: Disable
+ sslvpn: Disable
+ vpn_portal: Disable
+ red: Disable
+ wireless_protection: Disable
+ web_proxy: Disable
+ user_portal: Disable
+ smtp_relay: Disable
+ snmp: Disable
+ state: updated
+- name: Update VPN Zone Admin Services
+ sophos.sophos_firewall.sfos_zone:
+ name: VPN
+ https: Enable
+ ssh: Enable
+ ad_sso: Disable
+ captive_portal: Enable
+ radius_sso: Disable
+ client_authen: Disable
+ chromebook_sso: Disable
+ ping: Enable
+ dns: Enable
+ ipsec: Disable
+ sslvpn: Disable
+ vpn_portal: Enable
+ red: Disable
+ wireless_protection: Disable
+ web_proxy: Disable
+ user_portal: Enable
+ smtp_relay: Disable
+ snmp: Enable
+ state: updated
+- name: Update WiFi Zone Admin Services
+ sophos.sophos_firewall.sfos_zone:
+ name: WiFi
+ https: Disable
+ ssh: Disable
+ ad_sso: Disable
+ captive_portal: Disable
+ radius_sso: Disable
+ client_authen: Disable
+ chromebook_sso: Disable
+ ping: Disable
+ dns: Disable
+ ipsec: Disable
+ sslvpn: Disable
+ vpn_portal: Disable
+ red: Disable
+ wireless_protection: Disable
+ web_proxy: Disable
+ user_portal: Disable
+ smtp_relay: Disable
+ snmp: Disable
+ state: updated
+- name: Entferne 'Auto added firewall policy for MTA' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: Auto added firewall policy for MTA
+ state: absent
+- name: Erstelle 'LAN_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_WAN
+ action: accept
+ description: Lan > WAN Regel
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - Any
+ src_networks:
+ - Any
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN > WAN' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: LAN > WAN
+ description: Lan to WAN group
+ policy_list:
+ - LAN_to_WAN
+ policy_type: Any
+ source_zones:
+ - LAN
+ dest_zones:
+ - WAN
+ state: present
+- name: Erstelle 'bitformer Wartungszugang' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: bitformer Wartungszugang
+ action: accept
+ description: bitconnect Zugriff
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - VPN
+ dst_zones:
+ - Any
+ src_networks:
+ - bf_wartung
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'bitformer SPN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: bitformer SPN
+ action: accept
+ description: Zugriff auf bitformer SPN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - VPN
+ dst_zones:
+ - Any
+ src_networks:
+ - bf_spn_network
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_to_bitformer_SPN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_bitformer_SPN
+ action: accept
+ description: Zugriff auf bitformer SPN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - VPN
+ src_networks:
+ - Any
+ dst_networks:
+ - bf_spn_network
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'bitformer' Firewall Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: bitformer
+ description: bitformer group
+ policy_list:
+ - bitformer Wartungszugang
+ - bitformer SPN
+ - LAN_to_bitformer_SPN
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - Any
+ state: present
+- name: Erstelle 'VPN_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: VPN_to_WAN
+ action: accept
+ description: Zugriff von VPN
+ log: enable
+ status: disable
+ position: bottom
+ src_zones:
+ - VPN
+ dst_zones:
+ - LAN
+ src_networks:
+ - Any
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'VPN' Firewall Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: VPN
+ description: VPN group
+ policy_list:
+ - VPN_to_WAN
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - Any
+ state: present
+- name: Entferne [example] Firewallregeln
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: '{{ item }}'
+ state: absent
+ loop: '{{ firewall_rules_to_remove }}'
+- name: Erstelle 'DROP_ALL_LOG' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: DROP_ALL_LOG
+ action: drop
+ description: Verwirft alle Pakete mit Log
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - Any
+ dst_zones:
+ - Any
+ src_networks:
+ - Any
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Aktiviere 'Vordefinierten NTP-Server verwenden'
+ sophos.sophos_firewall.sfos_xmlapi:
+ xml_tag: Time
+ data: |
+
+ Europe/Berlin
+
+
+
+
+
+
+
+
+
+ 0
+
+
+ Enable
+
+ state: updated
diff --git a/roles/sophos_customer_bluuunit/README.md b/roles/sophos_customer_bluuunit/README.md
new file mode 100644
index 0000000..dc9f715
--- /dev/null
+++ b/roles/sophos_customer_bluuunit/README.md
@@ -0,0 +1,3 @@
+# sophos_customer_bluuunit
+
+Policy-preserving extraction from the uploaded production playbooks. Arguments, conditions, operation order, rule names and values were not changed. Invoke through the matching playbook.
diff --git a/roles/sophos_customer_bluuunit/tasks/main.yml b/roles/sophos_customer_bluuunit/tasks/main.yml
new file mode 100644
index 0000000..314ad5f
--- /dev/null
+++ b/roles/sophos_customer_bluuunit/tasks/main.yml
@@ -0,0 +1,527 @@
+---
+# Customer-specific firewall policy preserved from the uploaded source.
+- name: Update hostname settings
+ sophos.sophos_firewall.sfos_admin_settings:
+ hostname_settings:
+ hostname: '{{ hostname }}'
+ state: updated
+- name: Netzwerke als IP-Hosts in der Firewall anlegen
+ sophos.sophos_firewall.sfos_ip_host:
+ name: '{{ item.value.name }}'
+ network: '{{ item.value.network }}'
+ mask: '{{ item.value.subnetmask }}'
+ host_type: network
+ state: present
+ loop: '{{ network_objects | dict2items }}'
+- name: Zonen erstellen
+ sophos.sophos_firewall.sfos_zone:
+ name: '{{ item.value.zone_name }}'
+ description: '{{ item.value.zone_description }}'
+ zone_type: '{{ item.value.zone_type }}'
+ state: present
+ loop: '{{ vlan_interfaces | dict2items }}'
+ when: item.value.name != "LAN"
+- name: Update Management Zone Admin Services
+ sophos.sophos_firewall.sfos_zone:
+ name: Management
+ https: Enable
+ ssh: Enable
+ ad_sso: Disable
+ captive_portal: Disable
+ radius_sso: Disable
+ client_authen: Disable
+ chromebook_sso: Disable
+ ping: Enable
+ dns: Enable
+ ipsec: Disable
+ sslvpn: Disable
+ vpn_portal: Disable
+ red: Disable
+ wireless_protection: Disable
+ web_proxy: Disable
+ user_portal: Disable
+ smtp_relay: Disable
+ snmp: Disable
+ state: updated
+- name: Update Server Zone Admin Services
+ sophos.sophos_firewall.sfos_zone:
+ name: Server
+ https: Enable
+ ssh: Disable
+ ad_sso: Disable
+ captive_portal: Disable
+ radius_sso: Disable
+ client_authen: Disable
+ chromebook_sso: Disable
+ ping: Enable
+ dns: Enable
+ ipsec: Disable
+ sslvpn: Disable
+ vpn_portal: Disable
+ red: Disable
+ wireless_protection: Disable
+ web_proxy: Disable
+ user_portal: Disable
+ smtp_relay: Disable
+ snmp: Disable
+ state: updated
+- name: Update Guest Zone Admin Services
+ sophos.sophos_firewall.sfos_zone:
+ name: Guest
+ https: Disable
+ ssh: Disable
+ ad_sso: Disable
+ captive_portal: Disable
+ radius_sso: Disable
+ client_authen: Disable
+ chromebook_sso: Disable
+ ping: Enable
+ dns: Enable
+ ipsec: Disable
+ sslvpn: Disable
+ vpn_portal: Disable
+ red: Disable
+ wireless_protection: Disable
+ web_proxy: Disable
+ user_portal: Disable
+ smtp_relay: Disable
+ snmp: Disable
+ state: updated
+- name: Update Facility Zone Admin Services
+ sophos.sophos_firewall.sfos_zone:
+ name: Facility
+ https: Disable
+ ssh: Disable
+ ad_sso: Disable
+ captive_portal: Disable
+ radius_sso: Disable
+ client_authen: Disable
+ chromebook_sso: Disable
+ ping: Enable
+ dns: Enable
+ ipsec: Disable
+ sslvpn: Disable
+ vpn_portal: Disable
+ red: Disable
+ wireless_protection: Disable
+ web_proxy: Disable
+ user_portal: Disable
+ smtp_relay: Disable
+ snmp: Disable
+ state: updated
+- name: Update VOIP Zone Admin Services
+ sophos.sophos_firewall.sfos_zone:
+ name: VOIP
+ https: Disable
+ ssh: Disable
+ ad_sso: Disable
+ captive_portal: Disable
+ radius_sso: Disable
+ client_authen: Disable
+ chromebook_sso: Disable
+ ping: Enable
+ dns: Enable
+ ipsec: Disable
+ sslvpn: Disable
+ vpn_portal: Disable
+ red: Disable
+ wireless_protection: Disable
+ web_proxy: Disable
+ user_portal: Disable
+ smtp_relay: Disable
+ snmp: Disable
+ state: updated
+- name: Add VLAN Interfaces
+ sophos.sophos_firewall.sfos_xmlapi:
+ xml_tag: VLAN
+ data: |
+
+ {{ item.value.name }}
+ Port1
+ Port1
+ {{ item.value.zone_name }}
+ {{ item.value.vlan_id }}
+ Enable
+ Static
+ {{ item.value.ip_address }}
+ {{ item.value.subnetmask }}
+
+ state: present
+ loop: '{{ vlan_interfaces | dict2items }}'
+ loop_control:
+ label: '{{ item.key }} -> VLAN {{ item.value.vlan_id }}'
+- name: Erstelle 'LAN_to_LAN_old' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_LAN_old
+ action: accept
+ description: Erlaubt Zugriff von LAN auf Bestandsnetz
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - LAN
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - '{{ network_objects.lan_old.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'INTERNAL_to_bu_RZ' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: INTERNAL_to_bu_RZ
+ action: accept
+ description: Erlaubt Zugriff von internen Netzen auf bluu unit Rechenzentrum
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - Facility
+ - Guest
+ - LAN
+ - Management
+ - Server
+ - VOIP
+ dst_zones:
+ - VPN
+ src_networks:
+ - '{{ network_objects.facility.name }}'
+ - '{{ network_objects.guest.name }}'
+ - '{{ network_objects.lan_old.name }}'
+ - '{{ network_objects.management.name }}'
+ - '{{ network_objects.office.name }}'
+ - '{{ network_objects.server.name }}'
+ - '{{ network_objects.voip.name }}'
+ dst_networks:
+ - '{{ network_objects.derz_lan.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'SSLVPN_to_INTERNAL' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: SSLVPN_to_INTERNAL
+ action: accept
+ description: Erlaubt Zugriff von DERZ SSLVPN auf internen Netzen
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - VPN
+ dst_zones:
+ - Facility
+ - LAN
+ - Server
+ - VOIP
+ src_networks:
+ - '{{ network_objects.derz_sslvpn.name }}'
+ dst_networks:
+ - '{{ network_objects.facility.name }}'
+ - '{{ network_objects.lan_old.name }}'
+ - '{{ network_objects.office.name }}'
+ - '{{ network_objects.server.name }}'
+ - '{{ network_objects.voip.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von LAN auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_old_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_old_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von old LAN auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.lan_old.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'Server_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: Server_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von Server auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - Server
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.server.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'Management_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: Management_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von Management auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - Management
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.management.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'Guest_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: Guest_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von Guest auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - Guest
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.guest.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'Facility_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: Facility_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von Facility auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - Facility
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.facility.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'VOIP_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: VOIP_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von VOIP auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - VOIP
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.voip.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_to_Management' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_Management
+ action: accept
+ description: Erlaubt Zugriff von LAN auf Management
+ log: enable
+ status: disable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - Management
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - '{{ network_objects.management.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_to_Server' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_Server
+ action: accept
+ description: Erlaubt Zugriff von LAN auf Server
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - Server
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - '{{ network_objects.server.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_to_Facility' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_Facility
+ action: accept
+ description: Erlaubt Zugriff von LAN auf Facility
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - Facility
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - '{{ network_objects.facility.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_to_VOIP' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_VOIP
+ action: accept
+ description: Erlaubt Zugriff von LAN auf VOIP
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - VOIP
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - '{{ network_objects.voip.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'VPN' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: VPN
+ description: VPN
+ policy_list:
+ - INTERNAL_to_bu_RZ
+ - SSLVPN_to_INTERNAL
+ policy_type: Any
+ source_zones:
+ - VPN
+ dest_zones:
+ - Any
+ state: present
+- name: Erstelle 'X to Management' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to Management
+ description: Zugriff auf Management-Netz
+ policy_list:
+ - LAN_to_Management
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - Management
+ state: present
+- name: Erstelle 'X to Server' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to Server
+ description: Zugriff auf Server-Netz
+ policy_list:
+ - LAN_to_Server
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - Server
+ state: present
+- name: Erstelle 'X to LAN' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to LAN
+ description: Zugriff auf LAN-Netz
+ policy_list:
+ - LAN_to_LAN_old
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - LAN
+ state: present
+- name: Erstelle 'X to Facility' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to Facility
+ description: Zugriff auf Facility-Netz
+ policy_list:
+ - LAN_to_Facility
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - Facility
+ state: present
+- name: Erstelle 'X to VOIP' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to VOIP
+ description: Zugriff auf VOIP-Netz
+ policy_list:
+ - LAN_to_VOIP
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - VOIP
+ state: present
+- name: Erstelle 'X to WAN' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to WAN
+ description: X to WAN group
+ policy_list:
+ - LAN_to_WAN
+ - Server_to_WAN
+ - Management_to_WAN
+ - LAN_old_to_WAN
+ - Guest_to_WAN
+ - Facility_to_WAN
+ - VOIP_to_WAN
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - WAN
+ state: present
diff --git a/roles/sophos_customer_formicon/README.md b/roles/sophos_customer_formicon/README.md
new file mode 100644
index 0000000..b60b7bf
--- /dev/null
+++ b/roles/sophos_customer_formicon/README.md
@@ -0,0 +1,3 @@
+# sophos_customer_formicon
+
+Policy-preserving extraction from the uploaded production playbooks. Arguments, conditions, operation order, rule names and values were not changed. Invoke through the matching playbook.
diff --git a/roles/sophos_customer_formicon/tasks/main.yml b/roles/sophos_customer_formicon/tasks/main.yml
new file mode 100644
index 0000000..5473f88
--- /dev/null
+++ b/roles/sophos_customer_formicon/tasks/main.yml
@@ -0,0 +1,231 @@
+---
+# Customer-specific firewall policy preserved from the uploaded source.
+- name: Update hostname settings
+ sophos.sophos_firewall.sfos_admin_settings:
+ hostname_settings:
+ hostname: '{{ hostname }}'
+ state: updated
+- name: Netzwerke als IP-Hosts in der Firewall anlegen
+ sophos.sophos_firewall.sfos_ip_host:
+ name: '{{ item.value.name }}'
+ network: '{{ item.value.network }}'
+ mask: '{{ item.value.subnetmask }}'
+ host_type: network
+ state: present
+ loop: '{{ network_objects | dict2items }}'
+- name: Zonen erstellen
+ sophos.sophos_firewall.sfos_zone:
+ name: '{{ item.value.zone_name }}'
+ description: '{{ item.value.zone_description }}'
+ zone_type: '{{ item.value.zone_type }}'
+ state: present
+ loop: '{{ vlan_interfaces | dict2items }}'
+ when: item.value.name != "LAN"
+- name: Update Management Zone Admin Services
+ sophos.sophos_firewall.sfos_zone:
+ name: Management
+ https: Enable
+ ssh: Enable
+ ad_sso: Disable
+ captive_portal: Disable
+ radius_sso: Disable
+ client_authen: Disable
+ chromebook_sso: Disable
+ ping: Enable
+ dns: Enable
+ ipsec: Disable
+ sslvpn: Disable
+ vpn_portal: Disable
+ red: Disable
+ wireless_protection: Disable
+ web_proxy: Disable
+ user_portal: Disable
+ smtp_relay: Disable
+ snmp: Disable
+ state: updated
+- name: Add VLAN Interfaces
+ sophos.sophos_firewall.sfos_xmlapi:
+ xml_tag: VLAN
+ data: |
+
+ {{ item.value.name }}
+ Port1
+ Port1
+ {{ item.value.zone_name }}
+ {{ item.value.vlan_id }}
+ Enable
+ Static
+ {{ item.value.ip_address }}
+ {{ item.value.subnetmask }}
+
+ state: present
+ loop: '{{ vlan_interfaces | dict2items }}'
+ loop_control:
+ label: '{{ item.key }} -> VLAN {{ item.value.vlan_id }}'
+- name: Erstelle 'LAN_to_LAN_old' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_LAN_old
+ action: accept
+ description: Erlaubt Zugriff von LAN auf Bestandsnetz
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - LAN
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - '{{ network_objects.lan_old.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'INTERNAL_to_FHAZUREGWC_LAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: INTERNAL_to_FHAZUREGWC_LAN
+ action: accept
+ description: Erlaubt Zugriff von internen Netzen auf Formicon Holding Azure Germany West Central LAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ - Management
+ dst_zones:
+ - VPN
+ src_networks:
+ - '{{ network_objects.lan_old.name }}'
+ - '{{ network_objects.management.name }}'
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - '{{ network_objects.azuregwc_lan.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von LAN auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_old_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_old_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von old LAN auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.lan_old.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'Management_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: Management_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von Management auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - Management
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.management.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_to_Management' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_Management
+ action: accept
+ description: Erlaubt Zugriff von LAN auf Management
+ log: enable
+ status: disable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - Management
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - '{{ network_objects.management.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'VPN' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: VPN
+ description: VPN
+ policy_list:
+ - INTERNAL_to_FHAZUREGWC_LAN
+ policy_type: Any
+ source_zones:
+ - VPN
+ dest_zones:
+ - Any
+ state: present
+- name: Erstelle 'X to Management' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to Management
+ description: Zugriff auf Management-Netz
+ policy_list:
+ - LAN_to_Management
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - Management
+ state: present
+- name: Erstelle 'X to LAN' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to LAN
+ description: Zugriff auf LAN-Netz
+ policy_list:
+ - LAN_to_LAN_old
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - LAN
+ state: present
+- name: Erstelle 'X to WAN' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to WAN
+ description: X to WAN group
+ policy_list:
+ - LAN_to_WAN
+ - Management_to_WAN
+ - LAN_old_to_WAN
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - WAN
+ state: present
diff --git a/roles/sophos_customer_gebhardt_stahl/README.md b/roles/sophos_customer_gebhardt_stahl/README.md
new file mode 100644
index 0000000..0de8dfa
--- /dev/null
+++ b/roles/sophos_customer_gebhardt_stahl/README.md
@@ -0,0 +1,3 @@
+# sophos_customer_gebhardt_stahl
+
+Policy-preserving extraction from the uploaded production playbooks. Arguments, conditions, operation order, rule names and values were not changed. Invoke through the matching playbook.
diff --git a/roles/sophos_customer_gebhardt_stahl/tasks/main.yml b/roles/sophos_customer_gebhardt_stahl/tasks/main.yml
new file mode 100644
index 0000000..46b80ca
--- /dev/null
+++ b/roles/sophos_customer_gebhardt_stahl/tasks/main.yml
@@ -0,0 +1,195 @@
+---
+# Customer-specific firewall policy preserved from the uploaded source.
+- name: Update hostname settings
+ sophos.sophos_firewall.sfos_admin_settings:
+ hostname_settings:
+ hostname: '{{ hostname }}'
+ state: updated
+- name: Netzwerke als IP-Hosts in der Firewall anlegen
+ sophos.sophos_firewall.sfos_ip_host:
+ name: '{{ item.value.name }}'
+ network: '{{ item.value.network }}'
+ mask: '{{ item.value.subnetmask }}'
+ host_type: network
+ state: present
+ loop: '{{ network_objects | dict2items }}'
+- name: Zonen erstellen
+ sophos.sophos_firewall.sfos_zone:
+ name: '{{ item.value.zone_name }}'
+ description: '{{ item.value.zone_description }}'
+ zone_type: '{{ item.value.zone_type }}'
+ state: present
+ loop: '{{ vlan_interfaces | dict2items }}'
+ when: item.value.name != "LAN"
+- name: Add VLAN Interfaces
+ sophos.sophos_firewall.sfos_xmlapi:
+ xml_tag: VLAN
+ data: |
+
+ {{ item.value.name }}
+ Port1
+ Port1
+ {{ item.value.zone_name }}
+ {{ item.value.vlan_id }}
+ Enable
+ Static
+ {{ item.value.ip_address }}
+ {{ item.value.subnetmask }}
+
+ state: present
+ loop: '{{ vlan_interfaces | dict2items }}'
+ loop_control:
+ label: '{{ item.key }} -> VLAN {{ item.value.vlan_id }}'
+- name: Erstelle 'LAN_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von LAN auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'Guest_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: Guest_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von Guest auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - Guest
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.guest.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'Drucker_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: Drucker_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von Drucker auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - Drucker
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.drucker.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'WLAN_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: WLAN_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von WLAN auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - WLAN
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.wlan.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_to_Drucker' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_Drucker
+ action: accept
+ description: Erlaubt Zugriff von LAN auf Drucker
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - Drucker
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - '{{ network_objects.drucker.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_to_WLAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_WLAN
+ action: accept
+ description: Erlaubt Zugriff von LAN auf WLAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - WLAN
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - '{{ network_objects.wlan.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'X to Drucker' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to Drucker
+ description: Zugriff auf Drucker-Netz
+ policy_list:
+ - LAN_to_Drucker
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - Drucker
+ state: present
+- name: Erstelle 'X to WLAN' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to WLAN
+ description: Zugriff auf WLAN-Netz
+ policy_list:
+ - LAN_to_WLAN
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - WLAN
+ state: present
+- name: Erstelle 'X to WAN' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to WAN
+ description: X to WAN group
+ policy_list:
+ - LAN_to_WAN
+ - Guest_to_WAN
+ - Drucker_to_WAN
+ - WLAN_to_WAN
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - WAN
+ state: present
diff --git a/roles/sophos_customer_hungeling_und_toechter/README.md b/roles/sophos_customer_hungeling_und_toechter/README.md
new file mode 100644
index 0000000..1fac111
--- /dev/null
+++ b/roles/sophos_customer_hungeling_und_toechter/README.md
@@ -0,0 +1,3 @@
+# sophos_customer_hungeling_und_toechter
+
+Policy-preserving extraction from the uploaded production playbooks. Arguments, conditions, operation order, rule names and values were not changed. Invoke through the matching playbook.
diff --git a/roles/sophos_customer_hungeling_und_toechter/tasks/main.yml b/roles/sophos_customer_hungeling_und_toechter/tasks/main.yml
new file mode 100644
index 0000000..33c5b07
--- /dev/null
+++ b/roles/sophos_customer_hungeling_und_toechter/tasks/main.yml
@@ -0,0 +1,268 @@
+---
+# Customer-specific firewall policy preserved from the uploaded source.
+- name: Update hostname settings
+ sophos.sophos_firewall.sfos_admin_settings:
+ hostname_settings:
+ hostname: '{{ hostname }}'
+ state: updated
+- name: Netzwerke als IP-Hosts in der Firewall anlegen
+ sophos.sophos_firewall.sfos_ip_host:
+ name: '{{ item.value.name }}'
+ network: '{{ item.value.network }}'
+ mask: '{{ item.value.subnetmask }}'
+ host_type: network
+ state: present
+ loop: '{{ network_objects | dict2items }}'
+- name: Zonen erstellen
+ sophos.sophos_firewall.sfos_zone:
+ name: '{{ item.value.zone_name }}'
+ description: '{{ item.value.zone_description }}'
+ zone_type: '{{ item.value.zone_type }}'
+ state: present
+ loop: '{{ vlan_interfaces | dict2items }}'
+ when: item.value.name != "LAN"
+- name: Update Management Zone Admin Services
+ sophos.sophos_firewall.sfos_zone:
+ name: Management
+ https: Enable
+ ssh: Enable
+ ad_sso: Disable
+ captive_portal: Disable
+ radius_sso: Disable
+ client_authen: Disable
+ chromebook_sso: Disable
+ ping: Enable
+ dns: Enable
+ ipsec: Disable
+ sslvpn: Disable
+ vpn_portal: Disable
+ red: Disable
+ wireless_protection: Disable
+ web_proxy: Disable
+ user_portal: Disable
+ smtp_relay: Disable
+ snmp: Disable
+ state: updated
+- name: Add VLAN Interfaces
+ sophos.sophos_firewall.sfos_xmlapi:
+ xml_tag: VLAN
+ data: |
+
+ {{ item.value.name }}
+ Port1
+ Port1
+ {{ item.value.zone_name }}
+ {{ item.value.vlan_id }}
+ Enable
+ Static
+ {{ item.value.ip_address }}
+ {{ item.value.subnetmask }}
+
+ state: present
+ loop: '{{ vlan_interfaces | dict2items }}'
+ loop_control:
+ label: '{{ item.key }} -> VLAN {{ item.value.vlan_id }}'
+- name: Erstelle 'LAN_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von LAN auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'Management_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: Management_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von Management auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - Management
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.management.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'Guest_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: Guest_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von Guest auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - Guest
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.guest.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'Facility_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: Facility_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von Facility auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - Facility
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.facility.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'VOIP_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: VOIP_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von VOIP auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - VOIP
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.voip.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_to_Management' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_Management
+ action: accept
+ description: Erlaubt Zugriff von LAN auf Management
+ log: enable
+ status: disable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - Management
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - '{{ network_objects.management.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_to_Facility' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_Facility
+ action: accept
+ description: Erlaubt Zugriff von LAN auf Facility
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - Facility
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - '{{ network_objects.facility.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_to_VOIP' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_VOIP
+ action: accept
+ description: Erlaubt Zugriff von LAN auf VOIP
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - VOIP
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - '{{ network_objects.voip.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'X to Management' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to Management
+ description: Zugriff auf Management-Netz
+ policy_list:
+ - LAN_to_Management
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - Management
+ state: present
+- name: Erstelle 'X to Facility' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to Facility
+ description: Zugriff auf Facility-Netz
+ policy_list:
+ - LAN_to_Facility
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - Facility
+ state: present
+- name: Erstelle 'X to VOIP' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to VOIP
+ description: Zugriff auf VOIP-Netz
+ policy_list:
+ - LAN_to_VOIP
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - VOIP
+ state: present
+- name: Erstelle 'X to WAN' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to WAN
+ description: X to WAN group
+ policy_list:
+ - LAN_to_WAN
+ - Management_to_WAN
+ - Guest_to_WAN
+ - Facility_to_WAN
+ - VOIP_to_WAN
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - WAN
+ state: present
diff --git a/roles/sophos_customer_koenig_holding_gmbh/README.md b/roles/sophos_customer_koenig_holding_gmbh/README.md
new file mode 100644
index 0000000..466ffc8
--- /dev/null
+++ b/roles/sophos_customer_koenig_holding_gmbh/README.md
@@ -0,0 +1,3 @@
+# sophos_customer_koenig_holding_gmbh
+
+Policy-preserving extraction from the uploaded production playbooks. Arguments, conditions, operation order, rule names and values were not changed. Invoke through the matching playbook.
diff --git a/roles/sophos_customer_koenig_holding_gmbh/tasks/main.yml b/roles/sophos_customer_koenig_holding_gmbh/tasks/main.yml
new file mode 100644
index 0000000..f5a74c5
--- /dev/null
+++ b/roles/sophos_customer_koenig_holding_gmbh/tasks/main.yml
@@ -0,0 +1,407 @@
+---
+# Customer-specific firewall policy preserved from the uploaded source.
+- name: Update hostname settings
+ sophos.sophos_firewall.sfos_admin_settings:
+ hostname_settings:
+ hostname: '{{ hostname }}'
+ state: updated
+- name: Netzwerke als IP-Hosts in der Firewall anlegen
+ sophos.sophos_firewall.sfos_ip_host:
+ name: '{{ item.value.name }}'
+ network: '{{ item.value.network }}'
+ mask: '{{ item.value.subnetmask }}'
+ host_type: network
+ state: present
+ loop: '{{ network_objects | dict2items }}'
+- name: Zonen erstellen
+ sophos.sophos_firewall.sfos_zone:
+ name: '{{ item.value.zone_name }}'
+ description: '{{ item.value.zone_description }}'
+ zone_type: '{{ item.value.zone_type }}'
+ state: present
+ loop: '{{ vlan_interfaces | dict2items }}'
+ when: item.value.name != "LAN"
+- name: Update Management Zone Admin Services
+ sophos.sophos_firewall.sfos_zone:
+ name: Management
+ https: Enable
+ ssh: Enable
+ ad_sso: Disable
+ captive_portal: Disable
+ radius_sso: Disable
+ client_authen: Disable
+ chromebook_sso: Disable
+ ping: Enable
+ dns: Enable
+ ipsec: Disable
+ sslvpn: Disable
+ vpn_portal: Disable
+ red: Disable
+ wireless_protection: Disable
+ web_proxy: Disable
+ user_portal: Disable
+ smtp_relay: Disable
+ snmp: Disable
+ state: updated
+- name: Update Server Zone Admin Services
+ sophos.sophos_firewall.sfos_zone:
+ name: Server
+ https: Enable
+ ssh: Disable
+ ad_sso: Disable
+ captive_portal: Disable
+ radius_sso: Disable
+ client_authen: Disable
+ chromebook_sso: Disable
+ ping: Enable
+ dns: Enable
+ ipsec: Disable
+ sslvpn: Disable
+ vpn_portal: Disable
+ red: Disable
+ wireless_protection: Disable
+ web_proxy: Disable
+ user_portal: Disable
+ smtp_relay: Disable
+ snmp: Disable
+ state: updated
+- name: Update Guest Zone Admin Services
+ sophos.sophos_firewall.sfos_zone:
+ name: Guest
+ https: Disable
+ ssh: Disable
+ ad_sso: Disable
+ captive_portal: Disable
+ radius_sso: Disable
+ client_authen: Disable
+ chromebook_sso: Disable
+ ping: Enable
+ dns: Enable
+ ipsec: Disable
+ sslvpn: Disable
+ vpn_portal: Disable
+ red: Disable
+ wireless_protection: Disable
+ web_proxy: Disable
+ user_portal: Disable
+ smtp_relay: Disable
+ snmp: Disable
+ state: updated
+- name: Update Facility Zone Admin Services
+ sophos.sophos_firewall.sfos_zone:
+ name: Facility
+ https: Disable
+ ssh: Disable
+ ad_sso: Disable
+ captive_portal: Disable
+ radius_sso: Disable
+ client_authen: Disable
+ chromebook_sso: Disable
+ ping: Enable
+ dns: Enable
+ ipsec: Disable
+ sslvpn: Disable
+ vpn_portal: Disable
+ red: Disable
+ wireless_protection: Disable
+ web_proxy: Disable
+ user_portal: Disable
+ smtp_relay: Disable
+ snmp: Disable
+ state: updated
+- name: Update VOIP Zone Admin Services
+ sophos.sophos_firewall.sfos_zone:
+ name: VOIP
+ https: Disable
+ ssh: Disable
+ ad_sso: Disable
+ captive_portal: Disable
+ radius_sso: Disable
+ client_authen: Disable
+ chromebook_sso: Disable
+ ping: Enable
+ dns: Enable
+ ipsec: Disable
+ sslvpn: Disable
+ vpn_portal: Disable
+ red: Disable
+ wireless_protection: Disable
+ web_proxy: Disable
+ user_portal: Disable
+ smtp_relay: Disable
+ snmp: Disable
+ state: updated
+- name: Add VLAN Interfaces
+ sophos.sophos_firewall.sfos_xmlapi:
+ xml_tag: VLAN
+ data: |
+
+ {{ item.value.name }}
+ Port1
+ Port1
+ {{ item.value.zone_name }}
+ {{ item.value.vlan_id }}
+ Enable
+ Static
+ {{ item.value.ip_address }}
+ {{ item.value.subnetmask }}
+
+ state: present
+ loop: '{{ vlan_interfaces | dict2items }}'
+ loop_control:
+ label: '{{ item.key }} -> VLAN {{ item.value.vlan_id }}'
+- name: Erstelle 'LAN_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von LAN auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'Server_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: Server_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von Server auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - Server
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.server.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'Management_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: Management_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von Management auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - Management
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.management.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'Guest_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: Guest_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von Guest auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - Guest
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.guest.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'Facility_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: Facility_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von Facility auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - Facility
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.facility.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'VOIP_to_WAN' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: VOIP_to_WAN
+ action: accept
+ description: Erlaubt Zugriff von VOIP auf WAN
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - VOIP
+ dst_zones:
+ - WAN
+ src_networks:
+ - '{{ network_objects.voip.name }}'
+ dst_networks:
+ - Any
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_to_Management' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_Management
+ action: accept
+ description: Erlaubt Zugriff von LAN auf Management
+ log: enable
+ status: disable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - Management
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - '{{ network_objects.management.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_to_Server' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_Server
+ action: accept
+ description: Erlaubt Zugriff von LAN auf Server
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - Server
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - '{{ network_objects.server.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_to_Facility' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_Facility
+ action: accept
+ description: Erlaubt Zugriff von LAN auf Facility
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - Facility
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - '{{ network_objects.facility.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'LAN_to_VOIP' Firewall-Regel
+ sophos.sophos_firewall.sfos_firewall_rule:
+ name: LAN_to_VOIP
+ action: accept
+ description: Erlaubt Zugriff von LAN auf VOIP
+ log: enable
+ status: enable
+ position: bottom
+ src_zones:
+ - LAN
+ dst_zones:
+ - VOIP
+ src_networks:
+ - '{{ network_objects.office.name }}'
+ dst_networks:
+ - '{{ network_objects.voip.name }}'
+ service_list:
+ - Any
+ state: present
+- name: Erstelle 'X to Management' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to Management
+ description: Zugriff auf Management-Netz
+ policy_list:
+ - LAN_to_Management
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - Management
+ state: present
+- name: Erstelle 'X to Server' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to Server
+ description: Zugriff auf Server-Netz
+ policy_list:
+ - LAN_to_Server
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - Server
+ state: present
+- name: Erstelle 'X to Facility' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to Facility
+ description: Zugriff auf Facility-Netz
+ policy_list:
+ - LAN_to_Facility
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - Facility
+ state: present
+- name: Erstelle 'X to VOIP' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to VOIP
+ description: Zugriff auf VOIP-Netz
+ policy_list:
+ - LAN_to_VOIP
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - VOIP
+ state: present
+- name: Erstelle 'X to WAN' Firewall-Regel Gruppe
+ sophos.sophos_firewall.sfos_firewall_rulegroup:
+ name: X to WAN
+ description: X to WAN group
+ policy_list:
+ - LAN_to_WAN
+ - Server_to_WAN
+ - Management_to_WAN
+ - Guest_to_WAN
+ - Facility_to_WAN
+ - VOIP_to_WAN
+ policy_type: Any
+ source_zones:
+ - Any
+ dest_zones:
+ - WAN
+ state: present
diff --git a/roles/system_detect_roles/README.md b/roles/system_detect_roles/README.md
new file mode 100644
index 0000000..4cb6339
--- /dev/null
+++ b/roles/system_detect_roles/README.md
@@ -0,0 +1,31 @@
+# system_detect_roles
+
+Operator defaults are intentionally low-precedence; inventory and explicit run options may override them.
+
+```yaml
+---
+# Windows feature & service identifiers
+ad_ds_feature_name: AD-Domain-Services
+dhcp_windows_service_name: DHCPServer
+veeam_services:
+ vbr: VeeamBackupSvc
+ vbo: Veeam.Archiver.Service
+ em: VeeamEnterpriseManagerSvc
+unifi_linux_services:
+ - unifi
+ - unifi.service
+unifi_linux_packages:
+ - unifi
+want_linux_check_certificate: false
+want_windows_citrix: false
+want_windows_surebackup: false
+want_windows_backup: false
+```
+
+## Structured result integration
+
+Current reporting behavior and field semantics are specified in
+[scripts/docs/OPERATION_RESULTS.md](../../scripts/docs/OPERATION_RESULTS.md).
+The calling catalog playbook owns publication; helper roles do not implicitly export
+arbitrary facts, module results or debug data. Existing defaults above retain their
+precedence. See the current validation/sanity documents before using the new candidate.
diff --git a/roles/system_detect_roles/defaults/main.yml b/roles/system_detect_roles/defaults/main.yml
new file mode 100644
index 0000000..d0669ec
--- /dev/null
+++ b/roles/system_detect_roles/defaults/main.yml
@@ -0,0 +1,17 @@
+---
+# Windows feature & service identifiers
+ad_ds_feature_name: AD-Domain-Services
+dhcp_windows_service_name: DHCPServer
+veeam_services:
+ vbr: VeeamBackupSvc
+ vbo: Veeam.Archiver.Service
+ em: VeeamEnterpriseManagerSvc
+unifi_linux_services:
+ - unifi
+ - unifi.service
+unifi_linux_packages:
+ - unifi
+want_linux_check_certificate: false
+want_windows_citrix: false
+want_windows_surebackup: false
+want_windows_backup: false
diff --git a/roles/system_detect_roles/tasks/main.yml b/roles/system_detect_roles/tasks/main.yml
new file mode 100644
index 0000000..bbcd938
--- /dev/null
+++ b/roles/system_detect_roles/tasks/main.yml
@@ -0,0 +1,132 @@
+---
+# Read-only capability discovery. This does not assign inventory groups.
+- name: Windows | Detect AD DS feature (DC)
+ when: ansible_facts['os_family'] == "Windows"
+ ansible.windows.win_feature_info:
+ name: '{{ ad_ds_feature_name | default(''AD-Domain-Services'') }}'
+ register: _win_dc_feature
+ failed_when: false
+- name: 'Windows | Fallback: check NTDS service (DC)'
+ when: ansible_facts['os_family'] == "Windows"
+ ansible.windows.win_service_info:
+ name: NTDS
+ register: _win_ntds_svc
+ failed_when: false
+- name: Windows | Check DHCP service
+ when: ansible_facts['os_family'] == "Windows"
+ ansible.windows.win_service_info:
+ name: '{{ dhcp_windows_service_name | default(''DHCPServer'') }}'
+ register: _win_dhcp_svc
+ failed_when: false
+- name: Windows | Check Veeam VBR service
+ when: ansible_facts['os_family'] == "Windows"
+ ansible.windows.win_service_info:
+ name: '{{ veeam_services.vbr | default(''VeeamBackupSvc'') }}'
+ register: _veeam_vbr
+ failed_when: false
+- name: Windows | Check Veeam VBO service
+ when: ansible_facts['os_family'] == "Windows"
+ ansible.windows.win_service_info:
+ name: '{{ veeam_services.vbo | default(''Veeam.Archiver.Service'') }}'
+ register: _veeam_vbo
+ failed_when: false
+- name: Windows | Check Veeam Enterprise Manager service
+ when: ansible_facts['os_family'] == "Windows"
+ ansible.windows.win_service_info:
+ name: '{{ veeam_services.em | default(''VeeamEnterpriseManagerSvc'') }}'
+ register: _veeam_em
+ failed_when: false
+- name: Windows | Detect Hyper-V feature
+ when: ansible_facts['os_family'] == "Windows"
+ ansible.windows.win_feature_info:
+ name: '{{ hyperv_feature_name | default(''Hyper-V'') }}'
+ register: _win_hyperv_feature
+ failed_when: false
+- name: 'Windows | Fallback: check Hyper-V VMMS service'
+ when: ansible_facts['os_family'] == "Windows"
+ ansible.windows.win_service_info:
+ name: '{{ hyperv_vmms_service_name | default(''vmms'') }}'
+ register: _win_hyperv_vmms_svc
+ failed_when: false
+- name: Windows | Set Veeam/DC/DHCP booleans (base)
+ when: ansible_facts['os_family'] == "Windows"
+ ansible.builtin.set_fact:
+ is_dc: |-
+ {{
+ (
+ (_win_dc_feature.features | default([]))
+ | selectattr('installed')
+ | list
+ | length > 0
+ )
+ or
+ (_win_ntds_svc.exists | default(false))
+ }}
+ is_dhcp_server: '{{ _win_dhcp_svc.exists | default(false) }}'
+ has_veeam_vbr: '{{ _veeam_vbr.exists | default(false) }}'
+ has_veeam_vbo: '{{ _veeam_vbo.exists | default(false) }}'
+ has_veeam_em: '{{ _veeam_em.exists | default(false) }}'
+ is_hyperv_host: |-
+ {{
+ (
+ (_win_hyperv_feature.features | default([]))
+ | selectattr('installed')
+ | list
+ | length > 0
+ )
+ or
+ (_win_hyperv_vmms_svc.exists | default(false))
+ }}
+- name: Windows | Debug summary
+ when:
+ - ansible_facts['os_family'] == "Windows"
+ - aim_debug | default(false) | bool
+ ansible.builtin.debug:
+ msg:
+ is_dc: '{{ is_dc }}'
+ is_dhcp_server: '{{ is_dhcp_server }}'
+ has_veeam_vbr: '{{ has_veeam_vbr }}'
+ has_veeam_vbo: '{{ has_veeam_vbo }}'
+ has_veeam_em: '{{ has_veeam_em }}'
+ is_hyperv_host: '{{ is_hyperv_host }}'
+- name: Linux | Collect service facts
+ when: ansible_facts['os_family'] != "Windows"
+ ansible.builtin.service_facts: null
+- name: Linux | Collect package facts
+ when: ansible_facts['os_family'] != "Windows"
+ ansible.builtin.package_facts:
+ manager: auto
+- name: Linux | Set UniFi flags
+ when: ansible_facts['os_family'] != "Windows"
+ vars:
+ svcs: '{{ ansible_facts.services | default({}) }}'
+ pkgs: '{{ ansible_facts.packages | default({}) | list }}'
+ ansible.builtin.set_fact:
+ is_unifi_controller: |-
+ {{
+ (unifi_linux_services | select('in', svcs.keys()) | list | length > 0)
+ or
+ (pkgs | intersect(unifi_linux_packages) | length > 0)
+ }}
+ is_unifi_os_server: |-
+ {{
+ 'uosserver.service' in svcs
+ }}
+- name: Linux | Debug summary
+ when:
+ - ansible_facts['os_family'] != "Windows"
+ - aim_debug | default(false) | bool
+ ansible.builtin.debug:
+ msg:
+ is_unifi_controller: '{{ is_unifi_controller }}'
+ is_unifi_os_server: '{{ is_unifi_os_server }}'
+- name: Normalize detection booleans
+ ansible.builtin.set_fact:
+ is_dc: '{{ is_dc | default(false) }}'
+ is_dhcp_server: '{{ is_dhcp_server | default(false) }}'
+ has_veeam_vbr: '{{ has_veeam_vbr | default(false) }}'
+ has_veeam_vbo: '{{ has_veeam_vbo | default(false) }}'
+ has_veeam_em: '{{ has_veeam_em | default(false) }}'
+ is_unifi_controller: '{{ is_unifi_controller | default(false) }}'
+ is_unifi_os_server: '{{ is_unifi_os_server | default(false) }}'
+ is_hyperv_host: '{{ is_hyperv_host | default(false) }}'
diff --git a/scripts/AIM-WinRM-OneTime.ps1 b/scripts/AIM-WinRM-OneTime.ps1
new file mode 100644
index 0000000..4c3348d
--- /dev/null
+++ b/scripts/AIM-WinRM-OneTime.ps1
@@ -0,0 +1,198 @@
+$ErrorActionPreference = 'Stop'
+
+Write-Host 'Configuring WinRM for Ansible...'
+
+# ---------------------------------------------------------------------------
+# Configuration
+# ---------------------------------------------------------------------------
+
+$certificateFriendlyName = 'WinRM'
+$firewallRuleName = 'Windows Remote Management (HTTPS-In)'
+
+# Optional additional DNS names or IP addresses, e.g. NAT / bitconnect addresses.
+$additionalSANs = @(
+ # '192.168.100.10'
+ # 'server.example.lan'
+)
+
+# ---------------------------------------------------------------------------
+# Enable WinRM
+# ---------------------------------------------------------------------------
+
+Write-Host 'Enabling WinRM...'
+winrm quickconfig -quiet
+Set-Service -Name WinRM -StartupType Automatic
+if ((Get-Service -Name WinRM).Status -ne 'Running') {
+ Start-Service -Name WinRM
+}
+
+# ---------------------------------------------------------------------------
+# Determine host names and IP addresses
+# ---------------------------------------------------------------------------
+
+$hostName = $env:COMPUTERNAME
+$computerSystem = Get-CimInstance Win32_ComputerSystem
+$domain = $computerSystem.Domain
+
+$hostIPs = @(
+ Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
+ Where-Object {
+ $_.IPAddress -notlike '127.*' -and
+ $_.IPAddress -notlike '169.254.*'
+ } |
+ Select-Object -ExpandProperty IPAddress -Unique
+)
+
+if (-not $hostIPs) {
+ throw 'No usable IPv4 address found for WinRM certificate creation.'
+}
+
+$certificateNames = @($hostName)
+if ($computerSystem.PartOfDomain -and -not [string]::IsNullOrWhiteSpace($domain)) {
+ $certificateNames += "$hostName.$domain"
+}
+$certificateNames += $hostIPs
+$certificateNames += $additionalSANs
+$certificateNames = @(
+ $certificateNames |
+ Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
+ Select-Object -Unique
+)
+
+Write-Host 'Certificate SANs:'
+$certificateNames | ForEach-Object { Write-Host " - $_" }
+
+# ---------------------------------------------------------------------------
+# Find or create WinRM certificate
+# ---------------------------------------------------------------------------
+
+$cert = Get-ChildItem 'Cert:\LocalMachine\My' |
+ Where-Object {
+ $_.FriendlyName -eq $certificateFriendlyName -and
+ $_.NotAfter -gt (Get-Date).AddDays(30)
+ } |
+ Sort-Object NotAfter -Descending |
+ Select-Object -First 1
+
+if (-not $cert) {
+ Write-Host 'Creating self-signed WinRM certificate...'
+
+ $certCommand = Get-Command New-SelfSignedCertificate -ErrorAction Stop
+ $certParams = @{ DnsName = $certificateNames }
+
+ if ($certCommand.Parameters.ContainsKey('CertStoreLocation')) {
+ $certParams['CertStoreLocation'] = 'Cert:\LocalMachine\My'
+ }
+ if ($certCommand.Parameters.ContainsKey('FriendlyName')) {
+ $certParams['FriendlyName'] = $certificateFriendlyName
+ }
+ if ($certCommand.Parameters.ContainsKey('TextExtension')) {
+ $certParams['TextExtension'] = '2.5.29.37={text}1.3.6.1.5.5.7.3.1'
+ }
+
+ try {
+ $cert = New-SelfSignedCertificate @certParams
+ }
+ catch {
+ if ($_.CategoryInfo.Reason -ne 'InvalidStorePathException' -or -not $certParams.ContainsKey('CertStoreLocation')) {
+ throw
+ }
+
+ # Windows Server 2012 R2 / PowerShell 4 can expose CertStoreLocation but
+ # reject the valid Cert:\LocalMachine\My argument. The compatibility
+ # path is to run the cmdlet while the certificate provider is in that store.
+ Write-Host 'Legacy certificate-store behavior detected; retrying in compatibility mode.'
+ $certParams.Remove('CertStoreLocation')
+ Push-Location 'Cert:\LocalMachine\My'
+ try {
+ $cert = New-SelfSignedCertificate @certParams
+ }
+ finally {
+ Pop-Location
+ }
+ }
+
+ if ($cert.FriendlyName -ne $certificateFriendlyName) {
+ try {
+ $cert.FriendlyName = $certificateFriendlyName
+ }
+ catch {
+ Write-Warning 'Certificate was created, but its friendly name could not be set. Future runs may create a replacement certificate.'
+ }
+ }
+}
+else {
+ Write-Host 'Existing WinRM certificate found.'
+}
+
+if (-not $cert.Thumbprint) {
+ throw 'WinRM certificate does not contain a valid thumbprint.'
+}
+Write-Host "Certificate thumbprint: $($cert.Thumbprint)"
+
+# ---------------------------------------------------------------------------
+# Configure HTTPS listener
+# ---------------------------------------------------------------------------
+
+$httpsListener = Get-ChildItem WSMan:\localhost\Listener |
+ Where-Object { $_.Keys -contains 'Transport=HTTPS' } |
+ Select-Object -First 1
+
+if (-not $httpsListener) {
+ Write-Host 'Creating WinRM HTTPS listener...'
+ New-Item -Path WSMan:\localhost\Listener -Transport HTTPS -Address * -CertificateThumbPrint $cert.Thumbprint -Force | Out-Null
+}
+else {
+ Write-Host 'WinRM HTTPS listener already exists.'
+}
+
+# ---------------------------------------------------------------------------
+# Configure firewall
+# ---------------------------------------------------------------------------
+
+$firewallRule = Get-NetFirewallRule -DisplayName $firewallRuleName -ErrorAction SilentlyContinue
+if (-not $firewallRule) {
+ Write-Host 'Creating WinRM HTTPS firewall rule...'
+ New-NetFirewallRule -DisplayName $firewallRuleName -Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow -Program System | Out-Null
+}
+else {
+ Write-Host 'WinRM HTTPS firewall rule already exists.'
+ Enable-NetFirewallRule -DisplayName $firewallRuleName | Out-Null
+}
+
+# ---------------------------------------------------------------------------
+# Non-domain systems
+# ---------------------------------------------------------------------------
+
+if (-not $computerSystem.PartOfDomain) {
+ Write-Host 'Non-domain system detected.'
+ Write-Host 'Enabling remote administrative token for local accounts...'
+ New-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name 'LocalAccountTokenFilterPolicy' -PropertyType DWord -Value 1 -Force | Out-Null
+}
+
+# ---------------------------------------------------------------------------
+# Verification
+# ---------------------------------------------------------------------------
+
+Write-Host ''
+Write-Host 'Verifying WinRM configuration...'
+
+$listener = Get-ChildItem WSMan:\localhost\Listener |
+ Where-Object { $_.Keys -contains 'Transport=HTTPS' } |
+ Select-Object -First 1
+if (-not $listener) {
+ throw 'WinRM HTTPS listener verification failed.'
+}
+
+$firewallRule = Get-NetFirewallRule -DisplayName $firewallRuleName -ErrorAction SilentlyContinue
+if (-not $firewallRule) {
+ throw 'WinRM firewall rule verification failed.'
+}
+if ((Get-Service WinRM).Status -ne 'Running') {
+ throw 'WinRM service is not running.'
+}
+
+Write-Host ''
+Write-Host 'WinRM configuration completed successfully.'
+Write-Host 'HTTPS port: 5986'
+Write-Host "Certificate: $($cert.Thumbprint)"
diff --git a/scripts/addons/webgui/.aim-web-managed b/scripts/addons/webgui/.aim-web-managed
new file mode 100644
index 0000000..0ea0a29
--- /dev/null
+++ b/scripts/addons/webgui/.aim-web-managed
@@ -0,0 +1 @@
+aim-webgui 2.1.0rc9
diff --git a/scripts/addons/webgui/.credentials b/scripts/addons/webgui/.credentials
new file mode 120000
index 0000000..bea50ef
--- /dev/null
+++ b/scripts/addons/webgui/.credentials
@@ -0,0 +1 @@
+/var/lib/aim/webgui/.credentials
\ No newline at end of file
diff --git a/scripts/addons/webgui/ADDON-INSTALLATION.md b/scripts/addons/webgui/ADDON-INSTALLATION.md
new file mode 100644
index 0000000..24b44fa
--- /dev/null
+++ b/scripts/addons/webgui/ADDON-INSTALLATION.md
@@ -0,0 +1,688 @@
+# AIM WebGUI Add-on Installation
+
+**Applies to:** AIM WebGUI 2.1.0rc9
+**Required AIM Core:** 3.3.0rc8
+**Core service / wire / event API:** 1.0
+**Canonical Ansible Core:** 2.19.11, provided by the separately installed Core environment
+**WebGUI HTTP API / database:** v2 / SQLite schema 5
+**WebGUI Python:** Python 3.11+ with virtual-environment support
+**Managed deployment:** Linux with systemd
+**Prerequisite:** AIM Core 3.3.0rc8 `scripts/docs/INSTALLATION.md` (separately supplied Core guide)
+
+This guide continues after the Core installation guide. It installs a **new AIM WebGUI add-on on an already configured AIM controller**; it does not install AIM Core again. Complete Core installation, establish approved customer data, and accept a controlled terminal run before enabling WebGUI execution. Core remains usable without this add-on. [C1][C2]
+
+For an existing managed WebGUI installation, use [section 15](#15-updating-an-existing-add-on), not the fresh-install command. For a controller already running this exact add-on version, use the verification and acceptance sections; the deployer rejects a same-version reinstall. [W2]
+
+**Reading order:** fresh installations follow sections 1-13; backup, updates and recovery are in sections 14-17. The completion checklist is at the end.
+
+The commands below use `/etc/ansible`, the existing executor account `svc_bf-ansible`, and the default web account `aim-web`. These are the current release profile, not requirements to rename an established controller account. This document describes a release candidate and a source-checked procedure, not evidence that a new controller has passed live acceptance.
+
+## 1. Complete the Core prerequisite first
+
+Use Core's `INSTALLATION.md` for the source installation, dependency environments, canonical Ansible runtime, collections, controller configuration, inventories, Vaults and keys. Do not substitute the WebGUI virtual environment for either the AIM or Ansible environment. [C1, sections 1-12]
+
+Before continuing, establish:
+
+- `aim` and `aimctl` refer to the intended Core 3.3.0rc8 installation.
+- The AIM Python environment contains its declared dependencies, including the documented `rich>=13,<15` range.
+- The separately configured Ansible runtime contains exactly Core 2.19.11 and the approved collection/connection dependencies needed by the selected playbooks.
+- Core 3.3.0rc8 requires `ansible.windows >=3.8.0,<4.0.0` for Windows playbooks. Upgrade that collection deliberately in the canonical Core/Ansible collection path before accepting rc8; WebGUI does not install or upgrade it.
+- At least one approved test customer/host is available for the terminal and add-on acceptance run.
+- The non-root execution account is authorized by Core and has the required filesystem access.
+- Terminal AIM has completed an approved low-risk test independently of WebGUI.
+
+Check the installed commands:
+
+```bash
+command -v aim
+command -v aimctl
+aim --version
+aimctl --version
+```
+
+Both Core commands must report `3.3.0rc8`. A generic API version of `1.0` alone is not enough: this WebGUI candidate also checks its explicitly supported Core product version and capabilities. [W2][W3]
+
+For a fresh setup, leave this existing Core setting disabled while provisioning the add-on:
+
+```yaml
+addons:
+ execution_enabled: false
+```
+
+Merge configuration changes into `/etc/ansible/scripts/aim.yml`; do not replace the entire file. The final opt-in is covered in section 10. Discovery and installation of the add-on do not grant permission to execute jobs. [C1, sections 8 and 13]
+
+## 2. Confirm the two local service identities
+
+The managed topology uses two different non-root accounts: [W2][W4]
+
+| Component | Default account | Responsibility |
+|---|---|---|
+| `aim-web.service` | `aim-web` | HTTP, sessions, UI and private workflow database |
+| `aim-web-worker.service` | `aim-web` | Queue, authorization rechecks, journal/report persistence and credential handoff |
+| `aim-web-executor.service` | `svc_bf-ansible` | Fixed public `aimctl` calls and Core-owned native execution under that same UID |
+
+The WebGUI installer can create the local `aim-web` system account and group when absent. It **does not create the Core executor account or change its persistent group memberships**. The executor must already exist, must not be root, and must differ from the web account. [W2]
+
+Inspect the approved executor:
+
+```bash
+getent passwd svc_bf-ansible
+id svc_bf-ansible
+```
+
+The passwd/NSS home must be a reviewed, usable account home. The installer will create or normalize its `.ansible` and `.ansible/tmp` directories to executor-owned `0700`, in addition to the separate `/var/lib/aim-web-executor` tree. Review pre-existing shared uses or symlinks before applying; do not use installation as a general home-directory repair procedure. [W2][W4]
+
+Review Core's configured `required_group` and the account's active local/NSS memberships. Do not assume the example directory group in the Core installation guide or the local `aim-operators` group exists at every site. Provision authorization through the approved local/directory administration process, not through an add-on workaround. [C1][C2]
+
+The account needs access to the configured Core launcher/runtime, customer inventory and encrypted Vault, required role payloads and collections, and Core's cooperating customer-lock paths. In customer-key mode, the canonical private key must be an owner-only `0600` file owned by the execution UID. A key filename matching the account name does not establish ownership. [C2][C3]
+
+`service_user` is not a local UID switch. `runtime.private_key_owner` controls newly generated keys; it does not migrate an existing root-owned key or grant access. Existing-key migration is a separate, explicit Core/operator decision. Do not recursively change `/etc/ansible`, make private keys group-readable, or add the web account to root/operator groups to bypass this prerequisite. [C1, section 8][C2]
+
+The managed executor preserves its native primary group and receives the `aim-web` group for this service only. A normal interactive `sudo -u svc_bf-ansible` session therefore need not be able to read WebGUI's `root:aim-web 0640` configuration. That is not a reason to make it world-readable. [W4]
+
+## 3. Verify Core access as the executor
+
+First check metadata through the installed public launcher:
+
+```bash
+sudo -u svc_bf-ansible \
+ /usr/local/bin/aimctl \
+ --config /etc/ansible/scripts/aim.yml \
+ capabilities
+```
+
+Then check a protected operation:
+
+```bash
+printf '%s\n' '{"api_version":"1.0","operation":"list_customers"}' \
+ | sudo -u svc_bf-ansible \
+ /usr/local/bin/aimctl \
+ --config /etc/ansible/scripts/aim.yml \
+ request
+```
+
+`capabilities` is unguarded metadata; success there does not prove authorization. The customer-list response must also succeed. An empty list can be a valid discovery result, but it is not a completed managed-host acceptance test. [C3]
+
+The WebGUI integration expects the detailed progress, inventory hierarchy, target outcome, dual-home staging and operation-result contracts described by the current Core documentation. The installer probes live Core capabilities and protected customer discovery; do not edit a support snapshot or disable version checks to make an incompatible pair install. [C2][W2][W3]
+
+### Confirm runtime and collection visibility
+
+Use the executable configured in Core's `runtime.ansible_playbook`. For the separate environment shown in Core's fresh-install guide, the checks are:
+
+```bash
+sudo -u svc_bf-ansible \
+ /opt/ansible/venv/bin/ansible-playbook --version
+
+sudo -u svc_bf-ansible \
+ /opt/ansible/venv/bin/ansible-galaxy collection list
+ /opt/ansible/venv/bin/ansible-galaxy collection list ansible.windows
+```
+
+If Core was accepted with `/usr/bin/ansible-playbook`, use its corresponding runtime instead; do not create a replacement just for WebGUI. Sibling Vault/Galaxy tools and collection discovery must match the approved native runtime. [C1, sections 4-5][C3]
+
+For rc8, confirm the discovered `ansible.windows` version is at least 3.8.0 and below 4.0.0. Core advertises this floor in `capabilities.collection_baselines` and its readiness checks reject older versions before an affected playbook launches. Do not satisfy the check by installing a private collection visible only to root or to the WebGUI virtual environment.
+
+The collection-install command in the Core guide uses `sudo`. Verify discovery under the executor as well as root. Collections available only in root's private home do not establish executor access. Use Core's documented `runtime.ansible_collections_path` when an approved shared/nonstandard collection location is needed; WebGUI does not install collections or expose root's home. [C1, section 5][C3]
+
+These shell checks do not reproduce systemd restrictions. The installed executor's startup preflight and the real one-host test are still required.
+
+## 4. Review HTTPS, SSH trust and the shipped site profile
+
+### HTTPS and reverse proxy are prerequisites
+
+The WebGUI deployer does not install Nginx, Nginx Proxy Manager, certificates, CA trust or DNS. It serves HTTP on its configured backend listener; the approved reverse proxy provides the browser's HTTPS endpoint. [W1][W2]
+
+The bundled `deploy/webgui.example.toml` contains this **controller-specific** profile:
+
+| Setting | Shipped value |
+|---|---|
+| Backend | `127.0.0.1:8080` |
+| Browser origin | `https://aim.desq-gaming.de` |
+| Immediate trusted proxy | `127.0.0.1` |
+| Referenced upstream topology | NPM `192.168.20.3` -> verified HTTPS controller `192.168.20.46:8443` -> local Nginx -> loopback HTTP |
+| WebGUI execution / credential entry | Enabled |
+| Independent approval | Disabled |
+| Eligible playbooks | The 14 explicit catalog keys in the shipped profile |
+| Maximum requested hosts / execution timeout | 25 / 1,800 seconds |
+| `transport_verified` | `true`, representing this site's operator attestation |
+
+An allowlist does not establish that every playbook is executable or appropriate at the site. `transport_verified=true` does not provision or test TLS. Review the actual proxy path and trust before using these values. [W1][W5]
+
+The `public_url` must be the exact browser origin: scheme, hostname and optional port, with no path or trailing slash. The trusted-proxy list identifies the immediate backend proxy, not the browser's remote address. Execution rejects wildcard proxy trust. [W5]
+
+`deploy/nginx.example.conf` is a header reference, not an installable complete TLS server configuration. Preserve the browser Host and forwarded scheme through the approved proxy chain and verify streaming. WebGUI uses authenticated SSE, not WebSockets. No certificate setup or unverified replacement proxy configuration is supplied by this guide. [W1][W5][W9]
+
+### Other sites: an explicit configuration gate
+
+Do not treat the shipped domain, IP addresses or transport attestation as generic defaults.
+
+The current deployer can select the Core launcher/configuration and account names, but **has no `--profile`, `--config-file` or public-origin override argument**. It replaces the whole installed TOML from its release template during install/update and restores the checkpoint's configuration on rollback. Editing the extracted template invalidates its manifest. [W2]
+
+For repeatable release-managed deployment, obtain a reviewed release profile matching the new site. An operator can edit the installed `/etc/ansible/scripts/config/webgui.toml` before exposing it, but that is a **local override, not a durable site-profile mechanism**; the next managed update replaces it. Keep Core execution disabled and public ingress closed during any such adaptation. Preserve the managed state/socket paths and review account/path choices rather than copying a complete unrelated configuration.
+
+If credentials or execution are disabled locally, disable `[credentials].enabled` before or together with `[execution].enabled`. The configuration validator does not allow credential entry without enabled execution and verified HTTPS. Run `sudo aim-web config-check` after editing; it validates settings, not certificates. Configuration is loaded by the service processes: apply edits in a quiesced window and restart the appropriate services before exposing the site. Leave the worker stopped/disabled when execution is intentionally disabled. [W5]
+
+### SSH host trust is a separate prerequisite
+
+For Linux/SSH targets, the executor needs independently verified host trust for the effective connection destination. The inventory's logical hostname may differ from the actual `ansible_host` address.
+
+The existing approved controller-wide `/etc/ssh/ssh_known_hosts` arrangement can be retained. WebGUI does not enroll keys, overwrite that file or automatically copy root's personal trust decisions. Do not infer OpenSSH's effective user known-hosts path solely from the executor's substituted `HOME`. Review the effective SSH configuration and account context instead. [W4]
+
+Windows WinRM connectivity, credentials, transport dependencies and certificate policy remain the independently accepted Core/operator configuration. Do not disable TLS or SSH verification to pass add-on acceptance. [C1][C3]
+
+## 5. Obtain and verify the WebGUI release
+
+Transfer both files through a trusted channel:
+
+```text
+AIM-WebGUI-2.1.0rc9.zip
+AIM-WebGUI-2.1.0rc9.zip.sha256
+```
+
+Stage outside the active add-on and Core source directories:
+
+```bash
+cd /var/tmp
+sha256sum -c AIM-WebGUI-2.1.0rc9.zip.sha256
+unzip AIM-WebGUI-2.1.0rc9.zip
+cd aim-web-2.1.0rc9
+sha256sum -c MANIFEST.sha256
+```
+
+The expected ZIP SHA-256 is:
+
+```text
+bf0a6977d2672477bcb48c78b49085ff79e70195e780813b934d87ad334d7556
+```
+
+Use a new extraction directory, not a directory containing an older release. Never extract over `/etc/ansible/scripts/addons/webgui` or `/opt/aim-web/current`. A checksum verifies integrity, not publisher identity. The deployer additionally verifies manifest hashes, safe paths and required-file coverage. [W1][W2]
+
+Do not edit manifest-covered files, remove integrity checks or regenerate a manifest just to bypass a deployment error. This documentation companion is not an updated release ZIP and should be kept outside the verified extraction.
+
+## 6. Prepare WebGUI dependencies and inspect the deployer
+
+The controller must provide Linux/systemd, Python 3.11+ with `venv` support and administrative permission to perform installation. Inspect the local Python before running the installer:
+
+```bash
+python3 --version
+python3 -m venv --help
+python3 deploy/deploy.py --help
+```
+
+The WebGUI deployer creates its **own versioned virtual environment** and installs the declared WebGUI/build dependencies using the supplied `constraints.txt`. It does not run pip in AIM's environment, install Ansible, or install the test dependency extra. There is no need to pre-install FastAPI or pytest globally. [W2]
+
+If the deployment interpreter is not the Python intended for WebGUI, use the supported `--python /absolute/path/to/python3` option. Do not point it at an interpreter with an unsupported version. Separate environments can use the same Python installation without sharing package environments.
+
+The package is not a complete offline dependency bundle. A new installation needs approved package/asset access or prepared offline artifacts. Browser assets are the exact pinned Bootstrap 5.3.8 CSS and HTMX 2.0.10 JavaScript; the installer verifies their SHA-384 pins and serves them locally. Do not substitute fixture assets or a newer version. [W1][W8]
+
+For an offline installation, prepare wheels compatible with the chosen Python/OS/architecture, including constrained build dependencies, and an asset directory containing:
+
+```text
+bootstrap.min.css
+htmx.min.js
+```
+
+Pass the existing `--wheelhouse` and `--assets-dir` options in section 7. The detailed wheelhouse preparation depends on the operator's build environment; the supplied guide does not establish a universal offline-build recipe. [W1][W2]
+
+### Core and WebGUI deployers have different commands
+
+| Operation | Core guide | WebGUI 2.1.0rc9 |
+|---|---|---|
+| Preview | `install --dry-run` | No equivalent dry-run mode |
+| Fresh apply | `install --apply --quiesced` | `install` performs installation |
+| Existing add-on update | Not applicable | `update` |
+| Rollback selector | `--from-backup /path` | `--backup SNAPSHOT_ID` |
+
+Do **not** append Core's `--dry-run`, `--apply`, `--quiesced` or `--aim-python` options to the WebGUI deployer. Review its help, profile and source/documentation before applying. Its internal checks are not a whole-system no-write preview: preparatory add-on directories, dependencies or the web account may be created before a later failure. [C1][W2]
+
+An existing unrecognized installation, unrelated CLI link or unmanaged systemd drop-in stops deployment. Review and back up such files; do not delete overrides or disable guard checks blindly.
+
+## 7. Apply a fresh add-on installation
+
+Proceed only after the preceding Core, identity, profile and transport gates are satisfied. Do not run a Core source update or change its inventory/configuration concurrently.
+
+From the verified fresh extraction:
+
+```bash
+sudo python3 deploy/deploy.py install \
+ --aim-scripts /etc/ansible/scripts \
+ --service-user aim-web \
+ --executor-user svc_bf-ansible \
+ --aimctl /usr/local/bin/aimctl \
+ --core-config /etc/ansible/scripts/aim.yml
+```
+
+These options make the default binding explicit. The Core account and launcher must already exist. [W2]
+
+For the same installation using prepared offline artifacts, add:
+
+```bash
+sudo python3 deploy/deploy.py install \
+ --aim-scripts /etc/ansible/scripts \
+ --service-user aim-web \
+ --executor-user svc_bf-ansible \
+ --aimctl /usr/local/bin/aimctl \
+ --core-config /etc/ansible/scripts/aim.yml \
+ --wheelhouse /secure/wheelhouse \
+ --assets-dir /secure/aim-assets
+```
+
+The successful fresh-install sequence: [W2]
+
+1. Verify the release, stage dependencies/assets and probe public Core metadata as the executor.
+2. Create the web account if needed; create private WebGUI state and a protected deployment checkpoint.
+3. Install the reviewed TOML and provision the managed executor staging directories.
+4. Initialize schema 5 and the initial local administrator without resetting existing initialized accounts.
+5. Activate the full source/virtualenv and the `aim-web` launcher; write the three managed service units.
+6. Run executor startup checks, wait for its correctly permissioned socket, start HTTP and its local readiness check, and start the worker when the WebGUI execution setting is enabled.
+
+The deployer does not change Core's `addons.execution_enabled`, Core source, customer keys/Vaults, persistent executor group memberships, SSH trust or proxy certificates. The shipped profile starts the queue worker even when Core's independent execution opt-in is still false; that does not bypass the Core gate.
+
+Record the printed add-on source, configuration, checkpoint identifier and initial-credential location. They are needed for verification and recovery.
+
+### Nondefault layouts
+
+`--aim-scripts`, `--aimctl` and `--core-config` can bind the add-on to reviewed locations. They do not create an independent second WebGUI instance: `/opt/aim-web`, state paths, service names and the executor socket are fixed in this managed profile. The executor's inventory write exception also names `/etc/ansible/inventories`. A different inventory root or parallel installation needs a reviewed deployment profile; changing the CLI paths alone is insufficient. [W2]
+
+## 8. Verify the installed services and permissions
+
+Refresh command discovery:
+
+```bash
+hash -r
+command -v aim-web
+aim-web --version
+sudo aim-web config-check
+```
+
+Expected version:
+
+```text
+AIM WebGUI 2.1.0rc9 (AIM compatibility: 3.3.0rc8 / service API 1.0)
+```
+
+The installer supplies `/usr/local/bin/aim-web -> /opt/aim-web/current/bin/aim-web`; do not manually copy a versioned launcher over it. `config-check` validates TOML only and can run as root without opening the workflow database. [W2][W6]
+
+Check services and the actual startup preflight:
+
+```bash
+sudo systemctl status \
+ aim-web-executor.service \
+ aim-web.service \
+ aim-web-worker.service \
+ --no-pager
+
+sudo journalctl -u aim-web-executor.service -n 80 --no-pager
+
+systemctl show aim-web-executor.service \
+ -p User -p Group -p SupplementaryGroups
+```
+
+The intended executor identity is:
+
+```ini
+User=svc_bf-ansible
+Group=svc_bf-ansible
+SupplementaryGroups=aim-web
+```
+
+Numeric group IDs in `systemctl show` are acceptable when they resolve to the intended groups. Systemd may also initialize other account memberships; an empty explicit `SupplementaryGroups` field is not proof that the process has no supplementary groups. [W4]
+
+Inspect the managed paths, substituting the actual passwd/NSS home if different:
+
+```bash
+sudo stat -c '%U:%G %a %n' \
+ /var/lib/aim/webgui \
+ /var/lib/aim/webgui/webgui.sqlite3 \
+ /var/lib/aim-web-executor \
+ /var/lib/aim-web-executor/.ansible \
+ /var/lib/aim-web-executor/.ansible/tmp \
+ /home/svc_bf-ansible/.ansible \
+ /home/svc_bf-ansible/.ansible/tmp \
+ /run/aim-web-executor \
+ /run/aim-web-executor/core.sock \
+ /etc/ansible/scripts/config/webgui.toml
+```
+
+| Resource | Expected owner/group | Mode |
+|---|---|---|
+| WebGUI state directory | `aim-web:aim-web` | `0700` |
+| Workflow database | `aim-web:aim-web` | `0600` |
+| Executor state and process-home `.ansible/tmp` hierarchy | Executor:native primary group | `0700` |
+| Executor passwd-home `.ansible` and `.ansible/tmp` | Executor:native primary group | `0700` |
+| `/run/aim-web-executor` | Executor:native primary group | `0711` |
+| `core.sock` | Executor:`aim-web` | `0660` |
+| `webgui.toml` | `root:aim-web` | `0640` |
+| Managed unit files | `root:root` | `0644` |
+
+The runtime-directory traverse permission does not grant access to the private executor state or database. Socket permissions and peer checks govern IPC. These are checks of installer-managed state, not a recursive repair recipe. [W2][W4]
+
+### Both staging homes matter
+
+The executor sets `HOME=/var/lib/aim-web-executor`, but delegated local Ansible tasks can expand `~svc_bf-ansible` through the account database. The installer provisions both applicable staging paths and only their narrow write exceptions while retaining `ProtectHome=read-only`, `ProtectSystem=strict`, `NoNewPrivileges=true` and empty capabilities. [C1, section 11][W4]
+
+`ExecStartPre` runs `aim-web core-staging-check` inside the actual executor unit. Successful journal output followed by service startup is the relevant staging evidence. A plain interactive `sudo -u svc_bf-ansible aim-web core-staging-check` can fail to read the web-group configuration because it does not reproduce the unit's group context. A root invocation tests the wrong key/staging identity. Do not weaken file permissions to make those commands pass. [W4][W6]
+
+## 9. Open the HTTPS site and initialize administration
+
+Verify the approved proxy endpoint serves the correct installation with trusted HTTPS. For the shipped profile, the browser origin is:
+
+```text
+https://aim.desq-gaming.de
+```
+
+Do not use a raw LAN HTTP address as the browser origin or use disabled certificate validation as acceptance. No operational secret should be entered before transport has been accepted.
+
+Read the generated bootstrap credential file **locally**:
+
+```bash
+sudo cat /var/lib/aim/webgui/.credentials
+```
+
+The fresh administrator username is `admin`. The JSON file contains the generated password and is protected as service-owned private state; it is not served over HTTP. Do not paste it into tickets, chat, deployment logs or this documentation. [W6][W7]
+
+Sign in and perform the mandatory first password change. The application revokes the sessions for that account and invalidates/removes the bootstrap credential file after the password change. Sign in again with the new password as prompted. A later update or `init` command does not regenerate bootstrap credentials or reset an initialized administrator. [W7]
+
+Use User administration to establish named accounts and reviewed execution grants. An eligible administrator or a matching customer/playbook grant is required in addition to the configured allowlist and Core authorization. Job/history/report visibility is owner-or-admin; supplying one-run credentials remains requester-only. [W7][W9]
+
+Do not enable independent approval without providing a suitable separate approver. The shipped single-administrator site profile has `require_approval=false`; that does not bypass review of targets, mode, options or Core revisions.
+
+## 10. Accept local readiness, then enable Core execution deliberately
+
+Through the running add-on's executor:
+
+```bash
+sudo -u aim-web \
+ /opt/aim-web/current/bin/aim-web \
+ --config /etc/ansible/scripts/config/webgui.toml \
+ core-check
+```
+
+Confirm Core product version, live capabilities and authorized customer discovery. An `execution.enabled=false` value at this point is the expected independent Core gate. [C3][W6]
+
+For one existing approved Linux host with customer-key mode, replace the two literal identifiers before running:
+
+```bash
+sudo -u aim-web \
+ /opt/aim-web/current/bin/aim-web \
+ --config /etc/ansible/scripts/config/webgui.toml \
+ core-check \
+ --customer CUSTOMER \
+ --playbook debug_test_connection \
+ --host HOST \
+ --key-mode customer
+```
+
+For a Windows/native-inventory test use `--key-mode none`. This is not forced password authentication. The CLI readiness request uses check mode and performs local preparation/runtime checks; it does not submit a job, unlock Vault, verify remote credentials or prove remote connectivity. [C3][W6]
+
+Once Core terminal, transport, executor identity and local staging/readiness have been accepted, edit **Core's existing** configuration:
+
+```bash
+sudoedit /etc/ansible/scripts/aim.yml
+```
+
+Merge the approved opt-in, without creating a duplicate `addons` block:
+
+```yaml
+addons:
+ execution_enabled: true
+```
+
+Leave Core's accepted `runtime.ansible_playbook`, collection path, key-owner and other controller settings intact. Re-run `aim-web core-check` as `aim-web` and confirm that effective Core execution is now enabled. [C1, section 13][C3]
+
+The gates remain separate:
+
+```text
+Core addons.execution_enabled
+ + WebGUI execution/credential/transport policy
+ + eligible playbook and host limit
+ + enabled account/grant and any required approval
+ + fresh reviewed Core revision
+ + worker readiness and required one-run credentials
+ = an eligible execution request
+```
+
+No single `true` setting proves successful or safe managed-host execution.
+
+## 11. Run the first controlled end-to-end test
+
+Use a small approved scope. For a reporting acceptance test, **Detected host roles** is the Core guide's suggested starting operation. [C1, section 14][C4]
+
+In the browser:
+
+```text
+New run
+ -> Select the test customer and Detected host roles
+ -> Select one approved host
+ -> Review options, Apply/Check mode and key handling
+ -> Review the normalized scope, warnings and report contract
+ -> Confirm and Run once
+ -> Unlock this run when the worker is ready
+```
+
+A saved plan is optional. For an encrypted Linux customer key, choose customer-key loading; the credential modal can use the Vault-stored passphrase where Core permits it. Windows/native-inventory runs do not need a Linux customer key. Only fields requested by Core are offered. A supplied connection password is a native default, not an override of inventory credentials. [C3][W9]
+
+The modal's acceptance message means the handoff was accepted, not that a password was verified. The empty reservation lasts five minutes, followed by the existing bounded handoff/start interval. Each new attempt needs fresh credentials; never put passwords in CLI arguments, URLs, plans, config examples or a queued record. [W9]
+
+Check all three result dimensions:
+
+| Dimension | What to inspect |
+|---|---|
+| Core verdict | Status, stage, native exit and whether remote work may have started |
+| Target outcomes | Each reviewed host's authoritative final outcome and counters |
+| Operation report | Schema, availability, retained content and Apply/Check meaning |
+
+The role report should expose `host_capabilities_v1` with the eight declared booleans. A missing report is not eight false values. A successful native exit can still produce `failed / result_validation` when required output is missing or invalid. Do not rerun automatically: remote changes may already be complete. [C3][C4][W9]
+
+For a longer approved run, close/reopen the job, reload and check from another authorized device. Confirm recorded progress survives and new events continue without replaying execution or requesting the same credentials again. Validate Reports after finalization and verify the host activity link. No total-task percentage or ETA is promised.
+
+After the first success, use Core's `SANITY.md` and WebGUI's `docs/CONTROLLER-PILOT.md` for separately approved Windows/Linux, mixed-result, cancellation, recovery and reporting cases. Updating packages, rebooting, starting services, exporting logs or deleting files needs its own test scope. One successful role query does not qualify every playbook. [C4][W10]
+
+## 12. Review retention and storage capacity
+
+Schema 5 stores accounts/workflows plus job-linked progress and operation reports. The shipped limits are: [W1][W9]
+
+```toml
+[journal]
+max_events = 20000
+max_bytes = 8388608
+
+[reports]
+max_bytes = 16777216
+retain_configuration = false
+```
+
+The progress limit retains a bounded tail, not a complete raw terminal transcript. Omissions and capture gaps are disclosed. Report limits also remain subject to Core/catalog caps. Full parsed Checkmk configuration sections are not retained by default; availability/file/redaction metadata remain with a metadata-only label. Enabling full retention is a separate operator data decision and applies to future captured reports.
+
+Deleting a job removes its journal and report rows and its contribution to the displayed host history. A compact audit deletion event remains. Existing backups have their own retention policy; deleting a job is not physical erasure of backups or storage remnants. Only WebGUI-owned retained runs contribute to Host Activity and Insights. [W9]
+
+Allow space for the database, journals/reports, protected backups and multiple versioned virtual environments. The sources do not specify a universal minimum disk or memory size; capacity depends on retained jobs and report sizes. Monitor it rather than treating per-job limits as a total database cap.
+
+The full `webgui.toml` is release-managed. Local policy edits are overwritten on a later managed update unless incorporated into the next reviewed release profile. [W1]
+
+## 13. Know the managed layout and ownership boundary
+
+```text
+/usr/local/bin/aim-web -> /opt/aim-web/current/bin/aim-web
+/opt/aim-web/current -> selected versioned virtual environment
+/opt/aim-web/venvs/ versioned add-on environments
+/opt/aim-web/deployment.json active deployment metadata
+/opt/aim-web/pending.json interrupted/in-progress deployment journal
+/etc/ansible/scripts/addons/webgui/ managed add-on source
+/etc/ansible/scripts/config/webgui.toml managed add-on configuration
+/var/lib/aim/webgui/ private accounts, workflows and evidence DB
+/var/lib/aim-web-executor/ private executor process HOME/staging
+/run/aim-web-executor/core.sock private group-accessible Core bridge
+/var/backups/aim-web/ protected add-on deployment checkpoints
+```
+
+The passwd-home `.ansible` staging directories and the three systemd units are also release-managed as described above. An existing account-home `.ansible` parent is normalized to the managed owner-only policy; review shared/custom uses of that directory before installation. The whole home is not made writable inside the service. [W2][W4]
+
+Core source, `aim.yml`, inventories, Vaults, canonical keys, collection installation, global SSH trust and certificates remain outside the add-on installer. Core and add-on recovery data are different directories with different deployer commands. [C1][W1]
+
+### Patch-wave behavior after Core 3.3.0rc8
+
+The public catalog adds the Windows-only Continue patching after reboot option (catalog hint false), plus reboot message/delay. Blank controls inherit native inventory/role policy; the add-on never forces continuation on. A successful Windows wave can require another reviewed run. See [Patch waves](docs/PATCH-WAVES.md) before testing update/reboot behavior. Reports show pending/unknown, reboot-deferred and fixed per-update failure metadata without parsing raw output or creating follow-up jobs. Historical patch reports retain their recorded schema.
+
+## 14. Back up the add-on independently
+
+The deployer creates checkpoints during install/update. Maintain an operator backup policy in addition to those checkpoints, especially now that the database contains retained reports. Keep Core runtime/customer data backups separate. [W1]
+
+The supported database backup command uses SQLite's backup API. Run it as the database-owning web identity, with a **new, nonexisting** destination it can write:
+
+```bash
+BACKUP_FILE="/var/lib/aim/webgui/manual-backup-$(date -u +%Y%m%dT%H%M%SZ).sqlite3"
+
+sudo -u aim-web \
+ /opt/aim-web/current/bin/aim-web \
+ --config /etc/ansible/scripts/config/webgui.toml \
+ db backup "$BACKUP_FILE"
+```
+
+The backup is created as `0600`. Transfer it through the approved protected backup process, preserving confidentiality, and record the matching Core/WebGUI versions and configuration. Database contents include account hashes, session/workflow data and retained operational reports. A database backup alone is not the deployer's full source/configuration recovery checkpoint. [W6][W11]
+
+Do not use an uncoordinated live copy of `webgui.sqlite3` as a replacement for the SQLite backup operation. Do not install pytest into a production environment to perform an installation check.
+
+## 15. Updating an existing add-on
+
+For an existing compatible managed installation, use the **new release's** fresh extraction:
+
+```bash
+cd /var/tmp/aim-web-2.1.0rc9
+sudo python3 deploy/deploy.py update
+```
+
+Use this exact command only when moving from an earlier version to 2.1.0rc9; it is not a same-version refresh. Preserve the previously selected `--aim-scripts` and account options when they differ from defaults. [W2]
+
+When moving to Core 3.3.0rc8 from the older supported Core, finish/cancel work deliberately, stop submissions and quiesce terminal writers. Stop the add-on services before independently updating Core:
+
+```bash
+sudo systemctl stop \
+ aim-web-worker.service \
+ aim-web.service \
+ aim-web-executor.service
+```
+
+Follow Core's own preview/apply procedure, then deploy the compatible WebGUI release. Do not expect the older adapter to accept a newly installed Core automatically. [C1][W1]
+
+From WebGUI 2.1.0rc3, this update keeps SQLite schema 5: no new database migration or historical report rewrite. From a schema-4 baseline, the existing schema-5 migration remains (pending/queued work blocked, running work interrupted, stale reviews removed). Source/schema changes still require fresh review; do not replay an old prepared request. [W1]
+
+Ordinary 2.x updates do not need `--migrate-core`. That flag is reserved for the documented legacy 1.x integration/state migration and recognized key-export/capability retirement. Read `docs/MIGRATION-3.1.md` before using it. Never treat it as a general permission or version-check bypass.
+
+## 16. Recover without changing Core accidentally
+
+### Failed or interrupted installation/update
+
+Record the complete deployment error and printed checkpoint identifier. The deployer attempts recovery where a checkpoint exists; an incomplete recovery leaves `pending.json` to prevent ambiguous repeated installation. Keep services stopped until the failure and recovery state are understood. [W1][W2]
+
+For a recorded interrupted deployment, use the same release's extracted deployer:
+
+```bash
+cd /var/tmp/aim-web-2.1.0rc9
+sudo python3 deploy/deploy.py recover
+```
+
+`recover` is not a generic repair command: it requires the pending deployment journal. Do not delete that journal or switch the `current` symlink manually to bypass recovery.
+
+A failed first install preserves private state for deliberate recovery/retry; it does not automatically wipe accounts or generate another admin. A completed first-install checkpoint is not a prior installed version, and the rollback command rejects it. Inspect and preserve state instead of removing the database to force bootstrap. [W2][W7]
+
+### Roll back to a previous managed add-on
+
+After independently backing up current state and quiescing work, use the exact checkpoint **identifier**, not Core's `--from-backup` syntax:
+
+```bash
+sudo python3 deploy/deploy.py rollback \
+ --backup CHECKPOINT_ID
+```
+
+If the selected previous release requires schema 4, crossing back from schema 5 requires an explicit historical database restore:
+
+```bash
+sudo python3 deploy/deploy.py rollback \
+ --backup CHECKPOINT_ID \
+ --restore-auth-db
+```
+
+That can restore old password hashes and discard newer accounts, plans, jobs, journals and reports. Restored sessions are revoked. The backup ID must be one actually printed by this installation. [W1][W2]
+
+The restored adapter is checked against the installed Core. If incompatible, restored add-on services remain stopped and disabled. Core rollback is separate; after validating a matched Core/WebGUI/database combination, explicitly enable/start the intended services. Neither rollback reverses completed remote changes, dependency installation or independent account/trust decisions.
+
+### Disable the add-on
+
+After active work is finished or deliberately canceled:
+
+```bash
+sudo systemctl disable --now \
+ aim-web-worker.service \
+ aim-web.service \
+ aim-web-executor.service
+```
+
+This disables the three add-on units, not AIM terminal. It is not a data purge. There is no `uninstall` command in this deployer; retain private state and backups until a separately approved removal decision. [W2]
+
+## 17. Troubleshooting at the correct boundary
+
+| Symptom | First check / action |
+|---|---|
+| `No managed add-on installation exists` | Use `install` for a genuinely new add-on, not `update`. |
+| Missing manifest, coverage or hash error | Re-verify the published ZIP and use a fresh extraction. Do not disable the verifier. |
+| Unmanaged unit drop-in blocks deployment | Inspect the exact file and preserve it for review; do not blindly delete or reinstate older capability overrides. |
+| Root command reports unsafe database/staging ownership | Use the correct service identity or real executor startup context. Do not transfer service state to root. |
+| Manual executor command gives `PermissionError` on `webgui.toml`, but startup passed | The plain shell may lack the unit-scoped `aim-web` group. Inspect service journal and active identity. |
+| Web config says execution enabled, Core says disabled | Review Core's independent `addons.execution_enabled` opt-in. |
+| Missing collections or wrong native version | Fix the separately approved Core runtime/discovery as executor; do not pip-install Ansible into WebGUI. |
+| Customer key rejected | Inspect the selected canonical key's owner, `0600` mode and traversal as executor. `private_key_owner` does not migrate existing keys. |
+| Delegated controller task becomes `UNREACHABLE` | Inspect actual task/delegation and both staging paths inside the sandbox before blaming WinRM. |
+| Linux trust failure despite root terminal success | Review effective executor SSH trust and actual destination; retain the approved global trust file. |
+| Browser origin/security-token rejection | Check the exact HTTPS origin, immediate proxy/header chain, current page/session and clock. Do not disable CSRF. |
+| Queued job never asks for credentials | Check worker activity, Core execution policy, approval/schedule and current job state. Only its requester gets the modal. |
+| Native exit 0 but failed at `result_validation` | Inspect report availability/schema. Remote work may have completed; no automatic replay. |
+| Empty old timeline/report | Pre-capture history is not reconstructed. Check the job's version and capture/retention markers. |
+| `pending.json` blocks another deployment | Investigate and use the same deployer's recorded recovery path. |
+
+Start with version/status/configuration checks, the specific job's structured facts and the relevant service journal. Do not share passwords, full inventories/Vault dumps or unrestricted configuration/report bodies in diagnostics. Local Ansible/module logging may contain operational details even though the WebGUI journal excludes raw output. [C4][W1][W9]
+
+## Fresh add-on completion checklist
+
+Before broader operational use confirm:
+
+- Core's prerequisite installation and controlled terminal acceptance are complete.
+- The WebGUI ZIP checksum and release manifest pass, and the current profile fits the site.
+- The existing executor is independently authorized and can discover the intended Core runtime/collections.
+- HTTPS/proxy trust is accepted; no placeholder origin or unverified transport attestation was reused.
+- Fresh installation used `install`, or a real managed upgrade used `update`.
+- All three expected services start, and executor startup checks pass inside their actual sandbox.
+- Account/group, socket, configuration, state and dual-home staging match the managed policy.
+- The bootstrap admin password has been changed and appropriate named accounts/grants reviewed.
+- Core add-on execution was enabled only through the separate operator opt-in.
+- A small approved end-to-end run has accepted credentials, native outcomes and its declared report.
+- Reload/second-device progress and finished-job evidence behave correctly without replaying work.
+- Report retention, protected backups, storage growth and recovery checkpoints have been reviewed.
+- Candidate acceptance is recorded by actual UID/sandbox/runtime/platform; unsupported or untested cases are not claimed as passed.
+- Terminal AIM remains independently usable.
+
+## Related documentation and source basis
+
+This companion preserves the prerequisite boundary and terminology of the supplied Core guide. Add-on-specific commands and paths were checked against the published WebGUI archive. Core's installation guide has not been edited. This companion is updated with the add-on release; it is not a claim of a performed controller installation.
+
+| Reference | Source | What it governs |
+|---|---|---|
+| C1 | Core `scripts/docs/INSTALLATION.md`, AIM 3.3.0rc8 | Prerequisite Core installation, independent environments, opt-in, terminal acceptance and Core recovery |
+| C2 | Core `ADDON_AGENTS.md` | Same-UID public integration, ownership and hardening boundaries |
+| C3 | Core `scripts/docs/ADDON_API.md` | Authorization, discovery, readiness, request/credential/result semantics |
+| C4 | Core `scripts/docs/SANITY.md`, `VALIDATION.md`, `OPERATION_RESULTS.md` | Controller acceptance, candidate evidence and report rules |
+| W1 | WebGUI `docs/DEPLOYMENT.md` | Coordinated migration, retention, backups and rollback |
+| W2 | WebGUI `deploy/deploy.py`, `pyproject.toml`, `constraints.txt` | Actual supported installer flags, initialization, generated units, dependency handling and fixed paths |
+| W3 | WebGUI `adapters/core_v1.py`, `core/executor.py` under `src/aim_webgui/` | Qualified public Core compatibility and executor boundary |
+| W4 | WebGUI `docs/PERMISSIONS-ROLLOUT.md`, `AGENTS.md` | Resource ownership, split-home staging and known-hosts cautions |
+| W5 | WebGUI `deploy/webgui.example.toml`, `deploy/nginx.example.conf`, `src/aim_webgui/config.py` | Actual shipped site profile, reference headers and configuration validation |
+| W6 | WebGUI `src/aim_webgui/cli.py` | Version/configuration/readiness, backup and administration commands |
+| W7 | WebGUI `src/aim_webgui/auth/service.py`, `workflows.py` | Bootstrap, first password change, account/grant and job authorization |
+| W8 | WebGUI `deploy/fetch_assets.py` | Exact browser-asset pins and offline input names |
+| W9 | WebGUI `docs/CREDENTIALS.md`, `RUN-COMFORT.md`, `EXECUTION.md`, `JOURNAL.md`, `REPORTS.md` | Credential modal, execution lifecycle and retained-evidence behavior |
+| W10 | WebGUI `docs/CONTROLLER-PILOT.md`, `docs/VERIFICATION.md` | Operator tests and recorded qualification limits |
+| W11 | WebGUI `src/aim_webgui/db/store.py` | Database ownership and consistent backup behavior |
+
+Core topic files live in the independently installed Core tree. WebGUI topic/source paths above are relative to the verified `aim-web-2.1.0rc9` extraction unless stated otherwise. The current Core archive contains `scripts/docs/INSTALLATION.md`. Read that separately installed prerequisite; no Core source or runtime is copied into this add-on.
+
+**Evidence limit:** this document was produced from the supplied guides and inspected release code. It does not add new installation, dependency-security, proxy, systemd or managed-host test evidence. Use the two products' current validation records and complete the controller acceptance steps for the actual deployment.
diff --git a/scripts/addons/webgui/AGENTS.md b/scripts/addons/webgui/AGENTS.md
new file mode 100644
index 0000000..b8892ac
--- /dev/null
+++ b/scripts/addons/webgui/AGENTS.md
@@ -0,0 +1,97 @@
+# AIM WebGUI global agent standards - 2.1.0rc9
+
+Read this file before changing application, tests, deployment or documentation. Update it in the same release whenever a global convention or security boundary changes.
+
+## Product boundary and compatibility
+
+AIM Core is independently installed and managed. This candidate targets supplied Core **3.3.0rc8**, service/wire/event API **1.0**, `play_task_host_v1`, `inventory_hierarchy_v1`, `target_outcome_summary_v1`, and `native_defaults_preflight_v2`. WebGUI HTTP remains **v2** and SQLite remains **schema 5**. Do not call this production-qualified merely because source tests pass.
+Core rc8 additionally advertises `collection_baselines.ansible.windows` as `>=3.8.0,<4.0.0`. Require that public capability declaration, but do not install, upgrade or privately discover collections from WebGUI; Core readiness and operator acceptance own the actual collection environment.
+
+Read Core's ADDON_AGENTS.md, ADDON_API.md, ADDON_SUPPORT.md and RELEASE_HANDOFF.md. Use only documented fixed `aimctl` JSONL operations via the existing adapter/client. Never import private AIM/Ansible modules, parse Core inventories locally, rewrite Ansible argv, vendor Core, or modify its source/configuration/runtime/roles/Vaults/keys. Additive unknown response fields may be ignored; unsupported capabilities/errors must not become invented successes. Removing or breaking the add-on must not prevent terminal AIM from working.
+
+The executor is an add-on-owned, pre-started non-root same-UID Core caller. Web and queue use aim-web; the executor uses the independently provisioned AIM execution identity. No sudo/setuid/key-export bridge, HTTP-started Ansible command or arbitrary command/path/actor/environment endpoint. Private Unix IPC uses peer checks. Shared local service identities are trusted controller actors, not hostile-tenant isolation.
+
+## Execution, credentials and preserved workflows
+
+A run needs fresh review, not a saved plan. Keep bounded private expiring reviews, explicit targets, frozen scope/options/mode/key handling/revision and current grant/approval checks at dispatch. One-run submissions are idempotent; saved names are optional with transactional NFKC/casefold/strip collision prevention, never upsert by title. Existing plans/jobs/accounts/audit survive normal releases.
+
+Use Core's reported credential requirements and native inventory precedence. No forced Custom/password-only overrides, become-password UI, private-key uploads, prompt automation, credential guessing or secret cache. Secret collection stays authenticated, CSRF/origin-protected HTTPS POST with bounded bodies; credentials travel in a separate private FD, not request JSON, argv, environment, database, logs or files. Preserve literal UTF-8 and existing one-run deadlines. Python cleanup is not physical memory erasure.
+
+Final Core status/exit/counters and target facts remain authoritative. Partially succeeded is a presentation label, not a rewritten Core result or database queue state. Never derive final host success from SSE or task counts. Missing response, cancellation and interruption may leave work unknown; cancellation is not rollback. Manual retry creates a new reviewed job and fresh secrets. Running jobs cannot be deleted. Deletion removes job/lifecycle/idempotency records while retaining a compact audit event, not a hidden transcript archive.
+
+Only documented static labels, logical host names, fixed hints and numeric detail progress are rendered. Do not recover raw module output, decrypted variables or unsafe diagnostics. A bounded allowlisted structured journal is now retained with the job; rendered console strings and raw streams remain prohibited. Final report bodies are separate, declared and validated.
+
+## Read-only experience: explicit data scope
+
+Inventory Explorer consumes the current Core hierarchy and separate host metadata. All new explorer/activity/insights routes are GET-only and must never call prepare/execute, collect secrets or probe managed hosts. Current inventory is not persisted as a competing database. Group identities are full paths; direct/root membership and repeated host membership are preserved. Recursive counts are distinct hosts, not sum-of-appearance counts. The map is membership, not topology or live health.
+
+Host Activity and Playbook Insights use ONLY retained **AIM WebGUI jobs** and final Core per-target facts already stored with those jobs. Do not collect terminal AIM execution history, external/core-wide history, audit-derived reconstructed results or a separate shadow archive. Use a customer plus exact logical hostname identity; never silently merge renamed/recreated machines.
+
+History queries must use the same owner-or-admin visibility as job detail BEFORE aggregation. Saved plans remain owner-only, including for administrators. Inventory visibility does not confer access to another viewer's job history. Aggregate counts, filter choices, matrix cells and links must not leak hidden jobs. Preserve existing read policy rather than inventing tenant isolation.
+
+Stream all matching retained job rows rather than reuse the latest-100 Jobs overview. Page displayed records and bound graph/matrix output; define time range, modes and coverage. Count one requested host participation per job. Apply is the default; Check is explicit and never evidence of installed changes. Success percentage = successful / (successful + failed + unreachable), with denominator visible; not-started/indeterminate/unavailable/outstanding are separate. Changed values count tasks; any derived metric must say so. No per-host duration from job elapsed times. Deleted jobs disappear from statistics; incomplete/legacy target data remains unavailable, never guessed successful.
+
+A Core outage is not empty inventory or an offline host. Authorized retained history remains available when current inventory cannot be retrieved. Historical outcomes include timestamps and do not claim current health/compliance/software versions. No graph click launches a job.
+
+## UI and mobile conventions
+
+Use Jinja2, HTMX and small self-hosted JavaScript with Bootstrap 5. Keep centralized orange accents, charcoal/slate dark surfaces, sun/moon controls, semantic status chips, visible focus, keyboard navigation and reduced-motion behavior. No persistent special orange outline around Jobs/Saved plans. Non-sensitive theme preference may use browser storage; inventory/history/credentials must not.
+
+Desktop above 760px retains the sidebar. Mobile uses a single compact header: **AIM home link left; light/dark controls then hamburger at the far right, vertically aligned**. The native details/summary menu opens below/right with viewport-bounded internal scrolling. Remove horizontal swipe rails, arrow controls and swipe instructions. Keep Escape/focus return, outside-click close, native keyboard/no-JavaScript opening, and account/logout controls reachable. Do not use ARIA menu roles for ordinary site navigation.
+
+Inventory uses a deterministic linked SVG map and equivalent outline. Phone defaults to branch-focused outline; Map remains available with explicit zoom controls and internal scrolling. Breadcrumbs replace unbounded indentation. Host links open a real activity page. Never require hover, drag or pinch to obtain essential information. Direct host/group pages are bounded, searchable and paged.
+
+Use mobile cards for historical results/matrices. Prevent page-level horizontal overflow. Group selectors retain two columns on narrow screens and aligned selection columns; explicit submitted hostnames remain authoritative. Live output autoscroll changes the console scrollTop, never the window; manual upward scrolling pauses follow. Filters, text labels and errors remain legible at 320px and short landscape heights.
+
+Store/transport UTC, render semantic time[datetime] using browser locale, retain inspectable UTC; reformat initial load, HTMX replacements and pageshow. Schedule input stays explicitly UTC unless a separately tested conversion change is approved.
+
+## Permissions and deployment
+
+Preserve rc8's tested intent: executor primary group is its native account group; aim-web is unit-scoped supplementary group. Config root:aim-web0640; private web state aim-web0700; executor state and both process-home/passwd-home temp directories executor0700; runtime directory executor0711; socket executor:aim-web0660. Keep ProtectHome=read-only, narrow temp write exceptions, NoNewPrivileges and empty capabilities. Wait boundedly for socket readiness.
+
+Core authorization groups, inventory/Vault/private-key ownership, system SSH trust, certificates and proxies remain operator/Core managed. Do not repair them from HTTP. Do not infer OpenSSH known_hosts from HOME: effective SSH configuration and passwd expansion are authoritative. No recursive chmod/chown of /etc/ansible.
+
+Whole-release replacements, independent immutable versions, no Git requirement. Overwrite release-managed WebGUI config as requested; preserve private state. Stage dependencies and public Core metadata before downtime; backup matching source/config/units/DB and handle rollback explicitly. Unknown unit overrides require review. Do not modify either production Python environment for tests. Existing 1.x migration needs --migrate-core; ordinary 2.x updates do not.
+
+## Tests and evidence
+
+Test authentication, request boundaries, Core contract/FD transport, different-UID executor, migrations, existing workflow guards, read-only authorization/aggregates/deletion, >100 retained jobs, repeated membership, same hostnames across customers, outages/empty states, and escaped output. Browser QA covers mobile hamburger/alignment/focus, graph links/zoom, cards, themes, short heights, local timestamps and internal console scrolling. Distinguish real Core metadata from fake native execution and fixture browser assets.
+
+Verify the actual final extracted ZIP through deploy.verify_release, canonical manifest paths and required-file coverage, compile/templates/JS, wheel resources and pristine Core hashes. Never claim skips as passes, fake native tests as SSH/WinRM qualification, fixture events as live HTMX/SSE, or static unit tests as systemd deployment. Keep VERIFICATION.md and machine-readable results aligned with observed evidence.
+
+
+## Credential dialog and attention - 2.1.0rc9
+
+Use a progressively enhanced native dialog for deliberate owner-initiated credential entry; keep the authenticated full-page form as the no-JavaScript/unsupported-dialog fallback. Use Bootstrap 5 native radio/label button groups, never Bootstrap 4 JavaScript or jQuery. A key-passphrase source choice is allowed only for the exact Core requirement `ssh_key_passphrase_or_customer_vault_value` alongside `vault_password`; an explicit `ssh_key_passphrase` requirement remains required. This is not a Vault/Custom authentication override.
+
+Keep dialog contents outside HTMX-polled fragments. Load the form lazily for the current reservation, never pre-populate secrets or automatically open/focus a password field. Show the reviewed customer, playbook, target count and mode. Preserve focus containment, Escape and explicit Close, focus return after replaced triggers, short-viewport internal scrolling, persistent labels, paste, Show/Hide and Caps Lock hints. A backdrop tap must not accidentally discard typing. Close dismisses the form, not the job.
+
+Clear all marked secret inputs on submission, closure, expiry/revocation and pagehide, including revealed text inputs. Keep submitted secrets out of URLs, storage, logs, titles, status responses and history snapshots. Do not claim clearing DOM/references erases all browser/runtime memory. Preserve five-per-minute submission throttling, existing worker single-claim semantics, five-minute empty reservation and 60-second handoff/start deadline. Do not extend a reservation on a GET or while typing.
+
+Use a monotonic client countdown synchronized to server timestamps for display only. The server remains authoritative. An accepted handoff is not a verified password or completed execution. On an uncertain/lost POST response, clear inputs, lock submission and poll owner-only status; never automatically resend credentials. Reopening an uncertain job in the same page must not offer another POST. Terminal status, cancellation or lost eligibility must close the input opportunity without manufacturing a retry.
+
+Needs your attention is a non-secret, read-only view: your own live credential reservations and, for eligible administrators, other requesters' jobs pending independent approval. It does not reveal other owners' credential forms, automatically approve/execute jobs or use the latest-100 history limit as its population. Existing policy and grants still apply. Keep ordinary execution failures separate from jobs currently waiting on user input.
+
+The canonical credential POST is `/api/v2/runs/{id}/credentials`; retain the shipped `/api/v2/jobs/{id}/credentials` alias. New reservation-status GETs must not return secrets, override scope, renew a deadline, or invoke Core. Tests must exercise the shared rate limit, missing required keys, revoked sessions/grants, expiry, cancellation, duplicate submission, literal passwords, ambiguous acknowledgements and the real existing worker handoff socket. Browser fixtures are not live pinned-asset/CSP/HTTPS/HTMX qualification.
+
+
+## Core3.3 reports and persistent evidence
+
+The approved contract is Core3.3.0rc8/service-event1.0, aim_output_v1 publisher and aim_operation_result_v1 final result. Negotiate live capabilities; preserve validated result_contract with review. Do not add request output flags, read schemas from Core files or invoke private validators. Revalidate final mode/host/schema/scope/data against the recorded contract. Reports arrive only at finalization. A final event is not a final response; persist one report, never duplicate samples.
+
+Keep execution verdict, native target outcome, report availability and local retention distinct. Native exit0/result_validation stays failed even when all targets succeeded. An unavailable report is not empty/zero/false. Unknown versions stay unknown; pending updates are not installed; excluded services are not failed starts; ignored/rescued native semantics stay intact. Generic supported schemas may render safely, never execute custom code or external references.
+
+Progress is captured worker-side with no viewers, via bounded nonblocking queue/batched committed rows. IDs correlate interleaved tasks; no invented future task list/ETA/percentage. Record durable local cursors and deduplicate(job,run,sequence). Tail omissions, queue/storage loss, crash gaps and last-observed time are explicit. SSE replay checks session/job authorization throughout and cannot replay execution or credentials. Missing final response never becomes success from progress.
+
+Schema5 evidence tables are job-linked with cascade deletion. Journal default20,000 events/8MiB tail; latest checkpoint bounded; report default16MiB subject to reviewed slot bounds. Keep reports out of heavy ordinary job/history reads; load one authorized payload on demand. Parsed Checkmk sections are opt-in; default is labeled metadata_only and not a complete report body. Do not retain a secret cache, terminal history or shadow archive; backups/physical erasure have separate policy.
+
+Escape all labels/report text. Never follow returned paths/URLs or render HTML. Browser data storage remains forbidden for evidence. Preserve working modal/hamburger/theme and internal scrolling; no new privileges/services. Public result framing may grow independently of unchanged request/credential limits. Test full-size/split/torn/duplicate-final transport, all9 declarations, mixed outcomes, no_log/secret canaries, generic/global fixtures, two viewers/reload/revocation, retention pressure/deletion, migrations and matched rollback. Report native/browser/fixture boundaries honestly and update all current guides in the same release.
+
+
+## Core 3.3.0rc8 patch-wave presentation
+
+Obtain reboot/message/delay/continuation controls from public catalog metadata; do not maintain a second defaults table. Blank means omitted/inherited, not a forced value. Surface the Windows-only continuation flag and its catalog hint (false in rc8); explicit true is never inferred from reboot permission. Review normalized options before any run.
+
+Use only validated recorded patch_summary_v1 fields. A successful wave with continuation_required stays successful and calls for review, not an automatic job. remaining_updates_known=false prohibits presenting pending or an empty list as an authoritative next-wave state; retain original structured JSON separately. True refers to the dated final read-only discovery only. Missing optional fields in older reports remain unknown; validate new data with its prepared schema, not an old schema merely sharing the identifier.
+
+Show pre/post reboot/deferred observations, reviewed delay and cycles when supplied. Core owns bounded HRESULT codes/reasons/messages. Never parse raw failure output or equate install_not_allowed (including 0x80240016) with the separate preexisting_reboot_required preflight result. Native failure, report validation, report completeness and next-wave needs remain separate. No auto-enable reboot/rescan, no generic retry shortcut, no new permissions/services/secret handling.
diff --git a/scripts/addons/webgui/CHANGELOG.md b/scripts/addons/webgui/CHANGELOG.md
new file mode 100644
index 0000000..72aab04
--- /dev/null
+++ b/scripts/addons/webgui/CHANGELOG.md
@@ -0,0 +1,556 @@
+# Changelog
+
+## 2.1.0rc9 - Core 3.3.0rc8 release reconciliation
+
+Compatibility: separately deployed Core 3.3.0rc8; public service/wire/event 1.0; WebGUI HTTP v2; SQLite schema 5 unchanged.
+
+- Repackage the rc8-compatible WebGUI line as rc9 after reconciling current-release metadata, operator guidance and verification evidence with the supplied Core 3.3.0rc8 archive.
+- Preserve the rc8 public contract and live capability gates, including `ansible.windows >=3.8.0,<4.0.0`, `play_task_host_v1`, `inventory_hierarchy_v1`, `target_outcome_summary_v1`, `native_defaults_preflight_v2` and structured operation results.
+- Remove stale current-candidate rc4/rc5 labels from active documentation while retaining historical release notes and historical Core reviews as provenance.
+- Refresh release verification against the supplied rc8 source tree; no Core files, database schema, service API, permission model, credential model or execution semantics are changed by this repackaging.
+- Correct a stale legacy rollback comment (`rc18-only`) to describe the actual pre-2.x adapter boundary; runtime behavior is unchanged.
+
+## 2.1.0rc5 - Core 3.3.0rc8 compatibility
+
+Compatibility: separately deployed Core 3.3.0rc8; public service/wire/event 1.0; WebGUI HTTP v2; SQLite schema 5 unchanged.
+
+- Requalify the exact adapter/executor/deployment gates for Core 3.3.0rc8 while retaining live capability negotiation.
+- Require Core's advertised `ansible.windows >=3.8.0,<4.0.0` collection baseline. WebGUI does not install or upgrade collections.
+- Accept and render the additive `patch_summary_v1.reboot_reasons_before` native reboot-source observations without changing job verdicts or triggering follow-up work.
+- Update Windows filesystem-report wording to attached local storage volumes; mapped/network drives are intentionally excluded by Core rc8.
+- Preserve patch continuation, remaining-update knowledge, HRESULT presentation, report/journal retention, credentials, mobile UX, database schema 5 and the existing permission/systemd model.
+- Treat Core rc8 Checkmk script relocation/ACL hardening as Core-owned runbook behavior; do not reconstruct paths/ACLs or add private APIs.
+- Update fresh-install, deployment, controller-pilot and agent guidance for the new native collection floor and Core rc8 acceptance boundary.
+
+## Preserved previous release notes
+
+## 2.1.0rc4 - Core 3.3.0rc3 patch-wave support
+
+Compatibility: separately deployed Core3.3.0rc3; public service/wire/event1.0; WebGUI HTTPv2; SQLite5 unchanged from rc3.
+
+- Update exact adapter/executor/CLI/deployment compatibility checks while retaining live capability negotiation. Core remains unchanged.
+- Expose reboot message/delay and Windows-only post-reboot continuation through existing public catalog forms, showing platform applicability and catalog hints. Blank still omits overrides. Review distinguishes explicit reboot/continuation/delay from inherited inventory/role policy.
+- Present continuation_required independently from successful/failed Core and job status. No automatic follow-up jobs, reboot enablement or continuation override.
+- Render reboot before/after/deferred state, observed AIM reboot, reviewed delay, cycles, stop reasons and per-update unsigned/hex HRESULT, fixed reason and safe message. No raw fatal-text/event-log parsing.
+- Treat remaining_updates_known=false as unknown rather than an authoritative empty/old queue. True labels a final read-only, dated discovery, not an expanded install queue or current compliance. Original validated JSON stays available.
+- Preserve old patch reports using their recorded contracts, including the older closed patch_summary_v1 shape; do not invent absent fields or validate old history against the new catalog.
+- Keep modal/mobile/graph/history, retained journal/report policy, target outcomes, execution review, credential boundaries and the generated permission/unit contract unchanged.
+- Include the requested ADDON-INSTALLATION.md in the release and update deployment/patch acceptance/agent guidance. No new database migration, dependencies, services or write exceptions.
+- Add public-Core rc3, historical-report, patch-state, escaped-output and review regression coverage. Actual results and environment limitations are in docs/VERIFICATION.md.
+
+## Preserved previous release notes
+
+
+## 2.1.0rc3 - Core3.3 reports and retained execution evidence
+
+- Target independently managed Core3.3.0rc1 with existing service/wire/event1.0; WebHTTPv2, additive SQLite schema5.
+- Preserve negotiated result contracts in review; validate final operation reports and new result_validation/error families while keeping native outcome and report availability separate.
+- Separate large public-result framing from unchanged inbound credential/request limits; reject malformed, torn or oversized payloads without replay.
+- Retain structured worker-side progress with bounded batched writes, tail/checkpoints, durable cursor replay and explicit coverage gaps. No raw output or browser-dependent collection.
+- Retain eight ordinary report types by default and only metadata for parsed Checkmk config unless opted in. All9 schema-keyed summaries and generic supported JSON views; lazy per-slot payload reads, dated Host Activity links.
+- Cascade report/journal deletion with jobs, preserving only compact audit metadata. Historical jobs are not backfilled and stale pre-upgrade work is stopped by migration.
+- Preserve modal, mobile header, hierarchy/history, normal executor identity and all narrow staging/IPC permissions. Add restored-adapter Core compatibility check before service restarts on rollback.
+- Qualification evidence is recorded in docs/VERIFICATION.md; no new live-host certification is implied.
+
+## Historical releases
+
+# Changelog
+
+## 2.1.0rc2 - one-run credential dialog and attention
+
+Compatibility: AIM Core 3.2.1rc2; service/event API 1.0; WebGUI HTTP v2; SQLite schema 4. No Core, deployment, permission or dependency-pin change.
+
+- Replace default navigation to password entry with an owner-initiated native modal; retain full-page/no-JavaScript fallback. The modal is outside polled job fragments.
+- Show reviewed job context, server-synchronized reservation countdown, required fields, Show/Hide and Caps Lock feedback. Short/mobile viewports scroll within the dialog.
+- Add Bootstrap 5 segmented radio choices for customer-Vault versus separate SSH key passphrase only when Core permits both. Explicitly required key passphrases stay required. No Custom authentication override.
+- Clear revealed/masked inputs on dismissal, submit, invalidation and navigation. Treat lost acknowledgement as uncertain; reconcile with read-only status rather than automatically resending credentials.
+- Add Needs your attention to Overview/Jobs with owner credential actions and existing independent-administrator review links. Do not change approval or execution policy.
+- Add canonical /api/v2/runs/{id}/credentials POST alias, retaining /api/v2/jobs/{id}/credentials; add owner-only credential-status and guarded HTML fragment GETs. Both POST routes share existing throttling and worker handoff.
+- Preserve literal secrets, CSRF/origin/session/grant checks, bounded bodies and existing one-run deadlines. Improve ambiguous-handoff wording; enforce an explicitly required SSH key passphrase.
+- Add synthetic endpoint and real local worker-socket tests plus mobile/desktop credential-dialog browser fixtures. See VERIFICATION.md for measured results and limitations.
+
+
+## 2.1.0rc1 - read-only experience candidate
+
+Compatibility: independently managed AIM Core3.2.1rc2; service/wire/event1.0; WebGUI HTTPv2; SQLite4. Based on WebGUI2.0.0rc8. No new Core requirement, database migration, runtime permission or execution-policy change.
+
+- Replace the mobile swipe rail with a compact AIM-home / sun-moon / right-aligned hamburger row and native keyboard-operable dropdown; desktop sidebar retained.
+- Add current Core Inventory Explorer with linked SVG group/host map, branch focus, distinct counts, search and equivalent mobile outline. Graph nodes navigate, never execute.
+- Add Host Activity from retained WebGUI final per-target results with mode/time/playbook/outcome filters, paginated timeline, counter details and own saved-plan references.
+- Add Playbook Insights with explicit metric denominators, outcome distribution, paged host-by-playbook matrix and mobile cards. Never scrape terminal/core-wide history or infer live health.
+- Scope aggregation to underlying job authorization before reading records; cover more than100 jobs, legacy/unknown data and deletion without a shadow archive.
+- Connect existing host lists, job target summaries, Jobs and Saved Plans previews to activity pages. Keep partial host/parent outcomes distinct and current inventory outages visible.
+- Preserve one-run review/idempotency, optional collision-safe names, credentials, retry/deletion controls, bounded live output and existing managed permission model.
+- Reconcile current agent/read-only/mobile documentation and add unit, real-Core read, browser-fixture and synthetic large-history coverage. Actual qualification is recorded in docs/VERIFICATION.md, not inferred from older RC counts.
+
+## Preserved historical record
+
+The following is the changelog as supplied in2.0.0rc8, including its duplicated historical headings. It is retained as provenance; it is not new2.1 behavior or new verification.
+
+# Changelog
+
+## 2.0.0rc8
+
+- Require and integrate AIM Core 3.2.1rc2 / service API 1.0 while preserving the independent Core/WebGUI release boundary.
+- Consume `target_outcome_summary_v1` and retain Core's authoritative overall `failed` status while presenting mixed requested-target outcomes as `Partially succeeded` in WebGUI.
+- Show Core-provided per-target outcome and task counters on Job detail; Jobs overview shows successful/requested and failed/unreachable/not-started/indeterminate counts. No task-event reconstruction is used for final host state.
+- Consume read-only `inventory_hierarchy_v1` for nested parent/subgroup selection and inventory display. Parent scopes include Core-declared descendant hosts; execution still submits explicit reviewed hostnames.
+- Require `native_defaults_preflight_v2`; keep rc6 release-managed split-home staging and executor sandbox unchanged.
+- Remove the special persistent orange outline around Jobs and Saved plans; active/hover navigation styling remains consistent with the rest of the menu.
+- Preserve one-run jobs, optional collision-safe plan names, detailed safe live output, running-job deletion protection, API v2, SQLite schema 4, and the rc6 managed permission model.
+
+## 2.0.0rc6
+
+- Fix delegated `localhost` execution under the hardened executor sandbox. Ansible 2.19 local connections expand `~svc_bf-ansible/.ansible/tmp` from the passwd database even when the executor service sets `HOME=/var/lib/aim-web-executor`.
+- Release-manage `/home//.ansible` and `/home//.ansible/tmp` as executor-owned `0700`, and grant `ReadWritePaths` only to that exact local staging path while keeping `ProtectHome=read-only`.
+- Extend `core-staging-check` so startup validates both Core controller-local staging and Ansible delegated-local staging inside the actual systemd sandbox.
+- Do not set global `ANSIBLE_REMOTE_TMP`; doing so would also alter POSIX temp paths on managed Linux hosts.
+- Preserve Core 3.2.1rc1 detailed progress, managed socket/runtime permissions, API v2, SQLite schema 4, and existing credential boundaries.
+
+## 2.0.0rc5
+
+- Fix the rc4 executor-start race: deployment now waits for the `Type=simple` executor to bind and permission `/run/aim-web-executor/core.sock` before validating runtime ownership/mode. A temporarily missing socket is treated as startup-in-progress rather than immediate migration failure.
+- Fail deterministically if the executor service exits before binding, or if the managed socket does not become ready within the bounded startup window.
+- Preserve the rc4 release-managed identity model: `svc_bf-ansible` primary user/group, unit-scoped `aim-web` supplementary group, executor-owned `0711` runtime directory, and `executor:aim-web 0660` socket.
+- No Core, database schema, API, credential, inventory, Vault, key-ownership, or global SSH-trust changes.
+
+
+## 2.0.0rc5
+
+- Fix the rc3 runtime-directory ownership transition: systemd now owns `/run/aim-web-executor` as the executor identity with mode `0711`; authorization remains on `core.sock` as `executor:aim-web 0660`. This removes runtime `chgrp` and allows upgrades from rc2 without manual `/run` repair.
+- Preserve the release-managed executor primary group plus unit-scoped `aim-web` supplementary group, staging checks, config/state ownership, and detailed Core 3.2.1rc1 progress rendering.
+
+
+## 2.0.0rc5 — Core 3.2 detailed progress, operational UI, and managed permissions
+
+Compatibility: AIM 3.2.1rc1; public core service/wire/event 1.0; detail schema `play_task_host_v1`; WebGUI HTTP v2; SQLite 4.
+
+- Negotiate `progress_mode=detail` and render safety-filtered play/task/host progress in the live job console while keeping raw module stdout/stderr unavailable.
+- Adopt Core 3.2.1rc1 controller staging preflight requirements in the executor unit: private `.ansible/tmp`, scoped `ReadWritePaths`, and startup staging check.
+- Add semantic job state chips, richer Jobs and Saved plans scope previews, stronger navigation emphasis, aligned Audit filters, and viewport-bounded internal console scrolling.
+- Preserve running-job deletion protection, one-run execution, optional collision-safe plan names, API v2, schema 4, and separate non-root core executor architecture.
+- Preserve the executor account's normal primary group and grant `aim-web` only as a unit-scoped supplementary group; no `/etc/group` mutation is performed.
+- Release-manage and verify WebGUI config/state, executor HOME/staging, systemd unit ownership, runtime directory group, and core socket mode/ownership during deployment.
+- Keep Core-owned inventory/Vault/private-key permissions and global SSH trust outside WebGUI's ownership boundary.
+
+## 2.0.0rc1 — independent core API migration
+
+Compatibility: AIM3.1.0; public core service/wire/event1.0; WebGUI HTTPv2; SQLite4.
+Release candidate: no live-controller execution qualification is implied.
+
+- Replace private rc18 imports/command hooks/Ansible strategies and sudo key export
+ with the documented aimctl protocol. Pre-started non-root add-on executor under
+ the existing authorized key-owning account; no automatic core/user/permission edits.
+- New run -> review -> one-run submission, without a saved plan. Mode/key handling
+ included in immutable review. Saving is secondary and optional.
+- Generated unique titles for blank names, transactional casefold/NFKC duplicate
+ rejection for explicit account-local titles. Never overwrite by title.
+- Preserve authentication, grants, history, audit, bulk deletion, explicit retry,
+ existing orange light/dark/mobile shell and browser-local timestamp lifecycle.
+- Schema4 retains records; old pending/queued jobs blocked, running interrupted.
+ Old plans require fresh core review. Historical duplicate titles retained.
+- Retire unsupported Custom/raw-console features rather than misrepresent native
+ inventory defaults or bypass the new API. Platform groups remain selectable;
+ core1.0 lacks subgroup paths. Structured final core result/counters are shown.
+- Major migration requires --migrate-core; stages public core checks as executor,
+ backs up/removes known legacy helpers/drop-in, uses no privileged capabilities.
+ Rollback to legacy adapter remains stopped with incompatible core.
+- Verify the release manifest before deployment; use a separate executor HOME for
+ native caches and independently verified host trust. Alternative direct-HTTP
+ browsing profile keeps execution/credentials disabled.
+- New API/migration/security/contract-review/agent guidance and regression coverage.
+
+## Historical WebGUI1.x entries (superseded architecture)
+
+# Changelog
+
+## 2.0.0rc5
+
+- Fix the rc3 runtime-directory ownership transition: systemd now owns `/run/aim-web-executor` as the executor identity with mode `0711`; authorization remains on `core.sock` as `executor:aim-web 0660`. This removes runtime `chgrp` and allows upgrades from rc2 without manual `/run` repair.
+- Preserve the release-managed executor primary group plus unit-scoped `aim-web` supplementary group, staging checks, config/state ownership, and detailed Core 3.2.1rc1 progress rendering.
+
+
+## 1.1.0rc10 - 2026-09-19 (ephemeral live job console and execution diagnostics)
+
+- Adds an authenticated same-origin Server-Sent Events job console backed by an owner-only local Unix socket. Playbook output is bounded in memory, sanitized before leaving the worker child, cleared on navigation, and never stored in SQLite, audit history, or regular files.
+- Keeps the console outside the HTMX-polled status fragment so polling cannot erase the stream. SSE responses disable proxy buffering and use keepalives for reverse-proxy compatibility without WebSockets.
+- Classifies non-zero Ansible runs into remote connection/authentication, playbook task, controller/playbook-loading, or generic execution failures using sanitized output only.
+- Retains rc9 credential/runtime fixes and AIM 3.0.0rc18 compatibility; no AIM or database-schema changes.
+
+## 1.1.0rc9 - 2026-09-19 (Ansible 2.19 runtime qualification fixes)
+
+- Treat resolved Vault/connection secret values as literal data. Standard AIM exact variable references are dereferenced once; the resulting password/passphrase is never recursively templated, so Jinja-looking password text remains unchanged.
+- Remove the empty `ANSIBLE_CALLBACKS_ENABLED` environment override. Ansible Core 2.19.11 interprets an empty callback name as an invalid plugin and aborts before playbook execution.
+- Correct the synthetic SSH-key runtime test to use owner-only `0600`, matching the secure canonical-key policy.
+- Correct `credential-check` acceptance-test guidance for a disposable test environment and `AIM_TEST_ANSIBLE_PYTHON`.
+- No AIM 3.0.0rc18, SQLite schema, permission model, HTTP API version, or credential lifetime change.
+
+## 1.1.0rc8 - 2026-09-19 (secure key handoff + history QOL)
+
+- Fix the rc7 secure-key preflight: the resolver no longer opens canonical service-owned `0600` SSH private keys as `aim-web`. It validates path/type/mode metadata only; the restricted export helper running as AIM `service_user` is the sole reader of canonical key bytes.
+- Add bulk deletion to the Jobs overview for terminal jobs and to the Saved Plans overview for the requesting account's own plans. Individual deletion and append-only deletion audit records remain.
+- Add **Retry as new job** for failed jobs. Retry is requester-only, manual, creates a new job ID, revalidates current grants/source revisions/execution policy, preserves reviewed non-secret parameters and credential mode, and requires fresh one-run credentials.
+- Preserve the secure permission model: inventory/Vault sharing through `aim-runtime`; canonical private keys owned by AIM `service_user` at `0600`; one-job WebGUI key copies only.
+- No AIM 3.0.0rc18 or SQLite schema change. HTTP API v1 gains the additive manual retry endpoint.
+
+## 1.1.0rc7 - 2026-09-19 (rc6 deployment packaging fix)
+
+- Fix deployment of the restricted SSH key-export helper after the staged source directory is atomically activated. rc6 moved the staging directory before reading `key_export.py`, then attempted to read the obsolete staging path and rolled back. rc7 reads the helper from the activated managed source tree.
+- No AIM 3.0.0rc18, SQLite schema, execution policy, credential protocol, or WebGUI API changes.
+- Retains the rc6 secure permission model, terminal-job deletion, saved-plan deletion, and browser-local timestamp behavior.
+
+
+## 1.1.0rc6 - 2026-09-18 (Ansible 2.19 Vault/runtime qualification fixes)
+
+- Use Ansible Core 2.19.11's runtime `VaultSecret` API with UTF-8 bytes for one-run Vault passwords; remove the unavailable `TextVaultSecret` test-helper import exposed by controller qualification.
+- Keep `/usr/bin` as the shipped Ansible binary directory for this controller profile and resolve `/usr/bin/python3` from the actual `ansible-playbook` runtime rather than assuming a venv.
+- Distinguish missing, unreadable and empty SSH private keys using sanitized fixed messages. Permission failures now point to the shared `aim-runtime` read/traverse policy rather than collapsing into a generic Vault/reference error.
+- Preserve the rc4 fixes for literal special characters, explicit Vault-decrypt preflight, browser-local timestamp rendering, grant de-duplication and one-run credential lifetime.
+- Document the qualified filesystem model: AIM/WebGUI runtime identities need read/traverse access to encrypted Vault and configured customer private keys; AIM source remains externally managed and unchanged.
+- No AIM, HTTP API or SQLite schema change.
+
+## 1.1.0rc3 - 2026-09-18 (grant picker de-duplication)
+
+- Scoped execution grant forms now refresh the playbook choices for the selected account/customer and omit exact grants the account already has.
+- If every catalog playbook is already granted for that scope, the form shows a disabled explanatory option instead of offering a duplicate grant.
+- Duplicate grant submissions are rejected server-side with a conflict response rather than silently succeeding.
+- Existing grants remain visible below the form for review and revocation.
+- No schema, credential, execution, API version, AIM compatibility, or deployment-policy changes.
+
+## 1.1.0rc2 - 2026-09-18 (administration layout fix)
+
+- Removed the duplicate **Scoped execution grants** panel from the User administration page subtitle area.
+- Kept a single grant-management panel below Local accounts / Add account so account administration reads top-to-bottom without repeated controls.
+- No schema, credential, execution, API, AIM compatibility, or deployment-policy changes.
+
+## 1.1.0rc2 - 2026-09-18 (credential feature candidate)
+
+**Compatibility:** AIM **3.0.0rc18 only, unchanged**; HTTP API v1 with additive
+credential routes; SQLite schema **3** (additive from 2); Python >=3.11.
+Credential execution targets **Ansible Core 2.19.11 exactly**, in the existing
+external Ansible environment. No Ansible/AIM package is installed or updated.
+
+**Candidate, not production-qualified:** the build environment could not obtain
+Ansible 2.19.11 or OpenSSH client tools. Local protocol/route/policy tests and
+fake-worker regression tests pass; real Ansible/SSH/WinRM qualification remains
+required. Read docs/VERIFICATION.md, not a test count as a certification.
+
+### Added
+- Separate disabled-by-default [credentials] enabled switch, plus existing
+ execution allowlist, HTTPS transport attestation and approval requirements.
+- Accessible Vault/Custom mode control at job review. The custom username and
+ mode become immutable reviewed metadata. Passwords are collected only after
+ approval and the single worker reserves the job; they are not plan fields.
+- Five-minute empty worker reservation and 60-second single-run hand-off/start
+ deadline. No secrets are held for approval or scheduled-job delays. A retry or
+ check-to-apply is a new credential submission. No automatic replay on restart.
+- Private Unix socket hand-off, requester/session/job checks, anonymous child
+ pipe and job-private password-source helpers. Add-on code never persists
+ infrastructure passwords to SQLite, helper text, argv or environment values.
+- Resolver in the existing Ansible Python: standard customer Vault, effective
+ host/group connection references, per-host Windows NTLM credentials, existing
+ customer SSH keys and separate Vault key passphrase. Unused legacy SSH
+ password references do not block key authentication.
+- Custom mode overrides connection identity/password for every selected host;
+ disables prior SSH control sockets and key/agent fallback. SSH uses an add-on
+ ASKPASS helper, avoiding the native 2.19 named password shared-memory helper.
+- Dedicated linear strategy adapter and final launch authorization. Endpoint
+ changes, unsupported auth transports and selected source patterns fail closed.
+- aim-web credential-check for non-secret exact-runtime/import/tool checks;
+ six opt-in synthetic actual-Ansible tests and an execution acceptance guide.
+- Mobile/desktop credential page and no-echo error handling. Existing appearance,
+ account policy, selection semantics and network/TLS profile remain unchanged.
+
+### Deliberate restrictions
+- Standard SSH and WinRM/NTLM only; no network/API plugins, become-password
+ collection, private-key uploads, saved passwords, raw output streaming or SSO.
+- Credential jobs reject delegation, asynchronous tasks, explicit strategies,
+ dynamic task imports/inventory changes, external roles and SSH argument escape
+ hatches. Some rc18 catalog plays (notably delegated Checkmk work) remain terminal
+ only. An allowlist entry is not a guarantee that a play is eligible.
+- Custom credentials may still require Vault unlock for unrelated play variables.
+- Passwords use small HTTPS POST bodies. Proxy/body buffering and trusted playbook
+ behavior remain deployment responsibilities; process separation/shared UID is
+ not a privilege sandbox, and Python does not promise physical memory erasure.
+
+### Upgrade and rollback
+- Whole-release replacement and overwritten TOML continue. Credentials and
+ execution are both disabled in shipped defaults; the backend TLS setup is not
+ touched. Existing accounts, sessions, plans and jobs survive forward migration.
+- Schema 3 adds only credential phase/deadline metadata, not a credential store.
+- Rollback to 1.0.0/schema2 requires explicit historical database restoration,
+ losing post-checkpoint changes and potentially restoring old passwords. Make
+ a current protected backup first. Do not manually repoint the active venv.
+- Global AGENTS.md and security/API/deployment/execution docs updated together.
+
+
+## 1.0.0 - 2026-09-18
+
+**Compatibility:** AIM 3.0.0rc18 only (unchanged); HTTP API v1; SQLite schema **2**;
+Python >=3.11. Independently versioned add-on, not an AIM release.
+
+### Added
+- Named-administrator setup and bootstrap retirement, with forced first-password
+ change and last-admin/session protections retained.
+- Status/config-check/doctor CLI and administrator System diagnostics.
+- Inventory search, group filters, sorting and 100-row browse pagination;
+ private selection restoration, clear/visible select-all controls.
+- Typed catalog forms and private non-secret saved plans/preset reuse, using
+ existing rc18 input parsing and target validation.
+- Searchable/paginated administrative audit history and lifecycle events.
+- Optional disabled-by-default worker using rc18's existing PlaybookManager.run
+ and its external_presentation hook; no monkey patches or duplicated AIM CLI.
+- Exact customer/playbook execution grants; catalog allowlist; independent
+ administrator approval; one active job; host/timeout/start-window policies;
+ idempotent submission; one-shot UTC schedules; cancellation and crash recovery.
+- API v1 plan, selection, diagnostics, audit and job routes. Existing preflight
+ remains validation-only. POST /api/v1/runs returns 501 while execution is off.
+- Source-revision checks at submission and dispatch; interrupted jobs are never
+ automatically replayed. Potentially sensitive raw command output is discarded.
+
+### Fixed
+- Mobile group count badges have their own constrained grid slot instead of
+ overflowing two-column group cards. Long labels wrap without moving counts out.
+- The mobile navigation rail now shows scroll instructions and arrow controls.
+- Deployment journals before replacing TOML; checks installed candidate identity;
+ manages the worker with rollback; tests schema compatibility before rollback.
+- Verified, unchanged installed browser assets can be reused without a download.
+
+### Deployment changes requiring review
+- Release-managed TOML now uses loopback 127.0.0.1:8080 and trusts 127.0.0.1,
+ matching NPM -> HTTPS controller:8443 -> local Nginx -> WebGUI. NPM/certificates
+ are externally managed and are NOT modified by the add-on deployment.
+- Additive schema 1 -> 2 migration preserves accounts. Rollback to 0.1.x needs
+ explicit --restore-auth-db and loses post-checkpoint data; read the guide.
+- Execution is off, has an empty allowlist, and has no TLS verification
+ attestation by default. A release upgrade does not silently enable jobs.
+- Raw output streaming and interactive infrastructure credentials remain deferred.
+- See docs/VERIFICATION.md for actual test results and live-controller limits.
+
+# AIM WebGUI changelog
+
+WebGUI uses its own version sequence. An add-on release does not imply any AIM
+release, source edit or upgrade. Every entry must include supported AIM versions,
+auth schema compatibility, HTTP API compatibility and upgrade/rollback notes.
+
+## 0.1.6 - 2026-09-17
+
+### Compatibility
+
+| Component | Contract |
+| --- | --- |
+| AIM base | **3.0.0rc18 only; unchanged and externally managed** |
+| Python | 3.11+ |
+| HTTP API | v1 unchanged |
+| Add-on database | Schema 1 unchanged |
+| WebGUI config | Release-managed; overwritten on install/update/rollback |
+
+### Added / changed
+
+- Made mobile/responsive behavior a global UI standard. At 760px and below the desktop sidebar becomes a compact sticky header with one non-wrapping, horizontally scrollable navigation rail, preventing staggered/wrapped top navigation.
+- Reworked the mobile account/display bar so theme controls and account actions remain level and usable, long usernames ellipsize, and the desktop layout remains unchanged.
+- Added narrow-screen rules for touch-friendly group controls, contained table scrolling, stacked forms/actions, responsive system facts, reduced card spacing, and device safe-area insets.
+- Expanded the repository-root `AGENTS.md` into the authoritative AI-agent/contributor standards document covering the immutable AIM boundary, release/state ownership, security invariants, design system, mobile requirements, selection semantics, authentication, testing, verification, and documentation upkeep.
+
+### Upgrade / rollback
+
+Whole-release replacement semantics are unchanged. The release-managed deployment profile remains the validated `https://aim.desq-gaming.de` / Nginx Proxy Manager configuration from 0.1.5. `/var/lib/aim/webgui` auth/runtime state remains preserved across normal updates. AIM rc18 is never modified.
+
+## 0.1.5 - 2026-09-17
+
+### Compatibility
+
+| Component | Contract |
+| --- | --- |
+| AIM base | **3.0.0rc18 only; unchanged and externally managed** |
+| Python | 3.11+ |
+| HTTP API | v1 unchanged |
+| Add-on database | Schema 1 unchanged |
+| WebGUI config | Release-managed; overwritten on install/update/rollback |
+
+### Added / changed
+
+- Replaced the text Light/Dark selector with compact sun/moon theme controls while retaining explicit accessible labels and pressed-state semantics.
+- Tidied inventory-group bulk-selection controls and aligned the select-all header checkbox exactly with host-row selection checkboxes. Selection behavior and explicit-host-only preflight semantics are unchanged.
+- Managed deployment now creates `/usr/local/bin/aim-web` as a guarded symlink to `/opt/aim-web/current/bin/aim-web`. It follows update/rollback automatically and refuses to overwrite unrelated files or symlinks.
+
+### Upgrade / rollback
+
+Whole-release replacement semantics are unchanged. The release-managed config still uses `https://aim.desq-gaming.de`, backend listener `0.0.0.0:8080`, and trusted Nginx Proxy Manager `192.168.20.3`. `/var/lib/aim/webgui` auth/runtime state remains preserved across normal updates. AIM rc18 is never modified.
+
+## 0.1.4 - 2026-09-17
+
+### Compatibility
+
+| Component | Contract |
+| --- | --- |
+| AIM base | **3.0.0rc18 only; unchanged and externally managed** |
+| Python | 3.11+ |
+| HTTP API | v1 unchanged |
+| Add-on database | Schema 1 unchanged |
+| WebGUI config | Release-managed; overwritten on install/update/rollback |
+
+### Added / changed
+
+- Added a global Light/Dark display selector in the WebGUI top bar. The preference is browser-local presentation state only; no authentication/session material is stored with it.
+- Added a dark palette built from charcoal/slate surfaces instead of pure black, while retaining the existing AIM orange accent and accessible focus/selection states.
+- Centralized additional surface, table, note, badge and control colors into theme tokens so pages switch consistently between light and dark modes.
+- Added a select-all checkbox to the explicit-host table header with checked/indeterminate synchronization.
+- Added inventory-group bulk selection controls with host counts. Overlapping groups and manual host selections update group controls to checked/indeterminate states.
+- Group selection remains presentation-only: preflight continues to submit explicit inventory hostnames and never Ansible patterns or group expressions. The existing server-side explicit-target validation and 500-target limit are unchanged.
+
+### Upgrade / rollback
+
+Whole-release replacement semantics are unchanged. The release-managed config still uses `https://aim.desq-gaming.de`, backend listener `0.0.0.0:8080`, and trusted Nginx Proxy Manager `192.168.20.3`. `/var/lib/aim/webgui` auth/runtime state remains preserved across normal updates. AIM rc18 is never modified.
+
+## 0.1.3 - 2026-09-17
+
+### Compatibility
+
+| Component | Contract |
+| --- | --- |
+| AIM base | **3.0.0rc18 only; unchanged and externally managed** |
+| Python | 3.11+ |
+| HTTP API | v1 unchanged |
+| Add-on database | Schema 1 unchanged; users/passwords/auth state preserved |
+| WebGUI config | Release-managed; overwritten on install/update/rollback |
+
+### Fixed / changed
+
+- Hardened browser CSRF-origin handling for reverse-proxy deployments. `Origin` remains authoritative when present and must match `public_url`; a same-origin `Referer` is accepted only when `Origin` is absent.
+- If both `Origin` and `Referer` are absent, unsafe browser requests are accepted only with `Sec-Fetch-Site: same-origin`, and the existing per-session CSRF token remains mandatory. `Origin: null`, cross-site Fetch Metadata, and mismatching Origin/Referer values remain rejected.
+- Changed the response `Referrer-Policy` from `no-referrer` to `same-origin` so browsers can provide the safe Referer fallback without leaking referrers cross-origin.
+- Improved origin-rejection messages to distinguish mismatched Origin, mismatched Referer, cross-site Fetch Metadata, and missing same-origin browser metadata.
+- Corrected the managed Nginx Proxy Manager trust address for the validated deployment profile to `192.168.20.3`; backend remains `192.168.20.46:8080` and public origin remains `https://aim.desq-gaming.de`.
+
+### Upgrade / rollback
+
+Whole-release replacement semantics from 0.1.2 are unchanged. The release-managed WebGUI configuration is overwritten with the 0.1.3 profile during update; `/var/lib/aim/webgui` authentication/runtime state is preserved. Rollback restores the prior release source/config while leaving the auth database intact unless explicitly requested. AIM rc18 is never modified.
+
+## 0.1.2 - 2026-09-17
+
+### Compatibility
+
+| Component | Contract |
+| --- | --- |
+| AIM base | **3.0.0rc18 only; unchanged and externally managed** |
+| Python | 3.11+ |
+| HTTP API | v1 unchanged |
+| Add-on database | Schema 1 unchanged; users/passwords/auth state preserved |
+| WebGUI config | **Release-managed; overwritten on install/update/rollback** |
+
+### Changed
+
+- Managed deployments now treat `/etc/ansible/scripts/config/webgui.toml` as part of the versioned WebGUI release rather than operator state. Every install/update writes the release copy, and rollback restores the copy captured with the rolled-back release.
+- Update sequencing now snapshots and validates the active release before replacing configuration, then validates the candidate with the candidate runtime. An older WebGUI runtime is never asked to parse a newer configuration schema.
+- Authentication database checkpoints now use Python SQLite's native online backup API directly, so backup and update no longer depend on the previous WebGUI runtime or its ability to parse any configuration schema.
+- Private runtime state under `/var/lib/aim/webgui`, including the SQLite authentication database and consumed bootstrap state, remains preserved across normal updates.
+- The managed release profile binds `0.0.0.0:8080`, uses `https://aim.desq-gaming.de`, and trusts forwarded headers only from Nginx Proxy Manager at `192.168.10.11`.
+
+### Upgrade semantics
+
+`deploy/deploy.py update` performs whole-release replacement without Git. Source, venv, systemd unit and WebGUI configuration advance together. The SQLite authentication database is retained. On deployment failure, the previous source, venv, unit and configuration are restored automatically; AIM rc18 is never modified.
+
+## 0.1.1 - 2026-09-17
+
+### Compatibility
+
+| Component | Contract in this release |
+| --- | --- |
+| AIM baseline | Original `AIM-Ansible-3.0.0rc18.zip` only; unchanged |
+| Upgrade from WebGUI | `0.1.0` supported by managed replacement update |
+| Add-on database | Schema 1 unchanged; users/passwords/sessions preserved |
+| HTTP interface | API v1 unchanged |
+| Reverse proxy | Explicit trusted forwarded-header support added |
+
+### Fixed / changed
+
+- Added explicit non-loopback listener support for reverse-proxy deployments. The safe default remains `127.0.0.1`.
+- Added `proxy_headers` and `forwarded_allow_ips`; Uvicorn now trusts forwarded headers only when configured.
+- Added sectioned TOML configuration while retaining full read compatibility with the 0.1.0 flat configuration.
+- Non-loopback listeners require an HTTPS `public_url`, proxy-header processing, and at least one explicitly trusted proxy address/network.
+- Managed readiness checks now work with wildcard listeners (`0.0.0.0` / `::`) by probing loopback while sending the configured public Host header.
+- Documented Nginx Proxy Manager deployment validated with `aim.desq-gaming.de`, backend `192.168.20.46:8080`; site-specific values are examples, not package defaults.
+- Clarified that the managed systemd unit supplies AIM rc18's required group as a supplementary process group; manual tests launched with `sudo -u` may not inherit that group.
+
+### Upgrade / rollback
+
+Use `deploy/deploy.py update` from a freshly unpacked 0.1.1 release. The active add-on source and isolated venv are replaced as a unit; operator configuration and `/var/lib/aim/webgui/webgui.sqlite3` are retained. 0.1.0 flat TOML remains valid after upgrade. Rollback to the managed 0.1.0 snapshot remains supported without restoring the auth database unless explicitly requested. AIM rc18 is never modified.
+
+## 0.1.0 - 2026-09-17
+
+### Compatibility
+
+| Component | Contract in this release |
+| --- | --- |
+| AIM baseline | Original `AIM-Ansible-3.0.0rc18.zip` only |
+| AIM product version | `3.0.0rc18`, unchanged |
+| AIM rc19 / later | Not claimed; startup refuses unapproved versions |
+| Python | Requires 3.11+; executable tests run on CPython 3.13.5 |
+| Managed deployment | Linux with systemd; separate unprivileged service account |
+| Add-on database | Schema 1, SQLite rollback-journal mode |
+| HTTP interface | `/api/v1`, cookie authentication and CSRF for unsafe methods |
+| Frontend | Jinja2, HTMX 2.0.10, Bootstrap 5.3.8; no EJS/Node |
+| Core change requirement | None; no service/API facade added to AIM |
+
+### Added
+
+Independent `aim-webgui` Python distribution and `aim-web` executable. FastAPI /
+Uvicorn server, Jinja2 pages/partials, local static asset preparation and
+centralized orange theme with explicit Bootstrap component overrides.
+
+Read-only rc18 adapter for Config, group membership, CustomerManager,
+InventoryDocument/InventoryEditor, catalog parsing, InputSpec.parse/validate
+and PlaybookManager.validate_overrides. Target/platform filtering mirrors the
+rc18 target screen without importing its terminal UI. Inputs inherit role
+defaults unless explicitly supplied; the existing cleanup-off safety default
+is retained. Customer path traversal and escaping symlinks are rejected.
+
+SQLite bootstrap admin with random password written to private `.credentials`,
+mandatory first password change, Argon2id hashes, server-side hashed session IDs,
+CSRF/origin checks, idle/absolute expiry, request limits, login throttling,
+local account administration, last-admin protection and terminal recovery.
+
+Add-on-only, non-git staged replacement deployment. It prepares dependencies
+before stopping WebGUI, stores SQLite backups using its backup API, retains
+operator config/auth state, replaces old source rather than merging, and
+switches between permanent isolated venv paths. Readiness failures attempt
+rollback; interrupted operations leave a root-owned recovery journal.
+Code-only rollback preserves accounts by default. Database restoration requires
+an explicit destructive flag, and session tokens are always revoked on rollback.
+
+### Deliberately not included
+
+Browser-triggered playbook execution, infrastructure writes, Vault/SSH/WinRM
+credential collection, job workers, customer-scoped roles, MFA, SSO, API bearer
+tokens, generic plugin discovery, or any change to the base AIM installation.
+The `POST /api/v1/runs` compatibility boundary explicitly returns 501 after
+normal authentication/CSRF checks. It never starts a job.
+
+### Upgrade and rollback compatibility
+
+This is the first independent Python add-on release. It is NOT an upgrade path
+for the historical Node/EJS rc19 WebGUI or its API daemon. Leave those separate
+artifacts unused when deploying against rc18.
+
+Future updates must use a new add-on version and an explicitly recorded AIM
+compatibility contract. The installer refuses same-version replacement and
+downgrades through `update`. Use `rollback` for an installed prior snapshot.
+Migrations only advance schema versions; startup refuses a newer database.
+Do not restore an old database merely to roll back compatible code: that would
+also revert password, user and audit changes after the snapshot.
+
+### Verification limits
+
+See `docs/VERIFICATION.md` for actual results. No managed-host Ansible operations
+were run. Linux/systemd activation and an online dependency installation were
+not exercised against the operator's controller. The build container could not
+download browser assets or dependency wheels; deployment has a required pinned,
+integrity-checked online/offline preparation step. No claim of a penetration
+test or a current complete dependency vulnerability audit is made.
+
+## 1.1.0rc7
+- Preserve canonical AIM SSH private keys as service-owned `0600`; WebGUI uses a restricted service-user export bridge and job-private `0600` copies only.
+- Add deletion of terminal job history while retaining append-only audit deletion records. Saved-plan deletion remains supported.
+- Re-run browser-local timestamp formatting after HTMX settle and browser page-cache restores.
+- Document the `aim-runtime` shared-read model: inventories/Vaults `root:aim-runtime`, private keys service-owned `0600`.
diff --git a/scripts/addons/webgui/MANIFEST.sha256 b/scripts/addons/webgui/MANIFEST.sha256
new file mode 100644
index 0000000..ccad07c
--- /dev/null
+++ b/scripts/addons/webgui/MANIFEST.sha256
@@ -0,0 +1,160 @@
+1f1384af56beddd47178a8c60a2237d9f507097aa05c8cd670bae4d741c2d357 ADDON-INSTALLATION.md
+dc99c09506dc06a25ee247d09911149acbd38369de27d4ae818a17d7bafc09fb AGENTS.md
+c51cfc40c62dd261c5dbab1c9f320a408103433a59530d261b6ba5473dfed740 CHANGELOG.md
+d1997dbfb80c93ee9c407f134dee90d22a52217f62a54bf99a618043cb560ff1 README.md
+ec7dd6d823636327eea262d158f9357bf2c620df8cee377814389a8a8a9688de constraints.txt
+37142da0781c809f7c10ece7da94a1eea3cc01c88ec1468b440838bbfd7ee299 deploy/deploy.py
+0fea12d2a4a707a64770a5d29a0a4647c0ec7195ecb07ec121b0a3f54e61c36e deploy/fetch_assets.py
+dae90333281d07cf827585775516dd7091b62fcf0443b3685080739749f86d5c deploy/nginx.example.conf
+df104ed57e4e0d12c5a280cf8b521680747ec762fce4f7972c9099d4d3b0042d deploy/profiles/direct-npm.toml
+506e69c23372af047185de91f1a449bf0ac602192a1cd727eac7cf5c46f92cd2 deploy/webgui.example.toml
+80415ff7c4d6a56692405eb7f598592dd934753debd9ba0b6c25587bd17ca461 docs/API.md
+2673309dbf6a037cd9a35da7fb913bb6dc8d32486a52897378941e60a5cc4724 docs/ARCHITECTURE.md
+917e8808e7e8c661ece0e267c604a776bc73b90c0bd2fe38979ee9928dc27f7c docs/CONTROLLER-PILOT.md
+c3705f7efbbfa4cbe87d962b166522abb52cc0674cdade0014e1e65299828e60 docs/CORE-3.1-REVIEW.md
+d888ee7d061301b9648b50d1736ecac145735c20895b58f4e33477ac9ad443de docs/CORE-3.2.1RC1-REVIEW.md
+a81d360c0e3cb43c7fca22ebc377d69794a64daf171603dd4d2189e540819739 docs/CORE-3.2.1RC2-REVIEW.md
+8ccc3df07cc54d10048676692e522822719fdfaa44d26f6eb87be4f736ea09e8 docs/CORE-3.3-REVIEW.md
+dd4ef39a61f99eda3ff02d339210572ab0332cf30b0c7321b895a1e8f602b291 docs/CORE-3.3.0RC3-REVIEW.md
+6774b0924ce5a8293c0c6b7c1bf146cd3949be5da5f70c5e095e98993217b499 docs/CORE-3.3.0RC8-REVIEW.md
+969f07f03a6423894c678dbb63e1f5651f16f24f8d1bd3b754612ab30a4d09aa docs/CREDENTIALS.md
+f1df31bc38df55641d109be5f59086472b179e6f4643f88c52e6f1742e99adc8 docs/DEPLOYMENT.md
+cb9f965d7070d0bb0a3fe7bf76b7d6bec2ddac758d7d5b559d4150d5a41b875b docs/EXECUTION.md
+d2d8cb462e3589d05f3f335936aefccb5c8e3b5254e7a6157324fef81802e967 docs/JOURNAL.md
+a0b450bb134e0e9c729ebb3beeff426565b0ccb2c9c5c43b129377f757f93bd7 docs/MIGRATION-3.1.md
+408d97151591f55216b83c4e6994f1fb0f42eb04c35e5c57f81b8db8ca648226 docs/PATCH-WAVES.md
+82976cd54ac75b37c1061070fcd88d35a115b55ad53bade6682e3dae8596412d docs/PERMISSIONS-ROLLOUT.md
+2b31c7fb4d52361be9e2eadb4e445f466ed4c6bbd82ee7dec4a788c94b1a2100 docs/PRODUCT-COMPARISON.md
+f8462d2dd45f035d1c15ef5561f11de4b9ee64b0c5b5abdf7a1c7964dc2da022 docs/READ-ONLY-EXPERIENCE.md
+3955e965c2ad13f081f38fcefa64927e7f28c77cd96a069308beec860d43a591 docs/REPORTS.md
+48c3d5fa43902fd62472eaa59cb36a0291e34c7dd77a7e160fd8e78c4e60eff9 docs/ROADMAP.md
+76fc73c2cf6cf8b8ffab2b20fb4edc8033f8fefe25d654e2e3e8cacb6059a9de docs/RUN-COMFORT.md
+eb8982fa542381d877b723736423da1f8f79d1730e50a76870ae85e5e23316fe docs/SECURITY.md
+eccda2e276cc59c58e52e9dd79987253fd92e1eabf4ade1387b097480a140d89 docs/VERIFICATION.md
+bb981b7b25ac6596d0b4e39c4377563afdab1ba309411568f0028123b0cd1865 docs/verification-results.json
+076e15dec2cccb30d41db0caf8c119723989a3db19a8e7c747c7865ec8e4d39f pyproject.toml
+f8f4b4ca210adbceb2d9c0caad5de35dfa32c8b729c21925fd87ce3330978e2d requirements-test.txt
+c8883045777b542ac62894e833dc22b332528bbe13ddec90e3a28be82aadc2c9 src/aim_webgui/__init__.py
+7579c8c24cf1822096cdd914b5a60057e94e0e7c21d8e46d50ac2c3416c62ff8 src/aim_webgui/__main__.py
+92c961cf11f9c5e907e00f8ccfc53baac2b2b4901d9518bc9987d4b597b7464e src/aim_webgui/activity.py
+e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 src/aim_webgui/adapters/__init__.py
+ae71a963c320b131b85b45ec65525a6cc207bd32b8f0925ef579eb029a353e7f src/aim_webgui/adapters/core_v1.py
+11d793eb6cf2f4c4862a82e467e5230bf7a391520ede165b151a175a69cce6ef src/aim_webgui/app.py
+d5f74e126b9e969d93e5156b34d047b3d97471d58de3f0ad14b0948652912acc src/aim_webgui/assets.py
+e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 src/aim_webgui/auth/__init__.py
+8178c02694ba9e52529bf6863281a170d394ebc010962b4f3c86f84cc16494d4 src/aim_webgui/auth/service.py
+2aee1ed63c9e6a63ddd685b49763488eb495f44c24647c13d6d72d493fcef95a src/aim_webgui/cli.py
+7f7d74e6f4bc16be096cdae7b4abdfda8feb830d6b15ad94b6150436531b0d9d src/aim_webgui/config.py
+a4821e251f2fa79fd5998bcbc4c00871ba23f64d39c014a67c9931b7a02d7ffa src/aim_webgui/console.py
+ef57a644af7e925375105a9139f0bc4d083be3a18aad21311479e844519c4817 src/aim_webgui/core/__init__.py
+4b40a2f61190f50ddd77cdfea88a61f99a3c5e3288be921a4e2911b8960d4547 src/aim_webgui/core/client.py
+41589bd35f6bd15db01d36f3888f378e913a7e11e633f2adb76d939cf95c9f4b src/aim_webgui/core/executor.py
+50c900f3df331fb42d7c55b82e4f76103128b5cb98034765b44e1b5b8bec812c src/aim_webgui/core/jsonio.py
+293206bcb4bea0a6c28662f288762a70ee2428059f5b0536cc4d89e11ade0f0c src/aim_webgui/core/limits.py
+55fa4979bf80dcf786129208cbd47e891d7d5dff0845884f1975ffe163ff192e src/aim_webgui/core/process.py
+eb5d9868d930de6c9b2c72c97fdde759c97163a527fb840a54e8f1e01286d3c0 src/aim_webgui/core/protocol.py
+9c0d2343103c62060b901616490b349d27cf82f92700426db9a0de03d241bda2 src/aim_webgui/core/reports.py
+5478f1c990d81f677a3067b6cc9150f8384929b4288d6206970a4b4f459ec95e src/aim_webgui/credentials/__init__.py
+80fa44f632827f756f07dce4ea074b7c43f2414902fcbc3aeb1d58c4c306057b src/aim_webgui/credentials/presentation.py
+c16ab7e2c0a48e2ac63b53af4e72b137b5f3cb3141972f723da72289ab8169b6 src/aim_webgui/credentials/service.py
+541f6300ebe90b74f9f8cd99ccfd2346f1182b67a611436cef59326162390dcb src/aim_webgui/credentials/wire.py
+e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 src/aim_webgui/db/__init__.py
+31d4dbe46e301e745cf1f9965cfa31761a75978468c2cc0a9ab47eb9f7649e2a src/aim_webgui/db/migrations/0001_initial.sql
+712492fdcb23d8837c7c5c78de2124f1e3e5dc6956b788b2ae93465df7c2d9f8 src/aim_webgui/db/migrations/0002_workflows.sql
+6a6512b135382c23301b64c7ba88332d20c929fab3dc88ed1a5ab3178bf34985 src/aim_webgui/db/migrations/0003_credentials.sql
+2130f09e21d26a9ebfaddc321cdeb5c8952a3b31d8160ea47dea575ee2aea2ec src/aim_webgui/db/migrations/0004_core_v1.sql
+18b507b2dcf38a682f24db8771d60dd5fa039a5a110eba2447fec44adcc250f9 src/aim_webgui/db/migrations/0005_job_evidence.sql
+6fb41e826fc86396c54f9b665355eb5612ca58f34b9e4e310687ee3b95851f0a src/aim_webgui/db/store.py
+56463b5c5aefa0df0751df2ddbeaed29f1f211170dcc7e1d34c0ae593d63087e src/aim_webgui/diagnostics.py
+2d0fa9753846cadc0eb131a774e5355bb25050fb46ae1bc9a0f9dcca3d8e7ce5 src/aim_webgui/errors.py
+66dcd5b4d83e4c77eb21f651dbf2494ede6ab95c1711887ae905287853217ef9 src/aim_webgui/evidence_views.py
+b691092718c1ded269ef8c8fe7e5c30b30f1bbaab5796ac052ba8ec93890260c src/aim_webgui/explorer.py
+6b3302db5b97761d48aee621900a1f35bca2a32050479361c8e6e23cc9ebf04e src/aim_webgui/journal.py
+ce868ddf856ff4c36565cad2734f5e4d16182db822bc06ac802c3f4e95dac32d src/aim_webgui/names.py
+d982dc030bbaf30785aa337432662471c5970f680d0c64634096d6d5c3aea86d src/aim_webgui/patch_view.py
+d152c65c95a855e2f553923a0e620c106e9451d181b7ae087757481478425a18 src/aim_webgui/read_views.py
+5db233b25ce1316adb12dc95fcd16e08fb3b9d991e3c48d4232712ff13814c3b src/aim_webgui/reports.py
+e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 src/aim_webgui/routes/__init__.py
+8c53fbe43006ada151f4abe6a15c50638fdc6c2b4590ce88129d02ed4e728fca src/aim_webgui/routes/workflows.py
+0ab884dcef0d56fd7707ff0ba1f7c7b60e485d9923a9db2cccaeb7d7d9555698 src/aim_webgui/security.py
+a562f9af41fccf20b7d344a8015cffab03b24cc043d146746c16c958e0081640 src/aim_webgui/static/css/aim.css
+bd42da3a3466e2cf14f99046c0a3fec29c9498bb3ada1aaed2f32b5280b769c7 src/aim_webgui/static/css/bootstrap-overrides.css
+96e738bd85dc3105c0f5fd463f5ca72424347bb06ab9f5fbc94d4fede05df83b src/aim_webgui/static/css/credentials.css
+a976ecb03caf249f6cfc382863d370b1e94714a653bebdaba332b7503474ea9e src/aim_webgui/static/css/evidence.css
+220b3ffb332bb98a87ac1d187ae13b3e324d876d8531a8001ec3c32fb5e0a964 src/aim_webgui/static/css/experience.css
+f7d2db283169d8a04f73f256c7df9b3c513cab36b635032e96ff7a1853c12c3f src/aim_webgui/static/css/tokens.css
+78fc1d9449080b97716284ce7c261a0ea3fbf5c76d6ccfec16eba0f8f2f27b95 src/aim_webgui/static/js/aim.js
+00a4f188d4bc1131c7e88cca63a5ce5f9cbc15f4c393aa4fcfc054585735bb3b src/aim_webgui/static/js/credentials.js
+2b45ee9845893852503b031e343046c8b72eecddb3824caca1d09a1d375adf50 src/aim_webgui/static/js/evidence.js
+8ce9d1eafe0e96c8d65f045f0256542e88821521bb8fe4db58e97fedd1385d19 src/aim_webgui/static/js/experience.js
+1b4b4a0d37f3a9d4de5a149cf3d79d573d2dfdbf6abbf317929ed35910edec11 src/aim_webgui/static/js/theme.js
+f9b169a6ac2d46579997299da9a56488a0240db9e4880da653777ccb4c2a03de src/aim_webgui/static/vendor/THIRD-PARTY.txt
+3208cbbb47ea11d71bd1800d550dd6042e27e7bb26c433c568ecb397c7a9c534 src/aim_webgui/templates/layouts/base.html
+16112906e032601178e3c9828d678859d8eadc0a8c652f137bc0bd934f288787 src/aim_webgui/templates/pages/activity.html
+e6ec444c021b7b042bffae5267950ac56dbaa82833b85d1ee6c3c90c3e58f949 src/aim_webgui/templates/pages/audit.html
+b498800995024eba45adbdc82c5307b38e1f6b32054d253d372acaf711d5731b src/aim_webgui/templates/pages/credentials.html
+4f10734406e80f907d662ba7d43cf0faff232013773cb4c7cf72a113ab1a4b6c src/aim_webgui/templates/pages/customers.html
+28a413663f40f4862b1d5ebe8cc59f5bd6ffc262b5773cc73a49861360cd82da src/aim_webgui/templates/pages/error.html
+42a58b0e19ae4de3a40d90fcbf9e558a9dea9b45a72ae35f60a58f56a03e20f8 src/aim_webgui/templates/pages/explorer.html
+a819d71da97d0739f21578a961dc3ed5eb5ed1067946e263867307b85944a620 src/aim_webgui/templates/pages/hosts.html
+eafde0574c181c1e176bdd63a5a09b37f8c7aae32e9ad69dadb16ba9074a7a60 src/aim_webgui/templates/pages/insights.html
+9322f24f8476864838705775fcf50555cd00922ebef203c58e8bc8e09ea8821f src/aim_webgui/templates/pages/job.html
+d1258ec71642ab5e8b93d101ca2acc9a119380944f2b533d39f03a1b52223ab2 src/aim_webgui/templates/pages/jobs.html
+2592a7aaabd1b9ed8d55645fe93900b33bad307902c049572acb52f19efdc9a4 src/aim_webgui/templates/pages/login.html
+b642639de0e76bc9070fe8f2d176241787d993a153153ca72c1142b534002e16 src/aim_webgui/templates/pages/overview.html
+f2b7067d2f24e48e6ed40590ba968283c1fe7b54fed11d903d3cd9147b30339e src/aim_webgui/templates/pages/password.html
+268ab10c351a1e2bbd5df26ad0c134455a7f2e7122173b59853f1dae484d3f5c src/aim_webgui/templates/pages/plan.html
+6fb355b248007b755abb827a923aac8a6b4da5df4ec02606daa19b6a361147b3 src/aim_webgui/templates/pages/plan_detail.html
+4ccfe0e41a0f9fec427880f49add50e835b3ca78747bffbc2bfc862c7454e8a8 src/aim_webgui/templates/pages/plans.html
+ffe2f058a075bc69ddcc520874928e98c6840079deca7e54f33840be56777a86 src/aim_webgui/templates/pages/playbooks.html
+4e25189e533f9f0a9a2137f4faa8a436fd2a9da3e706790a85c996909e4ac6b3 src/aim_webgui/templates/pages/preflight.html
+e3cf11300103b24baee21156423f410c62d1901b7ee9c21d89662647d5c2686a src/aim_webgui/templates/pages/progress.html
+86251827563519b1ab7fed7c57c28db891ccbc319753f29905b9187b20760ce3 src/aim_webgui/templates/pages/report.html
+f02c5011a8022943b770fe67f6c862f9ce2801fd2b88a08079ff4a61b63b99cd src/aim_webgui/templates/pages/setup.html
+4bb8da42b5b57bce49131be876ecdeadd292a8440f89bde384a3275fac78afc7 src/aim_webgui/templates/pages/system.html
+e61b65c17fa24dbe99df9088b6e6e78915b8ca06fe29afabf84fa06a3f538bd1 src/aim_webgui/templates/pages/users.html
+370c78ed200254418afa3faf6496591150fdcaf6d7b97af954bb83cbdf6a7e38 src/aim_webgui/templates/partials/activity_macros.html
+218ce522d1f38d11e005b518a44a6d6d511745836a6a7495103ed33c8359bc38 src/aim_webgui/templates/partials/attention.html
+47e9b1cc70960f0c0f2d37bc90a60f8ffda278d2a8cf9f4ea662670991438939 src/aim_webgui/templates/partials/credential_dialog.html
+633092741d3a0d76582a4a608228360bbae4f6ec2cf47c23f5bee873b4d13bfb src/aim_webgui/templates/partials/credential_panel.html
+804af94cf92c3e83577ff508872da5a015b7963da8fca35be79b8cb9ce22838e src/aim_webgui/templates/partials/customers.html
+098a41c6cd6137d19af6f416c03ade15b4ac890b99fda1079cf7a297d89fe393 src/aim_webgui/templates/partials/error.html
+ee1f145576c0ed017e501e85053aee9ff28bb2b16b09feefc75875d211fd5d46 src/aim_webgui/templates/partials/grant_fields.html
+1baa4777f7fd31f56e3bed2b77dfae16f9fc2117c28d746d9aa7596035f90e6a src/aim_webgui/templates/partials/hosts.html
+7281ecceb20c7f74d9f025a2f37dcacdffd4592d766015f307e15576d85f6455 src/aim_webgui/templates/partials/job.html
+59c4cd9e7192ca65038f140b414c885b1c0bf1fe99d3c7e26fd720d6c87bc633 src/aim_webgui/templates/partials/navigation.html
+5a7ccab88b0314ba6df9ebf2b9b731d93fc447bc5b69bdb391b754c8828b09db src/aim_webgui/templates/partials/patch_report.html
+5d09d14049e13fffafbb0d5a788bd185b1ddec7fa2370708cdbfe9d9944309ab src/aim_webgui/templates/partials/preflight.html
+1460829bf004a878742edc683917054e252c19a96412197239f4418ae1726560 src/aim_webgui/templates/partials/progress.html
+0554bcf57db81e330cafd3df916975f7b4acc0313e9cbfdb9ab37b8fa8682339 src/aim_webgui/templates/partials/reports.html
+61e594655689bda7f7483b52c381115a9392bff4378e5629937e702d270eb363 src/aim_webgui/worker.py
+1a6ad0a12a43f9e25977552f5af4535f3fd447819e864b7e5fff4b2302cf68dd src/aim_webgui/workflows.py
+5ed4630e94de88f231e7d01527efb6e8ab0ef2cb0ebd86c51d753a396a7c8886 tests/benchmark_journal.py
+922138d85c03b14ad2df4861bbc28a9192647904a1e9e0d77ef1ea2a2a552061 tests/benchmark_read_history.py
+4880f3fa6f7a5645a5d5e77532c1c6c118c2a781c7ad935654b46c2c2945de00 tests/browser_credentials_qa.py
+fb3e951f74dfdb923356a35d11e25033ef555ec579d09490c545b4aacfd2b20e tests/browser_evidence_qa.py
+979b612a5898c9519399f5aa4037b3bdf8265ae1721d9c2942105b0361d08a7a tests/browser_patch_qa.py
+3258a1f7ea5997bc83f549060a9421c1c267de7a2a635b5640936427bfd1e3f5 tests/browser_qa.py
+1a7a29381207f04e06c49502165c3f4cbf25f8db3bdd6d71ffa0d9b9a7c2cc8d tests/conftest.py
+cc3e91a6a0c76c7cb37f843cbbff69c9401281287767fca30a07f6931b24f847 tests/evidence_fixtures.py
+b8a3cf8c9fd5987a3103177c5ab035faf0a1835a21a48ced2f0a1747188ac908 tests/fixtures/patch-summary-rc1.json
+e92ab1266cd139b77065ff4e5fa65c6489a1705e5dda3b0a33cd6e3d3e4d8fb1 tests/patch_fixtures.py
+bc55efde812758dedfff615d249aec28feebfe0ec8c03e7c51c11f5f2b37dc6e tests/run_release_tests.py
+776a7cfdd21704e44070f1e608b64345fdd0b620b356f7c9af6638de5bdab1bd tests/test_auth.py
+03e5ffbc9b3bdc6627c8a755a481311c071b8ac6773b39c0e355e0804cac77a7 tests/test_core_contract.py
+7fbb53e5a1382d648b0368c6a06c31a8444c780a1c545f9ddc446b59c2986812 tests/test_core_execution.py
+67bfc54b30fb1252358f06c551a877a89b1eca8f9cddeeeb3fba21459f62ef61 tests/test_core_rc8_patch.py
+2fa80cc2eb4b26b882634da157fb4cc293da4e12288a8fd8c3077ed1e60d096d tests/test_core_reports.py
+5f88ab6355a31b79ea29f9536316e215d187c0b3059175747f203bc65e60212c tests/test_credential_ux.py
+0df192ca4e46f9f2956012e7ca733d7cfcacc73536ac89bf73e9824c5f7cd888 tests/test_deployment_v2.py
+2e1ddfe17869b6f0ef60527a8e10c129658b310b2bada8c857f643d336d4afa7 tests/test_executor_unix.py
+4d8888c7588523ba9f1feb01139aa18187a89d349a7ce6017d9b87b1b6eb0e12 tests/test_journal_v5.py
+aad1fa10665dacf53ec405586ccc60a112b9005d40bebf7a48691b19052a2af4 tests/test_migration_v4.py
+d6756e12b74e6ca8134c4ac949fef93d0f25dd0fd9bedb287a73ed5294b93b54 tests/test_migration_v5.py
+30539c491ed9d8e6edc2583d2a43de5ab17ca3fd76082b5c1e43259d965a1f7f tests/test_read_experience.py
+01bf651842bd35fc1d40e7f8e5ec8fd1c5a95ab4243974d7228df5ceee0213db tests/test_report_transport.py
+2be7f712af36b182db40a9418a6ab238d269ea73278f5e55a8a23ff1eca93c0f tests/test_reports_v33.py
+88c67c59202b9f1cbb2f74bf5b96bb40e3c24798a9df288c16ac8e98a6f5271a tests/test_routes_v2.py
+5f3595836e79758fe7c451cb9a851cd00485fc6c91c292acb7ea8903b578070f tests/test_transport.py
+12adb2e7af91a9de4f6b782273ae91e626fd5ae896fcb4b0f85c7c89f89ee5ab tests/test_worker_v2.py
+5c8ecd8a5440b1c0ae36870c20e4610b030fe8eae07808f7c63e73a844c28673 tests/test_workflows_v2.py
diff --git a/scripts/addons/webgui/README.md b/scripts/addons/webgui/README.md
new file mode 100644
index 0000000..d674f49
--- /dev/null
+++ b/scripts/addons/webgui/README.md
@@ -0,0 +1,84 @@
+# AIM WebGUI 2.1.0rc9
+
+Independent add-on for **AIM Core 3.3.0rc8**, service/wire/event API **1.0**.
+WebGUI HTTP API **v2**; SQLite **schema 5**. This remains a release candidate,
+not a native Windows Update/controller acceptance certificate.
+
+## This update
+
+Core 3.3.0rc8 keeps service/wire/event API 1.0 and the nine structured report
+contracts, while tightening the native Windows baseline and several runbook internals.
+WebGUI now qualifies exactly against rc8 and requires the live capability metadata to
+advertise `ansible.windows >=3.8.0,<4.0.0`. Collection installation remains Core/operator
+owned; the add-on does not upgrade it.
+
+The patch report accepts rc8's additive `reboot_reasons_before` facts from native
+`ansible.windows.win_reboot_info`, renders them as dated observations, and keeps them
+separate from the existing reboot-required/blocked/continuation semantics. The overall
+patch policy remains unchanged: no automatic retry, no automatic follow-up job, and no
+silent post-reboot continuation.
+
+Windows filesystem reporting is relabeled to match rc8: it represents attached local
+storage volumes. Mapped/network drives are intentionally outside that host-capacity
+report. Existing retained reports continue to render against their recorded contracts.
+
+Core also relocates several AIM-managed Checkmk scripts and hardens their Windows ACLs.
+Those are Core/runbook changes, not WebGUI API changes; WebGUI continues to display the
+validated structured reports rather than reconstructing filesystem paths or ACL state.
+
+## Preserved functionality
+
+The credential modal, grouped passphrase controls, compact mobile hamburger/theme
+header, inventory map, Host Activity, Playbook Insights, one-run review, optional
+collision-safe plan names and per-target/partial-success views remain.
+
+The worker-side structured journal survives page reloads and other-device viewing:
+tail of20,000 events or8MiB by default. Final reports for nine schemas are retained
+separately with a16MiB per-job budget; full parsed Checkmk configuration stays opt-in.
+No raw output archive, credential cache, terminal-history collector, inventory/Vault
+manager or alternative execution engine is added. Job deletion removes linked evidence.
+
+## Install or upgrade
+
+Fresh installations: follow [ADDON-INSTALLATION.md](ADDON-INSTALLATION.md) after Core's
+separate `scripts/docs/INSTALLATION.md`.
+
+Upgrades: read [Deployment](docs/DEPLOYMENT.md). Quiesce work, independently deploy
+Core3.3.0rc8 with its own preview/apply procedure, then install from a fresh add-on
+extraction. The old add-on's exact Core gate must not be bypassed.
+
+```bash
+cd /var/tmp
+sha256sum -c AIM-WebGUI-2.1.0rc9.zip.sha256
+unzip AIM-WebGUI-2.1.0rc9.zip
+cd aim-web-2.1.0rc9
+sudo python3 deploy/deploy.py update
+```
+
+No --migrate-core flag is needed from2.x. From2.1.0rc3, no database schema change is
+needed. Accounts/plans/jobs/journals/reports/audit are retained. The approved full
+webgui.toml is still release-managed and overwritten. Existing unit/permission/staging
+contracts, dependency pins and browser pins are unchanged. No new ports or services.
+Unknown local unit overrides still require operator review.
+
+```bash
+aim-web --version
+aim-web config-check
+sudo systemctl status aim-web-executor.service aim-web.service aim-web-worker.service --no-pager
+sudo -u aim-web aim-web core-check
+```
+
+## Guides
+
+- [Patch-wave inputs, reports and semantic boundaries](docs/PATCH-WAVES.md)
+- [Reviewed Core rc8 delta](docs/CORE-3.3.0RC8-REVIEW.md)
+- [Operation reports](docs/REPORTS.md) and [retained progress](docs/JOURNAL.md)
+- [Credential modal](docs/RUN-COMFORT.md) and [read-only history](docs/READ-ONLY-EXPERIENCE.md)
+- [Controller acceptance](docs/CONTROLLER-PILOT.md)
+- [Current verification and limits](docs/VERIFICATION.md)
+- [Public API](docs/API.md), [security](docs/SECURITY.md), [agent standards](AGENTS.md)
+
+The release is not a complete offline dependency bundle. Bootstrap5.3.8 / HTMX2.0.10
+remain locally served and integrity-checked by the installer. Tests are separate from
+managed production environments; fixture browser assets and native-command simulators
+are never release acceptance of actual target updates or the production service sandbox.
diff --git a/scripts/addons/webgui/constraints.txt b/scripts/addons/webgui/constraints.txt
new file mode 100644
index 0000000..e8e0e21
--- /dev/null
+++ b/scripts/addons/webgui/constraints.txt
@@ -0,0 +1,25 @@
+# Deployment constraints: tested resolved runtime versions, independent of AIM.
+# No claim of being the newest releases. Review advisory updates before promotion.
+fastapi==0.128.2
+starlette==0.50.0
+uvicorn==0.48.0
+Jinja2==3.1.6
+argon2-cffi==25.1.0
+argon2-cffi-bindings==25.1.0
+ruamel.yaml==0.18.17
+ruamel.yaml.clib==0.2.15
+pydantic==2.13.4
+pydantic-core==2.46.4
+annotated-types==0.7.0
+annotated-doc==0.0.4
+typing-extensions==4.16.0
+typing-inspection==0.4.2
+anyio==4.13.0
+idna==3.17
+click==8.1.8
+h11==0.16.0
+MarkupSafe==3.0.3
+cffi==2.0.0
+pycparser==3.0
+setuptools==82.0.1
+packaging==25.0
diff --git a/scripts/addons/webgui/deploy/deploy.py b/scripts/addons/webgui/deploy/deploy.py
new file mode 100644
index 0000000..22fb93e
--- /dev/null
+++ b/scripts/addons/webgui/deploy/deploy.py
@@ -0,0 +1,890 @@
+#!/usr/bin/env python3
+"""Root-only, staged replacement of the WebGUI add-on. Never runs pip for AIM.
+
+Run from a freshly unpacked release directory, not the active add-on directory.
+Linux + systemd + Python >=3.11. See docs/DEPLOYMENT.md before use.
+"""
+from __future__ import annotations
+
+import argparse
+from dataclasses import dataclass
+from datetime import datetime, timezone
+import fcntl
+import grp
+import hashlib
+import json
+import os
+from pathlib import Path
+import pwd
+import re
+import shutil
+import sqlite3
+import subprocess
+import sys
+import tempfile
+import time
+import tomllib
+from urllib.request import Request, ProxyHandler, build_opener
+from urllib.parse import urlsplit
+
+from fetch_assets import prepare
+
+SERVICE = 'aim-web.service'
+PREFIX = Path('/opt/aim-web')
+STATE = Path('/var/lib/aim/webgui')
+BACKUPS = Path('/var/backups/aim-web')
+UNIT = Path('/etc/systemd/system') / SERVICE
+META = PREFIX / 'deployment.json'
+PENDING = PREFIX / 'pending.json'
+CLI_LINK = Path('/usr/local/bin/aim-web')
+EXECUTOR_UNIT = UNIT.with_name('aim-web-executor.service')
+EXECUTOR_STATE = Path('/var/lib/aim-web-executor')
+LEGACY_FILES = (
+ Path('/usr/local/libexec/aim-web-key-export'),
+ Path('/etc/sudoers.d/aim-web-key-export'),
+ Path('/etc/systemd/system/aim-web-worker.service.d/10-key-export-capabilities.conf'),
+)
+
+
+def executor_service(action):
+ if EXECUTOR_UNIT.is_file():
+ run(['systemctl', action, EXECUTOR_UNIT.name])
+
+
+def unit_text(user, group, config, command, *, executor=False, executor_group=None, supplementary_group=None, executor_local_home=None):
+ description = 'AIM WebGUI core executor (AIM remains separately managed)' if executor else 'AIM WebGUI ' + command
+ state = EXECUTOR_STATE if executor else STATE
+ runtime = 'RuntimeDirectory=aim-web-executor\nRuntimeDirectoryMode=0711\n' if executor else ''
+ # Native core takes its own inventory locks. DAC permissions still decide write
+ # access. The add-on does not chown or otherwise modify the inventory tree.
+ executor_home = EXECUTOR_STATE
+ staging = executor_home / '.ansible/tmp'
+ executor_local_tmp = Path(executor_local_home) / '.ansible/tmp' if executor and executor_local_home is not None else None
+ writable = f'{state} {staging} {executor_local_tmp} -/etc/ansible/inventories' if executor else str(state)
+ after = 'After=network.target\n' if executor else 'After=network.target aim-web-executor.service\nWants=aim-web-executor.service\n'
+ executor_env = f'Environment=HOME={executor_home}\n' if executor else ''
+ executor_pre = f'ExecStartPre={PREFIX}/current/bin/aim-web --config {config} core-staging-check\n' if executor else ''
+ service_group = executor_group if executor and executor_group is not None else group
+ supplementary = f'SupplementaryGroups={supplementary_group}\n' if executor and supplementary_group is not None else ''
+ return f'''[Unit]
+Description={description}
+{after}
+[Service]
+Type=simple
+User={user}
+Group={service_group}
+{supplementary}WorkingDirectory=/
+ExecStart={PREFIX}/current/bin/aim-web --config {config} {command}
+Environment=PYTHONDONTWRITEBYTECODE=1
+Environment=PYTHONNOUSERSITE=1
+{executor_env}{executor_pre}UMask=0077
+Restart=on-failure
+RestartSec=3
+KillMode=control-group
+TimeoutStopSec=30
+NoNewPrivileges=true
+PrivateTmp=true
+PrivateDevices=true
+ProtectSystem=strict
+ProtectHome={'read-only' if executor else 'true'}
+ReadWritePaths={writable}
+{runtime}RestrictSUIDSGID=true
+RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
+CapabilityBoundingSet=
+AmbientCapabilities=
+LockPersonality=true
+LimitCORE=0
+
+[Install]
+WantedBy=multi-user.target
+'''
+
+
+def provision_executor_staging(executor_user):
+ account=pwd.getpwnam(executor_user)
+ base=EXECUTOR_STATE / '.ansible'
+ staging=base / 'tmp'
+ local_base=Path(account.pw_dir) / '.ansible'
+ local_staging=local_base / 'tmp'
+ for path in (EXECUTOR_STATE,base,staging,local_base,local_staging):
+ path.mkdir(mode=0o700,parents=True,exist_ok=True)
+ os.chown(path,account.pw_uid,account.pw_gid)
+ path.chmod(0o700)
+
+
+
+def require_owner_mode(path: Path, uid: int, gid: int, mode: int, *, kind: str = 'path'):
+ if path.is_symlink() or not path.exists():
+ raise ValueError(f'Managed {kind} is missing or a symlink: {path}')
+ st=path.stat()
+ actual=st.st_mode & 0o777
+ if st.st_uid!=uid or st.st_gid!=gid or actual!=mode:
+ raise ValueError(
+ f'Managed {kind} has unexpected ownership/mode: {path}; '
+ f'expected uid={uid} gid={gid} mode={mode:04o}, got '
+ f'uid={st.st_uid} gid={st.st_gid} mode={actual:04o}.')
+
+
+def validate_managed_permissions(service_user: str, executor_user: str, config: Path):
+ web=pwd.getpwnam(service_user); executor=pwd.getpwnam(executor_user)
+ require_owner_mode(STATE,web.pw_uid,web.pw_gid,0o700,kind='WebGUI state directory')
+ require_owner_mode(config,0,web.pw_gid,0o640,kind='WebGUI configuration')
+ require_owner_mode(EXECUTOR_STATE,executor.pw_uid,executor.pw_gid,0o700,kind='executor state directory')
+ require_owner_mode(EXECUTOR_STATE/'.ansible',executor.pw_uid,executor.pw_gid,0o700,kind='executor Ansible directory')
+ require_owner_mode(EXECUTOR_STATE/'.ansible/tmp',executor.pw_uid,executor.pw_gid,0o700,kind='executor controller-local staging directory')
+ require_owner_mode(Path(executor.pw_dir)/'.ansible',executor.pw_uid,executor.pw_gid,0o700,kind='executor delegated-local Ansible directory')
+ require_owner_mode(Path(executor.pw_dir)/'.ansible/tmp',executor.pw_uid,executor.pw_gid,0o700,kind='executor delegated-local staging directory')
+ for path in (UNIT,worker_unit(),EXECUTOR_UNIT):
+ require_owner_mode(path,0,0,0o644,kind='systemd unit')
+
+
+def validate_executor_runtime_permissions(service_user: str, executor_user: str, socket_path: Path):
+ web=pwd.getpwnam(service_user); executor=pwd.getpwnam(executor_user)
+ require_owner_mode(socket_path.parent,executor.pw_uid,executor.pw_gid,0o711,kind='executor runtime directory')
+ require_owner_mode(socket_path,executor.pw_uid,web.pw_gid,0o660,kind='executor socket')
+
+
+def wait_executor_runtime_permissions(service_user: str, executor_user: str, socket_path: Path, *, timeout: float = 10.0):
+ """Wait for the Type=simple executor to bind and permission its managed socket.
+
+ systemctl start returns after the process is launched, not after Executor.run()
+ has completed capability negotiation and listener.bind(). Treat a missing socket
+ during that short window as startup-in-progress, not as a migration failure.
+ """
+ deadline=time.monotonic()+timeout
+ last=None
+ while True:
+ try:
+ validate_executor_runtime_permissions(service_user,executor_user,socket_path)
+ return
+ except ValueError as exc:
+ last=exc
+ active=subprocess.run(['systemctl','is-active','--quiet',EXECUTOR_UNIT.name]).returncode==0
+ if not active:
+ raise ValueError('Executor service exited before its managed socket became ready.') from last
+ if time.monotonic()>=deadline:
+ raise ValueError(f'Executor managed socket did not become ready within {timeout:g}s: {socket_path}') from last
+ time.sleep(0.1)
+
+def validate_legacy_files():
+ for path in LEGACY_FILES:
+ if path.is_symlink():
+ raise ValueError(f'Refusing a symlink at legacy bridge: {path}')
+ if not path.exists():
+ continue
+ text = path.read_text()
+ if path.name == 'aim-web-key-export':
+ if 'key' not in text or ('aim-web' not in text and 'private' not in text.lower()):
+ raise ValueError(f'Unrecognized legacy bridge; review manually: {path}')
+ elif 'CapabilityBoundingSet=CAP_SETUID CAP_SETGID' not in text:
+ raise ValueError(f'Unexpected worker capability override; review manually: {path}')
+ # Other unit overrides can silently retain the old identity/capabilities.
+ for name in ('aim-web.service','aim-web-worker.service','aim-web-executor.service'):
+ directory = UNIT.parent / (name + '.d')
+ if directory.exists():
+ unexpected = [p for p in directory.glob('*.conf') if p not in LEGACY_FILES]
+ if unexpected:
+ raise ValueError('Review and temporarily move unmanaged unit drop-ins before migration: ' + ', '.join(map(str,unexpected)))
+
+
+def restore_extra(snapshot, record):
+ for number,path in enumerate((EXECUTOR_UNIT,*LEGACY_FILES)):
+ saved=snapshot/f'extra-{number}'
+ if saved.exists():
+ path.parent.mkdir(parents=True,exist_ok=True)
+ atomic_bytes(path,saved.read_bytes(),record['extra_modes'][str(path)])
+ os.chown(path,0,0)
+ else:
+ path.unlink(missing_ok=True)
+
+
+
+def run(args, *, capture=False, **kwargs):
+ return subprocess.run([str(a) for a in args], check=True, text=True, capture_output=capture, **kwargs)
+
+
+def atomic_bytes(path: Path, data: bytes, mode=0o600):
+ if path.is_symlink():
+ raise ValueError(f'Refusing a symlink at managed file: {path}')
+ fd, name = tempfile.mkstemp(prefix='.aim-web-', dir=path.parent)
+ temp = Path(name)
+ try:
+ with os.fdopen(fd, 'wb') as stream:
+ stream.write(data)
+ stream.flush()
+ os.fsync(stream.fileno())
+ temp.chmod(mode)
+ os.replace(temp, path)
+ directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
+ try:
+ os.fsync(directory)
+ finally:
+ os.close(directory)
+ finally:
+ temp.unlink(missing_ok=True)
+
+
+def write_json(path, value):
+ atomic_bytes(path, (json.dumps(value, indent=2) + '\n').encode())
+
+
+
+
+def sqlite_backup(source: Path, destination: Path):
+ """Create a consistent SQLite checkpoint without importing any WebGUI runtime."""
+ if not source.is_file():
+ raise ValueError(f'Authentication database is missing: {source}')
+ destination.unlink(missing_ok=True)
+ src = sqlite3.connect(f'file:{source}?mode=ro', uri=True)
+ dst = sqlite3.connect(destination)
+ try:
+ src.backup(dst)
+ dst.commit()
+ finally:
+ dst.close()
+ src.close()
+ destination.chmod(0o600)
+
+def current_env() -> Path | None:
+ link = PREFIX / 'current'
+ return link.resolve() if link.is_symlink() else None
+
+
+def switch_env(env: Path):
+ link = PREFIX / '.current-next'
+ if link.exists() or link.is_symlink():
+ link.unlink()
+ link.symlink_to(env)
+ os.replace(link, PREFIX / 'current')
+
+
+def ensure_cli_link():
+ """Expose the active release on PATH without copying a versioned executable."""
+ target = PREFIX / 'current/bin/aim-web'
+ if CLI_LINK.exists() and not CLI_LINK.is_symlink():
+ raise ValueError(f'Refusing to overwrite an unmanaged CLI file: {CLI_LINK}')
+ if CLI_LINK.is_symlink():
+ if Path(os.readlink(CLI_LINK)) != target:
+ raise ValueError(f'Refusing to replace an unrelated CLI symlink: {CLI_LINK}')
+ return
+ CLI_LINK.parent.mkdir(parents=True, mode=0o755, exist_ok=True)
+ temporary = CLI_LINK.parent / ('.aim-web-link-' + new_id())
+ try:
+ temporary.symlink_to(target)
+ os.replace(temporary, CLI_LINK)
+ finally:
+ temporary.unlink(missing_ok=True)
+
+
+def remove_managed_cli_link():
+ target = PREFIX / 'current/bin/aim-web'
+ if CLI_LINK.is_symlink() and Path(os.readlink(CLI_LINK)) == target:
+ CLI_LINK.unlink()
+
+
+def worker_unit() -> Path:
+ return UNIT.with_name('aim-web-worker.service')
+
+
+def worker_active() -> bool:
+ return worker_unit().is_file() and subprocess.run(
+ ['systemctl', 'is-active', '--quiet', 'aim-web-worker.service'], check=False).returncode == 0
+
+
+def worker_service(action):
+ if worker_unit().is_file():
+ run(['systemctl', action, 'aim-web-worker.service'])
+
+
+def active() -> bool:
+ return subprocess.run(['systemctl', 'is-active', '--quiet', SERVICE], check=False).returncode == 0
+
+
+def service(action):
+ run(['systemctl', action, SERVICE])
+
+
+def web_config_value(config: dict, section: str, key: str, legacy: str, default):
+ values = config.get(section, {})
+ if isinstance(values, dict) and key in values:
+ return values[key]
+ return config.get(legacy, default)
+
+
+def safe_absolute(path: Path) -> Path:
+ # Protect systemd syntax and prevent unexpectedly following operator symlinks.
+ if not path.is_absolute() or not re.fullmatch(r'/[A-Za-z0-9_./-]+', str(path)):
+ raise ValueError('Deployment paths must be absolute and contain only letters, digits, /, _, . and -.')
+ if path.resolve() != path:
+ raise ValueError(f'Use a canonical path without symlinks or dot components: {path}')
+ return path
+
+
+def release_order(version: str) -> tuple[int, ...]:
+ match = re.fullmatch(r'(\d+)\.(\d+)\.(\d+)(?:rc(\d+))?', version)
+ if not match:
+ raise ValueError('Use independent x.y.z or x.y.zrcN add-on versions.')
+ major, minor, patch, rc = match.groups()
+ return (int(major), int(minor), int(patch), int(rc is None), int(rc or 0))
+
+
+def new_id() -> str:
+ return datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ')
+
+
+@dataclass
+class Deployment:
+ scripts: Path
+ user: str
+ required_gid: int
+
+ @property
+ def target(self):
+ return self.scripts / 'addons/webgui'
+
+ @property
+ def config(self):
+ return self.scripts / 'config/webgui.toml'
+
+ def as_user(self, command, *, capture=False):
+ account = pwd.getpwnam(self.user)
+ groups = list(set(os.getgrouplist(self.user, account.pw_gid) + [self.required_gid]))
+ def drop():
+ os.setgroups(groups)
+ os.setgid(account.pw_gid)
+ os.setuid(account.pw_uid)
+ os.umask(0o077)
+ env = {'PATH':'/usr/sbin:/usr/bin:/sbin:/bin', 'HOME':str(STATE), 'LANG':'C.UTF-8',
+ 'PYTHONDONTWRITEBYTECODE':'1', 'PYTHONNOUSERSITE':'1'}
+ return run(command, preexec_fn=drop, cwd='/', env=env, capture=capture)
+
+ def as_executor(self, user, command, *, capture=False):
+ account=pwd.getpwnam(user)
+ web=pwd.getpwnam(self.user)
+ def drop():
+ # Match the managed systemd identity: preserve the executor account's
+ # normal primary GID and add only the WebGUI group needed for the
+ # release-managed config/socket boundary. No /etc/group mutation.
+ os.setgroups(list(set(os.getgrouplist(user,account.pw_gid) + [web.pw_gid])))
+ os.setgid(account.pw_gid)
+ os.setuid(account.pw_uid)
+ os.umask(0o077)
+ environment={'PATH':'/usr/local/bin:/usr/bin:/bin','HOME':str(EXECUTOR_STATE),'LANG':'C.UTF-8',
+ 'PYTHONDONTWRITEBYTECODE':'1','PYTHONNOUSERSITE':'1'}
+ return run(command,preexec_fn=drop,cwd='/',env=environment,capture=capture)
+
+ def cli(self, env, *args, capture=False):
+ return self.as_user([env / 'bin/aim-web', '--config', self.config, *args], capture=capture)
+
+ def snapshot(self, old: dict | None) -> Path:
+ stamp = BACKUPS / new_id()
+ stamp.mkdir(mode=0o700)
+ data = {'previous':old, 'was_active':active(), 'worker_active':worker_active(), 'worker_present':worker_unit().exists(), 'config_present':self.config.exists(),
+ 'unit_present':UNIT.exists(), 'database_present':STATE.joinpath('webgui.sqlite3').exists()}
+ if self.target.exists():
+ shutil.copytree(self.target, stamp / 'source', symlinks=True)
+ if self.config.exists():
+ shutil.copy2(self.config, stamp / 'webgui.toml')
+ if UNIT.exists():
+ shutil.copy2(UNIT, stamp / 'aim-web.service')
+ if worker_unit().exists():
+ shutil.copy2(worker_unit(), stamp / 'aim-web-worker.service')
+ if data['database_present']:
+ # Use SQLite's online backup API directly. Snapshotting must not depend on
+ # the previous WebGUI runtime being able to parse the current config.
+ sqlite_backup(STATE / 'webgui.sqlite3', stamp / 'webgui.sqlite3')
+ os.chown(stamp / 'webgui.sqlite3', 0, 0)
+ data['executor_active'] = EXECUTOR_UNIT.exists() and subprocess.run(['systemctl','is-active','--quiet',EXECUTOR_UNIT.name],check=False).returncode==0
+ data['extra_modes']={}
+ for number,path in enumerate((EXECUTOR_UNIT,*LEGACY_FILES)):
+ if path.exists():
+ if path.is_symlink():raise ValueError(f'Unexpected symlink: {path}')
+ shutil.copy2(path,stamp/f'extra-{number}')
+ data['extra_modes'][str(path)] = path.stat().st_mode & 0o777
+ write_json(stamp / 'snapshot.json', data)
+ return stamp
+
+ def restore_db(self, snapshot: Path):
+ source = snapshot / 'webgui.sqlite3'
+ if not source.is_file():
+ raise ValueError('This snapshot has no database to restore.')
+ target = STATE / 'webgui.sqlite3'
+ temporary = STATE / '.restore.sqlite3'
+ temporary.unlink(missing_ok=True)
+ shutil.copyfile(source, temporary)
+ account = pwd.getpwnam(self.user)
+ os.chown(temporary, account.pw_uid, account.pw_gid)
+ temporary.chmod(0o600)
+ # Service must be stopped. Preserve failed data in the snapshot before replacement.
+ for suffix in ('', '-wal', '-shm', '-journal'):
+ file = Path(str(target) + suffix)
+ if file.exists():
+ shutil.copy2(file, snapshot / ('pre-restore-' + new_id() + suffix + '.sqlite3'))
+ if suffix:
+ file.unlink()
+ os.replace(temporary, target)
+
+ def restore(self, snapshot: Path, *, database: bool, config: bool = False):
+ record = json.loads((snapshot / 'snapshot.json').read_text())
+ old = record['previous']
+ worker_service('stop')
+ executor_service('stop')
+ restore_extra(snapshot,record)
+ if old is None:
+ # An early first-install failure may not have created/loaded a unit.
+ if UNIT.exists() or active():
+ service('stop')
+ # A failed first install preserves private state for a deliberate retry.
+ if self.target.exists():
+ shutil.rmtree(self.target)
+ (PREFIX / 'current').unlink(missing_ok=True)
+ remove_managed_cli_link()
+ META.unlink(missing_ok=True)
+ subprocess.run(['systemctl','disable',SERVICE,'aim-web-worker.service',EXECUTOR_UNIT.name],check=False,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
+ UNIT.unlink(missing_ok=True)
+ worker_unit().unlink(missing_ok=True)
+ if config:
+ self.config.unlink(missing_ok=True)
+ run(['systemctl', 'daemon-reload'])
+ return
+ service('stop')
+ if database:
+ self.restore_db(snapshot)
+ staged = self.target.parent / ('.webgui-restore-' + new_id())
+ shutil.copytree(snapshot / 'source', staged, symlinks=True)
+ displaced = self.target.parent / ('.webgui-displaced-' + new_id())
+ if self.target.exists():
+ os.replace(self.target, displaced)
+ os.replace(staged, self.target)
+ if displaced.exists():
+ shutil.rmtree(displaced)
+ switch_env(Path(old['venv']))
+ ensure_cli_link()
+ if config and record['config_present']:
+ atomic_bytes(self.config, (snapshot / 'webgui.toml').read_bytes(), 0o640)
+ os.chown(self.config, 0, pwd.getpwnam(self.user).pw_gid)
+ if record['unit_present']:
+ atomic_bytes(UNIT, (snapshot / 'aim-web.service').read_bytes(), 0o644)
+ if record.get('worker_present'):
+ atomic_bytes(worker_unit(), (snapshot / 'aim-web-worker.service').read_bytes(), 0o644)
+ else:
+ if worker_unit().exists():
+ run(['systemctl', 'disable', 'aim-web-worker.service'])
+ worker_unit().unlink()
+ write_json(META, old)
+ run(['systemctl', 'daemon-reload'])
+ # Revoking sessions also avoids restoring live session tokens from backups.
+ self.as_user([Path(old['venv']) / 'bin/python', '-c',
+ 'from aim_webgui.auth.service import Auth; from aim_webgui.config import Settings; '
+ 'from pathlib import Path; import sys; a=Auth(Settings.load(Path(sys.argv[1]))); '
+ 'db=a.store.connect(); db.execute("DELETE FROM sessions"); db.commit(); db.close()', self.config])
+ if release_order(old['version'])[0] < 2:
+ # The core was separately upgraded to 3.3.0rc8. The legacy 1.x adapter
+ # cannot be declared healthy here. Restored history/code remains stopped.
+ subprocess.run(['systemctl','disable',SERVICE,'aim-web-worker.service'],check=False,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
+ print('Restored legacy WebGUI but left services STOPPED and disabled: coordinate a separate AIM core rollback before restarting. AIM was not changed.',file=sys.stderr)
+ return
+ # Source and state restoration must not restart an adapter whose exact
+ # independently managed Core contract is no longer available. Probe using
+ # the restored package/config and the executor identity, never root.
+ if not self.restored_core_compatible(old):
+ subprocess.run(['systemctl','disable',SERVICE,'aim-web-worker.service',EXECUTOR_UNIT.name],
+ check=False,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
+ print('Restored add-on source/config/state; services remain STOPPED and disabled. '
+ 'The restored adapter does not qualify the currently installed Core. '
+ 'Coordinate the independent Core rollback, then explicitly re-enable services. '
+ 'No Core files were changed.',file=sys.stderr)
+ return
+ if record.get('executor_active'):
+ executor_service('start')
+ self.cli(Path(old['venv']), 'check')
+ if record['was_active']:
+ service('start')
+ self.health()
+ if record.get('worker_active'):
+ worker_service('start')
+
+ def restored_core_compatible(self, old):
+ try:
+ self.as_executor(old.get('executor_user','svc_bf-ansible'),
+ [Path(old['venv'])/'bin/python','-B','-c',
+ 'from dataclasses import replace; from pathlib import Path; import sys; '
+ 'from aim_webgui.config import Settings; from aim_webgui.adapters.core_v1 import CoreAdapter; '
+ 'CoreAdapter(replace(Settings.load(Path(sys.argv[1])),core_transport="stdio"))',self.config],capture=True)
+ return True
+ except (OSError,ValueError,subprocess.SubprocessError):
+ return False
+
+ def health(self):
+ with self.config.open('rb') as stream:
+ config = tomllib.load(stream)
+ host = web_config_value(config, 'server', 'host', 'host', '127.0.0.1')
+ port = web_config_value(config, 'server', 'port', 'port', 8080)
+ # A wildcard listener is not a routable health-check destination.
+ check_host = '127.0.0.1' if host in {'0.0.0.0', '::'} else host
+ authority = f'[{check_host}]' if ':' in check_host else check_host
+ url = f'http://{authority}:{port}/readyz'
+ origin = urlsplit(web_config_value(config, 'server', 'public_url', 'public_url', 'http://127.0.0.1:8080'))
+ opener = build_opener(ProxyHandler({}))
+ for _ in range(12):
+ try:
+ req = Request(url, headers={'Host':origin.netloc})
+ with opener.open(req, timeout=10) as response:
+ if response.status == 200 and json.load(response).get('status') == 'ready':
+ return
+ except Exception:
+ pass
+ time.sleep(.5)
+ raise RuntimeError('Readiness check failed. Inspect journalctl -u aim-web.service.')
+
+
+def verify_release(source):
+ """Verify the ZIP payload manifest before staging/provisioning any resource.
+
+ The separately verified ZIP digest checks the expected release file;
+ this manifest detects extraction damage, not publisher signatures.
+ """
+ manifest=source/'MANIFEST.sha256'
+ if not manifest.is_file() or manifest.is_symlink():
+ raise ValueError('Missing release MANIFEST.sha256. Use a fresh release ZIP.')
+ seen=set()
+ for line in manifest.read_text(encoding='utf-8').splitlines():
+ if not line: continue
+ digest,sep,name=line.partition(' ')
+ rel=Path(name)
+ normalized=rel.as_posix()
+ if not sep or not re.fullmatch('[0-9a-f]{64}',digest) or not name or rel.is_absolute() or '..' in rel.parts or normalized in seen:
+ raise ValueError('Invalid release manifest entry.')
+ seen.add(normalized)
+ path=source/rel
+ if any(p.is_symlink() for p in (path,*path.parents)) or not path.is_file():
+ raise ValueError('Release manifest references an unsafe or missing file.')
+ if hashlib.sha256(path.read_bytes()).hexdigest()!=digest:
+ raise ValueError('Release integrity mismatch: '+name)
+ if not {'pyproject.toml','deploy/deploy.py','src/aim_webgui/__init__.py'}<=seen:
+ raise ValueError('Release manifest does not cover required files.')
+
+
+def install(args):
+ scripts = safe_absolute(args.aim_scripts)
+ source = Path(__file__).resolve().parents[1]
+ verify_release(source)
+ target = scripts / 'addons/webgui'
+ if source == target:
+ raise ValueError('Unpack the new ZIP in a separate staging directory; do not update from the active source.')
+ if not scripts.is_dir():
+ raise ValueError('Deploy the separately managed AIM core first.')
+ core_launcher = args.aimctl.absolute()
+ if not core_launcher.is_file() or not os.access(core_launcher,os.X_OK):
+ raise ValueError('The configured aimctl launcher is missing or not executable. Deploy AIM 3.3.0rc8 first; this installer never creates it.')
+ safe_absolute(args.core_config)
+ if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,30}',args.executor_user):raise ValueError('Invalid executor account name.')
+ try: executor_account=pwd.getpwnam(args.executor_user)
+ except KeyError: raise ValueError('Provision and authorize the existing non-root AIM execution/key-owning account first.') from None
+ if executor_account.pw_uid==0 or args.executor_user==args.service_user:
+ raise ValueError('Managed topology needs distinct non-root web and execution accounts.')
+ validate_legacy_files()
+ if any(p.exists() for p in LEGACY_FILES) and not args.migrate_core:
+ raise ValueError('Legacy key-export/capability files exist. Review migration and pass --migrate-core to retire only these backed-up files.')
+ with (source / 'pyproject.toml').open('rb') as stream:
+ project = tomllib.load(stream)['project']
+ version = project['version']
+ if not re.fullmatch(r'[0-9A-Za-z.+-]+', version):
+ raise ValueError('Invalid add-on version.')
+ previous = json.loads(META.read_text()) if META.exists() else None
+ if args.command == 'update' and previous is None:
+ raise ValueError('No managed add-on installation exists. Use install first.')
+ if args.command == 'install' and previous:
+ raise ValueError('Add-on already installed. Use update; existing accounts will be retained.')
+ if previous and release_order(previous['version'])[0] < 2 and not args.migrate_core:
+ raise ValueError('Major core integration migration: pass --migrate-core after reviewing MIGRATION-3.1.md. Old queued work is stopped; schema changes require a backup.')
+ if previous and (previous['scripts'] != str(scripts) or previous['user'] != args.service_user):
+ raise ValueError('Update must retain the installed AIM scripts path and service identity.')
+ if previous and release_order(version) < release_order(previous['version']):
+ raise ValueError('Downgrades use rollback, not update.')
+ if previous and previous['version'] == version:
+ raise ValueError('This add-on version is already installed. Published versions are immutable; use a new release number.')
+ if target.exists() and (not previous or not (target / '.aim-web-managed').is_file()):
+ raise ValueError('Target is not a recognized managed WebGUI directory. Back it up and move it aside manually.')
+ if not previous and UNIT.exists():
+ raise ValueError('An unmanaged aim-web.service already exists; refusing to overwrite it.')
+ if target.is_symlink():
+ raise ValueError('The active add-on source must be a real directory, not a symlink.')
+ for path in source.rglob('*'):
+ if path.is_symlink():
+ raise ValueError(f'Release contains an unexpected symlink: {path}')
+ if not target.parent.exists():
+ target.parent.mkdir(parents=True, mode=0o755)
+ target.parent.chmod(0o755)
+ stage = Path(tempfile.mkdtemp(prefix='.webgui-stage-', dir=target.parent))
+ environment = PREFIX / 'venvs' / (version + '-' + new_id())
+ snapshot = None
+ deployment = None
+ activated = False
+ try:
+ shutil.copytree(source, stage, dirs_exist_ok=True,
+ ignore=shutil.ignore_patterns('__pycache__', '*.pyc', '.pytest_cache', '.credentials', '*.egg-info', 'build', 'dist', 'wheelhouse', 'offline-assets', '*.zip'))
+ # Network/offline artifacts are prepared BEFORE stopping the running service.
+ prepare(stage / 'src/aim_webgui/static/vendor', args.assets_dir,
+ reuse=target / 'src/aim_webgui/static/vendor' if previous else None)
+ environment.parent.mkdir(parents=True, mode=0o755, exist_ok=True)
+ environment.parent.chmod(0o755)
+ # Runtime code is root-owned but readable/executable by the service account.
+ os.umask(0o022)
+ run([args.python, '-m', 'venv', environment])
+ pip = [environment / 'bin/python', '-m', 'pip', 'install', '--no-compile', '--no-cache-dir']
+ if args.wheelhouse:
+ pip.extend(['--no-index', '--find-links', args.wheelhouse])
+ run([*pip, '-c', stage / 'constraints.txt', 'setuptools'])
+ run([*pip, '--no-build-isolation', '-c', stage / 'constraints.txt', stage])
+ run([environment / 'bin/python', '-m', 'pip', 'check'])
+ identity = run([environment / 'bin/python', '-B', '-c',
+ 'import aim_webgui,importlib.metadata; '
+ 'print(aim_webgui.__version__); print(importlib.metadata.version("aim-webgui"))'], capture=True)
+ if identity.stdout.splitlines() != [version, version]:
+ raise ValueError('Candidate package identity does not match this release; nothing was activated.')
+ os.umask(0o077)
+ # Do not parse or import private core configuration. Public protocol probe follows.
+ if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,30}', args.service_user):
+ raise ValueError('Use a local service account name, not a shell expression.')
+ try:
+ account = pwd.getpwnam(args.service_user)
+ except KeyError:
+ shell = shutil.which('nologin') or '/usr/sbin/nologin'
+ run(['useradd','--system','--user-group','--home-dir',STATE,'--shell',shell,args.service_user])
+ account = pwd.getpwnam(args.service_user)
+ if account.pw_uid == 0:
+ raise ValueError('The web service must not run as root.')
+ if not STATE.parent.exists():
+ STATE.parent.mkdir(parents=True, mode=0o755)
+ STATE.parent.chmod(0o755)
+ STATE.mkdir(mode=0o700, exist_ok=True)
+ if STATE.is_symlink():
+ raise ValueError('State directory must not be a symlink.')
+ os.chown(STATE, account.pw_uid, account.pw_gid)
+ STATE.chmod(0o700)
+ required_gid = account.pw_gid
+ deployment = Deployment(scripts, args.service_user, required_gid)
+ if not deployment.config.parent.exists():
+ deployment.config.parent.mkdir(parents=True, mode=0o755)
+ deployment.config.parent.chmod(0o755)
+
+ # Stage and parse the candidate config without changing the installed config.
+ text=(stage/'deploy/webgui.example.toml').read_text().replace('/etc/ansible/scripts',str(scripts))
+ text=text.replace('command = ["/usr/local/bin/aimctl"]',f'command = [{json.dumps(str(core_launcher))}]')
+ text=text.replace('config = "/etc/ansible/scripts/aim.yml"',f'config = {json.dumps(str(args.core_config))}')
+ text=text.replace('executor_user = "svc_bf-ansible"',f'executor_user = {json.dumps(args.executor_user)}')
+ text=text.replace('client_user = "aim-web"',f'client_user = {json.dumps(args.service_user)}')
+ release_config=tomllib.loads(text)
+ if release_config['state']['state_dir']!=str(STATE):raise ValueError('Unexpected managed state location.')
+ candidate=stage/'candidate.toml'
+ candidate.write_text(text);candidate.chmod(0o640);os.chown(candidate,0,account.pw_gid);stage.chmod(0o755)
+ deployment.as_user([environment/'bin/aim-web','--config',candidate,'check','--without-db','--without-core'])
+ # Test public capabilities and authorization in the EXISTING core environment,
+ # as the eventual executor identity. No dependency installation or core writes.
+ probe=deployment.as_executor(args.executor_user,[environment/'bin/python','-B','-c',
+ 'from dataclasses import replace; from pathlib import Path; import sys; '
+ 'from aim_webgui.config import Settings; from aim_webgui.adapters.core_v1 import CoreAdapter; '
+ 'a=CoreAdapter(replace(Settings.load(Path(sys.argv[1])),core_transport="stdio")); '
+ 'print(a.version); print("Authorized customer listing:",len(a.customers()))',candidate],capture=True)
+ print(probe.stdout.strip())
+ candidate.unlink()
+ snapshot=deployment.snapshot(previous)
+ write_json(PENDING,{'backup':snapshot.name,'candidate':str(environment),'phase':'before-stop',
+ 'scripts':str(scripts),'user':args.service_user,'required_gid':required_gid})
+ if previous:
+ worker_service('stop');service('stop');executor_service('stop')
+ sqlite_backup(STATE/'webgui.sqlite3',snapshot/'webgui.sqlite3')
+ os.chown(snapshot/'webgui.sqlite3',0,0)
+ atomic_bytes(deployment.config,text.encode(),0o640);os.chown(deployment.config,0,account.pw_gid)
+ # Neither group memberships nor inventory/key ownership are touched.
+ if EXECUTOR_STATE.exists() and (EXECUTOR_STATE.is_symlink() or EXECUTOR_STATE.stat().st_uid!=executor_account.pw_uid):
+ raise ValueError('Existing executor state directory has an unexpected owner or type.')
+ EXECUTOR_STATE.mkdir(mode=0o700,exist_ok=True);os.chown(EXECUTOR_STATE,executor_account.pw_uid,executor_account.pw_gid);EXECUTOR_STATE.chmod(0o700)
+ provision_executor_staging(args.executor_user)
+ if previous:deployment.cli(environment,'db','migrate')
+ else:deployment.cli(environment,'init')
+ deployment.cli(environment,'check','--without-core')
+ for directory in list(stage.rglob('__pycache__')) + list(stage.glob('src/*.egg-info')) + [stage / 'build', stage / 'dist']:
+ if directory.is_dir():
+ shutil.rmtree(directory)
+ # Source is replaced wholesale. No stale files, git, patch hunks or core overlay.
+ for item in stage.rglob('*'):
+ if not item.is_symlink():
+ item.chmod(0o755 if item.is_dir() else 0o644)
+ (stage / '.aim-web-managed').write_text('aim-webgui ' + version + '\n')
+ (stage / '.credentials').symlink_to(STATE / '.credentials')
+ stage.chmod(0o755)
+ if target.exists():
+ displaced = target.parent / ('.webgui-old-' + new_id())
+ os.replace(target, displaced)
+ else:
+ displaced = None
+ os.replace(stage, target)
+ activated = True
+ if displaced:
+ shutil.rmtree(displaced)
+ switch_env(environment)
+ ensure_cli_link()
+ atomic_bytes(UNIT,unit_text(args.service_user,account.pw_gid,deployment.config,'serve').encode(),0o644)
+ atomic_bytes(worker_unit(),unit_text(args.service_user,account.pw_gid,deployment.config,'worker').encode(),0o644)
+ atomic_bytes(EXECUTOR_UNIT,unit_text(args.executor_user,account.pw_gid,deployment.config,'executor',executor=True,executor_group=executor_account.pw_gid,supplementary_group=account.pw_gid,executor_local_home=executor_account.pw_dir).encode(),0o644)
+ for managed_unit in (UNIT,worker_unit(),EXECUTOR_UNIT): os.chown(managed_unit,0,0);managed_unit.chmod(0o644)
+ validate_managed_permissions(args.service_user,args.executor_user,deployment.config)
+ # Approved major migration retires the known old bridge and elevated worker
+ # override; snapshot restoration restores matching legacy files if needed.
+ for path in LEGACY_FILES:path.unlink(missing_ok=True)
+ write_json(META, {'version':version,'scripts':str(scripts),'user':args.service_user,
+ 'required_gid':required_gid,'venv':str(environment),'backup':snapshot.name,
+ 'executor_user':args.executor_user,'core_version':'3.3.0rc8','api_version':'1.0'})
+ write_json(PENDING, {'backup':snapshot.name,'candidate':str(environment),'phase':'activated',
+ 'scripts':str(scripts),'user':args.service_user,'required_gid':required_gid})
+ run(['systemctl','daemon-reload'])
+ run(['systemctl','enable',EXECUTOR_UNIT.name])
+ executor_service('start')
+ wait_executor_runtime_permissions(args.service_user,args.executor_user,Path(release_config['core']['socket']))
+ run(['systemctl','enable',SERVICE])
+ service('start')
+ deployment.health()
+ if release_config.get('execution', {}).get('enabled', False):
+ run(['systemctl', 'enable', 'aim-web-worker.service'])
+ worker_service('start')
+ else:
+ worker_service('stop')
+ run(['systemctl', 'disable', 'aim-web-worker.service'])
+ deployment.as_user([environment / 'bin/python', '-B', '-c',
+ 'import sys; from pathlib import Path; from aim_webgui.config import Settings; '
+ 'from aim_webgui.db.store import Store,audit; s=Store(Settings.load(Path(sys.argv[1])).database); '
+ 'db=s.connect(); audit(db,"deployer","release-installed",sys.argv[2]); db.commit(); db.close()',
+ deployment.config, version])
+ PENDING.unlink()
+ print(f'Installed AIM WebGUI {version}; separately managed AIM 3.3.0rc8 was not modified.')
+ print(f'Add-on source: {target}')
+ print(f'Configuration: {deployment.config}')
+ print(f'Rollback snapshot: {snapshot.name}')
+ if (STATE / '.credentials').exists():
+ print(f'Initial admin credentials (local file only): {(target / ".credentials").as_uri()}')
+ print(f'Read locally: sudo cat {STATE}/.credentials')
+ print(f'CLI: {CLI_LINK} -> {PREFIX}/current/bin/aim-web')
+ print('Service: systemctl status aim-web.service')
+ except BaseException:
+ if snapshot and deployment and PENDING.exists():
+ print('Deployment failed. Attempting to restore the prior add-on; AIM was not modified.', file=sys.stderr)
+ try:
+ deployment.restore(snapshot, database=bool(previous), config=True)
+ PENDING.unlink(missing_ok=True)
+ except Exception:
+ print(f'Automatic recovery did not complete. Keep service stopped; recovery snapshot: {snapshot}', file=sys.stderr)
+ raise
+ finally:
+ if stage.exists():
+ shutil.rmtree(stage)
+ # Failed venvs are left for diagnosis; never delete one referenced by a snapshot.
+
+
+def rollback(args):
+ if not META.exists():
+ raise ValueError('No active deployment metadata. Recover using docs/DEPLOYMENT.md and pending.json.')
+ latest = json.loads(META.read_text())
+ if not args.backup or not re.fullmatch(r'[0-9]{8}T[0-9]{12}Z', args.backup):
+ raise ValueError('Use --backup with the exact snapshot identifier printed during deployment.')
+ selected = BACKUPS / args.backup
+ record = json.loads((selected / 'snapshot.json').read_text())
+ if not record.get('previous'):
+ raise ValueError('This is the first-install checkpoint, not an earlier installed version.')
+ if record['previous']['scripts'] != latest['scripts']:
+ raise ValueError('Snapshot is for a different installation.')
+ deployment = Deployment(Path(latest['scripts']), latest['user'], latest['required_gid'])
+ if not args.restore_auth_db:
+ # Validate the rollback runtime against the configuration stored with that release.
+ old_env = Path(record['previous']['venv'])
+ schema = deployment.as_user([old_env / 'bin/python', '-B', '-c',
+ 'from aim_webgui import SCHEMA_VERSION; print(SCHEMA_VERSION)'], capture=True)
+ with sqlite3.connect((STATE / 'webgui.sqlite3').as_uri() + '?mode=ro', uri=True) as db:
+ current_schema = db.execute('PRAGMA user_version').fetchone()[0]
+ if current_schema != int(schema.stdout.strip()):
+ raise ValueError('Rollback crosses a database schema boundary. Use --restore-auth-db explicitly after backing up current data.')
+ with tempfile.TemporaryDirectory(prefix='.rollback-config-', dir=PREFIX) as directory:
+ check_dir = Path(directory)
+ check_dir.chmod(0o755)
+ check_config = check_dir / 'webgui.toml'
+ shutil.copyfile(selected / 'webgui.toml', check_config)
+ check_config.chmod(0o640)
+ os.chown(check_config, 0, pwd.getpwnam(latest['user']).pw_gid)
+ deployment.as_user([old_env / 'bin/aim-web', '--config', check_config, 'check', '--without-db', '--without-core'])
+ current_snapshot = deployment.snapshot(latest)
+ write_json(PENDING, {'backup':current_snapshot.name,'phase':'manual-rollback',
+ 'scripts':latest['scripts'],'user':latest['user'],'required_gid':latest['required_gid']})
+ try:
+ deployment.restore(selected, database=args.restore_auth_db, config=True)
+ PENDING.unlink(missing_ok=True)
+ print('Rollback completed. Release configuration restored. All WebGUI sessions revoked.')
+ print('Auth database restored from snapshot.' if args.restore_auth_db else 'Current users and passwords retained.')
+ print('Undo-rollback snapshot:', current_snapshot.name)
+ except BaseException:
+ deployment.restore(current_snapshot, database=args.restore_auth_db, config=True)
+ PENDING.unlink(missing_ok=True)
+ raise
+
+
+def recover():
+ if not PENDING.is_file():
+ raise ValueError('No interrupted deployment journal exists.')
+ pending = json.loads(PENDING.read_text())
+ snapshot = BACKUPS / pending['backup']
+ record = json.loads((snapshot / 'snapshot.json').read_text())
+ deployment = Deployment(Path(pending['scripts']), pending['user'], pending['required_gid'])
+ deployment.restore(snapshot, database=bool(record['previous']), config=True)
+ PENDING.unlink()
+ print('Interrupted deployment recovered. AIM was not changed.')
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('command', choices=['install','update','rollback','recover'])
+ parser.add_argument('--aim-scripts', type=Path, default=Path('/etc/ansible/scripts'))
+ parser.add_argument('--service-user', default='aim-web')
+ parser.add_argument('--executor-user',default='svc_bf-ansible',help='Existing authorized non-root key-owning AIM account; never created or modified.')
+ parser.add_argument('--aimctl',type=Path,default=Path('/usr/local/bin/aimctl'))
+ parser.add_argument('--core-config',type=Path,default=Path('/etc/ansible/scripts/aim.yml'))
+ parser.add_argument('--migrate-core',action='store_true',help='Acknowledge 1.x to 2.x API/state/service migration; read MIGRATION-3.1.md first.')
+ parser.add_argument('--python', default=sys.executable)
+ parser.add_argument('--wheelhouse', type=Path, help='Offline wheels for this Python/OS/architecture, including build dependencies.')
+ parser.add_argument('--assets-dir', type=Path, help='Offline pinned bootstrap.min.css and htmx.min.js.')
+ parser.add_argument('--backup', help='Rollback snapshot identifier.')
+ parser.add_argument('--restore-auth-db', action='store_true', help='DESTRUCTIVE: discard account changes since selected snapshot.')
+ args = parser.parse_args()
+ if os.geteuid() != 0:
+ parser.exit(1, 'Run deployment with sudo/root. Runtime will use an unprivileged account.\n')
+ if not Path('/run/systemd/system').is_dir():
+ parser.exit(1, 'Managed deployment requires Linux/systemd. See manual installation instructions.\n')
+ os.umask(0o077)
+ for location in (PREFIX, BACKUPS, STATE):
+ safe_absolute(location)
+ PREFIX.mkdir(mode=0o755, parents=True, exist_ok=True)
+ if PREFIX.stat().st_uid != 0:
+ parser.exit(1, 'The managed /opt/aim-web directory must be root-owned.\n')
+ PREFIX.chmod(0o755)
+ BACKUPS.mkdir(mode=0o700, parents=True, exist_ok=True)
+ if BACKUPS.stat().st_uid != 0:
+ parser.exit(1, 'The WebGUI backup directory must be root-owned.\n')
+ BACKUPS.chmod(0o700)
+ lock = os.open(PREFIX / '.deploy.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
+ try:
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ if PENDING.exists() and args.command != 'recover':
+ raise ValueError('Interrupted deployment detected in /opt/aim-web/pending.json. Recover before retrying.')
+ if args.command == 'recover':
+ recover()
+ elif args.command == 'rollback':
+ rollback(args)
+ else:
+ install(args)
+ except Exception as exc:
+ parser.exit(1, f'Deployment stopped: {exc}\n')
+ finally:
+ os.close(lock)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/addons/webgui/deploy/fetch_assets.py b/scripts/addons/webgui/deploy/fetch_assets.py
new file mode 100644
index 0000000..5a86f0c
--- /dev/null
+++ b/scripts/addons/webgui/deploy/fetch_assets.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""Fetch pinned upstream assets ONCE; browsers only load local copies.
+
+Supply --from-directory for air-gapped deployment. Integrity checks are identical.
+No npm, Node, CDN requests from the browser, or mutable 'latest' URLs.
+"""
+from __future__ import annotations
+import argparse
+import base64
+import hashlib
+import os
+from pathlib import Path
+import tempfile
+from urllib.request import urlopen
+
+ASSETS = {
+ 'bootstrap.min.css': (
+ 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css',
+ 'sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB'),
+ 'htmx.min.js': (
+ 'https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js',
+ 'H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V'),
+}
+
+
+def valid(data: bytes, expected: str) -> bool:
+ return base64.b64encode(hashlib.sha384(data).digest()).decode('ascii') == expected
+
+
+def prepare(destination: Path, offline: Path | None = None, *, reuse: Path | None = None) -> None:
+ destination.mkdir(parents=True, exist_ok=True)
+ for name, (url, integrity) in ASSETS.items():
+ target = destination / name
+ if target.is_file() and valid(target.read_bytes(), integrity):
+ print('Verified local asset:', name)
+ continue
+ if offline:
+ data = (offline / name).read_bytes()
+ elif reuse and (reuse / name).is_file() and not (reuse / name).is_symlink():
+ candidate = (reuse / name).read_bytes()
+ if valid(candidate, integrity):
+ data = candidate
+ print('Reusing verified installed asset:', name)
+ else:
+ with urlopen(url, timeout=30) as response:
+ data = response.read(2_000_001)
+ else:
+ with urlopen(url, timeout=30) as response:
+ data = response.read(2_000_001)
+ if len(data) > 2_000_000 or not valid(data, integrity):
+ raise ValueError(f'Upstream integrity mismatch: {name}. No asset was installed.')
+ fd, filename = tempfile.mkstemp(prefix='.asset-', dir=destination)
+ temporary = Path(filename)
+ try:
+ with os.fdopen(fd, 'wb') as stream:
+ stream.write(data)
+ stream.flush()
+ os.fsync(stream.fileno())
+ temporary.chmod(0o644)
+ os.replace(temporary, target)
+ finally:
+ temporary.unlink(missing_ok=True)
+ print('Installed verified local asset:', name)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--from-directory', type=Path)
+ parser.add_argument('--destination', type=Path, default=Path(__file__).resolve().parents[1] / 'src/aim_webgui/static/vendor')
+ args = parser.parse_args()
+ try:
+ prepare(args.destination, args.from_directory)
+ except Exception as exc:
+ parser.exit(1, f'Assets not prepared: {exc}\nUse --from-directory with the exact pinned upstream files for offline installation.\n')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/addons/webgui/deploy/nginx.example.conf b/scripts/addons/webgui/deploy/nginx.example.conf
new file mode 100644
index 0000000..ee30179
--- /dev/null
+++ b/scripts/addons/webgui/deploy/nginx.example.conf
@@ -0,0 +1,12 @@
+# NPM header reference only; not installed automatically.
+# Default 2.0 path: HTTPS 192.168.20.46:8443 -> host nginx -> loopback 8080.
+# See docs/EXECUTION.md for separate upstream CA verification requirements.
+# Reference only. Nginx Proxy Manager normally generates the server block.
+# AIM WebGUI does not require WebSockets.
+# Configure webgui.toml public_url to the exact HTTPS browser origin.
+
+proxy_set_header Host $host;
+proxy_set_header X-Real-IP $remote_addr;
+proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+proxy_set_header X-Forwarded-Proto $scheme;
+proxy_set_header X-Forwarded-Host $host;
diff --git a/scripts/addons/webgui/deploy/profiles/direct-npm.toml b/scripts/addons/webgui/deploy/profiles/direct-npm.toml
new file mode 100644
index 0000000..fb249c4
--- /dev/null
+++ b/scripts/addons/webgui/deploy/profiles/direct-npm.toml
@@ -0,0 +1,65 @@
+# AIM WebGUI 2.1.0rc9 release-managed configuration
+# Replaced on every managed install/update/rollback.
+# AIM core configuration is never modified.
+
+[server]
+host = "0.0.0.0"
+port = 8080
+public_url = "https://aim.desq-gaming.de"
+
+[proxy]
+proxy_headers = true
+forwarded_allow_ips = ["192.168.20.3"]
+
+[session]
+session_hours = 8
+idle_minutes = 30
+
+[aim]
+scripts_path = "/etc/ansible/scripts"
+
+# Public core protocol, not an Ansible executable or private source import.
+[core]
+transport = "unix"
+command = ["/usr/local/bin/aimctl"]
+config = "/etc/ansible/scripts/aim.yml"
+socket = "/run/aim-web-executor/core.sock"
+executor_user = "svc_bf-ansible"
+client_user = "aim-web"
+# Dedicated writable process HOME for native caches; SSH trust uses effective SSH configuration.
+home = "/var/lib/aim-web-executor"
+
+[state]
+state_dir = "/var/lib/aim/webgui"
+
+# Alternative browsing-only direct NPM -> application profile; do not use it to carry infrastructure credentials.
+# This release does not create, replace or renew the separately installed TLS certificates.
+[execution]
+enabled = false
+playbooks = [
+ "checkmk_install_agent",
+ "checkmk_update_scripts_config",
+ "checkmk_read_windows_config",
+ "checkmk_cleanup_scripts",
+ "debug_test_connection",
+ "debug_show_disk_usage",
+ "debug_detect_host_roles",
+ "maintenance_export_event_logs",
+ "maintenance_start_stopped_services",
+ "maintenance_patch_os",
+ "maintenance_reboot_hosts",
+ "sophos_apply_baseline",
+ "sophos_apply_customer",
+ "pfsense_apply_baseline",
+]
+max_hosts = 25
+timeout_seconds = 1800
+require_approval = false
+# WebGUI policy only; core addons.execution_enabled is a separate operator opt-in.
+# Direct NPM -> application HTTP is browsing-only until authenticated transport is separately designed.
+transport_verified = false
+window_start_hour = 0
+window_end_hour = 24
+
+[credentials]
+enabled = false
diff --git a/scripts/addons/webgui/deploy/webgui.example.toml b/scripts/addons/webgui/deploy/webgui.example.toml
new file mode 100644
index 0000000..9a39a79
--- /dev/null
+++ b/scripts/addons/webgui/deploy/webgui.example.toml
@@ -0,0 +1,75 @@
+# AIM WebGUI 2.1.0rc9 release-managed configuration
+# Replaced on every managed install/update/rollback.
+# AIM core configuration is never modified.
+
+[server]
+host = "127.0.0.1"
+port = 8080
+public_url = "https://aim.desq-gaming.de"
+
+[proxy]
+proxy_headers = true
+forwarded_allow_ips = ["127.0.0.1"]
+
+[session]
+session_hours = 8
+idle_minutes = 30
+
+[aim]
+scripts_path = "/etc/ansible/scripts"
+
+# Public core protocol, not an Ansible executable or private source import.
+[core]
+transport = "unix"
+command = ["/usr/local/bin/aimctl"]
+config = "/etc/ansible/scripts/aim.yml"
+socket = "/run/aim-web-executor/core.sock"
+executor_user = "svc_bf-ansible"
+client_user = "aim-web"
+# Dedicated writable process HOME for native caches; SSH trust uses effective SSH configuration.
+home = "/var/lib/aim-web-executor"
+
+[state]
+state_dir = "/var/lib/aim/webgui"
+
+# Browser -> NPM 192.168.20.3 -> HTTPS 192.168.20.46:8443 -> local nginx -> 127.0.0.1:8080.
+# This release does not create, replace or renew the separately installed TLS certificates.
+[execution]
+enabled = true
+playbooks = [
+ "checkmk_install_agent",
+ "checkmk_update_scripts_config",
+ "checkmk_read_windows_config",
+ "checkmk_cleanup_scripts",
+ "debug_test_connection",
+ "debug_show_disk_usage",
+ "debug_detect_host_roles",
+ "maintenance_export_event_logs",
+ "maintenance_start_stopped_services",
+ "maintenance_patch_os",
+ "maintenance_reboot_hosts",
+ "sophos_apply_baseline",
+ "sophos_apply_customer",
+ "pfsense_apply_baseline",
+]
+max_hosts = 25
+timeout_seconds = 1800
+require_approval = false
+# WebGUI policy only; core addons.execution_enabled is a separate operator opt-in.
+# Operator attestation for this deployment profile: backend CA trust has been established.
+transport_verified = true
+window_start_hour = 0
+window_end_hour = 24
+
+[credentials]
+enabled = true
+
+# Retained public metadata, not raw Ansible stdout/stderr. Deleted with the job.
+[journal]
+max_events = 20000
+max_bytes = 8388608
+
+[reports]
+max_bytes = 16777216
+# Expanded configuration content retention requires explicit operator opt-in.
+retain_configuration = false
diff --git a/scripts/addons/webgui/docs/API.md b/scripts/addons/webgui/docs/API.md
new file mode 100644
index 0000000..b3e2e48
--- /dev/null
+++ b/scripts/addons/webgui/docs/API.md
@@ -0,0 +1,31 @@
+# WebGUI HTTP APIv2 - Core3.3 / schema5 candidate
+
+Core service/wire/event remains1.0 and is reached through fixed aimctl transport. WebGUI HTTPv2 is a separate authenticated API. All existing review, one-run, credential, grant, plan/retry/delete routes remain; unsafe requests require existing CSRF and same-origin policy. No arbitrary core-command/actor/path endpoint.
+
+## Existing workflows
+
+POST /api/v2/preflight takes customer,playbook,explicit targets,overrides,check,key_mode and returns private expiring review_id and normalized plan. result_contract is now preserved. POST /api/v2/runs takes review_id,confirm plus Idempotency-Key; no saved plan is required. Optional one-shot UTC scheduled_at remains unchanged. POST /api/v2/plans has an optional unique account-local name, never an upsert. POST /api/v2/runs/{id}/retry is a new requester-owned reviewed attempt; running jobs cannot be deleted.
+
+Credential POST /api/v2/runs/{id}/credentials and existing jobs alias remain8KiB, requester-only reserved-window input; private FD downstream; no credential response echo. GET credential-status does not extend reservations or prove correctness. Modal and full-page fallback remain unchanged.
+
+## New retained-evidence reads
+
+All routes use owner-or-admin job authorization. Missing/inaccessible jobs are404, not aggregate leaks.
+
+| GET | Meaning |
+|---|---|
+| /api/v2/runs/{id}/progress | Tail snapshot/checkpoint, default200 records. after/before mutually exclusive, nonnegative committed local cursors; limit1..500. |
+| /api/v2/runs/{id}/progress/stream?after=N | Replay then follow committed public metadata through SSE; Last-Event-ID overrides initial after on reconnect. |
+| /api/v2/runs/{id}/console | Compatibility alias to the durable progress SSE, not raw console output. |
+| /jobs/{id}/progress?before=N | Authorized server-rendered pagination/no-JavaScript timeline. |
+| /api/v2/runs/{id}/reports | Report header/availability/retention index; no report bodies. |
+| /api/v2/runs/{id}/report?host=HOST | One retained slot, schema/availability/mode/time/retention and bounded data. Empty host selects global scope where applicable. |
+| /jobs/{id}/reports?host=HOST&field=FIELD&page=N | Schema-aware HTML summary,50-row collection paging and lazy JSON. |
+
+A journal response includes job/status/terminal, available,cursor,next_cursor,first_cursor,events,checkpoint,has_more,has_older,omitted_events,dropped_events,capture_state and capture_interrupted. Each event record has local cursor,receipt time,allowlisted Core event and derived display text. Text is rendered on reads, never stored as a console transcript.
+
+SSE events: snapshot (committed metadata), line (record and legacy text), gap (retention omission), end (terminal or revoked access). Only durable records carry data cursors. Transport keepalive comments are not task activity. Final event and final response are one run, not duplicate reports. Final authoritative target facts remain in /api/v2/runs/{id}; operation_result there is a compact availability/retention projection, with bodies only in the separate report route.
+
+Report states: available,missing,withheld,invalid,not_started,indeterminate. Local retention: retained,metadata_only,unavailable,not_retained_limit. Do not conflate these fields with execution success. Checkmk configuration defaults to metadata_only even with Core status available. Reports are finalization-only.
+
+No browser read calls prepare/execute, opens the credential channel, probes hosts or imports terminal Core history. Deletion cascades journal/reports while audit keeps its compact event. Old jobs without evidence show unavailable; cursor replay cannot recover nonretained/uncaptured data. All evidence uses Cache-Control:no-store and is escaped for display.
diff --git a/scripts/addons/webgui/docs/ARCHITECTURE.md b/scripts/addons/webgui/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..a72e916
--- /dev/null
+++ b/scripts/addons/webgui/docs/ARCHITECTURE.md
@@ -0,0 +1,17 @@
+# Architecture - 2.1.0rc9
+
+Core3.3.0rc8 owns current inventory, normalized requests/revisions, native execution, credential handling, safe progress, per-target outcomes and declared final report validation. Its API remains1.0. WebGUI owns authentication/authorization, reviewed workflows, UI and its own retained history.
+
+Browser -> HTTP/queue (aim-web) -> fixed private executor socket -> executor account -> aimctl under same UID -> native Ansible. No new daemon, privilege or Core import.
+
+The reviewed plan now includes a validated result_contract. Public response parsing has separate large-result limits and strict JSON parsing; secret transport stays unchanged. Core's final result event is progress only. The final response is projected against the reviewed schema/scope/targets/mode and is the sole report-persistence input.
+
+During execution Capture.submit projects metadata into a bounded queue. A writer thread batches durable journal rows and a latest-observation checkpoint into SQLite transactions. HTTP reads/replay query committed rows and never connect to the process console. Browser latency cannot fill the Core credential pipe or control capture. Capture loss/limits are recorded distinctly from execution outcomes.
+
+Schema5 adds job_progress_events,job_progress_state,job_operation_results and job_operation_reports; all reference jobs with ON DELETE CASCADE. Core_result keeps its small authoritative status/targets and a compact report availability summary. Analytics need not decode report bodies. Report reads lazily select one slot. Histories still use only retained WebGUI jobs, with owner/admin authorization before queries and aggregation.
+
+Core reports are independent of progress. Schema-aware views render typed structured facts and a generic safe JSON alternative. Unknown supported schemas have no dynamic code/$ref/network loading. Report availability, retention and execution verdict remain separate. No global reporting operation ships with Core; generic global support is fixture-qualified only.
+
+Journal/report storage uses the existing private web DB, rollback journaling and synchronous FULL. It is not an immutable or tamper-proof audit store. Bounded metadata queues plus250ms transaction busy limits separate capture pressure from task execution; persistent DB failure remains a service failure and cannot be hidden as success. Job-linked deletion is not backup/physical erasure.
+
+The existing read-only Explorer/Activity/Insights remain public Core reads or authorized SQL reads. They do not modify current inventory, contact hosts or include terminal history. Host report references explicitly label date/mode; successful Ansible outcomes are not live health/compliance.
diff --git a/scripts/addons/webgui/docs/CONTROLLER-PILOT.md b/scripts/addons/webgui/docs/CONTROLLER-PILOT.md
new file mode 100644
index 0000000..97e9717
--- /dev/null
+++ b/scripts/addons/webgui/docs/CONTROLLER-PILOT.md
@@ -0,0 +1,37 @@
+# Controller acceptance - WebGUI2.1.0rc9 / Core3.3.0rc8
+
+**Controller prerequisite:** Core rc8 requires `ansible.windows >=3.8.0,<4.0.0` for Windows playbooks. Confirm it in the canonical collection path under the executor identity; WebGUI does not install collections.
+
+Not a record of already performed managed-host tests. Use Core's SANITY.md and approved disposable targets. Preserve the actual execution UID/groups/HOME/sandbox and both staging exceptions; do not use root-shell success to certify the service.
+
+1. Quiesce/backup, deploy Core independently, deploy new WebGUI preserving schema5 (or migrate older schema4), verify versions/permissions and installed executor preflight. Check terminal AIM remains independent. Confirm old prepared jobs did not replay and old history remains.
+2. Run role detection and disk usage on a small approved scope. Verify prepared report declaration/schema, existing Unlock modal, final native facts and report payloads against authorized native observation. Verify unavailable/null/false values are distinct.
+3. During a longer safe run, close the page, reopen/reload, use another authorized device and inspect the same committed timeline. Check interleaved host/task IDs, last activity and bounded internal scrolling. Manual upward scrolling stops following. No invented ETA/completion percentage.
+4. Check mixed targets (one unreachable) and separate report availability. Test a controlled required-report fixture failing validation after native exit0: job stays failed/result_validation, native targets remain visible and no automatic retry occurs. Fixture/source changes require fresh review.
+5. Verify all nine report types in separately approved scope. Patching/reboot/service-start/export/delete operations require dedicated test conditions, not a casual UI smoke. Reports change task totals; compare intended effects, outcomes and schemas, not old exact task counts.
+6. Verify default parsed Checkmk config retention excludes sections while showing file/redaction metadata. Full content opt-in is deliberate. Check report copying/rendering and narrow mobile/short landscape; values never become HTML or automatic links.
+7. Revoke session/access while streaming, check another owner's job is unavailable, and confirm admin access does not mean submitting someone else's credentials. Verify journal/report deletion cascades and Host Activity contributions disappear without recreating data from audit.
+8. In a disposable run, restart a worker/executor and confirm prior recorded metadata survives but remote execution is not automatically resumed/replayed; uncommitted metadata or missing final outcome is disclosed.
+9. Test retention with synthetic high-volume data, not unbounded production playbooks. Omissions must be visible; final Core facts remain independent. Monitor DB growth/backups and storage pressure.
+10. Test matched schema/Core rollback with historical DB warning and sessions revoked. An incompatible restored adapter must remain stopped/disabled until independent Core rollback and explicit service enablement.
+
+Record actual versions, UID/group context, platform, report/mode and final result. Source/browser fixtures or success on one host do not certify all9 operations, other operating systems, physical keyboards, global reports or a different security profile.
+
+## Core rc8 patch-wave and native-baseline acceptance
+
+After the low-risk reporting checks above, use Core3.3.0rc8's current SANITY.md on
+separately approved disposable Windows update/reboot targets. Confirm the new catalog
+controls, false continuation hint, omitted inherited values and explicit true/false
+review. A reboot flag must not auto-enable continuation.
+
+Verify a successful patch-triggered reboot can end with continuation_required=true,
+remaining_updates_known=false and no next-wave list or auto-submitted job. Verify an
+explicit continuation run uses the documented Core cycle bound. Test the final
+read-only pending discovery path, deferred reboot, pre-existing-reboot block and a
+bounded per-update failure. Distinguish unsigned/hex HRESULT plus reason/message from
+the independent pending-reboot observation. Old reports without wave fields should
+remain viewable and labeled as historical; no unknown value becomes false/zero.
+
+Do not equate a Windows test with Linux or every supported Windows release. Preserve
+native result-validation failures, partial host outcomes, journal/report retention,
+modal deadlines and global SSH trust. This release does not change their policies.
diff --git a/scripts/addons/webgui/docs/CORE-3.1-REVIEW.md b/scripts/addons/webgui/docs/CORE-3.1-REVIEW.md
new file mode 100644
index 0000000..2dfc57c
--- /dev/null
+++ b/scripts/addons/webgui/docs/CORE-3.1-REVIEW.md
@@ -0,0 +1,90 @@
+> Historical reference retained from the preceding release. Current deployment/contracts and evidence are in DEPLOYMENT.md, REPORTS.md, JOURNAL.md and VERIFICATION.md. Do not treat old limitations or tests as current qualification.
+
+> Historical core review. Current2.1 behavior is documented in README, API and READ-ONLY-EXPERIENCE; older capability limits below are not current release claims.
+
+# Review of the supplied AIM 3.1.0 contract
+
+Reviewed archive: AIM-Ansible-3.1.0.zip
+SHA-256: `6005cab58875cfd6eabfd18e9b388c219a02d5b0472ba50a94c78abafb637ae9` (matched the supplied checksum).
+This is source-based analysis, not live-controller verification. The core archive
+and extracted baseline are not changed by WebGUI development.
+
+## Sources reviewed
+
+Paths below are relative to aim-core-3.1.0 in the uploaded ZIP:
+
+| Source | Load-bearing contract/findings |
+|---|---|
+| ADDON_AGENTS.md | Authoritative same-UID, independent client boundary; no private imports, argument interception or key export |
+| scripts/docs/ADDON_API.md | Public operations, RunRequest, credentials-fd framing, event/result schema, limits |
+| scripts/docs/ADDON_SUPPORT.md and addon-support-v1.json | Implemented scope versus unsupported Custom, raw output and mutable APIs |
+| scripts/docs/RELEASE_HANDOFF.md | Core 3.1/API 1.0 stability; controller results do NOT establish noninteractive execution or non-root SSH qualification |
+| scripts/docs/RC19_HANDOFF.md | Historical design intent only; newer support contract takes precedence |
+| scripts/docs/LOCAL_VALIDATION.md and CONTROLLER_ACCEPTANCE.md | Core acceptance procedure and evidence boundaries |
+| deploy/README.md and scripts/aim.yml | Independently installed aim/aimctl; operator-owned addon execution opt-in and runtime settings |
+| scripts/src/aim/services/v1/models.py | Strict RunRequest; unknown fields rejected; explicit hosts; no credential or arbitrary path/actor fields |
+| scripts/src/aim/services/v1/service.py | prepare/readiness/execute, key 0600 calling-UID policy, authoritative revisions, native inventory precedence, safe events |
+| scripts/src/aim/ctl.py | One request JSONL line; separate inherited pipe/socket secret channel; final response required |
+| scripts/src/aim/runtime/ansible.py and process.py | Native CLI discovery/execution, core-owned environment and cancellation |
+| scripts/src/aim/locking.py | Stable customer .aim.lock, shared terminal/service advisory locking |
+| scripts/src/aim/inventory and playbooks modules | Checked only to understand documented output; never imported by the add-on |
+
+## Decisions derived from these sources
+
+1. Replace RC18Adapter, external_presentation interception and the private Ansible
+credential strategy entirely with a bounded aimctl client. Core's private signatures
+are not the supported extension interface.
+2. `service_user` remains the remote account/key basename. `runtime.private_key_owner`
+controls new-key ownership, not automatic local UID switching. An explicitly
+pre-started add-on executor runs as the actual authorized key-owning UID. This is
+add-on infrastructure, not a claimed native core broker.
+3. prepare returns a core-owned revision and credential requirements. Save/queue/
+dispatch revalidate through prepare, not local inventory scans/hashes. No stat-only
+shortcut for protected sources. Key mode customer needs key access during prepare.
+4. Core 1.0 supports customer/catalog reads but no inventory mutations, Custom forced
+authentication or password-only verification. Native password defaults retain
+inventory precedence. The old Custom UI is removed rather than silently reinterpreted.
+5. list_hosts exposes name/address/platforms, not recursive inventory group paths.
+Bulk selection is retained for available platform groups only. This is a temporary
+feature-parity loss, not a reason to import inventory internals.
+6. Global catalog is a union of customer-scoped available catalogs; unavailable
+customer-specific playbooks are not offered. Catalog inputs remain core-typed.
+7. Events contain structured fixed statuses/counters, not raw task names/messages.
+The old raw live console becomes Execution progress. RunResult controls success;
+zero exit without final counters is not treated as successful.
+8. Optional core fields are ignored safely. The tested product is 3.1.0, service/
+wire/event1.0. Future core products need qualification even if API 1.x is stable.
+9. Core's public `readiness` performs local runtime/collection checks. It is not a
+remote connection test or guarantee that native task execution will succeed.
+10. There is no public per-browser-user actor authorization. WebGUI maintains its
+own account/grant/approval checks, but the executor UID is a trusted controller actor
+with the core permissions of that OS identity, not an isolated tenant.
+
+## Explicit changes from old WebGUI
+
+- Removed imports of aim.config, managers, inventory readers and old command hooks.
+- Removed TextVaultSecret/VaultSecret API adaptation, custom Ansible strategies,
+canonical-key-export sudo bridge, job-private canonical-key copies and elevated
+CAP_SETUID/CAP_SETGID worker design.
+- Ansible/version/collection interpretation now belongs to core. `/usr/bin` is no
+longer an add-on execution.ansible_bin setting.
+- Added a reviewed one-run path; saving is optional. Name collisions return 409.
+- New HTTP v2 and schema 4, independently of core API 1.0. Old APIs are not silently
+accepted with changed semantics.
+
+## Useful requests for a future core release (NOT implemented here)
+
+Additive HostSummary group paths would restore subgroup selection. Safe per-host
+result identifiers and explicitly bounded diagnostic categories could improve
+progress without raw logs. A separately designed forced Custom authentication mode
+would need to define native precedence, key/agent/control-socket fallback and
+approval semantics. None should be recreated in the add-on using private APIs.
+
+## Qualification status
+
+The tests use the real uploaded-core machine interface. Native Ansible is absent
+in this development container: controlled fake native commands verify protocol,
+result and worker integration only. Real SSH/WinRM, encrypted-key behavior, actual
+systemd sandbox deployment and reverse-proxy streaming require controller acceptance.
+See VERIFICATION.md for exact tests and limitations; no old rc9 qualification is
+claimed as qualification of this new core boundary.
diff --git a/scripts/addons/webgui/docs/CORE-3.2.1RC1-REVIEW.md b/scripts/addons/webgui/docs/CORE-3.2.1RC1-REVIEW.md
new file mode 100644
index 0000000..68c22f6
--- /dev/null
+++ b/scripts/addons/webgui/docs/CORE-3.2.1RC1-REVIEW.md
@@ -0,0 +1,20 @@
+> Historical reference retained from the preceding release. Current deployment/contracts and evidence are in DEPLOYMENT.md, REPORTS.md, JOURNAL.md and VERIFICATION.md. Do not treat old limitations or tests as current qualification.
+
+> Historical core review. Current2.1 behavior is documented in README, API and READ-ONLY-EXPERIENCE; older capability limits below are not current release claims.
+
+# Review of supplied AIM 3.2.1rc1
+
+Reviewed archive checksum: `AIM-Ansible-3.2.1rc1.zip.sha256` matched the uploaded ZIP. Core is separately managed and was not modified.
+
+## Contract adopted
+
+- Product: AIM 3.2.1rc1; service/wire/event API remains 1.0 / stable_1.x.
+- Canonical Ansible Core remains 2.19.11.
+- `capabilities.execution_progress` advertises `summary` and `detail`; WebGUI opts reviewed runs into `progress_mode: detail`.
+- Detail schema `play_task_host_v1` exposes safety-filtered static play/task labels, host outcomes, fixed failure hints, retries/async polls and per-host recap. Raw stdout/stderr, module arguments/results, rendered labels, paths and variables remain unavailable.
+- `staging_check` is now a public operation and staging preflight runs before credentials and again before launch. WebGUI treats staging errors as executor deployment/access failures rather than credential failures.
+- Core 3.2.1rc1 requires a private writable controller staging directory in the executor sandbox. The release-managed executor profile provisions `/var/lib/aim-web-executor/.ansible/tmp`, adds only that path to the writable sandbox set, sets the matching executor HOME, and runs `core-staging-check` as `ExecStartPre`.
+
+## UI mapping
+
+WebGUI renders the detail stream into an Ansible-like live view (`PLAY`, `TASK`, host status, fixed hints, recap) but does not claim it is raw Ansible output. The stream remains memory-only and disappears after completion/navigation. Authoritative job outcome remains the Core final result/counters.
diff --git a/scripts/addons/webgui/docs/CORE-3.2.1RC2-REVIEW.md b/scripts/addons/webgui/docs/CORE-3.2.1RC2-REVIEW.md
new file mode 100644
index 0000000..ebed8c6
--- /dev/null
+++ b/scripts/addons/webgui/docs/CORE-3.2.1RC2-REVIEW.md
@@ -0,0 +1,16 @@
+> Historical reference retained from the preceding release. Current deployment/contracts and evidence are in DEPLOYMENT.md, REPORTS.md, JOURNAL.md and VERIFICATION.md. Do not treat old limitations or tests as current qualification.
+
+# Review of supplied AIM 3.2.1rc2
+
+The uploaded SHA-256 sidecar matched the supplied Core archive. AIM Core remains separately installed and managed; this add-on does not modify it.
+
+## Public additions consumed by WebGUI rc7
+
+- `inventory_hierarchy_v1`: read-only customer/group/subgroup tree with direct hosts only per node. WebGUI uses Core paths for presentation and expands parent selection from Core-declared descendants, then submits explicit hosts through normal prepare/review.
+- `target_outcome_summary_v1`: authoritative final requested-target accounting from native Ansible host stats. WebGUI does not infer final host success from task events.
+- `native_defaults_preflight_v2`: separately covers process/config-home staging and passwd/NSS-home local-connection staging. WebGUI rc7 retains the rc6 release-managed writable paths and hardened systemd sandbox.
+- Service/wire/event API remains 1.0; detailed progress remains `play_task_host_v1`; raw module output remains unavailable.
+
+## Presentation boundary
+
+Core overall execution status remains authoritative. For a mixed result such as 24 successful targets and one unreachable target, Core may correctly return `failed`; WebGUI presents the job as `Partially succeeded` while showing the original Core status/exit code and the per-target facts.
diff --git a/scripts/addons/webgui/docs/CORE-3.3-REVIEW.md b/scripts/addons/webgui/docs/CORE-3.3-REVIEW.md
new file mode 100644
index 0000000..4565d10
--- /dev/null
+++ b/scripts/addons/webgui/docs/CORE-3.3-REVIEW.md
@@ -0,0 +1,15 @@
+> Historical integration basis for WebGUI 2.1.0rc3. For this candidate see [Core rc3 review](CORE-3.3.0RC3-REVIEW.md); historical test counts below are not new acceptance.
+
+# Core 3.3.0rc1 integration basis
+
+The separately supplied archive and checksum were verified. The197 source/documentation files are unchanged. Authoritative handoff material is Core ADDON_AGENTS.md and scripts/docs/{ADDON_API,ADDON_SUPPORT,OPERATION_RESULTS,DETAILED_PROGRESS,TARGET_OUTCOMES,INVENTORY_HIERARCHY,EXECUTOR_STAGING,VALIDATION,SANITY,RELEASE_HANDOFF}. The supplied ten standalone documents matched their archived versions during review.
+
+Core adds operation_results capability, null or schema-resolved catalog result declarations, PreparedRun.result_contract and final RunResult.operation_result. The publisher is aim_output_v1; public wrapper aim_operation_result_v1. The final event and final response are one result. Data arrives at finalization only. Existing API/event1.0, target accounting, hierarchy and dual-home preflight remain.
+
+WebGUI consumes only the public process boundary. Its consumer independently validates the published schema subset, declaration/review association, report data and response budgets. It never loads playbook schemas from the controller filesystem. Core source fixtures and schema files are used only in disposable release tests. All9 bundled schemas have registered presentation metadata and generic bounded data rendering. No Core source/config/Ansible runtime change is made by the add-on.
+
+A native exit0 with required output absent/invalid is a result_validation failure. Per-target native success remains distinct, and a native failed run can have useful available reports. Missing response never becomes success from an earlier event. Payload data.complete can have a separate meaning from report-slot complete. Config sections remain an explicit retention opt-in because safe filtering is not a universal secret scanner.
+
+The operation payload can total16MiB, with1MiB slot bounds. Core private native callback chunks do not imply chunked public JSONL. WebGUI therefore increased only public response frame/line/stream budgets and kept request/private-secret limits unchanged. Reports are stored separately from compact final job facts. Progress journaling is add-on-owned and not a new Core history collector.
+
+Core's188 recorded upstream local tests are not WebGUI test results. Native Ansible2.19.11, Windows/Linux publishers, global publishers and hardened controller behavior remain acceptance gates. See this release's VERIFICATION.md for observed tests and limitations.
diff --git a/scripts/addons/webgui/docs/CORE-3.3.0RC3-REVIEW.md b/scripts/addons/webgui/docs/CORE-3.3.0RC3-REVIEW.md
new file mode 100644
index 0000000..1fccb2a
--- /dev/null
+++ b/scripts/addons/webgui/docs/CORE-3.3.0RC3-REVIEW.md
@@ -0,0 +1,84 @@
+# Core 3.3.0rc3 integration review
+
+This is a source-based review of the user-supplied Core archive, not a native Windows
+Update qualification. AIM Core remains separately deployed and unmodified.
+
+## Source basis
+
+Authoritative Core topics: `ADDON_AGENTS.md`, `AGENTS.md`,
+`scripts/docs/RELEASE_NOTES.md`, `RELEASE_HANDOFF.md`, `ADDON_SUPPORT.md`, `ADDON_API.md`,
+`OPERATION_RESULTS.md`, `PLAYBOOKS.md`, `VALIDATION.md`, `SANITY.md`,
+`scripts/docs/INSTALLATION.md`, and `deploy/README.md`.
+
+The supplied Core ZIP contains 200 files under `aim-core-3.3.0rc3/`. Its sidecar and
+ZIP integrity were checked before extraction. Archive hashes and unchanged-source
+comparison are in this release's verification record.
+
+Compared with the supplied Core 3.3.0rc1 (the actual previous WebGUI integration),
+there are three added files: two Windows patch-cycle tasks and the Core installation
+guide. No files were removed in that comparison. The source-version/metadata,
+patch runbook/filter/catalog/schema and documentation changed. The semantic catalog
+difference is confined to `maintenance_patch_os`; eight other reporting schemas are
+byte-identical. Core's runtime, credentials, service transport, target-outcome,
+hierarchy and staging implementations are unchanged in this comparison. This is not
+an assertion that the new patch runbook was executed successfully here.
+
+## Existing public boundaries retained
+
+Service/wire/event API remains 1.0. Reports still use `aim_output_v1` publication and
+`aim_operation_result_v1` final results, with `result_contract` captured at review.
+Reports arrive at finalization, not as raw debug/stdout. Detailed progress remains
+`play_task_host_v1`; native target outcomes and the two-home staging preflight remain.
+No new execute-request field, credential channel, service or filesystem access is needed.
+
+## New rc2/rc3 patch metadata consumed
+
+Core's catalog provides reboot delay (minutes, 0..1440), reboot message and the
+Windows-only `os_patching_rescan_after_reboot` boolean. Its catalog hint is `false`.
+The WebGUI obtains these options and constraints from public discovery, not a
+parallel defaults table. Unchanged controls are omitted to preserve inventory/role
+precedence; a catalog hint is not a resolved effective inventory setting.
+
+Windows now discovers a deterministic queue, installs one discovered update per
+native invocation and stops a wave at a reboot boundary. With continuation disabled,
+an AIM-performed reboot can end a successful run with `continuation_required: true`
+and `remaining_updates_known: false`. An explicitly reviewed true continuation value
+allows Core to rediscover after reboot, bounded to 12 cycles. This is within one Core
+operation, not permission for the add-on to create more jobs.
+
+A wave finishing without reboot can perform a final read-only search; this does not
+extend the approved installation queue. When `remaining_updates_known` is true,
+`pending` is the recorded final discovery only. When false, the WebGUI must not show
+a pre-reboot queue or zero-length list as the established next-wave state.
+
+The patch report adds pre/post reboot observations, delay, deferred state and a stop
+reason (introduced in Core rc2). Windows adds optional continuation, cycle and
+remaining-update-knowledge fields in rc3. Individual failed updates carry Core's
+unsigned/hex HRESULT, reason and bounded safe message. `install_not_allowed` is not
+proof of a pending reboot; `preexisting_reboot_required` is an independent Core
+preflight observation. The UI displays provided codes; it does not parse fatal text,
+Windows logs or native error messages.
+
+## Consumer changes made
+
+- Qualify exact Core 3.3.0rc3 in adapter/executor/CLI/deployment metadata, while keeping
+ live capability negotiation and version rejection intact.
+- Surface all catalog-driven patch controls and platform hints, with a review note
+ separating reboot from continuation and preserving explicit true/false vs inherited.
+- Add a report presentation model for continuation, deferred reboot, pre-existing
+ reboot and bounded-cycle stop conditions. This never changes the job/Core verdict.
+- Present remaining updates according to the new knowledge flag; keep raw *structured
+ report JSON* available separately, not unrestricted process output.
+- Preserve historical schemas and data. The same `patch_summary_v1` identifier has a
+ larger closed shape now: newly added reboot fields and failure `message`/hex fields
+ are required where declared. Old jobs render with their recorded schema, never
+ revalidated against today's catalog or backfilled with invented false values.
+- Retain journal, reports, modal, owner/admin visibility and existing retention limits.
+
+## Qualification boundary
+
+Core's current VALIDATION.md records static/filter/schema and disposable deployment
+checks, not native Ansible 2.19.11/Windows Update/service-sandbox acceptance of rc3.
+WebGUI test outcomes are recorded independently in VERIFICATION.md. Source tests and
+synthetic report fixtures do not certify Windows servicing ordering, reboots, pending
+update state, Server 2012 R2, or every delegated service environment.
diff --git a/scripts/addons/webgui/docs/CORE-3.3.0RC8-REVIEW.md b/scripts/addons/webgui/docs/CORE-3.3.0RC8-REVIEW.md
new file mode 100644
index 0000000..2a64de5
--- /dev/null
+++ b/scripts/addons/webgui/docs/CORE-3.3.0RC8-REVIEW.md
@@ -0,0 +1,50 @@
+# Core 3.3.0rc8 integration review
+
+This is a source/package compatibility review of the user-supplied AIM Core 3.3.0rc8
+archive against AIM WebGUI 2.1.0rc9. Core remains separately deployed and unmodified.
+
+## Public contract
+
+Core remains service/wire/event API 1.0 with the existing detail-progress,
+inventory-hierarchy, target-outcome and `aim_operation_result_v1` contracts. The nine
+shipped report schemas remain catalog-driven. WebGUI therefore does not add a private
+adapter or parse playbook output.
+
+The release changes the required native Windows collection baseline. Live Core
+capabilities now advertise:
+
+```text
+ansible.windows >=3.8.0,<4.0.0
+```
+
+WebGUI 2.1.0rc9 requires that capability declaration. The actual collection remains
+Core/operator managed and must be visible to the execution identity in the canonical
+Ansible collection path. Core readiness remains authoritative for the installed runtime.
+
+## Report deltas
+
+`patch_summary_v1` is additive: rc8 adds optional `reboot_reasons_before`, a bounded
+array of `{source, description}` observations from `ansible.windows.win_reboot_info`.
+Existing required fields and the established patch continuation/reboot semantics remain.
+Historical reports continue to validate against their recorded result contracts.
+
+`filesystem_usage_v1` remains schema-compatible, but Windows semantics now describe
+attached local storage volumes through `community.windows.win_disk_facts`; mapped/network
+drives are intentionally excluded. WebGUI updates its explanatory note accordingly.
+
+Core's Checkmk script placement and Windows ACL hardening are runbook behavior, not a new
+add-on API. WebGUI does not reconstruct those paths, permissions or deployment rules from
+private source; it renders final structured reports supplied by Core.
+
+## Patch behavior retained
+
+The rc4 patch-wave model remains: one native `win_updates` wave per selected categories,
+AIM-owned reviewed reboot message/delay, explicit opt-in for post-reboot continuation,
+no automatic replay, and `remaining_updates_known` governing whether a final pending list
+is authoritative. `install_not_allowed` alone is not treated as proof of a reboot need.
+
+## Qualification boundary
+
+This review does not certify native Windows Update, Checkmk ACL changes, collection
+installation, WinRM/SSH, or the production systemd sandbox. Controller acceptance must
+use Core rc8's current SANITY/VALIDATION guidance under the actual executor identity.
diff --git a/scripts/addons/webgui/docs/CREDENTIALS.md b/scripts/addons/webgui/docs/CREDENTIALS.md
new file mode 100644
index 0000000..b5237d4
--- /dev/null
+++ b/scripts/addons/webgui/docs/CREDENTIALS.md
@@ -0,0 +1,66 @@
+> The behaviors below are retained from2.1.0rc2. Current candidate2.1.0rc9 targets Core3.3.0rc8/schema5 and adds reports/journal described in REPORTS.md and JOURNAL.md. Credential, read-only inventory and OS-permission boundaries here remain unchanged; old version/no-migration statements describe the earlier slice.
+
+# One-run credentials through core API 1.0
+
+Core 3.3.0rc8 is authoritative. WebGUI 2.1 does not decrypt Vault or read/export/copy keys.
+Use native inventory or explicit customer-key loading in New run. Existing usernames,
+Vault variables and host/group precedence are core/Ansible behavior.
+
+Custom forced username/password override is NOT exposed by core and was removed
+from this adapter. A supplied connection_password, when the catalog requests it, is
+a native default and may be overridden by inventory. It is not password-only testing.
+Become-password UI, key uploads, cross-job password caching and raw task output are
+not implemented. Terminal features are not automatically API features.
+
+## Where to enter a password
+
+Submit the reviewed job first. With approval disabled it queues directly; otherwise
+an independent enabled administrator approves it. The running queue worker reserves
+a slot after local core readiness. Job detail then offers Unlock this run. With JavaScript and native dialog support this opens an in-page modal; its link remains an authenticated full-page fallback. Overview and Jobs also surface your eligible reservations in Needs your attention.
+Only the requesting account may use this form. All requested fields derive from
+PreparedRun. A present customer Vault is conservatively required even when the
+catalog's require_vault flag is false. An encrypted customer key can use an explicit
+key passphrase or Core's literal vault_linux_ssh_key_passphrase lookup. The grouped Use customer Vault / Enter separately choice appears only for that alternative requirement with a Vault source. A separately required key passphrase is never made optional.
+
+A reservation without secrets lasts5minutes. After submission, hand-off/preparation/
+start must fit60seconds; execution has the separate bounded job timeout. An expired
+or failed attempt does not retain credentials for the next attempt. Scheduled jobs
+collect secrets only when their time/window/approval and worker are ready.
+
+## Transport and processing
+
+Existing verified HTTPS to NPM/backend and loopback final hop are assumed configured
+by the operator. Dedicated POST bodies, CSRF/origin/session authorization and8KiB
+body limit remain. Passwords are literal, max2048 UTF8 bytes each, no CR/LF/NUL. No
+client-side hash substitution. Browser fields clear on navigation and never enter
+HTMX history/localStorage. Raw body/error logging remains prohibited.
+
+The web identity sends private bounded local frames to its worker, then to the
+executor. The executor starts aimctl with a separate inherited secret pipe FD, not
+stdin request JSON, argv, environment or a password file. Core owns its provider,
+key agent and native credential helpers. WebGUI does not reinterpret Vault values.
+
+Python release of references is not guaranteed RAM erasure. Review swap/dumps,
+proxy request buffering and access to the service accounts. A compromised authorized
+web process may exercise the local core client authority; this is not tenant isolation.
+No automatic data/file-permission repair or credential fallback is performed.
+
+## Qualification
+
+`aim-web credential-check` now means public capability/authorized metadata checking.
+It does not test decryption or remote auth and requires no pytest in production.
+`core-check --customer ... --playbook ... --host ... --key-mode customer` additionally
+runs documented local readiness. Real SSH/WinRM acceptance remains an operator gate.
+Use the included disposable test instructions for API integration; fake native
+fixtures do not certify real Ansible or controller systemd permissions.
+
+
+## Dialog lifecycle and recovery
+
+The dialog loads only after an explicit user action. Its form lives outside status/attention polling so typing is not discarded by an HTMX replacement. It shows the frozen customer/playbook/mode and target count. Show/Hide never changes what is submitted, and pasting is not blocked. Escape/Close/Not now clear fields, including visible-password inputs, and return focus without canceling the job. Backdrop taps do not dismiss it. A small visual viewport constrains the dialog's internal scroll area rather than growing the page.
+
+Only required/supported Core fields are rendered. For the Vault/key alternative, the default sends just the Vault password; Enter separately adds a required key-passphrase field. Switching back clears that field. No source-radio name or extra scope/identity field is sent to the API. A plain HTML form with JavaScript unavailable exposes the optional separate field with its explanation and does not submit disabled source controls.
+
+The countdown uses the server deadline, not a new five-minute timer on each opening. Polling does not renew it. Submission clears live form values and disables repeat clicks. A 202 accepted response means only that the existing worker claimed the handoff. Core validation and remote authentication happen later. A lost acknowledgement must not cause automatic POST retry: the client locks the form and polls owner-only status. Worker single-claim/session/scope checks remain authoritative across tabs.
+
+The canonical POST route is `/api/v2/runs/{id}/credentials`; the existing `/api/v2/jobs/{id}/credentials` is kept as an alias. Both use the same five-attempts-per-minute requester throttle and existing private worker socket. `GET /api/v2/runs/{id}/credential-status` is non-secret status only. It never reports a password as correct, requests new work or extends the reservation.
diff --git a/scripts/addons/webgui/docs/DEPLOYMENT.md b/scripts/addons/webgui/docs/DEPLOYMENT.md
new file mode 100644
index 0000000..9492f5e
--- /dev/null
+++ b/scripts/addons/webgui/docs/DEPLOYMENT.md
@@ -0,0 +1,131 @@
+# Deployment and recovery - AIM WebGUI 2.1.0rc9
+
+Target: separately installed AIM Core **3.3.0rc8**, service/wire/event API **1.0**.
+WebGUI HTTP **v2**, SQLite **schema 5**. From WebGUI 2.1.0rc3 there is no database
+schema change or new permission/service requirement. This candidate qualifies the
+new public patch catalog/report shape; it does not upgrade Core itself.
+
+## Fresh installation
+
+Use [ADDON-INSTALLATION.md](../ADDON-INSTALLATION.md) after the independently supplied
+Core `scripts/docs/INSTALLATION.md`. `install` is for a new add-on; `update` is for an
+existing managed installation. The Core and WebGUI deployers have different flags.
+
+## Coordinate Core and WebGUI versions
+
+The previous WebGUI 2.1.0rc4 targets Core3.3.0rc3 exactly; it does not accept Core rc8.
+Finish active jobs, prevent new submissions and quiesce terminal writers. Cancellation
+is not rollback. Keep matching Core and add-on backups, including the workflow DB.
+
+```bash
+sudo systemctl stop aim-web-worker.service aim-web.service aim-web-executor.service
+```
+
+Deploy Core3.3.0rc8 independently with its verified source archive and its own
+
+**Controller prerequisite:** Core rc8 requires `ansible.windows >=3.8.0,<4.0.0` for Windows playbooks. Confirm it in the canonical collection path under the executor identity; WebGUI does not install collections.
+preview/apply/quiesced procedure. Do not overwrite Core settings, environments,
+inventory/Vault/keys or global SSH trust. Review patch-wave changes in Core's release
+notes and complete separately approved terminal acceptance. A UI release does not
+certify the new native Windows Update flow.
+
+Then extract this add-on into a fresh directory, not over the active source:
+
+```bash
+cd /var/tmp
+sha256sum -c AIM-WebGUI-2.1.0rc9.zip.sha256
+unzip AIM-WebGUI-2.1.0rc9.zip
+cd aim-web-2.1.0rc9
+sudo python3 deploy/deploy.py update
+```
+
+Ordinary2.x updates do not need --migrate-core. Same-version reinstall is rejected.
+The installer validates the actual manifest/required files, prepares dependencies and
+assets, checks public Core access, backs up and activates the release, writes known
+managed units and waits boundedly for socket readiness. Unknown unit drop-ins require
+operator review. Core remains unchanged by this updater.
+
+## State, review and retention
+
+Accounts, grants, plans, journals, reports and audit remain in schema5. Its five
+released SQL migrations are unchanged. An older schema4 install follows the existing
+schema5 migration (pending/queued work blocked, running work interrupted, old reviews
+removed). A schema5-to5 update does not rewrite job states: old prepared work is still
+subject to the existing source/revision revalidation and cannot silently execute a new
+Core revision. Quiesce first and use a fresh review after Core source/schema changes.
+
+Saved plans remain presets; selecting one requires a fresh review. Historical reports
+use their stored schema and are not revalidated with the new enlarged patch schema.
+There is no backfill, terminal-history collection or automatic retry/continuation.
+
+The full webgui.toml remains release-managed and overwritten with the approved site
+profile (execution enabled, existing14-playbook allowlist, no independent approval,
+credential transport attestation). Keep the independently configured Core opt-in.
+Retention defaults remain:
+
+```toml
+[journal]
+max_events = 20000
+max_bytes = 8388608
+[reports]
+max_bytes = 16777216
+retain_configuration = false
+```
+
+Custom local TOML edits must be incorporated into the reviewed release profile to
+survive later replacements. Job deletion removes its linked evidence; protected
+backups and physical-storage erasure have separate operator policies.
+
+## Verify in the real service context
+
+```bash
+aim-web --version
+aim-web config-check
+sudo systemctl status aim-web-executor.service aim-web.service aim-web-worker.service --no-pager
+sudo -u aim-web aim-web core-check
+sudo journalctl -u aim-web-executor.service -n 60 --no-pager
+```
+
+Expected: AIM WebGUI2.1.0rc9 / Core3.3.0rc8. ExecStartPre remains the authoritative
+staging probe inside the executor's actual sandbox. A plain sudo -u shell lacks its
+unit-scoped aim-web group and is not an equivalent preflight. Do not loosen config or
+staging permissions to make the interactive wrapper pass.
+
+The generated unit contract is unchanged: normal executor primary group plus aim-web
+supplementary group; web state0700; config root:aim-web0640; executor state and both
+staging paths0700; runtime directory executor0711; socket executor:aim-web0660;
+NoNewPrivileges, empty capabilities, ProtectSystem=strict, ProtectHome=read-only with
+only exact staging write exceptions. No new ports/services/writable paths are needed.
+
+Start with a safe read-only reporting job and reload its recorded evidence. New native
+patching/reboot behavior needs Core's disposable-target acceptance, not a production
+installation used as a browser smoke test. See [PATCH-WAVES.md](PATCH-WAVES.md).
+
+## Recovery and rollback
+
+Record the printed /var/backups/aim-web checkpoint. After an interrupted deployment,
+investigate pending.json and use the same release deployer's recover command; do not
+manually retarget current symlinks.
+
+```bash
+sudo python3 deploy/deploy.py recover
+sudo python3 deploy/deploy.py rollback --backup CHECKPOINT
+```
+
+The commands above are alternative recovery actions, not a sequence to run blindly.
+An rc5->rc4 add-on rollback stays on schema5, but the older adapter still requires its
+matched Core version. The deployer tests restored compatibility; an incompatible
+restoration remains stopped/disabled until the operator separately restores the
+matching Core and deliberately enables the units.
+
+Crossing schema5->4 requires --restore-auth-db and an explicit historical database
+restore. That can reinstate older password hashes and discard later users/jobs/plans/
+journals/reports; restored sessions are revoked. Back up current data first. Neither
+product rollback reverses remote installations/reboots or unrelated operator changes.
+
+## Offline assets
+
+This is not a full offline dependency bundle. Python and production browser pins are
+unchanged (Bootstrap5.3.8 and HTMX2.0.10 with SHA384 checks). Existing matching assets
+can be reused. --wheelhouse and --assets-dir accept independently prepared exact
+artifacts. No development/test dependency is installed into either product environment.
diff --git a/scripts/addons/webgui/docs/EXECUTION.md b/scripts/addons/webgui/docs/EXECUTION.md
new file mode 100644
index 0000000..e1e026b
--- /dev/null
+++ b/scripts/addons/webgui/docs/EXECUTION.md
@@ -0,0 +1,21 @@
+# Reviewed execution - 2.1.0rc9
+
+AIM Core3.3.0rc8 builds native commands and owns validation, credential preparation, runtime discovery, customer locking, cancellation and authoritative results. WebGUI uses only its public aimctl API1.0 and the existing non-root executor.
+
+A new run needs review, not a saved plan. Select exact customer/playbook/hosts, declared typed options, check/apply and key mode. Review normalized scope, warnings, credentials and the **declared result contract**. Core source/schema changes invalidate earlier reviews; the worker never silently rebuilds and executes stale scope. Saving remains optional with collision-safe default titles.
+
+WebGUI execution policy, allowlist, grants, host limits and optional approval apply independently of Core's add-on opt-in. The executor's OS access is not proof of the browser requester's authority. Native inventory precedence is unchanged. Key mode customer requires the canonical owner-only key; key mode none is not forced password authentication.
+
+The worker performs local readiness before offering the owner-only credential modal. Its five-minute empty reservation and sixty-second post-handoff preparation/start window remain. Supplied values use the existing private channel, never job records, argv, environment or journals. The modal preserves deliberate opening, grouped alternative key source, paste/show controls, field clearing and lost-response reconciliation without an automatic second POST.
+
+## Progress and results are separate
+
+1. A bounded structured journal records approved Core metadata independently of viewers. Reopening a running or ended job replays committed events and continues after its durable cursor. IDs correlate tasks/hosts; there is no guessed task total/ETA or future task plan. Raw terminal text and module dictionaries are not retained. See JOURNAL.md.
+2. Native target outcomes and final Core status/exit remain authoritative. A final event without a final response cannot establish successful completion. A partially successful host population does not rewrite Core's overall failure.
+3. Purposeful operation reports arrive only at finalization and are separately validated against the reviewed contract. They are retained subject to explicit local limits/policy and fetched lazily. See REPORTS.md. A report can be available for a failed run. Missing/invalid required reports after native exit0 yield Core failed/result_validation while native target stats remain unchanged; this is not a password error and never triggers replay.
+
+A closed browser is not cancellation. User cancellation stops owned local process groups through Core, not already completed remote changes or independently running asynchronous work. Check mode is not an unconditional no-side-effect guarantee. Trusted playbooks can run controller-local/delegated tasks; both narrow staging exceptions stay enabled.
+
+Manual retry creates a new reviewed job and fresh credentials. Changes to mode, key handling, hosts or options are not edits to queued work. No automatic retry or recurring schedule is introduced; the existing explicitly UTC one-shot schedule remains.
+
+Only terminal jobs can be deleted. Deletion removes their lifecycle/idempotency/progress/report records and future history contributions, while keeping a compact audit deletion fact. Saved-plan deletion does not remove existing copied job intent. Protected backups are independently retained; deletion is not a promise of physical erasure. Old jobs are not rerun or reconstructed to populate missing evidence.
diff --git a/scripts/addons/webgui/docs/JOURNAL.md b/scripts/addons/webgui/docs/JOURNAL.md
new file mode 100644
index 0000000..057e61b
--- /dev/null
+++ b/scripts/addons/webgui/docs/JOURNAL.md
@@ -0,0 +1,43 @@
+# Retained execution journal - 2.1.0rc9
+
+## What is recorded
+
+The worker captures only projected public Core metadata: job/run/sequence, source UTC time and receipt time, stage, opaque play/task IDs, approved/withheld labels, reviewed logical hosts, result/changed/ignored flags, supported retry/poll information, fixed diagnostic hints and recap counters. Unknown additive payloads, legacy duplicate progress in detail mode, raw stdout/stderr, debug bodies, credential frames and final report data are not journal entries.
+
+Core labels remain withheld when Core withholds them. A missing host stays null; no inference from a nearby event. A task result can arrive interleaved with another host/task: use IDs, not last-arriving names. The result event records only that it was observed; **the final response** establishes the authoritative result and retained report.
+
+The normal final Core result, targets and existing lifecycle/audit metadata remain separate. A journal does not become a second execution engine or replay operation.
+
+## Browser-independent capture
+
+Capture starts in the execution child after review/claim checks. An open page is not required. A 2,048-entry nonblocking queue feeds batches of up to 128 events with a normal 250 ms flush target. SQLite synchronous FULL transactions publish the event rows, bounded checkpoint and durable cursor together. The writer's busy timeout is 250 ms. Closing tries to drain within 2.5 seconds and waits up to 3 seconds for its thread.
+
+Those are bounded scheduling/lock budgets, not a guarantee against slow storage or physical failure. A crash can lose queued/uncommitted events. Queue or write failures increment a loss counter on the next possible commit. Persistent storage failure or a killed writer leaves an unclosed capture, shown as interrupted when the job ends. Metadata loss does not establish a task failure or success and never triggers re-execution. If the database cannot persist the final result, the normal workflow cannot certify success from a prior event.
+
+## Retention and display windows
+
+```toml
+[journal]
+max_events = 20000
+max_bytes = 8388608
+```
+
+The newest tail is retained. Older event rows are removed in the same commit when either limit is exceeded; a visible omission count/range remains. A bounded checkpoint retains last stage/play/task, observed task-start count, up to64 recent task contexts, and up to500 latest logical-host observations. Final Core facts do not depend on journal coverage. Limits are validated in Settings; the byte budget may be64KiB..64MiB and the event budget100..200000.
+
+The initial page loads200 tail events; API pages allow1..500. The browser keeps at most2,000 displayed records, with a link to the paged timeline. Display pagination never changes retained counts. Large streams are rendered at most once per animation frame. Only the console's scrollTop changes; manual upward scrolling pauses Follow output.
+
+Job deletion cascades through the journal. No terminal/Core-wide history collection and no shadow archive. Database/backup files stay in existing private storage; deletion is not secure physical erasure or deletion of prior backups. There is no silent age-pruning policy.
+
+## Reopen, reconnect and authorization
+
+GET the snapshot/tail, then connect SSE strictly after the committed high-water cursor. Last-Event-ID takes precedence over the initial after query on reconnection. A per-job local cursor and unique(job, Core run, Core sequence) suppress duplicates. Filtered legacy events produce ordinary Core-sequence gaps, not automatic loss claims. Retention gaps are explicit.
+
+Every read uses existing owner-or-admin job authorization. Every streaming iteration rechecks live session, password-change state and job access. A cursor is not permission. Deleted/inaccessible jobs end the feed and request clearing the view. Two devices read the same committed rows and never directly attach to the executor process.
+
+When the job ends, SSE drains through the available committed cursor and ends; history remains readable. Worker/service interruption does not resume Ansible. Closing a page does not cancel a job or resend a credential.
+
+Progress means **last observed activity**, not a total task graph. There is no invented percentage, ETA, list of future tasks or proof of stall from silence. Host observations are not final per-host outcomes. The displayed time is localized while stored values remain UTC.
+
+## Historical jobs
+
+Schema migration does not fabricate progress for old jobs. They retain lifecycle and final facts but show detailed history unavailable. No terminal import or automatic rerun fills gaps. A new queued job may show waiting for capture until the execution child starts.
diff --git a/scripts/addons/webgui/docs/MIGRATION-3.1.md b/scripts/addons/webgui/docs/MIGRATION-3.1.md
new file mode 100644
index 0000000..093a946
--- /dev/null
+++ b/scripts/addons/webgui/docs/MIGRATION-3.1.md
@@ -0,0 +1,105 @@
+> Historical reference retained from the preceding release. Current deployment/contracts and evidence are in DEPLOYMENT.md, REPORTS.md, JOURNAL.md and VERIFICATION.md. Do not treat old limitations or tests as current qualification.
+
+> Historical major-migration guide for the 1.x-to-2.x architecture. An existing 2.0.0rc8 deployment uses the ordinary 2.1 update in DEPLOYMENT.md; no schema change or migration acknowledgement is needed. Current capabilities are in API.md.
+
+# Major migration: WebGUI 1.x / rc18 -> WebGUI 2.0 / AIM 3.1
+
+Read CORE-3.1-REVIEW.md first. This is an explicit major migration. Core and add-on
+are separate releases with separate backups and rollback decisions.
+
+## Before downtime
+
+1. Let active jobs finish; cancel deliberately only if their remote effects are understood.
+2. Back up the old core using its OWN deployment procedure. Back up the WebGUI's
+source/config/units/database with its managed deployment checkpoint. Do not reset
+users or remove /var/lib/aim/webgui.
+3. Deploy AIM 3.2.1rc2 independently using its included guide. Check terminal AIM first.
+Do not copy core into the old WebGUI tree or install WebGUI into core's interpreter.
+4. Choose an existing non-root execution account, normally svc_bf-ansible. It must
+already satisfy AIM required_group, read its config/inventory/Vault, own each selected
+customer key 0600, and see native collections. The add-on does not provision these
+core permissions. `runtime.private_key_owner` does not migrate old keys or switch UID.
+5. Check aimctl as that account, not merely from a root shell:
+
+```bash
+sudo -u svc_bf-ansible /usr/local/bin/aimctl --config /etc/ansible/scripts/aim.yml capabilities
+printf '%s\n' '{"api_version":"1.0","operation":"list_customers"}' | sudo -u svc_bf-ansible /usr/local/bin/aimctl --config /etc/ansible/scripts/aim.yml request
+```
+
+The core config remains operator-owned. Its actual 3.1 settings include
+`addons.execution_enabled`, `runtime.ansible_playbook` and
+`runtime.private_key_owner` (NOT the earlier proposed execution_user/runtime_group
+fields). Configure/qualify those using the core guide. The add-on never edits them.
+
+The core acquires a stable customer `.aim.lock` during execution. The executor needs
+access to that lock, or creation permission if it does not exist; a read-only customer
+directory without an accessible lock is not sufficient. Use the core/operator's
+permission procedure rather than widening the whole tree from WebGUI.
+
+## Activation
+
+Unpack the new ZIP into a fresh staging path and run:
+
+```bash
+sudo python3 deploy/deploy.py update --migrate-core
+```
+
+The explicit flag acknowledges: schema 4, stopped legacy pending/running work,
+retirement of old key-export sudo bridge and its known worker capability drop-in,
+HTTP API v2, a new executor service and release-config replacement.
+
+The installer stages an isolated WebGUI venv/assets, probes the real public core
+metadata as the selected executor account, checkpoints old files, stops add-on
+services, migrates state, replaces source/config/units, then starts executor, web
+and the opt-in queue worker. It never stops terminal AIM or writes core files.
+Unknown systemd drop-ins block installation for deliberate operator review.
+
+## What is preserved
+
+Users/password hashes/sessions, named admin onboarding, grants, audit, selections,
+all old job records/events, and all saved plan payloads. Pending/queued old-core jobs
+become blocked; running ones become interrupted. Their state is not replayed.
+Historical duplicates in saved-plan names remain separate records.
+
+Legacy saved plans are reusable INPUTS only: open one, use its New run link and
+review mode/key handling again. Old Custom credentials cannot silently become native
+inventory credentials. A new valid review is required; no credentials are migrated.
+
+## What is retired
+
+Private core imports, rc18 adapter, command rewriting, private Ansible credential
+resolver/strategy, sudo key export, CAP_SETUID/CAP_SETGID worker capability grant and
+raw console interception. Known old helper/sudoers/drop-in files are backed up and
+removed. There is no need to give aim-web canonical key read access.
+
+## Rollback
+
+Schema4 cannot be opened by old schema 3 WebGUI. Downgrading requires the explicit
+--restore-auth-db flag, with loss of changes since that snapshot and possible
+restoration of older passwords. Services and config/code/venv must match the snapshot.
+Restoring a 1.x WebGUI while AIM 3.1 remains installed is NOT a working rollback: the
+installer restores the files/database but leaves legacy services stopped/disabled.
+Coordinate an independent core rollback before enabling them. Core is never rolled
+back by this add-on. Interrupted remote operations are never undone automatically.
+
+## Unsupported old behavior
+
+There is no Custom forced credential override or password-only verification in
+core API 1.0. No raw task/host output. Platform-group selection remains; recursive
+subgroup paths are unavailable. These limitations are shown in the interface and
+are recorded for an additive core extension, not bypassed through private access.
+
+## Executor HOME and host trust
+
+The release profile sets `[core] home = "/var/lib/aim-web-executor"`. aimctl and
+native commands use that separate writable HOME, not /root or WebGUI's private
+SQLite home. The executor remains the configured existing UID; no identity is
+changed by HOME. Install independently verified SSH host records under
+the effective SSH UserKnownHostsFile, owned by the execution account, or the already-reviewed system /etc/ssh/ssh_known_hosts policy. Process HOME alone does not select OpenSSH's known_hosts path. Do not blindly
+trust ssh-keyscan output. Existing per-user collections under the old home are not
+automatically copied: use the core's documented runtime.ansible_collections_path
+configuration if required. System collections remain native-core-discovered.
+
+The shipped systemd profile targets the documented /etc/ansible inventory layout.
+Nonstandard inventory roots require a separately reviewed ReadWritePaths service
+profile for core locks; this installer does not inspect private config to infer it.
diff --git a/scripts/addons/webgui/docs/PATCH-WAVES.md b/scripts/addons/webgui/docs/PATCH-WAVES.md
new file mode 100644
index 0000000..68ce481
--- /dev/null
+++ b/scripts/addons/webgui/docs/PATCH-WAVES.md
@@ -0,0 +1,72 @@
+# Patch-wave options and reports - WebGUI 2.1.0rc9 / Core 3.3.0rc8
+
+This guide describes add-on presentation of the Core-provided patch contract. It does
+not grant approval to install updates, reboot targets or perform repeated runs.
+
+## Prepare and review
+
+New run uses the public catalog for optional inputs. The Windows-only **Continue
+patching after reboot** control advertises the live catalog hint (false in this Core
+release). It starts as **Inherit**, not an explicit true override. Blank inputs stay
+omitted; inventory/role defaults are authoritative. To explicitly forbid continuation
+in a particular run, choose false and review the normalized override.
+
+**Reboot when required**, **Reboot delay (minutes)** and **Reboot message** are separate
+catalog controls. Enabling reboot must not implicitly enable continuation. Delay and
+message apply only to an AIM-initiated reboot; the message is not part of the report.
+The review highlights explicit vs inherited reboot, delay and continuation values.
+Core still revalidates scope/options/schema revision before execution.
+
+On Windows, each Core patch wave delegates the reviewed update categories to one native
+`ansible.windows.win_updates` install invocation with module reboot disabled. Windows
+Update and the collection own ordering inside that wave; AIM evaluates its reviewed reboot
+policy only after the native wave returns. With post-reboot continuation disabled, the run
+stops after an approved AIM reboot and another discovery requires a new reviewed run.
+Enabling continuation explicitly permits Core to rediscover and start another native wave
+after reboot within the same run, up to its 12-wave safety limit. There is no WebGUI
+follow-up scheduler or automatic replay.
+Linux keeps Core's native package-manager behavior and shared reboot-message/delay
+semantics; Windows cycle fields are not fabricated on Linux reports.
+
+## Reading a report
+
+| Field or condition | Meaning in the UI |
+|---|---|
+| Core succeeded + continuation_required true | Successful wave, but further patching needs review. The job is not relabeled failed. |
+| remaining_updates_known false | Remaining updates are not established. Do not show an empty pending list as zero remaining updates or reuse a pre-reboot queue. |
+| remaining_updates_known true | Pending is the final read-only discovery for the selected scope and recorded time, not current compliance or an expanded install queue. |
+| No remaining_updates_known field | Historical shape. Missing wave fields stay unestablished; no inferred post-reboot queue. |
+| reboot_deferred true | A reboot remains required with automatic reboot disabled. Installation success and reboot status remain separate. |
+| reboot_reasons_before | Bounded native reboot sources reported by `ansible.windows.win_reboot_info` before patching. These are observations from that run, not a live probe. |
+| blocked_reason preexisting_reboot_required | Core's independent preflight observed a prerequisite reboot; new patch work did not start on that path. |
+| blocked_reason cycle_limit_reached | Core stopped bounded continuation; a new decision is needed, never an automatic replay. |
+| failed_updates | Per-update title/ID, unsigned and hex HRESULT, fixed reason and safe message supplied by Core. |
+| install_not_allowed / 0x80240016 | May indicate an active installer or mandatory reboot; not proof of a pre-existing reboot by itself. |
+| Check mode | Observations/predictions, never proof of installation or an actual reboot. |
+
+The normal report summary keeps the pre/post reboot observations, performed/deferred
+flags, reviewed delay, cycle count and continuation policy visible when supplied.
+Pending-list display suppression is a presentation decision when Core says the list
+is not authoritative. It does not modify stored data: the retained structured JSON
+still provides the exact approved report body for inspection.
+
+Report-slot `complete` is availability, while payload `data.complete` describes update
+evidence. Neither independently proves the host is fully patched. Native target
+outcomes, Core result-validation failures and per-host reports remain separate.
+
+## Historical data and permissions
+
+Old reports retain their reviewed schema. No migration rewrites old patch outcomes or
+adds missing fields. Only retained WebGUI jobs contribute to host history; no terminal
+run tracking, inventory writes, raw logs or new credential storage are introduced.
+Report reads remain owner-or-admin and job deletion removes linked evidence. The
+Checkmk configuration-content retention opt-in is unchanged.
+
+## Controller acceptance
+
+Use Core's current SANITY.md under the actual executor context on a disposable,
+approved target. Confirm native `win_updates` wave behavior, the default stop after an AIM-performed
+reboot, explicit post-reboot continuation behavior, read-only final discovery,
+reboot-disabled/pre-existing-reboot cases and a bounded failure record. Compare actual
+effects and reported fields, not old task counts. Do not deliberately patch or reboot
+production systems merely to exercise a user-interface feature.
diff --git a/scripts/addons/webgui/docs/PERMISSIONS-ROLLOUT.md b/scripts/addons/webgui/docs/PERMISSIONS-ROLLOUT.md
new file mode 100644
index 0000000..08c26c0
--- /dev/null
+++ b/scripts/addons/webgui/docs/PERMISSIONS-ROLLOUT.md
@@ -0,0 +1,42 @@
+> The behaviors below are retained from2.1.0rc2. Current candidate2.1.0rc9 targets Core3.3.0rc8/schema5 and adds reports/journal described in REPORTS.md and JOURNAL.md. Credential, read-only inventory and OS-permission boundaries here remain unchanged; old version/no-migration statements describe the earlier slice.
+
+# Managed permissions - WebGUI 2.1.0rc1
+
+No permission change from2.0.0rc8. This document describes the existing add-on-owned contract, not instructions to recursively chown AIM.
+
+| Resource | Owner/group | Mode |
+|---|---|---|
+| WebGUI source, virtualenv, units | root-owned | Release-managed |
+| webgui.toml | root:aim-web |0640|
+| /var/lib/aim/webgui |aim-web:aim-web|0700|
+| SQLite database |aim-web:aim-web|0600|
+| /var/lib/aim-web-executor and .ansible/tmp chain |executor:native primary group|0700|
+| passwd-home .ansible and .ansible/tmp |executor:native primary group|0700|
+| /run/aim-web-executor |executor:native primary group|0711|
+| /run/aim-web-executor/core.sock |executor:aim-web|0660|
+
+Site executor is svc_bf-ansible. Systemd preserves its native primary group and grants aim-web as a unit-scoped SupplementaryGroups entry; numeric group IDs may appear in systemctl output. Other account memberships are resolved normally, so an empty explicit supplementary setting is not proof of an empty actual group list. The installer does not silently rewrite OS account memberships.
+
+The0711 runtime directory permits traversal to the known socket path, not directory listing for unrelated users. Socket DAC and peer checks govern access. NoNewPrivileges and empty capability sets remain; no sudo or root worker. ProtectHome remains read-only with a narrow writable passwd-home .ansible/tmp exception for delegated local tasks, plus the separate process-home staging path. The installer waits for socket readiness and checks exact managed owner/mode before starting dependent services.
+
+## Verification, not manual repair
+
+```bash
+systemctl show aim-web-executor.service -p User -p Group -p SupplementaryGroups
+stat -c '%U:%G %a %n' \
+ /var/lib/aim-web-executor \
+ /var/lib/aim-web-executor/.ansible/tmp \
+ /home/svc_bf-ansible/.ansible/tmp \
+ /run/aim-web-executor \
+ /run/aim-web-executor/core.sock \
+ /etc/ansible/scripts/config/webgui.toml
+sudo journalctl -u aim-web-executor.service -n 60 --no-pager
+```
+
+The release-managed ExecStartPre runs staging checks inside the real executor unit. Interactive sudo -u svc_bf-ansible alone does not reproduce unit-scoped aim-web group access to webgui.toml. Do not make that config world-readable to hide the distinction.
+
+## Outside add-on ownership
+
+AIM source/configuration, authorization groups, inventory/Vaults, canonical owner-only0600 private keys, /etc/ssh/ssh_known_hosts, certificates and Nginx remain Core/operator managed. The add-on does not enroll host keys or infer their trust from HOME. Keep independently verified effective SSH trust. Unknown unit drop-ins stop deployment for review; do not silently restore obsolete privilege/capability workarounds.
+
+The new explorer, activity and insights pages require no new write path, group, service, port or secret permission.
diff --git a/scripts/addons/webgui/docs/PRODUCT-COMPARISON.md b/scripts/addons/webgui/docs/PRODUCT-COMPARISON.md
new file mode 100644
index 0000000..908a055
--- /dev/null
+++ b/scripts/addons/webgui/docs/PRODUCT-COMPARISON.md
@@ -0,0 +1,96 @@
+> Historical reference retained from the preceding release. Current deployment/contracts and evidence are in DEPLOYMENT.md, REPORTS.md, JOURNAL.md and VERIFICATION.md. Do not treat old limitations or tests as current qualification.
+
+# AIM WebGUI 2.1.0rc2 compared with Jenkins, Semaphore UI and Foreman
+
+## Basis and limits
+
+This is a documentation-based capability and product-direction comparison, not a hands-on usability benchmark, security audit, licensing quotation or performance comparison. AIM statements refer to the inspected 2.1.0rc2 source and its test evidence. Other product statements refer to the official documentation listed below, consulted for this release. Features can depend on installed plugins, edition and configuration; Foreman examples use the published 3.18 host-management guide without claiming that version is the newest stable release. Recommendations below are design judgments, not measured rankings.
+
+## The products solve different problems
+
+**AIM Web** is a focused operator interface over a separately managed AIM Core. It discovers Core-owned customers, hosts, groups and catalog playbooks; prepares explicit reviewed runs; requests transient credentials only when the worker is ready; and shows Core-owned results. It does not own inventory/Vault editing, arbitrary automation code, server provisioning or a general plugin execution ecosystem. Its history covers retained WebGUI jobs only. [A1]
+
+**Jenkins** is primarily a Pipeline/CI/CD automation platform. Pipeline models multistage work and supports extensible execution through steps and plugins. Its Ansible plugin accepts playbooks, inventories and credential IDs. Treating Jenkins as a direct equivalent of a host inventory system would obscure that pipeline-centric design. [J1, J2]
+
+**Semaphore UI** is the closest operational comparator: projects combine repositories, inventories, reusable credentials, variables and task templates, with each execution recorded as a task. Its documented scope also includes tools other than Ansible. The user guide documents schedules and workflow-related capabilities, with some capabilities edition-dependent. [S1, S2]
+
+**Foreman** is broader host lifecycle management, not merely a Puppet runner. It maintains host inventory/group settings and supports provisioning and infrastructure integrations. Its documented remote-execution and Ansible workflows can operate against selected hosts through Smart Proxies; Puppet is one part of that ecosystem. [F1, F2]
+
+## Functional comparison
+
+| Area | AIM Web 2.1.0rc2 | Established-product reference |
+|---|---|---|
+| Inventory | Current read-only Core hierarchy; linked SVG/outline explorer; customer-scoped host pages | Semaphore manages inventory resources used by templates. Foreman manages hosts, host groups and inherited settings. Jenkins' Ansible plugin consumes inventory files/inline inventory for pipeline execution. [S3, F1, J2] |
+| Starting work | One-run review without mandatory plan; optional collision-safe saved title; existing independent approval when enabled | Semaphore starts tasks from templates and exposes user prompts. Jenkins offers Pipeline parameters/input steps. Foreman provides host selection and job-template workflows. [S2, J3, F2] |
+| Credential experience | Owner-initiated modal, Core-required fields, one-run lifetime, no add-on reusable secret store | Jenkins supports stored credentials referenced by IDs. Semaphore has a Key Store for reusable credentials. Foreman job settings include authentication/password and key-passphrase inputs where applicable. These are different operating models, not a security ranking. [J4, S4, F2] |
+| Output | Core-filtered static play/task/host labels and hints, bounded ephemeral stream; authoritative final per-target outcomes retained with the job | Jenkins' Ansible plugin supports console output; Semaphore documents live/completed logs and a raw-log view. Broader log access is useful for diagnosis but creates a different retention/exposure decision. [J2, S5] |
+| History | Host Activity and Playbook Insights over authorized retained WebGUI jobs, separating Check/Apply and host/whole-job outcomes | Semaphore exposes task/template history; Foreman is natively host-oriented. Do not equate a job log with an authoritative machine-wide history or claim a feature is absent merely because its documentation was not inspected. [S2, S5, F1] |
+| Orchestration | Existing queue, bounded targets, optional independent review and one-off UTC scheduling; no workflow DAG or recurring scheduler | Jenkins supports pipeline composition. Semaphore documents cron schedules; its documentation identifies workflows as a Pro feature. Foreman offers remote-job controls through its host-management workflow. [J1, S6, S7, F2] |
+| Access | Local accounts, customer/playbook execution grants; owner-or-admin job/history access and owner-only plans; not hostile-tenant isolation | Semaphore has project teams and built-in roles, with Enterprise extended permissions. Jenkins credential use is scoped and depends on authorization/plugin configuration. Wider identity deployments need product-specific evaluation. [S8, J4] |
+| Extensibility | Fixed public Core contract; add-on cannot import private Core managers or rewrite Ansible arguments | Jenkins has a broad Pipeline/plugin model; Foreman integrates host/provisioning components; Semaphore supports several automation applications. Flexibility also increases the scope an administrator must configure and govern. [J1, F1, S2] |
+
+## What the new modal improves
+
+The implemented path is now: open an existing reservation, recognize the reviewed customer/targets/mode, enter only the required credentials, submit once, then follow the job. Core-permitted key-passphrase choices use native radio buttons styled as a segmented control. Changing that presentation choice does not change reviewed scope, native inventory precedence or execution identity. [A2]
+
+This intentionally avoids asking an operator to configure a reusable credential resource just to perform one reviewed run. It also preserves a cost: repetitive or unattended operations are less convenient when credentials must be resupplied. That tradeoff is part of the current requested product boundary, not evidence that all stored-credential products are unsafe. [A2, J4, S4]
+
+Jenkins' input step demonstrates the value of making pending human input an explicit workflow state. Semaphore's template prompts demonstrate the value of exposing only the options a particular task needs. Our application of those lessons is the new Needs your attention section and Core-driven fields, not importing their wider parameter or credential models. [J3, S2]
+
+## Where AIM Web is already well aligned with this controller
+
+The strongest fit is the combination of explicit target review, customer-aware discovery, optional saved plans, per-target final results and linked host activity. Operators can inspect an inventory branch, open a host, find its last retained Checkmk result and return to the source run without moving inventory ownership out of AIM Core. The mobile header and native modal now make that narrower workflow easier to navigate. These are implemented behaviors, not evidence that our UI is universally faster or more accessible than the other products. [A1, A2]
+
+The history design is unusually explicit about what it does not know: no terminal AIM runs, deleted-job reconstruction, current-health score, inferred installed version or invented success for legacy data. A partially successful parent job does not erase each target's own final outcome. Preserve that clarity as the UI grows. [A1]
+
+## Where established platforms set a higher bar
+
+**Operational breadth:** reusable template catalogs, external integration, distributed execution, scheduled orchestration and richer organization models are documented strengths across these platforms. AIM Web does not yet provide comparable breadth, and turning a UI preference into a rushed workflow engine would undo the modularity gained from Core. [J1, S1, S6, S7, F1]
+
+**Investigative detail:** a retained full log can answer questions our ephemeral, filtered stream cannot. Semaphore explicitly exposes task logs after completion. AIM currently retains final facts, not a full transcript. A future Core-approved retention contract should be considered separately rather than quietly capturing raw stdout because it is convenient. [S5, A1]
+
+**Release and deployment assurance:** our recent manifest, startup and permission failures remain evidence that packaging and controller acceptance need attention. A passing synthetic suite does not establish production-browser, systemd, SSH/WinRM, upgrade or security qualification. This report does not claim comparable maturity or measure other products' defect rates. [A3]
+
+**Access administration:** project-scoped teams, federation and larger administrative structures deserve a deliberate design if the audience expands beyond the current controller. Semaphore documents built-in project roles and Enterprise extensions; those are not equivalent to our existing local execution grants. [S8, A1]
+
+## Security is not a badge comparison
+
+Jenkins documents encrypted stored credentials and ID-based use; its binding documentation also explains masking limitations and risks from processes sharing execution accounts. This is useful context, not a reason to claim that masking or a modal prevents all disclosure. [J4, J5]
+
+AIM's transient credential path reduces intentional add-on secret retention, but authorized controller code and service accounts remain trusted. DOM clearing cannot prove physical memory erasure. Core owns execution semantics; an accepted credential handoff does not prove authentication. Foreman/Semaphore/Jenkins have different persistence, identity and deployment choices that require their own configured-environment review. [A2]
+
+## Recommended direction after this RC
+
+My recommendation is to borrow interaction patterns, not product scope.
+
+1. **From Semaphore:** make supported playbook inputs feel like a small, well-labeled task form. Keep preflight and execution semantics in Core. Improve template/saved-plan discovery before adding arbitrary parameters.
+2. **From Foreman:** strengthen host-centric navigation and contextual actions. Add dated last-result overlays and comparisons of recorded runs, not invented live health or inventory mutation.
+3. **From Jenkins:** make stage transitions and pending human action unmistakable. Keep cancellation, acknowledgement uncertainty and dependency failures visible without turning every event into a wall of log text.
+
+None of those follow-on features is claimed shipped in this RC. The immediate release contains the modal and attention surface; targeted retry preparation, history overlays, run comparisons, federation and workflow orchestration remain separate proposals. Reliability and installed-controller acceptance should remain the next gate.
+
+## Official references and inspected AIM files
+
+Sources are provided as exact locations so the comparison can be rechecked as products evolve.
+
+- **A1** - This release: `AGENTS.md`, `docs/READ-ONLY-EXPERIENCE.md`, `docs/API.md`, `src/aim_webgui/activity.py`, `explorer.py`, `workflows.py` and `worker.py`.
+- **A2** - This release: `docs/RUN-COMFORT.md`, `docs/CREDENTIALS.md`, `credentials/presentation.py`, `credentials/service.py`, `routes/workflows.py`, credential templates and `static/js/credentials.js`.
+- **A3** - This release: `docs/VERIFICATION.md`, `docs/verification-results.json` and preserved `CHANGELOG.md` provenance.
+- **J1** - Jenkins Pipeline: `https://www.jenkins.io/doc/book/pipeline/`
+- **J2** - Official Jenkins Ansible plugin: `https://plugins.jenkins.io/ansible/`
+- **J3** - Jenkins Pipeline Input Step: `https://www.jenkins.io/doc/pipeline/steps/pipeline-input-step/`
+- **J4** - Jenkins Using credentials: `https://www.jenkins.io/doc/book/using/using-credentials/`
+- **J5** - Jenkins Credentials Binding: `https://www.jenkins.io/doc/pipeline/steps/credentials-binding/`
+- **S1** - Semaphore UI user guide: `https://semaphoreui.com/docs/user-guide`
+- **S2** - Semaphore task templates: `https://semaphoreui.com/docs/user-guide/task-templates/views` and `https://semaphoreui.com/docs/user-guide/task-templates`
+- **S3** - Semaphore inventory: `https://semaphoreui.com/docs/user-guide/inventory`
+- **S4** - Semaphore Key Store: `https://semaphoreui.com/docs/user-guide/key-store`
+- **S5** - Semaphore Tasks and log retention: `https://semaphoreui.com/docs/user-guide/tasks`
+- **S6** - Semaphore Schedules: `https://semaphoreui.com/docs/user-guide/schedules`
+- **S7** - Semaphore documentation index (Workflows Pro): `https://semaphoreui.com/docs`
+- **S8** - Semaphore Teams and Enterprise RBAC: `https://semaphoreui.com/docs/user-guide/team`
+- **F1** - Foreman introduction: `https://www.theforeman.org/introduction.html`
+- **F2** - Foreman 3.18 Managing hosts: `https://docs.theforeman.org/3.18/Managing_Hosts/index-foreman-el.html`
+- **U1** - User-provided Bootstrap4 button pattern: `https://getbootstrap.com/docs/4.0/components/buttons/#checkbox-and-radio-buttons`
+- **U2** - Bootstrap5 native check/radio toggle buttons used by this release: `https://getbootstrap.com/docs/5.3/forms/checks-radios/`
+- **U3** - WAI modal dialog interaction guidance: `https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/`
diff --git a/scripts/addons/webgui/docs/READ-ONLY-EXPERIENCE.md b/scripts/addons/webgui/docs/READ-ONLY-EXPERIENCE.md
new file mode 100644
index 0000000..790ce5f
--- /dev/null
+++ b/scripts/addons/webgui/docs/READ-ONLY-EXPERIENCE.md
@@ -0,0 +1,46 @@
+> The behaviors below are retained from2.1.0rc2. Current candidate2.1.0rc9 targets Core3.3.0rc8/schema5 and adds reports/journal described in REPORTS.md and JOURNAL.md. Credential, read-only inventory and OS-permission boundaries here remain unchanged; old version/no-migration statements describe the earlier slice.
+
+# Read-only experience - 2.1.0rc1
+
+## Mobile header
+
+At <=760px the top bar is one row: AIM logo/home shortcut left, light/dark sun/moon segments to the left of the hamburger at the right edge. Navigation and account actions open in a native details dropdown beneath it. Escape returns focus to the hamburger; outside clicks, navigation and desktop resize close it. No-JavaScript details navigation remains available. There are no swipe rails or arrow instructions. Above760px the desktop sidebar remains.
+
+## Inventory Explorer
+
+Open Inventory explorer from navigation, then select a customer. Core's current hierarchy and flat host metadata are fetched for that request only. Desktop starts with a deterministic linked SVG map; phones start with the equivalent outline. Map/Outline links switch representation. The map shows the current branch, at most12 immediate subgroups and up to3 direct host links per subgroup. Open a group to drill down. Up to24 direct hosts are listed per page. Search pages have50 results. These display limits never change execution targets or summary totals.
+
+Each group uses its full Core path, not just the leaf label. Direct customer-root hosts are shown. Repeated membership is valid; total group/root host counts use distinct logical names. A host links to one customer-scoped activity page regardless of how many branches contain it. Map lines are inventory memberships, not network links or dependencies. Empty groups and no direct members are explicit.
+
+Zoom buttons and internal scrolling are optional conveniences; all nodes also have ordinary links and the outline view. A graph click never prepares or executes a job. Current inventory failures show an error, not an invented empty or offline graph. There is no persisted secondary inventory, force simulation or remote discovery. Historical outcome overlays on graph nodes are deferred; the activity link supplies dated results.
+
+## Host Activity
+
+Identity is `(customer, exact logical hostname)`, not a machine UUID. Current membership/platform/address is read from Core separately from history. A removed/renamed host can retain history without implying it is still targetable; no automatic merging occurs. Core outages show a current-metadata-unavailable banner while authorized retained history remains usable.
+
+The page offers mode, rolling time range, playbook and target-outcome filters; playbook summaries, a25-record timeline, expandable numeric target counters and the viewer's own saved plans containing the exact hostname. A successful target remains successful even if the whole job partially succeeded. Link to the source job for overall status, Core exit and reviewed scope.
+
+## Playbook Insights
+
+The matrix shows the latest matching record per customer/host/playbook, with timestamp, mode and source-job link. Mobile renders host cards instead of compressing the matrix. Matrix pages have20 hosts and up to12 playbook columns; larger catalogs require filtering and disclose omitted columns. Statistics cover all matching retained records, not only the visible rows or the100-job overview. Outcome filtering means latest *matching* outcome, not necessarily latest run; the page labels that distinction.
+
+## Exact data and metric scope
+
+- Only jobs executed/tracked by this add-on and still retained in its workflow database contribute. No terminal AIM history, Core-wide history, scan, agent or second event collector is added.
+- Each distinct requested host contributes at most one sample per job. Group appearances, SSE reconnects and task counts do not create extra samples. Retries are separate jobs/attempts.
+- Owner-or-admin job visibility is applied in SQL before data is read/aggregated. Saved plans stay owner-only even for administrators. Filters, cells and totals obey the same scope.
+- Apply mode is the default. Check is separate; All explicitly combines labeled records. Check results are never evidence of installed changes. Rolling7/30/90/365days and All retained are available. The time axis uses finished_at or, when absent, created_at; all timestamps stay UTC internally.
+- Success rate = successful / (successful + failed + unreachable). The denominator is displayed. Not started, indeterminate, missing/legacy detail and unfinished jobs are counted separately. A zero denominator displays no rate.
+- Final outcomes come from valid Core target summaries whose host set matches the stored reviewed targets. Invalid/missing legacy facts become Detail unavailable. A nonterminal database job is Not finished even if a final response is in flight.
+- Changed values are task counts. The derived metric says host/run samples reporting changed tasks, not changed hosts. No per-host duration is inferred from job duration. No live-health, compliance or installed-version score.
+- Deleting a job removes its contribution immediately on the next query. Audit deletion events are not enough to recreate per-host outcomes and are not used to do so. No shadow results archive or materialized analytics table exists.
+
+## Read services and performance
+
+`activity.py` performs a consistent SQLite read snapshot, streams all matching job rows and builds only safe presentation records. Output records and matrix are paginated; all-history aggregation cost remains linear in matching retained job data. Customer/playbook/mode/time/owner predicates run in SQL. Distinct host/playbook cells are retained in memory for latest-result projection. This is suitable for the measured candidate scale, not an unbounded analytics claim. The optional synthetic benchmark is in `tests/benchmark_read_history.py`; consider indexed/materialized projections only after measured need, with deletion and authorization parity.
+
+`explorer.py` performs read-only public hierarchy/host requests and computes presentation geometry; `read_views.py` registers GET routes. There is no credential/prepare/execute call or extra executor operation for these pages. UI state is request-local except non-sensitive existing theme preference. No browser inventory/history storage is added.
+
+## Deliberately not in this slice
+
+No saved-plan edits or inventory/Vault management, host probes, terminal-history tracking, raw output retention, automated retry or scheduling changes. The graph is branch-focused, not a full unlimited topology canvas. Account-synced density/view preferences and graph last-result overlays can follow later after this candidate is qualified.
diff --git a/scripts/addons/webgui/docs/REPORTS.md b/scripts/addons/webgui/docs/REPORTS.md
new file mode 100644
index 0000000..c1b9234
--- /dev/null
+++ b/scripts/addons/webgui/docs/REPORTS.md
@@ -0,0 +1,62 @@
+# Operation reports - 2.1.0rc9 / Core3.3.0rc8
+
+Core's live `operation_results` capability, catalog result declaration and prepared result_contract are validated and preserved with review. Core's public protocol is `aim_operation_result_v1`; its publisher convention is `aim_output_v1`. The API remains1.0 and no new execute flag/schema path/report body is supplied by the browser.
+
+The recorded contract, not a newly fetched catalog schema, governs historical rendering. Schema changes stale preparation. No private Core imports, local inventory parsing or external schema/$ref execution is added. An independently implemented consumer validates the documented bounded JSON-schema subset. Unknown additive wrapper fields are discarded rather than stored. Unknown report identifiers using the supported schema language get a generic renderer; unsupported protocols/languages fail explicitly.
+
+## Three independent facts
+
+1. Core status/stage/native exit and remote_work_may_have_started.
+2. Native per-target outcomes from the final Core stats.
+3. Report-slot availability and local retention.
+
+Native exit0 plus a missing required report remains **failed/result_validation/exit0**, even when all native targets are successful. An existing failure can contain useful available reports. Complete report slots do not prove execution success. No automatic retries occur; already applied changes are not rolled back. Reports require the final response; an observed final event alone is not enough.
+
+Only available slots render data. Missing, withheld, invalid, not_started and indeterminate have data:null and never become zero/false/empty reports. Null versions remain unknown. Slot errors are fixed nullable codes, not arbitrary exception messages. Native target metrics in Host Activity keep their existing denominator and meaning.
+
+## Views
+
+| Schema | Presentation and meaning |
+|---|---|
+| host_capabilities_v1 | Eight Yes/No facts, not current inventory membership or live health. Missing is not No. |
+| filesystem_usage_v1 | Mount/drive table, observed byte counts and utilization, explicit unavailable/null. Mapped drives remain WinRM-session scoped. |
+| event_log_export_v1 | Channels, time window and target file paths; no browser download/file-read capability. Check mode reports no completed export. |
+| service_start_summary_v1 | Before/eligible/attempted/excluded/after facts and fixed failure reasons. Excluded is not a failed start; observed running does not prove sole causation. |
+| patch_summary_v1 | Linux net package/version-set changes or Windows update IDs/KBs, installed/pending/failed and reboot evidence. data.complete is separate from report complete; not an exhaustive transaction log. |
+| managed_cleanup_preview_v1 | Candidate versus actual removed managed files, with mode prominent. No unknown-file purge. |
+| checkmk_user_config_v1 | Parsed/redacted configuration or default metadata subset, not raw YAML/comments. Redacted markers are not absent settings. |
+| checkmk_agent_state_v1 | Observed installation/version/source, services and managed changes. Do not infer version from an MSI filename. |
+| checkmk_agent_config_v1 | Named section/file/check changes; not raw field diffs or a count of untouched unknown files. |
+
+All nine have schema-keyed titles/semantic notes, scalar fact cards, bounded array/object sections and an escaped JSON alternative. Collection sections paginate at50 entries. Nested long values are visibly shortened in tables; the separately loaded retained JSON is not truncated. Unknown supported schemas/global scope have generic rendering. Core ships no global operation; its native qualification is separate.
+
+Reports are available **when Core finalizes**, not while a publisher task is merely running. Job sections link Summary/Progress/Targets/Reports. The report page shows host, mode, recorded time, source job, execution verdict, availability and retention. Host Activity links the latest20 authorized report references for its exact customer/host across all dates and modes, separately from history filters. No current-state overlay or inventory update is implied.
+
+## Persistence and confidentiality
+
+```toml
+[reports]
+max_bytes = 16777216
+retain_configuration = false
+```
+
+Report bodies live in job-linked tables, not the ordinary jobs.core_result payload used by status polls and analytics. The worker atomically records the compact final result and report metadata/data once. Each report read is authorized owner-or-admin, with lazy one-slot JSON retrieval. Paths/URLs remain text and never become arbitrary downloads or commands. HTML/Rich-looking strings are escaped. No browser data storage or raw-body logging.
+
+The default retains validated ordinary reports, bounded by the reviewed slot cap (up to1MiB) and16MiB Core/add-on per-run caps. Lower local caps skip whole bodies with not_retained_limit rather than cutting JSON while claiming validity. Original Core availability remains separate from local retention.
+
+For checkmk_user_config_v1, **full sections are NOT retained by default**. The metadata_only subset contains path, exists, size_bytes, last_write_time_utc, redacted_paths and comment_preservation. That subset is explicitly labeled, not claimed as a full schema payload. Set retain_configuration=true only after approving the extra data-retention exposure and restart/reload the relevant services; release-managed config replacements require re-review of that preference. It applies to newly finalized runs and cannot restore prior omitted sections.
+
+Core filtering is not a universal secret scanner. Operational reports can disclose configuration choices. Keep private database and backup permissions, HTTPS, session/RBAC and operational source trust. Supplied secrets are checked again during public-report validation. No arbitrary debug/module output, invocation, environment or passwords are added.
+
+Delete a job -> its report and journal rows disappear. Audit holds only the deletion fact. No separate archive; protected backups have their own operator retention policy. No backfill from terminal history.
+
+## Transport budget
+
+Large **public responses** use128MiB JSONL-line,512MiB total-process-output and32MiB canonical UTF-8 executor-frame bounds; non-execute metadata lines are bounded at32MiB. These finite transport ceilings accommodate encoding/wrappers and duplicated final event/response, not a license for report payloads beyond Core's1MiB/slot and16MiB/run limits. Structural decoding rejects excessive nesting, duplicate keys and non-finite numbers. The consumer revalidates data and reviewed target/mode/schema association. Torn/oversized responses never become partial valid JSON or success.
+
+Request/secret boundaries are unchanged: normal browser bodies64KiB, credential bodies8KiB, private secret frames bounded, per-value WebGUI2048UTF-8 bytes and the existing Core credential FD. Increasing response capacity did not increase secret lifetimes, password sizes or inbound command authority.
+
+
+## Core rc8 patch-wave extensions
+
+See [PATCH-WAVES.md](PATCH-WAVES.md) for reboot-before/after/deferred/delay fields, Windows continuation/cycle reporting, remaining-update knowledge, and supplied unsigned/hex HRESULT/reason/message fields. The patch view explains these facts without changing the Core/job verdict. Pending is not shown as a final remaining-update list when Core explicitly reports remaining_updates_known=false. Structured JSON remains available; no report data is rewritten. Old reports retain their recorded contract and missing optional values are never backfilled.
diff --git a/scripts/addons/webgui/docs/ROADMAP.md b/scripts/addons/webgui/docs/ROADMAP.md
new file mode 100644
index 0000000..5621b80
--- /dev/null
+++ b/scripts/addons/webgui/docs/ROADMAP.md
@@ -0,0 +1,7 @@
+# Roadmap after2.1.0rc9
+
+Implemented candidate: Core3.3 reports/contract transport, schema5 retained metadata journal and job-bound report storage, reconnect/reload/multi-view replay, dated Host Activity report references and conservative configuration retention. All existing read-only/mobile/modal/workflow features remain.
+
+Next gate is controller acceptance and bug fixes, not expanded execution authority. Qualify real Ansible2.19.11 publishers, report semantics and both staging locations inside installed services; browser pinned assets/HTMX/SSE/mobile keyboard; recover/rollback; load/backups and deletion policy. See VERIFICATION and CONTROLLER-PILOT.
+
+Later independently approved UI work: problem-target filters, comparison of two retained host reports, optional dated map overlays, density/view preferences, and richer presentation driven by future supported schemas. Current reports are finalization-only. Do not invent live reports, monitoring/current compliance, terminal-wide history, private Core reads, arbitrary Ansible args, automated replay or a second credential store.
diff --git a/scripts/addons/webgui/docs/RUN-COMFORT.md b/scripts/addons/webgui/docs/RUN-COMFORT.md
new file mode 100644
index 0000000..fc07351
--- /dev/null
+++ b/scripts/addons/webgui/docs/RUN-COMFORT.md
@@ -0,0 +1,43 @@
+> The behaviors below are retained from2.1.0rc2. Current candidate2.1.0rc9 targets Core3.3.0rc8/schema5 and adds reports/journal described in REPORTS.md and JOURNAL.md. Credential, read-only inventory and OS-permission boundaries here remain unchanged; old version/no-migration statements describe the earlier slice.
+
+# Run comfort - AIM WebGUI 2.1.0rc2
+
+## Scope
+
+Based on the working 2.1.0rc1 read-only release, targeting separately managed AIM Core3.3.0rc3/API1.0. This is a user-interface enhancement to the existing one-run credential path, not a password store, new authentication mode or execution engine. SQLite4, WebGUI HTTPv2, Core detail/outcome/hierarchy/staging contracts and release-managed permissions remain unchanged.
+
+## Open without leaving the job
+
+When the worker is ready, the job owner selects **Unlock this run** on Job detail or **Unlock run** in Needs your attention. A native modal opens over the current page, with the reviewed playbook/customer, target count and Apply/Check mode visible. It does not open automatically, move the user into a new window, or focus a password field without a deliberate action. The title gets initial focus so opening on a phone does not immediately summon the keyboard.
+
+The form is fetched lazily and is not part of the HTMX-polled job or attention region. Refreshing those regions does not erase typing. Tab/Shift+Tab stay inside the dialog; Escape or the explicit Close button dismisses it and returns focus, even when polling replaced the original trigger. A backdrop tap is ignored to avoid accidental loss of input. The viewport-bounded body scrolls internally and responds to visual-viewport resize. The full-page link still works without JS or dialog support, and also when deliberately opened in another tab.
+
+## Use only the fields Core requires
+
+Vault-only jobs show one password field. Each input has a persistent label, Required/Optional text, Show/Hide button and Caps Lock hint. Paste is supported; the app neither stores values in browser storage nor disables a user's deliberate password-manager use. These are infrastructure passwords, not MFA/one-time-code inputs.
+
+When the requirements contain both `vault_password` and `ssh_key_passphrase_or_customer_vault_value`, a native radio-button group styled as buttons offers **Use customer Vault** (default) and **Enter separately**. Choosing the latter reveals a required key-passphrase field; returning to Vault clears/disables it. When a key is explicitly required by Core, the field stays required and the alternate-source toggle is not offered. A requested connection password is labeled a default, since native inventory precedence still applies. No Vault/Custom credentials switch, forced account override, become field or private-key upload is added.
+
+The user's Bootstrap4 grouped-radio example was used as visual inspiration. Implementation uses existing Bootstrap5 `btn-check` inputs with associated labels and native radio semantics, not Bootstrap4's button plugin or jQuery. Theme tokens and focus contrast remain in the established design system.
+
+## Submission and deadlines
+
+The timer is synchronized to the server's existing reservation deadline and advances from a monotonic browser clock. Opening, polling and typing do not extend the five-minute window. An amber near-expiry notice is displayed once; the timer is not announced every second. POST validation, not the client timer, controls eligibility. Handoff/start keeps its existing 60-second bound.
+
+Submit clears the live DOM values (including revealed text inputs), disables repeat interaction and sends only supported credential fields in the authenticated/CSRF-protected JSON POST. The confirmation says **handoff accepted**, not password verified. Core validates Vault/key/connection details later, and the user can close the dialog to follow the existing job output. Closing is not a job cancellation.
+
+If the HTTP response is lost or uncertain, credentials are cleared and the form locks. It reads owner-only job status rather than resending the password automatically. Reopening that job on the same page preserves only the uncertainty marker/job ID, never secrets. The worker's existing atomic claim prevents reuse; refreshing a page is not a way to replay a claimed handoff. A 400 field-validation failure can allow another explicit corrected submission; throttles, revoked permission, expiry and ended jobs remove the input opportunity. No error echoes credential values or raw Core exceptions.
+
+Clearing references is not physical memory erasure. The browser, network stack and operating system may have other transient copies. Infrastructure administrators must still manage TLS, proxy buffering/logging, dumps, swap and the trust of service identities.
+
+## Needs your attention
+
+Overview shows up to eight actionable jobs; Jobs shows up to100 and discloses any remaining total. The query scans current actionable jobs rather than only the newest100 history entries. Your own live credential reservations come first. Administrators see other owners' jobs needing independent approval as review links, not automatically approved jobs and not another requester's credential form. Ended/expired/canceled jobs leave the action list. Ordinary failed jobs stay in history rather than masquerading as pending input.
+
+## Retained functionality
+
+Inventory map/outline, Host Activity, Playbook Insights, mobile hamburger/theme controls, customer-scoped histories, one-run review, unique saved-plan names, partial-result presentation, manual fresh retry and terminal-only deletion remain. History continues to include only retained WebGUI jobs. No terminal Core history, inventory writes, secrets cache or live host probing is introduced.
+
+## Operator acceptance
+
+Use a disposable authorized job and test both Vault-only and a customer-key requirement. Verify fields against Core, focus/keyboard/mobile viewport behavior, a live HTMX refresh while typing, close/expiry/revocation clearing and an accepted handoff followed by the actual run. Test an ambiguous response only on a safe disposable operation and confirm no automatic POST repetition. Verify fallback form and owner/admin policy. Source/browser fixture evidence and untested production boundaries are in VERIFICATION.md.
diff --git a/scripts/addons/webgui/docs/SECURITY.md b/scripts/addons/webgui/docs/SECURITY.md
new file mode 100644
index 0000000..c13c7a0
--- /dev/null
+++ b/scripts/addons/webgui/docs/SECURITY.md
@@ -0,0 +1,23 @@
+# Security boundaries - 2.1.0rc9
+
+Preserve the established web/auth/queue/executor separation: non-root web and existing non-root executor, fixed Core executable/config, Unix peer checks, normal service groups, both narrow staging exceptions and no new sudo/capability/key-export path. This is a trusted-controller execution service, not a hostile-tenant or malicious-playbook sandbox.
+
+Argon2id, opaque server sessions, Secure/HttpOnly/SameSite cookies under HTTPS, CSRF/origin/trusted-proxy policy, throttles, current grants, explicit scope/revision review and last-admin checks remain. One-run credentials are collected only for the owning reservation and travel via private frames/FD. No password in job data, reports, journal, browser storage, logs, argv, environment or files. Keep existing deadlines, no automatic retry/POST replay and honest handoff-accepted wording. Reference cleanup is not physical memory erasure.
+
+## Approved persistence boundary
+
+This release intentionally replaces ephemeral-only progress with a **bounded structured journal**. It does not store raw Ansible stdout/stderr, debug values, rendered terminal transcripts, invocation/module dictionaries or decrypted Vault data. Only validated public detail metadata/fixed hints is retained; unknown additional event fields are dropped. Core's withheld labels/hosts remain withheld. A rejected/missing final response cannot be promoted from an earlier event.
+
+Operation reports are explicitly declared Core data, not automatically harmless content. Validate the recorded public schema, request host/mode association, scope, limits and availability. Supplied-secret checks are defense in depth. Static labels and unknown configuration values can still disclose information; Core filtering is not universal secret detection. Full parsed Checkmk sections therefore require explicit retention opt-in, defaulting to availability/file/redaction metadata only.
+
+All replay/report reads and statistics enforce the same owner/admin access as jobs; plan references remain owner-only. Session/job access is rechecked during streams. Cursors are not bearer authorization. Auth revocation ends live views; already displayed/downloaded/copied information cannot be recalled from a user.
+
+Only textContent/escaped templates render report strings. No arbitrary URLs, file downloads, source paths, HTML/Rich markup, schema references or executable validators are followed. JSON is bounded and fetched one slot on demand. No report/graph/history data is placed in browser localStorage. Copy JSON is a deliberate user clipboard action, not an automatic export.
+
+Delete a job -> remove journal/report rows and its statistical contribution; leave only compact audit deletion metadata. No shadow archive. Protected backups, browser transient memory, SQLite free pages and storage remnants require independent operator policy; deletion is not certified erasure. Audit is local, not tamper-proof.
+
+## Failures and qualification
+
+Core verdict, native target stats, report availability and journal coverage remain distinct. Native exit0/result_validation is not success and must not auto-replay. Queue overflow or journal write loss does not independently prove task failure. An interrupted stream remains unknown where Core cannot establish a final result.
+
+No native/systemd/proxy/browser-security qualification is claimed from source fixtures. See VERIFICATION.md and run CONTROLLER-PILOT.md with approved disposable scope. Maintain native Ansible2.19.11, Core's declared dependency range, validated TLS/global SSH trust and current operational-source review.
diff --git a/scripts/addons/webgui/docs/VERIFICATION.md b/scripts/addons/webgui/docs/VERIFICATION.md
new file mode 100644
index 0000000..c0f897d
--- /dev/null
+++ b/scripts/addons/webgui/docs/VERIFICATION.md
@@ -0,0 +1,57 @@
+# Verification - AIM WebGUI 2.1.0rc9
+
+Date: 2026-09-22. Target: independently managed AIM Core **3.3.0rc8**, public
+service/wire/event **1.0**; WebGUI HTTP **v2**; SQLite **schema 5**.
+
+## Scope
+
+This candidate reconciles the add-on release surface with the supplied Core 3.3.0rc8
+archive and packages the result as 2.1.0rc9. It does not claim native Windows/Linux or
+production-systemd acceptance merely because local source/contract checks pass.
+
+The supplied Core ZIP SHA-256 observed during this build is:
+
+```text
+76733be206b14c8a822126dfdd67ee3ad3667cdb88d3db925dd57859dd322ea0
+```
+
+No independent publisher sidecar was supplied, so this is an observed digest rather
+than a publisher-authentication claim. ZIP extraction/integrity succeeded.
+
+## Contract findings checked
+
+- Core rc8 reports product version `3.3.0rc8` and service/wire/event API `1.0`.
+- Live capabilities advertise `collection_baselines.ansible.windows` as
+ `>=3.8.0,<4.0.0`; WebGUI requires that declaration and does not manage collections.
+- Existing public detail-progress, inventory-hierarchy, target-outcome, staging and
+ structured operation-result contracts remain the integration boundary.
+- `patch_summary_v1` accepts rc8's additive optional `reboot_reasons_before` data while
+ historical report contracts remain independently validated.
+- No WebGUI database migration, Core mutation, new privilege bridge or private Core
+ import is introduced by rc9.
+
+## Automated evidence observed for rc9
+
+- Python compilation of `src`, `tests` and `deploy`: **passed**.
+- `tests/test_deployment_v2.py`: **10 passed**.
+- `test_core_contract.py::test_real_core_capabilities_and_customers` against the supplied
+ rc8 tree: **1 passed**.
+- Targeted rc8 patch checks completed before the bounded native-finalization fixture:
+ public catalog/options and new-report-schema/old-Core-gate checks **passed**.
+- The full `test_core_rc8_patch.py` file and the release-wide bounded runner did not
+ complete inside this execution environment's command window. Their interrupted runs
+ are **not** counted as passes and no rc4/rc5 test totals are carried forward.
+
+The final extracted rc9 ZIP is verified with the release's own `deploy.verify_release`
+manifest checker before delivery.
+
+## Not qualified here
+
+This build does **not** establish production installation of
+`ansible.windows >=3.8.0,<4.0.0`, native `win_reboot_info`, Windows Update, Checkmk ACL
+normalization/script placement, real WinRM/SSH, reboot behavior, production proxy/CSP/
+HTMX/EventSource traffic, or the actual systemd sandbox.
+
+Core rc8's current SANITY/VALIDATION guidance remains the operator acceptance source.
+Use a low-risk reporting operation first, then qualify Windows patching and changed
+Checkmk workflows separately on approved disposable targets.
diff --git a/scripts/addons/webgui/docs/verification-results.json b/scripts/addons/webgui/docs/verification-results.json
new file mode 100644
index 0000000..27dacdb
--- /dev/null
+++ b/scripts/addons/webgui/docs/verification-results.json
@@ -0,0 +1,64 @@
+{
+ "release": "2.1.0rc9",
+ "core": "3.3.0rc8",
+ "core_service_api": "1.0",
+ "http_api": 2,
+ "database_schema": 5,
+ "core_archive_sha256": "76733be206b14c8a822126dfdd67ee3ad3667cdb88d3db925dd57859dd322ea0",
+ "automated_tests": {
+ "python_compilation": {
+ "status": "passed",
+ "paths": [
+ "src",
+ "tests",
+ "deploy"
+ ]
+ },
+ "completed": [
+ {
+ "test": "tests/test_deployment_v2.py",
+ "passed": 10,
+ "failed": 0
+ },
+ {
+ "test": "tests/test_core_contract.py::test_real_core_capabilities_and_customers",
+ "passed": 1,
+ "failed": 0
+ },
+ {
+ "test": "tests/test_core_rc8_patch.py::test_rc8_public_catalog_options_not_hardcoded_defaults",
+ "status": "passed"
+ },
+ {
+ "test": "tests/test_core_rc8_patch.py::test_new_report_schema_and_old_core_gate",
+ "status": "passed"
+ }
+ ],
+ "interrupted_not_counted": [
+ "full tests/test_core_rc8_patch.py run",
+ "release-wide tests/run_release_tests.py run",
+ "test_real_core_rc8_patch_finalization_through_fake_native in combined targeted run"
+ ],
+ "native_managed_hosts_tested": false
+ },
+ "contract": {
+ "core_version": "3.3.0rc8",
+ "api_version": "1.0",
+ "collection_baseline_ansible_windows": ">=3.8.0,<4.0.0",
+ "detail_progress_schema": "play_task_host_v1",
+ "inventory_hierarchy_schema": "inventory_hierarchy_v1",
+ "target_outcome_schema": "target_outcome_summary_v1",
+ "staging_profile": "native_defaults_preflight_v2",
+ "operation_result_protocol": "aim_operation_result_v1"
+ },
+ "final_archive": {
+ "manifest_verified": true,
+ "archive_sha256": null
+ },
+ "limitations": [
+ "No native managed-host qualification",
+ "No real systemd installation or recovery",
+ "No production browser HTTPS/CSP/cookies/proxy/HTMX/SSE acceptance",
+ "Full release suite did not complete within the execution environment command window"
+ ]
+}
diff --git a/scripts/addons/webgui/pyproject.toml b/scripts/addons/webgui/pyproject.toml
new file mode 100644
index 0000000..8235f66
--- /dev/null
+++ b/scripts/addons/webgui/pyproject.toml
@@ -0,0 +1,40 @@
+[build-system]
+requires = ["setuptools==82.0.1"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "aim-webgui"
+version = "2.1.0rc9"
+description = "Independent Python operations WebGUI add-on for AIM 3.3.0rc8 service API 1.0"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+ "fastapi==0.128.2", "starlette==0.50.0", "uvicorn==0.48.0",
+ "Jinja2==3.1.6", "argon2-cffi==25.1.0", "ruamel.yaml==0.18.17"
+]
+
+[project.scripts]
+aim-web = "aim_webgui.cli:main"
+
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.setuptools.package-data]
+aim_webgui = ["templates/**/*.html", "static/**/*", "db/migrations/*.sql"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+pythonpath = ["src"]
+
+[tool.aim-web]
+compatible-aim = ["3.3.0rc8"]
+http-api = 2
+database-schema = 5
+core-service-api = "1.0"
+core-event-api = "1.0"
+# Native Ansible compatibility is owned/checked by core, not this package.
+release-status = "candidate; controller qualification required"
+# AIM is an externally managed runtime dependency, deliberately NOT pip-installed.
+
+[project.optional-dependencies]
+test = ["pytest>=8,<10", "httpx>=0.27,<1"]
diff --git a/scripts/addons/webgui/requirements-test.txt b/scripts/addons/webgui/requirements-test.txt
new file mode 100644
index 0000000..6750ee5
--- /dev/null
+++ b/scripts/addons/webgui/requirements-test.txt
@@ -0,0 +1,3 @@
+# Test tooling only; do not install in the AIM environment.
+pytest==9.0.2
+httpx==0.28.1
diff --git a/scripts/addons/webgui/src/aim_webgui/__init__.py b/scripts/addons/webgui/src/aim_webgui/__init__.py
new file mode 100644
index 0000000..25c6f8a
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/__init__.py
@@ -0,0 +1,4 @@
+__version__ = "2.1.0rc9"
+COMPATIBLE_AIM = ("3.3.0rc8",)
+SCHEMA_VERSION = 5
+HTTP_API_VERSION = 2
diff --git a/scripts/addons/webgui/src/aim_webgui/__main__.py b/scripts/addons/webgui/src/aim_webgui/__main__.py
new file mode 100644
index 0000000..17e6660
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/__main__.py
@@ -0,0 +1,2 @@
+from aim_webgui.cli import main
+main()
diff --git a/scripts/addons/webgui/src/aim_webgui/activity.py b/scripts/addons/webgui/src/aim_webgui/activity.py
new file mode 100644
index 0000000..1fe4df4
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/activity.py
@@ -0,0 +1,193 @@
+"""Read-only projections of retained WebGUI history, never terminal/Core history.
+
+Queries are scoped to the same owner/admin policy as Workflows.job. They stream
+all matching retained rows rather than using the latest-100 Jobs overview. No
+projection table, background collection, extra inventory database or secret access.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+import json
+import time
+from urllib.parse import urlencode, quote
+
+from aim_webgui.core.protocol import _target_outcomes
+from aim_webgui.db.store import Store
+from aim_webgui.errors import WebError
+from aim_webgui.workflows import actor, presentation_status, TERMINAL
+
+OUTCOMES = ('successful', 'failed', 'unreachable', 'not_started', 'indeterminate', 'unavailable', 'outstanding')
+LABELS = {'successful': 'Succeeded', 'failed': 'Failed', 'unreachable': 'Unreachable',
+ 'not_started': 'Not started', 'indeterminate': 'Indeterminate',
+ 'unavailable': 'Detail unavailable', 'outstanding': 'Not finished'}
+
+
+def text(value: str, limit: int = 200) -> str:
+ if not isinstance(value, str) or len(value) > limit or any(ord(c) < 32 or ord(c) == 127 for c in value):
+ raise WebError('invalid_filter', 'Use bounded printable filter values.', 400)
+ return value
+
+
+def host_url(customer: str, host: str, **filters) -> str:
+ return '/inventory/' + quote(customer, safe='') + '/activity?' + urlencode({'host': host, **filters})
+
+
+def explorer_url(customer: str, **filters) -> str:
+ return '/inventory/' + quote(customer, safe='') + '/explore' + ('?' + urlencode(filters) if filters else '')
+
+
+@dataclass(frozen=True)
+class HistoryFilter:
+ customer: str = ''
+ host: str = ''
+ playbook: str = ''
+ mode: str = 'apply'
+ days: str = '30'
+ outcome: str = ''
+ q: str = ''
+ page: int = 1
+
+ def validate(self) -> 'HistoryFilter':
+ for val in (self.customer, self.host, self.playbook, self.q): text(val, 255)
+ if self.mode not in {'apply', 'check', 'all'} or self.days not in {'7', '30', '90', '365', 'all'}:
+ raise WebError('invalid_filter', 'Choose an available mode and retained-history time range.')
+ if self.outcome and self.outcome not in OUTCOMES:
+ raise WebError('invalid_filter', 'Choose a supported target outcome.')
+ if type(self.page) is not int or not 1 <= self.page <= 100000:
+ raise WebError('invalid_filter', 'Page is outside the supported range.')
+ return self
+
+ def query(self, **updates) -> dict:
+ values = dict(customer=self.customer, host=self.host, playbook=self.playbook, mode=self.mode,
+ days=self.days, outcome=self.outcome, q=self.q, page=self.page)
+ values.update(updates)
+ return {key: val for key, val in values.items() if val != ''}
+
+
+def _dict(raw):
+ try:
+ result = json.loads(raw) if raw else {}
+ return result if isinstance(result, dict) else {}
+ except (ValueError, TypeError):
+ return {}
+
+
+def _facts(raw, requested):
+ """Use only Core-owned final facts; missing legacy records are not successes."""
+ value = _dict(raw)
+ try:
+ _, targets = _target_outcomes(value)
+ if {t['host'] for t in targets} != set(requested):
+ return value, {}
+ return value, {target['host']: target for target in targets}
+ except (WebError, TypeError, ValueError, KeyError):
+ return value, {}
+
+
+def _counts():
+ return dict.fromkeys(OUTCOMES, 0)
+
+
+def _metric(counts):
+ eligible = sum(counts[x] for x in ('successful', 'failed', 'unreachable'))
+ return {'counts': counts, 'samples': sum(counts.values()), 'eligible': eligible,
+ 'success_percent': round(counts['successful'] * 100 / eligible, 1) if eligible else None}
+
+
+class Activity:
+ def __init__(self, settings):
+ self.store = Store(settings.database)
+
+ def report(self, user_id: int, filters: HistoryFilter, *, now: int | None = None) -> dict:
+ """Read one consistent DB snapshot. Output is paginated, aggregates are not.
+
+ Customer/time/mode/owner predicates run in SQL. Each job JSON is read once.
+ No persistent rollup means deleting a job immediately removes its samples.
+ Counters are target/run participations, never task count or group count.
+ """
+ f = filters.validate()
+ now = int(time.time()) if now is None else now
+ since = None if f.days == 'all' else now - int(f.days) * 86400
+ totals = _counts(); rows = []; matched = 0; job_count = 0; changed_runs = 0
+ by_book = {}; latest = {}; distinct = set(); customers = set(); books = set()
+ coverage_start = None; coverage_end = None
+ start, end = (f.page - 1) * 25, f.page * 25
+ with self.store.read() as db:
+ db.execute('BEGIN')
+ who = actor(db, user_id)
+ # Never inspect another viewer's rows even while collecting filter options.
+ clauses = ['(? = \'admin\' OR j.owner_id = ?)']
+ args = [who['role'], user_id]
+ if f.mode != 'all': clauses.append('j.mode = ?'); args.append(f.mode)
+ if since is not None:
+ clauses.append('COALESCE(j.finished_at,j.created_at) >= ?'); args.append(since)
+ clauses.append('COALESCE(j.finished_at,j.created_at) <= ?'); args.append(now)
+ # json_valid guards historical malformed JSON without turning it into a query error.
+ if f.customer:
+ clauses.append("CASE WHEN json_valid(j.plan) THEN json_extract(j.plan,'$.customer') END = ?")
+ args.append(f.customer)
+ if f.playbook:
+ clauses.append("CASE WHEN json_valid(j.plan) THEN json_extract(j.plan,'$.playbook') END = ?")
+ args.append(f.playbook)
+ sql = '''SELECT j.id,j.owner_id,u.username,j.plan,j.core_result,j.mode,j.status,
+ j.created_at,j.finished_at,COALESCE(j.finished_at,j.created_at) AS recorded_at
+ FROM jobs j JOIN users u ON u.id=j.owner_id WHERE ''' + ' AND '.join(clauses) + ' ORDER BY recorded_at DESC,j.id DESC'
+ for row in db.execute(sql, args):
+ plan = _dict(row['plan']); customer = plan.get('customer'); book = plan.get('playbook')
+ targets = plan.get('targets')
+ if not isinstance(customer, str) or not isinstance(book, str) or not isinstance(targets, list):
+ continue
+ requested = list(dict.fromkeys(t for t in targets if isinstance(t, str)))
+ if f.host and f.host not in requested: continue
+ result, facts = _facts(row['core_result'], requested)
+ display = presentation_status(row['status'], result)
+ job_matched = False
+ for host in requested:
+ if f.host and f.host != host: continue
+ if f.q and f.q.casefold() not in host.casefold(): continue
+ fact = facts.get(host)
+ # The database lifecycle must be terminal before presenting terminal facts.
+ outcome = ('outstanding' if row['status'] not in TERMINAL else
+ fact['outcome'] if fact else 'unavailable')
+ if f.outcome and f.outcome != outcome: continue
+ counts = fact['counts'] if fact and outcome != 'outstanding' else None
+ sample = dict(job_id=row['id'], username=row['username'], customer=customer, playbook=book,
+ host=host, mode=row['mode'], outcome=outcome, counts=counts,
+ job_status=row['status'], job_display_status=display,
+ created_at=row['created_at'], recorded_at=row['recorded_at'],
+ finished_at=row['finished_at'], host_url=host_url(customer, host))
+ if start <= matched < end: rows.append(sample)
+ matched += 1; job_matched = True; totals[outcome] += 1
+ identity = (customer, host); distinct.add(identity); customers.add(customer); books.add(book)
+ stat = by_book.setdefault(book, {'playbook': book, 'counts': _counts(), 'latest': sample, 'jobs': 0, 'last_job': None})
+ stat['counts'][outcome] += 1
+ if stat['last_job'] != row['id']: stat['jobs'] += 1; stat['last_job'] = row['id']
+ latest.setdefault((customer, host, book), sample)
+ if counts and counts['changed'] > 0: changed_runs += 1
+ coverage_start = row['recorded_at'] if coverage_start is None else min(coverage_start, row['recorded_at'])
+ coverage_end = row['recorded_at'] if coverage_end is None else max(coverage_end, row['recorded_at'])
+ if job_matched: job_count += 1
+ # Saved plans retain their stricter owner-only visibility, including for admins.
+ plans = []
+ if f.customer and f.host:
+ for row in db.execute('SELECT id,name,customer,playbook,payload,created_at FROM plans WHERE owner_id=? AND customer=? ORDER BY created_at DESC,id', (user_id, f.customer)):
+ payload = _dict(row['payload'])
+ if f.host in payload.get('targets', []):
+ plans.append({k: row[k] for k in ('id', 'name', 'customer', 'playbook', 'created_at')})
+ book_stats = []
+ for val in by_book.values():
+ book_stats.append({'playbook': val['playbook'], 'jobs': val['jobs'], 'latest': val['latest'], **_metric(val['counts'])})
+ book_stats.sort(key=lambda v: v['playbook'].casefold())
+ # Bounded matrix cells; pages do not truncate the aggregated statistics.
+ host_keys = sorted(distinct, key=lambda v: (v[0].casefold(), v[1].casefold(), v))
+ matrix_hosts = host_keys[(f.page-1)*20:f.page*20]
+ columns = sorted(books, key=str.casefold)[:12]
+ matrix = [{'customer': c, 'host': h, 'url': host_url(c, h),
+ 'cells': [latest.get((c, h, b)) for b in columns]} for c, h in matrix_hosts]
+ return dict(source='retained_webgui_jobs', filters=f, **_metric(totals), records=rows,
+ matched=matched, jobs=job_count, host_count=len(distinct), book_stats=book_stats,
+ changed_participations=changed_runs, page=f.page, pages=max(1, (matched+24)//25),
+ matrix=matrix, columns=columns, matrix_pages=max(1, (len(host_keys)+19)//20),
+ omitted_columns=max(0,len(books)-len(columns)), customers=sorted(customers),
+ playbooks=sorted(books), plans=plans, since=since, as_of=now,
+ coverage_start=coverage_start, coverage_end=coverage_end)
diff --git a/roles/checkmk_scripts/files/Linux/local/unifi.cfg.example b/scripts/addons/webgui/src/aim_webgui/adapters/__init__.py
similarity index 100%
rename from roles/checkmk_scripts/files/Linux/local/unifi.cfg.example
rename to scripts/addons/webgui/src/aim_webgui/adapters/__init__.py
diff --git a/scripts/addons/webgui/src/aim_webgui/adapters/core_v1.py b/scripts/addons/webgui/src/aim_webgui/adapters/core_v1.py
new file mode 100644
index 0000000..83614bd
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/adapters/core_v1.py
@@ -0,0 +1,160 @@
+"""UI presentation adapter for the public AIM 3.3.0rc8 service/wire/event API only.
+
+Form conversion is syntax/type marshalling; AIM prepare owns all target and option
+validation. No private imports, file parsing, command building or key access.
+"""
+from __future__ import annotations
+import json
+import re
+from types import SimpleNamespace
+from aim_webgui.core.client import CoreClient
+from aim_webgui.errors import WebError
+from aim_webgui.core.reports import contract
+
+class CoreAdapter:
+ def __init__(self,settings,client=None):
+ self.settings=settings;self.client=client or CoreClient(settings);self._caps=None
+ @property
+ def capabilities(self):
+ if self._caps is None:
+ caps=self.client.request('capabilities')
+ if (not isinstance(caps,dict) or caps.get('core_version')!='3.3.0rc8' or caps.get('api_version')!='1.0'
+ or caps.get('event_version')!='1.0' or not {'list_customers','list_hosts','inventory_hierarchy','list_playbooks','prepare','readiness','execute','staging_check'}<=set(caps.get('operations',[]))):
+ raise WebError('core_incompatible','This release requires AIM 3.3.0rc8 with documented service/wire/event API 1.0.',503)
+ progress=caps.get('execution_progress',{})
+ if ('detail' not in progress.get('modes',[]) or progress.get('request_field')!='progress_mode'
+ or progress.get('detail_schema')!='play_task_host_v1'):
+ raise WebError('core_incompatible','This release requires AIM 3.3.0rc8 detailed progress (play_task_host_v1).',503)
+ hierarchy=caps.get('inventory_hierarchy',{})
+ outcomes=caps.get('target_outcomes',{})
+ staging=caps.get('controller_staging',{})
+ if hierarchy.get('schema')!='inventory_hierarchy_v1' or not hierarchy.get('nested_groups'):
+ raise WebError('core_incompatible','This release requires AIM inventory_hierarchy_v1 discovery.',503)
+ if outcomes.get('schema')!='target_outcome_summary_v1' or outcomes.get('source')!='native_final_host_stats':
+ raise WebError('core_incompatible','This release requires AIM target_outcome_summary_v1 final host accounting.',503)
+ if staging.get('profile')!='native_defaults_preflight_v2':
+ raise WebError('core_incompatible','This release requires AIM native_defaults_preflight_v2 controller staging.',503)
+ reports=caps.get('operation_results',{})
+ if (reports.get('protocol')!='aim_output_v1' or reports.get('result_protocol')!='aim_operation_result_v1'
+ or not {'per_host','global'}<=set(reports.get('scopes',[])) or reports.get('raw_output')is not False):
+ raise WebError('core_incompatible','This release requires the Core aim_operation_result_v1 contract.',503)
+ baselines=caps.get('collection_baselines',{})
+ if baselines.get('ansible.windows')!='>=3.8.0,<4.0.0':
+ raise WebError('core_incompatible','This release requires Core to advertise ansible.windows >=3.8.0,<4.0.0.',503)
+ self._caps=caps
+ return self._caps
+ @property
+ def version(self):return self.capabilities['core_version']
+ def customers(self):
+ self.capabilities
+ return [{'name':x,'host_count':None,'status':'Available via core'} for x in self.client.request('list_customers')]
+ def customer_path(self,customer):
+ # Historical UI caller name; this validates an ID, never returns an OS path.
+ if customer not in {x['name'] for x in self.customers()}:
+ raise WebError('customer_unavailable','Customer unavailable.',404)
+ return customer
+ def inventory_hierarchy(self,customer):
+ self.capabilities
+ result=self.client.request('inventory_hierarchy',customer=customer)
+ if not isinstance(result,dict) or result.get('schema')!='inventory_hierarchy_v1' or result.get('customer')!=customer:
+ raise WebError('core_protocol','AIM returned an invalid inventory hierarchy response.',502)
+ return result
+ def hierarchy_groups(self,customer):
+ hierarchy=self.inventory_hierarchy(customer); groups=[]
+ def walk(nodes):
+ for node in nodes:
+ children=walk(node.get('children',[]))
+ direct=set(node.get('hosts',[])); recursive=set(direct)
+ for child in children:recursive.update(child['hosts'])
+ item={'name':'/'.join(node['path']),'label':node['name'],'path':list(node['path']),
+ 'direct_hosts':sorted(direct,key=str.casefold),'hosts':sorted(recursive,key=str.casefold),'children':children}
+ groups.append(item)
+ return [g for g in groups if len(g['path']) and g['path'][-1] in {n.get('name') for n in nodes}]
+ # Build recursively without relying on a competing parser; paths/hosts are Core facts.
+ groups=[]
+ def build(node):
+ kids=[build(child) for child in node.get('children',[])]
+ recursive=set(node.get('hosts',[]))
+ for child in kids:recursive.update(child['hosts'])
+ return {'name':'/'.join(node['path']),'label':node['name'],'path':list(node['path']),
+ 'direct_hosts':list(node.get('hosts',[])),'hosts':sorted(recursive,key=str.casefold),'children':kids}
+ roots=[build(node) for node in hierarchy.get('groups',[])]
+ def flatten(nodes):
+ out=[]
+ for node in nodes:out.append(node);out.extend(flatten(node['children']))
+ return out
+ return {'direct_hosts':list(hierarchy.get('hosts',[])),'roots':roots,'groups':flatten(roots)}
+ def hosts(self,customer):
+ self.capabilities
+ items=[{'name':h['name'],'address':h.get('address'),'groups':list(h.get('platforms',[])),
+ 'platforms':list(h.get('platforms',[]))} for h in self.client.request('list_hosts',customer=customer)]
+ by_name={h['name']:h for h in items}
+ hierarchy=self.hierarchy_groups(customer)
+ for group in hierarchy['groups']:
+ label=group['name']
+ for host in group['hosts']:
+ if host in by_name and label not in by_name[host]['groups']:by_name[host]['groups'].append(label)
+ for host in items:host['groups'].sort(key=str.casefold)
+ return items
+ def playbooks(self,customer=None):
+ self.capabilities
+ if customer is not None:
+ return [dict(x,available=True) for x in self.client.request('list_playbooks',customer=customer)]
+ # The contract exposes a customer-scoped catalog only. Union available entries;
+ # do not manufacture unavailable entries from old private catalog files.
+ result={}
+ for item in self.customers():
+ for pb in self.playbooks(item['name']):result.setdefault(pb['key'],pb)
+ return list(result.values())
+ def spec(self,key,customer=None):
+ for p in self.playbooks(customer):
+ if p['key']==key:
+ p=dict(p);p['inputs']=tuple(SimpleNamespace(**x) for x in p.get('inputs',[]))
+ return SimpleNamespace(**p)
+ raise WebError('unknown_playbook','Playbook is not available for the selected customer.',404)
+ def typed_options(self,spec,values):
+ fields={x.name:x for x in spec.inputs};result={}
+ if not isinstance(values,dict) or set(values)-set(fields):raise WebError('invalid_options','Use declared catalog options only.')
+ for name,value in values.items():
+ if not isinstance(value,str):raise WebError('invalid_options','Form inputs must be text.')
+ if value=='':continue
+ kind=fields[name].type
+ try:
+ if kind=='bool':
+ if value.lower() not in {'true','false'}:raise ValueError()
+ result[name]=value.lower()=='true'
+ elif kind=='int':result[name]=int(value)
+ elif kind=='serial':result[name]=int(value) if value.strip().isdigit() else value.strip()
+ elif kind in {'list','sequence'}:
+ # JSON is the explicit interoperable wire type, not arbitrary YAML tags.
+ result[name]=json.loads(value) if value.strip().startswith('[') else [x.strip() for x in value.split(',')]
+ elif kind=='secret_ref':
+ if re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*',value.strip()):result[name]='{{ '+value.strip()+' }}'
+ else:result[name]=value # core validates the canonical reference, never a password
+ else:result[name]=value
+ except (ValueError,TypeError):raise WebError('invalid_options','An input could not be converted to its declared type.') from None
+ return result
+ def preflight(self,customer,playbook,targets,overrides,*,text_inputs=False,check=True,key_mode='none'):
+ self.capabilities
+ spec=self.spec(playbook,customer)
+ if text_inputs:overrides=self.typed_options(spec,overrides)
+ request={'customer':customer,'playbook':playbook,'hosts':targets,'overrides':overrides,
+ 'check':check,'key_mode':key_mode,'become_password':False,'timeout_seconds':self.settings.execution_timeout_seconds,
+ 'progress_mode':'detail'}
+ prepared=self.client.request('prepare',request=request)
+ req=prepared['request']; requirements=prepared['credential_requirements']
+ return {'customer':req['customer'],'playbook':req['playbook'],'targets':req['hosts'],
+ 'overrides':req['overrides'],'target_limit':','.join(req['hosts']),
+ 'required_collections':list(spec.requirements),'requires_vault_prompt':'vault_password' in requirements,
+ 'requires_connection_password':'connection_password' in requirements,
+ 'core_version':self.version,'core_api':'1.0','core_request':req,'core_revision':prepared['revision'],
+ 'result_contract':contract(prepared.get('result_contract')),
+ 'inventory_revision':prepared['revision'],'credential_requirements':requirements,
+ 'credential_requirement_reasons':prepared.get('credential_requirement_reasons',{}),
+ 'warnings':prepared.get('warnings',[]),'revision_coverage':prepared.get('revision_coverage',''),
+ 'authentication':{'mode':'inventory','key_mode':req['key_mode']}}
+ def readiness(self,plan):return self.client.request('readiness',request=plan['core_request'])
+ def reprepare(self,plan):
+ req=plan.get('core_request')
+ if not isinstance(req,dict):raise WebError('legacy_plan','This plan predates the core API. Reuse its targets in New run and review again.',409)
+ return self.preflight(req['customer'],req['playbook'],req['hosts'],req['overrides'],check=req['check'],key_mode=req['key_mode'])
diff --git a/scripts/addons/webgui/src/aim_webgui/app.py b/scripts/addons/webgui/src/aim_webgui/app.py
new file mode 100644
index 0000000..b3ddd2b
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/app.py
@@ -0,0 +1,406 @@
+from __future__ import annotations
+
+from dataclasses import asdict
+from pathlib import Path
+from urllib.parse import parse_qs, urlsplit, urlencode
+import json
+import logging
+from datetime import datetime, timezone
+
+from fastapi import FastAPI, Request
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+from starlette.concurrency import run_in_threadpool
+from starlette.middleware.trustedhost import TrustedHostMiddleware
+
+from aim_webgui import __version__, SCHEMA_VERSION
+from aim_webgui.adapters.core_v1 import CoreAdapter
+from aim_webgui.auth.service import Auth
+from aim_webgui.workflows import Workflows
+from aim_webgui.credentials.presentation import attention
+from aim_webgui.config import Settings
+from aim_webgui.errors import WebError
+from aim_webgui.security import RequestPolicy, same_origin
+
+ROOT = Path(__file__).parent
+TEMPLATES = Jinja2Templates(directory=str(ROOT / 'templates'))
+LOG = logging.getLogger('aim_webgui')
+from aim_webgui.activity import host_url, explorer_url
+from aim_webgui.read_views import install_read_views
+from aim_webgui.evidence_views import install_evidence_views
+from aim_webgui.reports import cell as report_cell, label as report_label
+TEMPLATES.env.globals.update(host_url=host_url, explorer_url=explorer_url)
+TEMPLATES.env.filters['report_cell']=report_cell
+TEMPLATES.env.filters['report_label']=report_label
+TEMPLATES.env.filters['utc'] = lambda value: datetime.fromtimestamp(value, timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC') if value is not None else '-'
+TEMPLATES.env.filters['utc_iso'] = lambda value: datetime.fromtimestamp(value, timezone.utc).isoformat().replace('+00:00', 'Z') if value is not None else ''
+
+
+def create_app(settings: Settings) -> RequestPolicy:
+ settings.validate()
+ auth = Auth(settings)
+ workflows = Workflows(settings)
+ auth.store.check()
+ # Login/local recovery remain available if the independent executor is offline.
+ # All core operations and execution still fail closed through CoreAdapter.
+ app = FastAPI(title='AIM WebGUI', version=__version__, docs_url=None, redoc_url=None, openapi_url=None)
+ app.state.auth, app.state.settings = auth, settings
+ app.add_middleware(TrustedHostMiddleware, allowed_hosts=list({urlsplit(settings.public_url).hostname,
+ '127.0.0.1', 'localhost', '[::1]'}))
+ app.mount('/static', StaticFiles(directory=ROOT / 'static', follow_symlink=False), name='static')
+
+ def render(request, template, *, status=200, **context):
+ session = getattr(request.state, 'session', None)
+ values = dict(request=request, session=session, version=__version__,
+ csrf=session['csrf'] if session else '', title='Controller overview',
+ nav='overview', error=None, execution_enabled=settings.execution_enabled, require_approval=settings.execution_require_approval)
+ values.update(context)
+ return TEMPLATES.TemplateResponse(request=request, name=template, context=values, status_code=status)
+
+ def error_response(request, exc: WebError):
+ if request.url.path.startswith('/api/'):
+ return JSONResponse({'error': {'code': exc.code, 'message': exc.message}}, status_code=exc.status)
+ template = 'partials/error.html' if request.headers.get('HX-Request') == 'true' else 'pages/error.html'
+ return render(request, template, status=exc.status, error=exc.message, title='Request could not be completed')
+
+ def redirect(request, path):
+ if request.headers.get('HX-Request') == 'true':
+ return HTMLResponse('', status_code=200, headers={'HX-Redirect': path})
+ return RedirectResponse(path, status_code=303)
+
+ def session_cookie(response, token):
+ response.set_cookie(settings.cookie_name, token, httponly=True, secure=settings.secure_cookie,
+ samesite='lax', path='/', max_age=settings.session_hours * 3600)
+ return response
+
+ async def form(request: Request) -> dict[str, list[str]]:
+ if request.headers.get('content-type', '').split(';')[0] != 'application/x-www-form-urlencoded':
+ raise WebError('unsupported_content_type', 'Use URL-encoded form data.', 415)
+ try:
+ return parse_qs((await request.body()).decode('utf-8'), keep_blank_values=True, max_num_fields=1500)
+ except (ValueError, UnicodeError):
+ raise WebError('invalid_form', 'Invalid form data.') from None
+
+ def one(values, key, default=''):
+ items = values.get(key, [default])
+ if len(items) != 1:
+ raise WebError('duplicate_field', 'Duplicate form field.')
+ return items[0]
+
+ def administrator(request):
+ session = request.state.session
+ if session['role'] != 'admin':
+ raise WebError('admin_required', 'Administrator access is required.', 403)
+ return session['user_id']
+
+ async def adapter_call(method, *args, **kwargs):
+ def call():
+ return getattr(CoreAdapter(settings), method)(*args, **kwargs)
+ return await run_in_threadpool(call)
+
+ @app.exception_handler(WebError)
+ async def web_error(request, exc):
+ return error_response(request, exc)
+
+ @app.exception_handler(RequestValidationError)
+ async def validation_error(request, exc):
+ return error_response(request, WebError('invalid_request', 'Request parameters are invalid.', 422))
+
+ @app.middleware('http')
+ async def gate(request, call_next):
+ path = request.url.path
+ try:
+ exempt = path in {'/healthz', '/readyz'} or path.startswith('/static/')
+ session = None if exempt else await run_in_threadpool(auth.session, request.cookies.get(settings.cookie_name))
+ request.state.session = session
+ if not exempt and path != '/login':
+ if not session or not session['user_id']:
+ if path.startswith('/api/'):
+ raise WebError('login_required', 'Sign in to access the API.', 401)
+ return redirect(request, '/login')
+ if session['must_change_password'] and path not in {'/password', '/logout', '/api/v2/session'}:
+ if path.startswith('/api/'):
+ raise WebError('password_change_required', 'Change the initial password before continuing.', 403)
+ return redirect(request, '/password')
+ if request.method not in {'GET', 'HEAD', 'OPTIONS'}:
+ fetch_site = request.headers.get('sec-fetch-site', '').strip().lower()
+ if fetch_site == 'cross-site':
+ raise WebError('origin_rejected', 'Cross-site request rejected.', 403)
+
+ # Origin is authoritative when the browser supplies it. Some browser/privacy
+ # combinations omit Origin for same-origin form POSTs, so accept a verified
+ # same-origin Referer as the fallback. If both are absent, only modern browser
+ # requests explicitly marked same-origin by Fetch Metadata may continue. CSRF
+ # token validation remains mandatory in all cases below.
+ origin = request.headers.get('origin', '').strip()
+ referer = request.headers.get('referer', '').strip()
+ if origin:
+ if origin == 'null' or not same_origin(origin, settings.public_url):
+ raise WebError('origin_rejected', 'Request Origin header does not match WebGUI public_url.', 403)
+ elif referer:
+ if not same_origin(referer, settings.public_url):
+ raise WebError('origin_rejected', 'Request Referer header does not match WebGUI public_url.', 403)
+ elif fetch_site != 'same-origin':
+ raise WebError('origin_rejected', 'Request is missing same-origin browser metadata.', 403)
+
+ token = request.headers.get('X-CSRF-Token', '')
+ if not token and request.headers.get('content-type', '').startswith('application/x-www-form-urlencoded'):
+ token = one(await form(request), '_csrf')
+ auth.csrf(session, token)
+ return await call_next(request)
+ except WebError as exc:
+ return error_response(request, exc)
+ except Exception as exc:
+ # Do not log exception text/tracebacks containing inventories or form values.
+ LOG.error('Request failed (%s); inspect configuration and permissions locally.', type(exc).__name__)
+ return error_response(request, WebError('internal_error', 'The request failed. Check the service logs and local configuration.', 500))
+
+ install_read_views(app, settings, render)
+ install_evidence_views(app, settings, auth, render)
+
+ @app.get('/healthz')
+ async def health():
+ return {'status': 'ok'}
+
+ @app.get('/readyz')
+ async def ready():
+ try:
+ await run_in_threadpool(auth.store.check)
+ await run_in_threadpool(lambda: CoreAdapter(settings).customers())
+ return {'status': 'ready'}
+ except Exception:
+ return JSONResponse({'status': 'not_ready'}, status_code=503)
+
+ @app.get('/login')
+ async def login_page(request: Request):
+ if request.state.session and request.state.session['user_id']:
+ return redirect(request, '/password' if request.state.session['must_change_password'] else '/')
+ peer = request.client.host if request.client else 'unknown'
+ if not request.state.session:
+ await run_in_threadpool(auth.throttle, [('login-page:' + peer, 120)], window=60)
+ token, session = await run_in_threadpool(auth.new_session)
+ request.state.session = session
+ return session_cookie(render(request, 'pages/login.html', title='Sign in'), token)
+ return render(request, 'pages/login.html', title='Sign in')
+
+ @app.post('/login')
+ async def login_submit(request: Request):
+ values = await form(request)
+ try:
+ token, session = await run_in_threadpool(auth.login, one(values, 'username'), one(values, 'password'),
+ request.cookies.get(settings.cookie_name, ''),
+ request.client.host if request.client else 'unknown')
+ except WebError as exc:
+ return render(request, 'pages/login.html', title='Sign in', error=exc.message, status=exc.status)
+ return session_cookie(redirect(request, '/password' if session['must_change_password'] else '/'), token)
+
+ @app.get('/password')
+ async def password_page(request: Request):
+ return render(request, 'pages/password.html', title='Change password', nav='account')
+
+ @app.post('/password')
+ async def password_submit(request: Request):
+ values = await form(request)
+ if one(values, 'password') != one(values, 'confirm'):
+ return render(request, 'pages/password.html', title='Change password', error='New passwords do not match.', status=400)
+ try:
+ await run_in_threadpool(auth.change_password, request.state.session['user_id'],
+ one(values, 'current_password'), one(values, 'password'))
+ except WebError as exc:
+ return render(request, 'pages/password.html', title='Change password', error=exc.message, status=exc.status)
+ response = redirect(request, '/login') # All sessions revoked; authenticate using the new password.
+ response.delete_cookie(settings.cookie_name, path='/', secure=settings.secure_cookie, httponly=True, samesite='lax')
+ return response
+
+ @app.post('/logout')
+ async def logout(request: Request):
+ await run_in_threadpool(auth.logout, request.cookies.get(settings.cookie_name, ''))
+ response = redirect(request, '/login')
+ response.delete_cookie(settings.cookie_name, path='/', secure=settings.secure_cookie, httponly=True, samesite='lax')
+ return response
+
+ @app.get('/')
+ async def overview(request: Request):
+ customers = await adapter_call('customers')
+ playbooks = await adapter_call('playbooks')
+ return render(request, 'pages/overview.html', customers=customers, playbooks=playbooks,
+ host_count=None, core=await run_in_threadpool(lambda: CoreAdapter(settings).capabilities),
+ recent_jobs=(await run_in_threadpool(workflows.jobs,request.state.session['user_id']))[:5],
+ attention=await run_in_threadpool(attention,workflows,request.state.session['user_id']))
+
+ @app.get('/customers')
+ async def customers_page(request: Request):
+ return render(request, 'pages/customers.html', title='Customers', nav='customers', customers=await adapter_call('customers'))
+
+ async def host_view(customer, q, group, sort, page):
+ all_hosts = await adapter_call('hosts', customer)
+ groups = sorted({g for h in all_hosts for g in h['groups']}, key=str.casefold)
+ q = q[:200]
+ filtered = [h for h in all_hosts if q.casefold() in (h['name'] + ' ' + (h['address'] or '') + ' ' + ' '.join(h['groups'])).casefold()
+ and (not group or group in h['groups'])]
+ if sort not in {'name','address','group'}:
+ sort = 'name'
+ filtered.sort(key=lambda h: (' '.join(h['groups']) if sort == 'group' else (h[sort] or '')).casefold())
+ pages = max(1, (len(filtered)+99)//100)
+ page = max(1, min(page, pages))
+ def link(number):
+ return '/customers/' + customer + '/hosts?' + urlencode({'q':q,'group':group,'sort':sort,'page':number})
+ return dict(customer=customer, hosts=filtered[(page-1)*100:page*100], q=q, group=group, sort=sort,
+ groups=groups, matched=len(filtered), total=len(all_hosts), page=page, pages=pages,
+ previous_url=link(page-1) if page>1 else None, next_url=link(page+1) if page str:
+ normalized = value.strip().lower()
+ if not USER_RE.fullmatch(normalized):
+ raise WebError('invalid_username', 'Use 3-64 lowercase letters, numbers, dots, underscores or hyphens; start with a letter.')
+ return normalized
+
+
+def password_policy(value: str) -> None:
+ if not isinstance(value, str) or not 15 <= len(value) <= 128 or '\x00' in value:
+ raise WebError('password_policy', 'Use a password or passphrase of 15-128 characters without NUL characters.')
+
+
+def verify(encoded: str, supplied: str) -> bool:
+ if not isinstance(supplied, str) or len(supplied) > 128:
+ return False
+ try:
+ return HASHER.verify(encoded, supplied)
+ except (VerificationError, InvalidHashError):
+ return False
+
+
+def digest(token: str) -> str:
+ return hashlib.sha256(token.encode('utf-8')).hexdigest()
+
+
+class Auth:
+ def __init__(self, settings: Settings):
+ self.settings = settings
+ self.store = Store(settings.database)
+
+ def bootstrap(self) -> bool:
+ """Explicit and restartable first initialization, never triggered by HTTP.
+
+ A pre-commit crash leaves the same protected credential file for retry.
+ A missing database after a completed install requires deliberate recovery.
+ """
+ ensure_private_dir(self.settings.state_dir)
+ lock_fd = os.open(self.settings.state_dir / '.init.lock',
+ os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
+ try:
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
+ marker = self.settings.state_dir / '.initialized'
+ if marker.exists() and not self.settings.database.exists():
+ raise ValueError('An initialized database is missing. Restore a backup; admin was not recreated.')
+ self.store.migrate(create=True)
+ with self.store.transaction() as db:
+ if db.execute("SELECT 1 FROM metadata WHERE key='initialized'").fetchone():
+ # Repair a marker missing after a post-commit crash, without resetting admin.
+ if not marker.exists():
+ private_file(marker, 'Initialized; restore database if it is missing.\n')
+ # Upgrade/reinstall does NOT regenerate credentials, even if deleted.
+ return False
+ if db.execute('SELECT COUNT(*) FROM users').fetchone()[0]:
+ raise ValueError('Accounts exist in an uninitialized database; refusing automatic bootstrap.')
+ credentials = self.settings.credentials
+ if credentials.exists() or credentials.is_symlink():
+ mode = credentials.lstat()
+ if (not stat.S_ISREG(mode.st_mode) or mode.st_uid != os.geteuid()
+ or stat.S_IMODE(mode.st_mode) & 0o077):
+ raise ValueError('Bootstrap credential file must be a service-owned regular 0600 file.')
+ record = json.loads(credentials.read_text(encoding='utf-8'))
+ if record.get('username') != 'admin' or record.get('purpose') != 'aim-web-first-install':
+ raise ValueError('Unrecognized existing bootstrap credential file.')
+ secret = record['password']
+ password_policy(secret)
+ else:
+ secret = secrets.token_urlsafe(24)
+ record = {'purpose': 'aim-web-first-install', 'username': 'admin', 'password': secret,
+ 'created_at': datetime.now(timezone.utc).isoformat(),
+ 'notice': 'Change at first login. This file is never served over HTTP.'}
+ private_file(credentials, json.dumps(record, indent=2) + '\n')
+ now = int(time.time())
+ db.execute('INSERT INTO users(username,password_hash,role,created_at,updated_at) VALUES(?,?,?,?,?)',
+ ('admin', HASHER.hash(secret), 'admin', now, now))
+ db.execute("INSERT INTO metadata(key,value) VALUES('initialized',?)", (str(now),))
+ audit(db, 'local-installer', 'bootstrap', 'admin')
+ if not marker.exists():
+ private_file(marker, 'Initialized; restore database if it is missing.\n')
+ return True
+ finally:
+ os.close(lock_fd)
+
+ def new_session(self, user_id: int | None = None) -> tuple[str, dict]:
+ token, csrf, now = secrets.token_urlsafe(32), secrets.token_urlsafe(32), int(time.time())
+ ttl = self.settings.session_hours * 3600 if user_id else 600
+ with self.store.transaction() as db:
+ db.execute('DELETE FROM sessions WHERE expires_at < ? OR last_seen_at < ?',
+ (now, now - self.settings.idle_minutes * 60))
+ # Bound anonymous session accumulation (plus proxy-level request limits).
+ if db.execute('SELECT COUNT(*) FROM sessions').fetchone()[0] >= 10000:
+ raise WebError('session_capacity', 'Session capacity reached. Try later.', 503)
+ db.execute('INSERT INTO sessions VALUES(?,?,?,?,?,?)',
+ (digest(token), user_id, csrf, now, now + ttl, now))
+ return token, self.session(token)
+
+ def session(self, token: str | None) -> dict | None:
+ if not token or len(token) > 100:
+ return None
+ now = int(time.time())
+ with self.store.transaction() as db:
+ row = db.execute('''SELECT s.*, u.username, u.role, u.enabled, u.must_change_password
+ FROM sessions s LEFT JOIN users u ON u.id=s.user_id WHERE s.token_hash=?''',
+ (digest(token),)).fetchone()
+ if not row:
+ return None
+ idle = self.settings.idle_minutes * 60 if row['user_id'] else 600
+ if (row['expires_at'] <= now or row['last_seen_at'] + idle <= now
+ or (row['user_id'] is not None and not row['enabled'])):
+ db.execute('DELETE FROM sessions WHERE token_hash=?', (digest(token),))
+ return None
+ if now - row['last_seen_at'] >= 60:
+ db.execute('UPDATE sessions SET last_seen_at=? WHERE token_hash=?', (now, digest(token)))
+ return dict(row)
+
+ @staticmethod
+ def csrf(session: dict | None, supplied: str) -> None:
+ if not session or not supplied or len(supplied) > 100 or not hmac.compare_digest(session['csrf'], supplied):
+ raise WebError('csrf_failed', 'Security token expired or missing. Reload the page and retry.', 403)
+
+ def throttle(self, bucket_values: list[tuple[str, int]], *, window: int = 300) -> None:
+ now = int(time.time())
+ with self.store.transaction() as db:
+ # A short-window bucket must never erase a longer-window login bucket.
+ db.execute('DELETE FROM rate_limits WHERE started < ?', (now - 3600,))
+ reservations = []
+ for value, limit in bucket_values:
+ bucket = digest(value)
+ row = db.execute('SELECT started,attempts FROM rate_limits WHERE bucket=?', (bucket,)).fetchone()
+ started, count = (row['started'], row['attempts']) if row and now - row['started'] < window else (now, 0)
+ if count >= limit:
+ raise WebError('login_throttled', 'Too many authentication attempts. Try again in five minutes.', 429)
+ reservations.append((bucket, started, count + 1))
+ for reservation in reservations:
+ db.execute("""INSERT INTO rate_limits VALUES(?,?,?)
+ ON CONFLICT(bucket) DO UPDATE SET started=excluded.started,attempts=excluded.attempts""", reservation)
+
+ def login(self, name: str, password: str, old_token: str, peer: str) -> tuple[str, dict]:
+ canonical = name.strip().lower()[:64]
+ self.throttle([('user:' + canonical, 10), ('peer:' + peer, 60), ('login-global', 200)])
+ with self.store.read() as db:
+ user = db.execute('SELECT * FROM users WHERE username=?', (canonical,)).fetchone()
+ encoded = user['password_hash'] if user else DUMMY_HASH
+ correct = verify(encoded, password)
+ if not user or not user['enabled'] or not correct:
+ with self.store.transaction() as db:
+ audit(db, canonical if USER_RE.fullmatch(canonical) else 'invalid-identifier', 'login-failed', 'authentication')
+ raise WebError('invalid_login', 'Invalid username or password.', 401)
+ rehash = HASHER.hash(password) if HASHER.check_needs_rehash(encoded) else encoded
+ with self.store.transaction() as db:
+ # Recheck after expensive hash, so disable/reset races cannot authenticate stale credentials.
+ current = db.execute('SELECT * FROM users WHERE id=?', (user['id'],)).fetchone()
+ if not current['enabled'] or current['password_hash'] != encoded:
+ raise WebError('invalid_login', 'Invalid username or password.', 401)
+ db.execute('UPDATE users SET password_hash=?,last_login_at=? WHERE id=?',
+ (rehash, int(time.time()), user['id']))
+ db.execute('DELETE FROM sessions WHERE token_hash=?', (digest(old_token),))
+ audit(db, canonical, 'login', canonical)
+ return self.new_session(user['id'])
+
+ def logout(self, token: str) -> None:
+ with self.store.transaction() as db:
+ user = db.execute('SELECT users.username FROM sessions JOIN users ON users.id=sessions.user_id WHERE token_hash=?', (digest(token),)).fetchone()
+ db.execute('DELETE FROM sessions WHERE token_hash=?', (digest(token),))
+ if user:
+ audit(db, user['username'], 'logout', user['username'])
+
+ def change_password(self, user_id: int, old: str, new: str) -> None:
+ password_policy(new)
+ self.throttle([('change:' + str(user_id), 10)])
+ with self.store.read() as db:
+ user = db.execute('SELECT * FROM users WHERE id=?', (user_id,)).fetchone()
+ if not user or not user['enabled'] or not verify(user['password_hash'], old):
+ raise WebError('invalid_password', 'Current password was not accepted.', 403)
+ if verify(user['password_hash'], new):
+ raise WebError('password_unchanged', 'Choose a password different from the current password.')
+ encoded = HASHER.hash(new)
+ with self.store.transaction() as db:
+ current = db.execute('SELECT * FROM users WHERE id=?', (user_id,)).fetchone()
+ if not current['enabled'] or current['password_hash'] != user['password_hash']:
+ raise WebError('account_changed', 'Account changed; sign in again.', 409)
+ db.execute('UPDATE users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=?',
+ (encoded, int(time.time()), user_id))
+ db.execute('DELETE FROM sessions WHERE user_id=?', (user_id,))
+ audit(db, user['username'], 'password-changed', user['username'])
+ if user['username'] == 'admin':
+ self.consume_bootstrap()
+
+ def consume_bootstrap(self) -> None:
+ # Password is already invalidated. Never rename a file still containing it.
+ self.settings.credentials.unlink(missing_ok=True)
+ used = self.settings.state_dir / '.credentials.used'
+ if not used.exists():
+ private_file(used, 'Bootstrap credentials invalidated at ' + datetime.now(timezone.utc).isoformat() + '\n')
+
+ def users(self) -> list[dict]:
+ with self.store.read() as db:
+ return [dict(r) for r in db.execute('''SELECT id,username,role,enabled,must_change_password,
+ created_at,last_login_at FROM users ORDER BY username''')]
+
+ @staticmethod
+ def require_admin(db, actor_id: int | None) -> str:
+ if actor_id is None: # Local CLI: enforced by private state ownership, never exposed as an HTTP argument.
+ return 'local-console'
+ actor = db.execute('SELECT * FROM users WHERE id=?', (actor_id,)).fetchone()
+ if not actor or not actor['enabled'] or actor['role'] != 'admin' or actor['must_change_password']:
+ raise WebError('admin_required', 'Administrator access is required.', 403)
+ return actor['username']
+
+ def create_user(self, name: str, password: str, role: str = 'viewer', *, actor_id: int | None = None) -> None:
+ name = username(name)
+ password_policy(password)
+ if role not in {'admin', 'viewer'}:
+ raise WebError('invalid_role', 'Unknown role.')
+ encoded, now = HASHER.hash(password), int(time.time())
+ with self.store.transaction() as db:
+ actor = self.require_admin(db, actor_id)
+ if db.execute('SELECT 1 FROM users WHERE username=?', (name,)).fetchone():
+ raise WebError('user_exists', 'That username already exists.', 409)
+ db.execute('INSERT INTO users(username,password_hash,role,created_at,updated_at) VALUES(?,?,?,?,?)',
+ (name, encoded, role, now, now))
+ audit(db, actor, 'user-created', name)
+
+ def manage_user(self, name: str, action: str, *, password: str | None = None,
+ actor_id: int | None = None) -> None:
+ name = username(name)
+ allowed = {'enable', 'disable', 'promote', 'demote', 'revoke', 'reset-password'}
+ if action not in allowed:
+ raise WebError('invalid_action', 'Unknown user action.')
+ encoded = None
+ if action == 'reset-password':
+ password_policy(password)
+ encoded = HASHER.hash(password)
+ with self.store.transaction() as db:
+ actor = self.require_admin(db, actor_id)
+ user = db.execute('SELECT * FROM users WHERE username=?', (name,)).fetchone()
+ if not user:
+ raise WebError('user_not_found', 'Account not found.', 404)
+ if action in {'disable', 'demote'} and user['enabled'] and user['role'] == 'admin':
+ count = db.execute("SELECT COUNT(*) FROM users WHERE enabled=1 AND role='admin'").fetchone()[0]
+ if count <= 1:
+ raise WebError('last_admin', 'The last enabled administrator cannot be disabled or demoted.', 409)
+ if action in {'enable', 'disable'}:
+ db.execute('UPDATE users SET enabled=? WHERE id=?', (int(action == 'enable'), user['id']))
+ elif action in {'promote', 'demote'}:
+ db.execute('UPDATE users SET role=? WHERE id=?', ('admin' if action == 'promote' else 'viewer', user['id']))
+ elif action == 'reset-password':
+ db.execute('UPDATE users SET password_hash=?,must_change_password=1 WHERE id=?', (encoded, user['id']))
+ db.execute('UPDATE users SET updated_at=? WHERE id=?', (int(time.time()), user['id']))
+ db.execute('DELETE FROM sessions WHERE user_id=?', (user['id'],))
+ audit(db, actor, 'user-' + action, name)
+ if name == 'admin' and action == 'reset-password':
+ self.consume_bootstrap()
diff --git a/scripts/addons/webgui/src/aim_webgui/cli.py b/scripts/addons/webgui/src/aim_webgui/cli.py
new file mode 100644
index 0000000..c2072f4
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/cli.py
@@ -0,0 +1,199 @@
+from __future__ import annotations
+
+import argparse
+import getpass
+import logging
+import json
+import subprocess
+import os
+import pwd
+import stat
+import tempfile
+from pathlib import Path
+import sys
+
+from aim_webgui import __version__
+from aim_webgui.config import DEFAULT_CONFIG, Settings
+
+
+def read_password() -> str:
+ from aim_webgui.auth.service import password_policy
+ if not sys.stdin.isatty():
+ raise ValueError('Use an interactive terminal. Passwords are not accepted as command-line arguments.')
+ first = getpass.getpass('New password: ')
+ if first != getpass.getpass('Confirm password: '):
+ raise ValueError('Passwords do not match.')
+ password_policy(first)
+ return first
+
+
+def main() -> None:
+ os.umask(0o077)
+ sys.dont_write_bytecode = True
+ parser = argparse.ArgumentParser(prog='aim-web', description='Independent WebGUI add-on; never updates AIM.')
+ parser.add_argument('--version', action='version', version=f'AIM WebGUI {__version__} (AIM compatibility: 3.3.0rc8 / service API 1.0)')
+ parser.add_argument('--config', type=Path, default=DEFAULT_CONFIG)
+ commands = parser.add_subparsers(dest='command', required=True)
+ commands.add_parser('serve', help='Serve on the configured interface; reverse-proxy settings are explicit.')
+ commands.add_parser('credential-check', help='Check core contract/credential fields; no passwords or remote contacts.')
+ commands.add_parser('executor', help='Pre-started local executor; run only as the configured execution identity.')
+ core_check=commands.add_parser('core-check', help='Core discovery, and optional prepared-run readiness, under the executor identity.')
+ core_check.add_argument('--customer')
+ core_check.add_argument('--playbook',default='debug_test_connection')
+ core_check.add_argument('--host',action='append',dest='hosts')
+ core_check.add_argument('--key-mode',choices=['none','customer'],default='none')
+ commands.add_parser('core-staging-check', help='Run Core 3.2 controller staging preflight under the executor identity.')
+ commands.add_parser('config-check', help='Validate TOML only; safe as root, no database access.')
+ status = commands.add_parser('status', help='Read systemd state and configured listener without opening SQLite.')
+ status.add_argument('--json', action='store_true')
+ doctor = commands.add_parser('doctor', help='Read-only diagnostics; run under the aim-web service identity.')
+ doctor.add_argument('--json', action='store_true')
+ worker = commands.add_parser('worker', help='Run the opt-in single-job worker, separate from HTTP.')
+ worker.add_argument('--once', action='store_true')
+ commands.add_parser('init', help='Explicit idempotent first initialization; never reset existing admin.')
+ check = commands.add_parser('check', help='Check core integration and optional database readiness.')
+ check.add_argument('--without-db', action='store_true')
+ check.add_argument('--without-core', action='store_true',help='Package/assets/state check only; deployment staging use.')
+ db = commands.add_parser('db').add_subparsers(dest='db_command', required=True)
+ db.add_parser('migrate', help='Apply forward migrations; stop service and back up first.')
+ backup = db.add_parser('backup', help='Use SQLite backup API, not a raw live copy.')
+ backup.add_argument('destination', type=Path)
+ users = commands.add_parser('user').add_subparsers(dest='user_command', required=True)
+ users.add_parser('list')
+ create = users.add_parser('create')
+ create.add_argument('username')
+ create.add_argument('--role', choices=['viewer', 'admin'], default='viewer')
+ for action in ('enable', 'disable', 'promote', 'demote', 'revoke', 'reset-password'):
+ users.add_parser(action).add_argument('username')
+ args = parser.parse_args()
+ logging.basicConfig(level=logging.INFO, format='%(levelname)s %(name)s: %(message)s')
+ try:
+ settings = Settings.load(args.config)
+ if args.command == 'executor':
+ from aim_webgui.core.executor import Executor
+ Executor(settings).run()
+ return
+ if args.command == 'core-staging-check':
+ # Core validates its controller-local staging path. Ansible's local
+ # connection plugin independently expands ~/.ansible/tmp
+ # for delegated localhost tasks, so validate that path from inside
+ # the same systemd sandbox before accepting executor readiness.
+ account = pwd.getpwuid(os.geteuid())
+ local_base = Path(account.pw_dir) / '.ansible'
+ local_tmp = local_base / 'tmp'
+ for path in (local_base, local_tmp):
+ st = path.lstat()
+ if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
+ raise ValueError(f'Executor delegated-local staging path is unsafe: {path}')
+ if st.st_uid != account.pw_uid or st.st_gid != account.pw_gid or stat.S_IMODE(st.st_mode) != 0o700:
+ raise ValueError(f'Executor delegated-local staging ownership/mode is unsafe: {path}')
+ fd, probe = tempfile.mkstemp(prefix='.aim-web-probe-', dir=local_tmp)
+ os.close(fd)
+ Path(probe).unlink()
+ from dataclasses import replace
+ from aim_webgui.core.client import CoreClient
+ result = CoreClient(replace(settings, core_transport='stdio')).request('staging_check')
+ result['delegated_local_tmp'] = {'path': str(local_tmp), 'writable': True, 'private': True}
+ print(json.dumps(result, indent=2))
+ return
+ if args.command in {'credential-check','core-check'}:
+ from aim_webgui.adapters.core_v1 import CoreAdapter
+ adapter=CoreAdapter(settings)
+ result={'core':adapter.capabilities,'customers':adapter.customers()}
+ if args.command=='core-check' and (args.customer or args.hosts):
+ if not args.customer or not args.hosts:raise ValueError('Provide both --customer and at least one --host.')
+ plan=adapter.preflight(args.customer,args.playbook,args.hosts,{},check=True,key_mode=args.key_mode)
+ result['readiness']=adapter.readiness(plan)
+ print(json.dumps(result,indent=2))
+ print('No passwords read or hosts contacted. Readiness is local, not a live execution qualification.')
+ return
+ if args.command == 'config-check':
+ print(f'Configuration valid: {args.config}; public origin: {settings.public_url}; execution={settings.execution_enabled}')
+ return
+ if args.command == 'status':
+ result = {'version': __version__, 'config': str(args.config),
+ 'listener': f'{settings.host}:{settings.port}', 'public_url': settings.public_url,
+ 'execution_enabled': settings.execution_enabled}
+ for unit in ('aim-web.service', 'aim-web-worker.service','aim-web-executor.service'):
+ try:
+ proc = subprocess.run(['systemctl', 'show', unit, '-p', 'ActiveState', '-p', 'SubState', '-p', 'MainPID'],
+ capture_output=True, text=True, timeout=5, check=False)
+ result[unit] = dict(line.split('=', 1) for line in proc.stdout.splitlines() if '=' in line)
+ except (OSError, subprocess.TimeoutExpired):
+ result[unit] = {'status': 'systemd unavailable'}
+ print(json.dumps(result, indent=2) if args.json else '\n'.join(f'{k}: {v}' for k,v in result.items()))
+ return
+ if args.command == 'doctor':
+ from aim_webgui.diagnostics import report
+ result = report(settings, args.config)
+ if args.json:
+ print(json.dumps(result, indent=2))
+ else:
+ print(f'AIM WebGUI {__version__}; {settings.public_url}')
+ for check in result['checks']:
+ print(f"{'PASS' if check['ok'] else 'CHECK'} {check['name']}: {check['detail']}")
+ print(result['transport_note'])
+ if os.geteuid() == 0:
+ print('Run doctor as the service identity: sudo -u aim-web aim-web doctor')
+ if not result['ok']:
+ raise SystemExit(1)
+ return
+ if args.command == 'worker':
+ from aim_webgui.worker import Worker
+ Worker(settings, args.config).run(once=args.once)
+ return
+ from aim_webgui.auth.service import Auth
+ auth = Auth(settings)
+ if args.command == 'init':
+ created = auth.bootstrap()
+ print('Initialized administrator: admin' if created else 'Already initialized; users and passwords unchanged.')
+ if created:
+ print(f'Bootstrap credentials (local file only): {settings.credentials.as_uri()}')
+ print('A password change is required at first login. The password is not printed here.')
+ elif args.command == 'check':
+ from aim_webgui.adapters.core_v1 import CoreAdapter
+ adapter = None if args.without_core else CoreAdapter(settings)
+ from aim_webgui.assets import check_assets
+ check_assets()
+ if not args.without_db:
+ auth.store.check()
+ print(f'AIM WebGUI {__version__}: ' + ('package/state checked; core not contacted' if args.without_core else f'AIM {adapter.version} / service v1 compatible; core unchanged.'))
+ elif args.command == 'db':
+ if args.db_command == 'migrate':
+ auth.store.migrate()
+ print('Database schema is current. Accounts preserved.')
+ else:
+ auth.store.backup(args.destination.resolve())
+ print(f'Database backed up: {args.destination}')
+ elif args.command == 'user':
+ # Local database owner can repair an out-of-band disabled admin; HTTP still fails closed.
+ auth.store.check(require_admin=False)
+ if args.user_command == 'list':
+ for user in auth.users():
+ print(f"{user['username']}\t{user['role']}\tenabled={bool(user['enabled'])}\tchange_password={bool(user['must_change_password'])}")
+ elif args.user_command == 'create':
+ auth.create_user(args.username, read_password(), args.role)
+ print('Account created; password change required at first login.')
+ else:
+ new = read_password() if args.user_command == 'reset-password' else None
+ auth.manage_user(args.username, args.user_command, password=new)
+ print('Account updated; active sessions revoked.')
+ else:
+ from aim_webgui.app import create_app
+ import uvicorn
+ from aim_webgui.assets import check_assets
+ check_assets()
+ app = create_app(settings)
+ print(f'AIM WebGUI {__version__}; public origin: {settings.public_url}')
+ uvicorn.run(app, host=settings.host, port=settings.port, workers=1,
+ proxy_headers=settings.proxy_headers,
+ forwarded_allow_ips=settings.forwarded_allow_ips_value,
+ server_header=False, access_log=False,
+ timeout_keep_alive=5, limit_concurrency=32, ws='none')
+ except KeyboardInterrupt:
+ raise SystemExit(130) from None
+ except Exception as exc:
+ from aim_webgui.errors import WebError
+ message = exc.message if isinstance(exc, WebError) else (str(exc) if isinstance(exc, ValueError) else type(exc).__name__)
+ print(f'AIM WebGUI: {message}', file=sys.stderr)
+ raise SystemExit(1) from None
diff --git a/scripts/addons/webgui/src/aim_webgui/config.py b/scripts/addons/webgui/src/aim_webgui/config.py
new file mode 100644
index 0000000..d28f091
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/config.py
@@ -0,0 +1,221 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+import ipaddress
+import tomllib
+from urllib.parse import urlsplit
+
+DEFAULT_CONFIG = Path('/etc/ansible/scripts/config/webgui.toml')
+
+
+@dataclass(frozen=True)
+class Settings:
+ aim_scripts: Path = Path('/etc/ansible/scripts')
+ state_dir: Path = Path('/var/lib/aim/webgui')
+ host: str = '127.0.0.1'
+ port: int = 8080
+ public_url: str = 'http://127.0.0.1:8080'
+ proxy_headers: bool = False
+ forwarded_allow_ips: tuple[str, ...] = ()
+ session_hours: int = 8
+ idle_minutes: int = 30
+ credentials_enabled: bool = False
+ execution_enabled: bool = False
+ core_transport: str = 'unix'
+ core_command: tuple[str, ...] = ('/usr/local/bin/aimctl',)
+ core_config: Path = Path('/etc/ansible/scripts/aim.yml')
+ core_socket: Path = Path('/run/aim-web-executor/core.sock')
+ core_executor_user: str = 'svc_bf-ansible'
+ core_client_user: str = 'aim-web'
+ core_home: Path | None = None
+ execution_playbooks: tuple[str, ...] = ()
+ execution_max_hosts: int = 25
+ execution_timeout_seconds: int = 900
+ execution_require_approval: bool = True
+ execution_transport_verified: bool = False
+ execution_window_start_hour: int = 0
+ execution_window_end_hour: int = 24
+
+ journal_max_events: int = 20_000
+ journal_max_bytes: int = 8 * 1024 * 1024
+ reports_max_bytes: int = 16 * 1024 * 1024
+ reports_retain_configuration: bool = False
+
+ @property
+ def database(self) -> Path:
+ return self.state_dir / 'webgui.sqlite3'
+
+ @property
+ def credentials(self) -> Path:
+ return self.state_dir / '.credentials'
+
+ @property
+ def secure_cookie(self) -> bool:
+ return urlsplit(self.public_url).scheme == 'https'
+
+ @property
+ def cookie_name(self) -> str:
+ return '__Host-aim_web' if self.secure_cookie else 'aim_web_local'
+
+ @property
+ def forwarded_allow_ips_value(self) -> str:
+ return ','.join(self.forwarded_allow_ips)
+
+ def validate(self) -> 'Settings':
+ if not self.aim_scripts.is_absolute() or not self.state_dir.is_absolute():
+ raise ValueError('aim_scripts and state_dir must be absolute paths.')
+ if self.state_dir.resolve().is_relative_to(self.aim_scripts.resolve()):
+ raise ValueError('Mutable state must be outside the AIM source directory.')
+ if not isinstance(self.host, str) or not self.host.strip():
+ raise ValueError('host must be a non-empty listen address.')
+ if type(self.port) is not int or not 1024 <= self.port <= 65535:
+ raise ValueError('port must be an unprivileged TCP port (1024..65535).')
+ p = urlsplit(self.public_url)
+ if (p.scheme not in {'http', 'https'} or not p.hostname or p.username or p.password
+ or p.path or p.query or p.fragment):
+ raise ValueError('public_url must be an HTTP(S) origin without a trailing slash or path.')
+ _ = p.port
+ if p.scheme == 'http':
+ try:
+ local = ipaddress.ip_address(p.hostname).is_loopback
+ except ValueError:
+ local = p.hostname == 'localhost'
+ if not local:
+ raise ValueError('Non-local public_url requires HTTPS.')
+ try:
+ bind_loopback = ipaddress.ip_address(self.host).is_loopback
+ except ValueError:
+ bind_loopback = self.host == 'localhost'
+ if not bind_loopback:
+ if p.scheme != 'https':
+ raise ValueError('A non-loopback listener requires an HTTPS public_url behind a reverse proxy.')
+ if not self.proxy_headers:
+ raise ValueError('A non-loopback listener requires proxy_headers=true.')
+ if not self.forwarded_allow_ips:
+ raise ValueError('A non-loopback listener requires at least one trusted proxy IP.')
+ if type(self.proxy_headers) is not bool:
+ raise ValueError('proxy_headers must be true or false.')
+ if not isinstance(self.forwarded_allow_ips, tuple):
+ raise ValueError('forwarded_allow_ips must be a list of IP addresses or CIDR networks.')
+ for value in self.forwarded_allow_ips:
+ if value == '*':
+ continue
+ try:
+ ipaddress.ip_network(value, strict=False)
+ except ValueError:
+ raise ValueError(f'Invalid trusted proxy IP/network: {value}') from None
+ if not self.proxy_headers and self.forwarded_allow_ips:
+ raise ValueError('forwarded_allow_ips requires proxy_headers=true.')
+ if not (type(self.session_hours) is int and 1 <= self.session_hours <= 24):
+ raise ValueError('session_hours must be 1..24.')
+ if not (type(self.idle_minutes) is int and 1 <= self.idle_minutes <= 60):
+ raise ValueError('idle_minutes must be 1..60.')
+ if any(type(getattr(self, name)) is not bool for name in
+ ('execution_enabled', 'execution_require_approval', 'execution_transport_verified')):
+ raise ValueError('Execution switches must be TOML booleans.')
+ if not isinstance(self.execution_playbooks, tuple) or any(
+ not isinstance(x, str) or not x or not all(c.isalnum() or c in '_-' for c in x)
+ for x in self.execution_playbooks):
+ raise ValueError('execution_playbooks must contain explicit catalog keys.')
+ if self.core_transport not in {'stdio', 'unix'}:
+ raise ValueError('core.transport must be stdio or unix.')
+ if (not isinstance(self.core_command, tuple) or not self.core_command
+ or not all(isinstance(x, str) and x and not any(ord(c)<32 for c in x) for x in self.core_command)
+ or not Path(self.core_command[0]).is_absolute()):
+ raise ValueError('core.command must be an explicit command array with an absolute executable.')
+ if not self.core_config.is_absolute() or not self.core_socket.is_absolute() or len(str(self.core_socket).encode())>100:
+ raise ValueError('Use absolute core config/socket paths; socket path must be at most 100 bytes.')
+ if self.core_home is not None and (not self.core_home.is_absolute() or self.core_home.resolve().is_relative_to(self.state_dir.resolve())):
+ raise ValueError('core.home must be an absolute separate executor home, never WebGUI private state.')
+ for name in (self.core_executor_user,self.core_client_user):
+ if not name or not all(c.isalnum() or c in '_-' for c in name):
+ raise ValueError('Use explicit local executor and client account names.')
+ if type(self.execution_max_hosts) is not int or not 1 <= self.execution_max_hosts <= 500:
+ raise ValueError('execution_max_hosts must be 1..500.')
+ if type(self.execution_timeout_seconds) is not int or not 30 <= self.execution_timeout_seconds <= 7200:
+ raise ValueError('execution_timeout_seconds must be 30..7200.')
+ if (type(self.execution_window_start_hour) is not int or type(self.execution_window_end_hour) is not int
+ or not 0 <= self.execution_window_start_hour < self.execution_window_end_hour <= 24):
+ raise ValueError('Execution start window must satisfy 0 <= start_hour < end_hour <= 24 (UTC).')
+ if self.execution_enabled and (not self.execution_playbooks or not self.secure_cookie
+ or not self.execution_transport_verified):
+ raise ValueError('Execution requires HTTPS, an explicit playbook allowlist, and transport_verified=true after operator verification of backend TLS.')
+ if type(self.credentials_enabled) is not bool:
+ raise ValueError('credentials_enabled must be a TOML boolean.')
+ if self.credentials_enabled and (not self.execution_enabled or not self.execution_transport_verified or not self.secure_cookie):
+ raise ValueError('Credential execution requires enabled execution and verified HTTPS transport.')
+ if self.execution_enabled and '*' in self.forwarded_allow_ips:
+ raise ValueError('Execution does not accept wildcard proxy trust.')
+ if type(self.journal_max_events) is not int or not 100 <= self.journal_max_events <= 200_000:
+ raise ValueError('journal.max_events must be 100..200000.')
+ if type(self.journal_max_bytes) is not int or not 65_536 <= self.journal_max_bytes <= 64 * 1024 * 1024:
+ raise ValueError('journal.max_bytes must be 65536..67108864.')
+ if type(self.reports_max_bytes) is not int or not 65_536 <= self.reports_max_bytes <= 16 * 1024 * 1024:
+ raise ValueError('reports.max_bytes must be 65536..16777216.')
+ if type(self.reports_retain_configuration) is not bool:
+ raise ValueError('reports.retain_configuration must be a TOML boolean.')
+ return self
+
+ @classmethod
+ def load(cls, path: Path = DEFAULT_CONFIG) -> 'Settings':
+ if not path.is_file():
+ raise ValueError(f'Configuration missing: {path}. Run deployment first.')
+ with path.open('rb') as stream:
+ raw = tomllib.load(stream)
+
+ # 0.1.0 used a flat TOML document. 0.1.1+ accepts it unchanged while
+ # introducing named sections for future add-on growth.
+ section_names = {'server', 'proxy', 'session', 'aim', 'state', 'execution', 'credentials', 'core', 'journal', 'reports'}
+ if section_names.intersection(raw):
+ unknown_sections = set(raw) - section_names
+ if unknown_sections:
+ raise ValueError('Unknown WebGUI configuration sections: ' + ', '.join(sorted(unknown_sections)))
+ data = {}
+ mapping = {
+ 'journal': {'max_events','max_bytes'},
+ 'reports': {'max_bytes','retain_configuration'},
+ 'credentials': {'enabled'},
+ 'core': {'transport','command','config','socket','executor_user','client_user','home'},
+ 'server': {'host', 'port', 'public_url'},
+ 'proxy': {'proxy_headers', 'forwarded_allow_ips'},
+ 'session': {'session_hours', 'idle_minutes'},
+ 'aim': {'scripts_path'},
+ 'state': {'state_dir'},
+ 'execution': {'enabled', 'playbooks', 'max_hosts', 'timeout_seconds',
+ 'require_approval', 'transport_verified', 'window_start_hour', 'window_end_hour'},
+ }
+ for section, allowed in mapping.items():
+ values = raw.get(section, {})
+ if not isinstance(values, dict):
+ raise ValueError(f'[{section}] must be a TOML table.')
+ unknown = set(values) - allowed
+ if unknown:
+ raise ValueError(f'Unknown keys in [{section}]: ' + ', '.join(sorted(unknown)))
+ for key, value in values.items():
+ data[section + '_' + key if section in {'execution', 'credentials', 'core', 'journal', 'reports'} else ('aim_scripts' if key == 'scripts_path' else key)] = value
+ else:
+ data = dict(raw)
+ unknown = set(data) - cls.__dataclass_fields__.keys()
+ if unknown:
+ raise ValueError('Unknown WebGUI configuration keys: ' + ', '.join(sorted(unknown)))
+
+ for name in ('aim_scripts', 'state_dir', 'core_config', 'core_socket', 'core_home'):
+ if name in data:
+ if not isinstance(data[name], str):
+ raise ValueError(f'{name} must be a string path.')
+ data[name] = Path(data[name])
+ if 'forwarded_allow_ips' in data:
+ values = data['forwarded_allow_ips']
+ if not isinstance(values, list) or not all(isinstance(v, str) for v in values):
+ raise ValueError('forwarded_allow_ips must be an array of strings.')
+ data['forwarded_allow_ips'] = tuple(values)
+ if 'core_command' in data:
+ if not isinstance(data['core_command'], list):
+ raise ValueError('core.command must be a TOML array.')
+ data['core_command'] = tuple(data['core_command'])
+ if 'execution_playbooks' in data:
+ if not isinstance(data['execution_playbooks'], list):
+ raise ValueError('Execution playbooks must be an array.')
+ data['execution_playbooks'] = tuple(data['execution_playbooks'])
+ return cls(**data).validate()
diff --git a/scripts/addons/webgui/src/aim_webgui/console.py b/scripts/addons/webgui/src/aim_webgui/console.py
new file mode 100644
index 0000000..fc9cb4f
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/console.py
@@ -0,0 +1,175 @@
+"""Ephemeral, same-host live console transport.
+
+Console text is sanitized in the execution child, kept only in a bounded memory
+buffer, and relayed through an owner-only Unix socket. Nothing here writes
+playbook output to SQLite, audit, logs, or regular files.
+"""
+from __future__ import annotations
+
+from collections import deque
+import json
+import os
+from pathlib import Path
+import re
+import socket
+import struct
+import threading
+from urllib.parse import quote
+
+ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))")
+CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
+SENSITIVE_ASSIGNMENT = re.compile(
+ r"(?i)(\b(?:password|passwd|passphrase|token|secret|api[_-]?key|private[_-]?key|vault_password)\b\s*[:=]\s*)"
+ r"(?:\"[^\"]*\"|'[^']*'|[^\s,}\]]+)"
+)
+MAX_LINE = 4096
+MAX_LINES = 500
+MAX_BYTES = 256 * 1024
+
+
+def console_socket(settings, ident: str) -> Path:
+ # Keep Unix-domain paths comfortably below the platform limit, including
+ # long test/state roots. The peer credential check and owner-only socket
+ # permissions remain the authorization boundary.
+ return settings.state_dir / f'.console-{ident[:12]}.sock'
+
+
+class Redactor:
+ def __init__(self, secrets=()):
+ self.secrets = []
+ self.add(secrets)
+
+ def add(self, secrets):
+ variants = set(self.secrets)
+ for value in secrets:
+ if not isinstance(value, str) or not value:
+ continue
+ variants.add(value)
+ variants.add(quote(value, safe=''))
+ try:
+ variants.add(json.dumps(value, ensure_ascii=False)[1:-1])
+ except (TypeError, ValueError):
+ pass
+ self.secrets = sorted((v for v in variants if v), key=len, reverse=True)
+
+ def clean(self, value: str) -> str:
+ text = ANSI.sub('', str(value)).replace('\r', '').rstrip('\n')
+ text = CONTROL.sub('', text)
+ for secret in self.secrets:
+ text = text.replace(secret, '*** REDACTED ***')
+ text = SENSITIVE_ASSIGNMENT.sub(r'\1*** REDACTED ***', text)
+ if len(text) > MAX_LINE:
+ text = text[:MAX_LINE] + ' …[truncated]'
+ return text
+
+
+def classify_failure(lines) -> str:
+ text = '\n'.join(lines).lower()
+ if any(marker in text for marker in (
+ 'unreachable!', 'failed to connect to the host via ssh', 'permission denied (publickey',
+ 'connection timed out', 'connection refused', 'winrm', 'ntlm', 'kerberos unreachable',
+ )):
+ return 'Remote connection or authentication failed. Review the live console and target connectivity.'
+ if any(marker in text for marker in (
+ "couldn't resolve module/action", 'syntax error', 'the error appears to be in',
+ '[error]:', 'unexpected exception', 'non-empty plugin name is required',
+ )):
+ return 'Ansible configuration or playbook loading failed. Review the live console and controller prerequisites.'
+ if any(marker in text for marker in ('failed!', 'fatal:', 'failed=', 'rescue')):
+ return 'A playbook task failed. Review the live console; completed remote changes were not rolled back.'
+ return 'AIM/Ansible execution failed. Review the live console while the run is active or reproduce it in AIM terminal.'
+
+
+class ConsoleServer:
+ """One-job console server; snapshot and live data exist only in memory."""
+ def __init__(self, path: Path, secrets=()):
+ self.path = path
+ self.redactor = Redactor(secrets)
+ self.buffer = deque()
+ self.buffer_bytes = 0
+ self.clients = set()
+ self.lock = threading.Lock()
+ self.stop = threading.Event()
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
+ os.chmod(path.parent, 0o700)
+ path.unlink(missing_ok=True)
+ self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ self.sock.bind(str(path))
+ os.chmod(path, 0o600)
+ self.sock.listen(8)
+ self.sock.settimeout(.5)
+ self.thread = threading.Thread(target=self._serve, name='aim-live-console', daemon=True)
+ self.thread.start()
+
+ def _serve(self):
+ while not self.stop.is_set():
+ try:
+ conn, _ = self.sock.accept()
+ except socket.timeout:
+ continue
+ except OSError:
+ break
+ try:
+ if hasattr(socket, 'SO_PEERCRED'):
+ _pid, uid, _gid = struct.unpack('3i', conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
+ if uid != os.getuid():
+ conn.close(); continue
+ conn.settimeout(2)
+ with self.lock:
+ snapshot = list(self.buffer)
+ for payload in snapshot:
+ conn.sendall(payload)
+ self.clients.add(conn)
+ except OSError:
+ with self.lock:
+ self.clients.discard(conn)
+ try: conn.close()
+ except OSError: pass
+
+ def _publish(self, obj):
+ payload = (json.dumps(obj, ensure_ascii=False, separators=(',', ':')) + '\n').encode('utf-8')
+ with self.lock:
+ self.buffer.append(payload)
+ self.buffer_bytes += len(payload)
+ while len(self.buffer) > MAX_LINES or self.buffer_bytes > MAX_BYTES:
+ self.buffer_bytes -= len(self.buffer.popleft())
+ clients = list(self.clients)
+ dead = []
+ for conn in clients:
+ try:
+ conn.sendall(payload)
+ except OSError:
+ dead.append(conn)
+ if dead:
+ with self.lock:
+ for conn in dead:
+ self.clients.discard(conn)
+ try: conn.close()
+ except OSError: pass
+
+ def add_secrets(self, secrets):
+ self.redactor.add(secrets)
+
+ def line(self, text: str):
+ clean = self.redactor.clean(text)
+ if clean:
+ self._publish({'type': 'line', 'text': clean})
+
+ def notice(self, text: str):
+ self._publish({'type': 'notice', 'text': self.redactor.clean(text)})
+
+ def close(self):
+ try:
+ self._publish({'type': 'end', 'text': 'Execution ended. Live console output is not retained.'})
+ except Exception:
+ pass
+ self.stop.set()
+ try: self.sock.close()
+ except OSError: pass
+ self.thread.join(timeout=2)
+ with self.lock:
+ clients = list(self.clients); self.clients.clear()
+ for conn in clients:
+ try: conn.close()
+ except OSError: pass
+ self.path.unlink(missing_ok=True)
diff --git a/scripts/addons/webgui/src/aim_webgui/core/__init__.py b/scripts/addons/webgui/src/aim_webgui/core/__init__.py
new file mode 100644
index 0000000..5f0245a
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/core/__init__.py
@@ -0,0 +1 @@
+"""AIM service/wire v1 client. No AIM or Ansible private imports belong here."""
diff --git a/scripts/addons/webgui/src/aim_webgui/core/client.py b/scripts/addons/webgui/src/aim_webgui/core/client.py
new file mode 100644
index 0000000..882f5e4
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/core/client.py
@@ -0,0 +1,66 @@
+"""Transport selection for AIM's documented API, never private manager access."""
+from __future__ import annotations
+import os
+import pwd
+import socket
+import stat
+import struct
+import threading
+import time
+from aim_webgui.errors import WebError
+from aim_webgui.credentials import wire
+from .protocol import request_message, response_result, safe_event, validate_secrets, TRANSPORT_ERRORS
+from .process import invoke
+from .reports import UNSPECIFIED
+from .limits import PUBLIC_FRAME_BYTES
+from .jsonio import loads
+
+class CoreClient:
+ def __init__(self,settings): self.settings=settings
+ def request(self,operation,*,credentials=None,event_sink=None,cancel=None,deadline=None,result_contract=UNSPECIFIED,**fields):
+ message=request_message(operation,**fields)
+ def emit(frame):
+ if event_sink: event_sink(safe_event(frame['event']))
+ if self.settings.core_transport=='stdio':
+ frame=invoke(self.settings,message,credentials=credentials,emit=emit,cancel=cancel,deadline=deadline)
+ return response_result(frame,operation,declaration=result_contract,request=fields.get('request'),secrets=tuple((credentials or {}).values()))
+ path=self.settings.core_socket
+ try:
+ st=path.lstat();expected=pwd.getpwnam(self.settings.core_executor_user).pw_uid
+ if not stat.S_ISSOCK(st.st_mode) or st.st_uid!=expected or stat.S_IMODE(st.st_mode)&0o007:
+ raise WebError('executor_identity','The local executor socket has an unexpected owner or mode.',503)
+ with socket.socket(socket.AF_UNIX) as sock:
+ sock.settimeout(5);sock.connect(str(path))
+ pid,uid,gid=struct.unpack('3i',sock.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
+ if uid!=expected: raise WebError('executor_identity','Unexpected local executor identity.',503)
+ # Secret data is an explicitly separate bounded frame, never part of core request JSON.
+ has_credentials=operation=='execute'
+ remaining=min(60.0,max(0.0,deadline-time.monotonic())) if deadline is not None else None
+ wire.send(sock,{'request':message,'credential_frame':has_credentials,'start_seconds':remaining},131200)
+ if has_credentials: wire.send(sock,{'credentials':validate_secrets(credentials or {})},wire.SECRET_LIMIT+512)
+ interrupted=threading.Event()
+ def watcher():
+ while not interrupted.wait(.1):
+ if cancel is not None and cancel.is_set():
+ try: sock.shutdown(socket.SHUT_RDWR)
+ except OSError: pass
+ return
+ watcher_thread=threading.Thread(target=watcher,daemon=True);watcher_thread.start()
+ timeout=(fields.get('request',{}).get('timeout_seconds',3600)+200 if operation=='execute' else 135)
+ sock.settimeout(timeout)
+ try:
+ while True:
+ frame=wire.receive(sock,PUBLIC_FRAME_BYTES,decoder=loads)
+ if frame.get('type')=='event': emit(frame)
+ elif frame.get('type')=='response': return response_result(frame,operation,declaration=result_contract,request=fields.get('request'),secrets=tuple((credentials or {}).values()))
+ elif frame.get('type')=='transport_error':
+ code=frame.get('code')
+ messages=TRANSPORT_ERRORS
+ raise WebError(code if code in messages else 'executor_error',messages.get(code,'The local executor could not complete the core request; no raw diagnostic was exposed.'),503)
+ else: raise WebError('core_protocol','Invalid local executor response.',502)
+ finally:
+ interrupted.set();watcher_thread.join(timeout=1)
+ except WebError: raise
+ except (OSError,ValueError,KeyError):
+ if cancel is not None and cancel.is_set(): raise WebError('core_cancelled','Cancellation requested; completed remote work is not rolled back.',409) from None
+ raise WebError('executor_unavailable','The local AIM executor is unavailable. Check aim-web-executor.service and the configured core endpoint.',503) from None
diff --git a/scripts/addons/webgui/src/aim_webgui/core/executor.py b/scripts/addons/webgui/src/aim_webgui/core/executor.py
new file mode 100644
index 0000000..c493ead
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/core/executor.py
@@ -0,0 +1,119 @@
+"""Add-on-owned pre-started executor. Core itself has no broker/daemon.
+
+systemd starts this process under the already authorized, key-owning identity.
+No sudo, setuid, key export, private API imports or raw output forwarding occurs.
+The local web UID is a trusted controller client, NOT a multi-tenant boundary.
+"""
+from __future__ import annotations
+import os
+import pwd
+import resource
+import select
+import signal
+import socket
+import stat
+import struct
+import threading
+import time
+from aim_webgui.credentials import wire
+from aim_webgui.errors import WebError
+from .process import invoke
+from .limits import PUBLIC_FRAME_BYTES
+from .protocol import validate_request, validate_secrets, TRANSPORT_ERRORS
+
+class Executor:
+ def __init__(self,settings):
+ self.settings=settings;self.stopping=threading.Event()
+ self.slots=threading.BoundedSemaphore(8);self.run_slot=threading.Lock();self.threads=[]
+ def handle(self,conn):
+ acquired=False;running=False
+ try:
+ conn.settimeout(5)
+ _,uid,_=struct.unpack('3i',conn.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
+ if uid not in {pwd.getpwnam(self.settings.core_client_user).pw_uid,0}: return
+ acquired=self.slots.acquire(blocking=False)
+ if not acquired: raise WebError('executor_busy','Concurrent executor limit.',503)
+ envelope=wire.receive(conn,131200)
+ if set(envelope)!={'request','credential_frame','start_seconds'} or type(envelope['credential_frame']) is not bool:
+ raise WebError('core_request','Invalid executor envelope.')
+ message=validate_request(envelope['request']); execute=message['operation']=='execute'
+ if envelope['credential_frame']!=execute: raise WebError('core_request','Invalid credential framing.')
+ supplied={}
+ if execute:
+ running=self.run_slot.acquire(blocking=False)
+ if not running: raise WebError('executor_busy','Another core run is active.',409)
+ frame=wire.receive(conn,wire.SECRET_LIMIT+512)
+ if set(frame)!={'credentials'}: raise WebError('invalid_credentials','Invalid credential frame.')
+ supplied=validate_secrets(frame['credentials']);frame.clear()
+ seconds=envelope['start_seconds']
+ if seconds is not None and (type(seconds) not in (int,float) or not 0 < seconds <= 60):
+ raise WebError('credential_expired','Credential start window expired.',409)
+ deadline=time.monotonic()+seconds if seconds is not None else None
+ def connected():
+ if self.stopping.is_set(): return False
+ ready,_,_=select.select([conn],[],[],0)
+ if ready:
+ # No more input is allowed; EOF means cancellation/disconnect.
+ return False
+ return True
+ def emit(frame): wire.send(conn,frame,PUBLIC_FRAME_BYTES)
+ final=invoke(self.settings,message,credentials=supplied,emit=emit,cancel=self.stopping,connected=connected,deadline=deadline)
+ wire.send(conn,final,PUBLIC_FRAME_BYTES)
+ except (WebError,OSError,ValueError,KeyError,TypeError) as exc:
+ # Deliberately do not log repr/body/traceback; submitted values may be secrets.
+ try: wire.send(conn,{'type':'transport_error','code':exc.code if isinstance(exc,WebError) and exc.code in TRANSPORT_ERRORS else 'executor_error'})
+ except (OSError,ValueError): pass
+ finally:
+ if 'supplied' in locals(): supplied.clear()
+ if running: self.run_slot.release()
+ if acquired: self.slots.release()
+ conn.close()
+ def run(self):
+ settings=self.settings
+ if os.geteuid()!=pwd.getpwnam(settings.core_executor_user).pw_uid or os.geteuid()==0:
+ raise ValueError('Executor must run as the configured non-root execution/key identity.')
+ resource.setrlimit(resource.RLIMIT_CORE,(0,0));os.umask(0o077)
+ if settings.core_home is not None:
+ home=settings.core_home.lstat()
+ if not stat.S_ISDIR(home.st_mode) or home.st_uid!=os.geteuid() or stat.S_IMODE(home.st_mode)&0o077:
+ raise ValueError('The configured executor home must be an executor-owned private 0700 directory.')
+ parent=settings.core_socket.parent
+ client_gid=pwd.getpwnam(settings.core_client_user).pw_gid
+ # systemd owns the runtime-directory contract. Keep the executor's normal
+ # primary group and permit pathname traversal only; authorization is enforced
+ # on the socket itself (executor:aim-web 0660). This avoids runtime chgrp and
+ # keeps the HTTP/worker identity unable to list the executor runtime directory.
+ st=parent.lstat()
+ if (not stat.S_ISDIR(st.st_mode) or st.st_uid!=os.geteuid()
+ or stat.S_IMODE(st.st_mode)!=0o711):
+ raise ValueError('Executor socket directory must be executor-owned 0711 and non-symlinked.')
+ # Verify the actual wire/API before accepting traffic; this needs no Ansible run.
+ from .protocol import response_result
+ result=response_result(invoke(settings,{'api_version':'1.0','operation':'capabilities'}),'capabilities')
+ if result.get('api_version')!='1.0' or result.get('core_version')!='3.3.0rc8':
+ raise ValueError('This executor release is qualified for AIM 3.3.0rc8 / service API 1.0 only.')
+ path=settings.core_socket
+ if path.exists():
+ item=path.lstat()
+ if not stat.S_ISSOCK(item.st_mode) or item.st_uid!=os.geteuid(): raise ValueError('Unexpected existing executor endpoint.')
+ # Refuse a second live executor; remove only a stale same-owner socket.
+ with socket.socket(socket.AF_UNIX) as probe:
+ try: probe.connect(str(path))
+ except ConnectionRefusedError: path.unlink()
+ else: raise ValueError('Another executor is already listening.')
+ listener=socket.socket(socket.AF_UNIX)
+ previous={}
+ try:
+ listener.bind(str(path));os.chown(path,-1,client_gid);os.chmod(path,0o660);listener.listen(8);listener.settimeout(.5)
+ for sig in (signal.SIGINT,signal.SIGTERM):
+ previous[sig]=signal.signal(sig,lambda *_:self.stopping.set())
+ while not self.stopping.is_set():
+ try: conn,_=listener.accept()
+ except socket.timeout: continue
+ t=threading.Thread(target=self.handle,args=(conn,),daemon=True);self.threads.append(t);t.start()
+ self.threads=[t for t in self.threads if t.is_alive()]
+ finally:
+ self.stopping.set();listener.close()
+ for t in self.threads:t.join(timeout=15)
+ path.unlink(missing_ok=True)
+ for sig,handler in previous.items():signal.signal(sig,handler)
diff --git a/scripts/addons/webgui/src/aim_webgui/core/jsonio.py b/scripts/addons/webgui/src/aim_webgui/core/jsonio.py
new file mode 100644
index 0000000..efb250d
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/core/jsonio.py
@@ -0,0 +1,26 @@
+"""Strict public JSON decoding; rejects ambiguous keys, NaN and excessive depth."""
+import json
+
+
+def loads(raw, max_depth=48):
+ # Scan brackets outside strings before allocating a recursive JSON tree.
+ text=raw.decode('utf-8') if isinstance(raw,(bytes,bytearray)) else raw
+ depth=0;quoted=False;escaped=False
+ for char in text:
+ if quoted:
+ if escaped:escaped=False
+ elif char=='\\':escaped=True
+ elif char=='"':quoted=False
+ elif char=='"':quoted=True
+ elif char in '[{':
+ depth+=1
+ if depth>max_depth:raise ValueError('Public JSON nesting exceeds its bound.')
+ elif char in ']}':depth-=1
+ def pairs(items):
+ result={}
+ for key,val in items:
+ if key in result:raise ValueError('Duplicate public JSON member.')
+ result[key]=val
+ return result
+ def constant(_):raise ValueError('Non-finite public JSON number.')
+ return json.loads(text,object_pairs_hook=pairs,parse_constant=constant)
diff --git a/scripts/addons/webgui/src/aim_webgui/core/limits.py b/scripts/addons/webgui/src/aim_webgui/core/limits.py
new file mode 100644
index 0000000..09c0e2c
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/core/limits.py
@@ -0,0 +1,11 @@
+"""Public response budgets, deliberately separate from request/credential limits.
+
+Core's 16 MiB report uses UTF-8 sizing. JSON escaping can expand it, and the final
+result appears twice on the wire. Default 128 MiB line / 512 MiB stream leaves
+bounded room for escaping, target/schema overhead and progress. IPC is canonical
+UTF-8 and gets a 32 MiB final frame. These limits never apply to credential input.
+"""
+PUBLIC_LINE_BYTES = 128 * 1024 * 1024
+PUBLIC_STREAM_BYTES = 512 * 1024 * 1024
+PUBLIC_FRAME_BYTES = 32 * 1024 * 1024
+METADATA_LINE_BYTES = 32 * 1024 * 1024
diff --git a/scripts/addons/webgui/src/aim_webgui/core/process.py b/scripts/addons/webgui/src/aim_webgui/core/process.py
new file mode 100644
index 0000000..40819a7
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/core/process.py
@@ -0,0 +1,139 @@
+"""One native aimctl process per call, under the existing execution UID.
+
+AIM is invoked through its documented wire protocol. Never imports AIM, alters
+Ansible arguments, supplies an actor, or switches operating-system identity.
+"""
+from __future__ import annotations
+import json
+import os
+import pwd
+import selectors
+import signal
+import subprocess
+import threading
+import time
+
+from aim_webgui.errors import WebError
+from .protocol import validate_request, validate_secrets, safe_event
+from .limits import PUBLIC_LINE_BYTES, PUBLIC_STREAM_BYTES, METADATA_LINE_BYTES
+from .jsonio import loads
+
+
+def stop(process):
+ if process.poll() is not None: return
+ try: process.send_signal(signal.SIGTERM)
+ except ProcessLookupError: return
+ try: process.wait(timeout=12)
+ except subprocess.TimeoutExpired:
+ try: os.killpg(process.pid,signal.SIGKILL)
+ except ProcessLookupError: pass
+ process.wait(timeout=3)
+
+
+def invoke(settings, message, *, credentials=None, emit=None, cancel=None, connected=None, deadline=None):
+ """Return a raw, authoritative response. Side-channel secrets never join stdin."""
+ validate_request(message)
+ operation=message['operation']
+ secret=validate_secrets(credentials or {})
+ if secret and operation!='execute':
+ raise WebError('core_request','Only execute accepts a private credential channel.')
+ request=(json.dumps(message,ensure_ascii=False,allow_nan=False,separators=(',',':'))+'\n').encode('utf-8')
+ command=list(settings.core_command)+['--config',str(settings.core_config)]
+ read_fd=write_fd=None
+ process=None; writer=None; delivered=threading.Event()
+ credential_bytes=None
+ try:
+ if operation=='execute':
+ read_fd,write_fd=os.pipe()
+ command+=['--credentials-fd',str(read_fd)]
+ credential_bytes=json.dumps(secret,ensure_ascii=False,allow_nan=False).encode('utf-8')
+ command+=['request']
+ # Trusted operator configuration chooses the executable; no browser-controlled argv.
+ account=pwd.getpwuid(os.geteuid())
+ env={'PATH':'/usr/local/bin:/usr/bin:/bin','HOME':str(settings.core_home) if settings.core_home else account.pw_dir,'LANG':'C.UTF-8',
+ 'PYTHONDONTWRITEBYTECODE':'1','PYTHONNOUSERSITE':'1'}
+ process=subprocess.Popen(command,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,
+ pass_fds=(() if read_fd is None else (read_fd,)),env=env,cwd='/',start_new_session=True)
+ if read_fd is not None: os.close(read_fd);read_fd=None
+ if write_fd is not None:
+ fd=write_fd;write_fd=None
+ def deliver():
+ try:
+ with os.fdopen(fd,'wb',buffering=0) as stream:
+ view=memoryview(credential_bytes)
+ while view:
+ n=stream.write(view)
+ if not n: break
+ view=view[n:]
+ except (OSError,BrokenPipeError): pass
+ finally: delivered.set()
+ writer=threading.Thread(target=deliver,daemon=True);writer.start()
+ # stdin is small/bounded (128 KiB). Writer runs concurrently with event reads.
+ def request_writer():
+ try: process.stdin.write(request);process.stdin.close()
+ except (OSError,BrokenPipeError): pass
+ sender=threading.Thread(target=request_writer,daemon=True);sender.start()
+ timeout=(message.get('request',{}).get('timeout_seconds',3600)+180 if operation=='execute'
+ else 120 if operation=='readiness' else 30)
+ end=time.monotonic()+timeout
+ line_limit=PUBLIC_LINE_BYTES if operation=='execute' else METADATA_LINE_BYTES
+ buffer=bytearray(); total=0; final=None; sequence=0; executing=False
+ with selectors.DefaultSelector() as selector:
+ selector.register(process.stdout,selectors.EVENT_READ)
+ eof=False
+ while not eof:
+ if cancel is not None and cancel.is_set():
+ stop(process); raise WebError('core_cancelled','Cancellation requested; completed remote work is not rolled back.',409)
+ if connected is not None and not connected():
+ stop(process); raise WebError('core_disconnected','Execution client disconnected; cancellation requested.',409)
+ if deadline is not None and not executing and time.monotonic()>deadline:
+ stop(process);raise WebError('credential_expired','Credentials expired before execution began. Review a fresh attempt.',409)
+ if time.monotonic()>end:
+ stop(process);raise WebError('core_timeout','Core did not complete within its bounded lifecycle. Outcome requires review.',504)
+ for key,_ in selector.select(.1):
+ chunk=os.read(key.fileobj.fileno(),65536)
+ if not chunk:
+ eof=True;break
+ total+=len(chunk);buffer.extend(chunk)
+ if total>PUBLIC_STREAM_BYTES:
+ raise WebError('core_protocol','Core output exceeded bounded protocol limits.',502)
+ while b'\n' in buffer:
+ raw,_,remainder=buffer.partition(b'\n');buffer=bytearray(remainder)
+ if len(raw)>line_limit:raise WebError('core_protocol','Core JSON line exceeded its response budget.',502)
+ if not raw.strip(): continue
+ try: value=loads(raw)
+ except (ValueError,UnicodeError): raise WebError('core_protocol','Core output is not valid JSONL.',502) from None
+ if final is not None: raise WebError('core_protocol','Unexpected data after final core response.',502)
+ if not isinstance(value,dict): raise WebError('core_protocol','Invalid core frame.',502)
+ if value.get('type')=='event':
+ event=safe_event(value.get('event'))
+ if event is not None:
+ if event['sequence']<=sequence: raise WebError('core_protocol','Non-monotonic core event stream.',502)
+ sequence=event['sequence']
+ if event.get('kind')=='stage' and event.get('stage')=='execution': executing=True
+ if emit: emit({'type':'event','event':event})
+ elif value.get('type')=='response': final=value
+ else: raise WebError('core_protocol','Unknown wire frame type.',502)
+ if len(buffer)>line_limit:raise WebError('core_protocol','Core JSON line exceeded its response budget.',502)
+ if buffer.strip(): raise WebError('core_protocol','Truncated core wire frame.',502)
+ process.wait(timeout=5)
+ sender.join(timeout=1)
+ if final is None: raise WebError('core_outcome_unknown','Core exited without a final response. Do not assume success or retry automatically.',502)
+ if final.get('ok') is True and process.returncode!=0:
+ raise WebError('core_outcome_unknown','Core exit status disagrees with its final response.',502)
+ return final
+ except (OSError,subprocess.TimeoutExpired):
+ raise WebError('core_unavailable','Configured aimctl could not run. Check the core launcher and execution identity.',503) from None
+ finally:
+ if process is not None:
+ stop(process)
+ for stream in (process.stdin,process.stdout):
+ if stream is not None:
+ try: stream.close()
+ except OSError: pass
+ for fd in (read_fd,write_fd):
+ if fd is not None:
+ try: os.close(fd)
+ except OSError: pass
+ if writer: writer.join(timeout=1)
+ secret.clear();credential_bytes=None
diff --git a/scripts/addons/webgui/src/aim_webgui/core/protocol.py b/scripts/addons/webgui/src/aim_webgui/core/protocol.py
new file mode 100644
index 0000000..827a6b4
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/core/protocol.py
@@ -0,0 +1,279 @@
+"""Narrow validation of the documented core wire/event protocol.
+
+Only structured core progress reaches a browser. Unknown additive response fields
+are ignored; unknown errors fail safely. Requests never carry passwords.
+"""
+from __future__ import annotations
+import json
+import re
+from aim_webgui.errors import WebError
+from .reports import contract, operation_result, UNSPECIFIED
+
+OPERATIONS = {'capabilities': set(), 'staging_check': set(), 'list_customers': set(),
+ 'list_hosts': {'customer'}, 'inventory_hierarchy': {'customer'}, 'list_playbooks': {'customer'},
+ 'prepare': {'request'}, 'readiness': {'request'},
+ 'execute': {'request', 'expected_revision'}}
+REQUEST_FIELDS = {'customer','playbook','hosts','overrides','check','key_mode','become_password','timeout_seconds','progress_mode'}
+CREDENTIAL_FIELDS = {'vault_password','connection_password','ssh_key_passphrase'}
+COUNTS = {'ok','changed','failures','unreachable','skipped','rescued','ignored'}
+ERRORS = {
+ 'operation_result_missing': 'Execution completed but a required report was missing. Remote changes may be complete; do not retry automatically.',
+ 'operation_result_invalid': 'Core rejected an operation report. Inspect report availability; no automatic retry was made.',
+ 'operation_result_withheld': 'Core withheld sensitive operation report data. This is not a credential-retry instruction.',
+ 'operation_result_limit': 'Operation report exceeded its declared limits. Remote work may be complete.',
+ 'operation_result_incomplete': 'Core did not receive a complete operation report stream. Review the execution and report states separately.',
+ 'invalid_credentials': 'Core rejected the supplied one-run credential fields.',
+ 'event_bridge_unavailable': 'Native execution did not emit final safe counters; success is not assumed.',
+ 'runtime_config_invalid': 'Core rejected the local Ansible configuration.',
+ 'runtime_incomplete': 'Core needs the native Ansible CLI tools in one consistent runtime.',
+ 'runtime_identity_ambiguous': 'The native Ansible tools do not report one unambiguous interpreter environment.',
+ 'collection_discovery_failed': 'Core could not inspect collections for the execution account.',
+ 'ssh_tools_missing': 'Core requires OpenSSH key-loading tools for this request.',
+ 'unencrypted_vault': 'Core requires the standard customer Vault to be encrypted.',
+ 'access_denied': 'The configured core execution identity cannot access a required resource or is not in AIM required_group.',
+ 'execution_disabled': 'AIM core has external execution disabled. An operator must review addons.execution_enabled in aim.yml.',
+ 'invalid_request': 'AIM rejected the request schema. Review the selection and supported options.',
+ 'invalid_target': 'A selected target is missing, incompatible or ambiguous. Review the current inventory.',
+ 'invalid_options': 'A selected catalog option is invalid. Review the declared inputs.',
+ 'source_invalid': 'Core configuration, inventory or catalog is invalid or inaccessible to its execution identity.',
+ 'customer_unavailable': 'The selected customer is unavailable to AIM core.',
+ 'unknown_playbook': 'The requested catalog playbook is not available.',
+ 'playbook_unavailable': 'The catalog entry has no installed playbook for this customer.',
+ 'resource_busy': 'Another AIM operation holds the customer lock. No automatic retry was made.',
+ 'review_stale': 'AIM sources or reviewed options changed. Review a new run before submitting.',
+ 'key_access_policy': 'Customer key mode requires a canonical 0600 key owned by the core execution account. No ownership changes were made.',
+ 'key_unavailable': 'The canonical customer SSH key is unavailable to AIM core.',
+ 'key_load_failed': 'AIM could not load the customer key. Verify ownership, key format and key passphrase in the terminal.',
+ 'vault_missing': 'This catalog operation requires a customer Vault which is not available.',
+ 'vault_unlock_failed': 'The customer Vault could not be unlocked. No automatic execution retry was made.',
+ 'credential_required': 'Core requires fresh credentials for the reviewed run.',
+ 'credentials_expired': 'The one-run credential window expired. Review and submit a new attempt.',
+ 'invalid_credential_channel': 'The private credential channel was rejected; no insecure fallback is available.',
+ 'runtime_missing': 'Core could not locate its configured native Ansible runtime.',
+ 'runtime_version_unsupported': 'The configured native Ansible runtime is not supported by AIM core.',
+ 'collections_missing': 'A catalog-required collection is missing for the core execution identity.',
+ 'collection_version_unsupported': 'A required Ansible collection version is outside the Core-supported range. Update the approved Core collection environment before retrying.',
+ 'runtime_credential_defaults_unsupported': 'Core rejected controller-wide Vault defaults for unattended execution. Inspect core readiness in the terminal.',
+ 'controller_staging_unavailable': 'Core executor staging is unavailable. Review the executor sandbox/write path; credentials were not consumed.',
+ 'controller_staging_unsafe': 'Core executor staging has unsafe ownership, permissions, type or symlinks. No automatic repair was made.',
+ 'controller_staging_config_unsupported': 'Core could not safely resolve the configured controller staging path.',
+ 'controller_staging_timeout': 'Core staging preflight timed out before remote execution.',
+ 'controller_staging_probe_failed': 'Core staging preflight failed inside the executor sandbox.',
+ 'event_bridge_incomplete': 'Core detailed progress was incomplete; success is not assumed.',
+ 'event_limit': 'Core detailed progress exceeded its bounded event limit; success is not assumed.',
+ 'syntax_check_failed': 'Native syntax/preflight checking failed. Use AIM terminal for detailed diagnostics.',
+ 'host_unreachable': 'Ansible reported unreachable hosts. This does not distinguish network failure from rejected authentication.',
+ 'playbook_failed': 'Ansible reported an execution or task failure. Remote work may have started.',
+ 'timeout': 'Core timed out. Completed remote work is not rolled back.',
+ 'cancelled': 'Core cancelled the local execution. Completed remote work is not rolled back.',
+ 'event_sink_failed': 'The progress consumer disconnected or failed; execution cancellation was requested.',
+ 'invalid_event_stream': 'Core could not validate native progress. The outcome requires operator review.',
+ 'internal_error': 'AIM core could not complete the operation. No raw exception or secret was exposed.',
+ 'api_version_unsupported': 'AIM service API version is incompatible with this add-on.',
+ 'source_symlink_unsupported': 'Core does not accept symlinks in this reviewed source set.',
+ 'revision_limit': 'Core source revision limits were exceeded.',
+}
+
+TRANSPORT_ERRORS = {
+ 'operation_result_missing': 'Execution completed but a required report was missing. Remote changes may be complete; do not retry automatically.',
+ 'operation_result_invalid': 'Core rejected an operation report. Inspect report availability; no automatic retry was made.',
+ 'operation_result_withheld': 'Core withheld sensitive operation report data. This is not a credential-retry instruction.',
+ 'operation_result_limit': 'Operation report exceeded its declared limits. Remote work may be complete.',
+ 'operation_result_incomplete': 'Core did not receive a complete operation report stream. Review the execution and report states separately.',
+ 'executor_busy': 'The executor is busy. Submit a fresh attempt when it is ready; credentials are not retained.',
+ 'credential_expired': 'Credentials expired before execution began. Submit a fresh attempt.',
+ 'core_cancelled': 'Core cancellation requested; completed remote work is not rolled back.',
+ 'core_unavailable': 'The executor could not start configured aimctl. Check the launcher, configuration path and execution account.',
+ 'core_timeout': 'The bounded core call timed out. Inspect the execution account and outcome before any explicit retry.',
+ 'core_protocol': 'Core returned an invalid or unsupported wire response. No success is assumed.',
+ 'core_outcome_unknown': 'Core exited without an authoritative result. The remote outcome is unknown; do not replay automatically.',
+ 'core_disconnected': 'The local execution client disconnected; cancellation was requested.',
+}
+
+def request_message(operation, **fields):
+ value={'api_version':'1.0','operation':operation,**fields}
+ validate_request(value)
+ return value
+
+def validate_request(value):
+ if not isinstance(value,dict) or value.get('api_version') != '1.0':
+ raise WebError('core_request', 'Use the supported AIM service API 1.0.')
+ op=value.get('operation')
+ if op not in OPERATIONS or set(value) != {'api_version','operation'}|OPERATIONS[op]:
+ raise WebError('core_request','Unsupported core operation or request field.')
+ if len(json.dumps(value,ensure_ascii=False,allow_nan=False).encode('utf-8')) > 131071:
+ raise WebError('core_request','Core request exceeds its size limit.')
+ if 'request' in value:
+ req=value['request']
+ if not isinstance(req,dict) or set(req)-REQUEST_FIELDS or not {'customer','playbook','hosts'} <= set(req):
+ raise WebError('core_request','Use documented run fields only; no credential, actor, path or command fields.')
+ if req.get('become_password',False) is not False:
+ raise WebError('become_unsupported','Become password entry is not exposed in this add-on release.')
+ return value
+
+def validate_secrets(values):
+ if not isinstance(values,dict) or set(values)-CREDENTIAL_FIELDS:
+ raise WebError('invalid_credentials','Only supported one-run credential fields are accepted.')
+ result={}
+ for k,v in values.items():
+ try: valid = isinstance(v,str) and not any(c in v for c in ('\x00','\r','\n')) and len(v.encode('utf-8'))<=2048
+ except UnicodeError: valid=False
+ if not valid:
+ raise WebError('invalid_credentials','Use literal single-line passwords up to 2048 UTF-8 bytes.')
+ if v: result[k]=v
+ if len(json.dumps(result,ensure_ascii=False).encode())>8192:
+ raise WebError('invalid_credentials','Credential hand-off exceeds 8 KiB.')
+ return result
+
+def safe_event(value):
+ if not isinstance(value,dict) or value.get('event_version')!='1.0':
+ raise WebError('core_protocol','Unsupported core event version.',502)
+ base={k:value[k] for k in ('event_version','run_id','sequence','timestamp','kind') if k in value}
+ if type(base.get('sequence')) is not int or base['sequence'] < 1:
+ raise WebError('core_protocol','Invalid core event sequence.',502)
+ kind=value.get('kind')
+ if kind=='stage':
+ if value.get('stage') not in {'credentials','syntax_check','execution'}: return None
+ base['stage']=value['stage']; return base
+ if kind=='progress':
+ if value.get('status') not in {'started','task','ok','failed','unreachable','skipped'}: return None
+ base['status']=value['status']; return base
+ if kind=='stats':
+ if not isinstance(value.get('counts'),dict): raise WebError('core_protocol','Invalid progress counters.',502)
+ base['counts']={k:v for k,v in value['counts'].items() if k in COUNTS and type(v) is int and v>=0}; return base
+ if kind=='result':
+ summary=value.get('result',value)
+ base['status']=summary.get('status') if isinstance(summary,dict) and summary.get('status') in {'succeeded','failed','cancelled'} else 'pending'
+ return base
+ # AIM 3.2 detailed events: validate the public safe projection again at the add-on boundary.
+ if kind=='play_started':
+ if not _id(value.get('play_id'),'p'): raise WebError('core_protocol','Invalid detailed play event.',502)
+ return {**base,'play_id':value['play_id'],'label':_label(value.get('label')),'label_redacted':value.get('label_redacted') is True}
+ if kind in {'play_skipped','play_stopped'}:
+ expected='no_hosts_matched' if kind=='play_skipped' else 'no_hosts_remaining'
+ if not _id(value.get('play_id'),'p') or value.get('reason')!=expected: raise WebError('core_protocol','Invalid detailed play state.',502)
+ return {**base,'play_id':value['play_id'],'reason':expected}
+ if kind=='task_started':
+ if not _id(value.get('play_id'),'p') or not _id(value.get('task_id'),'t') or type(value.get('handler')) is not bool:
+ raise WebError('core_protocol','Invalid detailed task event.',502)
+ return {**base,'play_id':value['play_id'],'task_id':value['task_id'],'label':_label(value.get('label')),
+ 'label_redacted':value.get('label_redacted') is True,'handler':value['handler']}
+ if kind=='host_result':
+ status=value.get('status')
+ if (not _id(value.get('play_id'),'p') or not _id(value.get('task_id'),'t') or status not in {'ok','changed','skipped','failed','unreachable'}
+ or any(type(value.get(k)) is not bool for k in ('host_redacted','changed','ignored','details_redacted'))):
+ raise WebError('core_protocol','Invalid detailed host result.',502)
+ error=value.get('error')
+ if error is not None:
+ if (not isinstance(error,dict) or set(error)!={'code','message','classification'} or not _label(error.get('message'),400)
+ or not isinstance(error.get('code'),str) or not isinstance(error.get('classification'),str)):
+ raise WebError('core_protocol','Invalid detailed diagnostic hint.',502)
+ code=error['code']
+ messages={
+ 'connection_refused':'Connection refused. Check the listener, port and firewall.',
+ 'connection_timeout':'Connection timed out.', 'name_resolution_failed':'Name resolution failed.',
+ 'tls_verification_failed':'TLS certificate verification failed.',
+ 'authentication_failed':'Connection authentication failed. Check the configured identity and authentication method.',
+ 'permission_denied':'Permission was denied.',
+ 'host_unreachable':'Ansible could not reach or authenticate to this host. Further details are withheld.',
+ 'task_failed':'The task failed. Further details are withheld.',
+ 'details_withheld':'Sensitive task details are withheld by Core.'}
+ code=code if code in messages else 'details_withheld'
+ error={'code':code,'message':messages[code],'classification':code}
+ return {**base,'play_id':value['play_id'],'task_id':value['task_id'],'host':_host(value.get('host')),
+ 'host_redacted':value['host_redacted'],'status':status,'changed':value['changed'],'ignored':value['ignored'],
+ 'details_redacted':value['details_redacted'],'error':error}
+ if kind in {'task_retry','task_async_poll'}:
+ attempt=value.get('attempt')
+ if (not _id(value.get('play_id'),'p') or not _id(value.get('task_id'),'t') or type(value.get('host_redacted')) is not bool
+ or type(value.get('details_redacted')) is not bool or (attempt is not None and (type(attempt) is not int or not 0<=attempt<=1_000_000))):
+ raise WebError('core_protocol','Invalid detailed retry/poll event.',502)
+ return {**base,'play_id':value['play_id'],'task_id':value['task_id'],'host':_host(value.get('host')),
+ 'host_redacted':value['host_redacted'],'attempt':attempt,'details_redacted':value['details_redacted']}
+ if kind=='host_recap':
+ if type(value.get('host_redacted')) is not bool or not isinstance(value.get('counts'),dict):
+ raise WebError('core_protocol','Invalid detailed recap event.',502)
+ counts={k:v for k,v in value['counts'].items() if k in COUNTS and type(v) is int and v>=0}
+ return {**base,'host':_host(value.get('host')),'host_redacted':value['host_redacted'],'counts':counts}
+ return None
+
+def _id(value,prefix):
+ return isinstance(value,str) and bool(re.fullmatch(prefix+r'[1-9][0-9]{0,8}',value))
+
+def _label(value,limit=200):
+ if not isinstance(value,str) or not value or len(value)>limit or any(ord(c)<32 and c!='\t' for c in value):
+ raise WebError('core_protocol','Invalid detailed label.',502)
+ return value
+
+def _host(value):
+ if value is None:return None
+ if not isinstance(value,str) or len(value)>255 or not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9_.-]*',value):
+ raise WebError('core_protocol','Invalid detailed host label.',502)
+ return value
+
+def safe_error(value):
+ code=value.get('code','internal_error') if isinstance(value,dict) else 'internal_error'
+ if not isinstance(code,str) or not re.fullmatch('[a-z][a-z0-9_]{0,79}',code): code='internal_error'
+ return {'code':code, 'message':ERRORS.get(code,'Core reported an unsupported error code. Inspect the core contract; no automatic retry was made.'),
+ 'stage':value.get('stage','unknown') if isinstance(value,dict) and value.get('stage') in {'validation','authorization','readiness','credentials','syntax_check','execution','unknown','preparation','key_loading','vault_unlock','completed','result_validation'} else 'unknown',
+ 'retryable':bool(isinstance(value,dict) and value.get('retryable') is True)}
+
+def _target_outcomes(result):
+ summary=result.get('target_summary')
+ targets=result.get('targets')
+ if not isinstance(summary,dict) or summary.get('schema')!='target_outcome_summary_v1' or not isinstance(targets,list):
+ raise WebError('core_protocol','Core did not provide authoritative per-target outcomes.',502)
+ fields=('requested','successful','failed','unreachable','not_started','indeterminate','accounted')
+ if any(type(summary.get(k)) is not int or summary[k]<0 for k in fields) or type(summary.get('complete')) is not bool:
+ raise WebError('core_protocol','Invalid target outcome summary.',502)
+ if summary['accounted']!=summary['requested'] or len(targets)!=summary['requested']:
+ raise WebError('core_protocol','Incomplete target outcome accounting.',502)
+ states={'successful','failed','unreachable','not_started','indeterminate'}
+ projected=[]; totals={state:0 for state in states}; seen=set()
+ for item in targets:
+ if not isinstance(item,dict) or item.get('outcome') not in states or not isinstance(item.get('counts'),dict):
+ raise WebError('core_protocol','Invalid per-target outcome.',502)
+ host=_host(item.get('host'))
+ if host is None or host in seen:raise WebError('core_protocol','Invalid per-target host accounting.',502)
+ seen.add(host); outcome=item['outcome'];totals[outcome]+=1
+ counts={k:v for k,v in item['counts'].items() if k in COUNTS and type(v) is int and v>=0}
+ if set(counts)!=COUNTS:raise WebError('core_protocol','Invalid per-target counters.',502)
+ projected.append({'host':host,'outcome':outcome,'counts':counts})
+ if any(summary[state]!=totals[state] for state in states):
+ raise WebError('core_protocol','Target outcome totals do not match target facts.',502)
+ projected_summary={'schema':'target_outcome_summary_v1',**{k:summary[k] for k in fields},'complete':summary['complete']}
+ return projected_summary,projected
+
+def response_result(value, operation, *, declaration=UNSPECIFIED, request=None, secrets=()):
+ if not isinstance(value,dict) or value.get('type')!='response' or value.get('api_version')!='1.0' or type(value.get('ok')) is not bool:
+ raise WebError('core_protocol','Missing or invalid authoritative core response.',502)
+ if 'result' in value and operation=='execute':
+ result=value['result']
+ if not isinstance(result,dict) or result.get('status') not in {'succeeded','failed','cancelled'} or type(result.get('remote_work_may_have_started')) is not bool:
+ raise WebError('core_protocol','Core execution outcome could not be established.',502)
+ if result['status']=='succeeded' and (value['ok'] is not True or not isinstance(result.get('counts'),dict) or not COUNTS<=set(result['counts']) or any(type(result['counts'][k])is not int or result['counts'][k]<0 for k in COUNTS) or result.get('exit_code')!=0 or result.get('error')):
+ raise WebError('core_protocol','Core did not provide final execution counters; success is not assumed.',502)
+ target_summary,targets=_target_outcomes(result)
+ if request is not None and [x['host'] for x in targets]!=request['hosts']:
+ raise WebError('core_protocol','Final target accounting differs from the reviewed request.',502)
+ report=operation_result(result.get('operation_result'),declaration,targets=[x['host'] for x in targets],
+ check=request.get('check',True) if request else None,secrets=secrets)
+ return {'api_version':'1.0', 'run_id':str(result.get('run_id',''))[:100], 'status':result['status'],
+ 'stage':result.get('stage') if result.get('stage') in {'validation','authorization','readiness','credentials','syntax_check','execution','preparation','key_loading','vault_unlock','completed','result_validation'} else 'unknown', 'exit_code':result.get('exit_code') if type(result.get('exit_code')) is int else None,
+ 'remote_work_may_have_started':result['remote_work_may_have_started'],
+ 'error':safe_error(result['error']) if result.get('error') else None,
+ 'counts':{k:v for k,v in (result.get('counts') if isinstance(result.get('counts'),dict) else {}).items() if k in COUNTS and type(v)is int and v>=0},
+ 'target_summary':target_summary,'targets':targets,'operation_result':report}
+ if not value['ok']:
+ err=safe_error(value.get('error',{}))
+ exc=WebError('core_'+err['code'],err['message'],409 if err['code'] in {'review_stale','resource_busy'} else 503)
+ exc.core_error=err
+ raise exc
+ result=value.get('result')
+ if operation=='prepare' and isinstance(result,dict):
+ result={**result,'result_contract':contract(result.get('result_contract'))}
+ elif operation=='readiness' and isinstance(result,dict) and isinstance(result.get('prepared'),dict):
+ result={**result,'prepared':{**result['prepared'],'result_contract':contract(result['prepared'].get('result_contract'))}}
+ elif operation=='list_playbooks' and isinstance(result,list):
+ result=[{**item,'result':contract(item.get('result'))} for item in result]
+ return result
diff --git a/scripts/addons/webgui/src/aim_webgui/core/reports.py b/scripts/addons/webgui/src/aim_webgui/core/reports.py
new file mode 100644
index 0000000..54ad0cd
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/core/reports.py
@@ -0,0 +1,171 @@
+"""Validate the documented Core operation-report schema at the add-on boundary.
+
+Independent consumer implementation of the public, bounded JSON-schema subset.
+Never import Core/Ansible, resolve schema files, execute validators or fetch $refs.
+"""
+from __future__ import annotations
+import json
+import math
+import re
+from aim_webgui.errors import WebError
+
+PROTOCOL = 'aim_operation_result_v1'
+PUBLISHER = 'aim_output_v1'
+SLOT_BYTES = 1024 * 1024
+RUN_BYTES = 16 * 1024 * 1024
+MAX_DEPTH, MAX_NODES, MAX_ITEMS = 20, 200_000, 20_000
+SCHEMA_ID = re.compile(r'[a-z][a-z0-9_]{0,79}_v[1-9][0-9]*\Z')
+SECRET_KEY = re.compile(r'(^|_)(password|passwd|passphrase|secret|token|credential|private_key|vault)(_|$)', re.I)
+AVAILABILITY = frozenset({'available','missing','withheld','invalid','not_started','indeterminate'})
+REPORT_ERRORS = frozenset({'operation_result_missing','operation_result_invalid','operation_result_withheld',
+ 'operation_result_limit','operation_result_incomplete'})
+KEYWORDS = {'type','properties','required','additionalProperties','items','enum','maxItems','maxLength','minimum','maximum','description'}
+KINDS = {'object','array','string','number','integer','boolean','null'}
+CONTRACT_FIELDS = {'protocol','schema','scope','required','sensitivity','max_bytes_per_host','data_schema'}
+UNSPECIFIED = object()
+
+
+def bad(message='Core supplied an invalid operation-report contract or payload.'):
+ raise WebError('core_protocol', message, 502)
+
+
+def encoded(value):
+ try:
+ return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(',',':')).encode('utf-8')
+ except (ValueError, TypeError, UnicodeError, RecursionError):
+ bad()
+
+
+def _text(value, maximum=8192):
+ if not isinstance(value,str) or len(value)>maximum or any(ord(c)<32 or ord(c)==127 for c in value):
+ bad()
+ try: value.encode('utf-8')
+ except UnicodeError: bad()
+ return value
+
+
+def contract(value):
+ if value is None:
+ return None
+ if not isinstance(value,dict) or not CONTRACT_FIELDS <= value.keys():
+ bad()
+ if (value['protocol']!=PUBLISHER or not isinstance(value['schema'],str) or not SCHEMA_ID.fullmatch(value['schema'])
+ or value['scope'] not in ('per_host','global') or type(value['required']) is not bool
+ or value['sensitivity']!='safe' or type(value['max_bytes_per_host']) is not int
+ or not 1<=value['max_bytes_per_host']<=SLOT_BYTES):
+ bad()
+ budget=[0]
+ def shape(node, depth=0):
+ budget[0]+=1
+ if depth>MAX_DEPTH or budget[0]>5000 or not isinstance(node,dict) or set(node)-KEYWORDS:
+ bad('Core report schema uses an unsupported or unbounded schema language.')
+ kinds=node.get('type'); kinds=kinds if isinstance(kinds,list) else [kinds]
+ if not kinds or any(not isinstance(k,str) or k not in KINDS for k in kinds):bad()
+ if len(kinds)!=len(set(kinds)):bad()
+ clean={k:v for k,v in node.items() if k in KEYWORDS}
+ if 'description' in node:_text(node['description'])
+ if 'object' in kinds:
+ if type(node.get('additionalProperties'))is not bool or not isinstance(node.get('properties'),dict):bad()
+ if len(node['properties'])>MAX_ITEMS:bad()
+ props={}
+ for key,child in node['properties'].items():
+ _text(key,256)
+ if SECRET_KEY.search(key):bad()
+ props[key]=shape(child,depth+1)
+ required=node.get('required',[])
+ if not isinstance(required,list) or any(not isinstance(k,str) or k not in props for k in required):bad()
+ if len(required)!=len(set(required)):bad()
+ clean['properties']=props;clean['required']=required
+ if 'array' in kinds:
+ if type(node.get('maxItems'))is not int or not 0<=node['maxItems']<=MAX_ITEMS:bad()
+ clean['items']=shape(node.get('items'),depth+1)
+ if 'string' in kinds and (type(node.get('maxLength'))is not int or not 0<=node['maxLength']<=8192):bad()
+ if 'enum' in node:
+ if not isinstance(node['enum'],list) or len(node['enum'])>100:bad()
+ if any(type(item) not in (type(None),bool,int,float,str) for item in node['enum']):bad()
+ for item in node['enum']:
+ if isinstance(item,str):_text(item)
+ if type(item) in (int,float) and (not -1e100<=item<=1e100 or not math.isfinite(item)):bad()
+ for key in ('minimum','maximum'):
+ if key in node and (type(node[key])not in (int,float) or not -1e100<=node[key]<=1e100 or not math.isfinite(node[key])):bad()
+ return clean
+ result={key:value[key] for key in CONTRACT_FIELDS}
+ result['data_schema']=shape(value['data_schema'])
+ if len(encoded(result))>256*1024:bad('Core report schema exceeds the supported metadata size.')
+ return result
+
+
+def data(value, schema, *, secrets=()):
+ """Copy and validate types, sizes and declared fields. No string reinterpretation."""
+ budget=[0]
+ dynamic={'type':list(KINDS),'properties':{},'additionalProperties':True,'maxItems':MAX_ITEMS,'maxLength':8192}
+ dynamic['items']=dynamic
+ def walk(item,node,depth=0):
+ budget[0]+=1
+ if depth>MAX_DEPTH or budget[0]>MAX_NODES:bad('Core report exceeds its structural bounds.')
+ kind=('null' if item is None else 'boolean' if type(item)is bool else 'integer' if type(item)is int
+ else 'number' if type(item)is float else 'string' if type(item)is str
+ else 'object' if type(item)is dict else 'array' if type(item)is list else 'invalid')
+ kinds=node['type'] if isinstance(node['type'],list) else [node['type']]
+ if kind not in kinds and not(kind=='integer' and 'number' in kinds):bad()
+ if 'enum' in node and not any(item==v and (type(item)is type(v) or type(item)in(int,float) and type(v)in(int,float)) for v in node['enum']):bad()
+ if kind=='object':
+ if len(item)>MAX_ITEMS:bad()
+ props=node['properties']
+ if (not node['additionalProperties'] and set(item)-set(props)) or set(node.get('required',[]))-set(item):bad()
+ result={}
+ for key,val in item.items():
+ _text(key,256)
+ if (SECRET_KEY.search(key) and val!='[REDACTED]') or any(s and s in key for s in secrets):bad('Core report contained disallowed sensitive content.')
+ result[key]=walk(val,props.get(key,dynamic),depth+1)
+ return result
+ if kind=='array':
+ if len(item)>node['maxItems']:bad()
+ return [walk(v,node['items'],depth+1) for v in item]
+ if kind=='string':
+ _text(item,node['maxLength'])
+ if any(s and s in item for s in secrets):bad('Core report contained a supplied credential value.')
+ if kind in ('integer','number'):
+ if not node.get('minimum',-1e100)<=item<=node.get('maximum',1e100) or not math.isfinite(item):bad()
+ return item
+ return walk(value,schema)
+
+
+def operation_result(value, declaration=UNSPECIFIED, *, targets=(), check=None, secrets=()):
+ """Project only public fields and bind every report to the reviewed contract."""
+ if value is None:
+ if declaration not in (None,UNSPECIFIED):bad('Declared report accounting is absent from the final Core result.')
+ return None
+ if declaration is None:bad('Core returned an undeclared operation report.')
+ if declaration is UNSPECIFIED:bad('A reviewed result contract is required to accept operation reports.')
+ decl=contract(declaration)
+ if not isinstance(value,dict):bad()
+ for key,expect in (('protocol',PROTOCOL),('schema',decl['schema']),('scope',decl['scope']),('required',decl['required'])):
+ if value.get(key)!=expect or key=='required' and type(value.get(key))is not bool:bad()
+ if type(value.get('complete'))is not bool or type(value.get('check_mode'))is not bool:bad()
+ if check is not None and value['check_mode'] is not check:bad('Core report mode differs from the reviewed request.')
+ hosts=value.get('hosts')
+ if not isinstance(hosts,dict) or len(hosts)>1000:bad()
+ if decl['scope']=='per_host':
+ if set(hosts)!=set(targets) or len(hosts)!=len(targets) or value.get('global')is not None:bad('Core report hosts differ from the reviewed targets.')
+ slots=hosts
+ else:
+ if hosts or not isinstance(value.get('global'),dict):bad()
+ slots={'':value['global']}
+ cleaned={};total=0
+ for host,entry in slots.items():
+ if (not isinstance(entry,dict) or entry.get('schema')!=decl['schema'] or not isinstance(entry.get('status'),str) or entry.get('status')not in AVAILABILITY
+ or entry.get('error') is not None and (not isinstance(entry.get('error'),str) or entry.get('error')not in REPORT_ERRORS)):bad()
+ content=None
+ if entry['status']=='available':
+ if entry.get('error')is not None:bad()
+ content=data(entry.get('data'),decl['data_schema'],secrets=secrets)
+ size=len(encoded({'protocol':PUBLISHER,'schema':decl['schema'],'data':content}))
+ total+=size
+ if size>decl['max_bytes_per_host'] or total>RUN_BYTES:bad('Core operation report exceeded its advertised byte limits.')
+ elif entry.get('data')is not None:bad('Unavailable report slots must not carry data.')
+ cleaned[host]={'schema':decl['schema'],'status':entry['status'],'data':content,'error':entry.get('error')}
+ if value['complete'] and any(v['status']!='available' for v in cleaned.values()):bad()
+ return {'protocol':PROTOCOL,'schema':decl['schema'],'scope':decl['scope'],'required':decl['required'],
+ 'complete':value['complete'],'check_mode':value['check_mode'],
+ 'hosts':cleaned if decl['scope']=='per_host' else {},'global':cleaned.get('') if decl['scope']=='global' else None}
diff --git a/scripts/addons/webgui/src/aim_webgui/credentials/__init__.py b/scripts/addons/webgui/src/aim_webgui/credentials/__init__.py
new file mode 100644
index 0000000..423f51e
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/credentials/__init__.py
@@ -0,0 +1 @@
+"""Job-scoped credentials. No durable secret store or cross-run cache."""
diff --git a/scripts/addons/webgui/src/aim_webgui/credentials/presentation.py b/scripts/addons/webgui/src/aim_webgui/credentials/presentation.py
new file mode 100644
index 0000000..78de64a
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/credentials/presentation.py
@@ -0,0 +1,77 @@
+"""Non-secret credential/attention presentation. Never prepares or runs Core work."""
+from __future__ import annotations
+
+import json
+import time
+
+from aim_webgui.errors import WebError
+from aim_webgui.workflows import Workflows, actor, allowed
+
+SUPPORTED = frozenset({'vault_password', 'connection_password',
+ 'ssh_key_passphrase', 'ssh_key_passphrase_or_customer_vault_value'})
+
+
+def form_context(job: dict, *, now: int | None = None) -> dict:
+ requirements = job['plan'].get('credential_requirements', [])
+ if not isinstance(requirements, list) or not requirements or any(r not in SUPPORTED for r in requirements):
+ raise WebError('unsupported_credentials', 'This run needs credentials not supported by this form. Review the job.', 409)
+ return {'job': job, 'credential_requirements': requirements,
+ 'credential_server_now': int(time.time()) if now is None else now,
+ 'has_vault': 'vault_password' in requirements,
+ 'has_connection': 'connection_password' in requirements,
+ 'has_key': any(r.startswith('ssh_key_passphrase') for r in requirements),
+ 'key_from_vault': ('vault_password' in requirements
+ and 'ssh_key_passphrase_or_customer_vault_value' in requirements
+ and 'ssh_key_passphrase' not in requirements)}
+
+
+def status(flow: Workflows, user_id: int, ident: str) -> dict:
+ """Owner-only status, not an eligibility token or an execution/renewal action."""
+ job = flow.job(user_id, ident)
+ if job['owner_id'] != user_id:
+ raise WebError('credential_owner', 'Only the requesting account may supply this job\'s credentials.', 403)
+ now = int(time.time())
+ deadline = job.get('credential_deadline') or 0
+ phase = job.get('credential_phase') or ''
+ can_submit = (flow.settings.credentials_enabled and job['status'] == 'running'
+ and phase == 'waiting' and deadline > now and not job['cancel_requested'])
+ if can_submit:
+ with flow.store.read() as db:
+ allowed(db, user_id, job['plan']['customer'], job['plan']['playbook'])
+ return {'job_id': ident, 'status': job['status'], 'phase': phase,
+ 'can_submit': bool(can_submit), 'cancel_requested': bool(job['cancel_requested']),
+ 'server_now': now, 'deadline': deadline if phase == 'waiting' else None}
+
+
+def attention(flow: Workflows, user_id: int, *, limit: int = 8) -> dict:
+ """All actionable jobs, not the latest-100 history page. No secrets or Core calls."""
+ now = int(time.time())
+ items = []
+ with flow.store.read() as db:
+ who = actor(db, user_id)
+ rows = db.execute('''SELECT jobs.id, jobs.owner_id, jobs.plan, jobs.mode, jobs.status,
+ jobs.credential_deadline, jobs.created_at, users.username
+ FROM jobs JOIN users ON users.id=jobs.owner_id
+ WHERE jobs.cancel_requested=0 AND (
+ (jobs.owner_id=? AND jobs.status='running' AND jobs.credential_phase='waiting'
+ AND jobs.credential_deadline>?) OR
+ (?='admin' AND jobs.owner_id<>? AND jobs.status='pending'))
+ ORDER BY CASE WHEN jobs.status='running' THEN 0 ELSE 1 END,
+ jobs.credential_deadline, jobs.created_at''', (user_id, now, who['role'], user_id))
+ for row in rows:
+ credential = row['status'] == 'running'
+ if credential and not flow.settings.credentials_enabled:
+ continue
+ plan = json.loads(row['plan'])
+ try:
+ allowed(db, row['owner_id'], plan['customer'], plan['playbook'])
+ except WebError:
+ continue
+ items.append({'id': row['id'], 'customer': plan['customer'], 'playbook': plan['playbook'],
+ 'username': row['username'], 'mode': row['mode'],
+ 'target_count': len(plan.get('targets', [])),
+ 'kind': 'credentials' if credential else 'approval',
+ 'deadline': row['credential_deadline'] if credential else None})
+ return {'items': items[:limit], 'total': len(items), 'shown': min(limit, len(items)),
+ 'credentials': sum(i['kind'] == 'credentials' for i in items),
+ 'approvals': sum(i['kind'] == 'approval' for i in items)}
diff --git a/scripts/addons/webgui/src/aim_webgui/credentials/service.py b/scripts/addons/webgui/src/aim_webgui/credentials/service.py
new file mode 100644
index 0000000..02a9c52
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/credentials/service.py
@@ -0,0 +1,67 @@
+"""HTTP-to-worker hand-off. Never retain secrets awaiting a queue slot."""
+from __future__ import annotations
+import json
+import time
+
+from aim_webgui.credentials import wire
+from aim_webgui.errors import WebError
+from aim_webgui.workflows import Workflows, allowed
+
+HANDOFF_SECONDS = 60
+WAIT_SECONDS = 300
+
+
+def fields(value, requirements):
+ """Core-reported one-run fields only. Passwords are literal, not overrides."""
+ from aim_webgui.core.protocol import validate_secrets
+ result=validate_secrets(value)
+ permitted=set(requirements)-{'ssh_key_passphrase_or_customer_vault_value'}
+ if 'ssh_key_passphrase_or_customer_vault_value' in requirements:permitted.add('ssh_key_passphrase')
+ if set(result)-permitted:raise WebError('unexpected_credentials','Only fields requested by this prepared core run may be supplied.')
+ for name in ('vault_password','connection_password','ssh_key_passphrase'):
+ if name in requirements and not result.get(name):
+ raise WebError('credential_required','Enter the required one-run credential; values are not saved.')
+ if ('ssh_key_passphrase_or_customer_vault_value' in requirements and not result.get('vault_password')
+ and not result.get('ssh_key_passphrase')):
+ raise WebError('credential_required','Enter the key passphrase or unlock the customer Vault containing it.')
+ return result
+
+
+def valid_session(flow, token_hash, user_id):
+ now = int(time.time())
+ with flow.store.read() as db:
+ row = db.execute('SELECT * FROM sessions WHERE token_hash=? AND user_id=?', (token_hash, user_id)).fetchone()
+ if not row or row['expires_at'] <= now or row['last_seen_at'] + flow.settings.idle_minutes * 60 <= now:
+ raise WebError('session_expired', 'Sign in again before submitting credentials.', 403)
+
+
+def eligible(flow, user_id, ident):
+ if not flow.settings.credentials_enabled:
+ raise WebError('credentials_disabled', 'Credential execution is disabled in this release profile.', 501)
+ job = flow.job(user_id, ident)
+ if job['owner_id'] != user_id:
+ raise WebError('credential_owner', 'Only the requesting account may supply this job\'s credentials.', 403)
+ if (job['status'] != 'running' or job['credential_phase'] != 'waiting'
+ or (job['credential_deadline'] or 0) <= time.time() or job['cancel_requested']):
+ raise WebError('worker_not_ready', 'Credentials are accepted only while this job is awaiting credentials.', 409)
+ with flow.store.read() as db:
+ allowed(db, user_id, job['plan']['customer'], job['plan']['playbook'])
+ return job
+
+
+def submit(settings, user_id, session_hash, ident, supplied):
+ flow = Workflows(settings)
+ job = eligible(flow, user_id, ident)
+ valid_session(flow, session_hash, user_id)
+ secret = fields(supplied, job['plan']['credential_requirements'])
+ try:
+ with wire.connect(settings.state_dir / '.credential.sock') as sock:
+ wire.send(sock, {'job': ident, 'user': user_id, 'session': session_hash, 'credentials': secret}, wire.SECRET_LIMIT)
+ result = wire.receive(sock, wire.SECRET_LIMIT)
+ if result.get('accepted') is not True:
+ raise WebError('credential_rejected', 'The worker did not accept this credential hand-off. Check the job before another manual attempt.', 409)
+ return {'accepted': True, 'notice': 'One-run hand-off accepted; this is not remote authentication verification.'}
+ except (OSError, ValueError):
+ raise WebError('worker_not_ready', 'The credential hand-off could not be confirmed. Check the job state before another manual attempt; do not resubmit automatically.', 409) from None
+ finally:
+ secret.clear()
diff --git a/scripts/addons/webgui/src/aim_webgui/credentials/wire.py b/scripts/addons/webgui/src/aim_webgui/credentials/wire.py
new file mode 100644
index 0000000..ac6df25
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/credentials/wire.py
@@ -0,0 +1,78 @@
+"""Bounded, length-framed local IPC. This module uses only the standard library."""
+from __future__ import annotations
+import json
+import os
+import socket
+import stat
+import struct
+
+MAX_MESSAGE = 1024 * 1024
+SECRET_LIMIT = 8192
+
+
+def recv_exact(sock, size):
+ buf = bytearray()
+ while len(buf) < size:
+ chunk = sock.recv(size - len(buf))
+ if not chunk:
+ raise ValueError('Credential channel closed.')
+ buf.extend(chunk)
+ return bytes(buf)
+
+
+def receive(sock, limit=MAX_MESSAGE, *, decoder=json.loads):
+ size = struct.unpack('!I', recv_exact(sock, 4))[0]
+ if size < 2 or size > limit:
+ raise ValueError('Credential message exceeds its limit.')
+ result = decoder(recv_exact(sock, size))
+ if not isinstance(result, dict):
+ raise ValueError('Invalid credential message.')
+ return result
+
+
+def send(sock, value, limit=MAX_MESSAGE):
+ data = json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(',', ':')).encode('utf-8')
+ if len(data) > limit:
+ raise ValueError('Credential message exceeds its limit.')
+ sock.sendall(struct.pack('!I', len(data)) + data)
+
+
+def peer(sock, *, same_group=False):
+ pid, uid, gid = struct.unpack('3i', sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
+ if uid != os.geteuid() or (same_group and os.getpgid(pid) != os.getpgrp()):
+ raise ValueError('Untrusted local credential peer.')
+ return pid
+
+
+def connect(path, *, timeout=5):
+ item = os.lstat(path)
+ if not stat.S_ISSOCK(item.st_mode) or item.st_uid != os.geteuid() or item.st_mode & 0o077:
+ raise ValueError('Credential endpoint must be private and service-owned.')
+ sock = socket.socket(socket.AF_UNIX)
+ sock.settimeout(timeout)
+ try:
+ sock.connect(str(path))
+ peer(sock)
+ except BaseException:
+ sock.close()
+ raise
+ return sock
+
+
+def listen(path):
+ # Caller owns a private directory. Never follow/delete an arbitrary symlink.
+ if os.path.lexists(path):
+ item = os.lstat(path)
+ if not stat.S_ISSOCK(item.st_mode) or item.st_uid != os.geteuid():
+ raise ValueError('Unsafe existing credential endpoint.')
+ os.unlink(path)
+ sock = socket.socket(socket.AF_UNIX)
+ try:
+ sock.bind(str(path))
+ os.chmod(path, 0o600)
+ sock.listen(2)
+ sock.settimeout(.5)
+ except BaseException:
+ sock.close()
+ raise
+ return sock
diff --git a/roles/checkmk_scripts/files/Windows/local/citrix_sessions_customized.ps1.example b/scripts/addons/webgui/src/aim_webgui/db/__init__.py
similarity index 100%
rename from roles/checkmk_scripts/files/Windows/local/citrix_sessions_customized.ps1.example
rename to scripts/addons/webgui/src/aim_webgui/db/__init__.py
diff --git a/scripts/addons/webgui/src/aim_webgui/db/migrations/0001_initial.sql b/scripts/addons/webgui/src/aim_webgui/db/migrations/0001_initial.sql
new file mode 100644
index 0000000..8e3e3a4
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/db/migrations/0001_initial.sql
@@ -0,0 +1,27 @@
+CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);
+CREATE TABLE users (
+ id INTEGER PRIMARY KEY,
+ username TEXT NOT NULL UNIQUE COLLATE NOCASE,
+ password_hash TEXT NOT NULL,
+ role TEXT NOT NULL CHECK(role IN ('admin','viewer')),
+ enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)),
+ must_change_password INTEGER NOT NULL DEFAULT 1 CHECK(must_change_password IN (0,1)),
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ last_login_at INTEGER
+);
+CREATE TABLE sessions (
+ token_hash TEXT PRIMARY KEY,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ csrf TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ last_seen_at INTEGER NOT NULL
+);
+CREATE INDEX sessions_user ON sessions(user_id);
+CREATE INDEX sessions_expiry ON sessions(expires_at);
+CREATE TABLE rate_limits (bucket TEXT PRIMARY KEY, started INTEGER NOT NULL, attempts INTEGER NOT NULL);
+CREATE TABLE audit (
+ id INTEGER PRIMARY KEY, occurred_at INTEGER NOT NULL,
+ actor TEXT NOT NULL, action TEXT NOT NULL, subject TEXT NOT NULL
+);
diff --git a/scripts/addons/webgui/src/aim_webgui/db/migrations/0002_workflows.sql b/scripts/addons/webgui/src/aim_webgui/db/migrations/0002_workflows.sql
new file mode 100644
index 0000000..b0f273c
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/db/migrations/0002_workflows.sql
@@ -0,0 +1,54 @@
+CREATE TABLE plans (
+ id TEXT PRIMARY KEY,
+ owner_id INTEGER NOT NULL REFERENCES users(id),
+ name TEXT NOT NULL,
+ customer TEXT NOT NULL,
+ playbook TEXT NOT NULL,
+ payload TEXT NOT NULL,
+ created_at INTEGER NOT NULL
+);
+CREATE INDEX plans_owner ON plans(owner_id, created_at);
+CREATE TABLE grants (
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ customer TEXT NOT NULL,
+ playbook TEXT NOT NULL,
+ PRIMARY KEY(user_id, customer, playbook)
+);
+CREATE TABLE jobs (
+ id TEXT PRIMARY KEY,
+ owner_id INTEGER NOT NULL REFERENCES users(id),
+ approver_id INTEGER REFERENCES users(id),
+ plan TEXT NOT NULL,
+ mode TEXT NOT NULL CHECK(mode IN ('apply','check')),
+ status TEXT NOT NULL CHECK(status IN ('pending','queued','running','successful','failed','canceled','timed_out','blocked','interrupted')),
+ created_at INTEGER NOT NULL,
+ scheduled_at INTEGER NOT NULL,
+ started_at INTEGER,
+ finished_at INTEGER,
+ return_code INTEGER,
+ cancel_requested INTEGER NOT NULL DEFAULT 0,
+ reason TEXT NOT NULL DEFAULT ''
+);
+CREATE INDEX jobs_due ON jobs(status, scheduled_at);
+CREATE TABLE job_events (
+ id INTEGER PRIMARY KEY,
+ job_id TEXT NOT NULL REFERENCES jobs(id),
+ occurred_at INTEGER NOT NULL,
+ event TEXT NOT NULL
+);
+CREATE INDEX events_job ON job_events(job_id, id);
+CREATE TABLE selection_drafts (
+ user_id INTEGER NOT NULL REFERENCES users(id),
+ customer TEXT NOT NULL,
+ playbook TEXT NOT NULL,
+ targets TEXT NOT NULL,
+ updated_at INTEGER NOT NULL,
+ PRIMARY KEY(user_id, customer, playbook)
+);
+CREATE INDEX audit_time ON audit(occurred_at, id);
+CREATE TABLE job_requests (
+ owner_id INTEGER NOT NULL REFERENCES users(id),
+ request_key TEXT NOT NULL,
+ job_id TEXT NOT NULL REFERENCES jobs(id),
+ PRIMARY KEY(owner_id,request_key)
+);
diff --git a/scripts/addons/webgui/src/aim_webgui/db/migrations/0003_credentials.sql b/scripts/addons/webgui/src/aim_webgui/db/migrations/0003_credentials.sql
new file mode 100644
index 0000000..790c016
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/db/migrations/0003_credentials.sql
@@ -0,0 +1,3 @@
+-- Non-secret phase metadata only. Passwords never enter this database.
+ALTER TABLE jobs ADD COLUMN credential_phase TEXT NOT NULL DEFAULT 'none';
+ALTER TABLE jobs ADD COLUMN credential_deadline INTEGER;
diff --git a/scripts/addons/webgui/src/aim_webgui/db/migrations/0004_core_v1.sql b/scripts/addons/webgui/src/aim_webgui/db/migrations/0004_core_v1.sql
new file mode 100644
index 0000000..9c30464
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/db/migrations/0004_core_v1.sql
@@ -0,0 +1,19 @@
+-- Additive migration. Keep historical jobs, accounts, grants, plans and audit.
+ALTER TABLE plans ADD COLUMN name_key TEXT;
+CREATE UNIQUE INDEX plan_names_unique ON plans(owner_id,name_key) WHERE name_key IS NOT NULL;
+ALTER TABLE job_requests ADD COLUMN request_hash TEXT;
+ALTER TABLE jobs ADD COLUMN core_result TEXT;
+CREATE TABLE run_reviews (
+ id TEXT PRIMARY KEY,
+ owner_id INTEGER NOT NULL REFERENCES users(id),
+ payload TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL
+);
+CREATE INDEX review_owner_expiry ON run_reviews(owner_id,expires_at);
+INSERT INTO audit(occurred_at,actor,action,subject)
+SELECT CAST(strftime('%s','now') AS INTEGER),'migration','legacy-job-stopped',id FROM jobs WHERE status IN ('pending','queued','running');
+UPDATE jobs SET status=CASE WHEN status='running' THEN 'interrupted' ELSE 'blocked' END,
+ finished_at=CAST(strftime('%s','now') AS INTEGER),credential_phase='released',credential_deadline=NULL,
+ reason='Core API migration: this old-core job was not replayed. Review a new run against AIM 3.1.0.'
+ WHERE status IN ('pending','queued','running');
diff --git a/scripts/addons/webgui/src/aim_webgui/db/migrations/0005_job_evidence.sql b/scripts/addons/webgui/src/aim_webgui/db/migrations/0005_job_evidence.sql
new file mode 100644
index 0000000..b34df39
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/db/migrations/0005_job_evidence.sql
@@ -0,0 +1,36 @@
+-- Retained WebGUI-owned evidence. No raw output, credential or inventory archive.
+CREATE TABLE job_progress_state (
+ job_id TEXT PRIMARY KEY REFERENCES jobs(id) ON DELETE CASCADE,
+ run_id TEXT, capture_state TEXT NOT NULL DEFAULT 'recording',
+ cursor INTEGER NOT NULL DEFAULT 0, last_sequence INTEGER NOT NULL DEFAULT 0,
+ retained_events INTEGER NOT NULL DEFAULT 0, retained_bytes INTEGER NOT NULL DEFAULT 0,
+ omitted_events INTEGER NOT NULL DEFAULT 0, dropped_events INTEGER NOT NULL DEFAULT 0,
+ checkpoint TEXT NOT NULL DEFAULT '{}', updated_at REAL NOT NULL, closed_at REAL
+);
+CREATE TABLE job_progress_events (
+ job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, cursor INTEGER NOT NULL,
+ run_id TEXT NOT NULL, sequence INTEGER NOT NULL, received_at REAL NOT NULL,
+ payload TEXT NOT NULL, size_bytes INTEGER NOT NULL,
+ PRIMARY KEY(job_id,cursor), UNIQUE(job_id,run_id,sequence)
+);
+CREATE TABLE job_operation_results (
+ job_id TEXT PRIMARY KEY REFERENCES jobs(id) ON DELETE CASCADE,
+ contract TEXT, protocol TEXT, schema_id TEXT, scope TEXT,
+ required INTEGER, complete INTEGER, check_mode INTEGER, stored_at REAL NOT NULL,
+ retention_policy TEXT NOT NULL
+);
+CREATE TABLE job_operation_reports (
+ job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
+ slot TEXT NOT NULL, schema_id TEXT NOT NULL, status TEXT NOT NULL, error TEXT,
+ retention TEXT NOT NULL, data TEXT, size_bytes INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY(job_id,slot)
+);
+-- Never replay plans prepared against the old independently upgraded Core.
+INSERT INTO audit(occurred_at,actor,action,subject)
+ SELECT CAST(strftime('%s','now') AS INTEGER),'migration','core33-job-stopped',id
+ FROM jobs WHERE status IN ('pending','queued','running');
+UPDATE jobs SET status=CASE WHEN status='running' THEN 'interrupted' ELSE 'blocked' END,
+ finished_at=CAST(strftime('%s','now') AS INTEGER),credential_phase='released',credential_deadline=NULL,
+ reason='Core 3.3 migration: old prepared work was not replayed. Review a fresh run.'
+ WHERE status IN ('pending','queued','running');
+DELETE FROM run_reviews;
diff --git a/scripts/addons/webgui/src/aim_webgui/db/store.py b/scripts/addons/webgui/src/aim_webgui/db/store.py
new file mode 100644
index 0000000..2572171
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/db/store.py
@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+from contextlib import contextmanager
+import os
+from pathlib import Path
+import sqlite3
+import stat
+import time
+
+from aim_webgui import SCHEMA_VERSION
+
+
+def private_file(path: Path, content: str, *, exclusive: bool = True) -> None:
+ flags = os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW
+ flags |= os.O_EXCL if exclusive else os.O_TRUNC
+ fd = os.open(path, flags, 0o600)
+ with os.fdopen(fd, 'w', encoding='utf-8') as stream:
+ os.fchmod(stream.fileno(), 0o600)
+ stream.write(content)
+ stream.flush()
+ os.fsync(stream.fileno())
+
+
+def ensure_private_dir(path: Path) -> None:
+ path.mkdir(parents=True, mode=0o700, exist_ok=True)
+ mode = path.lstat()
+ if not stat.S_ISDIR(mode.st_mode) or mode.st_uid != os.geteuid():
+ raise ValueError('State directory must be a real directory owned by the current service identity.')
+ if stat.S_IMODE(mode.st_mode) & 0o077:
+ raise ValueError('State directory must have mode 0700.')
+
+
+class Store:
+ def __init__(self, path: Path):
+ self.path = path
+
+ def connect(self, *, create: bool = False, timeout: float = 10) -> sqlite3.Connection:
+ ensure_private_dir(self.path.parent)
+ if create and not self.path.exists():
+ fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
+ os.close(fd)
+ mode = self.path.lstat()
+ if (not stat.S_ISREG(mode.st_mode) or mode.st_uid != os.geteuid()
+ or stat.S_IMODE(mode.st_mode) & 0o077):
+ raise ValueError('Database must be a service-owned regular file with mode 0600.')
+ db = sqlite3.connect(self.path.as_uri() + '?mode=rw', uri=True, timeout=timeout)
+ db.row_factory = sqlite3.Row
+ db.execute('PRAGMA foreign_keys=ON')
+ db.execute(f'PRAGMA busy_timeout={max(0,int(timeout * 1000))}')
+ # Retain rollback journaling and FULL commits; evidence uses bounded batches.
+ db.execute('PRAGMA synchronous=FULL')
+ return db
+
+ @contextmanager
+ def transaction(self, *, timeout: float = 10):
+ db = self.connect(timeout=timeout)
+ try:
+ db.execute('BEGIN IMMEDIATE')
+ yield db
+ db.commit()
+ except BaseException:
+ db.rollback()
+ raise
+ finally:
+ db.close()
+
+ @contextmanager
+ def read(self):
+ db = self.connect()
+ try:
+ yield db
+ finally:
+ db.close()
+
+ def migrate(self, *, create: bool = False) -> None:
+ db = self.connect(create=create)
+ try:
+ db.execute('PRAGMA journal_mode=DELETE')
+ db.execute('BEGIN IMMEDIATE')
+ version = db.execute('PRAGMA user_version').fetchone()[0]
+ if version > SCHEMA_VERSION:
+ raise ValueError('Database schema is newer than this add-on. Restore the matched backup before rollback.')
+ for target in range(version + 1, SCHEMA_VERSION + 1):
+ matches = list((Path(__file__).parent / 'migrations').glob(f'{target:04d}_*.sql'))
+ if len(matches) != 1:
+ raise ValueError('Missing or ambiguous database migration.')
+ statement = ''
+ for line in matches[0].read_text(encoding='utf-8').splitlines(True):
+ statement += line
+ if sqlite3.complete_statement(statement):
+ db.execute(statement)
+ statement = ''
+ if statement.strip():
+ raise ValueError('Incomplete database migration.')
+ if target == 4:
+ from aim_webgui.names import name_key
+ seen=set()
+ for row in db.execute('SELECT id,owner_id,name FROM plans ORDER BY created_at,id').fetchall():
+ key=(row['owner_id'],name_key(row['name']))
+ # Preserve existing duplicate titles/payloads unchanged. First
+ # occurrence reserves the key; new saves also check all legacy names.
+ if key not in seen:
+ db.execute('UPDATE plans SET name_key=? WHERE id=?',(key[1],row['id']))
+ seen.add(key)
+ db.execute(f'PRAGMA user_version={target}')
+ db.commit()
+ except BaseException:
+ db.rollback()
+ raise
+ finally:
+ db.close()
+
+ def check(self, *, require_admin: bool = True) -> None:
+ with self.read() as db:
+ if db.execute('PRAGMA user_version').fetchone()[0] != SCHEMA_VERSION:
+ raise ValueError('Database migration required. Stop the service and run aim-web db migrate.')
+ if db.execute("SELECT value FROM metadata WHERE key='initialized'").fetchone() is None:
+ raise ValueError('Database not initialized. Run aim-web init once.')
+ if require_admin and not db.execute("SELECT 1 FROM users WHERE role='admin' AND enabled=1 LIMIT 1").fetchone():
+ raise ValueError('No enabled administrator; use local recovery.')
+
+ def backup(self, destination: Path) -> None:
+ if destination.exists() or destination.is_symlink():
+ raise ValueError('Backup destination already exists.')
+ fd = os.open(destination, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
+ os.close(fd)
+ try:
+ with self.read() as source:
+ target = sqlite3.connect(destination)
+ try:
+ source.backup(target)
+ if target.execute('PRAGMA integrity_check').fetchone()[0] != 'ok':
+ raise ValueError('Database backup integrity check failed.')
+ finally:
+ target.close()
+ fd = os.open(destination, os.O_RDONLY | os.O_NOFOLLOW)
+ try:
+ os.fsync(fd)
+ finally:
+ os.close(fd)
+ except BaseException:
+ destination.unlink(missing_ok=True)
+ raise
+
+
+def audit(db, actor: str, action: str, subject: str) -> None:
+ # Fixed event names and account identifiers only; never request bodies/credentials.
+ db.execute('INSERT INTO audit(occurred_at,actor,action,subject) VALUES(?,?,?,?)',
+ (int(time.time()), actor, action, subject))
diff --git a/scripts/addons/webgui/src/aim_webgui/diagnostics.py b/scripts/addons/webgui/src/aim_webgui/diagnostics.py
new file mode 100644
index 0000000..d7f40b9
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/diagnostics.py
@@ -0,0 +1,45 @@
+"""Read-only diagnostics. No automatic repairs, ownership changes or secrets."""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+import time
+from aim_webgui import __version__, SCHEMA_VERSION, COMPATIBLE_AIM
+from aim_webgui.config import Settings
+from aim_webgui.db.store import Store
+
+
+def report(settings: Settings, config: Path | None = None) -> dict:
+ checks = []
+ def check(name, operation):
+ try:
+ value = operation()
+ checks.append({'name': name, 'ok': True, 'detail': str(value)})
+ except Exception as exc:
+ checks.append({'name': name, 'ok': False, 'detail': type(exc).__name__ + ': check service identity, permissions or installed dependencies.'})
+ check('configuration', lambda: settings.validate() and 'Valid')
+ from aim_webgui.adapters.core_v1 import CoreAdapter
+ check('AIM service contract',lambda:CoreAdapter(settings).version)
+ check('Core protected metadata access',lambda:len(CoreAdapter(settings).customers()))
+ check('Core execution opt-in',lambda:CoreAdapter(settings).capabilities['execution'])
+ from aim_webgui.assets import check_assets
+ check('local browser assets', lambda: check_assets() or 'Verified')
+ store = Store(settings.database)
+ check('database schema and enabled administrator', lambda: store.check() or f'Schema {SCHEMA_VERSION}')
+ heartbeat = None
+ try:
+ with store.read() as db:
+ row = db.execute("SELECT value FROM metadata WHERE key='worker_heartbeat'").fetchone()
+ heartbeat = max(0, int(time.time()) - int(row[0])) if row else None
+ except Exception:
+ pass
+ if settings.execution_enabled:
+ checks.append({'name': 'worker heartbeat', 'ok': heartbeat is not None and heartbeat < 15, 'detail': f'{heartbeat}s ago' if heartbeat is not None else 'Worker has not reported'})
+ return {'version': __version__, 'aim_compatibility': list(COMPATIBLE_AIM), 'schema': SCHEMA_VERSION,
+ 'config': str(config) if config else '(provided at startup)',
+ 'runtime': str(Path(__file__).resolve().parent), 'service_uid': os.geteuid(),'core_transport':settings.core_transport,'core_executor_user':settings.core_executor_user,
+ 'listener': f'{settings.host}:{settings.port}', 'public_url': settings.public_url,
+ 'trusted_proxies': list(settings.forwarded_allow_ips), 'execution_enabled': settings.execution_enabled,
+ 'worker_heartbeat_age_seconds': heartbeat, 'checks': checks,
+ 'ok': all(c['ok'] for c in checks),
+ 'transport_note': 'TLS verification between NPM and controller must be checked in Nginx; this application cannot prove it.'}
diff --git a/scripts/addons/webgui/src/aim_webgui/errors.py b/scripts/addons/webgui/src/aim_webgui/errors.py
new file mode 100644
index 0000000..15ca05f
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/errors.py
@@ -0,0 +1,5 @@
+class WebError(Exception):
+ """Only this deliberately public message may be returned to a browser."""
+ def __init__(self, code: str, message: str, status: int = 400):
+ super().__init__(message)
+ self.code, self.message, self.status = code, message, status
diff --git a/scripts/addons/webgui/src/aim_webgui/evidence_views.py b/scripts/addons/webgui/src/aim_webgui/evidence_views.py
new file mode 100644
index 0000000..5335a46
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/evidence_views.py
@@ -0,0 +1,102 @@
+"""Authorized read/replay endpoints for WebGUI-owned execution evidence."""
+from __future__ import annotations
+import asyncio
+import json
+import time
+from urllib.parse import urlencode
+from fastapi import Request
+from fastapi.responses import JSONResponse, StreamingResponse
+from starlette.concurrency import run_in_threadpool
+from aim_webgui.errors import WebError
+from aim_webgui.journal import Journal
+from aim_webgui.reports import Reports, presentation
+from aim_webgui.workflows import Workflows
+
+
+def job_evidence(settings,user_id,ident):
+ return {'progress':Journal(settings).snapshot(user_id,ident),'reports':Reports(settings).index(user_id,ident)}
+
+
+def install_evidence_views(app,settings,auth,render):
+ journal=Journal(settings);reports=Reports(settings);flow=Workflows(settings)
+ def uid(request):return request.state.session['user_id']
+
+ @app.get('/api/v2/runs/{ident}/progress')
+ async def progress(request: Request,ident: str,after: int|None=None,before: int|None=None,limit: int=200):
+ return await run_in_threadpool(journal.snapshot,uid(request),ident,after=after,before=before,limit=limit)
+
+ @app.get('/jobs/{ident}/progress')
+ async def progress_page(request: Request,ident: str,before: int|None=None):
+ data=await run_in_threadpool(journal.snapshot,uid(request),ident,before=before)
+ job=await run_in_threadpool(flow.job,uid(request),ident)
+ return render(request,'pages/progress.html',title='Recorded execution progress',nav='jobs',job=job,progress=data)
+
+ @app.get('/api/v2/runs/{ident}/progress/stream')
+ @app.get('/api/v2/runs/{ident}/console')
+ async def stream_progress(request: Request,ident: str,after: int=0):
+ value=request.headers.get('Last-Event-ID')
+ if value is not None:
+ if not value.isascii() or not value.isdigit() or len(value)>16:raise WebError('invalid_cursor','Invalid progress cursor.')
+ after=int(value)
+ # Validate cursor and object authorization before streaming headers.
+ initial=await run_in_threadpool(journal.snapshot,uid(request),ident,after=after)
+ async def stream():
+ cursor=after;last_heartbeat=0.;last_snapshot=None;first=initial
+ yield 'retry: 1500\n\n'
+ try:
+ while not await request.is_disconnected():
+ session=await run_in_threadpool(auth.session,request.cookies.get(settings.cookie_name))
+ if not session or session.get('user_id')!=uid(request) or session.get('must_change_password'):
+ yield 'event: end\ndata: {"text":"Session expired or access was revoked.","clear":true}\n\n';return
+ # Do not hold a DB read transaction while waiting on a browser.
+ data=first if first is not None else await run_in_threadpool(journal.snapshot,uid(request),ident,after=cursor)
+ first=None
+ if data.get('gap_before'):
+ cursor=data['first_cursor']-1
+ yield f'id: {cursor}\nevent: gap\ndata: '+json.dumps({'text':'Earlier progress metadata was omitted by the retention limit.','first_cursor':data['first_cursor']})+'\n\n'
+ metadata={k:v for k,v in data.items() if k not in ('events','has_older','has_more','next_cursor')}
+ fingerprint=(data['cursor'],data['job_status'],data.get('capture_state'),data.get('dropped_events'),data.get('capture_interrupted'))
+ if fingerprint!=last_snapshot:
+ yield 'event: snapshot\ndata: '+json.dumps(metadata,ensure_ascii=False,separators=(',',':'))+'\n\n'
+ last_snapshot=fingerprint
+ for item in data['events']:
+ cursor=item['cursor']
+ # Legacy console consumers can still render `text`; new clients
+ # also receive the correlated public metadata. Neither is input.
+ payload={'text':item['text'],'record':item}
+ yield f'id: {cursor}\nevent: line\ndata: '+json.dumps(payload,ensure_ascii=False,separators=(',',':'))+'\n\n'
+ if data['terminal'] and cursor>=data['cursor']:
+ text=('Execution ended. Recorded metadata and retained reports remain with this job.' if data['available']
+ else 'No detailed progress was captured for this historical or pre-execution job.')
+ yield 'event: end\ndata: '+json.dumps({'text':text,'job_status':data['job_status'],'cursor':data['cursor']})+'\n\n';return
+ if data.get('has_more'):continue
+ now=time.monotonic()
+ if now-last_heartbeat>=10:
+ yield ': heartbeat (transport only, not task activity)\n\n';last_heartbeat=now
+ await asyncio.sleep(.5)
+ except WebError:
+ yield 'event: end\ndata: {"text":"Job evidence is unavailable or access was revoked.","clear":true}\n\n'
+ return StreamingResponse(stream(),media_type='text/event-stream',headers={
+ 'Cache-Control':'no-store','X-Accel-Buffering':'no','Connection':'keep-alive'})
+
+ @app.get('/api/v2/runs/{ident}/reports')
+ async def report_index(request: Request,ident: str):
+ return await run_in_threadpool(reports.index,uid(request),ident)
+
+ @app.get('/_partials/jobs/{ident}/reports')
+ async def report_panel(request: Request,ident: str):
+ return render(request,'partials/reports.html',reports=await run_in_threadpool(reports.index,uid(request),ident))
+
+ @app.get('/jobs/{ident}/reports')
+ async def report_page(request: Request,ident: str,host: str|None=None,field: str='',page: int=1):
+ item=await run_in_threadpool(reports.slot,uid(request),ident,host)
+ index=await run_in_threadpool(reports.index,uid(request),ident)
+ job=await run_in_threadpool(flow.job,uid(request),ident)
+ return render(request,'pages/report.html',title=item['title'],nav='jobs',job=job,item=item,reports=index,
+ view=presentation(item,field=field,page=page),json_url='/api/v2/runs/'+ident+'/report?'+urlencode({'host':item['slot']}))
+
+ @app.get('/api/v2/runs/{ident}/report')
+ async def report_json(request: Request,ident: str,host: str|None=None):
+ item=await run_in_threadpool(reports.slot,uid(request),ident,host)
+ return JSONResponse({k:item[k] for k in ('schema_id','scope','slot','status','error','retention','data',
+ 'recorded_at','check_mode','complete')},headers={'Cache-Control':'no-store'})
diff --git a/scripts/addons/webgui/src/aim_webgui/explorer.py b/scripts/addons/webgui/src/aim_webgui/explorer.py
new file mode 100644
index 0000000..1074667
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/explorer.py
@@ -0,0 +1,130 @@
+"""Inventory presentation using only the documented Core read operations.
+
+The map is a deterministic focused hierarchy, not a network-topology or health
+map. Path tuples are identities; repeated host membership does not duplicate
+machine counts. No inventory is persisted and no execution endpoint is called.
+"""
+from __future__ import annotations
+import json
+import time
+from urllib.parse import urlencode
+
+from aim_webgui.adapters.core_v1 import CoreAdapter
+from aim_webgui.activity import explorer_url, host_url, text
+from aim_webgui.errors import WebError
+
+
+class Explorer:
+ def __init__(self, settings):
+ self.core = CoreAdapter(settings)
+
+ def snapshot(self, customer: str) -> dict:
+ text(customer)
+ hierarchy = self.core.inventory_hierarchy(customer)
+ # Reuse the same request-local client/capabilities; list_hosts is public.
+ metadata = self.core.client.request('list_hosts', customer=customer)
+ hosts = {x['name']: {'name': x['name'], 'address': x.get('address'),
+ 'platforms': list(x.get('platforms', [])), 'memberships': [],
+ 'url': host_url(customer, x['name'])} for x in metadata}
+ groups = {}; occurrences = 0
+ def build(node, depth):
+ nonlocal occurrences
+ if depth > 64 or len(groups) >= 10000:
+ raise WebError('core_protocol', 'Inventory hierarchy exceeds the documented bounds.', 502)
+ path = tuple(node['path'])
+ if path in groups: raise WebError('core_protocol', 'Core returned duplicate group paths.', 502)
+ group = {'name': node['name'], 'path': list(path), 'direct': sorted(set(node['hosts']), key=str.casefold), 'children': []}
+ groups[path] = group
+ group['children'] = [build(x, depth+1) for x in node['children']]
+ all_hosts = set(group['direct'])
+ for child in group['children']: all_hosts.update(child['members'])
+ group['members'] = all_hosts
+ group['url'] = explorer_url(customer, branch=json.dumps(list(path), separators=(',', ':')))
+ for host in group['direct']:
+ if host not in hosts: raise WebError('inventory_changed', 'Inventory changed during discovery. Refresh to fetch a consistent view.', 409)
+ hosts[host]['memberships'].append({'path': list(path), 'label': ' / '.join(path), 'url': group['url']})
+ occurrences += 1
+ return group
+ roots = [build(x, 1) for x in hierarchy['groups']]
+ direct = sorted(set(hierarchy['hosts']), key=str.casefold)
+ for h in direct:
+ if h not in hosts: raise WebError('inventory_changed', 'Inventory changed during discovery. Refresh to fetch a consistent view.', 409)
+ hosts[h]['memberships'].append({'path': [], 'label': 'Customer root', 'url': explorer_url(customer)})
+ members = set(direct)
+ for root in roots: members.update(root['members'])
+ if set(hosts) != members:
+ raise WebError('inventory_changed', 'Host metadata and hierarchy changed during discovery. Refresh to try again.', 409)
+ root = {'name': customer, 'path': [], 'direct': direct, 'children': roots, 'members': members, 'url': explorer_url(customer)}
+ return dict(customer=customer, root=root, groups=groups, hosts=hosts, host_count=len(members),
+ group_count=len(groups), memberships=occurrences+len(direct), fetched_at=int(time.time()))
+
+ def page(self, customer, *, branch='', q='', view='auto', gpage=1, hpage=1):
+ text(q, 200); text(branch, 16000)
+ if view not in {'auto', 'map', 'outline'} or not 1 <= gpage <= 100000 or not 1 <= hpage <= 100000:
+ raise WebError('invalid_filter', 'Choose an available inventory view and page.')
+ try:
+ path = json.loads(branch) if branch else []
+ if not isinstance(path, list) or len(path)>64 or any(not isinstance(p,str) for p in path): raise ValueError()
+ except (ValueError, TypeError):
+ raise WebError('invalid_group', 'Choose a group from the current inventory.') from None
+ snap = self.snapshot(customer)
+ selected = snap['groups'].get(tuple(path)) if path else snap['root']
+ if selected is None: raise WebError('group_not_found', 'This group is no longer in the current inventory.', 404)
+ def url(**updates):
+ values = dict(branch=branch, q=q, view=view, gpage=gpage, hpage=hpage)
+ values.update(updates)
+ return explorer_url(customer, **{k: v for k,v in values.items() if v != ''})
+ children = sorted(selected['children'],key=lambda n:n['name'].casefold())
+ direct = selected['direct']
+ search = []
+ if q:
+ needle=q.casefold()
+ for group in snap['groups'].values():
+ if needle in ' / '.join(group['path']).casefold():
+ search.append({'kind':'Group','name':' / '.join(group['path']),'url':group['url'], 'detail':str(len(group['members']))+' distinct hosts'})
+ for host in snap['hosts'].values():
+ if needle in (host['name']+' '+(host['address'] or '')).casefold():
+ search.append({'kind':'Host','name':host['name'],'url':host['url'], 'detail':', '.join(host['platforms'])})
+ displayed_groups = children[(gpage-1)*12:gpage*12]
+ displayed_hosts = [snap['hosts'][h] for h in direct[(hpage-1)*24:hpage*24]]
+ # SVG presentation: one focus node and bounded group lanes, each with direct-host links.
+ nodes=[];edges=[];width=1040
+ nodes.append(dict(kind='focus', x=20,y=24,w=1000,h=70,label=selected['name'],
+ detail=f"{len(selected['members'])} distinct hosts / {len(selected['children'])} subgroups",
+ url=selected['url']))
+ y=132
+ for g in displayed_groups:
+ nodes.append(dict(kind='group',x=28,y=y,w=290,h=72,label=g['name'],
+ detail=f"{len(g['direct'])} direct / {len(g['members'])} total",url=g['url']))
+ edges.append((170,94,170,y))
+ preview=g['direct'][:3]
+ for i,h in enumerate(preview):
+ nodes.append(dict(kind='host',x=354+i*226,y=y+5,w=212,h=62,label=h,
+ detail=', '.join(snap['hosts'][h]['platforms']) or 'Host',url=host_url(customer,h)))
+ edges.append((318,y+36,354+i*226,y+36))
+ if not preview:
+ nodes.append(dict(kind='note',x=354,y=y+5,w=658,h=62,label='Open group to explore its subgroups' if g['children'] else 'Empty group',
+ detail='Membership only; no live health checks',url=g['url']))
+ y+=102
+ if displayed_hosts:
+ y+=22
+ for i,h in enumerate(displayed_hosts):
+ row,col=divmod(i,3);x=28+col*336;yy=y+row*94
+ nodes.append(dict(kind='host',x=x,y=yy,w=318,h=68,label=h['name'],detail=', '.join(h['platforms']) or 'Direct member',url=h['url']))
+ edges.append((170,94,x+159,yy))
+ y+=((len(displayed_hosts)+2)//3)*94
+ crumbs=[{'name':customer,'url':explorer_url(customer)}]
+ for i,p in enumerate(path):
+ crumbs.append({'name':p,'url':explorer_url(customer,branch=json.dumps(path[:i+1],separators=(',',':')))})
+ return dict(customer=customer, snap=snap, selected=selected, crumbs=crumbs, q=q, view=view,
+ groups=displayed_groups, direct_hosts=displayed_hosts, graph_nodes=nodes, graph_edges=edges,
+ graph_width=width, graph_height=max(y+20,310), branch=branch,
+ group_pages=max(1,(len(children)+11)//12), host_pages=max(1,(len(direct)+23)//24),
+ gpage=gpage,hpage=hpage, map_url=url(view='map'),outline_url=url(view='outline'),
+ groups_prev=url(gpage=gpage-1) if gpage>1 else None,
+ groups_next=url(gpage=gpage+1) if gpage*121 else None,
+ hosts_next=url(hpage=hpage+1) if hpage*241 else None)
diff --git a/scripts/addons/webgui/src/aim_webgui/journal.py b/scripts/addons/webgui/src/aim_webgui/journal.py
new file mode 100644
index 0000000..3f8b884
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/journal.py
@@ -0,0 +1,235 @@
+"""Bounded durable PUBLIC progress metadata, owned only by retained WebGUI jobs.
+
+A bounded nonblocking queue isolates Core's event sink from DB/viewer latency.
+No rendered console, final report payload, secret frame or arbitrary event keys
+are stored. Readers use a committed per-job cursor, never a live-process socket.
+"""
+from __future__ import annotations
+from collections import deque
+from datetime import datetime
+import json
+import queue
+import re
+import sqlite3
+import threading
+import time
+from aim_webgui.core.protocol import safe_event
+from aim_webgui.db.store import Store
+from aim_webgui.errors import WebError
+from aim_webgui.workflows import actor, TERMINAL
+
+KINDS=frozenset({'stage','play_started','play_skipped','play_stopped','task_started',
+ 'host_result','task_retry','task_async_poll','host_recap','stats','result'})
+QUEUE_EVENTS=2048
+BATCH_EVENTS=128
+FLUSH_SECONDS=.25
+MAX_EVENT_BYTES=16*1024
+MAX_CHECKPOINT_TASKS=64
+
+
+def dumps(value):
+ return json.dumps(value,ensure_ascii=False,allow_nan=False,separators=(',',':'))
+
+
+def authorize(db,user_id,job_id):
+ user=actor(db,user_id)
+ row=db.execute('SELECT id,owner_id,status,plan,created_at,finished_at,reason FROM jobs WHERE id=?',(job_id,)).fetchone()
+ if not row or row['owner_id']!=user_id and user['role']!='admin':
+ raise WebError('job_not_found','Job not found for this account.',404)
+ return row
+
+
+def project(value,targets):
+ event=safe_event(value)
+ if event is None or event['kind']not in KINDS:return None
+ run=event.get('run_id');stamp=event.get('timestamp')
+ if not isinstance(run,str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,100}',run):
+ raise ValueError('Invalid public run identity.')
+ if not isinstance(stamp,str) or len(stamp)>48:raise ValueError('Invalid event time.')
+ date=datetime.fromisoformat(stamp.replace('Z','+00:00'))
+ if date.tzinfo is None:raise ValueError('Event time requires a timezone.')
+ # The projected event may contain a withheld/null host. Never replace it by
+ # a nearby visible host/task. A public host must belong to the reviewed scope.
+ if event.get('host') is not None and event['host'] not in targets:
+ raise ValueError('Event host is outside the reviewed scope.')
+ body=dumps(event)
+ if len(body.encode())>MAX_EVENT_BYTES:raise ValueError('Progress event limit.')
+ return event,body
+
+
+def checkpoint_update(checkpoint,event,received):
+ checkpoint['last_timestamp']=event['timestamp'];checkpoint['last_received_at']=received
+ kind=event['kind'];checkpoint['last_kind']=kind
+ if kind=='stage':checkpoint['stage']=event['stage']
+ if kind=='play_started':checkpoint['last_play']={k:event[k] for k in ('play_id','label','label_redacted')}
+ if kind=='task_started':
+ checkpoint['observed_task_starts']=checkpoint.get('observed_task_starts',0)+1
+ tasks=checkpoint.setdefault('tasks',{})
+ tasks[event['task_id']]={k:event[k] for k in ('task_id','play_id','label','label_redacted','handler')}
+ tasks[event['task_id']]['timestamp']=event['timestamp']
+ while len(tasks)>MAX_CHECKPOINT_TASKS:del tasks[next(iter(tasks))]
+ checkpoint['last_task']=tasks[event['task_id']]
+ if kind in ('host_result','task_retry','task_async_poll') and event.get('host'):
+ hosts=checkpoint.setdefault('hosts',{})
+ hosts[event['host']]={k:event[k] for k in ('host','task_id','play_id','timestamp')}
+ hosts[event['host']]['observation']=event.get('status',kind)
+ hosts[event['host']]['error_code']=(event.get('error')or{}).get('code')
+ while len(hosts)>500:del hosts[next(iter(hosts))]
+ if kind=='stats':checkpoint['counts']=event['counts']
+ # Final event is observed, NOT authoritative until the response is persisted.
+ if kind=='result':checkpoint['result_event_observed']=True
+ return checkpoint
+
+
+class Journal:
+ def __init__(self,settings):
+ self.settings=settings;self.store=Store(settings.database)
+
+ def begin(self,job_id):
+ with self.store.transaction() as db:
+ db.execute('INSERT INTO job_progress_state(job_id,updated_at) VALUES(?,?) ON CONFLICT(job_id) DO NOTHING',(job_id,time.time()))
+
+ def append(self,job_id,batch,*,dropped=0,closed=False,timeout=.25):
+ """Atomic metadata/checkpoint/cursor commit; idempotent public sequences."""
+ with self.store.transaction(timeout=timeout) as db:
+ row=db.execute('SELECT * FROM job_progress_state WHERE job_id=?',(job_id,)).fetchone()
+ if row is None:return # Deletion/absence cannot recreate a shadow journal.
+ state=dict(row);cp=json.loads(state['checkpoint']);run=state['run_id']
+ lost=max(state['dropped_events'],dropped)
+ for event,body,received in batch:
+ if run is not None and event['run_id']!=run:
+ lost+=1;continue
+ run=event['run_id']
+ if event['sequence']<=state['last_sequence']:continue
+ state['last_sequence']=event['sequence'];state['cursor']+=1
+ size=len(body.encode('utf-8'))
+ db.execute('INSERT INTO job_progress_events VALUES(?,?,?,?,?,?,?)',
+ (job_id,state['cursor'],run,event['sequence'],received,body,size))
+ state['retained_events']+=1;state['retained_bytes']+=size
+ checkpoint_update(cp,event,received)
+ remove=[]
+ if state['retained_events']>self.settings.journal_max_events or state['retained_bytes']>self.settings.journal_max_bytes:
+ for old in db.execute('SELECT cursor,size_bytes FROM job_progress_events WHERE job_id=? ORDER BY cursor',(job_id,)):
+ if state['retained_events']<=self.settings.journal_max_events and state['retained_bytes']<=self.settings.journal_max_bytes:break
+ state['retained_events']-=1;state['retained_bytes']-=old['size_bytes'];state['omitted_events']+=1;remove.append(old['cursor'])
+ if remove:db.execute('DELETE FROM job_progress_events WHERE job_id=? AND cursor<=?',(job_id,remove[-1]))
+ now=time.time();capture='closed' if closed else 'recording'
+ if lost:capture='closed_with_gaps' if closed else 'recording_with_gaps'
+ db.execute('''UPDATE job_progress_state SET run_id=?,cursor=?,last_sequence=?,retained_events=?,retained_bytes=?,
+ omitted_events=?,dropped_events=?,checkpoint=?,updated_at=?,capture_state=?,closed_at=? WHERE job_id=?''',
+ (run,state['cursor'],state['last_sequence'],state['retained_events'],state['retained_bytes'],
+ state['omitted_events'],lost,dumps(cp),now,capture,now if closed else None,job_id))
+
+ def snapshot(self,user_id,job_id,*,after=None,before=None,limit=200):
+ if type(limit)is not int or not 1<=limit<=500:raise WebError('invalid_cursor','Progress page size must be 1..500.')
+ for number in (after,before):
+ if number is not None and (type(number)is not int or not 0<=number<=2**53-1):raise WebError('invalid_cursor','Invalid progress cursor.')
+ if after is not None and before is not None:raise WebError('invalid_cursor','Choose before or after, not both.')
+ with self.store.read() as db:
+ db.execute('BEGIN')
+ job=authorize(db,user_id,job_id)
+ row=db.execute('SELECT * FROM job_progress_state WHERE job_id=?',(job_id,)).fetchone()
+ terminal=job['status']in TERMINAL
+ if row is None:
+ return {'job':job_id,'job_status':job['status'],'terminal':terminal,'available':False,'cursor':0,'next_cursor':0,
+ 'first_cursor':0,'events':[],'has_older':False,'has_more':False,'checkpoint':{},'capture_state':'unavailable',
+ 'omitted_events':0,'dropped_events':0,'capture_interrupted':False,
+ 'message':'Detailed history was not captured for this job.' if terminal else 'Waiting for the execution worker to begin metadata capture.'}
+ state=dict(row);watermark=state['cursor']
+ if after is not None and after>watermark:raise WebError('invalid_cursor','Progress cursor is ahead of this job.',409)
+ first=db.execute('SELECT MIN(cursor) FROM job_progress_events WHERE job_id=?',(job_id,)).fetchone()[0]or 0
+ params=[job_id]
+ if after is not None:
+ clause=' AND cursor>?';params.append(after);order='ASC'
+ elif before is not None:
+ clause=' AND cursor';params.append(before);order='DESC'
+ else:clause='';order='DESC'
+ rows=list(db.execute('SELECT cursor,received_at,payload FROM job_progress_events WHERE job_id=?'+clause+' ORDER BY cursor '+order+' LIMIT ?',(*params,limit)))
+ if order=='DESC':rows.reverse()
+ events=[{'cursor':r['cursor'],'received_at':r['received_at'],'event':json.loads(r['payload'])} for r in rows]
+ for item in events:item['text']=format_event(item['event'])
+ next_cursor=events[-1]['cursor'] if events else after if after is not None else watermark
+ cp=json.loads(state['checkpoint'])
+ cp['tasks']=list(cp.get('tasks',{}).values())
+ cp['hosts']=list(cp.get('hosts',{}).values())
+ return {'job':job_id,'job_status':job['status'],'terminal':terminal,'available':True,
+ 'cursor':watermark,'next_cursor':next_cursor,'first_cursor':first,'events':events,'checkpoint':cp,
+ 'has_more':next_cursorfirst),
+ 'capture_state':state['capture_state'],'omitted_events':state['omitted_events'],
+ 'dropped_events':state['dropped_events'],'capture_interrupted':terminal and state['closed_at']is None,
+ 'retained_events':state['retained_events'],'retained_bytes':state['retained_bytes'],
+ 'updated_at':state['updated_at'],'gap_before':bool(after is not None and first and after=end:break
+ if self.flush_deadline is not None and time.monotonic()>=self.flush_deadline:
+ self.lost(len(batch))
+ while True:
+ try:self.items.get_nowait();self.lost(1)
+ except queue.Empty:break
+ break
+ if not batch:continue
+ try:self.journal.append(self.job_id,batch,dropped=self.loss_count())
+ except (sqlite3.Error,OSError,ValueError):
+ self.lost(len(batch))
+ # No exception bodies or payloads are logged. Next commit records
+ # loss. A persistent storage failure leaves capture unconfirmed.
+ try:self.journal.append(self.job_id,[],dropped=self.loss_count(),closed=True)
+ except (sqlite3.Error,OSError,ValueError):pass
+
+ def close(self):
+ if self.closed:return not self.thread.is_alive()
+ self.closed=True;self.flush_deadline=time.monotonic()+2.5
+ self.stopping.set();self.thread.join(timeout=3)
+ # A killed writer may lose its uncommitted batch. A missing durable
+ # closed_at is rendered as interrupted capture, not complete history.
+ return not self.thread.is_alive()
+
+
+def format_event(e):
+ kind=e['kind'];host=e.get('host')or'';task=e.get('task_id','')
+ if kind=='stage':return '[AIM] Core stage: '+e['stage']
+ if kind=='play_started':return 'PLAY ['+e['label']+']'
+ if kind=='play_skipped':return 'skipping: no hosts matched'
+ if kind=='play_stopped':return 'stopped: no hosts remaining'
+ if kind=='task_started':return ('RUNNING HANDLER' if e['handler'] else 'TASK')+' ['+e['label']+'] ('+task+')'
+ if kind=='host_result':
+ status=e['status'];line=(f'fatal: [{host}]: '+status.upper()+'!' if status in ('failed','unreachable') else f'{status}: [{host}]')
+ line+=' ('+task+')'+(' (ignored)' if e['ignored'] else '')
+ if e.get('error'):line+='\n '+e['error']['message']
+ return line
+ if kind in ('task_retry','task_async_poll'):
+ return ('retrying' if kind=='task_retry' else 'async poll')+f': [{host}] ({task})'+(f" attempt={e['attempt']}" if e['attempt']is not None else '')
+ if kind=='host_recap':return f'RECAP [{host}] '+' '.join(f'{k}={v}' for k,v in e['counts'].items())
+ if kind=='stats':return 'PLAY RECAP\n[AIM] Counters: '+', '.join(f'{k}={v}' for k,v in sorted(e['counts'].items()))
+ if kind=='result':return '[AIM] Final result event observed. Authoritative response is recorded separately.'
+ return ''
diff --git a/scripts/addons/webgui/src/aim_webgui/names.py b/scripts/addons/webgui/src/aim_webgui/names.py
new file mode 100644
index 0000000..d6673ed
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/names.py
@@ -0,0 +1,10 @@
+"""Plan titles: presentation only, never record identifiers or upsert keys."""
+import unicodedata
+import uuid
+from datetime import datetime, timezone
+
+def name_key(name):
+ return unicodedata.normalize('NFKC',name.strip()).casefold()
+
+def suggested_name(playbook):
+ return f"{playbook[:48]} - {datetime.now(timezone.utc):%Y%m%d-%H%M%S} - {uuid.uuid4().hex[:10]}"
diff --git a/scripts/addons/webgui/src/aim_webgui/patch_view.py b/scripts/addons/webgui/src/aim_webgui/patch_view.py
new file mode 100644
index 0000000..394fbbe
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/patch_view.py
@@ -0,0 +1,90 @@
+"""Read-only presentation of Core's patch_summary_v1, including historic shapes.
+
+This module never plans updates, interprets HRESULTs, changes a job verdict, or
+starts a continuation. Values come only from a retained, schema-validated report.
+"""
+from __future__ import annotations
+from typing import Any
+
+BLOCK_NOTICES = {
+ 'preexisting_reboot_required': (
+ 'Pending reboot observed before patching',
+ 'Core reports a pre-existing reboot prerequisite. In this preflight path, new '
+ 'patch work did not start. Review the reboot policy before preparing another run.'),
+ 'cycle_limit_reached': (
+ 'Post-reboot continuation limit reached',
+ 'Core stopped at its bounded continuation limit. Review the recorded results '
+ 'before deciding whether to prepare another run.'),
+ 'install_not_allowed': (
+ 'Windows Update did not permit installation',
+ 'This classification can indicate an active installer or a mandatory reboot. '
+ 'It does not by itself prove a pending reboot; use the separate preflight observation.'),
+}
+
+
+def patch_view(item: dict[str, Any]) -> dict[str, Any] | None:
+ """Explain independent patch/reboot/report facts without manufacturing state."""
+ payload = item.get('data')
+ if (item.get('schema_id') != 'patch_summary_v1'
+ or item.get('status') != 'available' or not isinstance(payload, dict)):
+ return None
+ windows = payload.get('platform') == 'windows'
+ check = item.get('check_mode') is True
+ notices: list[dict[str, str]] = []
+ blocked = payload.get('blocked_reason')
+ if blocked:
+ title, text = BLOCK_NOTICES.get(blocked, (
+ 'A reported condition stopped this patch run',
+ 'Inspect the bounded failure records and original Core verdict. '
+ 'No failed update or job will be submitted again automatically.'))
+ notices.append({'tone': 'warning', 'title': title, 'text': text, 'code': blocked})
+ reasons=payload.get('reboot_reasons_before')
+ if windows and isinstance(reasons,list) and reasons:
+ notices.append({'tone':'secondary','title':'Native reboot prerequisite details','code':'',
+ 'text':'Core recorded one or more reboot sources from ansible.windows.win_reboot_info before patching. '
+ 'These are bounded observations from that run, not a live reboot-state probe.'})
+ if payload.get('reboot_deferred') is True:
+ notices.append({'tone': 'warning', 'title': 'Reboot deferred', 'code': '',
+ 'text': 'The report records a remaining reboot requirement with automatic reboot '
+ 'disabled. This is separate from whether the updates in this run succeeded. '
+ 'Review an explicit reboot decision before continuing patching.'})
+ if windows and payload.get('continuation_required') is True:
+ notices.append({'tone': 'info', 'title': 'Another patch run needs review', 'code': '',
+ 'text': 'Core reports that further patching needs a fresh operator decision. '
+ 'A successful wave can still need a later run; this does not change '
+ 'the Core verdict or create another job.'})
+ if windows and 'remaining_updates_known' not in payload:
+ notices.append({'tone': 'secondary', 'title': 'Historical patch-report format', 'code': '',
+ 'text': 'This recorded report does not supply the newer remaining-update knowledge '
+ 'flag. Missing wave or reboot fields are not assumed false or reconstructed.'})
+ if check:
+ notices.insert(0, {'tone': 'info', 'title': 'Check mode: no installation claim', 'code': '',
+ 'text': 'These are recorded check-mode observations or predictions. They are not '
+ 'evidence that updates were installed or that a reboot took place.'})
+ known = payload.get('remaining_updates_known')
+ if windows and known is False:
+ pending = {'title': 'Remaining updates not established', 'suppressed': True,
+ 'note': 'Core did not establish an authoritative final remaining-update list for '
+ 'this wave. No pending count or next-wave list is inferred, even if an '
+ 'earlier queue appears in the retained JSON.',
+ 'empty_text': 'Unknown after this wave. A new reviewed run owns the next discovery.'}
+ elif windows and known is True:
+ pending = {'title': 'Pending updates - final read-only discovery', 'suppressed': False,
+ 'note': 'The pending list is authoritative for the final read-only search recorded '
+ 'by Core, within the selected scope at that time. Discovery did not extend '
+ 'the approved install queue; it is not a live compliance check.',
+ 'empty_text': 'No pending updates reported by that final discovery in the selected scope.'}
+ elif windows:
+ pending = {'title': 'Pending updates - recorded legacy observation', 'suppressed': False,
+ 'note': 'No explicit remaining-update knowledge flag exists in this older report. '
+ 'Do not treat an empty list as proof that no later updates are applicable.',
+ 'empty_text': 'No entries reported; later update applicability is not established.'}
+ else:
+ pending = {'title': 'Pending updates', 'suppressed': False,
+ 'note': 'Use the report\'s evidence and mode. Linux package snapshots describe net '
+ 'observed version-set changes, not every intermediate transaction.',
+ 'empty_text': 'No entries reported.'}
+ return {'windows': windows, 'check_mode': check, 'notices': notices, 'pending': pending,
+ 'failure_note': 'Reason, safe message and unsigned/hexadecimal native code are '
+ 'supplied by Core. WebGUI does not parse fatal output or infer a reboot '
+ 'requirement from an HRESULT. Missing older fields remain unknown.'}
diff --git a/scripts/addons/webgui/src/aim_webgui/read_views.py b/scripts/addons/webgui/src/aim_webgui/read_views.py
new file mode 100644
index 0000000..e3ef066
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/read_views.py
@@ -0,0 +1,68 @@
+"""Add-on-owned GET-only discovery/history endpoints. No run preparation or writes."""
+from __future__ import annotations
+from dataclasses import asdict
+from urllib.parse import urlencode
+from fastapi import Request
+from starlette.concurrency import run_in_threadpool
+from aim_webgui.activity import Activity, HistoryFilter, LABELS, host_url
+from aim_webgui.explorer import Explorer
+from aim_webgui.errors import WebError
+from aim_webgui.reports import Reports
+
+
+def install_read_views(app, settings, render):
+ activity = Activity(settings)
+
+ async def report(request, customer='', host='', playbook='', mode='apply', days='30', outcome='', q='', page=1):
+ return await run_in_threadpool(activity.report, request.state.session['user_id'],
+ HistoryFilter(customer,host,playbook,mode,days,outcome,q,page))
+
+ @app.get('/inventory/{customer}/explore')
+ async def explore(request: Request, customer: str, branch: str='', q: str='', view: str='auto', gpage: int=1, hpage: int=1):
+ model = await run_in_threadpool(Explorer(settings).page,customer,branch=branch,q=q,view=view,gpage=gpage,hpage=hpage)
+ return render(request,'pages/explorer.html',title='Inventory explorer',nav='customers',**model)
+
+ @app.get('/api/v2/inventory/{customer}/explore')
+ async def explore_api(request: Request, customer: str, branch: str='', gpage: int=1, hpage: int=1):
+ model = await run_in_threadpool(Explorer(settings).page,customer,branch=branch,gpage=gpage,hpage=hpage)
+ return {'source':'core_inventory_hierarchy_v1','customer':customer,'branch':model['selected']['path'],
+ 'retrieved_at':model['snap']['fetched_at'],'host_count':model['snap']['host_count'],
+ 'group_count':model['snap']['group_count'],'nodes':model['graph_nodes'],'edges':model['graph_edges'],
+ 'width':model['graph_width'],'height':model['graph_height'],
+ 'group_pages':model['group_pages'],'host_pages':model['host_pages'],
+ 'gpage':gpage,'hpage':hpage}
+
+ @app.get('/inventory/{customer}/activity')
+ async def host_activity(request: Request, customer: str, host: str, playbook: str='', mode: str='apply', days: str='30', outcome: str='', page: int=1):
+ data = await report(request,customer,host,playbook,mode,days,outcome,page=page)
+ current = None; inventory_error = False; fetched_at = None
+ try:
+ snapshot = await run_in_threadpool(Explorer(settings).snapshot,customer)
+ current=snapshot['hosts'].get(host); fetched_at=snapshot['fetched_at']
+ except WebError:
+ # A historical page remains useful during a Core outage. Do not fake an empty inventory.
+ inventory_error=True
+ prev = host_url(customer,host,**{k:v for k,v in data['filters'].query(page=page-1).items() if k not in {'host','customer'}}) if page>1 else None
+ nxt = host_url(customer,host,**{k:v for k,v in data['filters'].query(page=page+1).items() if k not in {'host','customer'}}) if page1 else None
+ next_url='/insights?'+urlencode(data['filters'].query(page=page+1)) if pagesettings.reports_max_bytes:
+ retention='not_retained_limit';size=0
+ else:text=raw.decode();retained+=size
+ db.execute('INSERT INTO job_operation_reports VALUES(?,?,?,?,?,?,?,?)',
+ (job_id,slot,entry['schema'],entry['status'],entry['error'],retention,text,size))
+ item={'schema':entry['schema'],'status':entry['status'],'error':entry['error'],'retention':retention,'size_bytes':size}
+ if slot:summary['hosts'][slot]=item
+ else:summary['global']=item
+ # Preserve the public accounting, not megabytes of report bodies, in job polls.
+ small={**result,'operation_result':summary}
+ return small
+
+
+class Reports:
+ def __init__(self,settings):self.settings=settings;self.store=Store(settings.database)
+
+ def index(self,user_id,job_id):
+ with self.store.read() as db:
+ db.execute('BEGIN');job=authorize(db,user_id,job_id)
+ plan=json.loads(job['plan']);row=db.execute('SELECT * FROM job_operation_results WHERE job_id=?',(job_id,)).fetchone()
+ slots=[dict(r) for r in db.execute('SELECT slot,schema_id,status,error,retention,size_bytes FROM job_operation_reports WHERE job_id=? ORDER BY slot',(job_id,))]
+ declared=plan.get('result_contract')
+ model={'job':job_id,'job_status':job['status'],'customer':plan.get('customer'),'playbook':plan.get('playbook'),
+ 'mode':'check' if plan.get('core_request',{}).get('check') else 'apply','recorded':row is not None,
+ 'declared':declared is not None,'schema':declared.get('schema') if declared else None,
+ 'slots':slots,'recorded_at':row['stored_at'] if row else None}
+ if row:
+ model.update(schema=row['schema_id'],declared=row['schema_id']is not None,complete=bool(row['complete']),scope=row['scope'],
+ required=bool(row['required']),retention_policy=row['retention_policy'])
+ model['title']=TITLES.get(model['schema'],'Operation report')
+ for item in slots:item['url']=url(job_id,item['slot'])
+ return model
+
+ def slot(self,user_id,job_id,host=None):
+ with self.store.read() as db:
+ db.execute('BEGIN');job=authorize(db,user_id,job_id)
+ meta=db.execute('SELECT * FROM job_operation_results WHERE job_id=?',(job_id,)).fetchone()
+ if not meta or not meta['schema_id']:raise WebError('report_unavailable','No retained report is available for this job.',404)
+ if host is None:
+ row=db.execute("SELECT * FROM job_operation_reports WHERE job_id=? ORDER BY CASE status WHEN 'available' THEN 0 ELSE 1 END,slot LIMIT 1",(job_id,)).fetchone()
+ else:row=db.execute('SELECT * FROM job_operation_reports WHERE job_id=? AND slot=?',(job_id,host)).fetchone()
+ if not row:raise WebError('report_unavailable','This report slot is unavailable.',404)
+ item=dict(row);item['data']=json.loads(item['data']) if item['data']is not None else None
+ item['contract']=json.loads(meta['contract']);item['recorded_at']=meta['stored_at'];item['scope']=meta['scope']
+ item['complete']=bool(meta['complete']);item['check_mode']=bool(meta['check_mode']);item['required']=bool(meta['required'])
+ item['title']=TITLES.get(item['schema_id'],'Operation report');item['note']=NOTES.get(item['schema_id'],'Validated structured operation data; render as historical observation, not live state.')
+ item['url']=url(job_id,item['slot'])
+ return item
+
+ def host_links(self,user_id,customer,host,*,limit=20):
+ # Policy before SELECT/aggregation. Exact customer/logical hostname identity.
+ from aim_webgui.workflows import actor
+ with self.store.read() as db:
+ who=actor(db,user_id)
+ rows=db.execute('''SELECT r.job_id,r.schema_id,r.status,r.retention,m.stored_at,m.check_mode,
+ json_extract(j.plan,'$.playbook') AS playbook
+ FROM job_operation_reports r JOIN job_operation_results m ON m.job_id=r.job_id JOIN jobs j ON j.id=r.job_id
+ WHERE (?='admin' OR j.owner_id=?) AND json_extract(j.plan,'$.customer')=? AND r.slot=?
+ ORDER BY m.stored_at DESC,r.job_id DESC LIMIT ?''',(who['role'],user_id,customer,host,limit))
+ result=[dict(r) for r in rows]
+ for item in result:item['url']=url(item['job_id'],host);item['title']=TITLES.get(item['schema_id'],item['schema_id'])
+ return result
+
+
+def presentation(item,*,field='',page=1):
+ """Schema-keyed summaries plus generic bounded sections for every supported shape.
+
+ A page has at most 50 rows per collection; JSON is fetched separately on demand.
+ This never follows returned file paths/URLs or interprets strings as markup.
+ """
+ if type(page)is not int or not 1<=page<=400:raise WebError('invalid_page','Invalid report page.')
+ content=item.get('data')
+ if not isinstance(content,dict):
+ return {'facts':[],'sections':[],'scalar':content,'is_scalar':True,'generic':True}
+ if field and field not in content:raise WebError('invalid_page','Unknown report section.')
+ patch=patch_view(item)
+ facts=[];sections=[]
+ for key,value in content.items():
+ if isinstance(value,list):
+ current=page if field==key else 1;start=(current-1)*50;selected=value[start:start+50]
+ headers=[]
+ if selected and all(isinstance(x,dict) for x in selected):
+ headers=list(dict.fromkeys(k for x in selected for k in x))[:40]
+ section={'key':key,'title':label(key),'rows':selected,'headers':headers,'total':len(value),
+ 'page':current,'pages':max(1,(len(value)+49)//50),'start':start,'kind':'array'}
+ elif isinstance(value,dict):
+ current=page if field==key else 1;start=(current-1)*50;keys=list(value)[start:start+50]
+ section={'key':key,'title':label(key),'rows':[(k,value[k]) for k in keys], 'headers':[], 'total':len(value),
+ 'page':current,'pages':max(1,(len(value)+49)//50),'start':start,'kind':'object'}
+ else:
+ facts.append({'key':key,'title':label(key),'value':value});continue
+ if patch and key=='pending':
+ section.update(patch['pending'])
+ if section['suppressed']:
+ section.update(rows=[],headers=[],total=None,page=1,pages=1)
+ current=1
+ elif patch and key=='failed_updates':
+ section['note']=patch['failure_note']
+ params={'host':item['slot'],'field':key}
+ section['previous']=('/jobs/'+item['job_id']+'/reports?'+urlencode({**params,'page':current-1})+'#report-'+key) if current>1 else None
+ section['next']=('/jobs/'+item['job_id']+'/reports?'+urlencode({**params,'page':current+1})+'#report-'+key) if current 80 or not key.isalnum():
+ raise WebError('submission_key', 'Reload the plan before submitting.')
+ ident = await call('queue_review',uid(request),one(values,'review_id'),when,idempotency_key=key)
+ return redirect(request, '/jobs/' + ident)
+
+ @app.get('/jobs/{ident}')
+ async def job(request: Request, ident: str):
+ from aim_webgui.evidence_views import job_evidence
+ evidence=await run_in_threadpool(job_evidence,settings,uid(request),ident)
+ return render(request, 'pages/job.html', title='Job detail', nav='jobs', job=await call('job', uid(request), ident), **evidence)
+
+ @app.get('/_partials/jobs/{ident}')
+ async def job_fragment(request: Request, ident: str):
+ return render(request, 'partials/job.html', job=await call('job', uid(request), ident))
+
+ @app.get('/_partials/jobs/{ident}/credentials')
+ async def credential_fragment(request: Request, ident: str):
+ from aim_webgui.credentials.service import eligible
+ job = await run_in_threadpool(eligible, flow, uid(request), ident)
+ return render(request, 'partials/credential_panel.html', **form_context(job))
+
+ @app.get('/api/v2/runs/{ident}/credential-status')
+ async def credential_state(request: Request, ident: str):
+ return await run_in_threadpool(credential_status, flow, uid(request), ident)
+
+ @app.get('/jobs/{ident}/credentials')
+ async def credential_page(request: Request, ident: str):
+ from aim_webgui.credentials.service import eligible
+ job = await run_in_threadpool(eligible, flow, uid(request), ident)
+ return render(request, 'pages/credentials.html', title='One-run credentials', nav='jobs', **form_context(job))
+
+ async def credentials_submit(request, ident, value):
+ from aim_webgui.credentials.service import submit
+ # Trust only server-authenticated identity and session hash, never body fields.
+ await run_in_threadpool(auth.throttle, [('credential:' + str(uid(request)), 5)], window=60)
+ return await run_in_threadpool(submit, settings, uid(request), request.state.session['token_hash'], ident, value)
+
+ @app.post('/jobs/{ident}/credentials')
+ async def credential_form(request: Request, ident: str):
+ values = await form(request)
+ if set(values) - {'_csrf','vault_password','connection_password','ssh_key_passphrase'}:
+ raise WebError('invalid_credentials', 'Unexpected credential fields.')
+ await credentials_submit(request, ident, {k: one(values, k) for k in ('vault_password','connection_password','ssh_key_passphrase')})
+ return redirect(request, '/jobs/' + ident)
+
+ @app.post('/api/v2/runs/{ident}/credentials')
+ @app.post('/api/v2/jobs/{ident}/credentials')
+ async def credential_api(request: Request, ident: str):
+ return JSONResponse(await credentials_submit(request, ident, await api_payload(request)), status_code=202)
+
+ @app.post('/jobs/bulk-delete')
+ async def bulk_delete_jobs(request: Request):
+ values = await form(request)
+ await call('delete_jobs', uid(request), values.get('job_ids', []))
+ return redirect(request, '/jobs')
+
+ @app.post('/jobs/{ident}/retry')
+ async def retry_job(request: Request, ident: str):
+ new_ident = await call('retry_job', uid(request), ident)
+ return redirect(request, '/jobs/' + new_ident)
+
+ @app.post('/jobs/{ident}/{action}')
+ async def job_action(request: Request, ident: str, action: str):
+ await call('job_action', uid(request), ident, action)
+ return redirect(request, '/jobs/' + ident)
+
+ @app.get('/api/v2/runs')
+ async def api_jobs(request: Request):
+ return {'items': await call('jobs', uid(request))}
+
+ @app.get('/api/v2/runs/{ident}')
+ async def api_job(request: Request, ident: str):
+ return await call('job', uid(request), ident)
+
+ @app.post('/api/v2/runs')
+ async def api_queue(request: Request):
+ if not settings.execution_enabled:
+ raise WebError('execution_disabled', 'Execution is disabled; no run was started.', 501)
+ payload = await api_payload(request)
+ if set(payload)-{'review_id','scheduled_at','confirm'} or payload.get('confirm') is not True:
+ raise WebError('review_required','Send review_id, optional scheduled_at and confirm=true. Saving a plan is not required.')
+ key=request.headers.get('Idempotency-Key','')
+ ident=await call('queue_review',uid(request),payload.get('review_id'),payload.get('scheduled_at'),idempotency_key=key)
+ return JSONResponse({'id': ident}, status_code=202)
+
+ @app.post('/api/v2/runs/{ident}/retry')
+ async def api_retry(request: Request, ident: str):
+ new_ident = await call('retry_job', uid(request), ident)
+ return JSONResponse({'id': new_ident, 'retried_from': ident}, status_code=202)
+
+ @app.post('/api/v2/runs/{ident}/{action}')
+ async def api_action(request: Request, ident: str, action: str):
+ await call('job_action', uid(request), ident, action)
+ return {'id': ident, 'action': action}
diff --git a/scripts/addons/webgui/src/aim_webgui/security.py b/scripts/addons/webgui/src/aim_webgui/security.py
new file mode 100644
index 0000000..ed56947
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/security.py
@@ -0,0 +1,61 @@
+"""Small ASGI body limiter and response policy, independent of application routes."""
+from urllib.parse import urlsplit
+from starlette.responses import JSONResponse
+
+CSP = ("default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
+ "connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; "
+ "frame-ancestors 'none'; form-action 'self'")
+
+
+class RequestPolicy:
+ def __init__(self, app, secure: bool = False):
+ self.app, self.secure = app, secure
+
+ async def __call__(self, scope, receive, send):
+ if scope['type'] != 'http':
+ return await self.app(scope, receive, send)
+ async def secured_send(message):
+ if message['type'] == 'http.response.start':
+ headers = list(message.get('headers', []))
+ headers.extend([(b'content-security-policy', CSP.encode()),
+ (b'x-content-type-options', b'nosniff'),
+ (b'referrer-policy', b'same-origin'),
+ (b'x-frame-options', b'DENY'),
+ (b'cache-control', b'no-store'),
+ (b'permissions-policy', b'camera=(), microphone=(), geolocation=()')])
+ if self.secure:
+ headers.append((b'strict-transport-security', b'max-age=31536000'))
+ message['headers'] = headers
+ await send(message)
+ upstream = receive
+ if scope['method'] not in {'GET', 'HEAD', 'OPTIONS'}:
+ limit = 8192 if scope.get('path', '').endswith('/credentials') else 65536
+ body = bytearray()
+ while True:
+ message = await receive()
+ if message['type'] == 'http.disconnect':
+ return
+ body.extend(message.get('body', b''))
+ if len(body) > limit:
+ return await JSONResponse({'error': {'code': 'body_too_large', 'message': 'Request exceeds the body limit.'}},
+ status_code=413)(scope, receive, secured_send)
+ if not message.get('more_body', False):
+ break
+ delivered = False
+ async def replay():
+ nonlocal delivered
+ if not delivered:
+ delivered = True
+ return {'type': 'http.request', 'body': bytes(body), 'more_body': False}
+ return await upstream()
+ receive = replay
+ return await self.app(scope, receive, secured_send)
+
+
+def same_origin(actual: str, expected: str) -> bool:
+ try:
+ a, b = urlsplit(actual), urlsplit(expected)
+ return (a.scheme, a.hostname, a.port or (443 if a.scheme == 'https' else 80)) == (
+ b.scheme, b.hostname, b.port or (443 if b.scheme == 'https' else 80))
+ except ValueError:
+ return False
diff --git a/scripts/addons/webgui/src/aim_webgui/static/css/aim.css b/scripts/addons/webgui/src/aim_webgui/static/css/aim.css
new file mode 100644
index 0000000..8631bcf
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/static/css/aim.css
@@ -0,0 +1,200 @@
+a {text-decoration: none;} a:hover {text-decoration: underline;}
+:focus-visible {outline: 3px solid var(--aim-accent-ink); outline-offset: 3px;}
+h1 {font-size: clamp(1.65rem, 2.4vw, 2.1rem);font-weight: 700;letter-spacing: -.035em;}
+h2,h3 {letter-spacing: -.025em;}
+.app-shell {display: flex; min-height: 100vh;}
+.sidebar {width: 256px; flex: 0 0 256px; background: var(--aim-sidebar); color: var(--aim-ink); padding: 2rem 1rem; display: flex; flex-direction: column;}
+.brand {display: flex; gap: .75rem; align-items: center; color: white; padding: 0 .75rem 2.4rem; font-size: 1.8rem;font-weight: 750;line-height: 1.1;letter-spacing: -.03em;}
+.brand:hover {color: white;text-decoration: none;}
+.brand-mark {display: grid; place-items: center; background: var(--aim-accent); color: var(--aim-sidebar); width: 44px; height: 44px; border-radius: .7rem; font-size: 1.8rem;}
+.brand-subtitle {display: block; font-size: .5rem; letter-spacing: .13em; margin-top: .5rem; color: var(--aim-sidebar-muted);}
+.sidebar-caption {padding: 1rem .8rem .65rem; color: var(--aim-sidebar-muted); font-size: .65rem; font-weight: 600; letter-spacing: .12em;}
+.sidebar .nav-link {color: var(--aim-sidebar-link); border-radius: .45rem; padding: .8rem; font-size: .85rem; margin-bottom: .3rem; display: flex; align-items: center; gap: .85rem;}
+.sidebar .nav-link:hover {background: var(--aim-sidebar-hover);text-decoration: none;color: white;}
+.sidebar .nav-link.active {background: var(--aim-sidebar-active);color: var(--aim-sidebar-active-ink);box-shadow: inset 3px 0 var(--aim-accent);}
+.nav-symbol {font-size: .65rem; opacity: .75; font-family: monospace;}
+.sidebar-bottom {margin-top: auto; padding: 3rem .8rem 0; font-size: .7rem; color: var(--aim-sidebar-muted);line-height: 1.8;}
+.sidebar-bottom p {margin: .65rem 0 0;}
+.status-dot {display: inline-block; width: 6px; height: 6px; background: var(--aim-accent); border-radius: 50%; margin-right: .5rem;vertical-align: middle;}
+.workspace {flex: 1;min-width: 0;display: flex;flex-direction: column;}
+.topbar {min-height: 77px;display: flex; align-items: center; justify-content: space-between;gap: 1rem; padding: 1rem 2.5rem; border-bottom: 1px solid var(--aim-border);background: var(--aim-surface);}
+.environment-label {font-size: .65rem;font-weight: 650;letter-spacing: .12em;color: var(--aim-muted);}
+.account-links {display: flex; align-items: center;gap: .7rem;font-size: .8rem;}
+.account-links a {color: var(--aim-ink);font-weight: 600;}.account-links form {margin: 0;}
+.theme-control {display:inline-flex;align-items:center;gap:.2rem;padding:.2rem;border:1px solid var(--aim-border);border-radius:.55rem;background:var(--aim-surface-raised);}
+.theme-option {display:grid;place-items:center;width:2rem;height:2rem;padding:0;border:0;border-radius:.38rem;background:transparent;color:var(--aim-muted);font:inherit;line-height:1;cursor:pointer;}
+.theme-option span {font-size:1.08rem;transform:translateY(-.02rem);}
+.theme-option:hover {background:var(--aim-neutral-soft);color:var(--aim-ink);}
+.theme-option.active,.theme-option[aria-pressed="true"] {background:var(--aim-accent-soft);color:var(--aim-accent-ink);box-shadow:inset 0 0 0 1px var(--aim-pill-border);}
+.theme-option:focus-visible {outline-offset:2px;}
+.role-label {border: 1px solid var(--aim-border);border-radius: .3rem;padding: .1rem .4rem;color: var(--aim-muted);font-size: .65rem;}
+.main-content {padding: 2.5rem;max-width: 1540px;width: 100%;margin: 0 auto;flex: 1;}
+.page-heading {margin-bottom: 1.7rem;}.page-heading p:last-child {max-width: 850px;margin-top: .65rem;font-size: .92rem;line-height: 1.65;}
+.eyebrow {font-size: .64rem;font-weight: 700;letter-spacing: .13em;color: var(--aim-muted);margin-bottom: .65rem;display: block;}
+.mode-banner {border: 1px solid var(--aim-banner-border);background: linear-gradient(115deg,var(--aim-banner-start),var(--aim-banner-end));border-radius: var(--aim-radius);padding: 1.7rem;display: flex;align-items: center;justify-content: space-between;gap: 1.5rem;margin-bottom: 1.5rem;}
+.mode-banner h2 {font-size: 1.45rem;margin: .8rem 0 .5rem;}.mode-banner p {font-size: .85rem;color: var(--aim-muted);margin: 0;max-width: 590px;}.mode-banner .btn {flex-shrink: 0;}
+.pill {color: var(--aim-accent-ink);font-size: .6rem;letter-spacing: .09em;font-weight: 700;background: var(--aim-accent-soft);border: 1px solid var(--aim-pill-border);padding: .3rem .5rem;border-radius: .3rem;display: inline-block;}
+.metric-card {padding: 1.5rem;}.metric-label {font-size: .8rem;color: var(--aim-muted);font-weight: 500;}.metric-value {font-size: 2.4rem;letter-spacing: -.05em;line-height: 1.7;}
+.badge-success {background: var(--aim-success-soft);color: var(--aim-success);}.badge-warning {background: var(--aim-warning-soft);color: var(--aim-warning);}.badge-neutral {background: var(--aim-neutral-soft);color: var(--aim-neutral);}
+.entity-link,.entity-name {font-weight: 600;}.entity-link {color: var(--aim-ink);}
+.system-facts {display: grid;grid-template-columns: minmax(110px,1fr) minmax(0,1.4fr);font-size: .8rem;margin: 1.4rem 0;}.system-facts dt,.system-facts dd {padding: .65rem 0;border-bottom: 1px solid var(--aim-border);margin: 0;overflow-wrap: anywhere;}.system-facts dt {font-weight: 500;color: var(--aim-muted);}
+.note {font-size: .8rem;color: var(--aim-note-ink);background: var(--aim-note-bg);padding: 1rem;border-radius: .4rem;line-height: 1.6;border-left: 3px solid var(--aim-accent);}
+.empty-state {text-align: center;color: var(--aim-muted);padding: 3rem 1.5rem!important;}.empty-state p {font-size: .85rem;margin: .5rem 0 0;}
+.toolbar {display: flex;align-items: center;justify-content: space-between;gap: 1rem;margin-bottom: 1.5rem;}.search-form {display: flex;gap: .5rem;flex: 1;max-width: 570px;}.section-heading {display: flex;align-items: start;justify-content: space-between;gap: 1rem;}
+.form-card {max-width: 580px;}.login-layout {display: grid;grid-template-columns: minmax(270px,430px) minmax(250px,420px);gap: 4rem;align-items: center;}.login-note {color: var(--aim-muted);font-size: .9rem;line-height: 1.8;}.login-note h2 {color: var(--aim-ink);line-height: 1.4;}.section-index {display: block;font-size: .65rem;letter-spacing: .12em;color: var(--aim-accent-ink);font-weight: 650;margin-bottom: 1rem;}.reset-form {max-width: 360px;}
+.review-result {border-top: 3px solid var(--aim-accent);}.code-panel {background: var(--aim-code-panel);border: 1px solid var(--aim-border);padding: 1rem;border-radius: .4rem;margin-top: 1rem;white-space: pre-wrap;overflow-wrap: anywhere;}
+.selection-card-header {align-items:flex-start;}
+.selection-limit {font-size:.7rem;color:var(--aim-muted);white-space:nowrap;}
+.selection-column {width:64px!important;min-width:64px;text-align:center!important;padding-left:1rem!important;padding-right:1rem!important;}
+.selection-column .form-check-input {display:block;margin:0 auto;vertical-align:middle;}
+.group-selector {display:grid;grid-template-columns:minmax(130px,auto) 1fr;align-items:center;gap:1rem 1.25rem;padding:.9rem 1rem;border-bottom:1px solid var(--aim-border);background:var(--aim-surface-raised);}
+.group-selector-heading {display:flex;flex-direction:column;gap:.15rem;}
+.group-selector-label {font-size:.65rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--aim-muted);}
+.group-selector-help {font-size:.68rem;color:var(--aim-muted);white-space:nowrap;}
+.group-selector-items {display:flex;flex-wrap:wrap;gap:.42rem;}
+.group-choice {display:inline-flex;align-items:center;gap:.45rem;min-height:2rem;padding:.32rem .58rem;border:1px solid var(--aim-border);border-radius:.5rem;background:var(--aim-surface);color:var(--aim-ink);font-size:.78rem;cursor:pointer;}
+.group-choice:hover {border-color:var(--aim-accent);}
+.group-choice .form-check-input {margin:0;}
+.group-count {display:inline-grid;place-items:center;min-width:1.45rem;height:1.45rem;padding:0 .35rem;border-radius:999px;background:var(--aim-neutral-soft);color:var(--aim-neutral);font-size:.68rem;font-variant-numeric:tabular-nums;}
+html[data-theme="dark"] body {background:var(--aim-bg);color:var(--aim-ink);}
+html[data-theme="dark"] code {color:#ffc18d;}
+html[data-theme="dark"] .text-secondary {color:var(--aim-muted)!important;}
+.workspace-footer {font-size: .65rem;color: var(--aim-muted);padding: 1.25rem 2.5rem;border-top: 1px solid var(--aim-border);display: flex;gap: 1rem;justify-content: space-between;}.workspace-footer span {white-space: nowrap;}
+.table-responsive {overscroll-behavior-x:contain;-webkit-overflow-scrolling:touch;}
+.skip-link {position: absolute;left: 1rem;top: -5rem;background: var(--aim-surface);color: var(--aim-ink);padding: .75rem;z-index: 100;}.skip-link:focus {top: 1rem;}.htmx-indicator {opacity: 0;}.htmx-request .htmx-indicator {opacity: 1;}
+@media(max-width:1100px){
+ .sidebar{width:218px;flex-basis:218px;}
+ .main-content{padding:1.75rem;}
+ .mode-banner{align-items:flex-start;flex-direction:column;}
+ .login-layout{gap:2rem;grid-template-columns:1fr;}
+ .login-note{max-width:500px;}
+ .topbar{padding:1rem 1.75rem;}
+}
+
+/* Compact layout primitives. The mobile header/menu lives in experience.css. */
+@media(max-width:760px){
+ .workspace{min-width:0;}
+ .main-content{padding:1.25rem max(1rem,env(safe-area-inset-right)) 1.5rem max(1rem,env(safe-area-inset-left));}
+ .page-heading{margin-bottom:1.3rem;}
+ .toolbar,.section-heading{flex-direction:column;align-items:stretch;}
+ .group-selector{grid-template-columns:1fr;align-items:start;gap:.65rem;padding:.85rem 1rem;}
+ .group-selector-help{white-space:normal;}
+ .group-selector-items{gap:.5rem;}
+ .group-choice{min-height:2.65rem;padding:.45rem .65rem;}
+ .selection-limit{white-space:normal;}
+ .selection-column{width:56px!important;min-width:56px;padding-left:.75rem!important;padding-right:.75rem!important;}
+ .mode-banner{padding:1.25rem;}
+ .workspace-footer{padding:1rem max(1rem,env(safe-area-inset-right)) calc(1rem + env(safe-area-inset-bottom)) max(1rem,env(safe-area-inset-left));flex-direction:column;gap:.35rem;}
+ .workspace-footer span{white-space:normal;}
+ .table-responsive>.table{min-width:620px;}
+ .host-selection-card .table{min-width:720px;}
+ .table td,.table th{padding:.8rem 1rem;}
+}
+
+@media(max-width:480px){
+ h1{font-size:1.55rem;}
+ .brand-subtitle{display:none;}
+ .topbar{padding-top:.55rem;padding-bottom:.55rem;}
+ .account-links>a{max-width:8.5rem;}
+ .main-content{padding-top:1rem;}
+ .card-body.p-4{padding:1.1rem!important;}
+ .card-header{padding:1rem;}
+ .search-form{max-width:none;flex-direction:column;}
+ .search-form .btn,.toolbar>.btn,.mode-banner>.btn{width:100%;}
+ .group-selector-items{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));width:100%;}
+ .group-choice{width:100%;justify-content:flex-start;}
+ .group-count{margin-left:auto;}
+ .system-facts{grid-template-columns:1fr;}
+ .system-facts dt{padding-bottom:.15rem;border-bottom:0;}
+ .system-facts dd{padding-top:0;}
+}
+
+@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;transition:none!important;}}
+
+/* 1.0 shared responsive primitives. Count is a separate fixed grid track. */
+.group-choice { display:grid; grid-template-columns:1.05rem minmax(0,1fr) auto; align-items:center; gap:.5rem; min-width:0; max-width:100%; }
+.group-choice .group-name { min-width:0; overflow-wrap:anywhere; line-height:1.3; }
+.group-choice .group-count { margin-left:0; white-space:nowrap; justify-self:end; flex:none; }
+
+.filter-toolbar { display:flex; flex-wrap:wrap; gap:.65rem; padding:1rem; align-items:end; }
+.filter-toolbar>label { flex:1 1 10rem; min-width:0; }
+.filter-toolbar .btn { min-height:2.65rem; }
+.selection-help { padding:0 1rem .75rem; margin:0; }
+.workflow-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem; }
+.workflow-grid>* { min-width:0; }
+.workflow-actions { display:flex; flex-wrap:wrap; gap:.6rem; align-items:center; }
+.workflow-actions form { margin:0; }
+.form-text, .text-break, .system-facts dd { overflow-wrap:anywhere; }
+.event-list { list-style:none; padding:0; }
+.event-list li { border-bottom:1px solid var(--aim-border); padding:.75rem 0; }
+.job-status { background:var(--aim-accent-soft); color:var(--aim-accent-ink); padding:.3rem .65rem; border-radius:1rem; }
+@media(max-width:760px) {
+ .workflow-grid { grid-template-columns:1fr; }
+ .workflow-actions .btn { min-height:44px; }
+}
+@media(max-width:480px) {
+ .group-choice { grid-template-columns:1rem minmax(0,1fr) auto; gap:.35rem; padding:.45rem .5rem; }
+ .group-count { font-variant-numeric:tabular-nums; font-size:.72rem; padding:.15rem .3rem; }
+ .filter-toolbar>label, .filter-toolbar>.btn { flex-basis:100%; }
+}
+
+/* Shared, accessible credential controls. No page-local colour literals. */
+.credential-card { max-width: 52rem; }
+.credential-switch { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .25rem; padding: .25rem; border: 1px solid var(--aim-border); border-radius: .75rem; }
+.credential-switch label { position: relative; min-width: 0; margin: 0; }
+.credential-switch label span, .credential-choice { display: flex; align-items: center; justify-content: center; min-height: 44px; padding: .6rem .75rem; border-radius: .5rem; text-align: center; overflow-wrap: anywhere; }
+.credential-switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }
+.credential-switch input:checked + span, .credential-choice.is-active { background: var(--aim-accent); color: var(--aim-on-accent); font-weight: 700; }
+.credential-switch input:focus-visible + span { outline: 3px solid var(--aim-accent); outline-offset: 2px; }
+.credential-mode { min-width: 0; }
+@media (max-width: 480px) { [data-secret-form] .btn { width: 100%; margin-top: .5rem; } }
+
+/* Ephemeral job console: dark terminal surface in both themes, accent-aware controls. */
+.live-console-card { overflow: hidden; }
+.console-toolbar { display:flex; justify-content:space-between; align-items:center; gap:1rem; padding:1rem 1.25rem; border-bottom:1px solid var(--bs-border-color); }
+.console-controls { display:flex; align-items:center; justify-content:flex-end; gap:1rem; flex-wrap:wrap; }
+.console-state { font-size:.78rem; letter-spacing:.06em; text-transform:uppercase; color:var(--bs-secondary-color); }
+.live-console { margin:0; height:clamp(18rem,55dvh,40rem); max-height:calc(100dvh - 12rem); overflow-y:auto; overscroll-behavior:contain; scrollbar-gutter:stable; padding:1rem 1.25rem; border:0; border-radius:0; background:var(--aim-console-bg); color:var(--aim-console-ink); font-size:.82rem; line-height:1.55; white-space:pre-wrap; overflow-wrap:anywhere; }
+.console-line-notice { color:inherit; }
+.console-footnote { padding:.7rem 1.25rem; font-size:.78rem; color:var(--bs-secondary-color); border-top:1px solid var(--bs-border-color); }
+@media (max-width: 575.98px) {
+ .console-toolbar { align-items:flex-start; flex-direction:column; }
+ .console-controls { justify-content:flex-start; width:100%; }
+ .live-console { height:48dvh; min-height:14rem; max-height:calc(100dvh - 13rem); font-size:.76rem; padding:.85rem; }
+}
+
+
+/* Operational overview hierarchy and semantic state chips. */
+.nav-link-featured { font-weight:700; }
+.status-tag { display:inline-flex; align-items:center; gap:.42rem; border:1px solid var(--aim-border); border-radius:999px; padding:.28rem .58rem; font-size:.74rem; font-weight:750; line-height:1.15; text-transform:capitalize; white-space:nowrap; background:var(--bs-tertiary-bg); }
+.status-dot-mini { width:.48rem; height:.48rem; border-radius:50%; background:currentColor; flex:0 0 auto; }
+.status-successful { color:var(--bs-success-text-emphasis); background:var(--bs-success-bg-subtle); border-color:var(--bs-success-border-subtle); }
+.status-failed,.status-timed_out { color:var(--bs-danger-text-emphasis); background:var(--bs-danger-bg-subtle); border-color:var(--bs-danger-border-subtle); }
+.status-running { color:var(--bs-primary-text-emphasis); background:var(--bs-primary-bg-subtle); border-color:var(--bs-primary-border-subtle); }
+.status-running .status-dot-mini { animation:aim-status-pulse 1.5s ease-in-out infinite; }
+.status-pending,.status-queued,.status-blocked { color:var(--bs-warning-text-emphasis); background:var(--bs-warning-bg-subtle); border-color:var(--bs-warning-border-subtle); }
+.status-canceled,.status-interrupted { color:var(--bs-secondary-color); background:var(--bs-secondary-bg); }
+@keyframes aim-status-pulse { 50% { opacity:.35; transform:scale(.82); } }
+@media (prefers-reduced-motion:reduce) { .status-running .status-dot-mini { animation:none; } }
+.mode-tag { display:inline-block; border:1px solid var(--aim-border); border-radius:.4rem; padding:.18rem .4rem; font-size:.72rem; font-weight:700; text-transform:uppercase; }
+.overview-toolbar { display:flex; justify-content:space-between; align-items:end; gap:1rem; margin-bottom:.75rem; }
+.overview-toolbar>div { display:grid; gap:.12rem; }
+.operation-link { font-weight:750; }
+.row-meta,.host-preview { margin-top:.2rem; color:var(--bs-secondary-color); font-size:.76rem; line-height:1.35; }
+.host-preview { max-width:30rem; overflow-wrap:anywhere; }
+.host-preview span { white-space:nowrap; font-weight:650; }
+.operational-table tbody tr:hover { background:color-mix(in srgb,var(--aim-accent) 4%,transparent); }
+.audit-stack { display:grid; gap:1rem; }
+.audit-filter { padding:1rem 1.15rem; margin:0; border:1px solid var(--aim-border); border-radius:.65rem; background:var(--bs-body-bg); }
+.audit-filter>label { flex:1 1 24rem; }
+.filter-actions { display:flex; gap:.65rem; align-items:end; }
+@media (max-width:760px) { .overview-toolbar { align-items:stretch; flex-direction:column; } .overview-toolbar .btn { align-self:flex-start; } .filter-actions { width:100%; } .filter-actions .btn { flex:1; } }
+
+.status-partially_succeeded { color:var(--bs-warning-text-emphasis); background:var(--bs-warning-bg-subtle); border-color:var(--bs-warning-border-subtle); }
+.target-outcomes { border:1px solid var(--aim-border); border-radius:.7rem; padding:1rem; background:var(--aim-surface-raised); }
+.outcome-counters { display:flex; flex-wrap:wrap; gap:.55rem 1rem; margin-top:.8rem; font-size:.8rem; color:var(--aim-muted); }
+.target-outcome-table td:last-child { min-width:18rem; }
+.target-status-successful { color:var(--bs-success-text-emphasis); background:var(--bs-success-bg-subtle); border-color:var(--bs-success-border-subtle); }
+.target-status-failed,.target-status-unreachable { color:var(--bs-danger-text-emphasis); background:var(--bs-danger-bg-subtle); border-color:var(--bs-danger-border-subtle); }
+.target-status-not_started,.target-status-indeterminate { color:var(--bs-secondary-color); background:var(--bs-secondary-bg); }
+.outcome-summary { margin-top:.35rem; }
diff --git a/scripts/addons/webgui/src/aim_webgui/static/css/bootstrap-overrides.css b/scripts/addons/webgui/src/aim_webgui/static/css/bootstrap-overrides.css
new file mode 100644
index 0000000..ed9d7fe
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/static/css/bootstrap-overrides.css
@@ -0,0 +1,65 @@
+/* Component variables are intentional: --bs-primary alone does not recolor buttons. */
+:root {
+ --bs-primary: var(--aim-accent);
+ --bs-primary-rgb: var(--aim-accent-rgb);
+ --bs-body-color: var(--aim-ink);
+ --bs-body-bg: var(--aim-bg);
+ --bs-secondary-color: var(--aim-muted);
+ --bs-border-color: var(--aim-border);
+ --bs-link-color: var(--aim-accent-ink);
+ --bs-link-hover-color: var(--aim-link-hover);
+ --bs-border-radius: .5rem;
+ --bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", sans-serif;
+}
+.btn-primary {
+ --bs-btn-color: #1e1d1a;
+ --bs-btn-bg: var(--aim-accent);
+ --bs-btn-border-color: var(--aim-accent);
+ --bs-btn-hover-color: #1e1d1a;
+ --bs-btn-hover-bg: var(--aim-accent-hover);
+ --bs-btn-hover-border-color: var(--aim-accent-hover);
+ --bs-btn-active-color: #1e1d1a;
+ --bs-btn-active-bg: var(--aim-accent-hover);
+ --bs-btn-active-border-color: var(--aim-accent-hover);
+ --bs-btn-disabled-bg: var(--aim-accent);
+ --bs-btn-disabled-color: #1e1d1a;
+ --bs-btn-disabled-border-color: var(--aim-accent);
+ --bs-btn-focus-shadow-rgb: var(--aim-accent-rgb);
+}
+.btn-outline-primary {
+ --bs-btn-color: var(--aim-accent-ink);
+ --bs-btn-border-color: var(--aim-accent);
+ --bs-btn-hover-color: #1e1d1a;
+ --bs-btn-hover-bg: var(--aim-accent);
+ --bs-btn-hover-border-color: var(--aim-accent);
+ --bs-btn-active-bg: var(--aim-accent-hover);
+ --bs-btn-active-color: #1e1d1a;
+}
+.btn-outline-secondary {
+ --bs-btn-color: var(--aim-muted);
+ --bs-btn-border-color: var(--aim-border);
+ --bs-btn-hover-color: var(--aim-ink);
+ --bs-btn-hover-bg: var(--aim-surface-raised);
+ --bs-btn-hover-border-color: var(--aim-border);
+ --bs-btn-active-color: var(--aim-ink);
+ --bs-btn-active-bg: var(--aim-surface-raised);
+ --bs-btn-active-border-color: var(--aim-border);
+}
+.btn {font-weight: 600; padding: .65rem 1rem;}
+.btn-sm {padding: .35rem .6rem; font-size: .8rem;}
+.card {--bs-card-border-color: var(--aim-border); --bs-card-border-radius: var(--aim-radius); --bs-card-bg: var(--aim-surface); color: var(--aim-ink); box-shadow: var(--aim-shadow); overflow: hidden;}
+.card-header {background: var(--aim-surface); border-color: var(--aim-border); padding: 1.25rem 1.5rem; display: flex; gap: 1rem; align-items: center; justify-content: space-between;}
+.form-control,.form-select {background-color: var(--aim-surface); color: var(--aim-ink); border-color: var(--aim-border); min-height: 2.65rem;}
+.form-control::placeholder {color: var(--aim-muted); opacity: .8;}
+.form-control:focus,.form-select:focus {background-color: var(--aim-surface); color: var(--aim-ink); border-color: var(--aim-accent); box-shadow: 0 0 0 .2rem rgb(var(--aim-accent-rgb) / 22%);}
+.form-select option {background: var(--aim-surface); color: var(--aim-ink);}
+.form-check-input {background-color: var(--aim-surface); border-color: var(--aim-border);}
+.form-check-input:checked,.form-check-input:indeterminate {background-color: var(--aim-accent-ink);border-color: var(--aim-accent-ink);}
+.form-label {font-size: .875rem; font-weight: 600;}
+.form-text {color: var(--aim-muted);}
+.table {--bs-table-bg: var(--aim-surface); --bs-table-color: var(--aim-ink); --bs-table-border-color: var(--aim-border); font-size: .9rem;}
+.table th {background: var(--aim-table-head); color: var(--aim-muted); font-size: .7rem; letter-spacing: .06em; text-transform: uppercase; padding: .85rem 1.5rem;}
+.table td {padding: 1rem 1.5rem;}
+.table tr:last-child td {border-bottom: 0;}
+.badge {font-weight: 550; border-radius: .35rem; padding: .45rem .6rem; line-height: 1.2; white-space: normal;}
+.alert-danger {--bs-alert-bg: color-mix(in srgb, #dc3545 12%, var(--aim-surface)); --bs-alert-color: var(--aim-ink); --bs-alert-border-color: color-mix(in srgb, #dc3545 32%, var(--aim-border));}
diff --git a/scripts/addons/webgui/src/aim_webgui/static/css/credentials.css b/scripts/addons/webgui/src/aim_webgui/static/css/credentials.css
new file mode 100644
index 0000000..aecb901
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/static/css/credentials.css
@@ -0,0 +1,88 @@
+/* One-run dialog and attention surface. Bootstrap 5 native radio/label semantics. */
+.credential-dialog { padding: 0; border: 1px solid var(--aim-border); border-radius: 1.1rem; background: var(--aim-surface); color: var(--aim-ink); width: min(36rem, calc(100vw - 2rem)); max-width: 100%; max-height: calc(100dvh - 2rem); overflow: hidden; box-shadow: var(--aim-shadow); }
+.credential-dialog[open] { display: flex; flex-direction: column; }
+.credential-dialog::backdrop { background: var(--aim-dialog-backdrop); }
+html.credential-modal-open { overflow: hidden; scrollbar-gutter: stable; }
+.credential-dialog-header { flex: 0 0 auto; display: flex; align-items: flex-start; gap: 1rem; padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--aim-border); background: var(--aim-surface-raised); }
+.credential-dialog-header > div { flex: 1; min-width: 0; }
+.credential-dialog-header .eyebrow { margin: 0 0 .3rem; }
+.credential-dialog-header h2 { font-size: 1.45rem; margin: 0; line-height: 1.2; }
+.credential-dialog-header p:last-child { margin: .35rem 0 0; color: var(--aim-muted); font-size: .85rem; }
+.credential-close { width: 2.75rem; min-width: 2.75rem; height: 2.75rem; padding: 0; font-size: 1.5rem; line-height: 1; }
+.credential-dialog-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 1.25rem 1.5rem; }
+.credential-panel { text-align: left; min-width: 0; }
+.credential-panel [hidden] { display: none !important; }
+.credential-scope { display: flex; align-items: center; gap: .75rem; padding: .85rem; background: var(--aim-code-panel); border: 1px solid var(--aim-border); border-radius: .7rem; margin-bottom: 1rem; }
+.credential-scope > div { min-width: 0; }
+.credential-scope strong { display: block; font-size: 1rem; overflow-wrap: anywhere; }
+.credential-scope div > span { display: block; margin-top: .25rem; color: var(--aim-muted); font-size: .85rem; overflow-wrap: anywhere; }
+.credential-scope-symbol { display: grid; place-items: center; width: 2.5rem; min-width: 2.5rem; height: 2.5rem; border-radius: .55rem; background: var(--aim-accent-soft); color: var(--aim-accent-ink); }
+.credential-scope-symbol svg { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
+.credential-reservation { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: .5rem; color: var(--aim-muted); font-size: .83rem; margin-bottom: .85rem; }
+[data-credential-countdown] { font-variant-numeric: tabular-nums; }
+.credential-reservation.is-urgent { color: var(--aim-warning); }
+.credential-intro { color: var(--aim-muted); font-size: .9rem; margin-bottom: 1.2rem; }
+.credential-inputs { padding: 0; border: 0; margin: 0; min-width: 0; }
+.credential-field { margin-bottom: 1.1rem; min-width: 0; }
+.credential-field .form-label { display: flex; align-items: baseline; flex-wrap: wrap; justify-content: space-between; gap: .35rem; width: 100%; font-weight: 600; margin-bottom: .4rem; }
+.field-requirement { color: var(--aim-muted); font-size: .76rem; font-weight: 400; }
+.credential-input-row { display: flex; align-items: stretch; gap: .4rem; }
+.credential-input-row .form-control { min-width: 0; font-size: 1rem; min-height: 2.8rem; }
+.credential-reveal { min-width: 4rem; min-height: 2.8rem; font-size: .85rem; }
+.credential-caps { color: var(--aim-warning); font-size: .8rem; margin: .35rem 0 0; }
+.credential-field .form-text { margin-top: .35rem; line-height: 1.45; }
+.credential-key-source { margin: 0 0 .8rem; border: 0; padding: 0; min-width: 0; }
+.credential-key-source legend { font-size: .94rem; font-weight: 600; margin: 0 0 .5rem; float: none; width: auto; }
+.credential-segments { display: flex; width: 100%; gap: 0; }
+.credential-segments .btn { flex: 1 1 0; min-width: 0; min-height: 2.75rem; display: flex; justify-content: center; align-items: center; white-space: normal; font-size: .85rem; line-height: 1.25; border-color: var(--aim-border); background: var(--aim-surface); color: var(--aim-muted); }
+.credential-segments .btn-check:checked + .btn { background: var(--aim-accent-soft); border-color: var(--aim-accent-ink); color: var(--aim-accent-ink); font-weight: 600; }
+.credential-segments .btn-check:focus-visible + .btn { outline: 2px solid var(--aim-accent-ink); outline-offset: 3px; box-shadow: none; z-index: 2; }
+.credential-source-choice .form-text { margin: .5rem 0 .9rem; }
+.credential-explanation { padding: .8rem 0; margin: .2rem 0 .8rem; border-top: 1px solid var(--aim-border); border-bottom: 1px solid var(--aim-border); }
+.credential-explanation summary { color: var(--aim-muted); font-size: .85rem; cursor: pointer; }
+.credential-explanation p { font-size: .82rem; margin: .65rem 0 0; overflow-wrap: anywhere; }
+.credential-privacy { margin: .9rem 0; color: var(--aim-muted); font-size: .83rem; }
+.credential-privacy span { color: var(--aim-success); }
+.credential-submit-row { display: flex; flex-wrap: wrap; align-items: stretch; gap: .6rem; }
+.credential-submit-row .btn { min-height: 2.8rem; }
+.credential-submit-row .btn-primary { flex: 1; }
+.credential-submit-note { margin: .65rem 0 0; font-size: .78rem; }
+.credential-feedback { border-radius: .6rem; border: 1px solid var(--aim-border); padding: .9rem; margin-bottom: 1rem; background: var(--aim-neutral-soft); color: var(--aim-ink); font-size: .9rem; }
+.credential-feedback[data-tone="error"] { border-color: var(--aim-danger); }
+.credential-feedback[data-tone="accepted"] { color: var(--aim-success); background: var(--aim-success-soft); }
+.credential-fallback { max-width: 42rem; }
+.credential-loading { padding: 1rem; color: var(--aim-muted); }
+.status-waiting_credentials { color: var(--aim-warning); background: var(--aim-warning-soft); border-color: var(--aim-border); }
+.attention-section { margin: 1.2rem 0; padding: 1rem 1.2rem; background: var(--aim-surface); border: 1px solid var(--aim-border); border-radius: var(--aim-radius); }
+.attention-section .section-heading { margin-bottom: .75rem; }
+.attention-count { display: grid; place-items: center; min-width: 2rem; min-height: 2rem; padding: .15rem .45rem; background: var(--aim-warning-soft); color: var(--aim-warning); border-radius: .5rem; font-weight: 700; }
+.attention-items { display: grid; gap: .75rem; }
+.attention-item { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .9rem; border-radius: .65rem; background: var(--aim-code-panel); }
+.attention-item > div { min-width: 0; }
+.attention-item strong { display: block; margin-top: .4rem; overflow-wrap: anywhere; }
+.attention-item p { margin: .15rem 0; color: var(--aim-muted); font-size: .84rem; overflow-wrap: anywhere; }
+.attention-item small { color: var(--aim-muted); }
+.attention-item > a { flex-shrink: 0; min-height: 2.6rem; align-content: center; }
+.attention-empty { margin: 0; color: var(--aim-muted); font-size: .85rem; }
+@media(max-width:760px) {
+ .credential-dialog { width: calc(100vw - 1rem); border-radius: .85rem; max-height: calc(100dvh - 1rem); }
+ .credential-dialog-header { padding: .9rem 1rem; }
+ .credential-dialog-header h2 { font-size: 1.2rem; }
+ .credential-dialog-body { padding: 1rem; }
+ .credential-submit-row { flex-direction: column; }
+ .credential-scope { padding: .65rem; }
+ .attention-section { padding: .9rem; }
+ .attention-item { align-items: stretch; flex-direction: column; gap: .6rem; }
+}
+@media(max-height:430px) {
+ .credential-dialog-header { padding: .55rem 1rem; }
+ .credential-dialog-header .eyebrow, .credential-dialog-header p:last-child { display: none; }
+ .credential-close { min-height: 2.5rem; height: 2.5rem; }
+}
+/* Do not inherit the old full-width mobile secret-form button rule in input groups. */
+.credential-input-row .form-control { flex: 1 1 auto; width: 0; }
+.credential-panel .credential-input-row .credential-reveal { flex: 0 0 4.25rem; width: 4.25rem; margin: 0; }
+.credential-panel .credential-segments .btn { width: auto; margin-top: 0; }
+.credential-panel .credential-submit-row .btn { margin-top: 0; }
+/* Programmatic heading focus announces the modal; keyboard controls retain rings. */
+.credential-dialog-header h2:focus { outline: none; }
diff --git a/scripts/addons/webgui/src/aim_webgui/static/css/evidence.css b/scripts/addons/webgui/src/aim_webgui/static/css/evidence.css
new file mode 100644
index 0000000..b392abb
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/static/css/evidence.css
@@ -0,0 +1,49 @@
+/* Evidence views use the existing AIM tokens; no new theme or navigation language. */
+.job-view-nav {display:flex; flex-wrap:wrap; gap:.4rem; margin:0 0 1.2rem; padding:.35rem; background:var(--aim-surface); border:1px solid var(--aim-border);border-radius:var(--aim-radius);}
+.job-view-nav a {padding:.55rem 1rem; border-radius:.5rem; color:var(--aim-ink);text-decoration:none;}
+.job-view-nav a:hover,.job-view-nav a[aria-current] {background:var(--aim-accent-soft);color:var(--aim-accent-ink);}
+#job-status,#job-progress,#job-targets,#job-reports {scroll-margin-top:5rem;}
+.progress-checkpoint {display:grid;grid-template-columns:1fr 2fr;gap:.55rem 1rem;color:var(--aim-muted);font-size:.875rem;}
+.progress-checkpoint strong {color:var(--aim-ink);}
+.progress-checkpoint span {min-width:0;overflow-wrap:anywhere;}
+.journal-warning {color:var(--aim-warning);background:var(--aim-warning-soft);padding:.8rem;border-block:1px solid var(--aim-border);}
+.journal-output {white-space:pre-wrap;overflow-wrap:anywhere;scrollbar-gutter:stable;}
+.journal-timeline {list-style:none;padding:0;margin:0;}
+.journal-timeline li {padding:.75rem 0;border-bottom:1px solid var(--aim-border);}
+.journal-timeline time {font-size:.8rem;color:var(--aim-muted);}
+.journal-timeline pre {white-space:pre-wrap;overflow-wrap:anywhere;margin:.35rem 0;font-size:.85rem;color:var(--aim-ink);}
+.report-slot-list {display:grid;gap:.6rem;grid-template-columns:repeat(auto-fit,minmax(min(100%,20rem),1fr));}
+.report-slot {display:grid;grid-template-columns:1fr auto;gap:.4rem .7rem;padding:.9rem;border:1px solid var(--aim-border);border-radius:.6rem;color:var(--aim-ink);text-decoration:none;min-width:0;}
+.report-slot .entity-name {overflow-wrap:anywhere;min-width:0;}
+.report-slot:hover {background:var(--aim-accent-soft);}
+.report-slot small {color:var(--aim-muted);}
+.report-open {font-size:.8rem;color:var(--aim-accent-ink);}
+.report-availability {display:inline-flex;align-items:center;font-size:.78rem;border-radius:2rem;padding:.25rem .7rem;background:var(--aim-neutral-soft);color:var(--aim-neutral);width:max-content;max-width:100%;}
+.report-available {color:var(--aim-success);background:var(--aim-success-soft);}
+.report-invalid,.report-missing,.report-indeterminate {color:var(--aim-warning);background:var(--aim-warning-soft);}
+.report-host-select {display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.8rem;}
+.report-facts {display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,14rem),1fr));gap:.75rem;margin:0;}
+.report-facts>div {padding:.85rem;background:var(--aim-code-panel);border-radius:.5rem;min-width:0;}
+.report-facts dt {font-size:.85rem;color:var(--aim-muted);font-weight:500;}
+.report-facts dd {margin:.35rem 0 0;font-weight:600;overflow-wrap:anywhere;}
+.fact-yes {color:var(--aim-success);}.fact-no {color:var(--aim-muted);}
+.report-semantic-note {border-left:3px solid var(--aim-accent);padding:.7rem 1rem;background:var(--aim-note-bg);color:var(--aim-note-ink);}
+.report-table {font-size:.85rem;min-width:32rem;}
+.report-table td {white-space:normal;overflow-wrap:anywhere;max-width:28rem;min-width:8rem;vertical-align:top;}
+.report-table th {min-width:7rem;}
+.report-values {padding:1rem 1.2rem 1rem 2rem;overflow-wrap:anywhere;}
+.report-values li {padding:.2rem;}
+.report-key-values>div {padding:1rem;border-top:1px solid var(--aim-border);}
+.report-key-values pre {white-space:pre-wrap;overflow-wrap:anywhere;margin:.4rem 0 0;max-height:14rem;overflow:auto;}
+.report-json {min-height:8rem;max-height:min(45dvh,32rem);white-space:pre;overflow:auto;overscroll-behavior:contain;margin-top:.8rem;font-size:.85rem;}
+[data-json-status] {margin-left:.7rem;color:var(--aim-muted);font-size:.85rem;}
+@media (max-width:760px) {
+ .job-view-nav {gap:.1rem;}.job-view-nav a {flex:1;text-align:center;padding:.6rem .4rem;font-size:.85rem;}
+ .progress-checkpoint {grid-template-columns:1fr;gap:.35rem;}
+ .report-host-select {grid-template-columns:1fr auto;}.report-host-select label {grid-column:1/-1;}
+ .report-slot {grid-template-columns:minmax(0,1fr) auto;}.report-slot small {grid-column:1/-1;}
+ .report-slot-list {grid-template-columns:1fr;}.report-facts {grid-template-columns:repeat(2,minmax(0,1fr));gap:.45rem;}
+ .report-facts>div {padding:.65rem;}.report-facts dt {font-size:.8rem;}
+ .report-semantic-note {font-size:.875rem;}.report-json {max-height:38dvh;}
+}
+@media (max-width:360px) {.report-facts{grid-template-columns:1fr;}}
diff --git a/scripts/addons/webgui/src/aim_webgui/static/css/experience.css b/scripts/addons/webgui/src/aim_webgui/static/css/experience.css
new file mode 100644
index 0000000..5f88b97
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/static/css/experience.css
@@ -0,0 +1,162 @@
+/* Read-only workspaces. Shared colours live in tokens.css. */
+.mobile-topbar { display:none; }
+.main-content { overflow-wrap:anywhere; }
+.page-heading h1 { overflow-wrap:anywhere; }
+.context-links,.inventory-breadcrumbs { display:flex; flex-wrap:wrap; gap:.6rem 1.15rem; align-items:center; margin-bottom:1.3rem; font-size:.85rem; }
+.inventory-breadcrumbs { gap:.5rem; }
+.inventory-breadcrumbs [aria-current] { color:var(--aim-ink); font-weight:750; }
+.coverage-note { display:block; font-size:.76rem; line-height:1.65; color:var(--aim-muted); }
+.context-links a,.membership-chip { color:var(--aim-accent-ink); }
+.host-identity { display:flex; flex-direction:row; gap:1rem; padding:1.35rem; align-items:flex-start; }
+.host-identity>div:last-child { min-width:0; }
+.entity-symbol { display:grid; place-items:center; width:3rem; height:3rem; flex:0 0 3rem; border-radius:.8rem; background:var(--aim-accent-soft); color:var(--aim-accent-ink); font-size:1.5rem; }
+.small-symbol { width:2.25rem; height:2.25rem; flex-basis:2.25rem; border-radius:.6rem; font-size:1.2rem; }
+.membership-links { display:flex; flex-wrap:wrap; gap:.5rem; }
+.membership-chip { display:inline-flex; border:1px solid var(--aim-border); padding:.3rem .6rem; border-radius:.45rem; font-size:.8rem; background:var(--aim-surface-raised); }
+.history-filters { display:grid; grid-template-columns:repeat(auto-fit,minmax(135px,1fr)); align-items:end; gap:1rem; padding:1.1rem; margin:1rem 0; border:1px solid var(--aim-border); border-radius:var(--aim-radius); background:var(--aim-surface); }
+.history-filters label { display:grid; gap:.45rem; font-size:.76rem; font-weight:650; min-width:0; }
+.history-filters .btn { min-height:42px; }
+.insight-metrics { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:1rem; margin:1.2rem 0; }
+.insight-metrics .card { padding:1.25rem; min-width:0; }
+.insight-metrics strong { font-size:2.2rem; font-weight:700; letter-spacing:-.04em; line-height:1.4; }
+.insight-metrics section>span:last-child { font-size:.76rem; color:var(--aim-muted); }
+.distribution-card { padding:1.25rem; }
+.outcome-bar { display:block; width:100%; height:12px; border-radius:999px; overflow:hidden; margin:.6rem 0 1rem; background:var(--aim-neutral-soft); }
+.chart-successful { fill:var(--aim-success); background:var(--aim-success); }
+.chart-failed { fill:var(--aim-danger); background:var(--aim-danger); }
+.chart-unreachable { fill:var(--aim-warning); background:var(--aim-warning); }
+.chart-not_started { fill:var(--aim-muted); background:var(--aim-muted); }
+.chart-indeterminate { fill:var(--aim-info); background:var(--aim-info); }
+.chart-unavailable { fill:var(--aim-neutral); background:var(--aim-neutral); }
+.chart-outstanding { fill:var(--aim-accent); background:var(--aim-accent); }
+.outcome-legend { display:flex; gap:.6rem 1.15rem; flex-wrap:wrap; font-size:.76rem; margin-bottom:.8rem; }
+.outcome-legend>span { display:flex; gap:.4rem; align-items:center; }
+.legend-dot { display:inline-block; width:8px; height:8px; border-radius:50%; }
+.target-status-unavailable,.target-status-outstanding { background:var(--aim-neutral-soft); color:var(--aim-neutral); }
+.history-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.65rem 1rem; padding:1.2rem; border-bottom:1px solid var(--aim-border); }
+.history-item:last-child { border-bottom:0; }
+.history-item-result { display:flex; flex-direction:column; align-items:flex-end; gap:.45rem; }
+.history-item-main { display:grid; gap:.4rem; min-width:0; }
+.counter-details { grid-column:1/-1; }
+.counter-details summary { font-size:.76rem; cursor:pointer; color:var(--aim-muted); padding:.3rem 0; }
+.counter-grid { display:flex; gap:1.25rem; flex-wrap:wrap; margin:.7rem 0 0; padding:1rem; background:var(--aim-bg); border-radius:.5rem; }
+.counter-grid dt { font-size:.7rem; color:var(--aim-muted); font-weight:500; }
+.counter-grid dd { font-size:.95rem; margin:0; font-variant-numeric:tabular-nums; }
+.book-summary-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); }
+.book-summary { display:grid; gap:.45rem; padding:1.2rem; color:var(--aim-ink); border-bottom:1px solid var(--aim-border); }
+.book-summary:hover { background:var(--aim-surface-raised); text-decoration:none; }
+.book-summary>span { font-size:.76rem; color:var(--aim-muted); }
+.pagination-controls { display:flex; gap:.65rem; flex-wrap:wrap; align-items:center; margin-top:1rem; }
+.insights-matrix table { min-width:640px; }
+.insights-matrix th { min-width:170px; max-width:240px; }
+.insights-matrix th:first-child { min-width:220px; }
+.matrix-result { display:grid; gap:.4rem; justify-items:start; min-width:150px; }
+.matrix-result>span:not(.status-tag,.mode-tag) { color:var(--aim-muted); font-size:.72rem; }
+.matrix-mobile { display:none; }
+.mobile-cell { display:grid; gap:.45rem; padding:.65rem 0; border-top:1px solid var(--aim-border); }
+.mobile-cell strong { font-size:.83rem; }
+.explorer-summary { display:flex; align-items:center; flex-wrap:wrap; gap:1rem; justify-content:space-between; padding:1rem 1.3rem; border-left:3px solid var(--aim-accent); background:var(--aim-surface); border-radius:0 .65rem .65rem 0; }
+.explorer-summary strong { font-size:1.6rem; letter-spacing:-.03em; }
+.summary-separator { color:var(--aim-border); margin:0 .65rem; }
+.explorer-search { display:grid; gap:.5rem; margin:1.4rem 0; }
+.explorer-search>label { font-size:.8rem; font-weight:650; }
+.explorer-search>div { display:flex; gap:.65rem; }
+.explorer-search input { flex:1; min-width:0; }
+.explorer-toolbar { display:flex; flex-wrap:wrap; gap:1rem; align-items:center; justify-content:space-between; }
+.view-switch { display:flex; gap:.4rem; }
+.view-switch [aria-current] { color:var(--aim-accent-ink); background:var(--aim-accent-soft); border-color:var(--aim-pill-border); }
+.explorer-panel { overflow:hidden; }
+.map-controls { display:flex; gap:1rem; justify-content:space-between; align-items:center; padding:.8rem 1rem; }
+.zoom-controls { display:flex; gap:.35rem; }
+.zoom-controls button { min-width:40px; min-height:40px; padding:.3rem .7rem; border:1px solid var(--aim-border); background:var(--aim-surface); color:var(--aim-ink); border-radius:.4rem; }
+.zoom-controls button:disabled { opacity:.4; }
+.inventory-canvas { max-height:58dvh; min-height:240px; overflow:auto; overscroll-behavior:contain; background-color:var(--aim-map-bg); background-image:radial-gradient(var(--aim-graph-dot) 1px,transparent 1px); background-size:18px 18px; border-block:1px solid var(--aim-border); }
+.inventory-canvas svg { display:block; max-width:none; }
+.graph-edge { stroke:var(--aim-graph-edge); stroke-width:1.5; fill:none; }
+.graph-node rect { fill:var(--aim-surface); stroke:var(--aim-border); stroke-width:1.5; }
+.graph-node:hover rect,.graph-node:focus rect { stroke:var(--aim-accent-ink); stroke-width:2.5; }
+.graph-node:focus { outline:none; }
+.graph-focus rect { fill:var(--aim-accent-soft); stroke:var(--aim-pill-border); }
+.graph-group rect { fill:var(--aim-surface-raised); }
+.graph-symbol { font-size:22px; fill:var(--aim-accent-ink); }
+.graph-label { font-size:12px; font-weight:650; fill:var(--aim-ink); }
+.graph-focus .graph-label { font-size:17px; }
+.graph-detail { font-size:11px; fill:var(--aim-muted); }
+.graph-note rect { fill:var(--aim-bg); stroke-dasharray:4; }
+.inventory-group-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem; padding:1.2rem; }
+.inventory-group-card { padding:1rem; border:1px solid var(--aim-border); border-radius:.65rem; background:var(--aim-surface-raised); }
+.group-card-title { display:flex; align-items:center; gap:.6rem; color:var(--aim-ink); margin-bottom:.65rem; }
+.group-card-title>strong { flex:1; min-width:0; }
+.branch-hosts { list-style:none; padding:0; margin:.85rem 0; }
+.branch-hosts li { padding:.35rem 0; font-size:.8rem; }
+.branch-hosts a { color:var(--aim-ink); }
+.inventory-direct { padding:1.2rem; border-top:1px solid var(--aim-border); }
+.direct-host-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:.8rem; }
+.host-node-link { display:flex; align-items:center; gap:.65rem; color:var(--aim-ink); padding:.85rem; border:1px solid var(--aim-border); border-radius:.6rem; min-width:0; }
+.host-node-link>span:last-child { display:grid; gap:.3rem; min-width:0; font-size:.8rem; }
+.host-node-link small { color:var(--aim-muted); }
+.view-map .explorer-outline,.view-auto .explorer-outline { display:none; }
+.view-outline .explorer-map { display:none; }
+.experience-links { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem; margin:1.5rem 0; }
+.experience-link { padding:1.3rem; background:var(--aim-surface); border:1px solid var(--aim-border); border-radius:var(--aim-radius); display:flex; gap:1rem; color:var(--aim-ink); }
+.experience-link>span:last-child { display:grid; gap:.4rem; }
+.experience-link small { color:var(--aim-muted); line-height:1.5; }
+
+@media(max-width:760px) {
+ .sidebar,.topbar { display:none; }
+ .app-shell { display:block; min-height:calc(100dvh - 64px); }
+ .mobile-topbar { display:flex; align-items:center; justify-content:space-between; gap:.65rem; min-height:64px; padding:.5rem max(.75rem,env(safe-area-inset-right)) .5rem max(.75rem,env(safe-area-inset-left)); background:var(--aim-surface); border-bottom:1px solid var(--aim-border); position:sticky; top:0; z-index:500; }
+ .mobile-brand { display:flex; align-items:center; gap:.6rem; font-size:1.25rem; color:var(--aim-ink); flex-shrink:0; }
+ .mobile-brand .brand-mark { width:36px; height:36px; font-size:1.35rem; border-radius:.55rem; }
+ .mobile-actions { display:flex; align-items:center; gap:.5rem; }
+ .mobile-actions .theme-control { padding:.13rem; }
+ .mobile-actions .theme-option { width:42px; height:42px; }
+ .mobile-menu { position:static; }
+ .mobile-menu summary { display:grid; place-items:center; width:44px; height:44px; cursor:pointer; border:1px solid var(--aim-border); border-radius:.5rem; color:var(--aim-ink); list-style:none; background:var(--aim-surface-raised); }
+ .mobile-menu summary::-webkit-details-marker { display:none; }
+ .mobile-menu summary svg { stroke:currentColor; stroke-width:1.7; fill:none; stroke-linecap:round; }
+ .mobile-menu[open] summary { color:var(--aim-accent-ink); background:var(--aim-accent-soft); }
+ .mobile-menu-panel { position:absolute; top:calc(100% + .35rem); right:max(.75rem,env(safe-area-inset-right)); width:min(350px,calc(100vw - 1.5rem)); max-height:calc(100dvh - 85px); overflow-y:auto; overscroll-behavior:contain; padding:.8rem; border:1px solid var(--aim-border); border-radius:.8rem; box-shadow:var(--aim-shadow); background:var(--aim-surface); }
+ .mobile-menu .primary-nav { display:flex; flex-direction:column; flex-wrap:nowrap; gap:.2rem; overflow:visible; padding:0; }
+ .mobile-menu .nav-link { display:flex; align-items:center; gap:.8rem; color:var(--aim-ink); font-size:.88rem; min-height:44px; padding:.7rem .8rem; border-radius:.5rem; white-space:normal; }
+ .mobile-menu .nav-link.active { background:var(--aim-accent-soft); color:var(--aim-accent-ink); box-shadow:inset 3px 0 var(--aim-accent); }
+ .mobile-menu .nav-link:hover { background:var(--aim-neutral-soft); text-decoration:none; }
+ .mobile-menu .sidebar-caption { display:block; margin:0; padding:.75rem .8rem .4rem; color:var(--aim-muted); }
+ .mobile-account { display:flex; flex-wrap:wrap; gap:.6rem; align-items:center; margin-top:.75rem; padding:1rem .75rem .4rem; border-top:1px solid var(--aim-border); font-size:.8rem; }
+ .mobile-account .account-name { max-width:100%; overflow-wrap:anywhere; }
+ .mobile-account form { margin:0; }
+ .mobile-account .btn { min-height:44px; }
+ .context-links { gap:.65rem 1rem; }
+ .context-links a { min-height:40px; display:flex; align-items:center; }
+ .history-filters { grid-template-columns:repeat(2,minmax(0,1fr)); padding:.9rem; gap:.9rem; }
+ .history-filters .form-control,.history-filters .form-select { min-height:44px; }
+ .history-item { grid-template-columns:minmax(0,1fr); padding:1rem; }
+ .history-item-result { align-items:flex-start; }
+ .insight-metrics { gap:.5rem; }
+ .insight-metrics .card { padding:.8rem; }
+ .insight-metrics strong { font-size:1.6rem; }
+ .insight-metrics .eyebrow { font-size:.55rem; letter-spacing:.06em; }
+ .insights-matrix { display:none; }
+ .matrix-mobile { display:block; }
+ .matrix-mobile .history-item { display:grid; grid-template-columns:minmax(0,1fr); }
+ .book-summary-grid { grid-template-columns:1fr; }
+ .inventory-group-grid,.direct-host-grid { grid-template-columns:minmax(0,1fr); }
+ .view-auto .explorer-map { display:none; }
+ .view-auto .explorer-outline { display:block; }
+ .inventory-canvas { min-height:220px; max-height:52dvh; }
+ .explorer-toolbar { padding:1rem; }
+ .view-switch .btn { min-height:44px; display:flex; align-items:center; }
+ .map-controls { flex-wrap:wrap; gap:.5rem; }
+ .zoom-controls button { min-width:44px; min-height:44px; }
+ .experience-links { grid-template-columns:minmax(0,1fr); }
+ .host-identity { padding:1rem; }
+ .live-console { min-height:0; max-height:60dvh; height:50dvh; }
+}
+@media(max-width:380px) {
+ .insight-metrics { grid-template-columns:1fr; }
+ .insight-metrics .card { display:grid; grid-template-columns:1fr auto; gap:.1rem .5rem; }
+ .insight-metrics strong { grid-column:2; grid-row:1/3; }
+ .history-filters { grid-template-columns:1fr; }
+ .explorer-search>div { flex-wrap:wrap; }
+ .explorer-search input { flex-basis:100%; }
+}
diff --git a/scripts/addons/webgui/src/aim_webgui/static/css/tokens.css b/scripts/addons/webgui/src/aim_webgui/static/css/tokens.css
new file mode 100644
index 0000000..8aad0ad
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/static/css/tokens.css
@@ -0,0 +1,92 @@
+/* AIM identity: change these tokens, not individual page templates. */
+:root,
+html[data-theme="light"] {
+ --aim-accent: #f58220;
+ --aim-on-accent: #202a39;
+ --aim-accent-rgb: 245, 130, 32;
+ --aim-accent-hover: #df7017;
+ --aim-accent-ink: #913900;
+ --aim-accent-soft: #fff1e5;
+ --aim-bg: #f5f6f8;
+ --aim-surface: #ffffff;
+ --aim-surface-raised: #ffffff;
+ --aim-ink: #202a39;
+ --aim-muted: #5d6879;
+ --aim-border: #e1e5ea;
+ --aim-sidebar: #18212e;
+ --aim-sidebar-muted: #a9b5c6;
+ --aim-sidebar-link: #d5ddea;
+ --aim-sidebar-hover: #253244;
+ --aim-sidebar-active: #352c27;
+ --aim-sidebar-active-ink: #ffac68;
+ --aim-danger: #a32f40;
+ --aim-info: #385eb1;
+ --aim-map-bg: #f4f6f9;
+ --aim-graph-edge: #bcc7d4;
+ --aim-graph-dot: #dce3ec;
+ --aim-console-bg: #17191d;
+ --aim-console-ink: #e6e7e9;
+ --aim-success: #176c4b;
+ --aim-success-soft: #e9f5ee;
+ --aim-warning: #785400;
+ --aim-warning-soft: #fff4df;
+ --aim-neutral: #495468;
+ --aim-neutral-soft: #eef1f6;
+ --aim-table-head: #fafbfc;
+ --aim-code-panel: #f6f7f9;
+ --aim-note-bg: #fff8f1;
+ --aim-note-ink: #5a483a;
+ --aim-banner-start: #fff8f1;
+ --aim-banner-end: #ffffff;
+ --aim-banner-border: #f0d9c6;
+ --aim-pill-border: #edcfb6;
+ --aim-link-hover: #632700;
+ --aim-dialog-backdrop: rgb(13 20 30 / 65%);
+ --aim-radius: .75rem;
+ --aim-shadow: 0 2px 6px rgb(24 33 46 / 3%);
+ --aim-space: 1.5rem;
+}
+
+html[data-theme="dark"] {
+ --aim-accent: #f58b32;
+ --aim-on-accent: #202a39;
+ --aim-accent-rgb: 245, 139, 50;
+ --aim-accent-hover: #ff9d52;
+ --aim-accent-ink: #ffb06f;
+ --aim-accent-soft: #3a291f;
+ --aim-bg: #171b21;
+ --aim-surface: #20262f;
+ --aim-surface-raised: #252c36;
+ --aim-ink: #e8edf3;
+ --aim-muted: #a7b0bc;
+ --aim-border: #343d49;
+ --aim-sidebar: #131920;
+ --aim-sidebar-muted: #929eac;
+ --aim-sidebar-link: #c7d0db;
+ --aim-sidebar-hover: #202a35;
+ --aim-sidebar-active: #382b22;
+ --aim-sidebar-active-ink: #ffb678;
+ --aim-danger: #f395a4;
+ --aim-info: #96b7ff;
+ --aim-map-bg: #1a2029;
+ --aim-graph-edge: #435268;
+ --aim-graph-dot: #313b4b;
+ --aim-console-bg: #17191d;
+ --aim-console-ink: #e6e7e9;
+ --aim-success: #7bd1a8;
+ --aim-success-soft: #1d382d;
+ --aim-warning: #e4c676;
+ --aim-warning-soft: #3b321f;
+ --aim-neutral: #c2cad5;
+ --aim-neutral-soft: #2b333e;
+ --aim-table-head: #252c35;
+ --aim-code-panel: #181e25;
+ --aim-note-bg: #2b231d;
+ --aim-note-ink: #d8c3b1;
+ --aim-banner-start: #2b231d;
+ --aim-banner-end: #20262f;
+ --aim-banner-border: #4a392d;
+ --aim-pill-border: #5a4030;
+ --aim-link-hover: #ffc28f;
+ --aim-shadow: 0 8px 24px rgb(0 0 0 / 16%);
+}
diff --git a/scripts/addons/webgui/src/aim_webgui/static/js/aim.js b/scripts/addons/webgui/src/aim_webgui/static/js/aim.js
new file mode 100644
index 0000000..f940fe0
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/static/js/aim.js
@@ -0,0 +1,227 @@
+'use strict';
+// No inline handlers, no eval, no inventory or authentication tokens in browser storage.
+document.addEventListener('htmx:configRequest', function (event) {
+ const token = document.querySelector('meta[name="csrf-token"]');
+ if (token) event.detail.headers['X-CSRF-Token'] = token.content;
+});
+document.addEventListener('htmx:beforeSwap', function (event) {
+ if (event.detail.xhr.status >= 400 && event.detail.xhr.status < 600) {
+ event.detail.shouldSwap = true;
+ event.detail.isError = false;
+ }
+});
+
+
+function formatLocalTimes(root) {
+ const scope = root || document;
+ const items = Array.from(scope.querySelectorAll('time.js-local-time[datetime]'));
+ if (scope.matches && scope.matches('time.js-local-time[datetime]')) items.unshift(scope);
+ items.forEach(function (element) {
+ const date = new Date(element.getAttribute('datetime'));
+ if (Number.isNaN(date.getTime())) return;
+ try {
+ element.textContent = new Intl.DateTimeFormat(undefined, {dateStyle: 'medium', timeStyle: 'medium'}).format(date);
+ element.title = element.getAttribute('datetime') + ' (UTC)';
+ } catch (_) {
+ // Keep the server-rendered UTC fallback when Intl formatting is unavailable.
+ }
+ });
+}
+
+function hostGroups(input) {
+ try { return JSON.parse(input.dataset.groups || '[]'); }
+ catch (_) { return []; }
+}
+function allHosts() { return Array.from(document.querySelectorAll('[data-host-target]')); }
+function visibleHosts() { return allHosts().filter(function (host) { return !host.closest('tr').hidden; }); }
+function syncHostSelection() {
+ const hosts = allHosts();
+ const visible = visibleHosts();
+ const selected = hosts.filter(function (host) { return host.checked; }).length;
+ const selectedVisible = visible.filter(function (host) { return host.checked; }).length;
+ const all = document.querySelector('[data-select-all-hosts]');
+ const counter = document.querySelector('[data-selected-count]');
+ if (counter) counter.textContent = selected + ' selected' + (selected > 500 ? ' (limit 500)' : '');
+ const visibleCount = document.querySelector('[data-visible-count]');
+ if (visibleCount) visibleCount.textContent = visible.length + ' of ' + hosts.length + ' hosts match.';
+ if (all) {
+ all.checked = visible.length > 0 && selectedVisible === visible.length;
+ all.indeterminate = selectedVisible > 0 && selectedVisible < visible.length;
+ all.disabled = visible.length === 0;
+ }
+ document.querySelectorAll('[data-group-select]').forEach(function (group) {
+ const members = hosts.filter(function (host) { return hostGroups(host).includes(group.value); });
+ const checked = members.filter(function (host) { return host.checked; }).length;
+ group.checked = members.length > 0 && checked === members.length;
+ group.indeterminate = checked > 0 && checked < members.length;
+ });
+}
+function initializeHostSelection() {
+ const all = document.querySelector('[data-select-all-hosts]');
+ if (!all || all.dataset.initialized) return;
+ all.dataset.initialized = 'true';
+ const form = all.closest('form');
+ let timer;
+ let revision = 0;
+ function remember() {
+ syncHostSelection();
+ clearTimeout(timer);
+ const sequence = ++revision;
+ timer = setTimeout(async function () {
+ const status = document.querySelector('[data-selection-saved]');
+ const targets = allHosts().filter(function (h) { return h.checked; }).map(function (h) { return h.value; });
+ if (targets.length > 500) {
+ if (status) status.textContent = 'Select at most 500 hosts before saving or validating.';
+ return;
+ }
+ try {
+ const token = document.querySelector('meta[name="csrf-token"]').content;
+ const result = await fetch('/api/v2/selections', {
+ method: 'POST', credentials: 'same-origin',
+ headers: {'Content-Type': 'application/json', 'X-CSRF-Token': token},
+ body: JSON.stringify({customer: form.elements.customer.value, playbook: form.elements.playbook.value, targets: targets})
+ });
+ if (!result.ok) throw new Error('not saved');
+ if (status && revision === sequence) status.textContent = 'Selection saved privately for seven days.';
+ } catch (_) {
+ if (status && revision === sequence) status.textContent = 'Selection could not be saved. Your checkboxes are unchanged; reload after signing in.';
+ }
+ }, 300);
+ }
+ all.addEventListener('change', function () {
+ visibleHosts().forEach(function (host) { host.checked = all.checked; });
+ remember();
+ });
+ document.querySelectorAll('[data-group-select]').forEach(function (group) {
+ group.addEventListener('change', function () {
+ allHosts().forEach(function (host) { if (hostGroups(host).includes(group.value)) host.checked = group.checked; });
+ remember();
+ });
+ });
+ allHosts().forEach(function (host) { host.addEventListener('change', remember); });
+ const clear = document.querySelector('[data-clear-hosts]');
+ if (clear) clear.addEventListener('click', function () {
+ allHosts().forEach(function (host) { host.checked = false; }); remember();
+ });
+ const search = document.querySelector('[data-host-search]');
+ const group = document.querySelector('[data-host-group-filter]');
+ function filter() {
+ const q = search.value.toLocaleLowerCase().trim();
+ allHosts().forEach(function (host) {
+ const row = host.closest('tr');
+ const text = [row.dataset.name, row.dataset.address, row.dataset.group].join(' ').toLocaleLowerCase();
+ row.hidden = !text.includes(q) || (group.value && !hostGroups(host).includes(group.value));
+ });
+ syncHostSelection();
+ }
+ if (search && group) {
+ search.addEventListener('input', filter);
+ group.addEventListener('change', filter);
+ }
+ const sort = document.querySelector('[data-host-sort]');
+ if (sort) sort.addEventListener('change', function () {
+ const rows = Array.from(document.querySelectorAll('[data-host-row]'));
+ rows.sort(function (a, b) {
+ return (a.dataset[sort.value] || '').localeCompare(b.dataset[sort.value] || '', undefined, {numeric: true});
+ });
+ rows.forEach(function (row) { row.parentElement.appendChild(row); });
+ });
+ syncHostSelection();
+}
+document.addEventListener('DOMContentLoaded', function () { initializeHostSelection(); formatLocalTimes(document); });
+document.addEventListener('htmx:afterSwap', function (event) { initializeHostSelection(); formatLocalTimes(event.detail.target); });
+document.addEventListener('htmx:afterSettle', function (event) { formatLocalTimes(event.detail.target); });
+window.addEventListener('pageshow', function () { formatLocalTimes(document); });
+
+// Credentials never enter history, browser persistence or reusable form drafts.
+window.addEventListener('pagehide', function () {
+ document.querySelectorAll('[data-secret-form] input[type="password"], [data-credential-secret]').forEach(function (input) { input.value = ''; });
+});
+
+function initConfirmForms(root) {
+ (root || document).querySelectorAll('form[data-confirm]').forEach(function (form) {
+ if (form.dataset.confirmBound) return; form.dataset.confirmBound='1';
+ form.addEventListener('submit', function (event) { if (!window.confirm(form.dataset.confirm)) event.preventDefault(); });
+ });
+}
+document.addEventListener('DOMContentLoaded', function () { initConfirmForms(document); });
+document.addEventListener('htmx:afterSwap', function (event) { initConfirmForms(event.detail.target); });
+
+function initBulkForms(root) {
+ (root || document).querySelectorAll('[data-bulk-form]').forEach(function (form) {
+ if (form.dataset.bulkBound) return;
+ form.dataset.bulkBound = '1';
+ const all = form.querySelector('[data-bulk-select-all]');
+ const items = Array.from(form.querySelectorAll('[data-bulk-item]'));
+ const submit = form.querySelector('[data-bulk-submit]');
+ const sync = function () {
+ const checked = items.filter(function (item) { return item.checked; }).length;
+ if (all) {
+ all.checked = items.length > 0 && checked === items.length;
+ all.indeterminate = checked > 0 && checked < items.length;
+ all.disabled = items.length === 0;
+ }
+ if (submit) submit.disabled = checked === 0;
+ };
+ if (all) all.addEventListener('change', function () {
+ items.forEach(function (item) { item.checked = all.checked; });
+ sync();
+ });
+ items.forEach(function (item) { item.addEventListener('change', sync); });
+ sync();
+ });
+}
+document.addEventListener('DOMContentLoaded', function () { initBulkForms(document); });
+document.addEventListener('htmx:afterSwap', function (event) { initBulkForms(event.detail.target); });
+
+function initJobConsole(root) {
+ (root || document).querySelectorAll('[data-job-console]').forEach(function (panel) {
+ if (panel.dataset.consoleBound) return;
+ panel.dataset.consoleBound = '1';
+ const output = panel.querySelector('[data-console-output]');
+ const state = panel.querySelector('[data-console-state]');
+ const auto = panel.querySelector('[data-console-autoscroll]');
+ if (!output || panel.dataset.consoleActive !== 'true' || !window.EventSource) return;
+ output.textContent = '';
+ let internalScroll = false;
+ output.addEventListener('scroll', function () {
+ if (internalScroll || !auto || !auto.checked) return;
+ const atBottom = output.scrollHeight - output.scrollTop - output.clientHeight < 24;
+ if (!atBottom) auto.checked = false;
+ }, {passive:true});
+ if (auto) auto.addEventListener('change', function () {
+ if (auto.checked) { internalScroll=true; output.scrollTop=output.scrollHeight;
+ requestAnimationFrame(function () { internalScroll=false; }); }
+ });
+ const lines = [];
+ const append = function (text, kind) {
+ if (typeof text !== 'string' || !text) return;
+ lines.push((kind === 'notice' ? '[AIM] ' : '') + text);
+ while (lines.length > 500) lines.shift();
+ output.textContent = lines.join('\n') + '\n';
+ if (auto && auto.checked) { internalScroll=true; output.scrollTop=output.scrollHeight; requestAnimationFrame(function(){ internalScroll=false; }); }
+ };
+ const source = new EventSource('/api/v2/runs/' + encodeURIComponent(panel.dataset.jobId) + '/console');
+ panel._aimConsoleSource = source;
+ source.addEventListener('open', function () { if (state) state.textContent = 'Live'; });
+ ['line','notice'].forEach(function (kind) {
+ source.addEventListener(kind, function (event) {
+ try { append(JSON.parse(event.data).text, kind); } catch (_) {}
+ });
+ });
+ source.addEventListener('end', function (event) {
+ try { append(JSON.parse(event.data).text, 'notice'); } catch (_) {}
+ if (state) state.textContent = 'Ended';
+ source.close();
+ });
+ source.onerror = function () { if (state && source.readyState !== EventSource.CLOSED) state.textContent = 'Reconnecting'; };
+ });
+}
+document.addEventListener('DOMContentLoaded', function () { initJobConsole(document); });
+window.addEventListener('pagehide', function () {
+ document.querySelectorAll('[data-job-console]').forEach(function (panel) {
+ if (panel._aimConsoleSource) panel._aimConsoleSource.close();
+ const output = panel.querySelector('[data-console-output]');
+ if (output) output.textContent = 'Live console cleared on navigation.';
+ });
+});
diff --git a/scripts/addons/webgui/src/aim_webgui/static/js/credentials.js b/scripts/addons/webgui/src/aim_webgui/static/js/credentials.js
new file mode 100644
index 0000000..e6a76f4
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/static/js/credentials.js
@@ -0,0 +1,348 @@
+'use strict';
+// Presentation only. No browser secret cache, automatic POST retry or scope edits.
+(function () {
+ const sessions = new Set();
+ const dialog = document.querySelector('[data-credential-dialog]');
+ const body = dialog && dialog.querySelector('[data-credential-dialog-body]');
+ let opener = null;
+ let openedJob = null;
+ let loadController = null;
+ let loadSequence = 0;
+ const uncertainJobs = new Set(); // IDs only; cleared when the page is left.
+ const terminal = new Set(['successful', 'failed', 'blocked', 'canceled', 'timed_out', 'interrupted']);
+
+ function clearSecrets(root) {
+ root.querySelectorAll('[data-credential-secret]').forEach(function (input) {
+ input.value = '';
+ input.type = 'password';
+ });
+ root.querySelectorAll('[data-credential-reveal]').forEach(function (button) {
+ button.textContent = 'Show';
+ button.setAttribute('aria-pressed', 'false');
+ button.setAttribute('aria-label', button.getAttribute('aria-label').replace(/^Hide /, 'Show '));
+ });
+ root.querySelectorAll('[data-credential-caps]').forEach(function (note) { note.hidden = true; });
+ }
+ function feedback(ctx, text, tone, focus) {
+ ctx.feedback.textContent = text;
+ ctx.feedback.dataset.tone = tone || 'info';
+ ctx.feedback.hidden = false;
+ if (focus) ctx.feedback.focus({preventScroll: true});
+ }
+ function finish(ctx, text, accepted) {
+ if (ctx.closed) return;
+ clearSecrets(ctx.panel);
+ ctx.locked = true;
+ ctx.inputs.disabled = true;
+ ctx.inputs.hidden = true;
+ ctx.submit.disabled = true;
+ ctx.submit.hidden = true;
+ ctx.panel.querySelector('.credential-intro').hidden = true;
+ ctx.panel.querySelector('.credential-reservation').hidden = true;
+ ctx.panel.querySelector('[data-credential-submit-note]').hidden = true;
+ ctx.dismiss.textContent = 'View job progress';
+ feedback(ctx, text, accepted ? 'accepted' : 'error', true);
+ }
+ function expire(ctx) {
+ if (ctx.busy || ctx.locked || ctx.closed) return;
+ finish(ctx, 'This credential window has expired. Check the job before creating a fresh attempt. No further credentials will be submitted from this dialog.', false);
+ }
+ function clock(ctx, serverNow, deadline) {
+ ctx.remainingAtSync = Math.max(0, Number(deadline) - Number(serverNow));
+ ctx.syncedAt = performance.now();
+ }
+ function tick(ctx) {
+ if (ctx.closed || ctx.locked) return;
+ const seconds = Math.max(0, Math.ceil(ctx.remainingAtSync - (performance.now() - ctx.syncedAt) / 1000));
+ ctx.countdown.textContent = 'Time left ' + String(Math.floor(seconds / 60)).padStart(2, '0') + ':' + String(seconds % 60).padStart(2, '0');
+ ctx.countdown.closest('.credential-reservation').classList.toggle('is-urgent', seconds <= 60);
+ if (seconds <= 60 && !ctx.warned && seconds > 0 && !ctx.busy && !ctx.uncertain) {
+ ctx.warned = true;
+ feedback(ctx, 'Less than one minute remains in this reservation. Opening the form does not extend it.', 'info', false);
+ }
+ if (seconds === 0) expire(ctx);
+ }
+ function sourceMode(ctx) {
+ const choice = ctx.form.querySelector('[data-key-source-choice]');
+ if (!choice) return;
+ const separate = choice.querySelector('input[value="separate"]').checked;
+ const field = ctx.form.querySelector('[data-key-passphrase-field]');
+ const input = field.querySelector('[data-credential-secret]');
+ field.hidden = !separate;
+ input.disabled = !separate;
+ input.required = separate;
+ if (!separate) clearSecrets(field);
+ choice.querySelector('[data-key-source-explanation]').textContent = separate
+ ? 'Supply the private key passphrase for this run only. This does not change the reviewed authentication mode.'
+ : "Uses the Vault's stored key passphrase, checked after submission.";
+ }
+ async function poll(ctx) {
+ if (ctx.closed || ctx.checking || (ctx.locked && !ctx.uncertain)) return;
+ ctx.checking = true;
+ try {
+ const response = await fetch('/api/v2/runs/' + ctx.job + '/credential-status', {
+ credentials: 'same-origin', cache: 'no-store', headers: {'Accept': 'application/json'}, signal: ctx.getController.signal
+ });
+ if (ctx.closed) return;
+ if ([401, 403, 404].includes(response.status)) {
+ finish(ctx, 'This credential form is no longer available to this session. Return to the job or sign in again.', false);
+ ctx.uncertain = false;
+ return;
+ }
+ if (!response.ok) return; // Expiry remains local display only; POST always revalidates.
+ const state = await response.json();
+ if (ctx.closed || state.job_id !== ctx.job) return;
+ if (state.cancel_requested || terminal.has(state.status)) {
+ ctx.uncertain = false;
+ finish(ctx, 'This run has ended or cancellation was requested. Review the job result; do not resubmit credentials here.', false);
+ } else if (state.status === 'running' && state.phase === 'claimed') {
+ uncertainJobs.delete(ctx.job);
+ ctx.uncertain = false;
+ finish(ctx, 'The worker has claimed the credential handoff. Core validates credentials next; this is not confirmation of successful authentication.', true);
+ } else if (state.can_submit && !ctx.locked && !ctx.uncertain) {
+ clock(ctx, state.server_now, state.deadline);
+ tick(ctx);
+ } else if (!state.can_submit && !ctx.busy) {
+ ctx.uncertain = false;
+ finish(ctx, 'The worker is no longer accepting credentials for this reservation. Return to the job for its current status.', false);
+ }
+ } catch (_) {
+ // Never echo response bodies, exceptions, form values or credentials.
+ } finally { ctx.checking = false; }
+ }
+ function validationMessage(ctx) {
+ for (const input of ctx.form.querySelectorAll('[data-credential-secret]')) {
+ if (input.disabled) continue;
+ const value = input.value;
+ if (/[\r\n\u0000]/.test(value) || new TextEncoder().encode(value).length > 2048) {
+ input.focus();
+ return 'Use a single-line password of at most 2048 UTF-8 bytes. Spaces and other literal characters are preserved.';
+ }
+ }
+ return '';
+ }
+ async function submit(ctx, event) {
+ event.preventDefault();
+ if (ctx.busy || ctx.locked || ctx.uncertain || ctx.closed) return;
+ tick(ctx);
+ if (ctx.locked || !ctx.form.reportValidity()) return;
+ const invalid = validationMessage(ctx);
+ if (invalid) { feedback(ctx, invalid, 'error', false); return; }
+ let values = {};
+ ctx.form.querySelectorAll('[data-credential-secret]').forEach(function (input) {
+ if (!input.disabled && input.value !== '') values[input.name] = input.value;
+ });
+ let encoded = JSON.stringify(values);
+ if (new TextEncoder().encode(encoded).length > 8192) {
+ values = null; encoded = null;
+ feedback(ctx, 'The combined credential request exceeds 8 KiB. Nothing was submitted.', 'error', false);
+ return;
+ }
+ const csrf = ctx.form.querySelector('input[name="_csrf"]').value;
+ ctx.busy = true;
+ ctx.submit.disabled = true;
+ ctx.inputs.disabled = true;
+ ctx.form.setAttribute('aria-busy', 'true');
+ ctx.submit.textContent = 'Submitting...';
+ feedback(ctx, 'Submitting once. Core has not yet verified these credentials.', 'info', false);
+ // Minimize live DOM lifetime, including a currently revealed input.
+ clearSecrets(ctx.panel);
+ const controller = new AbortController();
+ const timeout = setTimeout(function () { controller.abort(); }, 15000);
+ try {
+ const request = fetch('/api/v2/runs/' + ctx.job + '/credentials', {
+ method: 'POST', credentials: 'same-origin', cache: 'no-store', redirect: 'error',
+ headers: {'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-Token': csrf},
+ body: encoded, signal: controller.signal
+ });
+ values = null; encoded = null; // Not a physical memory-erasure guarantee.
+ const response = await request;
+ const result = await response.json();
+ if (response.status === 202 && result.accepted === true) {
+ uncertainJobs.delete(ctx.job);
+ if (!ctx.closed) finish(ctx, 'Credential handoff accepted for this run. Core validates the Vault, key or connection next. Follow the job for the result.', true);
+ } else if (response.status === 400 && result.error && ['credential_required', 'invalid_credentials', 'unexpected_credentials'].includes(result.error.code)) {
+ if (!ctx.closed) {
+ ctx.inputs.disabled = false;
+ ctx.submit.disabled = false;
+ feedback(ctx, 'The credential fields were not accepted. Enter the required values again; nothing is saved or echoed here.', 'error', true);
+ }
+ } else if (response.status === 429) {
+ if (!ctx.closed) finish(ctx, 'Too many credential submissions. Check the job and wait before making another manual attempt.', false);
+ } else if ([401, 403, 404].includes(response.status)) {
+ if (!ctx.closed) finish(ctx, 'Your session or permission no longer allows this submission. Return to the job or sign in again.', false);
+ } else {
+ uncertainJobs.add(ctx.job);
+ if (!ctx.closed) {
+ ctx.uncertain = true;
+ finish(ctx, 'Submission was not confirmed. Checking the job status; do not submit the password again. Closing this dialog does not cancel a submitted run.', false);
+ }
+ }
+ } catch (_) {
+ uncertainJobs.add(ctx.job);
+ if (!ctx.closed) {
+ ctx.uncertain = true;
+ finish(ctx, 'Submission was not confirmed. Checking the job status; do not submit the password again. Closing this dialog does not cancel a submitted run.', false);
+ }
+ } finally {
+ values = null; encoded = null; clearTimeout(timeout);
+ ctx.busy = false;
+ ctx.form.removeAttribute('aria-busy');
+ ctx.submit.textContent = 'Submit credentials and continue';
+ if (!ctx.closed) { clearSecrets(ctx.panel); poll(ctx); }
+ }
+ }
+ function initialize(panel) {
+ if (panel.dataset.credentialBound) return;
+ panel.dataset.credentialBound = 'true';
+ const form = panel.querySelector('[data-credential-form]');
+ if (!form || !/^[a-f0-9]{32}$/.test(panel.dataset.jobId)) return;
+ const ctx = {panel: panel, form: form, job: panel.dataset.jobId, feedback: panel.querySelector('[data-credential-feedback]'),
+ inputs: form.querySelector('[data-credential-inputs]'), submit: form.querySelector('[data-credential-submit]'),
+ dismiss: form.querySelector('[data-credential-dismiss]'), countdown: panel.querySelector('[data-credential-countdown]'),
+ busy: false, closed: false, locked: false, checking: false, uncertain: false, warned: false, getController: new AbortController()};
+ sessions.add(ctx);
+ clock(ctx, panel.dataset.serverNow, panel.dataset.deadline);
+ panel.querySelectorAll('[data-credential-reveal]').forEach(function (button) {
+ button.hidden = false;
+ button.addEventListener('click', function () {
+ const input = panel.querySelector('#' + button.getAttribute('aria-controls'));
+ const show = input.type === 'password';
+ input.type = show ? 'text' : 'password';
+ button.textContent = show ? 'Hide' : 'Show';
+ button.setAttribute('aria-pressed', String(show));
+ button.setAttribute('aria-label', button.getAttribute('aria-label').replace(/^(Show|Hide) /, show ? 'Hide ' : 'Show '));
+ });
+ });
+ panel.querySelectorAll('[data-credential-secret]').forEach(function (input) {
+ function caps(event) { input.closest('.credential-field').querySelector('[data-credential-caps]').hidden = !event.getModifierState('CapsLock'); }
+ input.addEventListener('keydown', caps); input.addEventListener('keyup', caps);
+ input.addEventListener('blur', function () { input.closest('.credential-field').querySelector('[data-credential-caps]').hidden = true; });
+ });
+ const choice = form.querySelector('[data-key-source-choice]');
+ if (choice) {
+ choice.hidden = false;
+ choice.querySelectorAll('input').forEach(function (input) { input.disabled = false; });
+ choice.addEventListener('change', function () { sourceMode(ctx); }); sourceMode(ctx);
+ }
+ form.addEventListener('submit', function (event) { submit(ctx, event); });
+ ctx.dismiss.addEventListener('click', function (event) {
+ if (dialog && dialog.contains(panel)) { event.preventDefault(); closeDialog(); }
+ });
+ ctx.tickTimer = setInterval(function () { tick(ctx); }, 1000);
+ ctx.pollTimer = setInterval(function () { poll(ctx); }, 3000);
+ tick(ctx);
+ if (uncertainJobs.has(ctx.job)) {
+ ctx.uncertain = true;
+ finish(ctx, 'A previous submission has not been confirmed. Checking the job; do not resubmit credentials.', false);
+ }
+ poll(ctx);
+ }
+ function dispose(root) {
+ sessions.forEach(function (ctx) {
+ if (root.contains(ctx.panel)) {
+ ctx.closed = true; ctx.getController.abort(); clearInterval(ctx.tickTimer); clearInterval(ctx.pollTimer);
+ clearSecrets(ctx.panel); sessions.delete(ctx);
+ }
+ });
+ }
+ function fitDialog() {
+ if (!dialog || !dialog.open || !window.visualViewport) return;
+ const viewport = window.visualViewport;
+ dialog.style.maxHeight = Math.max(100, viewport.height - 16) + 'px';
+ dialog.style.top = (viewport.offsetTop + viewport.height / 2) + 'px';
+ dialog.style.bottom = 'auto'; dialog.style.margin = '0 auto'; dialog.style.transform = 'translateY(-50%)';
+ }
+ function closeDialog() {
+ if (!dialog) return;
+ ++loadSequence;
+ if (loadController) { loadController.abort(); loadController = null; }
+ dispose(dialog);
+ if (dialog.open) dialog.close();
+ body.replaceChildren();
+ document.documentElement.classList.remove('credential-modal-open');
+ let target = opener && opener.isConnected ? opener : null;
+ if (!target && openedJob) target = document.querySelector('[data-credential-open][data-job-id="' + openedJob + '"]');
+ if (!target) target = document.querySelector('#job-status') || document.querySelector('main');
+ if (target) { if (!target.matches('a,button,input')) target.setAttribute('tabindex', '-1'); target.focus({preventScroll: true}); }
+ opener = null; openedJob = null;
+ }
+ async function openDialog(link) {
+ const job = link.dataset.jobId;
+ if (!/^[a-f0-9]{32}$/.test(job)) return;
+ if (dialog.open) return;
+ opener = link; openedJob = job;
+ const sequence = ++loadSequence;
+ const loading = document.createElement('p'); loading.className = 'credential-loading'; loading.setAttribute('role', 'status'); loading.textContent = 'Checking this worker reservation...'; body.replaceChildren(loading);
+ dialog.showModal(); document.documentElement.classList.add('credential-modal-open'); fitDialog();
+ dialog.querySelector('[data-credential-title]').focus({preventScroll: true});
+ loadController = new AbortController();
+ const timeout = setTimeout(function () { if (sequence === loadSequence && loadController) loadController.abort(); }, 10000);
+ try {
+ const response = await fetch('/_partials/jobs/' + job + '/credentials', {credentials: 'same-origin', cache: 'no-store', redirect: 'error', signal: loadController.signal});
+ if (!response.ok) throw new Error('unavailable');
+ const html = await response.text();
+ if (sequence !== loadSequence || !dialog.open) return;
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+ const panel = doc.querySelector('[data-credential-panel]');
+ if (!panel || panel.dataset.jobId !== job) throw new Error('invalid fragment');
+ body.replaceChildren(document.importNode(panel, true));
+ initialize(body.querySelector('[data-credential-panel]'));
+ if (typeof formatLocalTimes === 'function') formatLocalTimes(body);
+ } catch (_) {
+ if (sequence !== loadSequence || !dialog.open) return;
+ const note = document.createElement('p'); note.setAttribute('role', 'alert'); note.textContent = 'This credential window is unavailable or the session changed. Check the job before trying again.';
+ const back = document.createElement('a'); back.href = '/jobs/' + job; back.className = 'btn btn-outline-primary'; back.textContent = 'Open job'; body.replaceChildren(note, back);
+ } finally { clearTimeout(timeout); }
+ }
+ if (dialog && typeof dialog.showModal === 'function') {
+ function enhanceOpeners() {
+ document.querySelectorAll('[data-credential-open]').forEach(function (link) {
+ link.setAttribute('role', 'button');
+ link.setAttribute('aria-haspopup', 'dialog');
+ link.setAttribute('aria-controls', 'credential-dialog');
+ });
+ }
+ enhanceOpeners();
+ document.addEventListener('htmx:afterSwap', enhanceOpeners);
+ document.addEventListener('keydown', function (event) {
+ const link = event.target.closest('[data-credential-open]');
+ if (link && event.key === ' ' && !event.ctrlKey && !event.metaKey && !event.altKey) {
+ event.preventDefault(); openDialog(link);
+ }
+ });
+ document.addEventListener('click', function (event) {
+ const link = event.target.closest('[data-credential-open]');
+ if (!link || event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
+ event.preventDefault(); openDialog(link);
+ });
+ dialog.querySelector('[data-credential-close]').addEventListener('click', closeDialog);
+ dialog.addEventListener('cancel', function (event) { event.preventDefault(); closeDialog(); });
+ dialog.addEventListener('keydown', function (event) {
+ if (event.key !== 'Tab' || !dialog.open) return;
+ const focusable = Array.from(dialog.querySelectorAll('button,a[href],input,select,textarea,summary,[tabindex]:not([tabindex="-1"])')).filter(function (element) {
+ return !element.matches(':disabled') && element.tabIndex >= 0 && element.getClientRects().length > 0;
+ });
+ if (!focusable.length) { event.preventDefault(); dialog.querySelector('[data-credential-title]').focus(); return; }
+ const first = focusable[0], last = focusable[focusable.length - 1];
+ if (event.shiftKey && (document.activeElement === first || !focusable.includes(document.activeElement))) {
+ event.preventDefault(); last.focus();
+ } else if (!event.shiftKey && (document.activeElement === last || !focusable.includes(document.activeElement))) {
+ event.preventDefault(); first.focus();
+ }
+ });
+ // Deliberate Close/Escape only: a stray backdrop tap cannot erase typed input.
+ if (window.visualViewport) {
+ window.visualViewport.addEventListener('resize', fitDialog);
+ window.visualViewport.addEventListener('scroll', fitDialog);
+ }
+ }
+ document.querySelectorAll('[data-credential-panel]').forEach(initialize);
+ window.addEventListener('pagehide', function () { dispose(document); closeDialog(); clearSecrets(document); uncertainJobs.clear(); });
+ window.addEventListener('pageshow', function (event) {
+ if (event.persisted) {
+ clearSecrets(document);
+ document.querySelectorAll('[data-credential-panel]').forEach(function (panel) { delete panel.dataset.credentialBound; initialize(panel); });
+ }
+ });
+}());
diff --git a/scripts/addons/webgui/src/aim_webgui/static/js/evidence.js b/scripts/addons/webgui/src/aim_webgui/static/js/evidence.js
new file mode 100644
index 0000000..fed974c
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/static/js/evidence.js
@@ -0,0 +1,123 @@
+/* Durable public metadata replay. No secrets, localStorage, execution or POST. */
+(function () {
+ 'use strict';
+ function stamp(value) {
+ const d = new Date(value);
+ return Number.isNaN(d.getTime()) ? value : new Intl.DateTimeFormat(undefined, {dateStyle:'medium',timeStyle:'medium'}).format(d);
+ }
+ function text(node, value) { if (node) node.textContent = value == null ? 'Not observed' : String(value); }
+ function dates(root) {
+ root.querySelectorAll('time[data-evidence-time], time[data-journal-time]').forEach(function (node) {
+ if (node.dateTime) { text(node, stamp(node.dateTime)); node.title = node.dateTime; }
+ });
+ }
+ async function read(url) {
+ const response=await fetch(url,{credentials:'same-origin',cache:'no-store',headers:{'Accept':'application/json'}});
+ if (!response.ok) throw new Error(response.status === 401 || response.status === 403 || response.status === 404 ? 'Access expired or this job is unavailable.' : 'Recorded evidence could not be loaded.');
+ return await response.json();
+ }
+ function journal(panel) {
+ if (panel.dataset.bound) return;
+ panel.dataset.bound='1';
+ const job=panel.dataset.jobId, base='/api/v2/runs/'+encodeURIComponent(job), output=panel.querySelector('[data-journal-output]');
+ const follow=panel.querySelector('[data-journal-follow]'), state=panel.querySelector('[data-journal-state]'), warning=panel.querySelector('[data-journal-warning]');
+ const older=panel.querySelector('[data-journal-older]');
+ let records=[], cursor=0, first=0, internal=false, source=null, alive=true, ended=false, olderTrimmed=false, drawPending=false;
+ function warningText(data) {
+ const messages=[];
+ if (data.omitted_events) messages.push(data.omitted_events+' older events omitted by retention policy.');
+ if (data.dropped_events) messages.push(data.dropped_events+' events were not recorded due to capture pressure.');
+ if (data.capture_interrupted) messages.push('Capture did not close cleanly; the last uncommitted batch may be absent.');
+ if (olderTrimmed) messages.push('The screen shows a bounded recent window; use the paged timeline for the remaining retained metadata.');
+ text(warning,messages.join(' ')); if(warning) warning.hidden=!messages.length;
+ }
+ function snapshot(data) {
+ const cp=data.checkpoint||{};
+ text(panel.querySelector('[data-journal-stage]'),cp.stage);
+ text(panel.querySelector('[data-journal-task]'),cp.last_task ? cp.last_task.label+' ('+cp.last_task.task_id+')' : 'Not observed');
+ text(panel.querySelector('[data-journal-tasks]'),cp.observed_task_starts||0);
+ const when=panel.querySelector('[data-journal-time]');
+ if (when && cp.last_timestamp) {when.dateTime=cp.last_timestamp;text(when,stamp(cp.last_timestamp));when.title=cp.last_timestamp;}
+ warningText(data);
+ const list=panel.querySelector('[data-journal-hosts]');
+ if (list) {
+ list.replaceChildren();
+ (cp.hosts||[]).slice(0,25).forEach(function(h){
+ const row=document.createElement('li');row.textContent=h.host+' - '+h.observation+' ('+h.task_id+')';list.appendChild(row);
+ });
+ if ((cp.hosts||[]).length>25) {const row=document.createElement('li');row.textContent='Latest observations for '+cp.hosts.length+' hosts; first 25 shown here. Use the correlated timeline for more.';list.appendChild(row);}
+ }
+ }
+ function render(preserve) {
+ const scroll=output.scrollTop, previousHeight=output.scrollHeight;
+ output.textContent=records.map(function(r){return stamp(r.event.timestamp)+' '+r.text;}).join('\n')+(records.length?'\n':'');
+ first=records.length?records[0].cursor:0;
+ internal=true;
+ if (preserve) output.scrollTop=scroll+output.scrollHeight-previousHeight;
+ else if (follow && follow.checked) output.scrollTop=output.scrollHeight;
+ else output.scrollTop=scroll;
+ requestAnimationFrame(function(){internal=false;});
+ }
+ function append(record) {
+ if (!record || !Number.isSafeInteger(record.cursor) || record.cursor<=cursor) return;
+ cursor=record.cursor;records.push(record);
+ if(records.length>2000) {records.splice(0,records.length-2000);olderTrimmed=true;}
+ if(!drawPending){drawPending=true;requestAnimationFrame(function(){drawPending=false;if(alive)render(false);});}
+ }
+ output.addEventListener('scroll',function(){if(!internal && follow && follow.checked && output.scrollHeight-output.scrollTop-output.clientHeight>24)follow.checked=false;},{passive:true});
+ if(follow)follow.addEventListener('change',function(){if(follow.checked)render(false);});
+ async function connect() {
+ try {
+ const data=await read(base+'/progress');if(!alive)return;
+ records=data.events||[];cursor=data.cursor;first=records.length?records[0].cursor:0;snapshot(data);render(false);
+ if(!records.length)text(output,data.message||'No metadata recorded yet.');
+ if(older)older.hidden=!data.has_older;
+ if(data.terminal){text(state,'Finished - retained');ended=true;return;}
+ if(!window.EventSource){text(state,'Use Refresh for updated metadata');return;}
+ source=new EventSource(base+'/progress/stream?after='+cursor);panel._journalSource=source;
+ source.addEventListener('open',function(){text(state,'Following');});
+ source.addEventListener('snapshot',function(e){try{snapshot(JSON.parse(e.data));}catch(_){text(state,'Invalid progress metadata');}});
+ source.addEventListener('line',function(e){try{const data=JSON.parse(e.data);append(data.record);}catch(_){text(state,'Invalid progress event');}});
+ source.addEventListener('gap',function(e){try{const data=JSON.parse(e.data);text(warning,data.text);warning.hidden=false;}catch(_){} });
+ source.addEventListener('end',function(e){
+ source.close();ended=true;text(state,'Ended - retained');
+ try{const data=JSON.parse(e.data);if(data.clear){records=[];text(output,data.text);text(state,'Access unavailable');return;}}catch(_){}
+ // Refresh only the final report summary, never the credential modal or window.
+ const report=document.getElementById('job-reports');
+ if(report && window.htmx) window.htmx.ajax('GET','/_partials/jobs/'+job+'/reports',{target:report,swap:'outerHTML'});
+ });
+ source.onerror=function(){if(!ended)text(state,'Reconnecting - recorded history preserved');};
+ } catch(e){text(state,'History unavailable');text(output,e.message);}
+ }
+ if(older)older.addEventListener('click',async function(e){
+ if(!window.fetch || !first)return;
+ e.preventDefault();older.setAttribute('aria-disabled','true');
+ try{const data=await read(base+'/progress?before='+first);if(!alive)return;
+ if(follow)follow.checked=false;
+ if(records.length+(data.events||[]).length>2000){window.location.assign('/jobs/'+job+'/progress?before='+first);return;}
+ records=(data.events||[]).concat(records);render(true);older.hidden=!data.has_older;
+ }catch(error){text(state,error.message);}finally{older.removeAttribute('aria-disabled');}
+ });
+ panel._journalClose=function(){alive=false;if(source)source.close();records=[];output.textContent='View cleared. Retained metadata remains available after authorized reload.';};
+ connect();
+ }
+ function jsonPanel(node) {
+ if(node.dataset.bound)return;node.dataset.bound='1';
+ let loaded=false,loading=false;
+ const output=node.querySelector('[data-json-output]'), status=node.querySelector('[data-json-status]'),copy=node.querySelector('[data-json-copy]');
+ node.addEventListener('toggle',async function(){
+ if(!node.open||loaded||loading)return;loading=true;text(status,'Loading');
+ try {const data=await read(node.dataset.reportJson);text(output,JSON.stringify(data.data,null,2));loaded=true;if(copy)copy.disabled=false;text(status,data.retention==='metadata_only'?'Retained metadata subset':'Retained report data');}
+ catch(e){text(status,e.message);}finally{loading=false;}
+ });
+ if(copy)copy.addEventListener('click',async function(){
+ try{await navigator.clipboard.writeText(output.textContent);text(status,'Copied');}
+ catch(_){text(status,'Copy is unavailable; select the JSON text manually.');}
+ });
+ }
+ function init(root){dates(root);root.querySelectorAll('[data-progress-journal]').forEach(journal);root.querySelectorAll('[data-report-json]').forEach(jsonPanel);}
+ document.addEventListener('DOMContentLoaded',function(){init(document);});
+ document.addEventListener('htmx:afterSwap',function(e){init(e.detail.target||document);});
+ window.addEventListener('pagehide',function(){document.querySelectorAll('[data-progress-journal]').forEach(function(p){if(p._journalClose)p._journalClose();});document.querySelectorAll('[data-json-output]').forEach(function(n){n.textContent='View cleared.';});});
+ window.addEventListener('pageshow',function(e){if(e.persisted)window.location.reload();});
+})();
diff --git a/scripts/addons/webgui/src/aim_webgui/static/js/experience.js b/scripts/addons/webgui/src/aim_webgui/static/js/experience.js
new file mode 100644
index 0000000..9cc23b8
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/static/js/experience.js
@@ -0,0 +1,47 @@
+'use strict';
+// Presentation-only state. No inventory, histories or credentials are persisted here.
+(function () {
+ function initialize(root) {
+ const menu = document.querySelector('[data-mobile-menu]');
+ if (menu && !menu.dataset.bound) {
+ menu.dataset.bound = 'true';
+ const toggle = menu.querySelector('summary');
+ const close = (restore) => { menu.open = false; if (restore) toggle.focus(); };
+ menu.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && menu.open) { e.preventDefault(); close(true); }
+ });
+ document.addEventListener('click', (e) => { if (menu.open && !menu.contains(e.target)) close(false); });
+ menu.addEventListener('focusout', () => requestAnimationFrame(() => {
+ if (menu.open && !menu.contains(document.activeElement)) close(false);
+ }));
+ menu.querySelectorAll('a').forEach((a) => a.addEventListener('click', () => close(false)));
+ window.addEventListener('resize', () => { if (window.innerWidth > 760) close(false); });
+ window.addEventListener('pagehide', () => close(false));
+ }
+ (root || document).querySelectorAll('[data-explorer]').forEach((panel) => {
+ if (panel.dataset.graphBound) return;
+ panel.dataset.graphBound='true';
+ const graph=panel.querySelector('[data-inventory-graph]');
+ const box=panel.querySelector('.inventory-canvas');
+ if (!graph || !box) return;
+ const bw=Number(graph.dataset.baseWidth), bh=Number(graph.dataset.baseHeight);
+ let scale=1;
+ function setSize(n) {
+ scale=Math.max(.35,Math.min(1.8,n));
+ graph.setAttribute('width',String(Math.round(bw*scale)));
+ graph.setAttribute('height',String(Math.round(bh*scale)));
+ panel.querySelector('[data-graph-zoom="out"]').disabled=scale<=.35;
+ panel.querySelector('[data-graph-zoom="in"]').disabled=scale>=1.8;
+ }
+ // On phones avoid illegible fit-to-width; the graph remains internally scrollable.
+ setSize(Math.max(.65,Math.min(1,box.clientWidth/bw)));
+ panel.querySelectorAll('[data-graph-zoom]').forEach((button) => button.addEventListener('click', () => {
+ const dir=button.dataset.graphZoom;
+ setSize(dir==='reset'?Math.max(.65,Math.min(1,box.clientWidth/bw)):scale+(dir==='in'?.15:-.15));
+ }));
+ });
+ }
+ document.addEventListener('DOMContentLoaded',()=>initialize(document));
+ document.addEventListener('htmx:afterSwap',(e)=>initialize(e.detail.target));
+ window.addEventListener('pageshow',()=>initialize(document));
+}());
diff --git a/scripts/addons/webgui/src/aim_webgui/static/js/theme.js b/scripts/addons/webgui/src/aim_webgui/static/js/theme.js
new file mode 100644
index 0000000..1931188
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/static/js/theme.js
@@ -0,0 +1,45 @@
+'use strict';
+(function () {
+ const key = 'aim-web-display-theme';
+ const valid = new Set(['light', 'dark']);
+
+ function storedTheme() {
+ try {
+ const value = window.localStorage.getItem(key);
+ return valid.has(value) ? value : 'light';
+ } catch (_) {
+ return 'light';
+ }
+ }
+
+ function syncButtons(theme) {
+ document.querySelectorAll('[data-theme-option]').forEach(function (button) {
+ const active = button.dataset.themeOption === theme;
+ button.setAttribute('aria-pressed', active ? 'true' : 'false');
+ button.classList.toggle('active', active);
+ });
+ }
+
+ function applyTheme(theme) {
+ const value = valid.has(theme) ? theme : 'light';
+ document.documentElement.dataset.theme = value;
+ document.documentElement.dataset.bsTheme = value;
+ document.documentElement.style.colorScheme = value;
+ syncButtons(value);
+ }
+
+ applyTheme(storedTheme());
+
+ document.addEventListener('DOMContentLoaded', function () {
+ const buttons = document.querySelectorAll('[data-theme-option]');
+ if (!buttons.length) return;
+ syncButtons(document.documentElement.dataset.theme || 'light');
+ buttons.forEach(function (button) {
+ button.addEventListener('click', function () {
+ const value = valid.has(button.dataset.themeOption) ? button.dataset.themeOption : 'light';
+ try { window.localStorage.setItem(key, value); } catch (_) { /* Display preference only. */ }
+ applyTheme(value);
+ });
+ });
+ });
+}());
diff --git a/scripts/addons/webgui/src/aim_webgui/static/vendor/THIRD-PARTY.txt b/scripts/addons/webgui/src/aim_webgui/static/vendor/THIRD-PARTY.txt
new file mode 100644
index 0000000..99f4c43
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/static/vendor/THIRD-PARTY.txt
@@ -0,0 +1,38 @@
+Assets installed by deploy/fetch_assets.py:
+
+Bootstrap 5.3.8 (CSS only)
+https://github.com/twbs/bootstrap/tree/v5.3.8
+MIT License
+Copyright (c) 2011-2025 The Bootstrap Authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+htmx 2.0.10
+https://github.com/bigskysoftware/htmx/tree/v2.0.10
+Zero-Clause BSD
+
+Permission to use, copy, modify, and/or distribute this software for
+any purpose with or without fee is hereby granted.
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL
+WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
+OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
+FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
+DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
+AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/scripts/addons/webgui/src/aim_webgui/static/vendor/bootstrap.min.css b/scripts/addons/webgui/src/aim_webgui/static/vendor/bootstrap.min.css
new file mode 100644
index 0000000..1d8bac4
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/static/vendor/bootstrap.min.css
@@ -0,0 +1,6 @@
+@charset "UTF-8";/*!
+ * Bootstrap v5.3.8 (https://getbootstrap.com/)
+ * Copyright 2011-2025 The Bootstrap Authors
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ */:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--bs-link-hover-color:#8bb9fe;--bs-link-color-rgb:110,168,254;--bs-link-hover-color-rgb:139,185,254;--bs-code-color:#e685b5;--bs-highlight-color:#dee2e6;--bs-highlight-bg:#664d03;--bs-border-color:#495057;--bs-border-color-translucent:rgba(255, 255, 255, 0.15);--bs-form-valid-color:#75b798;--bs-form-valid-border-color:#75b798;--bs-form-invalid-color:#ea868f;--bs-form-invalid-border-color:#ea868f}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:var(--bs-border-width) solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1));text-decoration:underline}a:hover{--bs-link-color-rgb:var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;line-height:inherit;font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button{cursor:pointer;filter:grayscale(1)}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-weight:300;line-height:1.2;font-size:calc(1.625rem + 4.5vw)}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-weight:300;line-height:1.2;font-size:calc(1.575rem + 3.9vw)}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-weight:300;line-height:1.2;font-size:calc(1.525rem + 3.3vw)}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-weight:300;line-height:1.2;font-size:calc(1.475rem + 2.7vw)}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-weight:300;line-height:1.2;font-size:calc(1.425rem + 2.1vw)}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-weight:300;line-height:1.2;font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:var(--bs-body-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:var(--bs-secondary-color)}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color-type:initial;--bs-table-bg-type:initial;--bs-table-color-state:initial;--bs-table-bg-state:initial;--bs-table-color:var(--bs-emphasis-color);--bs-table-bg:var(--bs-body-bg);--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-emphasis-color);--bs-table-striped-bg:rgba(var(--bs-emphasis-color-rgb), 0.05);--bs-table-active-color:var(--bs-emphasis-color);--bs-table-active-bg:rgba(var(--bs-emphasis-color-rgb), 0.1);--bs-table-hover-color:var(--bs-emphasis-color);--bs-table-hover-bg:rgba(var(--bs-emphasis-color-rgb), 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state,var(--bs-table-color-type,var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state,var(--bs-table-bg-type,var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(var(--bs-border-width) * 2) solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:var(--bs-border-width) 0}.table-bordered>:not(caption)>*>*{border-width:0 var(--bs-border-width)}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-active{--bs-table-color-state:var(--bs-table-active-color);--bs-table-bg-state:var(--bs-table-active-bg)}.table-hover>tbody>tr:hover>*{--bs-table-color-state:var(--bs-table-hover-color);--bs-table-bg-state:var(--bs-table-hover-bg)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#a6b5cc;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#b5b6b7;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#a7b9b1;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#a6c3ca;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#ccc2a4;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#c6acae;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#c6c7c8;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#4d5154;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + var(--bs-border-width));padding-bottom:calc(.375rem + var(--bs-border-width));margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + var(--bs-border-width));padding-bottom:calc(.5rem + var(--bs-border-width));font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + var(--bs-border-width));padding-bottom:calc(.25rem + var(--bs-border-width));font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:var(--bs-secondary-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:var(--bs-secondary-bg)}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:var(--bs-body-color);background-color:transparent;border:solid transparent;border-width:var(--bs-border-width) 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2));padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2))}textarea.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}textarea.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-control-color{width:3rem;height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2));padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color::-webkit-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-image:var(--bs-form-select-bg-img),var(--bs-form-select-bg-icon,none);background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:var(--bs-secondary-bg)}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 var(--bs-body-color)}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}[data-bs-theme=dark] .form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{--bs-form-check-bg:var(--bs-body-bg);flex-shrink:0;width:1em;height:1em;margin-top:.25em;vertical-align:top;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");width:2em;margin-left:-2.5em;background-image:var(--bs-form-switch-bg);background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus){--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e")}.form-range{width:100%;height:1.5rem;padding:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;-webkit-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;-moz-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:var(--bs-secondary-color)}.form-range:disabled::-moz-range-thumb{background-color:var(--bs-secondary-color)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(var(--bs-border-width) * 2));min-height:calc(3.5rem + calc(var(--bs-border-width) * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;max-width:100%;height:100%;padding:1rem .75rem;overflow:hidden;color:rgba(var(--bs-body-color-rgb),.65);text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:var(--bs-border-width) solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem;padding-left:.75rem}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>textarea:focus~label::after,.form-floating>textarea:not(:placeholder-shown)~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>textarea:disabled~label::after{background-color:var(--bs-secondary-bg)}.form-floating>.form-control-plaintext~label{border-width:var(--bs-border-width) 0}.form-floating>.form-control:disabled~label,.form-floating>:disabled~label{color:#6c757d}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);text-align:center;white-space:nowrap;background-color:var(--bs-tertiary-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius)}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(-1 * var(--bs-border-width));border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-valid-color)}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-success);border-radius:var(--bs-border-radius)}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:var(--bs-form-valid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:var(--bs-form-valid-border-color)}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:var(--bs-form-valid-border-color)}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:var(--bs-form-valid-color)}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:var(--bs-form-valid-color)}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-invalid-color)}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-danger);border-radius:var(--bs-border-radius)}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:var(--bs-form-invalid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:var(--bs-form-invalid-border-color)}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:var(--bs-form-invalid-border-color)}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:var(--bs-form-invalid-color)}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:var(--bs-form-invalid-color)}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:var(--bs-body-color);--bs-btn-bg:transparent;--bs-btn-border-width:var(--bs-border-width);--bs-btn-border-color:transparent;--bs-btn-border-radius:var(--bs-border-radius);--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked:focus-visible+.btn{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:0 0 0 #000;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:var(--bs-border-radius-lg)}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:var(--bs-border-radius-sm)}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:var(--bs-body-color);--bs-dropdown-bg:var(--bs-body-bg);--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:var(--bs-border-radius);--bs-dropdown-border-width:var(--bs-border-width);--bs-dropdown-inner-border-radius:calc(var(--bs-border-radius) - var(--bs-border-width));--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:var(--bs-box-shadow);--bs-dropdown-link-color:var(--bs-body-color);--bs-dropdown-link-hover-color:var(--bs-body-color);--bs-dropdown-link-hover-bg:var(--bs-tertiary-bg);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:var(--bs-tertiary-color);--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0;border-radius:var(--bs-dropdown-item-border-radius,0)}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:var(--bs-border-radius)}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:calc(-1 * var(--bs-border-width))}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:calc(-1 * var(--bs-border-width))}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn:nth-child(n+3),.btn-group-vertical>:not(.btn-check)+.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;background:0 0;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:var(--bs-border-width);--bs-nav-tabs-border-color:var(--bs-border-color);--bs-nav-tabs-border-radius:var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color:var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color:var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg:var(--bs-body-bg);--bs-nav-tabs-link-active-border-color:var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:var(--bs-border-radius);--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-underline{--bs-nav-underline-gap:1rem;--bs-nav-underline-border-width:0.125rem;--bs-nav-underline-link-active-color:var(--bs-emphasis-color);gap:var(--bs-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--bs-nav-underline-border-width) solid transparent}.nav-underline .nav-link:focus,.nav-underline .nav-link:hover{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:700;color:var(--bs-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-grow:1;flex-basis:0;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color:rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color:rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius:var(--bs-border-radius);--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-grow:1;flex-basis:100%;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark]{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}[data-bs-theme=dark] .navbar-toggler-icon{--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width:var(--bs-border-width);--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:var(--bs-body-bg);--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0;color:var(--bs-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child)>.card-header,.card-group>.card:not(:last-child)>.card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child)>.card-footer,.card-group>.card:not(:last-child)>.card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child)>.card-header,.card-group>.card:not(:first-child)>.card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child)>.card-footer,.card-group>.card:not(:first-child)>.card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:var(--bs-body-color);--bs-accordion-bg:var(--bs-body-bg);--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:var(--bs-border-width);--bs-accordion-border-radius:var(--bs-border-radius);--bs-accordion-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:var(--bs-body-color);--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e");--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:var(--bs-primary-text-emphasis);--bs-accordion-active-bg:var(--bs-primary-bg-subtle)}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type>.accordion-header .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type>.accordion-header .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type>.accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush>.accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush>.accordion-item:first-child{border-top:0}.accordion-flush>.accordion-item:last-child{border-bottom:0}.accordion-flush>.accordion-item>.accordion-collapse,.accordion-flush>.accordion-item>.accordion-header .accordion-button,.accordion-flush>.accordion-item>.accordion-header .accordion-button.collapsed{border-radius:0}[data-bs-theme=dark] .accordion-button::after{--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e");--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e")}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:var(--bs-secondary-color);--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:var(--bs-body-bg);--bs-pagination-border-width:var(--bs-border-width);--bs-pagination-border-color:var(--bs-border-color);--bs-pagination-border-radius:var(--bs-border-radius);--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:var(--bs-tertiary-bg);--bs-pagination-hover-border-color:var(--bs-border-color);--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:var(--bs-secondary-bg);--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:var(--bs-secondary-color);--bs-pagination-disabled-bg:var(--bs-secondary-bg);--bs-pagination-disabled-border-color:var(--bs-border-color);display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(-1 * var(--bs-border-width))}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:var(--bs-border-radius-lg)}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:var(--bs-border-radius-sm)}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:var(--bs-border-radius);display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:var(--bs-border-width) solid var(--bs-alert-border-color);--bs-alert-border-radius:var(--bs-border-radius);--bs-alert-link-color:inherit;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:var(--bs-primary-text-emphasis);--bs-alert-bg:var(--bs-primary-bg-subtle);--bs-alert-border-color:var(--bs-primary-border-subtle);--bs-alert-link-color:var(--bs-primary-text-emphasis)}.alert-secondary{--bs-alert-color:var(--bs-secondary-text-emphasis);--bs-alert-bg:var(--bs-secondary-bg-subtle);--bs-alert-border-color:var(--bs-secondary-border-subtle);--bs-alert-link-color:var(--bs-secondary-text-emphasis)}.alert-success{--bs-alert-color:var(--bs-success-text-emphasis);--bs-alert-bg:var(--bs-success-bg-subtle);--bs-alert-border-color:var(--bs-success-border-subtle);--bs-alert-link-color:var(--bs-success-text-emphasis)}.alert-info{--bs-alert-color:var(--bs-info-text-emphasis);--bs-alert-bg:var(--bs-info-bg-subtle);--bs-alert-border-color:var(--bs-info-border-subtle);--bs-alert-link-color:var(--bs-info-text-emphasis)}.alert-warning{--bs-alert-color:var(--bs-warning-text-emphasis);--bs-alert-bg:var(--bs-warning-bg-subtle);--bs-alert-border-color:var(--bs-warning-border-subtle);--bs-alert-link-color:var(--bs-warning-text-emphasis)}.alert-danger{--bs-alert-color:var(--bs-danger-text-emphasis);--bs-alert-bg:var(--bs-danger-bg-subtle);--bs-alert-border-color:var(--bs-danger-border-subtle);--bs-alert-link-color:var(--bs-danger-text-emphasis)}.alert-light{--bs-alert-color:var(--bs-light-text-emphasis);--bs-alert-bg:var(--bs-light-bg-subtle);--bs-alert-border-color:var(--bs-light-border-subtle);--bs-alert-link-color:var(--bs-light-text-emphasis)}.alert-dark{--bs-alert-color:var(--bs-dark-text-emphasis);--bs-alert-bg:var(--bs-dark-bg-subtle);--bs-alert-border-color:var(--bs-dark-border-subtle);--bs-alert-link-color:var(--bs-dark-text-emphasis)}@keyframes progress-bar-stripes{0%{background-position-x:var(--bs-progress-height)}}.progress,.progress-stacked{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:var(--bs-secondary-bg);--bs-progress-border-radius:var(--bs-border-radius);--bs-progress-box-shadow:var(--bs-box-shadow-inset);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:var(--bs-body-color);--bs-list-group-bg:var(--bs-body-bg);--bs-list-group-border-color:var(--bs-border-color);--bs-list-group-border-width:var(--bs-border-width);--bs-list-group-border-radius:var(--bs-border-radius);--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:var(--bs-secondary-color);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-tertiary-bg);--bs-list-group-action-active-color:var(--bs-body-color);--bs-list-group-action-active-bg:var(--bs-secondary-bg);--bs-list-group-disabled-color:var(--bs-secondary-color);--bs-list-group-disabled-bg:var(--bs-body-bg);--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:not(.active):focus,.list-group-item-action:not(.active):hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:not(.active):active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{--bs-list-group-color:var(--bs-primary-text-emphasis);--bs-list-group-bg:var(--bs-primary-bg-subtle);--bs-list-group-border-color:var(--bs-primary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-primary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-primary-border-subtle);--bs-list-group-active-color:var(--bs-primary-bg-subtle);--bs-list-group-active-bg:var(--bs-primary-text-emphasis);--bs-list-group-active-border-color:var(--bs-primary-text-emphasis)}.list-group-item-secondary{--bs-list-group-color:var(--bs-secondary-text-emphasis);--bs-list-group-bg:var(--bs-secondary-bg-subtle);--bs-list-group-border-color:var(--bs-secondary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-secondary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-secondary-border-subtle);--bs-list-group-active-color:var(--bs-secondary-bg-subtle);--bs-list-group-active-bg:var(--bs-secondary-text-emphasis);--bs-list-group-active-border-color:var(--bs-secondary-text-emphasis)}.list-group-item-success{--bs-list-group-color:var(--bs-success-text-emphasis);--bs-list-group-bg:var(--bs-success-bg-subtle);--bs-list-group-border-color:var(--bs-success-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-success-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-success-border-subtle);--bs-list-group-active-color:var(--bs-success-bg-subtle);--bs-list-group-active-bg:var(--bs-success-text-emphasis);--bs-list-group-active-border-color:var(--bs-success-text-emphasis)}.list-group-item-info{--bs-list-group-color:var(--bs-info-text-emphasis);--bs-list-group-bg:var(--bs-info-bg-subtle);--bs-list-group-border-color:var(--bs-info-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-info-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-info-border-subtle);--bs-list-group-active-color:var(--bs-info-bg-subtle);--bs-list-group-active-bg:var(--bs-info-text-emphasis);--bs-list-group-active-border-color:var(--bs-info-text-emphasis)}.list-group-item-warning{--bs-list-group-color:var(--bs-warning-text-emphasis);--bs-list-group-bg:var(--bs-warning-bg-subtle);--bs-list-group-border-color:var(--bs-warning-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-warning-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-warning-border-subtle);--bs-list-group-active-color:var(--bs-warning-bg-subtle);--bs-list-group-active-bg:var(--bs-warning-text-emphasis);--bs-list-group-active-border-color:var(--bs-warning-text-emphasis)}.list-group-item-danger{--bs-list-group-color:var(--bs-danger-text-emphasis);--bs-list-group-bg:var(--bs-danger-bg-subtle);--bs-list-group-border-color:var(--bs-danger-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-danger-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-danger-border-subtle);--bs-list-group-active-color:var(--bs-danger-bg-subtle);--bs-list-group-active-bg:var(--bs-danger-text-emphasis);--bs-list-group-active-border-color:var(--bs-danger-text-emphasis)}.list-group-item-light{--bs-list-group-color:var(--bs-light-text-emphasis);--bs-list-group-bg:var(--bs-light-bg-subtle);--bs-list-group-border-color:var(--bs-light-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-light-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-light-border-subtle);--bs-list-group-active-color:var(--bs-light-bg-subtle);--bs-list-group-active-bg:var(--bs-light-text-emphasis);--bs-list-group-active-border-color:var(--bs-light-text-emphasis)}.list-group-item-dark{--bs-list-group-color:var(--bs-dark-text-emphasis);--bs-list-group-bg:var(--bs-dark-bg-subtle);--bs-list-group-border-color:var(--bs-dark-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-dark-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-dark-border-subtle);--bs-list-group-active-color:var(--bs-dark-bg-subtle);--bs-list-group-active-bg:var(--bs-dark-text-emphasis);--bs-list-group-active-border-color:var(--bs-dark-text-emphasis)}.btn-close{--bs-btn-close-color:#000;--bs-btn-close-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414'/%3e%3c/svg%3e");--bs-btn-close-opacity:0.5;--bs-btn-close-hover-opacity:0.75;--bs-btn-close-focus-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-btn-close-focus-opacity:1;--bs-btn-close-disabled-opacity:0.25;box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;filter:var(--bs-btn-close-filter);border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.btn-close-white{--bs-btn-close-filter:invert(1) grayscale(100%) brightness(200%)}:root,[data-bs-theme=light]{--bs-btn-close-filter: }[data-bs-theme=dark]{--bs-btn-close-filter:invert(1) grayscale(100%) brightness(200%)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-border-width:var(--bs-border-width);--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:var(--bs-border-radius);--bs-toast-box-shadow:var(--bs-box-shadow);--bs-toast-header-color:var(--bs-secondary-color);--bs-toast-header-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-header-border-color:var(--bs-border-color-translucent);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color:var(--bs-body-color);--bs-modal-bg:var(--bs-body-bg);--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:var(--bs-border-width);--bs-modal-border-radius:var(--bs-border-radius-lg);--bs-modal-box-shadow:var(--bs-box-shadow-sm);--bs-modal-inner-border-radius:calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:var(--bs-border-width);--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transform:translate(0,-50px);transition:transform .3s ease-out}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin-top:calc(-.5 * var(--bs-modal-header-padding-y));margin-right:calc(-.5 * var(--bs-modal-header-padding-x));margin-bottom:calc(-.5 * var(--bs-modal-header-padding-y));margin-left:auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:var(--bs-box-shadow)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:var(--bs-body-bg);--bs-tooltip-bg:var(--bs-emphasis-color);--bs-tooltip-border-radius:var(--bs-border-radius);--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:var(--bs-body-bg);--bs-popover-border-width:var(--bs-border-width);--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:var(--bs-border-radius-lg);--bs-popover-inner-border-radius:calc(var(--bs-border-radius-lg) - var(--bs-border-width));--bs-popover-box-shadow:var(--bs-box-shadow);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color:inherit;--bs-popover-header-bg:var(--bs-secondary-bg);--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:var(--bs-body-color);--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;filter:var(--bs-carousel-control-icon-filter);border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:var(--bs-carousel-indicator-active-bg);background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:var(--bs-carousel-caption-color);text-align:center}.carousel-dark{--bs-carousel-indicator-active-bg:#000;--bs-carousel-caption-color:#000;--bs-carousel-control-icon-filter:invert(1) grayscale(100)}:root,[data-bs-theme=light]{--bs-carousel-indicator-active-bg:#fff;--bs-carousel-caption-color:#fff;--bs-carousel-control-icon-filter: }[data-bs-theme=dark]{--bs-carousel-indicator-active-bg:#000;--bs-carousel-caption-color:#000;--bs-carousel-control-icon-filter:invert(1) grayscale(100)}.spinner-border,.spinner-grow{display:inline-block;flex-shrink:0;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color:var(--bs-body-color);--bs-offcanvas-bg:var(--bs-body-bg);--bs-offcanvas-border-width:var(--bs-border-width);--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:var(--bs-box-shadow-sm);--bs-offcanvas-transition:transform 0.3s ease-in-out;--bs-offcanvas-title-line-height:1.5}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin-top:calc(-.5 * var(--bs-offcanvas-padding-y));margin-right:calc(-.5 * var(--bs-offcanvas-padding-x));margin-bottom:calc(-.5 * var(--bs-offcanvas-padding-y));margin-left:auto}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(var(--bs-primary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(var(--bs-secondary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(var(--bs-success-rgb),var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(var(--bs-info-rgb),var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(var(--bs-warning-rgb),var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(var(--bs-danger-rgb),var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(var(--bs-light-rgb),var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(var(--bs-dark-rgb),var(--bs-bg-opacity,1))!important}.link-primary{color:RGBA(var(--bs-primary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important}.link-primary:focus,.link-primary:hover{color:RGBA(10,88,202,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important}.link-secondary{color:RGBA(var(--bs-secondary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important}.link-secondary:focus,.link-secondary:hover{color:RGBA(86,94,100,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important}.link-success{color:RGBA(var(--bs-success-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important}.link-success:focus,.link-success:hover{color:RGBA(20,108,67,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important}.link-info{color:RGBA(var(--bs-info-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important}.link-info:focus,.link-info:hover{color:RGBA(61,213,243,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important}.link-warning{color:RGBA(var(--bs-warning-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important}.link-warning:focus,.link-warning:hover{color:RGBA(255,205,57,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important}.link-danger{color:RGBA(var(--bs-danger-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important}.link-danger:focus,.link-danger:hover{color:RGBA(176,42,55,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important}.link-light{color:RGBA(var(--bs-light-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important}.link-light:focus,.link-light:hover{color:RGBA(249,250,251,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important}.link-dark{color:RGBA(var(--bs-dark-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important}.link-dark:focus,.link-dark:hover{color:RGBA(26,30,33,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-body-emphasis:focus,.link-body-emphasis:hover{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,.75))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x,0) var(--bs-focus-ring-y,0) var(--bs-focus-ring-blur,0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-underline-offset:0.25em;-webkit-backface-visibility:hidden;backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media (prefers-reduced-motion:reduce){.icon-link>.bi{transition:none}}.icon-link-hover:focus-visible>.bi,.icon-link-hover:hover>.bi{transform:var(--bs-icon-link-transform,translate3d(.25em,0,0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption),.visually-hidden:not(caption){position:absolute!important}.visually-hidden *,.visually-hidden-focusable:not(:focus):not(:focus-within) *{overflow:hidden!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:var(--bs-border-width);min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.object-fit-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-none{-o-object-fit:none!important;object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--bs-box-shadow)!important}.shadow-sm{box-shadow:var(--bs-box-shadow-sm)!important}.shadow-lg{box-shadow:var(--bs-box-shadow-lg)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--bs-focus-ring-color:rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color:rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color:rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color:rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color:rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color:rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color:rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color:rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-black{--bs-border-opacity:1;border-color:rgba(var(--bs-black-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--bs-success-border-subtle)!important}.border-info-subtle{border-color:var(--bs-info-border-subtle)!important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle)!important}.border-light-subtle{border-color:var(--bs-light-border-subtle)!important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:3rem!important}.column-gap-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-body-secondary{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-body-tertiary{--bs-text-opacity:1;color:var(--bs-tertiary-color)!important}.text-body-emphasis{--bs-text-opacity:1;color:var(--bs-emphasis-color)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--bs-success-text-emphasis)!important}.text-info-emphasis{color:var(--bs-info-text-emphasis)!important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis)!important}.text-light-emphasis{color:var(--bs-light-text-emphasis)!important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis)!important}.link-opacity-10{--bs-link-opacity:0.1}.link-opacity-10-hover:hover{--bs-link-opacity:0.1}.link-opacity-25{--bs-link-opacity:0.25}.link-opacity-25-hover:hover{--bs-link-opacity:0.25}.link-opacity-50{--bs-link-opacity:0.5}.link-opacity-50-hover:hover{--bs-link-opacity:0.5}.link-opacity-75{--bs-link-opacity:0.75}.link-opacity-75-hover:hover{--bs-link-opacity:0.75}.link-opacity-100{--bs-link-opacity:1}.link-opacity-100-hover:hover{--bs-link-opacity:1}.link-offset-1{text-underline-offset:0.125em!important}.link-offset-1-hover:hover{text-underline-offset:0.125em!important}.link-offset-2{text-underline-offset:0.25em!important}.link-offset-2-hover:hover{text-underline-offset:0.25em!important}.link-offset-3{text-underline-offset:0.375em!important}.link-offset-3-hover:hover{text-underline-offset:0.375em!important}.link-underline-primary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-secondary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-success{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important}.link-underline-info{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important}.link-underline-warning{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important}.link-underline-danger{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important}.link-underline-light{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important}.link-underline-dark{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important}.link-underline{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-underline-opacity-0{--bs-link-underline-opacity:0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity:0}.link-underline-opacity-10{--bs-link-underline-opacity:0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity:0.1}.link-underline-opacity-25{--bs-link-underline-opacity:0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity:0.25}.link-underline-opacity-50{--bs-link-underline-opacity:0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity:0.5}.link-underline-opacity-75{--bs-link-underline-opacity:0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity:0.75}.link-underline-opacity-100{--bs-link-underline-opacity:1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-body-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-bg-rgb),var(--bs-bg-opacity))!important}.bg-body-tertiary{--bs-bg-opacity:1;background-color:rgba(var(--bs-tertiary-bg-rgb),var(--bs-bg-opacity))!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle)!important}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-0{border-top-left-radius:0!important;border-top-right-radius:0!important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm)!important;border-top-right-radius:var(--bs-border-radius-sm)!important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg)!important;border-top-right-radius:var(--bs-border-radius-lg)!important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl)!important;border-top-right-radius:var(--bs-border-radius-xl)!important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl)!important;border-top-right-radius:var(--bs-border-radius-xxl)!important}.rounded-top-circle{border-top-left-radius:50%!important;border-top-right-radius:50%!important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill)!important;border-top-right-radius:var(--bs-border-radius-pill)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-0{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm)!important;border-bottom-right-radius:var(--bs-border-radius-sm)!important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg)!important;border-bottom-right-radius:var(--bs-border-radius-lg)!important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl)!important;border-bottom-right-radius:var(--bs-border-radius-xl)!important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-right-radius:var(--bs-border-radius-xxl)!important}.rounded-end-circle{border-top-right-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill)!important;border-bottom-right-radius:var(--bs-border-radius-pill)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-0{border-bottom-right-radius:0!important;border-bottom-left-radius:0!important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm)!important;border-bottom-left-radius:var(--bs-border-radius-sm)!important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg)!important;border-bottom-left-radius:var(--bs-border-radius-lg)!important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl)!important;border-bottom-left-radius:var(--bs-border-radius-xl)!important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-left-radius:var(--bs-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-right-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill)!important;border-bottom-left-radius:var(--bs-border-radius-pill)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-0{border-bottom-left-radius:0!important;border-top-left-radius:0!important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm)!important;border-top-left-radius:var(--bs-border-radius-sm)!important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg)!important;border-top-left-radius:var(--bs-border-radius-lg)!important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl)!important;border-top-left-radius:var(--bs-border-radius-xl)!important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl)!important;border-top-left-radius:var(--bs-border-radius-xxl)!important}.rounded-start-circle{border-bottom-left-radius:50%!important;border-top-left-radius:50%!important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill)!important;border-top-left-radius:var(--bs-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.object-fit-sm-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-sm-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-sm-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-sm-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-sm-none{-o-object-fit:none!important;object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:3rem!important}.column-gap-sm-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-sm-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-sm-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-sm-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-sm-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-sm-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.object-fit-md-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-md-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-md-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-md-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-md-none{-o-object-fit:none!important;object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:3rem!important}.column-gap-md-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-md-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-md-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-md-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-md-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-md-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.object-fit-lg-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-lg-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-lg-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-lg-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-lg-none{-o-object-fit:none!important;object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:3rem!important}.column-gap-lg-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-lg-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-lg-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-lg-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-lg-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-lg-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.object-fit-xl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xl-none{-o-object-fit:none!important;object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:3rem!important}.column-gap-xl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xxl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xxl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xxl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xxl-none{-o-object-fit:none!important;object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:3rem!important}.column-gap-xxl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xxl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xxl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xxl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xxl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xxl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}}
+/*# sourceMappingURL=bootstrap.min.css.map */
\ No newline at end of file
diff --git a/scripts/addons/webgui/src/aim_webgui/static/vendor/htmx.min.js b/scripts/addons/webgui/src/aim_webgui/static/vendor/htmx.min.js
new file mode 100644
index 0000000..3b7ac1a
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/static/vendor/htmx.min.js
@@ -0,0 +1 @@
+var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=dn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.10"};Q.onLoad=j;Q.process=Ft;Q.on=ye;Q.off=xe;Q.trigger=ae;Q.ajax=Nn;Q.find=f;Q.findAll=y;Q.closest=g;Q.remove=z;Q.addClass=w;Q.removeClass=b;Q.toggleClass=G;Q.takeClass=W;Q.swap=_e;Q.defineExtension=_n;Q.removeExtension=zn;Q.logAll=$;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:se,canAccessLocalStorage:U,findThisElement:we,filterValues:yn,swap:_e,hasAttribute:s,getAttributeValue:a,getClosestAttributeValue:ne,getClosestMatch:A,getExpressionVars:Rn,getHeaders:mn,getInputValues:dn,getInternalData:oe,getSwapSpecification:bn,getTriggerSpecs:st,getTarget:Se,makeFragment:P,mergeObjects:le,makeSettleInfo:Sn,oobSwap:He,querySelectorExt:ce,settleImmediately:Yt,shouldCancel:ht,triggerEvent:ae,triggerErrorEvent:fe,withExtensions:Vt};const de=["get","post","put","delete","patch"];const R=de.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function a(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function te(){return document}function q(e,t){return e.getRootNode?e.getRootNode({composed:t}):te()}function A(e,t){while(e&&!t(e)){e=c(e)}return e||null}function o(e,t,n){const r=a(t,n);const o=a(t,"hx-disinherit");var i=a(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function ne(t,n){let r=null;A(t,function(e){return!!(r=o(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function N(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function I(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=te().createElement("script");ie(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function D(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){H(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=N(t);let r;if(n==="html"){r=new DocumentFragment;const i=I(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=I(t);L(r,i.body);r.title=i.title}else{const i=I(''+t+" ");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){D(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function re(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function M(e){return t(e,"Object")}function oe(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e=0}function se(e){return e.getRootNode({composed:true})===document}function X(e){return e.trim().split(/\s+/)}function le(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function v(e){try{return JSON.parse(e)}catch(e){H(e);return null}}function U(){const e="htmx:sessionStorageTest";try{sessionStorage.setItem(e,e);sessionStorage.removeItem(e);return true}catch(e){return false}}function V(e){try{const t=new URL(e,window.location.href);e=t.pathname+t.search}catch(e){}if(e!="/"){e=e.replace(/\/+$/,"")}return e}function e(e){return On(te().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function $(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function f(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return f(te(),e)}}function y(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return y(te(),e)}}function x(){return window}function z(e,t){e=S(e);if(t){x().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function J(e){return e instanceof HTMLElement?e:null}function K(e){return typeof e==="string"?e:null}function p(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function w(e,t,n){e=ue(S(e));if(!e){return}if(n){x().setTimeout(function(){w(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function b(e,t,n){let r=ue(S(e));if(!r){return}if(n){x().setTimeout(function(){b(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function G(e,t){e=S(e);e.classList.toggle(t)}function W(e,t){e=S(e);ie(e.parentElement.children,function(e){b(e,t)});w(ue(e),t)}function g(e,t){e=ue(S(e));if(e){return e.closest(t)}return null}function l(e,t){return e.substring(0,t.length)===t}function Z(e,t){return e.substring(e.length-t.length)===t}function Y(e){const t=e.trim();if(l(t,"<")&&Z(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(t,r,n){if(r.indexOf("global ")===0){return m(t,r.slice(7),true)}t=S(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=Y(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),Y(r.slice(8)))}else if(r.indexOf("find ")===0){e=f(p(t),Y(r.slice(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,Y(r.slice(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=ge(t,Y(r.slice(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=q(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const u=p(q(t,!!n));i.push(...F(u.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=p(q(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ce(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(te().body,e)[0]}}function S(e,t){if(typeof e==="string"){return f(p(t)||document,e)}else{return e}}function me(e,t,n,r){if(k(t)){return{target:te().body,event:K(e),listener:t,options:n}}else{return{target:S(e),event:K(t),listener:n,options:r}}}function ye(t,n,r,o){Gn(function(){const e=me(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function xe(t,n,r){Gn(function(){const e=me(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const be=te().createElement("output");function ve(t,n){const e=ne(t,n);if(e){if(e==="this"){return[we(t,n)]}else{const r=m(t,e);const o=/(^|,)(\s*)inherit(\s*)($|,)/.test(e);if(o){const i=ue(A(t,function(e){return e!==t&&s(ue(e),n)}));if(i){r.push(...ve(i,n))}}if(r.length===0){H('The selector "'+e+'" on '+n+" returned no matches!");return[be]}else{return r}}}}function we(e,t){return ue(A(e,function(e){return a(ue(e),t)!=null}))}function Se(e){const t=ne(e,"hx-target");if(t){if(t==="this"){return we(e,"hx-target")}else{return ce(e,t)}}else{const n=oe(e);if(n.boosted){return te().body}else{return e}}}function Ee(e){return Q.config.attributesToSettle.includes(e)}function Ce(t,n){ie(Array.from(t.attributes),function(e){if(!n.hasAttribute(e.name)&&Ee(e.name)){t.removeAttribute(e.name)}});ie(n.attributes,function(e){if(Ee(e.name)){t.setAttribute(e.name,e.value)}})}function Oe(t,e){const n=Jn(e);for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=m(t,n,false);if(r.length){ie(r,function(e){let t;const n=o.cloneNode(true);t=te().createDocumentFragment();t.appendChild(n);if(!Oe(s,e)){t=p(n)}const r={shouldSwap:true,target:e,fragment:t};if(!ae(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){Re(t);je(s,e,e,t,i);Te()}ie(i.elts,function(e){ae(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(te().body,"htmx:oobErrorNoTarget",{content:o,target:n})}return e}function Te(){const e=f("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=f("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function Re(e){ie(y(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=a(e,"id");const n=te().getElementById(t);if(n!=null){if(e.moveBefore){let e=f("#--htmx-preserve-pantry--");if(e==null){te().body.insertAdjacentHTML("afterend","
");e=f("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function qe(i,e,s){ie(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const e=p(i);const r=e&&e.querySelector(CSS.escape(t.tagName)+"#"+CSS.escape(n));if(r&&r!==e){const o=t.cloneNode();Ce(t,r);s.tasks.push(function(){Ce(t,o)})}}})}function Ae(e){return function(){b(e,Q.config.addedClass);Ft(ue(e));Ne(p(e));ae(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=J(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function u(e,t,n,r){qe(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;w(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function _e(h,d,p,g){if(!g){g={}}let m=null;let n=null;let e=function(){re(g.beforeSwapCallback);h=S(h);const r=g.contextElement?q(g.contextElement,false):te();const e=document.activeElement;let t={};t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null};const o=Sn(h);if(p.swapStyle==="textContent"){h.textContent=d}else{let n=P(d);o.title=g.title||n.title;if(g.historyRequest){n=n.querySelector("[hx-history-elt],[data-hx-history-elt]")||n}if(g.selectOOB){const i=g.selectOOB.split(",");for(let t=0;t0){x().setTimeout(n,p.settleDelay)}else{n()}};let t=Q.config.globalViewTransitions;if(p.hasOwnProperty("transition")){t=p.transition}const r=g.contextElement||te();if(t&&ae(r,"htmx:beforeTransition",g.eventInfo)&&typeof Promise!=="undefined"&&document.startViewTransition){const o=new Promise(function(e,t){m=e;n=t});const i=e;e=function(){document.startViewTransition(function(){i();return o})}}try{if(p?.swapDelay&&p.swapDelay>0){x().setTimeout(e,p.swapDelay)}else{e()}}catch(e){fe(r,"htmx:swapError",g.eventInfo);re(n);throw e}}function ze(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=v(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(M(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}ae(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=On(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(te().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function O(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=O(e,Qe).trim();e.shift()}else{t=O(e,C)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{O(o,Ze);const l=o.length;const u=O(o,/[,\[\s]/);if(u!==""){if(u==="every"){const c={trigger:"every"};O(o,Ze);c.pollInterval=d(O(o,/[,\[\s]/));O(o,Ze);var i=nt(e,o,"event");if(i){c.eventFilter=i}r.push(c)}else{const f={trigger:u};var i=nt(e,o,"event");if(i){f.eventFilter=i}O(o,Ze);while(o.length>0&&o[0]!==","){const a=o.shift();if(a==="changed"){f.changed=true}else if(a==="once"){f.once=true}else if(a==="consume"){f.consume=true}else if(a==="delay"&&o[0]===":"){o.shift();f.delay=d(O(o,C))}else if(a==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=O(o,C);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}f.from=s}else if(a==="target"&&o[0]===":"){o.shift();f.target=rt(o)}else if(a==="throttle"&&o[0]===":"){o.shift();f.throttle=d(O(o,C))}else if(a==="queue"&&o[0]===":"){o.shift();f.queue=O(o,C)}else if(a==="root"&&o[0]===":"){o.shift();f[a]=rt(o)}else if(a==="threshold"&&o[0]===":"){o.shift();f[a]=O(o,C)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ze)}r.push(f)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ze)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=a(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){oe(e).cancelled=true}function ut(e,t,n){const r=oe(e);r.timeout=x().setTimeout(function(){if(se(e)&&r.cancelled!==true){if(!pt(n,e,Xt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ut(e,t,n)}},n.pollInterval)}function ct(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function ft(e){return g(e,Q.config.disableSelector)}function at(t,n,e){if(t instanceof HTMLAnchorElement&&ct(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){gt(t,function(e,t){const n=ue(e);if(ft(n)){E(n);return}he(r,o,n,t)},n,e,true)})}}function ht(e,t){if(e.type==="submit"&&t.tagName==="FORM"){return true}else if(e.type==="click"){const n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit"){return true}const r=t.closest("a");const o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href"))){return true}}return false}function dt(e,t){return oe(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(te().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function gt(l,u,e,c,f){const a=oe(l);let t;if(c.from){t=m(l,c.from)}else{t=[l]}if(c.changed){if(!("lastValue"in a)){a.lastValue=new WeakMap}t.forEach(function(e){if(!a.lastValue.has(c)){a.lastValue.set(c,new WeakMap)}a.lastValue.get(c).set(e,e.value)})}ie(t,function(i){const s=function(e){if(!se(l)){i.removeEventListener(c.trigger,s);return}if(dt(l,e)){return}if(f||ht(e,i)){e.preventDefault()}if(pt(c,l,e)){return}const t=oe(e);t.triggerSpec=c;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(c.consume){e.stopPropagation()}if(c.target&&e.target){if(!h(ue(e.target),c.target)){return}}if(c.once){if(a.triggeredOnce){return}else{a.triggeredOnce=true}}if(c.changed){const n=e.target;const r=n.value;const o=a.lastValue.get(c);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(a.delayed){clearTimeout(a.delayed)}if(a.throttle){return}if(c.throttle>0){if(!a.throttle){ae(l,"htmx:trigger");u(l,e);a.throttle=x().setTimeout(function(){a.throttle=null},c.throttle)}}else if(c.delay>0){a.delayed=x().setTimeout(function(){ae(l,"htmx:trigger");u(l,e)},c.delay)}else{ae(l,"htmx:trigger");u(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:c.trigger,listener:s,on:i});i.addEventListener(c.trigger,s)})}let mt=false;let yt=null;function xt(){if(!yt){yt=function(){mt=true};window.addEventListener("scroll",yt);window.addEventListener("resize",yt);setInterval(function(){if(mt){mt=false;ie(te().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&B(e)){e.setAttribute("data-hx-revealed","true");const t=oe(e);if(t.initHash){ae(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){ae(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;ae(e,"htmx:trigger");t(e)}};if(r>0){x().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;ie(de,function(r){if(s(t,"hx-"+r)){const o=a(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(ft(n)){E(n);return}he(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){xt();gt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ce(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ut(ue(r),n,e)}else{gt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Rt(e){const t=At(e.target);const n=It(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=It(e);if(t){t.lastButtonClicked=null}}function At(e){return g(ue(e),"button, input[type='submit']")}function Nt(e){return e.form||g(e,"form")}function It(e){const t=At(e.target);if(!t){return}const n=Nt(t);if(!n){return}return oe(n)}function Lt(e){e.addEventListener("click",Rt);e.addEventListener("focusin",Rt);e.addEventListener("focusout",qt)}function Dt(t,e,n){const r=oe(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){On(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){De(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(te().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Jt(t){if(!U()){return null}t=V(t);const n=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){r.response=this.response;ae(te().body,"htmx:historyCacheMissLoad",r);_e(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:true});$t(r.path);ae(te().body,"htmx:historyRestore",{path:e,cacheMiss:true,serverResponse:r.response})}else{fe(te().body,"htmx:historyCacheMissLoadError",r)}};if(ae(te().body,"htmx:historyCacheMiss",r)){t.send()}}function en(e){Gt();e=e||location.pathname+location.search;const t=Jt(e);if(t){const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll};const r={path:e,item:t,historyElt:_t(),swapSpec:n};if(ae(te().body,"htmx:historyCacheHit",r)){_e(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title});$t(r.path);ae(te().body,"htmx:historyRestore",r)}}else{if(Q.config.refreshOnHistoryMiss){Q.location.reload(true)}else{Qt(e)}}}function tn(e){let t=ve(e,"hx-indicator");if(t==null){t=[e]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;w(e,Q.config.requestClass)});return t}function nn(e){let t=ve(e,"hx-disabled-elt");if(t==null){t=[]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;if(!e.hasAttribute("disabled")){e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")}});return t}function rn(e,t){ie(e.concat(t),function(e){const t=oe(e);t.requestCount=(t.requestCount||1)-1});ie(e,function(e){const t=oe(e);if(t.requestCount===0){b(e,Q.config.requestClass)}});ie(t,function(e){const t=oe(e);if(t.requestCount===0&&e.hasAttribute("data-disabled-by-htmx")){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function on(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);ie(e,e=>r.append(t,e))}}function cn(e){if(e instanceof HTMLSelectElement&&e.multiple){return F(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e instanceof HTMLInputElement&&e.files){return F(e.files)}return e.value}function fn(t,n,r,e,o){if(e==null||on(t,e)){return}else{t.push(e)}if(sn(e)){const i=ee(e,"name");ln(i,cn(e),n);if(o){an(e,r)}}if(e instanceof HTMLFormElement){ie(e.elements,function(e){if(t.indexOf(e)>=0){un(e.name,cn(e),n)}else{t.push(e)}if(o){an(e,r)}});new FormData(e).forEach(function(e,t){if(e instanceof File&&e.name===""){return}ln(t,e,n)})}}function an(e,t){const n=e;if(n.willValidate){ae(n,"htmx:validation:validate");if(!n.checkValidity()){if(ae(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&Q.config.reportValidityOfForms){n.reportValidity()}t.push({elt:n,message:n.validationMessage,validity:n.validity})}}}function hn(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function dn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=oe(e);if(s.lastButtonClicked&&!se(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||a(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){fn(n,o,i,Nt(e),l)}fn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const c=s.lastButtonClicked||e;const f=ee(c,"name");ln(f,c.value,o)}const u=ve(e,"hx-include");ie(u,function(e){fn(n,r,i,ue(e),l);if(!h(e,"form")){ie(p(e).querySelectorAll(ot),function(e){fn(n,r,i,e,l)})}});hn(r,o);return{errors:i,formData:r,values:kn(r)}}function pn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function gn(e){e=Dn(e);let n="";e.forEach(function(e,t){n=pn(n,t,e)});return n}function mn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":a(t,"id"),"HX-Current-URL":location.href};Cn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(oe(e).boosted){r["HX-Boosted"]="true"}return r}function yn(n,e){const t=ne(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){ie(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;ie(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function xn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function bn(e,t){const n=t||ne(e,"hx-swap");const r={swapStyle:oe(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&oe(e).boosted&&!xn(e)){r.show="top"}if(n){const s=X(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=c;r.scrollTarget=i}else if(l.indexOf("show:")===0){const f=l.slice(5);var o=f.split(":");const a=o.pop();var i=o.length>0?o.join(":"):null;r.show=a;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{H("Unknown modifier in hx-swap: "+l)}}}}return r}function vn(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function wn(t,n,r){let o=null;Vt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(vn(n)){return hn(new FormData,Dn(r))}else{return gn(r)}}}function Sn(e){return{tasks:[],elts:[e]}}function En(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ce(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}if(typeof t.scroll==="number"){x().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ce(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Cn(r,e,o,i,s){if(i==null){i={}}if(r==null){return i}const l=a(r,e);if(l){let e=l.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=On(r,function(){if(s){return Function("event","return ("+e+")").call(r,s)}else{return Function("return ("+e+")").call(r)}},{})}else{n=v(e)}for(const u in n){if(n.hasOwnProperty(u)){if(i[u]==null){i[u]=n[u]}}}}return Cn(ue(c(r)),e,o,i,s)}function On(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function Hn(e,t,n){return Cn(e,"hx-vars",true,n,t)}function Tn(e,t,n){return Cn(e,"hx-vals",false,n,t)}function Rn(e,t){return le(Hn(e,t),Tn(e,t))}function qn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function An(t){if(t.responseURL){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(te().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function T(e,t){return t.test(e.getAllResponseHeaders())}function Nn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return he(t,n,null,null,{targetOverride:S(r)||be,returnPromise:true})}else{let e=S(r.target);if(r.target&&!e||r.source&&!e&&!S(r.source)){e=be}return he(t,n,S(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true,push:r.push,replace:r.replace,selectOOB:r.selectOOB})}}else{return he(t,n,null,null,{returnPromise:true})}}function In(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Ln(e,t,n){const r=new URL(t,location.protocol!=="about:"?location.href:window.origin);const o=location.protocol!=="about:"?location.origin:window.origin;const i=o===r.origin;if(Q.config.selfRequestsOnly){if(!i){return false}}return ae(e,"htmx:validateUrl",le({url:r,sameHost:i},n))}function Dn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Pn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function kn(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Pn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,k){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=te().body}const M=i.handler||Vn;const F=i.select||null;if(!se(r)){re(s);return e}const u=i.targetOverride||ue(Se(r));if(u==null||u==be){fe(r,"htmx:targetError",{target:ne(r,"hx-target")});re(l);return e}let c=oe(r);const f=c.lastButtonClicked;if(f){const A=ee(f,"formaction");if(A!=null){n=A}const N=ee(f,"formmethod");if(N!=null){if(de.includes(N.toLowerCase())){t=N}else{re(s);return e}}}const a=ne(r,"hx-confirm");if(k===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:u,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:a};if(ae(r,"htmx:confirm",G)===false){re(s);return e}}let h=r;let d=ne(r,"hx-sync");let p=null;let B=false;if(d){const I=d.split(":");const L=I[0].trim();if(L==="this"){h=we(r,"hx-sync")}else{h=ue(ce(r,L))}d=(I[1]||"drop").trim();c=oe(h);if(d==="drop"&&c.xhr&&c.abortable!==true){re(s);return e}else if(d==="abort"){if(c.xhr){re(s);return e}else{B=true}}else if(d==="replace"){ae(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");p=(W[1]||"last").trim()}}if(c.xhr){if(c.abortable){ae(h,"htmx:abort")}else{if(p==null){if(o){const D=oe(o);if(D&&D.triggerSpec&&D.triggerSpec.queue){p=D.triggerSpec.queue}}if(p==null){p="last"}}if(c.queuedRequests==null){c.queuedRequests=[]}if(p==="first"&&c.queuedRequests.length===0){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="all"){c.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="last"){c.queuedRequests=[];c.queuedRequests.push(function(){he(t,n,r,o,i)})}re(s);return e}}const g=new XMLHttpRequest;c.xhr=g;c.abortable=B;const m=function(){c.xhr=null;c.abortable=false;if(c.queuedRequests!=null&&c.queuedRequests.length>0){const e=c.queuedRequests.shift();e()}};const X=ne(r,"hx-prompt");if(X){var y=prompt(X);if(y===null||!ae(r,"htmx:prompt",{prompt:y,target:u})){re(s);m();return e}}if(a&&!k){if(!confirm(a)){re(s);m();return e}}let x=mn(r,u,y);if(t!=="get"&&!vn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=le(x,i.headers)}const U=dn(r,t);let b=U.errors;const V=U.formData;if(i.values){hn(V,Dn(i.values))}const j=Dn(Rn(r,o));const v=hn(V,j);let w=yn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(u,"id")||"true")}if(n==null||n===""){n=location.href}const S=Cn(r,"hx-request");const $=oe(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:$,useUrlParams:E,formData:w,parameters:kn(w),unfilteredFormData:v,unfilteredParameters:kn(v),headers:x,elt:r,target:u,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!ae(r,"htmx:configRequest",C)){re(s);m();return e}n=C.path;t=C.verb;x=C.headers;w=Dn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){ae(r,"htmx:validation:halted",C);re(s);m();return e}const _=n.split("#");const z=_[0];const O=_[1];let H=n;if(E){H=z;const Z=!w.keys().next().done;if(Z){if(H.indexOf("?")<0){H+="?"}else{H+="&"}H+=gn(w);if(O){H+="#"+O}}}if(!Ln(r,H,C)){fe(r,"htmx:invalidPath",C);re(l);m();return e}g.open(t.toUpperCase(),H,true);g.overrideMimeType("text/html");g.withCredentials=C.withCredentials;g.timeout=C.timeout;if(S.noHeaders){}else{for(const P in x){if(x.hasOwnProperty(P)){const Y=x[P];qn(g,P,Y)}}}const T={xhr:g,target:u,requestConfig:C,etc:i,boosted:$,select:F,pathInfo:{requestPath:n,finalRequestPath:H,responsePath:null,anchor:O}};g.onload=function(){try{const t=In(r);T.pathInfo.responsePath=An(g);M(r,T);if(T.keepIndicators!==true){rn(R,q)}ae(r,"htmx:afterRequest",T);ae(r,"htmx:afterOnLoad",T);if(!se(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(se(n)){e=n}}if(e){ae(e,"htmx:afterRequest",T);ae(e,"htmx:afterOnLoad",T)}}re(s)}catch(e){fe(r,"htmx:onLoadError",le({error:e},T));throw e}finally{m()}};g.onerror=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:sendError",T);re(l);m()};g.onabort=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:sendAbort",T);re(l);m()};g.ontimeout=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:timeout",T);re(l);m()};if(!ae(r,"htmx:beforeRequest",T)){re(s);m();return e}var R=tn(r);var q=nn(r);ie(["loadstart","loadend","progress","abort"],function(t){ie([g,g.upload],function(e){e.addEventListener(t,function(e){ae(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ae(r,"htmx:beforeSend",T);const J=E?null:wn(g,r,w);g.send(J);return e}function Mn(e,t){const n=t.xhr;let r=null;let o=null;if(T(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(T(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(T(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;let l=t.etc.push||ne(e,"hx-push-url");let u=t.etc.replace||ne(e,"hx-replace-url");if(l==="false")l=null;if(u==="false")u=null;const c=oe(e).boosted;let f=null;let a=null;if(l){f="push";a=l}else if(u){f="replace";a=u}else if(c){f="push";a=s||i}if(a){if(a==="true"){a=s||i}if(t.pathInfo.anchor&&a.indexOf("#")===-1){a=a+"#"+t.pathInfo.anchor}return{type:f,path:a}}else{return{}}}function Fn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Bn(e){for(var t=0;t`+`.${t}{opacity:0;visibility: hidden} `+`.${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`+"")}}function Zn(){const e=te().querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function Yn(){const e=Zn();if(e){Q.config=le(Q.config,e)}}Gn(function(){Yn();Wn();let e=te().body;Ft(e);const t=te().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.detail.elt||e.target;const n=oe(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){en();ie(t,function(e){ae(e,"htmx:restored",{document:te(),triggerEvent:ae})})}else{if(n){n(e)}}};x().setTimeout(function(){ae(e,"htmx:load",{});e=null},0)});return Q}();
\ No newline at end of file
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/layouts/base.html b/scripts/addons/webgui/src/aim_webgui/templates/layouts/base.html
new file mode 100644
index 0000000..6de5cf9
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/layouts/base.html
@@ -0,0 +1,59 @@
+
+
+
+
+
+ {{ title }} | AIM Web
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% from 'partials/navigation.html' import links, theme, account %}
+Skip to content
+
+ A AIM
+ {{ theme() }}
+
+
+
+
+
+
+
+
+ AIM WEB / {{ nav|upper }}
{{ title }} {% block subtitle %}{% endblock %}
+ {% if error %}{{ error }}
{% endif %}
+ {% block content %}{% endblock %}
+
+
+
+
+{% if session and session.user_id %}{% include 'partials/credential_dialog.html' %}{% endif %}
+
+
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/activity.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/activity.html
new file mode 100644
index 0000000..aa27fc6
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/activity.html
@@ -0,0 +1,23 @@
+{% extends 'layouts/base.html' %}
+{% from 'partials/activity_macros.html' import stamp, outcome, filters, summary, records %}
+{% block subtitle %}Host activity · {{ customer }} . Current membership from Core; execution history from this add-on only.
{% endblock %}
+{% block content %}
+← Inventory explorer Customer insights History Saved plans
+
+▣
+CURRENT INVENTORY MEMBERSHIP
+{% if inventory_error %}
Current inventory is unavailable. Retained history is still shown below.
This is not an empty-inventory or offline-host result. Refresh after Core connectivity is restored.
+{% elif current %}
{% for member in current.memberships %}
{{ member.label }} {% else %}
No group membership {% endfor %}
{{ current.platforms|join(', ') or 'Platform not supplied' }} · {{ current.address or 'Address not supplied' }}
Retrieved {{ stamp(fetched_at) }}
+{% else %}
Not in current inventory.
Historical records are retained under this logical customer/hostname identity; it is not a hardware identifier.
{% endif %}
+
+RETAINED WEBGUI HISTORY
Playbook activity
+{{ filters(data,outcome_labels,'/inventory/' + (customer|urlencode) + '/activity',host) }}
+{{ 'All retained history' if data.filters.days == 'all' else 'Rolling ' + data.filters.days + '-day window' }} · {% if data.filters.mode == 'check' %}Check-mode results do not prove that changes were applied.{% elif data.filters.mode == 'all' %}Apply and check results are combined explicitly; each record is labeled.{% else %}Apply runs only.{% endif %} Deleted jobs and AIM terminal runs are not included.
+{{ summary(data,outcome_labels) }}
+{% if data.book_stats %}{% endif %}
+{{ records(data,outcome_labels) }}
+
+
+
+Final Ansible outcomes are historical execution facts, not current software versions or application health. Renamed or recreated hosts are not automatically merged.
+{% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/audit.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/audit.html
new file mode 100644
index 0000000..f90b4d2
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/audit.html
@@ -0,0 +1,5 @@
+{% extends 'layouts/base.html' %}{% block content %}
+Security and workflow metadata only. No passwords, request bodies, parameter values or command output. This local audit store is not tamper-proof against its service owner or root.
+
+
Time Actor Event Subject {% for item in items %}{{ item.occurred_at|utc }} {{ item.actor }} {{ item.action }} {{ item.subject }} {% else %}No events match. {% endfor %}
+{% if items|length == 100 %}Older events {% endif %}{% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/credentials.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/credentials.html
new file mode 100644
index 0000000..b4efe8b
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/credentials.html
@@ -0,0 +1,7 @@
+{% extends 'layouts/base.html' %}
+{% block content %}
+
+
ONE RUN / PRIVATE CREDENTIAL CHANNEL
Unlock this run
+ {% include 'partials/credential_panel.html' %}
+
+{% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/customers.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/customers.html
new file mode 100644
index 0000000..a7a46f8
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/customers.html
@@ -0,0 +1,3 @@
+{% extends 'layouts/base.html' %}
+{% block subtitle %}Customer directories remain owned and managed by AIM.
{% endblock %}
+{% block content %}{% include 'partials/customers.html' %} {% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/error.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/error.html
new file mode 100644
index 0000000..e90c7f1
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/error.html
@@ -0,0 +1 @@
+{% extends 'layouts/base.html' %}{% block content %}Return to the previous page, correct the selection, or sign in again if your session expired.
Back to overview {% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/explorer.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/explorer.html
new file mode 100644
index 0000000..0f910b3
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/explorer.html
@@ -0,0 +1,27 @@
+{% extends 'layouts/base.html' %}
+{% block subtitle %}{{ customer }} · Core-provided groups and memberships. Explore a branch, then open a host's recorded playbook activity.
{% endblock %}
+{% block content %}
+← Customers Host list Playbook insights
+CURRENT CORE SNAPSHOT {{ snap.host_count }} distinct hosts / {{ snap.group_count }} groups
Retrieved {{ snap.fetched_at|utc }}
+
+{% if q %}
+{% else %}
+{% for item in crumbs %}{% if not loop.first %}/ {% endif %}{{ item.name }} {% endfor %}
+
+
+
+
□ Group ▣ Host · Select a node to open it − Fit +
+
Groups show up to three direct host links in the map; open a group for all members and subgroups. Links show inventory membership, not network connections or live status.
+
+{% for g in groups %}
□ {{ g.name }} → {{ g.direct|length }} direct · {{ g.members|length }} total distinct hosts · {{ g.children|length }} subgroups
{% for h in g.direct[:3] %}▣ {{ h }} {% else %}{{ 'Hosts are nested in subgroups.' if g.children else 'This group is empty.' }} {% endfor %} Explore group → {% else %}
No subgroups in this branch.
{% endfor %}
+Direct hosts {{ selected.direct|length }}
+
+
+{% endif %}
+Current metadata is read from Core on each request. A host may belong to multiple groups but has one activity page per customer. Opening this page never runs a playbook.
+{% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/hosts.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/hosts.html
new file mode 100644
index 0000000..faae126
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/hosts.html
@@ -0,0 +1,9 @@
+{% extends 'layouts/base.html' %}
+{% block subtitle %}Read-only inventory navigation. Host variables and Vault values are not exposed.
{% endblock %}
+{% block content %}Inventory map & outline Playbook insights
+{% include 'partials/hosts.html' %} {% endblock %}
\ No newline at end of file
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/insights.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/insights.html
new file mode 100644
index 0000000..7b9ad16
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/insights.html
@@ -0,0 +1,15 @@
+{% extends 'layouts/base.html' %}
+{% from 'partials/activity_macros.html' import stamp, outcome, filters, summary %}
+{% block subtitle %}Connect the dots across retained WebGUI jobs. No terminal history collection, live monitoring or background host probes.
{% endblock %}
+{% block content %}
+{{ filters(data,outcome_labels,'/insights') }}
+{{ 'All retained history' if data.filters.days == 'all' else 'Rolling ' + data.filters.days + '-day window' }}. Visibility matches the source jobs: your jobs, or all jobs for an administrator. Deleting a job removes its contribution.
+{{ summary(data,outcome_labels) }}
+
+Latest matching record per customer/host/playbook, within the selected filter. {{ 'Select one mode to avoid mixing check and apply.' if data.filters.mode == 'all' else '' }} Empty cells mean no matching retained result, never success.{% if data.filters.outcome %} An outcome filter is active: this is the latest matching outcome, not necessarily the latest run.{% endif %}{% if data.omitted_columns %} Showing 12 playbooks; {{ data.omitted_columns }} more are available using the playbook filter.{% endif %}
+
+{% for row in data.matrix %}
{{ row.host }} {{ row.customer }} {% for book in data.columns %}{% set cell=row.cells[loop.index0] %}{% endfor %}{% else %}
No retained WebGUI results match this filter.
{% endfor %}
+
+
+
+{% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/job.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/job.html
new file mode 100644
index 0000000..d9c5174
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/job.html
@@ -0,0 +1,7 @@
+{% extends 'layouts/base.html' %}
+{% block content %}
+Summary Progress Targets Reports
+{% include 'partials/job.html' %}
+{% include 'partials/progress.html' %}
+{% include 'partials/reports.html' %}
+{% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/jobs.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/jobs.html
new file mode 100644
index 0000000..c380020
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/jobs.html
@@ -0,0 +1,8 @@
+{% extends 'layouts/base.html' %}{% block content %}
+{{ 'Execution enabled for explicitly allowlisted operations.' if execution else 'Execution disabled. No job will execute in this configuration.' }} Running jobs remain protected from deletion.
+{% include 'partials/attention.html' %}
+{% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/login.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/login.html
new file mode 100644
index 0000000..d3cbb5a
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/login.html
@@ -0,0 +1,12 @@
+{% extends 'layouts/base.html' %}
+{% block subtitle %}Sign in to your controller workspace.
{% endblock %}
+{% block content %}
+
+
LOCAL ACCOUNT
Welcome back
+
01 / FIRST SIGN-IN Built around your existing AIM installation. Retrieve the initial administrator password from the protected .credentials path printed by deployment. The file is local to your controller, not accessible through this website.
First login requires a password change. Your terminal workflow stays independent.
+{% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/overview.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/overview.html
new file mode 100644
index 0000000..3ab7289
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/overview.html
@@ -0,0 +1,9 @@
+{% extends 'layouts/base.html' %}
+{% block subtitle %}Run a reviewed job now. Save a plan only when you need to reuse it.
{% endblock %}
+{% block content %}{% if session.username == 'admin' %}{% endif %}
+AIM 3.3.0rc8 / SERVICE API 1.0 Select. Review. Run once. The core owns inventory, validation, credentials and native execution. This workspace owns your workflow.
New run →
+{% include 'partials/attention.html' %}
+{% for label,value,note in [('Customers',customers|length,'Available to the configured core identity'),('Available operations',playbooks|length,'Union of customer-scoped catalogs'),('Execution', 'Enabled' if execution_enabled and core.execution.enabled else 'Disabled','Both WebGUI policy and core opt-in must allow runs')] %}
{{ label }} {{ value }} {{ note }}
{% endfor %}
+Job Requester Mode Status Created {% for j in recent_jobs %}{{ j.id[:12] }} {{ j.username }} {{ j.mode }} {{ j.display_status|replace('_',' ') }}{{ j.created_at|utc }} {% else %}No jobs yet. Start with New run; a saved plan is not required. {% endfor %}
+{% include 'partials/customers.html' %}
Native inventory authentication Usernames and Vault references retain AIM's normal precedence. Custom credential overrides and raw module output are not exposed. Core 3.2 provides safety-filtered detailed play/task/host progress.
Execution streams detailed play/task/host events plus structured stages and counters. Readiness and live SSH/WinRM acceptance remain separate checks.
Saved plans
+{% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/password.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/password.html
new file mode 100644
index 0000000..9696e0a
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/password.html
@@ -0,0 +1,8 @@
+{% extends 'layouts/base.html' %}
+{% block subtitle %}{% if session.must_change_password %}Replace your temporary password before accessing the workspace.{% else %}Changing your password signs out every active session.{% endif %}
{% endblock %}
+{% block content %}{% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/plan.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/plan.html
new file mode 100644
index 0000000..93e8660
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/plan.html
@@ -0,0 +1,98 @@
+{% extends 'layouts/base.html' %}
+{% block subtitle %}Choose a customer, review explicit hosts and validate optional catalog inputs.
{% endblock %}
+{% block content %}{% if reused %}Loaded a saved plan as a reusable preset. Hosts no longer compatible are not selected. Validate again before saving a new snapshot or submitting a job.
{% endif %}
+
+{% if spec %}
+
+{% endif %}
+{% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/plan_detail.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/plan_detail.html
new file mode 100644
index 0000000..8503cd5
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/plan_detail.html
@@ -0,0 +1,7 @@
+{% extends 'layouts/base.html' %}{% block content %}
+REUSABLE NON-SECRET PLAN
Customer {{ saved.customer }} Playbook {{ saved.playbook }} Targets {{ result.targets|length }} Created {{ saved.created_at|utc }}
+{% if not result.core_request %}
This plan was saved against the old rc18 adapter. Its data is retained. Review it under the new core before running; old credential modes are not reused.
{% endif %}
+
{{ result.targets|join('
+') }} {{ result.overrides|tojson(indent=2) }}
+
Review and run once Loads these selections into New run. A fresh core revision, confirmation and any required one-run credentials are mandatory.
+Delete saved plan {% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/plans.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/plans.html
new file mode 100644
index 0000000..b124f48
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/plans.html
@@ -0,0 +1,7 @@
+{% extends 'layouts/base.html' %}{% block content %}
+Reusable reviewed selections with non-secret inputs only. Host scope is shown directly so you can recognize a plan before opening it.
New run
+
+
+
+
+ {% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/playbooks.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/playbooks.html
new file mode 100644
index 0000000..a3ce0e6
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/playbooks.html
@@ -0,0 +1,3 @@
+{% extends 'layouts/base.html' %}
+{% block subtitle %}Catalog metadata comes directly from AIM. Selecting a playbook does not run it.
{% endblock %}
+{% block content %}{% for p in playbooks %}
{{ p.category }} {{ p.name }} {{ p.description }}
{% for platform in p.platforms %}{{ platform }} {% endfor %}
{{ p.inputs|length }} declared input(s) · {% if p.available is none %}Select a customer to check availability{% elif p.available %}Playbook file present{% else %}Playbook file missing{% endif %}
{% if p.result %}
Report: {{ p.result.schema }} · {{ 'required' if p.result.required else 'optional' }}
{% else %}
No structured report declared.
{% endif %}
Review operation → {% endfor %}
{% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/preflight.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/preflight.html
new file mode 100644
index 0000000..5da5bd3
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/preflight.html
@@ -0,0 +1 @@
+{% extends 'layouts/base.html' %}{% block content %}{% include 'partials/preflight.html' %}{% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/progress.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/progress.html
new file mode 100644
index 0000000..86c8021
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/progress.html
@@ -0,0 +1,7 @@
+{% extends 'layouts/base.html' %}{% block content %}
+← Job detail Reports
+{{ job.plan.customer }} · {{ job.plan.playbook }} · {{ job.mode|upper }}. Chronological retained metadata, not raw console output.
+{% if progress.omitted_events or progress.dropped_events or progress.capture_interrupted %}Coverage is incomplete: {{ progress.omitted_events }} older events omitted; {{ progress.dropped_events }} capture drops. {% if progress.capture_interrupted %}The last uncommitted batch may be absent.{% endif %}
{% endif %}
+{% for row in progress.events %}{{ row.event.timestamp }} {{ row.text }} {% else %}{{ progress.message|default('No recorded events in this page.') }} {% endfor %}
+
+{% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/report.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/report.html
new file mode 100644
index 0000000..cfdd5a3
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/report.html
@@ -0,0 +1,31 @@
+{% extends 'layouts/base.html' %}{% block subtitle %}{{ job.plan.customer }} · {{ item.slot or 'Global report' }} · {{ 'CHECK' if item.check_mode else 'APPLY' }}
{% endblock %}
+{% block content %}
+Summary Progress Targets Reports
+RECORDED OBSERVATION
{{ item.title }} {{ item.status|replace('_',' ') }}
+
Recorded {{ item.recorded_at|utc }} · Job {{ job.id[:12] }} · {{ item.schema_id }}
+
{{ item.note }}
+
Core execution {{ job.core_result.status }} / {{ job.core_result.stage }} / exit {{ job.core_result.exit_code if job.core_result.exit_code is not none else 'not supplied' }} Report slots complete {{ 'Yes' if item.complete else 'No' }} — this is availability, not execution success. Local retention {{ item.retention|replace('_',' ') }}
+{% if item.check_mode %}
Check-mode report. Observations/predicted changes must not be presented as completed installation, patching, export or deletion.
{% endif %}
+
Report host {% for slot in reports.slots %}{{ slot.slot or 'Global report' }} · {{ slot.status }} {% endfor %} Open
+
+{% if item.status != 'available' %}Report {{ item.status|replace('_',' ') }} Core did not supply usable data for this slot. This is not an empty report, a negative detection result or proof that nothing happened.
{% if item.error %}{{ item.error }}{% endif %}
+{% elif item.retention=='not_retained_limit' %}Core supplied an available report, but its content was not retained under the WebGUI byte budget. No truncated payload is presented as complete.
+{% else %}
+{% if item.retention=='metadata_only' %}Core report available; full configuration sections were not retained by WebGUI policy. Only file and redaction metadata are shown. Enabling retention later cannot restore omitted content.
{% endif %}
+{% if view.patch %}{% include 'partials/patch_report.html' %}{% endif %}
+{{ 'Retained metadata' if item.retention=='metadata_only' else 'Report summary' }} Sections show 50 entries per page and at most 40 columns. Long nested cells are marked when shortened; the retained JSON contains the full stored data.
+{% if view.generic %}
Generic renderer for a supported declared schema. No private Core parsing or custom executable renderer is used.
{% endif %}
+{% if view.facts %}
{% for fact in view.facts %}
{{ fact.title }} {{ fact.value|report_cell }} {% endfor %} {% endif %}
+{% if view.is_scalar %}
{{ view.scalar|report_cell }} {% endif %}
+
+{% for section in view.sections %}
+{% if section.note %}{{ section.note }}
{% endif %}
+{% if section.suppressed %}{{ section.empty_text }}
+{% elif section.kind=='object' %}{% for name,val in section.rows %}
{{ name }} {{ val|report_cell }} {% else %}
No entries reported.
{% endfor %}
+{% elif section.headers %}{% for key in section.headers %}{{ key|report_label }} {% endfor %} {% for row in section.rows %}{% for key in section.headers %}{{ row.get(key)|report_cell }} {% endfor %} {% endfor %}
+{% else %}{% for val in section.rows %}{{ val|report_cell }} {% else %}{{ section.empty_text or 'No entries reported.' }} {% endfor %} {% endif %}
+{% if section.previous or section.next %}{% endif %}
+ {% endfor %}
+Show {{ 'retained metadata' if item.retention=='metadata_only' else 'report' }} JSON Loaded on demand from this authorized job. No output is placed in browser storage.
Copy JSON Open to load retained JSON. Open authenticated JSON
+{% endif %}
+{% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/setup.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/setup.html
new file mode 100644
index 0000000..fc90f09
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/setup.html
@@ -0,0 +1,13 @@
+{% extends 'layouts/base.html' %}{% block content %}
+Keep recovery access until the named account has signed in successfully. No AIM or operating-system accounts are created here.
+
+
+
02 / VERIFY & RETIRE
Retire the bootstrap account Sign out, sign in with your new account, replace its temporary password, then return here.
Only a named administrator with a permanent password can disable the bootstrap account. Existing bootstrap sessions are revoked.
+
Disable bootstrap admin
+
Local recovery remains available through aim-web user under the service identity.
+{% endblock %}
\ No newline at end of file
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/system.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/system.html
new file mode 100644
index 0000000..a47090f
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/system.html
@@ -0,0 +1,5 @@
+{% extends 'layouts/base.html' %}{% block content %}
+Controller readiness
+WebGUI {{ system.version }} AIM compatibility {{ system.aim_compatibility|join(', ') }} Schema {{ system.schema }} Listener {{ system.listener }} Public origin {{ system.public_url }} Trusted proxies {{ system.trusted_proxies|join(', ') or 'None' }} Execution {{ 'Opt-in enabled' if system.execution_enabled else 'Disabled' }} Runtime {{ system.runtime }}
+
{% for check in system.checks %}{{ 'PASS' if check.ok else 'CHECK' }} / {{ check.name }} {{ check.detail }}
{% endfor %}
+
{{ system.transport_note }}
{% endblock %}
\ No newline at end of file
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/pages/users.html b/scripts/addons/webgui/src/aim_webgui/templates/pages/users.html
new file mode 100644
index 0000000..9d5275e
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/pages/users.html
@@ -0,0 +1,10 @@
+{% extends 'layouts/base.html' %}
+{% block subtitle %}These accounts grant WebGUI access only. Both roles can read all AIM customers and prepare selections; administrators can also manage WebGUI accounts.
{% endblock %}
+{% block content %}
+
+
+Scoped execution grants Viewer accounts can become operators for explicit customer/playbook pairs. Reading remains controller-wide. Only administrators approve jobs; an administrator cannot approve their own request.
+
{% include 'partials/grant_fields.html' %}
+
{% for g in grants %}{{ g.username }} / {{ g.customer }} / {{ g.playbook }}Revoke {% else %}No scoped grants. Administrators remain subject to the controller allowlist and approval policy. {% endfor %} {% endblock %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/partials/activity_macros.html b/scripts/addons/webgui/src/aim_webgui/templates/partials/activity_macros.html
new file mode 100644
index 0000000..a5bdf54
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/partials/activity_macros.html
@@ -0,0 +1,35 @@
+{% macro stamp(value) %}{% if value is not none %}{{ value|utc }} {% else %}No record {% endif %}{% endmacro %}
+{% macro outcome(value,labels) %} {{ labels.get(value,value|replace('_',' ')) }} {% endmacro %}
+{% macro filters(data,labels,action,host='') %}
+
+{% if host %} {% else %}Customer {% endif %}
+Playbook
+{% for book in data.playbooks %}{% endfor %}
+Mode{% for val,label in [('apply','Apply runs'),('check','Check runs'),('all','Apply + check')] %}{{ label }} {% endfor %}
+Time range{% for val,label in [('7','Last 7 days'),('30','Last 30 days'),('90','Last 90 days'),('365','Last 365 days'),('all','All retained history')] %}{{ label }} {% endfor %}
+Target outcomeAll outcomes {% for val,label in labels.items() %}{{ label }} {% endfor %}
+{% if not host %}Host search {% endif %}
+Apply filters
+ {% endmacro %}
+{% macro summary(data,labels) %}
+
+RETAINED WEBGUI RUNS {{ data.jobs }} Visible jobs in this filter
+TARGET OUTCOMES {{ data.samples }} One host per job = one sample
+SUCCESSFUL / KNOWN {{ data.success_percent|string + '%' if data.success_percent is not none else 'No samples' }} {{ data.counts.successful }} / {{ data.eligible }} succeeded, failed or unreachable
+
+
+Recorded outcomes Not a live health or compliance measurement.
{{ data.filters.mode }}
+{% if data.samples %}
+{% set pos=namespace(x=0) %}{% for val,num in data.counts.items() %}{% if num %}{% set width=num*1000/data.samples %}{{ labels[val] }}: {{ num }} {% set pos.x=pos.x+width %}{% endif %}{% endfor %} {% endif %}
+{% for val,num in data.counts.items() %} {{ labels[val] }} {{ num }} {% endfor %}
+{{ data.changed_participations }} host/run samples reported changed tasks. Unknown, not-started, unavailable and unfinished samples are excluded from the success-rate denominator.
+
+{% endmacro %}
+{% macro records(data,labels) %}
+{% for r in data.records %}
+
+ {{ r.playbook|replace('_',' ') }} Job {{ r.job_id[:12] }} · {{ r.username }} · {{ r.mode }}
{{ stamp(r.recorded_at) }}{% if r.finished_at is none %} · creation time{% endif %}
+ {{ outcome(r.outcome,labels) }}Whole job: {{ r.job_display_status|replace('_',' ') }}
+ {% if r.counts is not none %}Target task counters {% for k,n in r.counts.items() %}
{{ k }} {{ n }} {% endfor %} {% else %}{{ 'This job has not finished.' if r.outcome == 'outstanding' else 'No authoritative per-target result is retained for this record.' }}
{% endif %}
+ {% else %}
No retained runs match Try a wider time range or another mode. Terminal AIM runs and deleted jobs are not collected.
{% endfor %}
+{% endmacro %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/partials/attention.html b/scripts/addons/webgui/src/aim_webgui/templates/partials/attention.html
new file mode 100644
index 0000000..9ad9de2
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/partials/attention.html
@@ -0,0 +1,8 @@
+
+ Needs your attention {{ attention.credentials }} awaiting your credentials{% if attention.approvals %} · {{ attention.approvals }} awaiting your review{% endif %}
{% if attention.total %}
{{ attention.total }} {% endif %}
+ {% if attention['items'] %}{% for item in attention['items'] %}
+
{{ 'Credentials needed' if item.kind == 'credentials' else 'Review requested' }} {{ item.playbook|replace('_',' ') }} {{ item.customer }} · {{ item.target_count }} {{ 'host' if item.target_count == 1 else 'hosts' }} · {{ item.mode }}{% if item.kind == 'approval' %} · {{ item.username }}{% endif %}
{% if item.deadline %}
Reservation ends {{ item.deadline|utc }} {% endif %}
+ {% if item.kind == 'credentials' %}Unlock run {% else %}Review job {% endif %}
+ {% endfor %}
{% else %}No jobs are waiting for your input. Submitted jobs continue normally.
{% endif %}
+ {% if attention.total > attention.shown %}Showing {{ attention.shown }} of {{ attention.total }} actionable jobs. Open Jobs to review the remainder.
{% endif %}
+
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/partials/credential_dialog.html b/scripts/addons/webgui/src/aim_webgui/templates/partials/credential_dialog.html
new file mode 100644
index 0000000..98f1b72
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/partials/credential_dialog.html
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/partials/credential_panel.html b/scripts/addons/webgui/src/aim_webgui/templates/partials/credential_panel.html
new file mode 100644
index 0000000..2aba32a
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/partials/credential_panel.html
@@ -0,0 +1,48 @@
+{% macro secret_field(name, label, hint, required, suffix) %}
+
+
{{ label }} {{ 'Required' if required else 'Optional' }}
+
+
+ Show
+
+
{{ hint }}
+
Caps Lock is on.
+
+{% endmacro %}
+
+
+
+
{{ job.plan.playbook|replace('_', ' ')|capitalize }} {{ job.plan.customer }} · {{ job.plan.targets|length }} {{ 'host' if job.plan.targets|length == 1 else 'hosts' }} · {{ job.mode }}
+
+ Awaiting credentials Until {{ job.credential_deadline|utc }}
+ Your reviewed scope and options stay unchanged.
+
+
+
+ One-run credentials
+ {% if has_vault %}{{ secret_field('vault_password', 'Customer Vault password', 'Unlock the Vault for this customer. This is not your AIM Web sign-in password.', true, 'vault') }}{% endif %}
+ {% if has_connection %}{{ secret_field('connection_password', 'Default connection password', 'Requested by Core. Inventory-defined credentials take precedence; this is not a forced override.', true, 'connection') }}{% endif %}
+ {% if has_key %}
+ SSH key passphrase
+ {% if key_from_vault %}
+
+ {% endif %}
+ {{ secret_field('ssh_key_passphrase', 'Separate SSH key passphrase', 'Leave blank to use the customer Vault value.' if key_from_vault else 'Unlock the encrypted customer private key for this run.', not key_from_vault, 'key') }}
+
+ {% endif %}
+
+ How credentials are used Used only for this job. AIM Web does not save passwords in plans, history or browser storage. Core performs Vault/key validation after the handoff; submission alone does not verify authentication.
Leaving this form does not cancel the job or extend its reservation. A submitted run continues independently.
Job {{ job.id[:12] }} · {{ job.plan.targets|join(', ') }}
+ ◇ One run only. Not saved by AIM Web.
+
+
Submit credentials and continue
+
Not now
+
+ Core validates the credentials next. No automatic retry.
+
+
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/partials/customers.html b/scripts/addons/webgui/src/aim_webgui/templates/partials/customers.html
new file mode 100644
index 0000000..146027a
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/partials/customers.html
@@ -0,0 +1,4 @@
+Customer Hosts Inventory Action
+{% for c in customers %}{{ c.name }} {{ c.host_count if c.host_count is not none else '-' }} {{ c.status }} New run →
+{% else %}No customers found Create or restore customer inventories using the existing AIM terminal application.
{% endfor %}
+
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/partials/error.html b/scripts/addons/webgui/src/aim_webgui/templates/partials/error.html
new file mode 100644
index 0000000..9e63428
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/partials/error.html
@@ -0,0 +1 @@
+{{ error }}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/partials/grant_fields.html b/scripts/addons/webgui/src/aim_webgui/templates/partials/grant_fields.html
new file mode 100644
index 0000000..dbc3fd1
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/partials/grant_fields.html
@@ -0,0 +1,23 @@
+
+ Account
+
+ {% for u in users %}{{ u.username }} {% endfor %}
+
+
+ Customer
+
+ {% for c in customers %}{{ c.name }} {% endfor %}
+
+
+ Playbook
+
+ {% for p in available_playbooks %}{{ p.name }} {% else %}All playbooks are already granted for this account/customer. {% endfor %}
+
+
+
+Grant execution scope
+{% if not available_playbooks %}Revoke an existing scope below before granting it again.
{% endif %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/partials/hosts.html b/scripts/addons/webgui/src/aim_webgui/templates/partials/hosts.html
new file mode 100644
index 0000000..a814397
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/partials/hosts.html
@@ -0,0 +1,5 @@
+Host Address Memberships
+{% for h in hosts %}{{ h.name }} {{ h.address or '-' }}{% for g in h.groups %}{{ g }} {% else %}No group membership {% endfor %}
+{% else %}No matching hosts. {% endfor %}
+
+{% if previous_url %}
Previous 100 {% endif %}{% if next_url %}
Next 100 {% endif %}
\ No newline at end of file
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/partials/job.html b/scripts/addons/webgui/src/aim_webgui/templates/partials/job.html
new file mode 100644
index 0000000..ca82a94
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/partials/job.html
@@ -0,0 +1,15 @@
+
+
{{ job.id[:12] }} {{ 'Awaiting credentials' if job.credential_phase == 'waiting' and job.status == 'running' else job.display_status|replace('_',' ') }}Requester {{ job.username }} Customer / playbook {{ job.plan.customer }} / {{ job.plan.playbook }} {% if job.plan.authentication %}Authentication Native inventory{% if job.plan.core_request %} / {{ job.plan.core_request.key_mode }}{% endif %}{% if job.plan.authentication.username %} / {{ job.plan.authentication.username }}{% endif %} {% endif %}Mode {{ job.mode }} Targets {{ job.plan.targets|length }} Not before {{ job.scheduled_at|utc }} Outcome {{ job.reason or 'Waiting' }}
+{% if job.core_result and job.core_result.stage == "result_validation" %}
Native execution completed, but the required operation report could not be validated. Review report availability below. No automatic retry was made.
{% endif %}
+{% if job.core_result %}
Core result Status: {{ job.core_result.status }} / stage: {{ job.core_result.stage }} / exit: {{ job.core_result.exit_code if job.core_result.exit_code is not none else 'not supplied' }}
Remote work may have started: {{ 'yes' if job.core_result.remote_work_may_have_started else 'no, according to core' }}.
{% if job.core_result.counts %}{% for name,value in job.core_result.counts.items() %}{{ name }}={{ value }} {% endfor %}
{% endif %}
+{% if job.core_result.target_summary %}
Target outcomes Core-owned final host accounting
{{ job.display_status|replace('_',' ') }}{{ job.core_result.target_summary.successful }} successful{{ job.core_result.target_summary.failed }} failed{{ job.core_result.target_summary.unreachable }} unreachable{{ job.core_result.target_summary.not_started }} not started{{ job.core_result.target_summary.indeterminate }} indeterminate
{% if job.core_result.targets %}Host Outcome Counts {% for target in job.core_result.targets %}{{ target.host }} {{ target.outcome|replace('_',' ') }}{% for name,value in target.counts.items() %}{{ name }}={{ value }}{% if not loop.last %} · {% endif %}{% endfor %} {% endfor %}
{% endif %}{% endif %}
+{% endif %}
Reviewed targets and non-secret options {{ job.plan.targets|join('
+') }} {{ job.plan.overrides|tojson(indent=2) }}
+{% if job.status == 'running' and job.credential_phase == 'waiting' %}
+
This worker slot is reserved until
{{ job.credential_deadline|utc }} . No password has been stored.
+{% if job.owner_id == session.user_id %}
Unlock this run {% else %}
The requesting account must supply credentials; approval does not grant access to their secrets.
{% endif %}
+{% endif %}
+
{% for e in job.events %}{{ e.occurred_at|utc }} {{ e.event }}
{% endfor %}
+{% if job.status == 'pending' and session.role == 'admin' and session.user_id != job.owner_id %}
Approve reviewed job {% endif %}
+{% if job.status in ['pending','queued','running'] %}Request cancellation {% endif %}{% if job.status == 'failed' and job.owner_id == session.user_id %}Retry as new job {% endif %}{% if job.status in ['successful','failed','canceled','timed_out','blocked','interrupted'] %}Delete job history {% endif %}
+
Approved progress metadata and retained operation reports stay with the job. Raw stdout/stderr and credentials are never stored. Completed remote changes are never automatically rolled back.
\ No newline at end of file
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/partials/navigation.html b/scripts/addons/webgui/src/aim_webgui/templates/partials/navigation.html
new file mode 100644
index 0000000..a7cf553
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/partials/navigation.html
@@ -0,0 +1,17 @@
+{% macro links(nav, session) %}
+
+{% for key,href,label,symbol in [('overview','/','Overview','01'),('plan','/plan','New run','02'),('jobs','/jobs','Jobs','03'),('plans','/plans','Saved plans','04'),('customers','/customers','Inventory explorer','05'),('insights','/insights','Playbook insights','06'),('playbooks','/playbooks','Playbook catalog','07')] %}
+{{ symbol }} {{ label }}
+{% endfor %}
+{% if session and session.role == 'admin' %}
+{% for key,href,label in [('users','/users','Users & access'),('setup','/setup','Named administrator'),('audit','/audit','Audit history'),('system','/system','System & diagnostics')] %}
+{{ label }} {% endfor %}{% endif %}
+ {% endmacro %}
+{% macro theme() %}
+☀
+☾
+
{% endmacro %}
+{% macro account(session,csrf) %}{% if session and session.user_id %}
+{{ session.username }} {{ session.role }}
+Sign out
+{% else %}Authorized operators only {% endif %}{% endmacro %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/partials/patch_report.html b/scripts/addons/webgui/src/aim_webgui/templates/partials/patch_report.html
new file mode 100644
index 0000000..98e6f70
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/partials/patch_report.html
@@ -0,0 +1,14 @@
+
+
+
RECORDED PATCH POLICY AND OUTCOME
+
{{ 'Windows patch wave' if view.patch.windows else 'Linux patch and reboot observations' }}
+
Execution status, installed-update evidence, reboot state and next-wave needs are separate facts. This report does not change the job verdict or authorize another operation.
+ {% for notice in view.patch.notices %}
+
+
{{ notice.title }} {{ notice.text }}
+ {% if notice.code %}
{{ notice.code }}{% endif %}
+
+ {% endfor %}
+ {% if view.patch.windows %}
Core owns the sequential update queue and its reboot boundary. Reboot permission is not post-reboot continuation permission. A follow-up needs a fresh reviewed request and one-run credentials; this page never submits one.
{% endif %}
+
+
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/partials/preflight.html b/scripts/addons/webgui/src/aim_webgui/templates/partials/preflight.html
new file mode 100644
index 0000000..c3db49c
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/partials/preflight.html
@@ -0,0 +1,25 @@
+
+
CORE VALIDATED / REVIEW BEFORE RUNNING
Ready for your review
+
Customer {{ result.customer }} Playbook {{ result.playbook }} Targets {{ result.targets|length }} Mode {{ 'Check' if result.core_request.check else 'Apply' }} SSH key handling {{ result.core_request.key_mode }} Credential requirements {{ result.credential_requirements|join(', ') or 'None reported' }} Required collections {{ result.required_collections|join(', ') or 'None declared' }}
+{% if result.result_contract %}
Expected operation report {{ result.result_contract.schema }} · {{ result.result_contract.scope|replace('_',' ') }} · {{ 'Required' if result.result_contract.required else 'Optional' }}
Reports arrive at finalization and are retained with this job. Native execution success and report availability are separate. Parsed Checkmk configuration sections require an explicit retention opt-in.
{% else %}
No structured operation report is declared for this playbook.
{% endif %}
+{% if result.result_contract and result.result_contract.schema=='patch_summary_v1' %}
+
Review patch and reboot scope
+Automatic reboot {% if 'os_patching_reboot' in result.overrides %}{{ 'Enabled' if result.overrides.os_patching_reboot else 'Disabled' }} (explicit override){% else %}Inherited from inventory / role defaults{% endif %}
+Continue patching after reboot (Windows) {% if 'os_patching_rescan_after_reboot' in result.overrides %}{{ 'Enabled' if result.overrides.os_patching_rescan_after_reboot else 'Disabled' }} (explicit override){% else %}Inherited from inventory / role defaults; not enabled by WebGUI{% endif %}
+Reboot delay (minutes) {% if 'os_patching_reboot_delay_minutes' in result.overrides %}{{ result.overrides.os_patching_reboot_delay_minutes }} (explicit override){% else %}Inherited from inventory / role defaults{% endif %}
+An enabled Windows continuation option allows Core to rediscover and install additional waves after an AIM-performed reboot, within its bounded run. It is not a new WebGUI job. With continuation disabled, a later wave requires a fresh reviewed run. Reboot permission alone does not opt in to continuation. Exact overrides, including any reboot message, follow below.
+{% endif %}
+
Exact targets and options {{ result.targets|join('
+') }} {{ result.overrides|tojson(indent=2) }}
+
Core warnings and review revision {% for warning in result.warnings %}{{ warning }} {% endfor %} {{ result.core_revision }}
{{ result.revision_coverage }}
+
Core validates the selection. Local readiness and credentials are checked when the worker claims the job. Review does not prove connectivity, authentication, or installed runtime readiness.
+{% if result.review_id %}
+
+Schedule later (optional) Run once at (UTC) Leave blank for the next available slot. Maximum 30 days; credentials are collected only when the worker is ready.
+I reviewed the explicit scope and change risk. Cancellation does not undo completed changes.
+{{ 'Submit one-run job for approval' if require_approval else 'Run once' }}
+Change selection
+{% if not execution_enabled %}WebGUI execution is disabled. You may save this plan or continue in AIM terminal.
{% endif %}
+
+
+{% else %} {% endif %}
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/partials/progress.html b/scripts/addons/webgui/src/aim_webgui/templates/partials/progress.html
new file mode 100644
index 0000000..20a1f33
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/partials/progress.html
@@ -0,0 +1,21 @@
+
+
+
+
+ Stage: {{ progress.checkpoint.stage|default('Not observed') }}
+ Most recent task: {{ progress.checkpoint.last_task.label if progress.checkpoint.last_task else 'Not observed' }}
+ Recorded task starts: {{ progress.checkpoint.observed_task_starts|default(0) }}
+ Last activity: {{ progress.checkpoint.last_timestamp|default('Not observed') }}
+
+
Observed metadata only, not a complete task plan or completion percentage. Multiple hosts/tasks may interleave; a quiet task is not proof of a stalled run.
+
Latest recorded host observations {% for h in progress.checkpoint.hosts|default([]) %}{% if loop.index<=25 %}{{ h.host }} — {{ h.observation }} ({{ h.task_id }}) {% endif %}{% endfor %} Observations are not final host outcomes. The Targets section uses Core's authoritative final stats.
+
+ {% if progress.omitted_events %}{{ progress.omitted_events }} older events omitted by the retention limit. {% endif %}{% if progress.dropped_events %}{{ progress.dropped_events }} events were not recorded due to capture pressure. {% endif %}{% if progress.capture_interrupted %}Capture did not close cleanly; the last uncommitted batch may be absent.{% endif %}
+
+
+
{% for row in progress.events %}{{ row.event.timestamp }} {{ row.text }}
+{% else %}{{ progress.message|default('No progress metadata recorded yet.') }}{% endfor %}
+
+
diff --git a/scripts/addons/webgui/src/aim_webgui/templates/partials/reports.html b/scripts/addons/webgui/src/aim_webgui/templates/partials/reports.html
new file mode 100644
index 0000000..1d6e9ff
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/templates/partials/reports.html
@@ -0,0 +1,9 @@
+
+CORE-DECLARED OPERATION DATA
Reports {% if reports.recorded_at %}
{{ reports.recorded_at|utc }} {% endif %}
+{% if not reports.recorded %}
{% if reports.job_status in ['pending','queued','running'] %}Reports are available when Core finalizes. Progress metadata above is recorded while running.{% else %}No final operation report was captured. Historical jobs are not reconstructed or rerun.{% endif %}
+{% elif not reports.declared %}
No structured report was declared for this playbook.
+{% else %}
{{ reports.title }} · {{ reports.schema }} · {{ 'Required' if reports.required else 'Optional' }}
+
Report availability is separate from execution success. {{ 'All declared slots are available.' if reports.complete else 'Some report slots are unavailable or incomplete.' }} All observations retain their recorded mode and date.
+{% if reports.schema=='patch_summary_v1' %}
A successful patch run may still need a reboot or another reviewed patch wave. Open each host report for its recorded continuation, remaining-update knowledge and failure details; nothing is scheduled automatically.
{% endif %}
+
+{% endif %}
diff --git a/scripts/addons/webgui/src/aim_webgui/worker.py b/scripts/addons/webgui/src/aim_webgui/worker.py
new file mode 100644
index 0000000..fc60c03
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/worker.py
@@ -0,0 +1,340 @@
+"""Single-controller, single-job worker. Never run inside an HTTP request.
+
+Credential hand-offs remain one-run and memory-only. Approved public progress
+metadata and final declared reports are retained with their job. Raw output is
+never captured; progress recording does not depend on an open browser.
+"""
+from __future__ import annotations
+
+import fcntl
+from datetime import datetime, timezone
+import json
+import os
+from pathlib import Path
+import signal
+import socket
+import resource
+import subprocess
+import sys
+import time
+
+from aim_webgui.adapters.core_v1 import CoreAdapter
+from aim_webgui.core.client import CoreClient
+import threading
+from aim_webgui.auth.service import Auth
+from aim_webgui.config import Settings
+from aim_webgui.errors import WebError
+from aim_webgui.db.store import audit, ensure_private_dir
+from aim_webgui.workflows import Workflows, allowed, event
+from aim_webgui.credentials import wire
+from aim_webgui.credentials.service import fields, valid_session, eligible, HANDOFF_SECONDS, WAIT_SECONDS
+
+
+def revalidate(flow, row):
+ plan = json.loads(row['plan'])
+ with flow.store.read() as db:
+ allowed(db, row['owner_id'], plan['customer'], plan['playbook'])
+ if flow.settings.execution_require_approval:
+ if not row['approver_id'] or row['approver_id'] == row['owner_id']:
+ raise WebError('approval_required', 'Independent administrator approval is required.', 403)
+ Auth.require_admin(db, row['approver_id'])
+ flow.execution_policy(plan)
+ current = CoreAdapter(flow.settings).reprepare(plan)
+ flow.assert_revision(plan, current)
+ if plan.get("authentication"):
+ current["authentication"] = plan["authentication"]
+ return current
+
+
+def execute_claimed(settings, ident, packet=None):
+ flow=Workflows(settings)
+ with flow.store.read() as db:
+ row=db.execute("SELECT * FROM jobs WHERE id=? AND status='running'",(ident,)).fetchone()
+ if not row or row['cancel_requested']:raise WebError('job_not_claimed','The worker no longer owns this job.',409)
+ plan=revalidate(flow,row)
+ requirements=plan['credential_requirements']
+ secrets={};deadline=None
+ if requirements:
+ if (not isinstance(packet,dict) or packet.get('job')!=ident or packet.get('user')!=row['owner_id']
+ or packet.get('deadline',0)<=time.monotonic()):
+ raise WebError('credential_expired','The one-run hand-off expired; review a new attempt.',409)
+ valid_session(flow,packet['session'],row['owner_id'])
+ secrets=fields(packet['credentials'],requirements);packet['credentials'].clear();deadline=packet['deadline']
+ from aim_webgui.journal import Capture
+ from aim_webgui.reports import persist
+ capture=Capture(settings,ident,plan['targets'])
+ cancelled=threading.Event();old_handlers={};execution_seen=False;last_stage=None
+ for sig in (signal.SIGINT,signal.SIGTERM):
+ old_handlers[sig]=signal.signal(sig,lambda *_:cancelled.set())
+ def on_event(value):
+ nonlocal execution_seen,last_stage
+ if value is None:return
+ capture.submit(value)
+ if value.get('kind')=='stage':
+ stage=value['stage']
+ if requirements and not execution_seen:valid_session(flow,packet['session'],row['owner_id'])
+ if stage=='execution':execution_seen=True
+ if stage!=last_stage:
+ with flow.store.transaction() as db:
+ db.execute("UPDATE jobs SET credential_phase=? WHERE id=?",('executing' if execution_seen else 'preparing',ident))
+ event(db,ident,'Core stage: '+stage+'.')
+ last_stage=stage
+ try:
+ # Last add-on authorization/revision check before native core execution.
+ with flow.store.read() as db:fresh=db.execute('SELECT * FROM jobs WHERE id=?',(ident,)).fetchone()
+ revalidate(flow,fresh)
+ if fresh['cancel_requested']:raise WebError('core_cancelled','Canceled before core execution.',409)
+ result=CoreClient(settings).request('execute',request=plan['core_request'],expected_revision=plan['core_revision'],
+ credentials=secrets,event_sink=on_event,cancel=cancelled,deadline=deadline,
+ result_contract=plan.get('result_contract'))
+ capture.close()
+ with flow.store.transaction() as db:
+ result=persist(db,settings,ident,plan,result)
+ db.execute('UPDATE jobs SET core_result=?,return_code=?,reason=? WHERE id=?',
+ (json.dumps(result,sort_keys=True),result.get('exit_code'),
+ result['error']['message'] if result.get('error') else 'Core reported '+result['status']+'.',ident))
+ if result['status']!='succeeded':
+ raise WebError('core_execution_failed',result['error']['message'] if result.get('error') else 'Core execution did not succeed.',409)
+ except WebError as exc:
+ with flow.store.transaction() as db:
+ old=db.execute('SELECT core_result FROM jobs WHERE id=?',(ident,)).fetchone()
+ if old and not old[0]:
+ # An outer core error is pre-launch; transport loss is conservatively unknown.
+ known=getattr(exc,'core_error',None)
+ outcome={'status':'failed' if known else 'unknown','stage':known['stage'] if known else 'transport',
+ 'remote_work_may_have_started':execution_seen or known is None,
+ 'error':known or {'code':exc.code,'message':exc.message}}
+ db.execute('UPDATE jobs SET core_result=? WHERE id=?',(json.dumps(outcome),ident))
+ raise
+ finally:
+ secrets.clear();capture.close()
+ for sig,handler in old_handlers.items():signal.signal(sig,handler)
+
+
+def stop_group(process):
+ try:
+ os.killpg(process.pid, signal.SIGTERM)
+ except ProcessLookupError:
+ return
+ try:
+ process.wait(timeout=20)
+ except subprocess.TimeoutExpired:
+ os.killpg(process.pid, signal.SIGKILL)
+ process.wait()
+ # Kill any remaining descendants after the leader exits; no remote rollback.
+ try:
+ os.killpg(process.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+
+
+class Worker:
+ def __init__(self, settings: Settings, config: Path):
+ self.settings, self.config = settings, config
+ self.flow = Workflows(settings)
+ self.stopping = False
+
+ def heartbeat(self):
+ with self.flow.store.transaction() as db:
+ db.execute("INSERT INTO metadata VALUES('worker_heartbeat',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
+ (str(int(time.time())),))
+
+ def finish(self, ident, status, reason, code=None):
+ with self.flow.store.transaction() as db:
+ db.execute("UPDATE jobs SET status=?,finished_at=?,return_code=?,reason=?,credential_phase='released',credential_deadline=NULL WHERE id=?",
+ (status, int(time.time()), code, reason, ident))
+ event(db, ident, reason)
+ audit(db, 'worker', 'job-' + status, ident)
+
+ def wait_credentials(self, row):
+ """The worker owns the serialized slot before any secret may be posted."""
+ ident = row['id']
+ plan = json.loads(row['plan'])
+ path = self.settings.state_dir / '.credential.sock'
+ listener = wire.listen(path)
+ deadline = int(time.time()) + WAIT_SECONDS
+ monotonic_deadline = time.monotonic() + WAIT_SECONDS
+ with self.flow.store.transaction() as db:
+ db.execute("UPDATE jobs SET credential_phase='waiting',credential_deadline=?,started_at=NULL WHERE id=?", (deadline, ident))
+ event(db, ident, 'Worker ready: requesting account may submit credentials within five minutes. No secrets are stored while waiting.')
+ try:
+ while time.monotonic() < monotonic_deadline:
+ self.heartbeat()
+ with self.flow.store.read() as db:
+ fresh = db.execute('SELECT * FROM jobs WHERE id=?', (ident,)).fetchone()
+ if fresh['cancel_requested'] or self.stopping:
+ raise WebError('credential_cancelled', 'Credential wait canceled before execution.', 409)
+ try:
+ conn, _ = listener.accept()
+ except socket.timeout:
+ continue
+ with conn:
+ conn.settimeout(2)
+ packet = None
+ try:
+ wire.peer(conn)
+ packet = wire.receive(conn, wire.SECRET_LIMIT)
+ if set(packet) != {'job','user','session','credentials'} or packet['job'] != ident or packet['user'] != row['owner_id']:
+ raise WebError('credential_scope', 'Credential hand-off scope mismatch.', 403)
+ eligible(self.flow, row['owner_id'], ident)
+ valid_session(self.flow, packet['session'], row['owner_id'])
+ revalidate(self.flow, fresh)
+ packet['credentials'] = fields(packet['credentials'], plan['credential_requirements'])
+ packet['deadline'] = time.monotonic() + HANDOFF_SECONDS
+ with self.flow.store.transaction() as db:
+ changed = db.execute("UPDATE jobs SET credential_phase='claimed',credential_deadline=?,started_at=? WHERE id=? AND status='running' AND cancel_requested=0 AND credential_phase='waiting'",
+ (int(time.time()) + HANDOFF_SECONDS, int(time.time()), ident)).rowcount
+ if changed != 1:
+ raise WebError('credential_race', 'Credential hand-off no longer available.', 409)
+ event(db, ident, 'Single-run credential hand-off claimed; never stored in job records.')
+ wire.send(conn, {'accepted': True})
+ return packet
+ except (WebError, ValueError, KeyError, OSError, TypeError):
+ if packet:
+ packet.clear()
+ try:
+ wire.send(conn, {'accepted': False})
+ except OSError:
+ pass
+ raise WebError('credential_wait_expired', 'No credentials supplied before the worker reservation expired. Submit a new job.', 409)
+ finally:
+ listener.close()
+ path.unlink(missing_ok=True)
+
+ def tick(self):
+ self.heartbeat()
+ if not self.settings.execution_enabled:
+ return False
+ hour = datetime.now(timezone.utc).hour
+ if not self.settings.execution_window_start_hour <= hour < self.settings.execution_window_end_hour:
+ return False
+ with self.flow.store.transaction() as db:
+ row = db.execute("SELECT * FROM jobs WHERE status='queued' AND scheduled_at<=? ORDER BY scheduled_at,created_at LIMIT 1",
+ (int(time.time()),)).fetchone()
+ if not row:
+ return False
+ row = dict(row)
+ db.execute("UPDATE jobs SET status='running',started_at=? WHERE id=?", (int(time.time()), row['id']))
+ event(db, row['id'], 'Worker claimed job; rechecking approval, permissions and source revisions.')
+ ident = row['id']
+ try:
+ checked = revalidate(self.flow, row)
+ CoreAdapter(self.settings).readiness(checked)
+ except WebError as exc:
+ self.finish(ident, 'blocked', exc.message)
+ return True
+ packet = None
+ if json.loads(row['plan']).get('credential_requirements'):
+ try:
+ packet = self.wait_credentials(row)
+ except (WebError, OSError) as exc:
+ reason = exc.message if isinstance(exc, WebError) else 'Credential listener unavailable; no credentials saved.'
+ self.finish(ident, 'canceled' if getattr(exc, 'code', '') == 'credential_cancelled' else 'blocked', reason)
+ return True
+ # No credentials in argv, environment, stdout, stderr, job log or database.
+ # This environment excludes inherited proxy/password/Ansible overrides.
+ env = {'PATH': '/usr/bin:/bin', 'HOME': str(self.settings.state_dir), 'LANG': 'C.UTF-8',
+ 'PYTHONDONTWRITEBYTECODE': '1', 'PYTHONNOUSERSITE': '1'}
+ command = [sys.executable, '-B', '-m', 'aim_webgui.worker', str(self.config), ident]
+ try:
+ process = subprocess.Popen(command, stdin=subprocess.PIPE if packet else subprocess.DEVNULL, stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL, env=env, start_new_session=True)
+ except OSError:
+ if packet: packet.clear()
+ self.finish(ident, 'failed', 'Worker process could not start.')
+ return True
+ start = time.monotonic()
+ try:
+ if packet:
+ try:
+ process.stdin.write(json.dumps(packet,ensure_ascii=False).encode('utf-8'))
+ process.stdin.close()
+ except (BrokenPipeError, OSError):
+ pass
+ finally:
+ packet['credentials'].clear()
+ packet.clear()
+ while process.poll() is None:
+ with self.flow.store.read() as db:
+ cancel = db.execute('SELECT cancel_requested FROM jobs WHERE id=?', (ident,)).fetchone()[0]
+ if cancel or self.stopping:
+ stop_group(process)
+ self.finish(ident, 'interrupted' if self.stopping else 'canceled',
+ 'Execution stopped. Completed remote changes were not rolled back.', process.returncode)
+ return True
+ if time.monotonic() - start >= self.settings.execution_timeout_seconds + 200:
+ stop_group(process)
+ self.finish(ident, 'timed_out', 'Execution timed out. Completed remote changes were not rolled back.', process.returncode)
+ return True
+ self.heartbeat()
+ time.sleep(.5)
+ code = process.returncode
+ with self.flow.store.read() as db:
+ completed=db.execute('SELECT core_result,return_code FROM jobs WHERE id=?',(ident,)).fetchone()
+ native_code=completed['return_code'] if completed else None
+ self.finish(ident, 'successful' if code == 0 else 'failed',
+ 'AIM core reported success with final counters.' if code == 0 else self.child_failure(ident), native_code)
+ finally:
+ stop_group(process)
+ return True
+
+ def child_failure(self, ident):
+ with self.flow.store.read() as db:
+ row = db.execute('SELECT reason FROM jobs WHERE id=?', (ident,)).fetchone()
+ return row['reason'] or 'AIM run failed. Inspect prerequisites in the terminal; raw output is not retained.'
+
+ def run(self, *, once=False):
+ resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
+ ensure_private_dir(self.settings.state_dir)
+ self.flow.store.check()
+ fd = os.open(self.settings.state_dir / '.worker.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
+ try:
+ try:
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except BlockingIOError:
+ raise ValueError('Another worker owns this controller queue.') from None
+ # Crash recovery NEVER retries a job that might already have changed hosts.
+ with self.flow.store.transaction() as db:
+ for row in db.execute("SELECT id FROM jobs WHERE status='running'").fetchall():
+ db.execute("UPDATE jobs SET status='interrupted',finished_at=?,reason=?,credential_phase='released',credential_deadline=NULL WHERE id=?",
+ (int(time.time()), 'Worker restarted; execution outcome requires terminal review.', row['id']))
+ event(db, row['id'], 'Interrupted job was not automatically retried.')
+ previous = {}
+ for sig in (signal.SIGTERM, signal.SIGINT):
+ previous[sig] = signal.signal(sig, lambda *_: setattr(self, 'stopping', True))
+ try:
+ while not self.stopping:
+ self.tick()
+ if once:
+ break
+ time.sleep(1)
+ finally:
+ for sig, handler in previous.items():
+ signal.signal(sig, handler)
+ finally:
+ os.close(fd)
+
+
+if __name__ == '__main__':
+ os.umask(0o077)
+ sys.dont_write_bytecode = True
+ try:
+ resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
+ config = Settings.load(Path(sys.argv[1]))
+ # stdin is an anonymous pipe for credential jobs, /dev/null otherwise.
+ data = sys.stdin.buffer.read(wire.SECRET_LIMIT + 1)
+ if len(data) > wire.SECRET_LIMIT:
+ raise ValueError('Credential packet limit.')
+ packet = json.loads(data) if data else None
+ execute_claimed(config, sys.argv[2], packet)
+ except WebError as exc:
+ # Only our fixed, sanitized WebError messages. Never raw subprocess exceptions.
+ try:
+ with Workflows(config).store.transaction() as db:
+ db.execute('UPDATE jobs SET reason=? WHERE id=?', (exc.message, sys.argv[2]))
+ except Exception:
+ pass
+ raise SystemExit(1) from None
+ except Exception:
+ # Do not dump exceptions which can contain YAML inventory or decrypted data.
+ raise SystemExit(1) from None
diff --git a/scripts/addons/webgui/src/aim_webgui/workflows.py b/scripts/addons/webgui/src/aim_webgui/workflows.py
new file mode 100644
index 0000000..7db42f0
--- /dev/null
+++ b/scripts/addons/webgui/src/aim_webgui/workflows.py
@@ -0,0 +1,448 @@
+"""Add-on-owned workflow records; never change AIM's source or inventory.
+
+All job authorization is checked again in the worker. A queued job is not a
+permission token. No request bodies, credentials or Ansible output are audited.
+"""
+from __future__ import annotations
+
+import json
+import time
+import uuid
+import hashlib
+import re
+import unicodedata
+from datetime import datetime, timezone
+from aim_webgui.names import name_key, suggested_name
+
+from aim_webgui.auth.service import Auth, HASHER, password_policy, username
+from aim_webgui.config import Settings
+from aim_webgui.db.store import Store, audit
+from aim_webgui.errors import WebError
+from aim_webgui.adapters.core_v1 import CoreAdapter
+
+TERMINAL = frozenset({'successful', 'failed', 'canceled', 'timed_out', 'blocked', 'interrupted'})
+
+
+def presentation_status(status, core_result):
+ if status != 'failed' or not isinstance(core_result, dict):
+ return status
+ summary = core_result.get('target_summary')
+ if not isinstance(summary, dict) or summary.get('schema') != 'target_outcome_summary_v1':
+ return status
+ requested = summary.get('requested')
+ successful = summary.get('successful')
+ if type(requested) is not int or type(successful) is not int or requested <= 0:
+ return status
+ unsuccessful = sum(summary.get(key, 0) for key in ('failed','unreachable','not_started','indeterminate')
+ if type(summary.get(key, 0)) is int)
+ if successful > 0 and successful < requested and unsuccessful > 0:
+ return 'partially_succeeded'
+ return status
+
+
+def actor(db, user_id: int):
+ user = db.execute('SELECT * FROM users WHERE id=?', (user_id,)).fetchone()
+ if not user or not user['enabled'] or user['must_change_password']:
+ raise WebError('account_unavailable', 'An enabled account with a permanent password is required.', 403)
+ return user
+
+
+def allowed(db, user_id: int, customer: str, playbook: str):
+ user = actor(db, user_id)
+ if user['role'] == 'admin' or db.execute(
+ 'SELECT 1 FROM grants WHERE user_id=? AND customer=? AND playbook=?',
+ (user_id, customer, playbook)).fetchone():
+ return user
+ raise WebError('execution_forbidden', 'This account has no execution grant for this customer and playbook.', 403)
+
+
+def event(db, job_id: str, message: str):
+ db.execute('INSERT INTO job_events(job_id,occurred_at,event) VALUES(?,?,?)',
+ (job_id, int(time.time()), message))
+
+
+def serialized(value):
+ return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(',', ':'))
+
+
+class Workflows:
+ def __init__(self, settings: Settings):
+ self.settings = settings
+ self.store = Store(settings.database)
+
+ def record(self, user_id: int, action: str, subject: str):
+ with self.store.transaction() as db:
+ user = actor(db, user_id)
+ audit(db, user['username'], action, subject)
+
+ def validated_plan(self, customer, playbook, targets, overrides, *, text_inputs=False, check=True, key_mode='none'):
+ return CoreAdapter(self.settings).preflight(customer,playbook,targets,overrides,
+ text_inputs=text_inputs,check=check,key_mode=key_mode)
+
+ def review(self, user_id, customer, playbook, targets, overrides, *, text_inputs=False, check=True, key_mode='none'):
+ with self.store.read() as db: actor(db,user_id)
+ plan=self.validated_plan(customer,playbook,targets,overrides,text_inputs=text_inputs,check=check,key_mode=key_mode)
+ ident=uuid.uuid4().hex
+ now=int(time.time())
+ plan['suggested_name']=suggested_name(playbook)
+ with self.store.transaction() as db:
+ who=actor(db,user_id)
+ db.execute('DELETE FROM run_reviews WHERE expires_at',(now,))
+ if db.execute('SELECT COUNT(*) FROM run_reviews WHERE owner_id=?',(user_id,)).fetchone()[0]>=100:
+ db.execute('DELETE FROM run_reviews WHERE id=(SELECT id FROM run_reviews WHERE owner_id=? ORDER BY created_at LIMIT 1)',(user_id,))
+ db.execute('INSERT INTO run_reviews VALUES(?,?,?,?,?)',(ident,user_id,serialized(plan),now,now+1800))
+ audit(db,who['username'],'run-reviewed',ident)
+ return {**plan,'review_id':ident}
+
+ def review_record(self,user_id,ident):
+ with self.store.read() as db:
+ actor(db,user_id)
+ row=db.execute('SELECT * FROM run_reviews WHERE id=? AND owner_id=?',(ident,user_id)).fetchone()
+ if not row or row['expires_at']100 or any(unicodedata.category(c).startswith('C') for c in name):
+ raise WebError('invalid_plan_name','Use up to 100 printable characters; leave blank for an automatic unique name.')
+ ident=uuid.uuid4().hex
+ with self.store.transaction() as db:
+ user=actor(db,user_id)
+ rows=db.execute('SELECT name FROM plans WHERE owner_id=?',(user_id,)).fetchall()
+ if len(rows)>=100:raise WebError('plan_limit','Delete an old saved plan before creating another (100 per account).',409)
+ existing={name_key(row['name']) for row in rows}
+ if explicit and name_key(name) in existing:
+ raise WebError('plan_name_exists','A plan with this title already exists in your account. Choose another title; nothing was overwritten.',409)
+ while name_key(name) in existing:name=suggested_name(plan['playbook'])
+ db.execute('INSERT INTO plans(id,owner_id,name,customer,playbook,payload,created_at,name_key) VALUES(?,?,?,?,?,?,?,?)',
+ (ident,user_id,name,plan['customer'],plan['playbook'],serialized(plan),int(time.time()),name_key(name)))
+ audit(db,user['username'],'plan-created',ident)
+ return ident
+
+ def plans(self, user_id):
+ with self.store.read() as db:
+ actor(db, user_id)
+ rows=list(db.execute('SELECT id,name,customer,playbook,payload,created_at FROM plans WHERE owner_id=? ORDER BY created_at DESC',(user_id,)))
+ result=[]
+ for row in rows:
+ item=dict(row); payload=json.loads(item.pop('payload'))
+ targets=payload.get('targets',[]) if isinstance(payload,dict) else []
+ item['target_count']=len(targets); item['target_preview']=targets[:2]
+ result.append(item)
+ return result
+
+ def plan(self, user_id, ident):
+ with self.store.read() as db:
+ actor(db, user_id)
+ row = db.execute('SELECT * FROM plans WHERE id=? AND owner_id=?', (ident, user_id)).fetchone()
+ if not row:
+ raise WebError('plan_not_found', 'Saved plan not found for this account.', 404)
+ result = dict(row)
+ result['payload'] = json.loads(result['payload'])
+ return result
+
+ def delete_plan(self, user_id, ident):
+ self.delete_plans(user_id, [ident])
+
+ def delete_plans(self, user_id, identifiers):
+ if (not isinstance(identifiers, list) or not 1 <= len(identifiers) <= 100
+ or len(set(identifiers)) != len(identifiers)
+ or any(not isinstance(ident, str) or len(ident) != 32 for ident in identifiers)):
+ raise WebError('invalid_plan_selection', 'Select between 1 and 100 saved plans to delete.')
+ with self.store.transaction() as db:
+ user = actor(db, user_id)
+ placeholders = ','.join('?' for _ in identifiers)
+ rows = list(db.execute(
+ f'SELECT id FROM plans WHERE owner_id=? AND id IN ({placeholders})',
+ (user_id, *identifiers)))
+ if {row['id'] for row in rows} != set(identifiers):
+ raise WebError('plan_not_found', 'One or more selected saved plans are unavailable for this account.', 404)
+ db.execute(
+ f'DELETE FROM plans WHERE owner_id=? AND id IN ({placeholders})',
+ (user_id, *identifiers))
+ for ident in identifiers:
+ audit(db, user['username'], 'plan-deleted', ident)
+
+ def save_selection(self, user_id, customer, playbook, targets):
+ adapter = CoreAdapter(self.settings)
+ spec = adapter.spec(playbook, customer)
+ compatible = {h['name'] for h in adapter.hosts(customer) if set(h['platforms']).intersection(spec.platforms)}
+ if (not isinstance(targets, list) or len(targets) > 500 or any(not isinstance(t, str) or t not in compatible for t in targets)
+ or len(set(targets)) != len(targets)):
+ raise WebError('invalid_selection', 'Use at most 500 distinct compatible explicit hosts.')
+ with self.store.transaction() as db:
+ actor(db, user_id)
+ db.execute('DELETE FROM selection_drafts WHERE updated_at', (int(time.time()) - 7 * 86400,))
+ db.execute('''INSERT INTO selection_drafts VALUES(?,?,?,?,?) ON CONFLICT(user_id,customer,playbook)
+ DO UPDATE SET targets=excluded.targets,updated_at=excluded.updated_at''',
+ (user_id, customer, playbook, serialized(targets), int(time.time())))
+ return {'saved': len(targets)}
+
+ def selection(self, user_id, customer, playbook):
+ with self.store.read() as db:
+ actor(db, user_id)
+ row = db.execute('SELECT targets FROM selection_drafts WHERE user_id=? AND customer=? AND playbook=? AND updated_at>?',
+ (user_id, customer, playbook, int(time.time()) - 7 * 86400)).fetchone()
+ return json.loads(row[0]) if row else []
+
+ def onboarding(self, user_id):
+ with self.store.read() as db:
+ Auth.require_admin(db, user_id)
+ users = [dict(r) for r in db.execute("SELECT id,username,role,enabled,must_change_password FROM users ORDER BY username")]
+ return {'users': users, 'ready': any(u['role'] == 'admin' and u['enabled'] and not u['must_change_password']
+ and u['username'] != 'admin' for u in users)}
+
+ def named_admin(self, user_id, name, temporary_password):
+ name = username(name)
+ if name == 'admin':
+ raise WebError('named_account_required', 'Choose a personal administrator name other than admin.')
+ Auth(self.settings).create_user(name, temporary_password, 'admin', actor_id=user_id)
+
+ def retire_bootstrap(self, user_id):
+ with self.store.transaction() as db:
+ who = Auth.require_admin(db, user_id)
+ current = actor(db, user_id)
+ if current['username'] == 'admin':
+ raise WebError('named_signin_required', 'Sign in with the named administrator after changing its temporary password.', 403)
+ db.execute("UPDATE users SET enabled=0,updated_at=? WHERE username='admin'", (int(time.time()),))
+ db.execute("DELETE FROM sessions WHERE user_id IN (SELECT id FROM users WHERE username='admin')")
+ db.execute("INSERT INTO metadata VALUES('bootstrap_retired','1') ON CONFLICT(key) DO UPDATE SET value='1'")
+ audit(db, who, 'bootstrap-retired', 'admin')
+ Auth(self.settings).consume_bootstrap()
+
+ def grant(self, admin_id, user_id, customer, playbook, *, revoke=False):
+ adapter = CoreAdapter(self.settings)
+ adapter.customer_path(customer)
+ adapter.spec(playbook, customer)
+ with self.store.transaction() as db:
+ who = Auth.require_admin(db, admin_id)
+ target = db.execute('SELECT username FROM users WHERE id=?', (user_id,)).fetchone()
+ if not target:
+ raise WebError('user_not_found', 'Account not found.', 404)
+ if revoke:
+ db.execute('DELETE FROM grants WHERE user_id=? AND customer=? AND playbook=?', (user_id, customer, playbook))
+ else:
+ exists = db.execute('SELECT 1 FROM grants WHERE user_id=? AND customer=? AND playbook=?',
+ (user_id, customer, playbook)).fetchone()
+ if exists:
+ raise WebError('grant_exists', 'This account already has that execution grant.', 409)
+ db.execute('INSERT INTO grants VALUES(?,?,?)', (user_id, customer, playbook))
+ audit(db, who, 'grant-revoked' if revoke else 'grant-created', target['username'])
+
+ def granted_playbooks(self, admin_id, user_id, customer):
+ CoreAdapter(self.settings).customer_path(customer)
+ with self.store.read() as db:
+ Auth.require_admin(db, admin_id)
+ target = db.execute('SELECT id FROM users WHERE id=?', (user_id,)).fetchone()
+ if not target:
+ raise WebError('user_not_found', 'Account not found.', 404)
+ return [r['playbook'] for r in db.execute(
+ 'SELECT playbook FROM grants WHERE user_id=? AND customer=? ORDER BY playbook',
+ (user_id, customer))]
+
+ def grants(self, admin_id):
+ with self.store.read() as db:
+ Auth.require_admin(db, admin_id)
+ return [dict(r) for r in db.execute('SELECT grants.*,users.username FROM grants JOIN users ON users.id=grants.user_id')]
+
+ def execution_policy(self, plan):
+ s=self.settings
+ if not s.execution_enabled:raise WebError('execution_disabled','Browser execution is disabled. AIM terminal remains available.',501)
+ if plan['playbook'] not in s.execution_playbooks:raise WebError('playbook_not_enabled','This operation is not in the WebGUI execution allowlist.',403)
+ if len(plan['targets'])>s.execution_max_hosts:raise WebError('host_limit',f'Execution policy permits at most {s.execution_max_hosts} hosts.')
+ if not plan.get('core_request'):raise WebError('legacy_plan','Old-core plans require a fresh New run review.',409)
+ capabilities=CoreAdapter(s).capabilities
+ if capabilities.get('execution',{}).get('enabled') is not True:
+ raise WebError('core_execution_disabled','Core external execution is disabled. Review addons.execution_enabled with the AIM operator; WebGUI does not edit aim.yml.',409)
+ if capabilities.get('execution',{}).get('profile')!='same_uid_native_inventory':
+ raise WebError('core_profile','This core execution profile is not qualified by this add-on.',409)
+ if plan['credential_requirements'] and not s.credentials_enabled:
+ raise WebError('credentials_disabled','This run requires one-run credentials; credential collection is disabled.',409)
+
+ def queue_review(self,user_id,review_id,scheduled_at=None,*,idempotency_key):
+ if not isinstance(idempotency_key,str) or not re.fullmatch(r'[A-Za-z0-9_-]+',idempotency_key) or not 1<=len(idempotency_key)<=80:
+ raise WebError('submission_key','Supply a fresh alphanumeric/hyphen submission key up to 80 characters.')
+ fingerprint=hashlib.sha256(serialized({'review':review_id,'scheduled_at':scheduled_at}).encode()).hexdigest()
+ with self.store.read() as db:
+ actor(db,user_id)
+ previous=db.execute('SELECT job_id,request_hash FROM job_requests WHERE owner_id=? AND request_key=?',(user_id,idempotency_key)).fetchone()
+ if previous:
+ if previous['request_hash']!=fingerprint:raise WebError('idempotency_conflict','Submission key was already used for a different request.',409)
+ return previous['job_id']
+ reviewed=self.review_record(user_id,review_id)
+ current=CoreAdapter(self.settings).reprepare(reviewed)
+ self.assert_revision(reviewed,current)
+ self.execution_policy(current)
+ now=int(time.time());when=now if scheduled_at is None else scheduled_at
+ if type(when)is not int or not now-60<=when<=now+30*86400:raise WebError('invalid_schedule','Choose a time within the next 30 days.')
+ ident=uuid.uuid4().hex
+ status='pending' if self.settings.execution_require_approval else 'queued'
+ mode='check' if current['core_request']['check'] else 'apply'
+ with self.store.transaction() as db:
+ who=allowed(db,user_id,current['customer'],current['playbook'])
+ old=db.execute('SELECT job_id,request_hash FROM job_requests WHERE owner_id=? AND request_key=?',(user_id,idempotency_key)).fetchone()
+ if old:
+ if old['request_hash']!=fingerprint:raise WebError('idempotency_conflict','Submission key was reused with a different request.',409)
+ return old['job_id']
+ fresh=db.execute('SELECT expires_at FROM run_reviews WHERE id=? AND owner_id=?',(review_id,user_id)).fetchone()
+ if not fresh or fresh['expires_at']=100:
+ raise WebError('queue_full','Queue limit reached (100 outstanding jobs).',409)
+ db.execute('INSERT INTO jobs(id,owner_id,plan,mode,status,created_at,scheduled_at) VALUES(?,?,?,?,?,?,?)',
+ (ident,user_id,serialized(current),mode,status,now,when))
+ db.execute('INSERT INTO job_requests(owner_id,request_key,job_id,request_hash) VALUES(?,?,?,?)',(user_id,idempotency_key,ident,fingerprint))
+ event(db,ident,'Submitted for independent approval.' if status=='pending' else 'Queued as one-run job. No saved plan was required.')
+ audit(db,who['username'],'job-submitted',ident)
+ return ident
+
+ @staticmethod
+ def assert_revision(saved,current):
+ if not saved.get('core_revision') or saved.get('core_revision')!=current.get('core_revision') or saved.get('core_request')!=current.get('core_request'):
+ raise WebError('plan_stale','AIM sources or reviewed options changed. Review a new run; a saved plan is optional.',409)
+
+ def job(self, user_id, ident):
+ with self.store.read() as db:
+ user = actor(db, user_id)
+ row = db.execute('SELECT jobs.*,users.username FROM jobs JOIN users ON users.id=jobs.owner_id WHERE jobs.id=?', (ident,)).fetchone()
+ if not row or (row['owner_id'] != user_id and user['role'] != 'admin'):
+ raise WebError('job_not_found', 'Job not found for this account.', 404)
+ result = dict(row)
+ result['events'] = [dict(r) for r in db.execute('SELECT occurred_at,event FROM job_events WHERE job_id=? ORDER BY id', (ident,))]
+ result['plan'] = json.loads(result['plan'])
+ result['core_result'] = json.loads(result['core_result']) if result.get('core_result') else None
+ result['display_status'] = presentation_status(result['status'], result['core_result'])
+ return result
+
+ def jobs(self, user_id):
+ with self.store.read() as db:
+ user = actor(db, user_id)
+ rows=list(db.execute('''SELECT jobs.id,owner_id,users.username,mode,status,credential_phase,credential_deadline,cancel_requested,jobs.plan,jobs.core_result,jobs.created_at,scheduled_at
+ FROM jobs JOIN users ON users.id=jobs.owner_id WHERE (?='admin' OR owner_id=?) ORDER BY jobs.created_at DESC LIMIT 100''',
+ (user['role'], user_id)))
+ result=[]
+ for row in rows:
+ item=dict(row); plan=json.loads(item.pop('plan'))
+ targets=plan.get('targets',[]) if isinstance(plan,dict) else []
+ item['customer']=plan.get('customer',''); item['playbook']=plan.get('playbook','')
+ item['target_count']=len(targets); item['target_preview']=targets[:2]
+ core_result=json.loads(item['core_result']) if item.get('core_result') else None
+ item['core_result']=core_result
+ item['display_status']=presentation_status(item['status'],core_result)
+ item['target_summary']=core_result.get('target_summary') if isinstance(core_result,dict) else None
+ result.append(item)
+ return result
+
+ def retry_job(self, user_id, ident):
+ # Manual retries create a new job and never replay/mutate the failed row.
+ # One-run credentials are not retained and must be supplied again.
+ with self.store.read() as db:
+ actor(db, user_id)
+ row = db.execute('SELECT * FROM jobs WHERE id=?', (ident,)).fetchone()
+ if not row or row['owner_id'] != user_id:
+ raise WebError('job_not_found', 'Only the original requester can retry this job.', 404)
+ if row['status'] != 'failed':
+ raise WebError('job_state', 'Only failed jobs can be retried.', 409)
+ previous = dict(row)
+ saved = json.loads(previous['plan'])
+ current = CoreAdapter(self.settings).reprepare(saved)
+ self.assert_revision(saved, current)
+ if saved.get('authentication'):
+ current['authentication'] = dict(saved['authentication'])
+ self.execution_policy(current)
+ now = int(time.time())
+ status = 'pending' if self.settings.execution_require_approval else 'queued'
+ new_ident = uuid.uuid4().hex
+ with self.store.transaction() as db:
+ who = allowed(db, user_id, current['customer'], current['playbook'])
+ latest = db.execute('SELECT status,owner_id FROM jobs WHERE id=?', (ident,)).fetchone()
+ if not latest or latest['owner_id'] != user_id or latest['status'] != 'failed':
+ raise WebError('job_state', 'The failed job changed while retrying. Reload and try again.', 409)
+ if db.execute("SELECT COUNT(*) FROM jobs WHERE status IN ('pending','queued','running')").fetchone()[0] >= 100:
+ raise WebError('queue_full', 'The queue is full (100 outstanding jobs).', 409)
+ db.execute('INSERT INTO jobs(id,owner_id,plan,mode,status,created_at,scheduled_at) VALUES(?,?,?,?,?,?,?)',
+ (new_ident, user_id, serialized(current), previous['mode'], status, now, now))
+ if status == 'pending':
+ event(db, new_ident, f'Retry of {ident[:12]} submitted; awaiting independent administrator approval.')
+ else:
+ event(db, new_ident, f'Queued as manual retry of {ident[:12]}.')
+ audit(db, who['username'], 'job-retried', f'{ident}:{new_ident}')
+ return new_ident
+
+ def delete_jobs(self, user_id, identifiers):
+ if (not isinstance(identifiers, list) or not 1 <= len(identifiers) <= 100
+ or len(set(identifiers)) != len(identifiers)
+ or any(not isinstance(ident, str) or len(ident) != 32 for ident in identifiers)):
+ raise WebError('invalid_job_selection', 'Select between 1 and 100 finished jobs to delete.')
+ with self.store.transaction() as db:
+ who = actor(db, user_id)
+ placeholders = ','.join('?' for _ in identifiers)
+ rows = list(db.execute(
+ f'SELECT id,owner_id,status FROM jobs WHERE id IN ({placeholders})', identifiers))
+ if {row['id'] for row in rows} != set(identifiers):
+ raise WebError('job_not_found', 'One or more selected jobs are unavailable.', 404)
+ if any(row['owner_id'] != user_id and who['role'] != 'admin' for row in rows):
+ raise WebError('job_not_found', 'One or more selected jobs are unavailable.', 404)
+ if any(row['status'] not in TERMINAL for row in rows):
+ raise WebError('job_state', 'Only finished jobs can be deleted.', 409)
+ for row in rows:
+ subject = f"{row['id']}:{row['status']}"
+ db.execute('DELETE FROM job_events WHERE job_id=?', (row['id'],))
+ db.execute('DELETE FROM job_requests WHERE job_id=?', (row['id'],))
+ db.execute('DELETE FROM jobs WHERE id=?', (row['id'],))
+ audit(db, who['username'], 'job-deleted', subject)
+
+ def job_action(self, user_id, ident, action):
+ with self.store.transaction() as db:
+ who = actor(db, user_id)
+ row = db.execute('SELECT * FROM jobs WHERE id=?', (ident,)).fetchone()
+ if not row or (row['owner_id'] != user_id and who['role'] != 'admin'):
+ raise WebError('job_not_found', 'Job not found.', 404)
+ if action == 'approve':
+ Auth.require_admin(db, user_id)
+ if row['owner_id'] == user_id:
+ raise WebError('independent_approval', 'A different administrator must approve this job.', 403)
+ if row['status'] != 'pending':
+ raise WebError('job_state', 'Only pending jobs can be approved.', 409)
+ db.execute("UPDATE jobs SET status='queued',approver_id=? WHERE id=?", (user_id, ident))
+ event(db, ident, 'Approved by a different administrator; waiting for scheduled time and worker.')
+ elif action == 'cancel':
+ if row['status'] in TERMINAL:
+ raise WebError('job_finished', 'This job has already finished.', 409)
+ if row['status'] == 'running':
+ db.execute('UPDATE jobs SET cancel_requested=1 WHERE id=?', (ident,))
+ event(db, ident, 'Cancellation requested. Completed remote changes cannot be rolled back.')
+ else:
+ db.execute("UPDATE jobs SET status='canceled',finished_at=? WHERE id=?", (int(time.time()), ident))
+ event(db, ident, 'Canceled before execution.')
+ elif action == 'delete':
+ if row['status'] not in TERMINAL:
+ raise WebError('job_state', 'Only finished jobs can be deleted.', 409)
+ subject = f"{ident}:{row['status']}"
+ db.execute('DELETE FROM job_events WHERE job_id=?', (ident,))
+ db.execute('DELETE FROM job_requests WHERE job_id=?', (ident,))
+ db.execute('DELETE FROM jobs WHERE id=?', (ident,))
+ audit(db, who['username'], 'job-deleted', subject)
+ return
+ else:
+ raise WebError('invalid_action', 'Choose approve, cancel or delete.')
+ audit(db, who['username'], 'job-' + action, ident)
+
+ def audit(self, admin_id, *, before=None, action=''):
+ with self.store.read() as db:
+ Auth.require_admin(db, admin_id)
+ return [dict(r) for r in db.execute('''SELECT * FROM audit WHERE id AND (?='' OR action=?)
+ ORDER BY id DESC LIMIT 100''', (before or 2**63-1, action, action))]
diff --git a/scripts/addons/webgui/tests/benchmark_journal.py b/scripts/addons/webgui/tests/benchmark_journal.py
new file mode 100644
index 0000000..0281167
--- /dev/null
+++ b/scripts/addons/webgui/tests/benchmark_journal.py
@@ -0,0 +1,46 @@
+"""Synthetic local metadata retention benchmark; no Core calls or managed hosts."""
+from pathlib import Path
+import argparse,json,resource,sys,tempfile,time
+sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'src'))
+from aim_webgui.auth.service import Auth
+from aim_webgui.config import Settings
+from aim_webgui.journal import Journal,project
+from evidence_fixtures import job,ev
+
+
+def main():
+ parser=argparse.ArgumentParser()
+ parser.add_argument('--events',type=int,default=25000)
+ parser.add_argument('--out',type=Path,required=True)
+ args=parser.parse_args()
+ if not 1<=args.events<=200000:parser.error('events must be 1..200000')
+ with tempfile.TemporaryDirectory(prefix='aim-journal-benchmark-') as directory:
+ settings=Settings(state_dir=Path(directory)/'state',public_url='https://example.test')
+ auth=Auth(settings);auth.bootstrap()
+ with auth.store.transaction() as db:
+ db.execute('UPDATE users SET must_change_password=0')
+ owner=db.execute('SELECT id FROM users').fetchone()[0]
+ ident=job(auth,owner);journal=Journal(settings);journal.begin(ident)
+ begin=time.perf_counter()
+ for start in range(1,args.events+1,128):
+ batch=[(*project(ev(i),{'test01.example'}),time.time()) for i in range(start,min(start+128,args.events+1))]
+ journal.append(ident,batch)
+ journal.append(ident,[],closed=True)
+ elapsed=time.perf_counter()-begin
+ t=time.perf_counter();snapshot=journal.snapshot(owner,ident);read=time.perf_counter()-t
+ assert snapshot['cursor']==args.events
+ assert snapshot['retained_events']<=settings.journal_max_events
+ assert snapshot['retained_bytes']<=settings.journal_max_bytes
+ assert snapshot['checkpoint']['observed_task_starts']==args.events
+ assert len(snapshot['events'])<=200
+ result={'synthetic_only':True,'events':args.events,'batch_events':128,
+ 'write_seconds':round(elapsed,4),'latest_snapshot_seconds':round(read,4),
+ 'retained_events':snapshot['retained_events'],'omitted_events':snapshot['omitted_events'],
+ 'retained_metadata_bytes':snapshot['retained_bytes'],'response_events':len(snapshot['events']),
+ 'checkpoint_tasks':len(snapshot['checkpoint']['tasks']),
+ 'process_peak_rss_kib':resource.getrusage(resource.RUSAGE_SELF).ru_maxrss,
+ 'database_bytes':settings.database.stat().st_size,'core_calls':0,
+ 'limits':'20,000 event / 8 MiB tail, using synchronous 128-event batches; not executor/load qualification'}
+ args.out.write_text(json.dumps(result,indent=2)+'\n');print(json.dumps(result,indent=2))
+
+if __name__=='__main__':main()
diff --git a/scripts/addons/webgui/tests/benchmark_read_history.py b/scripts/addons/webgui/tests/benchmark_read_history.py
new file mode 100644
index 0000000..c4c0b32
--- /dev/null
+++ b/scripts/addons/webgui/tests/benchmark_read_history.py
@@ -0,0 +1,65 @@
+"""Optional synthetic read-projection benchmark; never pass a production DB.
+
+python tests/benchmark_read_history.py --jobs 20000 --out /tmp/aim-history-benchmark.json
+Uses an automatically cleaned temporary database and only retained WebGUI fixtures.
+"""
+from pathlib import Path
+import argparse
+import json
+import resource
+import statistics
+import sys
+import tempfile
+import time
+sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'src'))
+from aim_webgui.activity import Activity, HistoryFilter
+from aim_webgui.auth.service import Auth
+from aim_webgui.config import Settings
+
+
+def main():
+ parser=argparse.ArgumentParser()
+ parser.add_argument('--jobs',type=int,default=20000)
+ parser.add_argument('--out',type=Path,required=True)
+ args=parser.parse_args()
+ if not 1 <= args.jobs <= 100000:parser.error('--jobs must be 1..100000')
+ counts=('ok','changed','failures','unreachable','skipped','rescued','ignored')
+ now=int(time.time())
+ with tempfile.TemporaryDirectory(prefix='aim-read-benchmark-') as tmp:
+ auth=Auth(Settings(state_dir=Path(tmp)/'state',public_url='https://fixture.invalid').validate())
+ auth.bootstrap()
+ with auth.store.transaction() as db:
+ db.execute('UPDATE users SET must_change_password=0')
+ actor=db.execute('SELECT id FROM users WHERE username=?',('admin',)).fetchone()[0]
+ for i in range(args.jobs):
+ hosts=[f'host-{(i+j)%200:04d}.example' for j in range(4)]
+ outcome='unreachable' if i%5==0 else 'successful'
+ targets=[]
+ for j,h in enumerate(hosts):
+ c=dict.fromkeys(counts,0);c['ok']=30;c['changed']=1
+ state=outcome if j==3 else 'successful'
+ if state=='unreachable':c['unreachable']=1
+ targets.append({'host':h,'outcome':state,'counts':c})
+ unreachable=int(outcome=='unreachable')
+ result={'status':'failed' if unreachable else 'succeeded','targets':targets,
+ 'target_summary':{'schema':'target_outcome_summary_v1','requested':4,'accounted':4,'complete':True,
+ 'successful':4-unreachable,'failed':0,'unreachable':unreachable,'indeterminate':0,'not_started':0}}
+ plan={'customer':'synthetic','playbook':f'debug_fixture_{i%8}','targets':hosts}
+ db.execute('INSERT INTO jobs(id,owner_id,plan,mode,status,created_at,scheduled_at,finished_at,core_result) VALUES(?,?,?,?,?,?,?,?,?)',
+ (f'{i:032x}',actor,json.dumps(plan),'apply','failed' if unreachable else 'successful',now-i-1,now-i-1,now-i-1,json.dumps(result)))
+ reader=Activity(auth.settings);durations=[]
+ for _ in range(3):
+ start=time.perf_counter();report=reader.report(actor,HistoryFilter(days='all'),now=now)
+ durations.append(time.perf_counter()-start)
+ assert report['jobs']==args.jobs and report['samples']==args.jobs*4
+ assert len(report['records'])==min(25,args.jobs*4) and len(report['matrix'])<=20
+ result={'synthetic_only':True,'jobs':args.jobs,'host_run_samples':report['samples'],
+ 'distinct_customer_hosts':report['host_count'],'playbooks':len(report['playbooks']),
+ 'runs_seconds':durations,'median_seconds':statistics.median(durations),
+ 'process_peak_rss_kib':resource.getrusage(resource.RUSAGE_SELF).ru_maxrss,
+ 'sqlite_bytes':auth.settings.database.stat().st_size,'output_records':len(report['records']),
+ 'matrix_rows':len(report['matrix']),'core_calls':0,'projection_tables_added':0}
+ args.out.write_text(json.dumps(result,indent=2)+'\n')
+ print(json.dumps(result,indent=2))
+
+if __name__=='__main__':main()
diff --git a/scripts/addons/webgui/tests/browser_credentials_qa.py b/scripts/addons/webgui/tests/browser_credentials_qa.py
new file mode 100644
index 0000000..80e1cf2
--- /dev/null
+++ b/scripts/addons/webgui/tests/browser_credentials_qa.py
@@ -0,0 +1,200 @@
+"""Chromium about:blank fixtures bridged to real ASGI endpoints; synthetic worker handoffs.
+
+This is not live systemd/Core/SSH qualification. Bootstrap may be a declared
+substitute. HTMX is deliberately not loaded when unavailable: status replacements
+are driven explicitly and labeled as synthetic lifecycle coverage, not HTMX proof.
+Never install fixture assets into production static/vendor.
+"""
+from pathlib import Path
+import argparse
+import json
+import re
+import sys
+import tempfile
+import time
+from urllib.parse import urlsplit
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'src'))
+
+import pytest
+from fastapi.testclient import TestClient
+from playwright.sync_api import sync_playwright
+from aim_webgui.app import create_app
+from aim_webgui.credentials import wire
+from test_credential_ux import authz as auth_fixture, make_job, SYNTHETIC
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--out', type=Path, required=True)
+ parser.add_argument('--bootstrap', type=Path, default=Path('/opt/imagemagick/share/doc/ImageMagick-7/www/assets/bootstrap.min.css'))
+ args = parser.parse_args(); args.out.mkdir(parents=True, exist_ok=True)
+ cases = []
+ with tempfile.TemporaryDirectory(prefix='aim-credential-qa-') as tmp, pytest.MonkeyPatch.context() as monkey:
+ auth, users = auth_fixture.__wrapped__(Path(tmp), monkey)
+ fixture = (auth, users)
+ packets = []
+ class Sock:
+ def __enter__(self): return self
+ def __exit__(self, *args): pass
+ monkey.setattr(wire, 'connect', lambda *a, **k: Sock())
+ monkey.setattr(wire, 'send', lambda sock, packet, limit: packets.append(json.loads(json.dumps(packet))))
+ def claimed(*args):
+ ident = packets[-1]['job']
+ with auth.store.transaction() as db:
+ db.execute("UPDATE jobs SET credential_phase='claimed' WHERE id=?", (ident,))
+ return {'accepted': True}
+ monkey.setattr(wire, 'receive', claimed)
+ origin = auth.settings.public_url
+ with TestClient(create_app(auth.settings), base_url=origin, follow_redirects=False) as server, sync_playwright() as pw:
+ browser = pw.chromium.launch(executable_path='/usr/bin/chromium', args=['--no-sandbox', '--disable-dev-shm-usage'])
+ try:
+ for width, height in ((320,750),(390,844),(760,900),(1440,1000),(740,390)):
+ for theme in ('light','dark'):
+ # Independent viewport fixtures must not share the live five/minute bucket.
+ # Production throttle remains enabled and is covered in unit tests.
+ with auth.store.transaction() as db: db.execute('DELETE FROM rate_limits')
+ token, session = auth.new_session(users['operator'])
+ context = browser.new_context(viewport={'width':width,'height':height}, locale='en-GB', timezone_id='Europe/Berlin', reduced_motion='reduce')
+ context.add_cookies([{'name':auth.settings.cookie_name, 'value':token, 'url':origin,'secure':True,'httpOnly':True,'sameSite':'Lax'}])
+ errors = []
+ mode = {'drop_post':False}
+ def bridge(url, options):
+ path = urlsplit(url).path
+ method = options.get('method', 'GET')
+ if mode['drop_post'] and method == 'POST' and path.endswith('/credentials'):
+ mode['post_count'] = mode.get('post_count',0)+1
+ return {'network_error': True}
+ headers = {**options.get('headers', {}), 'Origin': origin}
+ response = server.request(method, path, headers=headers, content=options.get('body'))
+ return {'status': response.status_code, 'body': response.text}
+ server.cookies.set(auth.settings.cookie_name, token)
+ page = context.new_page(); page.on('pageerror', lambda e: errors.append(str(e)))
+ page.expose_function('_fixtureHTTP', bridge)
+ def mount(path):
+ page.goto('about:blank')
+ content = server.get(path).text
+ content = re.sub(r'', '', content)
+ content = re.sub(r' ]+rel="stylesheet"[^>]*>', '', content)
+ css = args.bootstrap.read_text() + '\n' + '\n'.join((ROOT/'src/aim_webgui/static/css'/n).read_text() for n in ('tokens.css','bootstrap-overrides.css','aim.css','experience.css','credentials.css','evidence.css'))
+ fixture_js = """
+window.fetch = async function(url, options) {
+ const r=await window._fixtureHTTP(url, options||{});
+ if(r.network_error) throw new TypeError('Synthetic lost response');
+ return new Response(r.body,{status:r.status,headers:{'Content-Type':'application/json'}});
+};
+window.EventSource=class { constructor(){} addEventListener(){} close(){} };
+"""
+ js='\n'.join((ROOT/'src/aim_webgui/static/js'/n).read_text() for n in ('theme.js','aim.js','experience.js','credentials.js','evidence.js'))
+ content=content.replace('','').replace('
','')
+ page.set_content(content, wait_until='load')
+ try:
+ ident = make_job(fixture)
+ mount('/jobs/'+ident)
+ page.locator('[data-theme-option='+theme+']:visible').click()
+ opener = page.locator('[data-credential-open]')
+ opener.click()
+ panel = page.locator('dialog [data-credential-panel]'); panel.wait_for()
+ assert page.locator('dialog').evaluate('(e)=>e.open')
+ assert page.locator('#credential-dialog-title').evaluate('(e)=>e===document.activeElement')
+ assert page.locator('[data-key-source-choice]').is_visible()
+ assert not page.locator('[name="ssh_key_passphrase"]').is_visible()
+ assert not page.locator('dialog').evaluate('(e)=>e.scrollWidth>e.clientWidth+1')
+ assert page.evaluate('document.documentElement.scrollWidth<=innerWidth')
+ box=page.locator('dialog').bounding_box(); assert box['x']>=0 and box['y']>=0 and box['x']+box['width']<=width+1 and box['y']+box['height']<=height+1
+ # Native modal keeps background inert and cycles focus within dialog.
+ for _ in range(12):
+ page.keyboard.press('Tab')
+ assert page.evaluate("document.querySelector('dialog').contains(document.activeElement)")
+ page.locator('[name="vault_password"]').fill(SYNTHETIC)
+ page.locator('button[aria-controls="credential-vault"]').click()
+ assert page.locator('[name="vault_password"]').get_attribute('type')=='text'
+ # Poll replacement cannot replace the modal or its typed/revealed input.
+ page.evaluate("async (job)=>{const r=await fetch('/_partials/jobs/'+job);const html=await r.text();document.getElementById('job-status').outerHTML=html;document.dispatchEvent(new CustomEvent('htmx:afterSwap',{detail:{target:document.getElementById('job-status')}}));}", ident)
+ assert page.locator('[name="vault_password"]').input_value()==SYNTHETIC
+ page.locator('label[for="credential-key-separate"]').click()
+ page.locator('[name="ssh_key_passphrase"]').fill('Synthetic separate key')
+ page.locator('label[for="credential-key-vault"]').click()
+ assert page.locator('[name="ssh_key_passphrase"]').input_value()==''
+ # Revealed input must be cleared on Escape, not just hidden visually.
+ page.keyboard.press('Escape')
+ assert not page.locator('dialog').evaluate('(e)=>e.open')
+ assert page.locator('[data-credential-secret]').count()==0
+ assert page.locator('[data-credential-open]').evaluate('(e)=>e===document.activeElement')
+ page.locator('[data-credential-open]').click(); panel.wait_for()
+ assert page.locator('[name="vault_password"]').input_value()==''
+ if theme=='dark' and width in (390,1440):
+ page.screenshot(path=str(args.out/f'credential-dialog-{width}.png'), full_page=False)
+ start=len(packets)
+ page.locator('[name="vault_password"]').fill(SYNTHETIC)
+ page.locator('[data-credential-submit]').click()
+ page.locator('[data-credential-feedback][data-tone="accepted"]').wait_for()
+ assert len(packets)==start+1 and packets[-1]['credentials']['vault_password']==SYNTHETIC
+ assert 'ssh_key_passphrase' not in packets[-1]['credentials']
+ assert page.locator('[name="vault_password"]').input_value()==''
+ assert 'accepted' in page.locator('[data-credential-feedback]').inner_text().lower()
+ assert page.url=='about:blank'
+ page.locator('[data-credential-dismiss]').click()
+ assert not page.locator('dialog').evaluate('(e)=>e.open')
+ # Attention action belongs to this viewer; choose only their credentials.
+ ident2=make_job(fixture, requirements=['vault_password'])
+ mount('/jobs')
+ page.locator('#attention [data-job-id="'+ident2+'"]').click(); panel.wait_for()
+ assert page.locator('[data-key-source-choice]').count()==0
+ # Lost acknowledgement: only one POST, then GET reconciliation; no replay.
+ mode['drop_post']=True;mode['post_count']=0
+ page.locator('[name="vault_password"]').fill(SYNTHETIC)
+ page.locator('[data-credential-submit]').click()
+ page.wait_for_function("document.querySelector('[data-credential-feedback]').textContent.includes('not confirmed')")
+ page.wait_for_timeout(3500)
+ assert mode['post_count']==1 and page.locator('[name="vault_password"]').input_value()==''
+ page.locator('[data-credential-dismiss]').click()
+ page.locator('#attention [data-job-id="'+ident2+'"]').click();panel.wait_for()
+ page.wait_for_function("document.querySelector('[data-credential-feedback]').textContent.includes('not been confirmed')")
+ assert page.locator('[data-credential-submit]').is_hidden()
+ page.keyboard.press('Escape');mode['drop_post']=False
+ # Server cancels while the form is open: input is cleared without POST.
+ ident3=make_job(fixture)
+ mount('/jobs/'+ident3);page.locator('[data-credential-open]').click();panel.wait_for()
+ page.locator('[name="vault_password"]').fill(SYNTHETIC)
+ with auth.store.transaction() as db: db.execute('UPDATE jobs SET cancel_requested=1 WHERE id=?',(ident3,))
+ page.wait_for_function("document.querySelector('[data-credential-feedback]').textContent.includes('cancellation')")
+ assert page.locator('[name="vault_password"]').input_value()==''
+ page.keyboard.press('Escape')
+ # Short visual viewport is internally scrollable; footer stays reachable.
+ ident4=make_job(fixture)
+ mount('/jobs/'+ident4);page.locator('[data-credential-open]').click();panel.wait_for()
+ page.set_viewport_size({'width':width,'height':min(360,height)})
+ page.wait_for_timeout(100)
+ assert page.locator('.credential-dialog-body').evaluate('(e)=>e.scrollHeight>e.clientHeight')
+ page.locator('[data-credential-submit]').scroll_into_view_if_needed()
+ box=page.locator('dialog').bounding_box();assert box['y']>=-1 and box['y']+box['height']<=min(360,height)+1
+ # Page hide clears revealed secrets too. BFCache style re-init never restores them.
+ page.locator('[name="vault_password"]').fill(SYNTHETIC)
+ page.locator('button[aria-controls="credential-vault"]').click()
+ page.evaluate("window.dispatchEvent(new PageTransitionEvent('pagehide'))")
+ assert page.locator('[data-credential-secret]').count()==0
+ assert not errors, errors
+ cases.append({'width':width,'height':height,'theme':theme,'passed':True})
+ print('Credential QA passed',width,height,theme,flush=True)
+ finally: context.close()
+ # No-JS native markup and a plain HTTP form POST are verified separately.
+ with auth.store.transaction() as db: db.execute('DELETE FROM rate_limits')
+ token, session = auth.new_session(users['operator'])
+ server.cookies.set(auth.settings.cookie_name, token)
+ ident=make_job(fixture,requirements=['vault_password'])
+ r=server.get('/jobs/'+ident+'/credentials')
+ assert r.status_code==200 and 'method="post"' in r.text
+ r=server.post('/jobs/'+ident+'/credentials', data={'_csrf':session['csrf'],'vault_password':SYNTHETIC},headers={'Origin':origin})
+ assert r.status_code==303 and r.headers['location']=='/jobs/'+ident
+ cases.append({'plain_http_form_fallback':True,'passed':True})
+ finally: browser.close()
+ report={'fixture_only':True,'cases':cases,'asgi_auth_csrf_endpoints':True,'browser_network':'about:blank fetch bridge to real TestClient; no real browser CSP/TLS/cookie transport qualification',
+ 'bootstrap_source':str(args.bootstrap),'bootstrap_substitution':'5.3.6, production pin 5.3.8',
+ 'htmx_loaded':False,'htmx_swap':'synthetic replacement + lifecycle event',
+ 'real_worker_core_execution':False,'browser':'Chromium via Playwright'}
+ (args.out/'results.json').write_text(json.dumps(report,indent=2))
+ print(len(cases),'browser scenario groups passed')
+
+if __name__=='__main__': main()
diff --git a/scripts/addons/webgui/tests/browser_evidence_qa.py b/scripts/addons/webgui/tests/browser_evidence_qa.py
new file mode 100644
index 0000000..24b09fd
--- /dev/null
+++ b/scripts/addons/webgui/tests/browser_evidence_qa.py
@@ -0,0 +1,136 @@
+"""Chromium fixture evidence QA. ASGI GET with simulated EventSource, no native tasks.
+
+Default CSS is the explicitly identified Bootstrap 5.3.6 fixture substitute.
+No fixture stylesheet is copied into the release. HTMX is deliberately unavailable
+unless the operator supplies the pinned asset; this is not proxy/HTTPS acceptance.
+"""
+from pathlib import Path
+import argparse,json,os,socket,sys,tempfile,time,re
+sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'src'))
+from dataclasses import replace
+from fastapi.testclient import TestClient
+from playwright.sync_api import sync_playwright
+from aim_webgui.app import create_app
+from aim_webgui.auth.service import Auth
+from aim_webgui.config import Settings
+from aim_webgui.journal import Journal,project
+from aim_webgui.reports import persist, TITLES
+from evidence_fixtures import declared,sample,plan,result,job,ev
+
+
+def main():
+ p=argparse.ArgumentParser();p.add_argument('--out',type=Path,required=True);p.add_argument('--bootstrap',type=Path,default=Path('/opt/imagemagick/share/doc/ImageMagick-7/www/assets/bootstrap.min.css'));args=p.parse_args();args.out.mkdir(parents=True,exist_ok=True)
+ with tempfile.TemporaryDirectory(prefix='aim-evidence-browser-')as td:
+ state=Path(td)/'state'
+ sock=socket.socket();sock.bind(('127.0.0.1',0));port=sock.getsockname()[1];sock.close()
+ url=f'http://127.0.0.1:{port}'
+ s=Settings(state_dir=state,public_url=url,core_transport='stdio');a=Auth(s);a.bootstrap()
+ with a.store.transaction()as db:
+ db.execute('UPDATE users SET must_change_password=0');uid=db.execute('SELECT id FROM users').fetchone()[0]
+ paths={};jobs={}
+ for name in TITLES:
+ d=declared(name);data=sample(d['data_schema'])
+ if name=='host_capabilities_v1':data['is_unifi_controller']=True
+ if name=='filesystem_usage_v1':
+ data={'platform':'linux','filesystems':[{'name':'/dev/synthetic1','mount':'/','filesystem_type':'ext4','used_bytes':42949672960,'total_bytes':128849018880,'available_bytes':85899345920,'used_percent':33.3,'status':'available'},{'name':'/dev/synthetic2','mount':'/srv/archive','filesystem_type':None,'used_bytes':None,'total_bytes':None,'available_bytes':None,'used_percent':None,'status':'unavailable'}]}
+ ident=job(a,uid,plan(d),status='successful');r=result(d,report_data=data)
+ with a.store.transaction()as db:
+ small=persist(db,s,ident,plan(d),r)
+ db.execute('UPDATE jobs SET core_result=?,finished_at=? WHERE id=?',(json.dumps(small),time.time(),ident))
+ paths[name]='/jobs/'+ident+'/reports';jobs[name]=ident
+ running=job(a,uid,plan(declared()),status='running');j=Journal(s);j.begin(running)
+ def add(start,end):j.append(running,[(*project(ev(i),{'test01.example'}),time.time())for i in range(start,end+1)])
+ add(1,300)
+ paths['progress']='/jobs/'+running
+ client=TestClient(create_app(s),base_url=url)
+ token,_=a.new_session(uid);client.cookies.set(s.cookie_name,token);records=[]
+ static=Path(__file__).resolve().parents[1]/'src/aim_webgui/static'
+ def html(path):
+ response=client.get(path);assert response.status_code==200,(path,response.text[:400])
+ text=response.text
+ def css(m):
+ name=m.group(1).split('?')[0]
+ source=args.bootstrap if 'bootstrap.min.css' in name else static/name.removeprefix('/static/')
+ return ''
+ def js(m):
+ name=m.group(1).split('?')[0]
+ if 'htmx.min.js'in name:return ''
+ source=static/name.removeprefix('/static/')
+ return ''
+ text=re.sub(r' ]+href="(/static/[^"]+\.css(?:\?[^"]*)?)"[^>]*>',css,text)
+ text=re.sub(r'',js,text)
+ return text
+ def bridge_read(source,path):
+ response=client.get(path)
+ return {'status':response.status_code,'text':response.text}
+ fixture_js="""
+ window.__sources=[];
+ window.fetch=async function(url,opts){const r=await window.fixtureRead(String(url));return {ok:r.status>=200&&r.status<300,status:r.status,json:async()=>JSON.parse(r.text),text:async()=>r.text};};
+ window.EventSource=class{constructor(url){this.url=url;this.listeners={};window.__sources.push(this);setTimeout(()=>this.emit('open',{}),0);}addEventListener(k,f){this.listeners[k]=f;}close(){this.closed=true;}emit(k,d){if(this.listeners[k]&&!this.closed)this.listeners[k]({data:JSON.stringify(d)});}};
+ window.__emit=function(kind,data){window.__sources.forEach(s=>s.emit(kind,data));};
+ """
+ def mount(page,path):
+ content=html(path)
+ page.set_content(content.replace('