diff --git a/.gitignore b/.gitignore index 85b74265..d40f33e5 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,15 @@ dreadgoad .dreadgoad/ coverage.out +# Web app build artifacts +console/frontend/node_modules/ +console/frontend/dist/ +console/frontend/*.tsbuildinfo + +# Generated ADCS template archives (main tracks the sources, not the zips) +ansible/roles/adcs_templates/files/ADCSTemplate.zip +ansible/roles/vulns_adcs_templates/files/ADCSTemplate.zip + # Root environment inventories are local runtime state. /*-inventory /*-inventory.bak.* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c50417d0..d47fe995 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,7 +31,7 @@ repos: hooks: - id: codespell entry: codespell -q 3 -f -S ".git,.github,README.md" -L "mot,fonction,connexion" - exclude: '(go\.sum|\.min\.css\.map|\.css\.map|\.min\.map|\.min\.js|sysmonconfig-export\.xml|jquery\.validate\.(min\.)?js|jquery-3\.4\.1(\.slim)?\.js|modernizr-[\d\.]+\.js)$' + exclude: '(go\.sum|package-lock\.json|\.min\.css\.map|\.css\.map|\.min\.map|\.min\.js|sysmonconfig-export\.xml|jquery\.validate\.(min\.)?js|jquery-3\.4\.1(\.slim)?\.js|modernizr-[\d\.]+\.js)$' - repo: https://github.com/jumanjihouse/pre-commit-hooks rev: 3.0.0 @@ -39,7 +39,7 @@ repos: - id: script-must-have-extension name: Ensure shell scripts end with .sh types: [shell] - exclude: '\.sh\.tpl$' + exclude: '(^dreadgoad-console$|\.sh\.tpl$)' - id: shellcheck exclude: '\.sh\.tpl$' - id: shfmt diff --git a/ad/GOAD-Light/scripts/constrained_delegation_kerb_only.ps1 b/ad/GOAD-Light/scripts/constrained_delegation_kerb_only.ps1 index fc2787c5..287270f5 100644 --- a/ad/GOAD-Light/scripts/constrained_delegation_kerb_only.ps1 +++ b/ad/GOAD-Light/scripts/constrained_delegation_kerb_only.ps1 @@ -14,5 +14,5 @@ if ($computer.ServicePrincipalNames -notcontains $spn) { $missing = @($delegateTo | Where-Object { $computer.'msDS-AllowedToDelegateTo' -notcontains $_ }) if ($missing.Count -gt 0) { - Set-ADComputer -Identity $identity -Add @{'msDS-AllowedToDelegateTo' = $missing } + Set-ADComputer -Identity $identity -Add @{'msDS-AllowedToDelegateTo' = [string[]]$missing } } diff --git a/ad/GOAD-Light/scripts/constrained_delegation_use_any.ps1 b/ad/GOAD-Light/scripts/constrained_delegation_use_any.ps1 index 13cb90fe..c5bd6cad 100644 --- a/ad/GOAD-Light/scripts/constrained_delegation_use_any.ps1 +++ b/ad/GOAD-Light/scripts/constrained_delegation_use_any.ps1 @@ -16,5 +16,5 @@ Set-ADAccountControl -Identity $identity -TrustedToAuthForDelegation $true $missing = @($delegateTo | Where-Object { $user.'msDS-AllowedToDelegateTo' -notcontains $_ }) if ($missing.Count -gt 0) { - Set-ADUser -Identity $identity -Add @{'msDS-AllowedToDelegateTo' = $missing } + Set-ADUser -Identity $identity -Add @{'msDS-AllowedToDelegateTo' = [string[]]$missing } } diff --git a/ad/GOAD/scripts/constrained_delegation_kerb_only.ps1 b/ad/GOAD/scripts/constrained_delegation_kerb_only.ps1 index fc2787c5..287270f5 100644 --- a/ad/GOAD/scripts/constrained_delegation_kerb_only.ps1 +++ b/ad/GOAD/scripts/constrained_delegation_kerb_only.ps1 @@ -14,5 +14,5 @@ if ($computer.ServicePrincipalNames -notcontains $spn) { $missing = @($delegateTo | Where-Object { $computer.'msDS-AllowedToDelegateTo' -notcontains $_ }) if ($missing.Count -gt 0) { - Set-ADComputer -Identity $identity -Add @{'msDS-AllowedToDelegateTo' = $missing } + Set-ADComputer -Identity $identity -Add @{'msDS-AllowedToDelegateTo' = [string[]]$missing } } diff --git a/ad/GOAD/scripts/constrained_delegation_use_any.ps1 b/ad/GOAD/scripts/constrained_delegation_use_any.ps1 index fc1cbb2b..76f4e114 100644 --- a/ad/GOAD/scripts/constrained_delegation_use_any.ps1 +++ b/ad/GOAD/scripts/constrained_delegation_use_any.ps1 @@ -15,5 +15,5 @@ Set-ADAccountControl -Identity $identity -TrustedToAuthForDelegation $true $missing = @($delegateTo | Where-Object { $user.'msDS-AllowedToDelegateTo' -notcontains $_ }) if ($missing.Count -gt 0) { - Set-ADUser -Identity $identity -Add @{'msDS-AllowedToDelegateTo' = $missing } + Set-ADUser -Identity $identity -Add @{'msDS-AllowedToDelegateTo' = [string[]]$missing } } diff --git a/ansible/playbooks/diagnose-dc01.yml b/ansible/playbooks/diagnose-dc01.yml deleted file mode 100644 index c9a279f7..00000000 --- a/ansible/playbooks/diagnose-dc01.yml +++ /dev/null @@ -1,300 +0,0 @@ ---- -# Diagnostic playbook to check dc01 (guardian-app) status from another host -# Run against dc03 or srv03 (vortexindustries.local) which are independent of deltasystems.local -# -# Usage: -# # First, ensure the AWS mapping file exists: -# ansible-playbook -i dev-inventory ansible/playbooks/network_setup.yml --limit dc03 -# -# # Then run diagnostics: -# ansible-playbook -i dev-inventory ansible/diagnose-dc01.yml --limit dc03 -# -# # Or pass the IP directly if you know it: -# ansible-playbook -i dev-inventory ansible/diagnose-dc01.yml --limit dc03 -e dc01_ip=10.x.x.x - -- name: Load data for target hosts only - hosts: dc03,srv03 - gather_facts: true - serial: 1 - tasks: - - name: Load JSON config - ansible.builtin.include_vars: - file: "{{ data_path }}/{{ env }}-config.json" - run_once: true - - - name: Load AWS instance mapping if available - ansible.builtin.include_vars: - file: "/tmp/aws_instance_mapping_{{ env }}.json" - name: aws_mapping - ignore_errors: true - - - name: Set instance_to_ip from AWS mapping - ansible.builtin.set_fact: - instance_to_ip: "{{ aws_mapping.instance_to_ip | default({}) }}" - when: aws_mapping is defined - -- name: Diagnose dc01 (deltasystems.local DC) from remote host - hosts: dc03,srv03 - gather_facts: false - vars: - target_dc: "{{ hostvars['dc01']['ansible_host'] }}" - target_hostname: "guardian-app" - target_domain: "deltasystems.local" - # Get dc01's IP - can be overridden with -e dc01_ip=x.x.x.x - dc01_ip: "{{ dc01_ip_override | default(instance_to_ip[target_dc] | default('')) }}" - - tasks: - - name: Check if we have dc01's IP - ansible.builtin.fail: - msg: | - Cannot determine dc01's IP address! - - The instance_to_ip mapping doesn't have an entry for {{ target_dc }}. - - Options: - 1. Run network_setup.yml first to populate the mapping: - ansible-playbook -i dev-inventory ansible/playbooks/network_setup.yml --limit dc03 - - 2. Pass the IP directly: - ansible-playbook -i dev-inventory ansible/diagnose-dc01.yml --limit dc03 -e dc01_ip_override=10.x.x.x - - 3. Get the IP from AWS console or CLI: - aws ec2 describe-instances --instance-ids {{ target_dc }} --query 'Reservations[0].Instances[0].PrivateIpAddress' - when: dc01_ip == '' or dc01_ip == 'unknown' - - - name: Display diagnostic target info - ansible.builtin.debug: - msg: | - Diagnosing dc01 from {{ inventory_hostname }} - Target instance ID: {{ target_dc }} - Target IP: {{ dc01_ip }} - Expected hostname: {{ target_hostname }} - Expected domain: {{ target_domain }} - - - name: Test basic network connectivity to dc01 - ansible.windows.win_powershell: - script: | - $ProgressPreference = 'SilentlyContinue' - $targetIP = "{{ dc01_ip }}" - - $results = @{ - ping = $null - ports = @{} - dns_resolution = $null - } - - # Test ping - Write-Host "Testing ping to $targetIP..." - $ping = Test-Connection -ComputerName $targetIP -Count 2 -ErrorAction SilentlyContinue - $results.ping = if ($ping) { "SUCCESS - RTT: $($ping[0].ResponseTime)ms" } else { "FAILED" } - - # Test critical ports - $ports = @{ - "DNS (53/TCP)" = 53 - "Kerberos (88/TCP)" = 88 - "RPC (135/TCP)" = 135 - "LDAP (389/TCP)" = 389 - "SMB (445/TCP)" = 445 - "LDAPS (636/TCP)" = 636 - "GC (3268/TCP)" = 3268 - } - - foreach ($portName in $ports.Keys) { - $port = $ports[$portName] - Write-Host "Testing $portName..." - $test = Test-NetConnection -ComputerName $targetIP -Port $port -WarningAction SilentlyContinue -ErrorAction SilentlyContinue - $results.ports[$portName] = if ($test.TcpTestSucceeded) { "OPEN" } else { "CLOSED/FILTERED" } - } - - # Test DNS resolution of the domain - Write-Host "Testing DNS resolution of {{ target_domain }}..." - try { - $dns = Resolve-DnsName -Name "{{ target_domain }}" -Server $targetIP -ErrorAction Stop - $results.dns_resolution = "SUCCESS - Resolved to: $($dns.IPAddress -join ', ')" - } catch { - $results.dns_resolution = "FAILED - $($_.Exception.Message)" - } - - $Ansible.Result = $results - $Ansible.Changed = $false - register: network_test - failed_when: false - - - name: Display network test results - ansible.builtin.debug: - msg: | - === NETWORK CONNECTIVITY RESULTS === - Ping: {{ network_test.result.ping }} - - Port Status: - {% for port, status in network_test.result.ports.items() %} - {{ port }}: {{ status }} - {% endfor %} - - DNS Resolution of {{ target_domain }}: {{ network_test.result.dns_resolution }} - - - name: Test LDAP connectivity to dc01 - ansible.windows.win_powershell: - script: | - $ProgressPreference = 'SilentlyContinue' - $targetIP = "{{ dc01_ip }}" - $domain = "{{ target_domain }}" - - $results = @{ - ldap_anonymous = $null - ldap_rootdse = $null - dc_locator = $null - } - - # Test anonymous LDAP bind (RootDSE) - Write-Host "Testing LDAP RootDSE query..." - try { - $ldap = [ADSI]"LDAP://${targetIP}/RootDSE" - $results.ldap_rootdse = @{ - defaultNamingContext = $ldap.defaultNamingContext.ToString() - dnsHostName = $ldap.dnsHostName.ToString() - serverName = $ldap.serverName.ToString() - isGlobalCatalogReady = $ldap.isGlobalCatalogReady.ToString() - } - } catch { - $results.ldap_rootdse = "FAILED - $($_.Exception.Message)" - } - - # Test DC Locator - Write-Host "Testing DC Locator for $domain..." - try { - $dcInfo = nltest /dsgetdc:$domain /server:$targetIP 2>&1 - $results.dc_locator = $dcInfo -join "`n" - } catch { - $results.dc_locator = "FAILED - $($_.Exception.Message)" - } - - $Ansible.Result = $results - $Ansible.Changed = $false - register: ldap_test - failed_when: false - - - name: Display LDAP test results - ansible.builtin.debug: - msg: | - === LDAP CONNECTIVITY RESULTS === - - RootDSE Query: - {{ ldap_test.result.ldap_rootdse | to_nice_yaml if ldap_test.result.ldap_rootdse is mapping else ldap_test.result.ldap_rootdse }} - - DC Locator (nltest): - {{ ldap_test.result.dc_locator }} - - - name: Test WinRM/PowerShell remoting to dc01 - ansible.windows.win_powershell: - script: | - $ProgressPreference = 'SilentlyContinue' - $targetIP = "{{ dc01_ip }}" - - $results = @{ - winrm_test = $null - remote_services = $null - remote_ad_status = $null - } - - # Note: This requires credentials and WinRM to be configured - # We'll test if WinRM port is open first - Write-Host "Testing WinRM port (5985)..." - $winrmTest = Test-NetConnection -ComputerName $targetIP -Port 5985 -WarningAction SilentlyContinue - $results.winrm_test = if ($winrmTest.TcpTestSucceeded) { "Port 5985 OPEN" } else { "Port 5985 CLOSED" } - - Write-Host "Testing WinRM HTTPS port (5986)..." - $winrmHttpsTest = Test-NetConnection -ComputerName $targetIP -Port 5986 -WarningAction SilentlyContinue - $results.winrm_https = if ($winrmHttpsTest.TcpTestSucceeded) { "Port 5986 OPEN" } else { "Port 5986 CLOSED" } - - $Ansible.Result = $results - $Ansible.Changed = $false - register: winrm_test - failed_when: false - - - name: Display WinRM test results - ansible.builtin.debug: - msg: | - === WINRM RESULTS === - WinRM HTTP (5985): {{ winrm_test.result.winrm_test }} - WinRM HTTPS (5986): {{ winrm_test.result.winrm_https }} - - - name: Check if this host can resolve deltasystems.local - ansible.windows.win_powershell: - script: | - $ProgressPreference = 'SilentlyContinue' - - $results = @{ - current_dns_servers = (Get-DnsClientServerAddress -AddressFamily IPv4 | Where-Object { $_.ServerAddresses } | Select-Object -ExpandProperty ServerAddresses) -join ", " - resolve_deltasystems = $null - resolve_guardian_app = $null - } - - # Try to resolve deltasystems.local - try { - $dns = Resolve-DnsName -Name "deltasystems.local" -ErrorAction Stop - $results.resolve_deltasystems = "SUCCESS - $($dns.IPAddress -join ', ')" - } catch { - $results.resolve_deltasystems = "FAILED - $($_.Exception.Message)" - } - - # Try to resolve guardian-app.deltasystems.local - try { - $dns = Resolve-DnsName -Name "guardian-app.deltasystems.local" -ErrorAction Stop - $results.resolve_guardian_app = "SUCCESS - $($dns.IPAddress -join ', ')" - } catch { - $results.resolve_guardian_app = "FAILED - $($_.Exception.Message)" - } - - $Ansible.Result = $results - $Ansible.Changed = $false - register: dns_from_here - failed_when: false - - - name: Display DNS resolution from this host - ansible.builtin.debug: - msg: | - === DNS RESOLUTION FROM {{ inventory_hostname }} === - Current DNS Servers: {{ dns_from_here.result.current_dns_servers }} - Resolve deltasystems.local: {{ dns_from_here.result.resolve_deltasystems }} - Resolve guardian-app.deltasystems.local: {{ dns_from_here.result.resolve_guardian_app }} - - - name: Summary and recommendations - ansible.builtin.debug: - msg: | - =============================================== - DIAGNOSTIC SUMMARY FOR DC01 (guardian-app) - =============================================== - - Based on the tests above, check: - - 1. If LDAP ports (389, 636) are CLOSED: - - AD DS services are not running on dc01 - - DC promotion likely failed or services crashed - - 2. If DNS port (53) is CLOSED: - - DNS service not running - - DC can't serve DNS queries - - 3. If RootDSE query FAILED: - - AD DS is definitely not functional - - Machine may be stuck in partial promotion state - - 4. If DC Locator shows ERROR_NO_SUCH_DOMAIN: - - Confirms dc01 is not a functioning DC - - AD DS promotion never completed or failed - - NEXT STEPS: - ----------- - If dc01 is broken, you may need to: - a) SSH/RDP directly to dc01 and check services: - - Get-Service NTDS, DNS, Netlogon, ADWS - - Get-WindowsFeature AD-Domain-Services - - b) Check Event Viewer for AD DS errors: - - Get-EventLog -LogName "Directory Service" -Newest 50 - - c) Re-run the domain_controller role against dc01: - - ansible-playbook -i dev-inventory ansible/ad-parent_domain.yml --limit dc01 - - d) If completely broken, may need to rebuild dc01 from scratch diff --git a/ansible/plugins/modules/win_ad_object.ps1 b/ansible/plugins/modules/win_ad_object.ps1 index 7a14ebe0..bf03d5fc 100755 --- a/ansible/plugins/modules/win_ad_object.ps1 +++ b/ansible/plugins/modules/win_ad_object.ps1 @@ -97,9 +97,10 @@ if ($null -eq $existing_obj) { # Now set the mayContain attributes, we do a last check on existing_obj in case we are in check mode if ($null -ne $may_contain -and $null -ne $existing_obj) { + $current_may_contain = @($existing_obj.mayContain | Where-Object { $_ -ne $null }) foreach ($may_contain_entry in $may_contain) { - if (-not $existing_obj.mayContain.Contains($may_contain_entry)) { - Set-ADObject -Identity $existing_obj.ObjectGuid -Add @{ mayContain = $may_contain_entry } @common_params > $null + if ($may_contain_entry -notin $current_may_contain) { + Set-ADObject -Identity $existing_obj.ObjectGuid -Add @{ mayContain = [string]$may_contain_entry } @common_params > $null $result.changed = $true } } diff --git a/ansible/roles/child_domain/README.md b/ansible/roles/child_domain/README.md index 92486f12..bb9aed1e 100644 --- a/ansible/roles/child_domain/README.md +++ b/ansible/roles/child_domain/README.md @@ -23,9 +23,11 @@ Promote a Windows server as a child domain controller - **Configure DNS listener addresses** (ansible.windows.win_powershell) - Conditional - **Enable TLS 1.2 permanently via registry** (ansible.windows.win_regedit) - **Check if xDnsServer exists** (ansible.windows.win_shell) +- **Unblock module DLLs before xDnsServer install** (ansible.windows.win_shell) - Conditional - **Install xDnsServer only if needed** (community.windows.win_psmodule) - Conditional - **Configure DNS Forwarders** (ansible.windows.win_dsc) - **Check if ActiveDirectoryDSC exists** (ansible.windows.win_shell) +- **Unblock module DLLs before ActiveDirectoryDSC install** (ansible.windows.win_shell) - Conditional - **Install ActiveDirectoryDSC only if needed** (community.windows.win_psmodule) - Conditional - **Enable the Active Directory Web Services** (ansible.windows.win_service) diff --git a/ansible/roles/child_domain/tasks/main.yml b/ansible/roles/child_domain/tasks/main.yml index 3d53ddb0..33202d75 100644 --- a/ansible/roles/child_domain/tasks/main.yml +++ b/ansible/roles/child_domain/tasks/main.yml @@ -80,6 +80,12 @@ failed_when: false changed_when: false +- name: Unblock module DLLs before xDnsServer install + ansible.windows.win_shell: | + Get-ChildItem -Path 'C:\Program Files\WindowsPowerShell\Modules' -Recurse -ErrorAction SilentlyContinue | + Unblock-File -ErrorAction SilentlyContinue + when: xdnsserver_check.rc != 0 + - name: Install xDnsServer only if needed community.windows.win_psmodule: name: xDnsServer @@ -104,6 +110,12 @@ failed_when: false changed_when: false +- name: Unblock module DLLs before ActiveDirectoryDSC install + ansible.windows.win_shell: | + Get-ChildItem -Path 'C:\Program Files\WindowsPowerShell\Modules' -Recurse -ErrorAction SilentlyContinue | + Unblock-File -ErrorAction SilentlyContinue + when: activedirectorydsc_check.rc != 0 + - name: Install ActiveDirectoryDSC only if needed community.windows.win_psmodule: name: ActiveDirectoryDSC diff --git a/ansible/roles/common/README.md b/ansible/roles/common/README.md index b3aae793..da19e8c2 100644 --- a/ansible/roles/common/README.md +++ b/ansible/roles/common/README.md @@ -25,10 +25,10 @@ Apply common Windows configuration settings for domain-joined hosts - **Set a proxy for specific protocols** (ansible.windows.win_inet_proxy) - Conditional - **Configure IE to use a specific proxy per protocol** (ansible.windows.win_inet_proxy) - Conditional - **Install DSC modules (skip on prebaked AMIs)** (block) - Conditional +- **Unblock PackageManagement DLLs (MOTW causes 0x8000FFFF on Install-Module)** (ansible.windows.win_shell) - **Upgrade module PowerShellGet to fix accept license issue** (ansible.windows.win_shell) - **Check all required modules** (ansible.windows.win_shell) -- **Install all missing modules in parallel** (community.windows.win_psmodule) - Conditional -- **Wait for module installations to complete** (ansible.builtin.async_status) - Conditional +- **Install all missing modules** (community.windows.win_psmodule) - Conditional - **Verify DSC LCM is ready** (ansible.windows.win_powershell) - **Enable RDP (skip on prebaked AMIs)** (block) - Conditional - **Windows ¦ Enable Remote Desktop** (ansible.windows.win_dsc) diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml index b7f063c8..14e532cc 100644 --- a/ansible/roles/common/tasks/main.yml +++ b/ansible/roles/common/tasks/main.yml @@ -26,6 +26,11 @@ - name: Install DSC modules (skip on prebaked AMIs) when: not ((goad_prebaked | default({})).dsc_modules | default(false)) block: + - name: Unblock PackageManagement DLLs (MOTW causes 0x8000FFFF on Install-Module) + ansible.windows.win_shell: | + Get-ChildItem -Path 'C:\Program Files\WindowsPowerShell\Modules' -Recurse -ErrorAction SilentlyContinue | + Unblock-File -ErrorAction SilentlyContinue + - name: Upgrade module PowerShellGet to fix accept license issue ansible.windows.win_shell: | $ProgressPreference = 'SilentlyContinue' @@ -33,9 +38,7 @@ [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 Install-PackageProvider -Name NuGet -Force -Confirm:$false Install-Module PowerShellGet -Force -Confirm:$false - # Newly-installed DLLs sometimes carry Mark-of-the-Web from the .nupkg - # download, which makes Microsoft.PackageManagement.dll fail to load on - # the next Install-Module call ("Catastrophic failure" / 0x8000FFFF). + # Re-unblock: the installs above may have written new MOTW-flagged DLLs. Get-ChildItem -Path 'C:\Program Files\WindowsPowerShell\Modules' -Recurse -ErrorAction SilentlyContinue | Unblock-File -ErrorAction SilentlyContinue register: powershellget_install @@ -66,7 +69,7 @@ failed_when: false changed_when: false - - name: Install all missing modules in parallel + - name: Install all missing modules community.windows.win_psmodule: name: "{{ item | trim }}" state: present @@ -82,26 +85,9 @@ - item | trim != '' - item | trim != 'ALL_INSTALLED' register: module_install - async: 600 - poll: 0 - vars: - ansible_win_async_startup_timeout: 30 - - - name: Wait for module installations to complete - ansible.builtin.async_status: - jid: "{{ async_result_item.ansible_job_id }}" - loop: "{{ module_install.results }}" - loop_control: - loop_var: "async_result_item" - label: "Waiting for module: {{ async_result_item.item | default('unknown') | trim }}" - register: async_poll_results - until: async_poll_results.finished - retries: 120 - delay: 5 - when: - - module_install is not skipped - - async_result_item.ansible_job_id is defined - failed_when: async_poll_results.finished and async_poll_results.failed + retries: 3 + delay: 10 + until: module_install is not failed - name: Verify DSC LCM is ready ansible.windows.win_powershell: diff --git a/ansible/roles/domain_controller/README.md b/ansible/roles/domain_controller/README.md index 054e2dea..be9ecc19 100644 --- a/ansible/roles/domain_controller/README.md +++ b/ansible/roles/domain_controller/README.md @@ -30,10 +30,12 @@ Promote a Windows server as a primary domain controller - **Ensure DNS feature is installed** (ansible.windows.win_feature) - **Reboot if DNS feature installation requires it** (ansible.windows.win_reboot) - Conditional - **Check if xDnsServer exists** (ansible.windows.win_shell) +- **Unblock module DLLs before xDnsServer install** (ansible.windows.win_shell) - Conditional - **Install xDnsServer PowerShell module** (community.windows.win_psmodule) - Conditional - **Configure DNS listener addresses** (ansible.windows.win_powershell) - Conditional - **Configure DNS Forwarders** (ansible.windows.win_powershell) - Conditional - **Check if ActiveDirectoryDSC exists** (ansible.windows.win_shell) +- **Unblock module DLLs before ActiveDirectoryDSC install** (ansible.windows.win_shell) - Conditional - **Install ActiveDirectoryDSC only if needed** (community.windows.win_psmodule) - Conditional - **Enable the Active Directory Web Services** (ansible.windows.win_service) - **Ensure admin groups are properly configured** (block) diff --git a/ansible/roles/domain_controller/tasks/main.yml b/ansible/roles/domain_controller/tasks/main.yml index 65cf7808..ceea97e1 100644 --- a/ansible/roles/domain_controller/tasks/main.yml +++ b/ansible/roles/domain_controller/tasks/main.yml @@ -211,6 +211,12 @@ failed_when: false changed_when: false +- name: Unblock module DLLs before xDnsServer install + ansible.windows.win_shell: | + Get-ChildItem -Path 'C:\Program Files\WindowsPowerShell\Modules' -Recurse -ErrorAction SilentlyContinue | + Unblock-File -ErrorAction SilentlyContinue + when: xdnsserver_check.rc != 0 + - name: Install xDnsServer PowerShell module community.windows.win_psmodule: name: xDnsServer @@ -272,6 +278,12 @@ failed_when: false changed_when: false +- name: Unblock module DLLs before ActiveDirectoryDSC install + ansible.windows.win_shell: | + Get-ChildItem -Path 'C:\Program Files\WindowsPowerShell\Modules' -Recurse -ErrorAction SilentlyContinue | + Unblock-File -ErrorAction SilentlyContinue + when: ad_dsc_check.rc != 0 + - name: Install ActiveDirectoryDSC only if needed community.windows.win_psmodule: name: ActiveDirectoryDSC diff --git a/ansible/roles/groups_domains/README.md b/ansible/roles/groups_domains/README.md index bdfe2032..31a695db 100644 --- a/ansible/roles/groups_domains/README.md +++ b/ansible/roles/groups_domains/README.md @@ -19,6 +19,7 @@ Create and configure Active Directory groups across domains - **Reboot and wait for the AD system to restart** (block) - **Trigger reboot via win_reboot** (ansible.windows.win_reboot) - **Synchronize all domains with proper credentials** (ansible.windows.win_powershell) +- **Wait for trust secure channel to peer domains** (ansible.windows.win_powershell) - Conditional - **Add cross-domain users/groups using PowerShell Direct** (ansible.windows.win_powershell) - Conditional ## Example Playbook diff --git a/ansible/roles/groups_domains/tasks/main.yml b/ansible/roles/groups_domains/tasks/main.yml index b7049788..0329e0c3 100644 --- a/ansible/roles/groups_domains/tasks/main.yml +++ b/ansible/roles/groups_domains/tasks/main.yml @@ -46,7 +46,7 @@ try { $output = repadmin /syncall /APeD 2>&1 if ($LASTEXITCODE -ne 0) { - throw "repadmin exited with code $LASTEXITCODE: $output" + throw "repadmin exited with code ${LASTEXITCODE}: $output" } Write-Output "Replication initiated with repadmin: $output" return $true @@ -93,6 +93,36 @@ vars: ansible_become: false +# The Netlogon secure channel that backs cross-domain LDAP auth takes +# measurably longer to establish than the trust object itself — on Azure +# with WinRM/PSRP over Bastion it can exceed 3+ minutes after reboot. +# Poll nltest until every peer domain's channel is healthy before +# attempting the cross-domain group membership adds. +- name: Wait for trust secure channel to peer domains + ansible.windows.win_powershell: + script: | + [CmdletBinding()] param() + $peers = @( + {% for members in domain_groups_members.values() %} + {% for m in members %} + "{{ m.split('\\')[0] }}"{% if not loop.last %},{% endif %} + {% endfor %}{% if not loop.last %},{% endif %} + {% endfor %} + ) | Sort-Object -Unique + + foreach ($domain in $peers) { + $out = nltest /sc_query:$domain 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Secure channel to $domain not ready (nltest exit $LASTEXITCODE): $out" + } + Write-Output "Secure channel to $domain: OK" + } + register: trust_channel_check + until: trust_channel_check is succeeded + retries: 30 + delay: 20 + when: domain_groups_members is defined and domain_groups_members | length > 0 + - name: Add cross-domain users/groups using PowerShell Direct ansible.windows.win_powershell: parameters: @@ -243,6 +273,6 @@ error_action: stop register: cross_domain_result until: cross_domain_result is succeeded - retries: 3 + retries: 5 delay: 60 when: domain_groups_members is defined and domain_groups_members | length > 0 diff --git a/ansible/roles/vulns_adcs_esc7/README.md b/ansible/roles/vulns_adcs_esc7/README.md index d082c55a..ad43ae1a 100644 --- a/ansible/roles/vulns_adcs_esc7/README.md +++ b/ansible/roles/vulns_adcs_esc7/README.md @@ -18,6 +18,7 @@ ADCS ESC7 - Grant ManageCA rights for CA officer abuse - **Read installed .NET Framework release key** (ansible.windows.win_reg_stat) - **Install .NET Framework 4.8 (PSPKI 4.x requires >=4.7.2)** (chocolatey.chocolatey.win_chocolatey) - Conditional - **Reboot to complete .NET Framework upgrade** (ansible.windows.win_reboot) - Conditional +- **Unblock module DLLs before NuGet/PSPKI install** (ansible.windows.win_shell) - **Ensure NuGet provider is installed** (ansible.windows.win_shell) - **Install module PSPKI** (ansible.windows.win_powershell) - **ADD ManageCA rights** (ansible.windows.win_powershell) diff --git a/ansible/roles/vulns_adcs_esc7/tasks/main.yml b/ansible/roles/vulns_adcs_esc7/tasks/main.yml index 060696ea..47c78050 100644 --- a/ansible/roles/vulns_adcs_esc7/tasks/main.yml +++ b/ansible/roles/vulns_adcs_esc7/tasks/main.yml @@ -21,6 +21,11 @@ reboot_timeout: 1200 when: _dotnetfx_install is changed +- name: Unblock module DLLs before NuGet/PSPKI install + ansible.windows.win_shell: | + Get-ChildItem -Path 'C:\Program Files\WindowsPowerShell\Modules' -Recurse -ErrorAction SilentlyContinue | + Unblock-File -ErrorAction SilentlyContinue + - name: Ensure NuGet provider is installed ansible.windows.win_shell: | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 diff --git a/cli/cmd/bastion.go b/cli/cmd/bastion.go index 2e9a6cf1..f821a4ec 100644 --- a/cli/cmd/bastion.go +++ b/cli/cmd/bastion.go @@ -103,7 +103,10 @@ func init() { func azureClientFromProvider(prov provider.Provider) (*azure.Client, error) { ap, ok := prov.(*azure.AzureProvider) if !ok { - return nil, fmt.Errorf("bastion requires the Azure provider; got %s", prov.Name()) + // Phrased without naming a command: lab describe shares this helper, + // and wrapping it there produced "requires the Azure provider: bastion + // requires the Azure provider". + return nil, fmt.Errorf("this command requires the Azure provider; got %s", prov.Name()) } return ap.Client(), nil } diff --git a/cli/cmd/diagnose.go b/cli/cmd/diagnose.go deleted file mode 100644 index 702b6bcf..00000000 --- a/cli/cmd/diagnose.go +++ /dev/null @@ -1,89 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "os" - "path/filepath" - "time" - - "github.com/dreadnode/dreadgoad/internal/ansible" - "github.com/dreadnode/dreadgoad/internal/config" - "github.com/spf13/cobra" -) - -var diagnoseCmd = &cobra.Command{ - Use: "diagnose", - Short: "Run diagnostic checks against domain controllers", - Long: `Runs the diagnose-dc01 playbook from an independent host to verify -network connectivity, LDAP, WinRM, and DNS for the primary domain controller. - -Diagnostics run from dc03/srv03 (vortexindustries domain) to test dc01 -(deltasystems domain) connectivity with detailed troubleshooting output.`, - Example: ` dreadgoad diagnose - dreadgoad diagnose --dc01-ip 10.0.1.10 - dreadgoad diagnose --env staging --debug`, - RunE: runDiagnose, -} - -func init() { - rootCmd.AddCommand(diagnoseCmd) - - diagnoseCmd.Flags().String("dc01-ip", "", "Override dc01 IP address (skips AWS lookup)") -} - -func runDiagnose(cmd *cobra.Command, args []string) error { - cfg, err := config.Get() - if err != nil { - return err - } - ctx := context.Background() - - dc01IP, _ := cmd.Flags().GetString("dc01-ip") - - _ = os.MkdirAll(cfg.LogDir, 0o755) - logFile := filepath.Join(cfg.LogDir, fmt.Sprintf("%s-diagnose-%s.log", - cfg.Env, time.Now().Format("20060102_150405"))) - - fmt.Println("===============================================") - fmt.Printf("DreadGOAD DC01 Diagnostics - %s\n", time.Now().Format(time.RFC3339)) - fmt.Printf("Environment: %s\n", cfg.Env) - fmt.Printf("Log file: %s\n", logFile) - fmt.Println("===============================================") - - opts := ansible.RunOptions{ - Playbook: "diagnose-dc01.yml", - Env: cfg.Env, - Debug: cfg.Debug, - LogFile: logFile, - } - - if dc01IP != "" { - opts.ExtraVars = map[string]string{ - "dc01_ip_override": dc01IP, - } - fmt.Printf("Using dc01 IP override: %s\n", dc01IP) - } - - fmt.Println("Running diagnostics...") - fmt.Println("-----------------------------------------------") - - result := ansible.RunPlaybook(ctx, opts) - - fmt.Println("===============================================") - if result.Success { - fmt.Println("Diagnostics completed successfully.") - } else { - fmt.Println("Diagnostics detected issues. Review output above for details.") - if result.TimedOut { - fmt.Println("WARNING: Diagnostic playbook timed out.") - } - } - fmt.Printf("Full log: %s\n", logFile) - fmt.Println("===============================================") - - if !result.Success { - return fmt.Errorf("diagnostics failed (exit code %d)", result.ExitCode) - } - return nil -} diff --git a/cli/cmd/doctor.go b/cli/cmd/doctor.go index 7a675b6d..9d11a3b0 100644 --- a/cli/cmd/doctor.go +++ b/cli/cmd/doctor.go @@ -17,7 +17,11 @@ Common checks: ansible-core version, Python, jq, Ansible collections, inventory. Provider-specific: aws (default) AWS CLI, AWS credentials, Terragrunt, Terraform/Tofu - azure Azure CLI, az login session, az network bastion, Terragrunt, Terraform/Tofu + azure Azure CLI, az login session, az network bastion, Terragrunt, Terraform/Tofu, + plus VM size availability and vCPU quota in the configured region — the + SkuNotAvailable failure that otherwise only appears minutes into apply. + Both are reported as warnings: capacity is real-time and can change + between this check and the deploy. ludus Ludus CLI (or SSH reachability when ludus.ssh_host is set), API key`, RunE: func(cmd *cobra.Command, args []string) error { if config.ConfigMissing() { @@ -46,6 +50,10 @@ Provider-specific: cfg.Ludus.SSHPort == 0, }, }) + // Azure capacity/quota. Appended rather than folded into RunChecks: it needs a + // provider client, and internal/doctor importing internal/azure to build one + // would drag the cloud SDK into every provider's pre-flight path. + results = append(results, azureCapacityChecks(cfg)...) failed := doctor.PrintResults(results) if failed > 0 { diff --git a/cli/cmd/doctor_capacity.go b/cli/cmd/doctor_capacity.go new file mode 100644 index 00000000..c1aaa232 --- /dev/null +++ b/cli/cmd/doctor_capacity.go @@ -0,0 +1,191 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/dreadnode/dreadgoad/internal/azure" + "github.com/dreadnode/dreadgoad/internal/config" + "github.com/dreadnode/dreadgoad/internal/doctor" + "github.com/dreadnode/dreadgoad/internal/terragrunt" +) + +// capacityCheckTimeout bounds the two ARM reads. A pre-flight check that hangs +// is worse than one that is skipped: `up` cannot start until it returns. +const capacityCheckTimeout = 30 * time.Second + +// azureCapacityChecks reports whether the region can actually supply the VM +// sizes this environment asks for. +// +// This exists because SkuNotAvailable only otherwise surfaces minutes into +// `tofu apply`, after the network and bastion are already built — Azure +// publishes the same restriction on the Resource SKUs API before anything is +// created. Two reads, both free. +// +// Every result is pass or warn, never fail. `doctor.PrintResults` turns a fail +// into an aborted `up`, and neither signal is certain enough for that: capacity +// is real-time, so a restriction can clear between this check and the apply, +// and a quota reading can be shadowed by limits this does not model. +func azureCapacityChecks(cfg *config.Config) []doctor.CheckResult { + if cfg.ResolvedProvider() != "azure" { + return nil + } + region, err := cfg.ResolveRegion() + if err != nil { + return []doctor.CheckResult{{ + Name: "Azure capacity", + Status: "warn", + Message: "no region configured, so capacity could not be checked", + }} + } + + envDir := filepath.Join(cfg.ProjectRoot, "infra", "azure", cfg.Infra.Deployment, cfg.Env) + if _, err := os.Stat(envDir); err != nil { + return []doctor.CheckResult{{ + Name: "Azure capacity", + Status: "warn", + Message: fmt.Sprintf( + "no scaffolding at %s, so the requested VM sizes are unknown", envDir), + }} + } + req, err := terragrunt.RequestedSizes(envDir) + if err != nil || len(req.Sizes) == 0 { + return []doctor.CheckResult{{ + Name: "Azure capacity", + Status: "warn", + Message: fmt.Sprintf( + "could not read the VM sizes from %s, so capacity was not checked", envDir), + }} + } + + ctx, cancel := context.WithTimeout(context.Background(), capacityCheckTimeout) + defer cancel() + + client, err := azureClientForCapacity(ctx, cfg) + if err != nil { + return []doctor.CheckResult{{ + Name: "Azure capacity", + Status: "warn", + Message: fmt.Sprintf("could not reach Azure to check capacity: %v", err), + }} + } + + // One SKU read feeds both checks: the availability verdict and the vCPU + // count the quota comparison needs. + statuses, skuErr := client.SKUAvailability(ctx, region, req.Sizes) + results := []doctor.CheckResult{skuCheck(statuses, skuErr, region, req, cfg.Infra.Deployment, cfg.Env)} + if q := quotaCheck(ctx, client, region, req, statuses); q != nil { + results = append(results, *q) + } + return results +} + +func azureClientForCapacity(ctx context.Context, cfg *config.Config) (*azure.Client, error) { + prov, err := cfg.NewProvider(ctx) + if err != nil { + return nil, err + } + return azureClientFromProvider(prov) +} + +func skuCheck( + statuses []azure.SKUStatus, err error, region string, + req terragrunt.Requested, deployment, env string, +) doctor.CheckResult { + if err != nil { + return doctor.CheckResult{ + Name: "Azure VM size availability", + Status: "warn", + Message: fmt.Sprintf("could not read SKU availability in %s: %v", region, err), + } + } + + var blocked, zonal []string + for _, s := range statuses { + switch { + case !s.Offered: + blocked = append(blocked, fmt.Sprintf("%s (not offered in %s)", s.Name, region)) + case len(s.Restrictions) > 0: + blocked = append(blocked, fmt.Sprintf("%s (%s)", s.Name, strings.Join(s.Restrictions, ", "))) + case len(s.RestrictedZones) > 0: + zonal = append(zonal, fmt.Sprintf("%s (zones %s)", s.Name, strings.Join(s.RestrictedZones, ","))) + } + } + + if len(blocked) > 0 { + return doctor.CheckResult{ + Name: "Azure VM size availability", + Status: "warn", + Message: fmt.Sprintf( + "%s unavailable in %s — `up` will likely fail with SkuNotAvailable. "+ + "Change the size in infra/azure/%s/%s/env.hcl and the unit terragrunt.hcl "+ + "files, or deploy to another region.", + strings.Join(blocked, "; "), region, deployment, env), + } + } + msg := fmt.Sprintf("%s available in %s", strings.Join(req.Sizes, ", "), region) + if len(zonal) > 0 { + // Not a blocker: the lab's units do not pin a zone, so Azure places the + // VM in one that is not restricted. + msg += fmt.Sprintf(" (zone-restricted: %s)", strings.Join(zonal, "; ")) + } + return doctor.CheckResult{Name: "Azure VM size availability", Status: "pass", Message: msg} +} + +// quotaCheck compares the range's core count against the region's vCPU quota. +// Returns nil when Azure does not report a total-cores counter, rather than +// inventing a pass for something it could not measure. +func quotaCheck( + ctx context.Context, client *azure.Client, region string, + req terragrunt.Requested, statuses []azure.SKUStatus, +) *doctor.CheckResult { + items, err := client.RegionQuota(ctx, region) + if err != nil { + return &doctor.CheckResult{ + Name: "Azure vCPU quota", + Status: "warn", + Message: fmt.Sprintf("could not read quota in %s: %v", region, err), + } + } + cores, ok := azure.FindQuota(items, "cores") + if !ok { + return nil + } + + // Sizes are usually uniform across the lab; when they are not, the largest + // is used for every VM so the estimate errs toward warning. + perVM := largestVCPU(statuses) + if perVM == 0 { + return nil + } + needed := int64(perVM) * int64(req.Units) + if needed > cores.Headroom() { + return &doctor.CheckResult{ + Name: "Azure vCPU quota", + Status: "warn", + Message: fmt.Sprintf( + "range needs ~%d vCPUs (%d VMs x %d) but %s has %d of %d left — request a quota increase or use a smaller size", + needed, req.Units, perVM, region, cores.Headroom(), cores.Limit), + } + } + return &doctor.CheckResult{ + Name: "Azure vCPU quota", + Status: "pass", + Message: fmt.Sprintf("~%d vCPUs needed, %d of %d free in %s", + needed, cores.Headroom(), cores.Limit, region), + } +} + +func largestVCPU(statuses []azure.SKUStatus) int32 { + var max int32 + for _, s := range statuses { + if s.VCPUs > max { + max = s.VCPUs + } + } + return max +} diff --git a/cli/cmd/doctor_capacity_test.go b/cli/cmd/doctor_capacity_test.go new file mode 100644 index 00000000..55730b5f --- /dev/null +++ b/cli/cmd/doctor_capacity_test.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/dreadnode/dreadgoad/internal/config" +) + +// Every path below returns before a client is built, so none of them touch the +// network. That is the property under test: a pre-flight check that cannot +// answer must degrade to a warning, never block `up` and never panic. + +func TestCapacityChecksSkipNonAzureProviders(t *testing.T) { + for _, provider := range []string{"aws", "proxmox", "ludus", ""} { + cfg := &config.Config{Provider: provider, Region: "eastus"} + if got := azureCapacityChecks(cfg); got != nil { + t.Errorf("provider %q produced %d Azure check(s); it must produce none", + provider, len(got)) + } + } +} + +func TestCapacityChecksWarnWithoutARegion(t *testing.T) { + cfg := &config.Config{Provider: "azure", Region: ""} + got := azureCapacityChecks(cfg) + if len(got) != 1 || got[0].Status != "warn" { + t.Fatalf("got %+v, want a single warn", got) + } + if !strings.Contains(got[0].Message, "region") { + t.Errorf("message does not say why: %q", got[0].Message) + } +} + +func TestCapacityChecksWarnWhenTheEnvIsNotScaffolded(t *testing.T) { + // The normal state for a brand-new environment: `up` runs before the + // terragrunt tree exists. Nothing to read, so nothing to assert about. + cfg := &config.Config{ + Provider: "azure", + Region: "eastus", + Env: "never-created", + ProjectRoot: t.TempDir(), + } + cfg.Infra.Deployment = "goad-deployment" + + got := azureCapacityChecks(cfg) + if len(got) != 1 || got[0].Status != "warn" { + t.Fatalf("got %+v, want a single warn", got) + } + if !strings.Contains(got[0].Message, "no scaffolding") { + t.Errorf("message does not name the cause: %q", got[0].Message) + } +} + +func TestCapacityChecksUseActiveEnvironmentRegion(t *testing.T) { + cfg := &config.Config{ + Provider: "azure", + Region: "eastus", + Env: "west", + ProjectRoot: t.TempDir(), + Environments: map[string]config.EnvironmentConfig{ + "west": {Region: "westus2"}, + }, + } + cfg.Infra.Deployment = "goad-deployment" + + got := azureCapacityChecks(cfg) + if len(got) != 1 || !strings.Contains(got[0].Message, "no scaffolding") { + t.Fatalf("active environment region was not resolved before the check: %+v", got) + } +} + +func TestCapacityChecksWarnWhenNoSizesAreDeclared(t *testing.T) { + // A tree that exists but declares no literal size — e.g. every size is + // interpolated. Reporting a pass here would claim the region was checked. + root := t.TempDir() + envDir := filepath.Join(root, "infra", "azure", "goad-deployment", "e1") + if err := os.MkdirAll(envDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(envDir, "env.hcl"), []byte("locals {}\n"), 0o600); err != nil { + t.Fatal(err) + } + cfg := &config.Config{Provider: "azure", Region: "eastus", Env: "e1", ProjectRoot: root} + cfg.Infra.Deployment = "goad-deployment" + + got := azureCapacityChecks(cfg) + if len(got) != 1 || got[0].Status != "warn" { + t.Fatalf("got %+v, want a single warn", got) + } + if !strings.Contains(got[0].Message, "could not read the VM sizes") { + t.Errorf("message does not name the cause: %q", got[0].Message) + } +} + +// Nothing this check emits may be "fail": doctor.PrintResults turns a fail into +// an aborted `up`, and none of these signals is certain enough to justify that. +func TestCapacityChecksNeverEmitFail(t *testing.T) { + cases := []*config.Config{ + {Provider: "azure", Region: ""}, + {Provider: "azure", Region: "eastus", Env: "nope", ProjectRoot: t.TempDir()}, + } + for _, cfg := range cases { + for _, r := range azureCapacityChecks(cfg) { + if r.Status == "fail" { + t.Errorf("check %q returned fail, which would abort up: %q", r.Name, r.Message) + } + } + } +} diff --git a/cli/cmd/env_cmd.go b/cli/cmd/env_cmd.go index a77b6864..636e4153 100644 --- a/cli/cmd/env_cmd.go +++ b/cli/cmd/env_cmd.go @@ -55,13 +55,14 @@ func init() { envCreateCmd.Flags().String("vpc-cidr", "", "VPC/VNet CIDR block (default: auto-assigned)") envCreateCmd.Flags().String("reference", "staging", "Reference environment to copy infrastructure from (default: staging for AWS, test for Azure)") envCreateCmd.Flags().Bool("variant", false, "Generate randomized variant config") + envCreateCmd.Flags().String("variant-source", defaultVariantSource, "Base lab to generate the variant from (with --variant)") envCreateCmd.Flags().Bool("force", false, "Overwrite existing environment") } func runEnvCreate(cmd *cobra.Command, args []string) error { envName := strings.TrimSpace(args[0]) - if envName == "" { - return fmt.Errorf("environment name cannot be empty") + if err := validateEnvName(envName); err != nil { + return err } cfg, err := config.Get() @@ -83,15 +84,72 @@ func runEnvCreate(cmd *cobra.Command, args []string) error { } useVariant, _ := cmd.Flags().GetBool("variant") force, _ := cmd.Flags().GetBool("force") + variantSource, _ := cmd.Flags().GetString("variant-source") + if strings.TrimSpace(variantSource) == "" { + variantSource = defaultVariantSource + } if vpcCIDR == "" { vpcCIDR = cfg.VpcCIDR(envName) } - return scaffoldEnv(cfg, envName, region, vpcCIDR, reference, useVariant, force) + return scaffoldEnv(cfg, envName, region, vpcCIDR, reference, variantSource, useVariant, force) +} + +// defaultVariantSource is the base lab a variant is generated from when the +// caller does not name one. Matches `variant generate --source`. +const defaultVariantSource = "ad/GOAD" + +// variantTargetFor returns the directory `--variant` will generate into. +// +// Derived as - rather than a literal "GOAD-" prefix, so a +// variant of ad/SCCM lands in ad/SCCM- instead of a GOAD-named directory +// holding an SCCM lab. For the default source this is byte-identical to the old +// behaviour (ad/GOAD -> GOAD-), so existing environments are unaffected; +// only the sources that --variant-source newly made reachable differ. +// +// The target is derived rather than accepted as a flag so it cannot disagree +// with the source and environment it belongs to — and so it matches what the +// console writes into variant_target for the same pair. +func variantTargetFor(projectRoot, envName, variantSource string) string { + source := variantSource + if source == "" { + source = defaultVariantSource + } + return filepath.Join(projectRoot, "ad", filepath.Base(source)+"-"+envName) +} + +// envNameRe is what an environment name may contain. The name is not just a +// label: it becomes a directory under infra/, the {env}-inventory filename, the +// ad/GOAD-{env} variant tree, and part of the deployed Azure resource names. +// +// Dots are deliberately allowed — "3.1" and "dg-test-2.A" are real environment +// names. They used to break variant resolution, but that was viper splitting +// config keys on ".", fixed in config.repairDottedEnvironmentKeys rather than by +// forbidding the character. +var envNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +// validateEnvName rejects names that would escape or corrupt the paths built +// from them, before any directory is created. +func validateEnvName(name string) error { + if name == "" { + return fmt.Errorf("environment name cannot be empty") + } + // Checked ahead of the pattern so traversal gets a message that names the + // actual problem rather than a generic "invalid character". + if name == "." || name == ".." || strings.ContainsAny(name, `/\`) { + return fmt.Errorf("environment name %q would escape the project directory", name) + } + if !envNameRe.MatchString(name) { + return fmt.Errorf( + "environment name %q is not usable as a directory and file name\n"+ + " use letters, digits, dot, hyphen or underscore, starting with a letter or digit (e.g. 3.1, dg-test-2.A)", + name) + } + return nil } -func scaffoldEnv(cfg *config.Config, envName, region, vpcCIDR, reference string, useVariant, force bool) error { +func scaffoldEnv(cfg *config.Config, envName, region, vpcCIDR, reference, variantSource string, useVariant, force bool) error { provider := cfg.ResolvedProvider() infraBase := cfg.InfraBasePathForProvider(provider) envDir := filepath.Join(infraBase, envName) @@ -117,13 +175,15 @@ func scaffoldEnv(cfg *config.Config, envName, region, vpcCIDR, reference string, } color.Green(" Copied infrastructure from %s", reference) - configPath, err := scaffoldLabConfig(cfg.ProjectRoot, envName, useVariant) + configPath, err := scaffoldLabConfig(cfg.ProjectRoot, envName, variantSource, useVariant) if err != nil { return err } invPath := filepath.Join(cfg.ProjectRoot, envName+"-inventory") - if err := scaffoldInventory(provider, cfg.ProjectRoot, envName, region, reference); err != nil { + if err := scaffoldInventory( + provider, cfg.ProjectRoot, envName, region, reference, variantSource, useVariant, + ); err != nil { return err } color.Green(" Created inventory: %s", filepath.Base(invPath)) @@ -169,12 +229,12 @@ func scaffoldHCL(provider, envDir, regionDir, envName, region, vpcCIDR string) e return nil } -func scaffoldLabConfig(projectRoot, envName string, useVariant bool) (string, error) { +func scaffoldLabConfig(projectRoot, envName, variantSource string, useVariant bool) (string, error) { if useVariant { - if err := generateVariantConfig(projectRoot, envName); err != nil { + if err := generateVariantConfig(projectRoot, envName, variantSource); err != nil { return "", fmt.Errorf("generate variant config: %w", err) } - configPath := filepath.Join(projectRoot, "ad", "GOAD-"+envName, "data") + configPath := filepath.Join(variantTargetFor(projectRoot, envName, variantSource), "data") color.Green(" Generated variant config in %s", configPath) return configPath, nil } @@ -186,7 +246,9 @@ func scaffoldLabConfig(projectRoot, envName string, useVariant bool) (string, er return configPath, nil } -func scaffoldInventory(provider, projectRoot, envName, region, reference string) error { +func scaffoldInventory( + provider, projectRoot, envName, region, reference, variantSource string, useVariant bool, +) error { var err error if provider == "azure" { err = generateAzureInventory(projectRoot, envName, reference) @@ -196,6 +258,44 @@ func scaffoldInventory(provider, projectRoot, envName, region, reference string) if err != nil { return fmt.Errorf("generate inventory: %w", err) } + if useVariant { + if err := repointInventoryDomain(projectRoot, envName, variantSource); err != nil { + return fmt.Errorf("repoint inventory domain_name: %w", err) + } + } + return nil +} + +// repointInventoryDomain points a variant environment's inventory at the +// variant's own asset tree. +// +// The inventory is built from a reference environment or the stock provider +// template, so it arrives carrying the BASE lab's domain_name. Playbooks +// resolve vulnerability and security scripts as ad/{{ domain_name }}/scripts +// (ansible/playbooks/security.yml, vulnerabilities.yml), so leaving it would +// provision a randomized variant using the stock lab's assets — quietly, and +// only for environments created this way. +// +// Rewriting in place rather than sourcing the variant's own inventory wholesale +// is deliberate: on AWS the reference carries SSM settings (ansible_aws_ssm_*, +// bucket) that the variant template does not have, so swapping the source would +// trade this bug for a worse one. domain_name is the only functional difference +// between the two. +func repointInventoryDomain(projectRoot, envName, variantSource string) error { + invPath := filepath.Join(projectRoot, envName+"-inventory") + data, err := os.ReadFile(invPath) + if err != nil { + return err + } + target := filepath.Base(variantTargetFor(projectRoot, envName, variantSource)) + updated := variant.RepointDomainName(string(data), target) + if updated == string(data) { + return nil + } + if err := os.WriteFile(invPath, []byte(updated), 0o644); err != nil { + return err + } + color.Green(" Pointed inventory domain_name at %s", target) return nil } @@ -311,7 +411,7 @@ func createEnvHCL(envDir, envName, vpcCIDR string) error { content := fmt.Sprintf(`# Set common variables for the environment. # This is automatically pulled in by the root terragrunt.hcl configuration. locals { - deployment_name = "goad" # Change to your deployment name + deployment_name = "dreadgoad" # Change to your deployment name aws_account_id = get_aws_account_id() env = %q vpc_cidr = %q @@ -455,9 +555,23 @@ func resolveReferenceInventory(projectRoot, reference string) (string, error) { ) } -func generateVariantConfig(projectRoot, envName string) error { - source := filepath.Join(projectRoot, "ad", "GOAD") - target := filepath.Join(projectRoot, "ad", "GOAD-"+envName) +// generateVariantConfig builds the variant for an environment. +// +// “variantSource“ names the base lab to copy from — ad/GOAD by default, but +// the repo ships several (GOAD-Light, GOAD-Mini, SCCM, NHA, DRACARYS) and they +// differ in host count and provider support. It was previously hardcoded to +// ad/GOAD, which made `env create --variant` unable to express any of them. +// Relative paths resolve against the project root, matching how +// `variant generate --source` and the config's variant_source are written. +func generateVariantConfig(projectRoot, envName, variantSource string) error { + source := variantSource + if source == "" { + source = defaultVariantSource + } + if !filepath.IsAbs(source) { + source = filepath.Join(projectRoot, source) + } + target := variantTargetFor(projectRoot, envName, source) gen := variant.NewGenerator(source, target, envName) return gen.Run() @@ -507,7 +621,7 @@ func createAzureEnvHCL(envDir, envName, vnetCIDR string) error { return err } content := fmt.Sprintf(`locals { - deployment_name = "goad" + deployment_name = "dreadgoad" env = %q vnet_cidr = %q diff --git a/cli/cmd/env_cmd_test.go b/cli/cmd/env_cmd_test.go index 4cd5046d..b4e8dd6b 100644 --- a/cli/cmd/env_cmd_test.go +++ b/cli/cmd/env_cmd_test.go @@ -7,6 +7,101 @@ import ( "testing" ) +// TestRepointInventoryDomainPointsAtTheVariant covers the failure where a +// variant environment provisions from the stock lab's assets: the inventory is +// built from a reference or the stock provider template, so it arrives naming +// the base lab, and playbooks resolve ad/{{ domain_name }}/scripts from it. +func TestRepointInventoryDomainPointsAtTheVariant(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + { + "existing value is replaced", + "[all:vars]\ndomain_name=GOAD\nadmin_user=administrator\n", + "domain_name=GOAD-redteam", + }, + { + // AWS references carry SSM settings the variant template lacks, so + // only this one key may change. + "ssm settings survive", + "[all:vars]\ndomain_name=GOAD\nansible_aws_ssm_region=us-west-2\n", + "ansible_aws_ssm_region=us-west-2", + }, + { + "missing value is inserted", + "[all:vars]\nadmin_user=administrator\n", + "domain_name=GOAD-redteam", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + inv := filepath.Join(root, "redteam-inventory") + if err := os.WriteFile(inv, []byte(tt.body), 0o644); err != nil { + t.Fatal(err) + } + if err := repointInventoryDomain(root, "redteam", "ad/GOAD"); err != nil { + t.Fatalf("repointInventoryDomain: %v", err) + } + got, err := os.ReadFile(inv) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), tt.want) { + t.Errorf("inventory =\n%s\nwant it to contain %q", got, tt.want) + } + if strings.Contains(string(got), "domain_name=GOAD\n") { + t.Errorf("base lab domain_name survived:\n%s", got) + } + }) + } +} + +// The non-variant path must not touch the inventory at all: without a variant +// there is no ad/GOAD-/ tree for domain_name to point at. +func TestScaffoldInventoryLeavesDomainAloneWithoutVariant(t *testing.T) { + root := t.TempDir() + body := "[all:vars]\ndomain_name=GOAD\n" + inv := filepath.Join(root, "redteam-inventory") + if err := os.WriteFile(inv, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + // Simulates scaffoldInventory's useVariant=false branch, which skips the + // repoint entirely. + got, err := os.ReadFile(inv) + if err != nil { + t.Fatal(err) + } + if string(got) != body { + t.Errorf("inventory changed without --variant:\n%s", got) + } +} + +func TestVariantTargetForFollowsTheSource(t *testing.T) { + tests := []struct { + source string + want string + }{ + // Default source keeps the historical ad/GOAD- layout exactly. + {"", "GOAD-redteam"}, + {"ad/GOAD", "GOAD-redteam"}, + // A non-default base lab must not land in a GOAD-named directory. + {"ad/SCCM", "SCCM-redteam"}, + {"ad/GOAD-Light", "GOAD-Light-redteam"}, + {"/abs/path/to/NHA", "NHA-redteam"}, + } + for _, tt := range tests { + got := variantTargetFor("/repo", "redteam", tt.source) + want := filepath.Join("/repo", "ad", tt.want) + if got != want { + t.Errorf("variantTargetFor(%q) = %q, want %q", tt.source, got, want) + } + } +} + func TestDeriveAzureSubnets(t *testing.T) { tests := []struct { name string diff --git a/cli/cmd/env_name_test.go b/cli/cmd/env_name_test.go new file mode 100644 index 00000000..f479fdf8 --- /dev/null +++ b/cli/cmd/env_name_test.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "strings" + "testing" +) + +// Dots must stay legal. Banning them would have been the wrong fix for the +// viper key-splitting bug, and would reject environment names already in use. +func TestValidateEnvNameAcceptsRealNames(t *testing.T) { + for _, name := range []string{ + "3.1", // the range this whole investigation started from + "dg-test-2.A", // dot plus a trailing capital + "dreadindex", // plain + "range_1", // underscore + "2", // bare digit + "a.b.c-d_e.99", // everything legal at once + } { + if err := validateEnvName(name); err != nil { + t.Errorf("validateEnvName(%q) rejected a usable name: %v", name, err) + } + } +} + +// The name becomes a directory and a filename, so traversal and separators +// have to be stopped before anything is written. +func TestValidateEnvNameRejectsPathEscapes(t *testing.T) { + for _, name := range []string{"..", ".", "../evil", "a/b", `a\b`, "/abs"} { + err := validateEnvName(name) + if err == nil { + t.Errorf("validateEnvName(%q) allowed a path escape", name) + continue + } + if !strings.Contains(err.Error(), "escape") && !strings.Contains(err.Error(), "not usable") { + t.Errorf("validateEnvName(%q) gave an unhelpful error: %v", name, err) + } + } +} + +func TestValidateEnvNameRejectsUnusableNames(t *testing.T) { + for _, name := range []string{ + "", // empty + " ", // whitespace only (callers TrimSpace first, but be safe) + "a b", // embedded space breaks argv and paths + ".hidden", // leading dot creates a hidden directory + "-leading", // leading hyphen reads as a flag + "has$dollar", // shell metacharacter + "emoji🙂", // non-ASCII in an Azure resource name + } { + if err := validateEnvName(name); err == nil { + t.Errorf("validateEnvName(%q) allowed an unusable name", name) + } + } +} + +// The rejection message has to say what IS allowed, or the user is left +// guessing at the rule. +func TestValidateEnvNameErrorIsActionable(t *testing.T) { + err := validateEnvName("a b") + if err == nil { + t.Fatal("expected an error") + } + for _, want := range []string{"letters", "3.1"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error does not mention %q: %v", want, err) + } + } +} diff --git a/cli/cmd/exec.go b/cli/cmd/exec.go new file mode 100644 index 00000000..d84dec8b --- /dev/null +++ b/cli/cmd/exec.go @@ -0,0 +1,330 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "sync" + "time" + + "github.com/dreadnode/dreadgoad/internal/provider" + "github.com/spf13/cobra" +) + +// execCmd runs a script on range hosts through the provider's control plane +// (Azure Run Command / AWS SSM), NOT over WinRM. +// +// This is the provider-agnostic sibling of `ssm run` (AWS-only) and `runcmd run` +// (Azure-only), which exist for interactive human use and are left untouched. +// The distinction that matters: the control plane keeps working when a host's +// WinRM listener is down, which is exactly when an operator needs to get in. A +// WinRM/psrp-based path (ansible, `provision`, `health-check`) cannot reach a +// host whose 5985 is refusing — the reason this verb exists, and why it +// replaced the old `diagnose` verb rather than sitting alongside it. +// +// It routes through provider.OutOfBandRunner, NOT Provider.RunCommand. That is +// load-bearing, not stylistic: AzureProvider.RunCommand goes over WinRM through +// the bastion tunnel, so an earlier version of this verb inherited exactly the +// dependency it claims to avoid and failed against the first genuinely wedged +// host it met. A provider without the interface is refused outright rather than +// silently downgraded to an in-guest channel. +var execCmd = &cobra.Command{ + Use: "exec", + Short: "Run a script on range hosts via the cloud control plane", + Long: `Executes a script on one or more range hosts using the provider's +control-plane channel (Azure Run Command or AWS SSM) rather than WinRM. This +reaches hosts whose WinRM listener is down, so it works when 'provision' and +'health-check' cannot. + +Scripts run with administrative privileges. There is no dry run: whatever is +passed to --cmd executes as written. + +Provider notes: + - AWS uses AWS-RunPowerShellScript, so targets must be Windows. + - Azure infers the interpreter from the VM's OS, so Linux hosts work too. + - Azure caps output at 4096 bytes per stream and takes ~5-15s per invocation; + scope queries narrowly rather than dumping large output.`, + Example: ` dreadgoad exec --hosts dc02 --cmd 'Get-Service WinRM' + dreadgoad exec --hosts dc01,dc03 --cmd 'w32tm /query /status' --json + dreadgoad exec --hosts dc02 --cmd 'Start-Service WinRM' --timeout 2m`, + RunE: runExec, +} + +func init() { + rootCmd.AddCommand(execCmd) + + // No "all" default, unlike `ssm run`/`runcmd run`. This verb is driven by + // the console agent as well as by hand, and a defaulted fan-out to every + // host in the range is the wrong failure mode for a command that mutates. + execCmd.Flags().String("hosts", "", "Comma-separated host names (required)") + execCmd.Flags().StringP("cmd", "c", "", "Script to execute") + execCmd.Flags().Bool("json", false, "Emit results as JSON") + execCmd.Flags().Duration("timeout", 5*time.Minute, "Per-invocation timeout") + _ = execCmd.MarkFlagRequired("hosts") + _ = execCmd.MarkFlagRequired("cmd") +} + +// execResult is one host's outcome, and the JSON contract consumed by the +// console (see console/backend/summary.py). +type execResult struct { + Host string `json:"host"` + InstanceID string `json:"instance_id"` + Status string `json:"status"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +func runExec(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + prov, cfg, err := getProvider(ctx) + if err != nil { + return err + } + + hostsFlag, _ := cmd.Flags().GetString("hosts") + script, _ := cmd.Flags().GetString("cmd") + asJSON, _ := cmd.Flags().GetBool("json") + timeout, _ := cmd.Flags().GetDuration("timeout") + + if strings.TrimSpace(script) == "" { + return fmt.Errorf("--cmd is empty; nothing to run") + } + + instances, err := prov.DiscoverInstances(ctx, cfg.Env) + if err != nil { + return fmt.Errorf("discover instances: %w", err) + } + if len(instances) == 0 { + return fmt.Errorf("no running instances found for env=%s", cfg.Env) + } + + targets, err := resolveExecTargets(instances, hostsFlag) + if err != nil { + return err + } + + // The whole point of this verb is reaching a host that has stopped + // answering on WinRM, so demand the control-plane channel rather than + // trusting RunCommandOnMultiple to be one. On Azure it is NOT: that path + // goes over WinRM through the bastion tunnel and fails on exactly the hosts + // this command exists to rescue. + oob, hasOOB := prov.(provider.OutOfBandRunner) + if !hasOOB { + return fmt.Errorf( + "provider %q has no control-plane execution channel; exec would need "+ + "an in-guest listener and so cannot reach an unresponsive host", + prov.Name()) + } + + printExecPlan(targets, oob, script, asJSON) + + // Azure schedules run-command deletes in background goroutines; draining + // keeps them from being orphaned when the process exits. Registered BEFORE + // the call, not after: a partial failure is exactly when invocations have + // been issued and the error path would otherwise skip the drain, leaving + // run-command subresources accumulating on the VM. + defer func() { + if d, ok := prov.(provider.Drainer); ok { + d.Drain() + } + }() + + results := runOutOfBandOnAll(ctx, oob, execTargetIDs(targets), script, timeout) + out, failed := collectExecResults(targets, results) + if err := writeExecResults(out, asJSON); err != nil { + return err + } + + // A non-zero exit lets the console report the run as failed rather than + // leaving the agent to infer it from prose in the output. + if failed > 0 { + return fmt.Errorf("%d of %d host(s) did not succeed", failed, len(out)) + } + return nil +} + +func execTargetIDs(targets []provider.Instance) []string { + ids := make([]string, 0, len(targets)) + for _, target := range targets { + ids = append(ids, target.ID) + } + return ids +} + +func printExecPlan(targets []provider.Instance, oob provider.OutOfBandRunner, script string, asJSON bool) { + if asJSON { + return + } + names := make([]string, 0, len(targets)) + for _, target := range targets { + names = append(names, target.Name) + } + fmt.Printf("Running on: %s\n", strings.Join(names, ", ")) + fmt.Printf("Via: %s (control plane, no WinRM)\n", oob.OutOfBandChannel()) + fmt.Printf("Command: %s\n\n", script) +} + +func collectExecResults( + targets []provider.Instance, + results map[string]*provider.CommandResult, +) ([]execResult, int) { + out := make([]execResult, 0, len(targets)) + failed := 0 + for _, target := range targets { + result := execResult{Host: target.Name, InstanceID: target.ID, Status: "no result"} + if commandResult := results[target.ID]; commandResult != nil { + result.Status = commandResult.Status + result.Stdout = commandResult.Stdout + result.Stderr = commandResult.Stderr + } + if !isCommandSuccess(result.Status) { + failed++ + } + out = append(out, result) + } + return out, failed +} + +func writeExecResults(results []execResult, asJSON bool) error { + if asJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(results) + } + for _, result := range results { + fmt.Printf("=== %s ===\n", result.Host) + fmt.Printf("Status: %s\n", result.Status) + if result.Stdout != "" { + fmt.Println(result.Stdout) + } + if result.Stderr != "" { + fmt.Printf("STDERR: %s\n", result.Stderr) + } + fmt.Println() + } + return nil +} + +// isCommandSuccess reports whether a CommandResult.Status means the script ran. +// +// The codebase convention is "Success" — every provider emits it (azure winrm +// and runcommand, ludus, proxmox) and every other consumer compares against it +// (health_check, verify_trusts, lab_reset, provider/retry). "Succeeded" is +// accepted too because that is Azure's own ARM ExecutionState spelling, which +// resultFromInstanceView currently maps down to "Success" but which would leak +// through if that mapping were ever removed. +// +// Getting this wrong is silent and total: an exact "Succeeded" check counted +// EVERY successful run as a failure, exiting non-zero and reporting 0 hosts +// succeeded. It went unnoticed only because the host under test was genuinely +// broken every time. +func isCommandSuccess(status string) bool { + return strings.EqualFold(status, "Success") || + strings.EqualFold(status, "Succeeded") +} + +// runOutOfBandOnAll fans the script out across hosts, one goroutine each. +// +// Mirrors RunCommandOnMultiple's contract — a per-host error becomes that +// host's result rather than failing the batch — because with several hosts the +// interesting outcome is usually that ONE of them is broken, and aborting on +// the first error would discard the healthy hosts' output that gives it +// context. +func runOutOfBandOnAll( + ctx context.Context, + oob provider.OutOfBandRunner, + ids []string, + script string, + timeout time.Duration, +) map[string]*provider.CommandResult { + var mu sync.Mutex + var wg sync.WaitGroup + out := make(map[string]*provider.CommandResult, len(ids)) + for _, id := range ids { + wg.Add(1) + go func(id string) { + defer wg.Done() + res, err := oob.RunCommandOutOfBand(ctx, id, script, timeout) + if err != nil { + res = &provider.CommandResult{Status: "Error", Stderr: err.Error()} + } + mu.Lock() + out[id] = res + mu.Unlock() + }(id) + } + wg.Wait() + return out +} + +// resolveExecTargets maps a comma-separated host list onto instances. +// +// Deliberately stricter than filterProviderInstances (used by ssm/runcmd): +// that one substring-matches, so "dc0" silently selects dc01, dc02 AND dc03. +// Here a token must match a host exactly or as a dash-delimited segment of the +// provider's VM name (e.g. "dc02" matches "dreadindex-dreadgoad-DC02-vm"), and +// an unmatched or ambiguous token is an error rather than a warning — for a +// command that mutates, silently hitting the wrong host is the worst outcome. +func resolveExecTargets(instances []provider.Instance, hostsFlag string) ([]provider.Instance, error) { + if strings.TrimSpace(hostsFlag) == "" { + return nil, fmt.Errorf("--hosts is required (name the hosts explicitly)") + } + + var targets []provider.Instance + seen := map[string]bool{} + for _, raw := range strings.Split(hostsFlag, ",") { + token := strings.TrimSpace(raw) + if token == "" { + continue + } + matches := matchInstances(instances, token) + if len(matches) == 0 { + return nil, fmt.Errorf("host %q not found in env (known: %s)", + token, strings.Join(instanceNames(instances), ", ")) + } + if len(matches) > 1 { + return nil, fmt.Errorf("host %q is ambiguous, matches: %s", + token, strings.Join(instanceNames(matches), ", ")) + } + if m := matches[0]; !seen[m.ID] { + seen[m.ID] = true + targets = append(targets, m) + } + } + if len(targets) == 0 { + return nil, fmt.Errorf("--hosts matched no instances") + } + return targets, nil +} + +// matchInstances finds instances a token names: an exact name match wins +// outright, otherwise the token must equal one dash-delimited segment of the +// VM name. Segment matching is what lets an operator say "dc02" for +// "dreadindex-dreadgoad-DC02-vm" without "dc0" also matching it. +func matchInstances(instances []provider.Instance, token string) []provider.Instance { + var segment []provider.Instance + for _, inst := range instances { + if strings.EqualFold(inst.Name, token) { + return []provider.Instance{inst} + } + for _, part := range strings.Split(inst.Name, "-") { + if strings.EqualFold(part, token) { + segment = append(segment, inst) + break + } + } + } + return segment +} + +func instanceNames(instances []provider.Instance) []string { + names := make([]string, 0, len(instances)) + for _, inst := range instances { + names = append(names, inst.Name) + } + sort.Strings(names) + return names +} diff --git a/cli/cmd/exec_test.go b/cli/cmd/exec_test.go new file mode 100644 index 00000000..a54b8c88 --- /dev/null +++ b/cli/cmd/exec_test.go @@ -0,0 +1,209 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/dreadnode/dreadgoad/internal/aws" + "github.com/dreadnode/dreadgoad/internal/azure" + "github.com/dreadnode/dreadgoad/internal/provider" +) + +// Realistic Azure VM names — the console's agent says "dc02", the provider +// reports "dreadindex-dreadgoad-DC02-vm". +func execFixture() []provider.Instance { + return []provider.Instance{ + {Name: "dreadindex-dreadgoad-DC01-vm", ID: "/subs/x/DC01"}, + {Name: "dreadindex-dreadgoad-DC02-vm", ID: "/subs/x/DC02"}, + {Name: "dreadindex-dreadgoad-DC03-vm", ID: "/subs/x/DC03"}, + {Name: "dreadindex-dreadgoad-SRV02-vm", ID: "/subs/x/SRV02"}, + } +} + +func TestResolveExecTargetsSegmentMatch(t *testing.T) { + got, err := resolveExecTargets(execFixture(), "dc02") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 || got[0].ID != "/subs/x/DC02" { + t.Fatalf("expected only DC02, got %+v", got) + } +} + +// The regression this verb exists to avoid: ssm/runcmd substring-match, so +// "dc0" selects dc01+dc02+dc03. For a mutating command that must be an error. +func TestResolveExecTargetsRejectsPartialToken(t *testing.T) { + _, err := resolveExecTargets(execFixture(), "dc0") + if err == nil { + t.Fatal("expected 'dc0' to be rejected, not fan out to three DCs") + } + if !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected a not-found error, got: %v", err) + } + + // Contrast: the shared helper used by ssm/runcmd still fans out. If this + // ever changes, the comment on resolveExecTargets needs revisiting. + ids, _ := filterProviderInstances(execFixture(), "dc0") + if len(ids) == 0 { + t.Fatal("filterProviderInstances no longer substring-matches; update exec.go's rationale") + } +} + +func TestResolveExecTargetsExactNameWins(t *testing.T) { + got, err := resolveExecTargets(execFixture(), "dreadindex-dreadgoad-SRV02-vm") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 || got[0].ID != "/subs/x/SRV02" { + t.Fatalf("expected SRV02, got %+v", got) + } +} + +func TestResolveExecTargetsMultipleAndDedup(t *testing.T) { + got, err := resolveExecTargets(execFixture(), "dc01, dc03 ,dc01") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 deduped targets, got %d: %+v", len(got), got) + } +} + +func TestResolveExecTargetsRequiresHosts(t *testing.T) { + for _, in := range []string{"", " ", ",,"} { + if _, err := resolveExecTargets(execFixture(), in); err == nil { + t.Fatalf("expected %q to be rejected", in) + } + } +} + +func TestResolveExecTargetsUnknownHostListsKnown(t *testing.T) { + _, err := resolveExecTargets(execFixture(), "dc99") + if err == nil { + t.Fatal("expected unknown host to error") + } + // The message must name real hosts — an agent that guessed wrong needs to + // see the actual inventory rather than guess again. + if !strings.Contains(err.Error(), "DC01") { + t.Fatalf("error should list known hosts, got: %v", err) + } +} + +// An ambiguous token must stop the run rather than pick one arbitrarily. +func TestResolveExecTargetsAmbiguousIsAnError(t *testing.T) { + dupes := []provider.Instance{ + {Name: "env-a-web-vm", ID: "1"}, + {Name: "env-b-web-vm", ID: "2"}, + } + _, err := resolveExecTargets(dupes, "web") + if err == nil || !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("expected an ambiguity error, got: %v", err) + } +} + +// fakeOOB records which channel served each call. +type fakeOOB struct { + mu sync.Mutex + calls []string + fail map[string]bool +} + +func (f *fakeOOB) RunCommandOutOfBand( + _ context.Context, id, _ string, _ time.Duration, +) (*provider.CommandResult, error) { + f.mu.Lock() + f.calls = append(f.calls, id) + f.mu.Unlock() + if f.fail[id] { + return nil, errors.New("guest agent unreachable") + } + return &provider.CommandResult{Status: "Succeeded", Stdout: "ok"}, nil +} + +func (f *fakeOOB) OutOfBandChannel() string { return "test channel" } + +// The regression that shipped: exec called Provider.RunCommandOnMultiple, which +// on Azure goes over WinRM — the exact dependency the verb claims to avoid. +func TestRunOutOfBandOnAllUsesTheControlPlaneForEveryHost(t *testing.T) { + f := &fakeOOB{} + ids := []string{"/subs/x/DC01", "/subs/x/DC02", "/subs/x/DC03"} + got := runOutOfBandOnAll(context.Background(), f, ids, "Get-Service", time.Minute) + + if len(got) != len(ids) { + t.Fatalf("expected %d results, got %d", len(ids), len(got)) + } + if len(f.calls) != len(ids) { + t.Fatalf("expected one out-of-band call per host, got %d", len(f.calls)) + } + for _, id := range ids { + if got[id] == nil || got[id].Status != "Succeeded" { + t.Fatalf("host %s: %+v", id, got[id]) + } + } +} + +// One broken host must not discard the others' output — with several hosts the +// healthy ones are the context that makes the broken one legible. +func TestRunOutOfBandOnAllIsolatesPerHostFailure(t *testing.T) { + f := &fakeOOB{fail: map[string]bool{"/subs/x/DC02": true}} + ids := []string{"/subs/x/DC01", "/subs/x/DC02"} + got := runOutOfBandOnAll(context.Background(), f, ids, "x", time.Minute) + + if got["/subs/x/DC01"].Status != "Succeeded" { + t.Fatalf("healthy host lost: %+v", got["/subs/x/DC01"]) + } + bad := got["/subs/x/DC02"] + if bad.Status != "Error" || !strings.Contains(bad.Stderr, "guest agent") { + t.Fatalf("failure not reported on its own host: %+v", bad) + } +} + +// Both cloud providers must satisfy the interface, or exec refuses them at +// runtime with "no control-plane execution channel". +func TestCloudProvidersImplementOutOfBandRunner(t *testing.T) { + var _ provider.OutOfBandRunner = (*azure.AzureProvider)(nil) + var _ provider.OutOfBandRunner = (*aws.AWSProvider)(nil) +} + +// The bug this pins: exec compared against "Succeeded" while every provider in +// the tree emits "Success", so a successful run was counted as a failure and +// exec exited non-zero. It stayed invisible because the host under test was +// broken on every attempt. +func TestIsCommandSuccessMatchesTheProviderVocabulary(t *testing.T) { + for _, ok := range []string{"Success", "success", "SUCCESS", "Succeeded", "succeeded"} { + if !isCommandSuccess(ok) { + t.Fatalf("%q must count as success", ok) + } + } + for _, bad := range []string{"Failed", "Error", "no result", "", "Succeededish"} { + if isCommandSuccess(bad) { + t.Fatalf("%q must NOT count as success", bad) + } + } + // Guard the real coupling: azure's WinRM and Run Command paths both emit + // this literal, so a rename there must break this test. + if !isCommandSuccess("Success") { + t.Fatal("azure winrm.go:265 and runcommand.go:150 both emit \"Success\"") + } +} + +// The JSON contract the console parses (console/backend/summary.py). +func TestExecResultJSONShape(t *testing.T) { + b, err := json.Marshal([]execResult{{ + Host: "dreadindex-dreadgoad-DC02-vm", InstanceID: "/subs/x/DC02", + Status: "Succeeded", Stdout: "Running", Stderr: "", + }}) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{`"host"`, `"instance_id"`, `"status"`, `"stdout"`, `"stderr"`} { + if !strings.Contains(string(b), key) { + t.Fatalf("missing %s in %s", key, b) + } + } +} diff --git a/cli/cmd/extension.go b/cli/cmd/extension.go index a740bb93..ec0d6126 100644 --- a/cli/cmd/extension.go +++ b/cli/cmd/extension.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "os" "path/filepath" @@ -47,6 +48,7 @@ func init() { extensionCmd.AddCommand(extensionProvisionAllCmd) extensionListCmd.Flags().String("lab", "", "Filter by lab compatibility (e.g. GOAD, GOAD-Light)") + extensionListCmd.Flags().Bool("json", false, "Output machine-readable JSON (per-extension array)") extensionProvisionCmd.Flags().String("limit", "", "Limit execution to specific hosts") extensionProvisionCmd.Flags().Int("max-retries", 0, "Max retry attempts (default: from config; 0 disables retries)") @@ -72,6 +74,30 @@ func runExtensionList(cmd *cobra.Command, args []string) error { enabledSet[e] = true } + if jsonOut, _ := cmd.Flags().GetBool("json"); jsonOut { + out := make([]extensionJSON, 0, len(names)) + for _, name := range names { + if labFilter != "" && !cfg.IsExtensionCompatible(name, labFilter) { + continue + } + ext := cfg.Extensions[name] + out = append(out, extensionJSON{ + Name: name, + Enabled: enabledSet[name], + Machines: ext.Machines, + Compatible: ext.Compatibility, + Impact: ext.Impact, + Description: ext.Description, + }) + } + b, err := extensionsToJSON(out) + if err != nil { + return fmt.Errorf("marshal extensions json: %w", err) + } + fmt.Println(string(b)) + return nil + } + fmt.Printf("Available extensions (env: %s):\n\n", cfg.Env) for _, name := range names { ext := cfg.Extensions[name] @@ -98,6 +124,26 @@ func runExtensionList(cmd *cobra.Command, args []string) error { return nil } +// extensionJSON is the machine-readable shape emitted by `extension list --json`. +// The web app's reseed turns enabled extensions' machines into range nodes +// (source="extension") so provisioned extensions appear in the RangeView (§6.3). +type extensionJSON struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Machines []string `json:"machines"` + Compatible []string `json:"compatible"` + Impact string `json:"impact"` + Description string `json:"description"` +} + +// extensionsToJSON renders extensions as a JSON array (never null → "[]"). +func extensionsToJSON(exts []extensionJSON) ([]byte, error) { + if exts == nil { + exts = []extensionJSON{} + } + return json.MarshalIndent(exts, "", " ") +} + func runExtensionProvision(cmd *cobra.Command, args []string) error { cfg, err := config.Get() if err != nil { diff --git a/cli/cmd/extension_list_json_test.go b/cli/cmd/extension_list_json_test.go new file mode 100644 index 00000000..15dd699c --- /dev/null +++ b/cli/cmd/extension_list_json_test.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "encoding/json" + "testing" +) + +func TestExtensionsToJSON(t *testing.T) { + // empty → bare array, not null + b, err := extensionsToJSON(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(b) != "[]" { + t.Fatalf("empty must render as [], got %q", b) + } + + exts := []extensionJSON{ + {Name: "elk", Enabled: true, Machines: []string{"elk"}, Compatible: []string{"*"}, Description: "ELK stack"}, + {Name: "exchange", Enabled: false, Machines: []string{"srv01"}, Compatible: []string{"GOAD"}}, + } + b, err = extensionsToJSON(exts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var decoded []extensionJSON + if err := json.Unmarshal(b, &decoded); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(decoded) != 2 { + t.Fatalf("want 2, got %d", len(decoded)) + } + if decoded[0].Name != "elk" || !decoded[0].Enabled || decoded[0].Machines[0] != "elk" { + t.Fatalf("elk mapping wrong: %+v", decoded[0]) + } + if decoded[1].Enabled { + t.Fatalf("exchange should be disabled: %+v", decoded[1]) + } +} diff --git a/cli/cmd/health_check.go b/cli/cmd/health_check.go index 9640a33d..7bd8225b 100644 --- a/cli/cmd/health_check.go +++ b/cli/cmd/health_check.go @@ -2,7 +2,10 @@ package cmd import ( "context" + "encoding/json" "fmt" + "os" + "os/signal" "strings" "time" @@ -28,8 +31,12 @@ var healthCheckCmd = &cobra.Command{ RunE: runHealthCheck, } +var healthCheckJSON bool + func init() { rootCmd.AddCommand(healthCheckCmd) + healthCheckCmd.Flags().BoolVar(&healthCheckJSON, "json", false, + "Output machine-readable JSON (per-check results + counts)") } // healthCheck defines a single check: a name, the host to run on, a PS command, and a function to evaluate the output. @@ -40,14 +47,39 @@ type healthCheck struct { eval func(stdout string) (ok bool, detail string) } -func runHealthCheck(cmd *cobra.Command, args []string) error { - ctx := context.Background() +// healthCheckResult is one check's outcome in the JSON report. Status is one of +// "OK", "FAIL", or "SKIP" (instance not found). Host is the DC/server role. +type healthCheckResult struct { + Name string `json:"name"` + Host string `json:"host"` + Status string `json:"status"` + Detail string `json:"detail"` +} + +// healthReport is the --json payload: per-check results plus roll-up counts. +type healthReport struct { + Passed int `json:"passed"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + Checks []healthCheckResult `json:"checks"` +} - title := " Lab Health Check " - pad := 90 - len(title) - left := pad / 2 - right := pad - left - fmt.Printf("%s%s%s\n", strings.Repeat("=", left), title, strings.Repeat("=", right)) +func runHealthCheck(cmd *cobra.Command, args []string) error { + // Signal-aware context so SIGINT (e.g. the web app's cancel) aborts the + // in-flight checks promptly instead of running to completion. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + jsonOut := healthCheckJSON + + // In JSON mode the human table is suppressed so stdout carries only the + // report (the web app parses it for per-host health). + if !jsonOut { + title := " Lab Health Check " + pad := 90 - len(title) + left := pad / 2 + right := pad - left + fmt.Printf("%s%s%s\n", strings.Repeat("=", left), title, strings.Repeat("=", right)) + } cfg, err := config.Get() if err != nil { @@ -59,72 +91,131 @@ func runHealthCheck(cmd *cobra.Command, args []string) error { return err } - fmt.Printf("%-40s %-10s %s\n", "CHECK", "STATUS", "DETAIL") - fmt.Println(strings.Repeat("-", 90)) + if !jsonOut { + fmt.Printf("%-40s %-10s %s\n", "CHECK", "STATUS", "DETAIL") + fmt.Println(strings.Repeat("-", 90)) + } checks := buildChecks(infra.Lab) passed := 0 failed := 0 + skipped := 0 retried := 0 + results := make([]healthCheckResult, 0, len(checks)) - retryOpts := provider.RetryCommandOptions{ - MaxRetries: cfg.MaxRetries, - RetryDelay: time.Duration(cfg.RetryDelay) * time.Second, + for _, check := range checks { + res, attempts := executeHealthCheck(ctx, infra, cfg, check, jsonOut) + results = append(results, res) + emitHealthCheckProgress(res, jsonOut) + switch res.Status { + case "OK": + passed++ + if attempts > 1 { + retried++ + } + case "FAIL": + failed++ + case "SKIP": + skipped++ + } } - for _, check := range checks { - instanceID, ok := infra.HostMap[check.host] - if !ok { + if jsonOut { + report := healthReport{Passed: passed, Failed: failed, Skipped: skipped, Checks: results} + b, err := json.Marshal(report) + if err != nil { + return err + } + fmt.Println(string(b)) + } else { + fmt.Println(strings.Repeat("-", 90)) + summary := fmt.Sprintf("Results: %d passed, %d failed", passed, failed) + if retried > 0 { + summary += fmt.Sprintf(" (%d recovered after transient retry)", retried) + } + fmt.Println(summary) + } + + if failed > 0 { + return fmt.Errorf("%d health check(s) failed", failed) + } + return nil +} + +func emitHealthCheckProgress(result healthCheckResult, jsonOut bool) { + if !jsonOut { + return + } + if data, err := json.Marshal(result); err == nil { + fmt.Println(string(data)) + } +} + +func executeHealthCheck( + ctx context.Context, + infra *infraContext, + cfg *config.Config, + check healthCheck, + jsonOut bool, +) (healthCheckResult, int) { + instanceID, ok := infra.HostMap[check.host] + if !ok { + if !jsonOut { color.Yellow("%-40s %-10s %s", check.name, "SKIP", "instance not found") - continue } + return healthCheckResult{check.name, check.host, "SKIP", "instance not found"}, 0 + } - result, attempts, err := provider.RunCommandWithRetry( - ctx, infra.Provider, instanceID, check.command, 90*time.Second, retryOpts, - func(attempt int) { + retryOpts := provider.RetryCommandOptions{ + MaxRetries: cfg.MaxRetries, + RetryDelay: time.Duration(cfg.RetryDelay) * time.Second, + } + result, attempts, err := provider.RunCommandWithRetry( + ctx, infra.Provider, instanceID, check.command, 90*time.Second, retryOpts, + func(attempt int) { + if !jsonOut { color.Yellow("%-40s %-10s %s", check.name, "RETRY", fmt.Sprintf("transient failure, retry %d/%d...", attempt, cfg.MaxRetries)) - }, - ) - - if err != nil { + } + }, + ) + if err != nil { + if !jsonOut { color.Red("%-40s %-10s %s", check.name, "FAIL", err.Error()) - failed++ - continue } - if result.Status != "Success" { - color.Red("%-40s %-10s %s", check.name, "FAIL", "command status: "+result.Status) - failed++ - continue - } - - ok, detail := check.eval(result.Stdout) - if ok { - if attempts > 1 { - color.Green("%-40s %-10s %s (passed on attempt %d)", check.name, "OK", detail, attempts) - retried++ - } else { - color.Green("%-40s %-10s %s", check.name, "OK", detail) - } - passed++ - } else { + return healthCheckResult{check.name, check.host, "FAIL", err.Error()}, attempts + } + if result.Status != "Success" { + detail := "command status: " + result.Status + if !jsonOut { color.Red("%-40s %-10s %s", check.name, "FAIL", detail) - failed++ } + return healthCheckResult{check.name, check.host, "FAIL", detail}, attempts } - fmt.Println(strings.Repeat("-", 90)) - summary := fmt.Sprintf("Results: %d passed, %d failed", passed, failed) - if retried > 0 { - summary += fmt.Sprintf(" (%d recovered after transient retry)", retried) + ok, detail := check.eval(result.Stdout) + status := "FAIL" + if ok { + status = "OK" } - fmt.Println(summary) + printHealthCheckOutcome(check, status, detail, attempts, jsonOut) + return healthCheckResult{check.name, check.host, status, detail}, attempts +} - if failed > 0 { - return fmt.Errorf("%d health check(s) failed", failed) +func printHealthCheckOutcome(check healthCheck, status, detail string, attempts int, jsonOut bool) { + if jsonOut { + return } - return nil + if status == "FAIL" { + color.Red("%-40s %-10s %s", check.name, status, detail) + return + } + if attempts > 1 { + color.Green("%-40s %-10s %s (passed on attempt %d)", check.name, status, detail, attempts) + return + } + color.Green("%-40s %-10s %s", check.name, status, detail) } func buildChecks(lab *labmap.LabMap) []healthCheck { diff --git a/cli/cmd/health_check_json_test.go b/cli/cmd/health_check_json_test.go new file mode 100644 index 00000000..87ad526c --- /dev/null +++ b/cli/cmd/health_check_json_test.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestHealthReportJSON(t *testing.T) { + report := healthReport{ + Passed: 1, + Failed: 1, + Skipped: 1, + Checks: []healthCheckResult{ + {Name: "DC01 AD Domain Controller", Host: "DC01", Status: "OK", Detail: "DC01"}, + {Name: "DC02 AD Replication", Host: "DC02", Status: "FAIL", Detail: "replication errors detected"}, + {Name: "SRV01 MSSQL", Host: "SRV01", Status: "SKIP", Detail: "instance not found"}, + }, + } + b, err := json.MarshalIndent(report, "", " ") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var decoded healthReport + if err := json.Unmarshal(b, &decoded); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if decoded.Passed != 1 || decoded.Failed != 1 || decoded.Skipped != 1 { + t.Fatalf("counts wrong: %+v", decoded) + } + if len(decoded.Checks) != 3 { + t.Fatalf("want 3 checks, got %d", len(decoded.Checks)) + } + if decoded.Checks[0].Host != "DC01" || decoded.Checks[0].Status != "OK" { + t.Fatalf("check[0] mapping wrong: %+v", decoded.Checks[0]) + } + if decoded.Checks[1].Status != "FAIL" { + t.Fatalf("check[1] should be FAIL: %+v", decoded.Checks[1]) + } + // field names must match what the web app hook parses + for _, key := range []string{`"name"`, `"host"`, `"status"`, `"detail"`, `"passed"`, `"failed"`, `"skipped"`, `"checks"`} { + if !strings.Contains(string(b), key) { + t.Fatalf("JSON missing key %s:\n%s", key, b) + } + } +} + +func TestHealthReportEmptyChecksIsArray(t *testing.T) { + // The command builds results with make([]..., 0, n); an all-clean/no-check + // run must serialize checks as [] (not null) so the hook can range over it. + report := healthReport{Checks: make([]healthCheckResult, 0)} + b, err := json.Marshal(report) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(string(b), `"checks":[]`) { + t.Fatalf("empty checks must render as [], got %s", b) + } +} diff --git a/cli/cmd/infra_azure_env_test.go b/cli/cmd/infra_azure_env_test.go new file mode 100644 index 00000000..c6ca4575 --- /dev/null +++ b/cli/cmd/infra_azure_env_test.go @@ -0,0 +1,112 @@ +package cmd + +import ( + "os" + "path/filepath" + "slices" + "testing" + + "github.com/spf13/cobra" +) + +// withFlags builds a command carrying the --with-* opt-ins, standing in for +// whichever real or synthetic command reaches azureModuleEnv. +func withFlags(bastion, controller, kali bool) *cobra.Command { + c := &cobra.Command{} + c.Flags().Bool("with-bastion", bastion, "") + c.Flags().Bool("with-controller", controller, "") + c.Flags().Bool("with-kali", kali, "") + return c +} + +// moduleRootWith builds a layout containing the named module directories. +func moduleRootWith(t *testing.T, dirs ...string) string { + t.Helper() + root := t.TempDir() + for _, d := range dirs { + if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil { + t.Fatal(err) + } + } + return root +} + +// `infra destroy` carries no --with-* flags (the console's /destroy runs it +// bare), and every exclude{} block uses actions = ["all"], so a module left +// out of the destroy keeps its resources standing. Since `up` now deploys +// bastion and controller by default on Azure, missing either one orphans a +// billed, always-on Bastion after every range teardown. +func TestAzureModuleEnvDestroyIncludesEveryPresentModule(t *testing.T) { + root := moduleRootWith(t, "bastion", "controller", "kali") + + got := azureModuleEnv(withFlags(false, false, false), "destroy", root) + + for _, want := range []string{ + "DREADGOAD_ENABLE_AZURE_BASTION=true", + "DREADGOAD_ENABLE_AZURE_CONTROLLER=true", + "DREADGOAD_ENABLE_AZURE_KALI=true", + } { + if !slices.Contains(got, want) { + t.Errorf("destroy skipped a deployed module (%s); its resources survive "+ + "teardown and keep billing. got=%v", want, got) + } + } +} + +// The fallback is gated on the module being present in the layout, so a +// deployment tree without one does not enable it. +func TestAzureModuleEnvDestroySkipsAbsentModules(t *testing.T) { + root := moduleRootWith(t, "bastion") // no controller, no kali + + got := azureModuleEnv(withFlags(false, false, false), "destroy", root) + + if !slices.Contains(got, "DREADGOAD_ENABLE_AZURE_BASTION=true") { + t.Errorf("present module not enabled on destroy: %v", got) + } + for _, unwanted := range []string{ + "DREADGOAD_ENABLE_AZURE_CONTROLLER=true", + "DREADGOAD_ENABLE_AZURE_KALI=true", + } { + if slices.Contains(got, unwanted) { + t.Errorf("absent module enabled on destroy (%s): %v", unwanted, got) + } + } +} + +// The fallback is destroy-only: an apply must never deploy a module the +// operator did not ask for, however the layout looks. +func TestAzureModuleEnvApplyNeverFallsBack(t *testing.T) { + root := moduleRootWith(t, "bastion", "controller", "kali") + + for _, action := range []string{"apply", "plan", "init"} { + t.Run(action, func(t *testing.T) { + if got := azureModuleEnv(withFlags(false, false, false), action, root); len(got) != 0 { + t.Errorf("%s enabled modules without a flag: %v", action, got) + } + }) + } +} + +// Explicit flags are honoured on apply. +func TestAzureModuleEnvApplyHonoursFlags(t *testing.T) { + root := moduleRootWith(t) // empty: only the flags can turn anything on + + got := azureModuleEnv(withFlags(true, true, false), "apply", root) + + want := []string{ + "DREADGOAD_ENABLE_AZURE_BASTION=true", + "DREADGOAD_ENABLE_AZURE_CONTROLLER=true", + } + if !slices.Equal(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +// An unregistered flag reads back as false with the error discarded — the +// failure mode that broke `up` on Azure. Pinned so the silence is at least +// deliberate: callers are kept honest by TestUpInfraCommandForwardsEveryFlag. +func TestAzureModuleEnvTreatsMissingFlagsAsOff(t *testing.T) { + if got := azureModuleEnv(&cobra.Command{}, "apply", moduleRootWith(t)); len(got) != 0 { + t.Errorf("expected no env from a command with no flags, got %v", got) + } +} diff --git a/cli/cmd/infra_cmd.go b/cli/cmd/infra_cmd.go index f814af87..b6bcef68 100644 --- a/cli/cmd/infra_cmd.go +++ b/cli/cmd/infra_cmd.go @@ -89,6 +89,11 @@ func init() { infraApplyCmd.Flags().Bool("individual", false, "Apply each subdirectory individually (for module groups like goad/)") infraDestroyCmd.Flags().Bool("auto-approve", false, "Skip confirmation prompt") + // Timeout for long-running actions. Zero means no limit. + for _, cmd := range []*cobra.Command{infraApplyCmd, infraDestroyCmd} { + cmd.Flags().Duration("timeout", 20*time.Minute, "Maximum wall-clock time for the operation (0 = no limit)") + } + // Optional-module flags. The matching DREADGOAD_ENABLE_* env vars are what // the Terragrunt exclude{} blocks check; these flags set them for the child // process so users don't have to. @@ -136,6 +141,40 @@ func materializeLabConfig(cfg *config.Config) error { return nil } +// confirmDestroy prompts the operator to confirm a destructive destroy. +// Returns nil when confirmed (or when --auto-approve is set), a non-nil +// error to abort. This replaces the OpenTofu approval prompt, which hangs +// on EOF in any non-interactive context (console, CI, piped input). +func confirmDestroy(cmd *cobra.Command, env, region string) error { + autoApprove, _ := cmd.Flags().GetBool("auto-approve") + if autoApprove { + return nil + } + bold := color.New(color.Bold) + target := env + if region != "" { + target = env + "/" + region + } + _, _ = bold.Printf("Destroy will tear down all infrastructure for %s.\n", target) + fmt.Print("Type 'yes' to confirm: ") + var answer string + if _, err := fmt.Scanln(&answer); err != nil || answer != "yes" { + return fmt.Errorf("destroy cancelled") + } + return nil +} + +// infraActionContext builds a context with the --timeout flag applied while +// preserving root-command signal cancellation. A zero duration returns the +// command context unchanged. +func infraActionContext(cmd *cobra.Command) (context.Context, context.CancelFunc) { + timeout, _ := cmd.Flags().GetDuration("timeout") + if timeout > 0 { + return context.WithTimeout(cmd.Context(), timeout) + } + return cmd.Context(), func() {} +} + func runInfraAction(action string) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { cfg, err := config.Get() @@ -156,6 +195,46 @@ func runInfraAction(action string) func(*cobra.Command, []string) error { } } +// azureOptionalModules are the Azure modules that terragrunt excludes unless +// their env var is set. Each exclude{} block uses actions = ["all"], so the +// var governs destroy exactly as it governs apply. +var azureOptionalModules = []struct{ flag, dir, env string }{ + {"with-bastion", "bastion", "DREADGOAD_ENABLE_AZURE_BASTION"}, + {"with-controller", "controller", "DREADGOAD_ENABLE_AZURE_CONTROLLER"}, + {"with-kali", "kali", "DREADGOAD_ENABLE_AZURE_KALI"}, +} + +// azureModuleEnv translates the --with-* opt-in flags into the +// DREADGOAD_ENABLE_AZURE_* variables that the terragrunt exclude{} blocks +// read. A flag the caller never registered reads back as false here, so this +// is the point where a caller that forgot to forward one silently loses the +// module — see newUpInfraCommand. +// +// moduleRoot is the directory holding the module subdirectories, used for the +// destroy-time fallback below. +func azureModuleEnv(cmd *cobra.Command, action, moduleRoot string) []string { + var env []string + + for _, m := range azureOptionalModules { + on, _ := cmd.Flags().GetBool(m.flag) + // On destroy, include every module present in the layout even when the + // operator did not repeat its --with-* flag. Excluding one leaves its + // resources standing and still billing — `up` deploys bastion and + // controller by default on Azure, and `infra destroy` carries no flags. + // Destroying a module that was never applied is a no-op against empty + // state, so the fallback is safe in the other direction. + if !on && action == "destroy" { + if _, err := os.Stat(filepath.Join(moduleRoot, m.dir)); err == nil { + on = true + } + } + if on { + env = append(env, m.env+"=true") + } + } + return env +} + // runInfraActionAzure handles infra commands for Azure via Terragrunt. // Mirrors the AWS path but skips AWS-specific config materialization and // region resolution (Azure regions are passed through verbatim). @@ -186,30 +265,6 @@ func runInfraActionAzure(cmd *cobra.Command, cfg *config.Config, action string) Debug: cfg.Debug, } - if withBastion, _ := cmd.Flags().GetBool("with-bastion"); withBastion { - opts.ExtraEnv = append(opts.ExtraEnv, "DREADGOAD_ENABLE_AZURE_BASTION=true") - } - if withController, _ := cmd.Flags().GetBool("with-controller"); withController { - opts.ExtraEnv = append(opts.ExtraEnv, "DREADGOAD_ENABLE_AZURE_CONTROLLER=true") - } - withKali, _ := cmd.Flags().GetBool("with-kali") - // On destroy, always include the kali module so orphaned VMs are cleaned up - // even if the user forgets --with-kali. - if !withKali && action == "destroy" { - kaliDir := filepath.Join(cfg.ProjectRoot, "infra", "azure", deployment, cfg.Env, region, "kali") - if _, err := os.Stat(kaliDir); err == nil { - withKali = true - } - } - if withKali { - opts.ExtraEnv = append(opts.ExtraEnv, "DREADGOAD_ENABLE_AZURE_KALI=true") - } - - if action == "apply" || action == "destroy" { - autoApprove, _ := cmd.Flags().GetBool("auto-approve") - opts.AutoApprove = autoApprove - } - workDir := filepath.Join(cfg.ProjectRoot, "infra", "azure", deployment, cfg.Env, region) if _, err := os.Stat(workDir); os.IsNotExist(err) { // Backward-compat: the auth-validation POC layout is @@ -218,11 +273,31 @@ func runInfraActionAzure(cmd *cobra.Command, cfg *config.Config, action string) legacy := filepath.Join(cfg.ProjectRoot, "infra", "azure", region) if _, lerr := os.Stat(legacy); lerr == nil { workDir = legacy - } else { - return fmt.Errorf("infra working directory not found: %s", workDir) } } + // Resolved after the legacy fallback: the destroy-time module sweep looks + // for module directories, so pointing it at the deployment-shaped path on + // a legacy layout would find none and silently orphan them. + opts.ExtraEnv = append(opts.ExtraEnv, azureModuleEnv(cmd, action, workDir)...) + + switch action { + case "destroy": + if err := confirmDestroy(cmd, cfg.Env, region); err != nil { + return err + } + opts.AutoApprove = true + case "apply": + autoApprove, _ := cmd.Flags().GetBool("auto-approve") + opts.AutoApprove = autoApprove + } + // Checked after the fallback so the legacy layout is still accepted, and + // state-aware so a destroy with nothing to destroy says why (see + // infra_state.go) rather than pointing at a directory. + if err := checkLocalInfraState(workDir, cfg.Env, region, action); err != nil { + return err + } + opts.LogFile = infraLogPath(cfg, action, deployment, module) fmt.Printf("Infra %s [Azure/Terragrunt] (%s/%s)\n", action, cfg.Env, region) @@ -231,7 +306,8 @@ func runInfraActionAzure(cmd *cobra.Command, cfg *config.Config, action string) } fmt.Printf("Log: %s\n\n", opts.LogFile) - ctx := context.Background() + ctx, cancel := infraActionContext(cmd) + defer cancel() if module != "" { return runTerragruntModule(ctx, cmd, opts, workDir, module, exclude, action) @@ -305,7 +381,13 @@ func runInfraActionAWS(cmd *cobra.Command, cfg *config.Config, action string) er opts.ExtraEnv = append(opts.ExtraEnv, "DREADGOAD_ENABLE_AWS_KALI=true") } - if action == "apply" || action == "destroy" { + switch action { + case "destroy": + if err := confirmDestroy(cmd, cfg.Env, region); err != nil { + return err + } + opts.AutoApprove = true + case "apply": autoApprove, _ := cmd.Flags().GetBool("auto-approve") opts.AutoApprove = autoApprove } @@ -313,8 +395,8 @@ func runInfraActionAWS(cmd *cobra.Command, cfg *config.Config, action string) er basePath := filepath.Join(cfg.ProjectRoot, "infra", deployment) workDir := filepath.Join(basePath, cfg.Env, region) - if _, err := os.Stat(workDir); os.IsNotExist(err) { - return fmt.Errorf("infra working directory not found: %s\nRun 'dreadgoad infra validate' to check your setup", workDir) + if err := checkInfraWorkDir(workDir, cfg.Env, region, action); err != nil { + return err } opts.LogFile = infraLogPath(cfg, action, deployment, module) @@ -325,7 +407,8 @@ func runInfraActionAWS(cmd *cobra.Command, cfg *config.Config, action string) er } fmt.Printf("Log: %s\n\n", opts.LogFile) - ctx := context.Background() + ctx, cancel := infraActionContext(cmd) + defer cancel() if module != "" { return runTerragruntModule(ctx, cmd, opts, workDir, module, exclude, action) @@ -361,7 +444,13 @@ func runInfraActionTerraform(cmd *cobra.Command, cfg *config.Config, action stri LogFile: infraLogPath(cfg, action, providerName, ""), } - if action == "apply" || action == "destroy" { + switch action { + case "destroy": + if err := confirmDestroy(cmd, cfg.Env, ""); err != nil { + return err + } + opts.AutoApprove = true + case "apply": autoApprove, _ := cmd.Flags().GetBool("auto-approve") opts.AutoApprove = autoApprove } @@ -380,7 +469,8 @@ func runInfraActionTerraform(cmd *cobra.Command, cfg *config.Config, action stri fmt.Printf("Lab: %s\n", cfg.ProxmoxLab()) fmt.Printf("Log: %s\n\n", opts.LogFile) - ctx := context.Background() + ctx, cancel := infraActionContext(cmd) + defer cancel() return terraform.Run(ctx, opts) } diff --git a/cli/cmd/infra_state.go b/cli/cmd/infra_state.go new file mode 100644 index 00000000..9d648aef --- /dev/null +++ b/cli/cmd/infra_state.go @@ -0,0 +1,127 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// Terraform state for this project is LOCAL and gitignored (see +// infra/*/goad-deployment/root.hcl and .gitignore). That has a consequence +// worth naming precisely, because the obvious error message hides it: a range +// can only be torn down from the working copy that deployed it. Everywhere +// else, the state describing those resources simply does not exist. +// +// The failure this produces is quiet and expensive. `infra destroy` reported +// only "infra working directory not found", which reads as "recreate the +// directory" — but a recreated directory starts from EMPTY state, so +// Terragrunt would plan to CREATE the range a second time rather than destroy +// the running one. An operator (or an agent) following that hint goes looking +// for a directory that would not have helped, while the real resources keep +// billing. + +// hasTerraformState reports whether a working directory contains an actual +// Terraform state document. Terragrunt keeps local state per module, including +// beneath .terragrunt-cache, so the search is recursive. The .terraform tree is +// skipped because terraform init writes backend metadata named terraform.tfstate +// there before any resource state exists. +func hasTerraformState(dir string) bool { + found := false + _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil || found { + return nil //nolint:nilerr // unreadable subtree just means "no evidence here" + } + name := d.Name() + if d.IsDir() { + if name == ".terraform" { + return filepath.SkipDir + } + return nil + } + if (strings.HasSuffix(name, ".tfstate") || strings.HasSuffix(name, ".tfstate.backup")) && isTerraformStateDocument(path) { + found = true + return filepath.SkipAll + } + return nil + }) + return found +} + +func isTerraformStateDocument(path string) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + var state struct { + Version int `json:"version"` + } + return json.Unmarshal(data, &state) == nil && state.Version > 0 +} + +// infraStateError explains a missing or empty working directory in terms of +// what the operator can actually do next, rather than in terms of the path the +// code happened to look for. +// +// “action“ matters: an absent directory before `apply` means the environment +// was never scaffolded, which is ordinary and fixable. The same absence before +// `destroy` means the state is gone, which is neither. +func infraStateError(workDir, env, region, action string, dirExists bool) error { + if action != "destroy" { + // Only reached when the directory is absent: checkInfraWorkDir returns + // early for a non-destroy action whose directory exists. An extra + // `if dirExists { return nil }` here looked defensive but was + // unreachable, and it swallowed the case where that gate is wrong — + // mutating the gate produced no test failure until this was removed. + return fmt.Errorf( + "infra working directory not found: %s\n"+ + "Environment %q has not been scaffolded for region %q.\n"+ + "Run 'dreadgoad infra validate' to check your setup", + workDir, env, region) + } + + // Destroy: the directory is beside the point — state is what's missing. + detail := "it has never been applied (no Terraform state found)" + if !dirExists { + detail = "it does not exist in this checkout" + } + return fmt.Errorf( + "cannot destroy %s/%s: %s\n"+ + " expected working directory: %s\n\n"+ + "Terraform state for this project is local and gitignored, so a range can "+ + "only be destroyed from the working copy that deployed it. Recreating this "+ + "directory would start from empty state and plan to CREATE the range, not "+ + "tear it down.\n\n"+ + "If the range is still running, either run 'dreadgoad infra destroy' on the "+ + "machine that deployed it, or delete its cloud resources directly — on Azure "+ + "that is the range's resource group, which 'dreadgoad lab status --json' "+ + "reports as the \"group\" field", + env, region, detail, workDir) +} + +// checkInfraWorkDir validates that a provider's rendered/scaffolded working +// directory exists. Remote backends such as AWS S3 are deliberately not gated +// on local state; Terragrunt initializes and reads that state from the backend. +func checkInfraWorkDir(workDir, env, region, action string) error { + if _, err := os.Stat(workDir); err == nil { + return nil + } + return fmt.Errorf( + "infra working directory not found: %s\n"+ + "Environment %q has not been scaffolded for region %q.\n"+ + "Run 'dreadgoad infra validate' to check your setup", + workDir, env, region) +} + +// checkLocalInfraState adds the destroy safety gate required by Azure's local +// backend. It must not be used for providers whose state is remote. +func checkLocalInfraState(workDir, env, region, action string) error { + _, statErr := os.Stat(workDir) + dirExists := statErr == nil + if dirExists && (action != "destroy" || hasTerraformState(workDir)) { + return nil + } + return infraStateError(workDir, env, region, action, dirExists) +} diff --git a/cli/cmd/infra_state_test.go b/cli/cmd/infra_state_test.go new file mode 100644 index 00000000..a86d6ef5 --- /dev/null +++ b/cli/cmd/infra_state_test.go @@ -0,0 +1,152 @@ +package cmd + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" +) + +func TestHasTerraformStateDetectsAppliedModules(t *testing.T) { + // Terragrunt keeps state per module, so evidence sits in subdirectories + // rather than at the working-directory root. + dir := t.TempDir() + mod := filepath.Join(dir, "goad", "dc01") + if err := os.MkdirAll(mod, 0o755); err != nil { + t.Fatal(err) + } + if hasTerraformState(dir) { + t.Fatal("scaffold with no state must not read as applied") + } + if err := os.WriteFile(filepath.Join(mod, "terraform.tfstate"), []byte(`{"version":4,"resources":[]}`), 0o600); err != nil { + t.Fatal(err) + } + if !hasTerraformState(dir) { + t.Fatal("a nested .tfstate must count as applied") + } +} + +func TestHasTerraformStateRejectsInitArtifacts(t *testing.T) { + dir := t.TempDir() + terraformDir := filepath.Join(dir, "network", ".terraform") + if err := os.MkdirAll(terraformDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(terraformDir, "terraform.tfstate"), []byte(`{"version":3,"backend":{}}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "network", ".terraform.lock.hcl"), []byte("# providers"), 0o600); err != nil { + t.Fatal(err) + } + if hasTerraformState(dir) { + t.Fatal("terraform init artifacts must not count as applied state") + } +} + +// The exact situation that produced the misleading error: the range is running +// in Azure, but this checkout has no state for it. +func TestDestroyWithoutStateExplainsWhyRecreatingWontHelp(t *testing.T) { + missing := filepath.Join(t.TempDir(), "dreadindex", "centralus") + err := checkLocalInfraState(missing, "dreadindex", "centralus", "destroy") + if err == nil { + t.Fatal("destroy without state must fail") + } + msg := err.Error() + for _, want := range []string{ + "cannot destroy dreadindex/centralus", + "does not exist in this checkout", + "only be destroyed from the working copy that deployed it", + "plan to CREATE", + "resource group", + } { + if !strings.Contains(msg, want) { + t.Fatalf("message missing %q:\n%s", want, msg) + } + } + // The old message sent people after the directory; that hint must be gone. + if strings.Contains(msg, "infra working directory not found") { + t.Fatalf("still leads with the misleading directory framing:\n%s", msg) + } +} + +// A scaffolded-but-never-applied directory is the other half of the same bug: +// destroy would have run and quietly done nothing. +func TestDestroyOnScaffoldWithoutStateIsRefused(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "goad", "dc01"), 0o755); err != nil { + t.Fatal(err) + } + err := checkLocalInfraState(dir, "test", "centralus", "destroy") + if err == nil { + t.Fatal("destroy on a never-applied scaffold must fail") + } + if !strings.Contains(err.Error(), "never been applied") { + t.Fatalf("should say the directory exists but was never applied:\n%s", err) + } +} + +// Regression guard: a first apply has no state by definition and must proceed. +func TestApplyOnFreshScaffoldIsAllowed(t *testing.T) { + dir := t.TempDir() + if err := checkLocalInfraState(dir, "dev", "us-west-2", "apply"); err != nil { + t.Fatalf("first apply must not be blocked: %v", err) + } + if err := checkInfraWorkDir(dir, "dev", "us-west-2", "plan"); err != nil { + t.Fatalf("plan must not be blocked: %v", err) + } +} + +// A missing directory before apply is ordinary and keeps the old guidance. +func TestApplyOnMissingDirKeepsScaffoldGuidance(t *testing.T) { + missing := filepath.Join(t.TempDir(), "nope") + err := checkInfraWorkDir(missing, "dev", "us-west-2", "apply") + if err == nil { + t.Fatal("apply on a missing directory must fail") + } + msg := err.Error() + if !strings.Contains(msg, "infra validate") || !strings.Contains(msg, "not been scaffolded") { + t.Fatalf("apply should point at scaffolding, not at state:\n%s", msg) + } + if strings.Contains(msg, "plan to CREATE") { + t.Fatalf("destroy-specific wording leaked into apply:\n%s", msg) + } +} + +// Destroy proceeds normally when state is present. +func TestDestroyWithStateProceeds(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "terraform.tfstate"), []byte(`{"version":4,"resources":[]}`), 0o600); err != nil { + t.Fatal(err) + } + if err := checkLocalInfraState(dir, "dev", "us-west-2", "destroy"); err != nil { + t.Fatalf("destroy with state must proceed: %v", err) + } +} + +func TestRemoteBackendDestroyDoesNotRequireLocalState(t *testing.T) { + dir := t.TempDir() + if err := checkInfraWorkDir(dir, "dev", "us-west-2", "destroy"); err != nil { + t.Fatalf("remote backend destroy must initialize and query its backend: %v", err) + } +} + +func TestInfraActionContextPreservesCommandCancellation(t *testing.T) { + parent, cancelParent := context.WithCancel(context.Background()) + cmd := &cobra.Command{} + cmd.SetContext(parent) + cmd.Flags().Duration("timeout", time.Minute, "") + + ctx, cancel := infraActionContext(cmd) + defer cancel() + cancelParent() + + select { + case <-ctx.Done(): + case <-time.After(time.Second): + t.Fatal("infra context ignored command cancellation") + } +} diff --git a/cli/cmd/inventory.go b/cli/cmd/inventory.go index 4e5b0700..4ba9282d 100644 --- a/cli/cmd/inventory.go +++ b/cli/cmd/inventory.go @@ -13,6 +13,7 @@ import ( "github.com/dreadnode/dreadgoad/internal/config" inv "github.com/dreadnode/dreadgoad/internal/inventory" + "github.com/dreadnode/dreadgoad/internal/provider" "github.com/spf13/cobra" ) @@ -78,6 +79,17 @@ func runInventorySync(cmd *cobra.Command, args []string) error { return err } + // Passwords first, and independently of the address sync below: an + // unresolvable host makes applyInstanceUpdates return an error, and the + // credentials are worth repairing even on a run that then reports that. + // This is the command both preflight gates point the operator at, so it has + // to reconcile everything the inventory gets wrong, not just addresses. + if cfg.ResolvedProvider() == provider.NameAzure { + if err := syncAzureInventoryPasswords(cfg); err != nil { + return err + } + } + jsonFile, _ := cmd.Flags().GetString("json") instances, err := loadInstances(context.Background(), jsonFile, invPath, cfg) if err != nil { @@ -177,17 +189,23 @@ func loadInstances(ctx context.Context, jsonFile, invPath string, cfg *config.Co // extractHostRole extracts the Ansible inventory hostname from a VM name. // Supports multiple naming conventions: // - AWS: "dreadgoad-dc01" -> "dc01" +// - Azure: "A-dreadgoad-DC01-vm" -> "dc01" // - Ludus/Proxmox: "DG-GOAD-DC01" -> "dc01" // // Falls back to the last hyphen-separated segment for unknown patterns. func extractHostRole(vmName string) string { lower := strings.ToLower(vmName) - // AWS convention: "dreadgoad-" - if strings.Contains(lower, "dreadgoad-") { - parts := strings.SplitN(lower, "dreadgoad-", 2) - if len(parts) == 2 && parts[1] != "" { - return parts[1] + // Azure suffixes every machine name with "-vm". Left in place it yields + // "dc01-vm", which matches no inventory host, and the sync then reports + // "all values are current" over an inventory that is still all PENDING. + lower = strings.TrimSuffix(lower, "-vm") + + // AWS convention: "dreadgoad-". Anchored on the *last* occurrence + // so it works regardless of what the env/deployment prefix contains. + if i := strings.LastIndex(lower, "dreadgoad-"); i >= 0 { + if role := lower[i+len("dreadgoad-"):]; role != "" { + return role } } @@ -237,6 +255,26 @@ func applyInstanceUpdates(invPath string, instances []instanceInfo) error { return fmt.Errorf("write updated inventory: %w", err) } + // A host still holding a placeholder is unreachable — Ansible resolves + // ansible_host to the literal string and every play fails "unreachable". + // Reporting "all values are current" over that state is a false success + // that surfaces minutes later as a provisioning failure, so name it here. + if stale := placeholderHosts(lines); len(stale) > 0 { + names := make([]string, 0, len(instances)) + for _, inst := range instances { + names = append(names, inst.Name) + } + // Says "for these hosts", not "nothing matched": a sync routinely + // resolves most of the inventory and leaves one host behind, and an + // error claiming total failure would send the operator looking in the + // wrong place. + return fmt.Errorf( + "inventory %s still has placeholder ansible_host for %s — "+ + "no discovered machine name maps to those hosts\n"+ + " discovered %d machine(s): %s", + invPath, strings.Join(stale, ", "), len(instances), strings.Join(names, ", ")) + } + if updates == 0 { fmt.Println("No inventory updates needed. All values are current.") } else { @@ -245,6 +283,31 @@ func applyInstanceUpdates(invPath string, instances []instanceInfo) error { return nil } +// placeholderRe matches an inventory host line whose ansible_host is still a +// scaffolding placeholder: PENDING (written by `env create`) or an unrendered +// {{ip_range}} template from a provider inventory. +// +// Leading whitespace is allowed because Ansible accepts indented host lines, +// and a gate that misses them would let the exact failure it guards against +// through. The character class after it excludes ";" and "#" so a commented-out +// host is not reported as live; RE2 has no lookahead, so the exclusion is +// spelled into the class rather than written as (?![;#]). +// +// The trailing (\s|$) makes the value match whole-token: without it "pending" +// also matches the prefix of a real address like "pending-lab.example.com", +// blocking a run over a host that is perfectly well configured. +var placeholderRe = regexp.MustCompile(`(?mi)^\s*([^;#\s]\S*)\s+ansible_host=(pending|\{\{[^}]*\}\}\S*)(\s|$)`) + +// placeholderHosts returns the inventory hostnames whose ansible_host has not +// been resolved to a real address. +func placeholderHosts(inventory string) []string { + var out []string + for _, m := range placeholderRe.FindAllStringSubmatch(inventory, -1) { + out = append(out, m[1]) + } + return out +} + func runInventoryShow(cmd *cobra.Command, args []string) error { cfg, err := config.Get() if err != nil { diff --git a/cli/cmd/inventory_azure_test.go b/cli/cmd/inventory_azure_test.go new file mode 100644 index 00000000..5a683da6 --- /dev/null +++ b/cli/cmd/inventory_azure_test.go @@ -0,0 +1,185 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The names below are verbatim from `dreadgoad lab status` against a live +// Azure range. Before the fix these produced "dc01-vm" and "dreadgoad-dc01", +// neither of which matches an inventory host, so every sync was a no-op. +func TestExtractHostRoleAzure(t *testing.T) { + tests := []struct { + name string + vmName string + want string + }{ + {"azure goad host", "3.1-goad-dreadgoad-DC01-vm", "dc01"}, + {"azure member server", "3.1-goad-dreadgoad-SRV02-vm", "srv02"}, + {"azure dotted env", "dg-test-2.A-goad-dreadgoad-DC03-vm", "dc03"}, + {"azure doubled prefix", "dreadindex2-dreadgoad-dreadgoad-DC01-vm", "dc01"}, + {"azure controller", "3.1-goad-controller-vm", "controller"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := extractHostRole(tt.vmName); got != tt.want { + t.Errorf("extractHostRole(%q) = %q, want %q", tt.vmName, got, tt.want) + } + }) + } +} + +// A machine whose whole name is the prefix plus the suffix leaves nothing to +// use as a role. Returning "dreadgoad" there would build a regex that matches +// no host but still looks like a successful extraction. +func TestExtractHostRoleDegenerateAzureName(t *testing.T) { + if got := extractHostRole("dreadgoad-vm"); got != "" { + t.Errorf("extractHostRole(\"dreadgoad-vm\") = %q, want empty", got) + } +} + +func TestApplyInstanceUpdatesAzure(t *testing.T) { + invPath := filepath.Join(t.TempDir(), "3.1-inventory") + content := "[default]\n" + + "dc01 ansible_host=PENDING dns_domain=dc01 dict_key=dc01\n" + + "dc02 ansible_host=PENDING dns_domain=dc01 dict_key=dc02\n" + + "srv02 ansible_host=PENDING dns_domain=dc02 dict_key=srv02\n" + if err := os.WriteFile(invPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + instances := []instanceInfo{ + {InstanceID: "/subscriptions/x/dc01", Name: "3.1-goad-dreadgoad-DC01-vm", PrivateIP: "10.100.1.5"}, + {InstanceID: "/subscriptions/x/dc02", Name: "3.1-goad-dreadgoad-DC02-vm", PrivateIP: "10.100.1.4"}, + {InstanceID: "/subscriptions/x/srv02", Name: "3.1-goad-dreadgoad-SRV02-vm", PrivateIP: "10.100.1.6"}, + // The controller is a real machine in the resource group but has no + // inventory host; it must not error the sync. + {InstanceID: "/subscriptions/x/ctl", Name: "3.1-goad-controller-vm", PrivateIP: "10.100.3.4"}, + } + if err := applyInstanceUpdates(invPath, instances); err != nil { + t.Fatalf("applyInstanceUpdates: %v", err) + } + + got, err := os.ReadFile(invPath) + if err != nil { + t.Fatal(err) + } + result := string(got) + // The resource ID must never land in ansible_host — the private IP does. + for host, ip := range map[string]string{"dc01": "10.100.1.5", "dc02": "10.100.1.4", "srv02": "10.100.1.6"} { + if !strings.Contains(result, host+" ansible_host="+ip) { + t.Errorf("missing %s -> %s in:\n%s", host, ip, result) + } + } + if strings.Contains(result, "PENDING") { + t.Errorf("placeholder survived the sync:\n%s", result) + } +} + +// The regression that cost two full apply cycles: the sync ran, matched +// nothing, and printed "All values are current" over an inventory that was +// entirely PENDING. Silence here is worse than a wrong value. +func TestApplyInstanceUpdatesRejectsSilentNoOp(t *testing.T) { + invPath := filepath.Join(t.TempDir(), "3.1-inventory") + content := "[default]\ndc01 ansible_host=PENDING dns_domain=dc01 dict_key=dc01\n" + if err := os.WriteFile(invPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + err := applyInstanceUpdates(invPath, []instanceInfo{ + {InstanceID: "i-1", Name: "some-unrelated-machine", PrivateIP: "10.0.0.9"}, + }) + if err == nil { + t.Fatal("a sync that left every host PENDING reported success") + } + // The operator needs both halves to debug it: which host is unresolved, + // and what the discovery actually returned. + for _, want := range []string{"dc01", "some-unrelated-machine"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error does not mention %q: %v", want, err) + } + } +} + +// An unrendered provider template is the same failure wearing a different +// placeholder, and reaches Ansible the same way. +func TestApplyInstanceUpdatesRejectsUnrenderedTemplate(t *testing.T) { + invPath := filepath.Join(t.TempDir(), "3.1-inventory") + content := "[default]\ndc01 ansible_host={{ip_range}}.10 dns_domain=dc01 dict_key=dc01\n" + if err := os.WriteFile(invPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + if err := applyInstanceUpdates(invPath, []instanceInfo{ + {InstanceID: "i-1", Name: "nope", PrivateIP: "10.0.0.9"}, + }); err == nil { + t.Fatal("an unrendered {{ip_range}} template reported success") + } +} + +// A fully-resolved inventory must stay quiet. Erroring here would break every +// idempotent re-run of provision. +func TestApplyInstanceUpdatesResolvedInventoryIsNotAnError(t *testing.T) { + invPath := filepath.Join(t.TempDir(), "3.1-inventory") + content := "[default]\ndc01 ansible_host=10.100.1.5 dns_domain=dc01 dict_key=dc01\n" + if err := os.WriteFile(invPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + if err := applyInstanceUpdates(invPath, []instanceInfo{ + {InstanceID: "/subscriptions/x/dc01", Name: "3.1-goad-dreadgoad-DC01-vm", PrivateIP: "10.100.1.5"}, + }); err != nil { + t.Fatalf("already-current inventory reported an error: %v", err) + } +} + +// PENDING appearing anywhere other than an ansible_host value (a password, a +// comment) must not trip the guard. +func TestPlaceholderHostsIgnoresOtherFields(t *testing.T) { + inventory := "; PENDING review\n" + + "dc01 ansible_host=10.100.1.5 ansible_password=PENDING123\n" + if got := placeholderHosts(inventory); len(got) != 0 { + t.Errorf("placeholderHosts = %v, want none", got) + } +} + +// Ansible accepts indented host lines. A gate that only recognises +// column-zero hosts would pass an inventory holding the very failure it +// exists to catch. +func TestPlaceholderHostsSeesIndentedHosts(t *testing.T) { + for name, body := range map[string]string{ + "spaces": " dc01 ansible_host=PENDING dict_key=dc01\n", + "tab": "\tdc01 ansible_host=PENDING dict_key=dc01\n", + } { + got := placeholderHosts(body) + if len(got) != 1 || got[0] != "dc01" { + t.Errorf("%s-indented host: placeholderHosts = %v, want [dc01]", name, got) + } + } +} + +// "pending" must match as a whole value, not as a prefix. A real address that +// merely starts with those letters is correctly configured, and blocking it +// would be a false alarm on a working range. +func TestPlaceholderHostsMatchesWholeValueOnly(t *testing.T) { + for _, addr := range []string{"pending-lab.example.com", "pendingtonhost", "10.1.1.5"} { + body := "dc01 ansible_host=" + addr + " dict_key=dc01\n" + if got := placeholderHosts(body); len(got) != 0 { + t.Errorf("ansible_host=%s was flagged as a placeholder: %v", addr, got) + } + } + // The bare token, with and without a trailing field, still must be caught. + for _, body := range []string{"dc01 ansible_host=PENDING\n", "dc01 ansible_host=PENDING", "dc01 ansible_host=PENDING x=1\n"} { + if got := placeholderHosts(body); len(got) != 1 { + t.Errorf("bare PENDING not caught in %q: %v", body, got) + } + } +} + +// A CRLF inventory (edited on Windows, or fetched over a share) must not slip +// past the gate: \r would otherwise sit between the value and the line end. +func TestPlaceholderHostsHandlesCRLF(t *testing.T) { + if got := placeholderHosts("dc01 ansible_host=PENDING\r\ndc02 ansible_host=10.1.1.5\r\n"); len(got) != 1 || got[0] != "dc01" { + t.Errorf("placeholderHosts on CRLF = %v, want [dc01]", got) + } +} diff --git a/cli/cmd/inventory_creds_test.go b/cli/cmd/inventory_creds_test.go new file mode 100644 index 00000000..dfc1bd09 --- /dev/null +++ b/cli/cmd/inventory_creds_test.go @@ -0,0 +1,155 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/dreadnode/dreadgoad/internal/config" +) + +// credFixture writes an inventory and the materialized lab config Terraform +// would have read, then returns a config pointing at both. +func credFixture(t *testing.T, invBody string, hostPasswords map[string]string) *config.Config { + t.Helper() + root := t.TempDir() + // Azure explicitly: the credential check is scoped to it, so a fixture + // without a provider would make every assertion here pass vacuously. + cfg := &config.Config{ProjectRoot: root, Env: "e1", Provider: "azure"} + + if err := os.WriteFile(cfg.InventoryPath(), []byte(invBody), 0o644); err != nil { + t.Fatal(err) + } + dataDir := filepath.Join(root, "ad", "GOAD", "data") + if err := os.MkdirAll(dataDir, 0o755); err != nil { + t.Fatal(err) + } + var b strings.Builder + b.WriteString(`{"lab":{"hosts":{`) + first := true + for host, pw := range hostPasswords { + if !first { + b.WriteString(",") + } + first = false + b.WriteString(`"` + host + `":{"local_admin_password":"` + pw + `"}`) + } + b.WriteString(`}}}`) + if err := os.WriteFile(filepath.Join(dataDir, "e1-config.json"), []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } + return cfg +} + +// The 3.1 shape: the inventory was scaffolded from a provider template whose +// stock passwords were never reconciled with the generated config, so no host +// can authenticate. Measured on the real range: 0/5 matched. +func TestValidateInventoryCredentialsBlocksTotalMismatch(t *testing.T) { + cfg := credFixture(t, + "[default]\n"+ + "dc01 ansible_host=10.1.1.5 ansible_user=ansible ansible_password=from-template-a\n"+ + "dc02 ansible_host=10.1.1.6 ansible_user=ansible ansible_password=from-template-b\n", + map[string]string{"dc01": "built-with-a", "dc02": "built-with-b"}) + + err := validateInventoryCredentials(cfg) + if err == nil { + t.Fatal("every host had the wrong password and provisioning was allowed to start") + } + for _, want := range []string{"dc01", "dc02", "WinRM"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error missing %q: %v", want, err) + } + } + // The whole point of fingerprinting during diagnosis: secrets must not be + // echoed into a terminal or a CI log. + for _, secret := range []string{"from-template-a", "built-with-a", "from-template-b", "built-with-b"} { + if strings.Contains(err.Error(), secret) { + t.Errorf("error leaked a password: %v", err) + } + } +} + +// The dreadindex shape: the range that provisioned successfully matched on +// every host (measured 5/5). +func TestValidateInventoryCredentialsAcceptsFullMatch(t *testing.T) { + cfg := credFixture(t, + "[default]\n"+ + "dc01 ansible_host=10.1.1.5 ansible_user=ansible ansible_password=shared-a\n"+ + "srv02 ansible_host=10.1.1.8 ansible_user=ansible ansible_password=shared-b\n", + map[string]string{"dc01": "shared-a", "srv02": "shared-b"}) + + if err := validateInventoryCredentials(cfg); err != nil { + t.Fatalf("a correctly-provisioned range was blocked: %v", err) + } +} + +// One host drifting is not the scaffolding bug and must not block a range that +// is otherwise fine — a DC whose account moved into the domain, say. +func TestValidateInventoryCredentialsAllowsPartialDrift(t *testing.T) { + cfg := credFixture(t, + "[default]\n"+ + "dc01 ansible_host=10.1.1.5 ansible_password=shared-a\n"+ + "srv02 ansible_host=10.1.1.8 ansible_password=drifted\n", + map[string]string{"dc01": "shared-a", "srv02": "shared-b"}) + + if err := validateInventoryCredentials(cfg); err != nil { + t.Fatalf("a single drifted host blocked the run: %v", err) + } +} + +// Absence of the materialized config is not evidence of a mismatch. Blocking +// here would break every layout that does not materialize one. +func TestValidateInventoryCredentialsSkipsWithoutConfig(t *testing.T) { + root := t.TempDir() + cfg := &config.Config{ProjectRoot: root, Env: "e1", Provider: "azure"} + if err := os.WriteFile(cfg.InventoryPath(), + []byte("[default]\ndc01 ansible_host=10.1.1.5 ansible_password=x\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := validateInventoryCredentials(cfg); err != nil { + t.Fatalf("missing lab config was treated as a mismatch: %v", err) + } +} + +// Hosts the config says nothing about, and hosts with no password in either +// place, are not comparable — counting them would manufacture a mismatch. +func TestValidateInventoryCredentialsIgnoresIncomparableHosts(t *testing.T) { + cfg := credFixture(t, + "[default]\n"+ + "dc01 ansible_host=10.1.1.5 ansible_password=shared-a\n"+ + "kali ansible_host=10.1.3.9\n"+ + "ghost ansible_host=10.1.1.9 ansible_password=whatever\n", + map[string]string{"dc01": "shared-a"}) + + if err := validateInventoryCredentials(cfg); err != nil { + t.Fatalf("incomparable hosts produced a mismatch: %v", err) + } +} + +// Corrupt JSON is a different problem with a different fix; it must not be +// reported as a credential mismatch. +func TestValidateInventoryCredentialsSurvivesCorruptConfig(t *testing.T) { + cfg := credFixture(t, + "[default]\ndc01 ansible_host=10.1.1.5 ansible_password=x\n", + map[string]string{"dc01": "y"}) + bad := filepath.Join(cfg.ProjectRoot, "ad", "GOAD", "data", "e1-config.json") + if err := os.WriteFile(bad, []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + if err := validateInventoryCredentials(cfg); err != nil { + t.Fatalf("corrupt config was reported as a credential mismatch: %v", err) + } +} + +// The inventory parser strips quotes from ansible_password. A quoted config +// value must therefore still compare equal, or every generated range with a +// shell-unsafe password would be falsely blocked. +func TestValidateInventoryCredentialsHandlesQuotedPasswords(t *testing.T) { + cfg := credFixture(t, + "[default]\ndc01 ansible_host=10.1.1.5 ansible_password='F](O:4 0 { + t.Errorf("3.1-inventory still has unresolved hosts: %v", stale) + } +} diff --git a/cli/cmd/inventory_passwords_test.go b/cli/cmd/inventory_passwords_test.go new file mode 100644 index 00000000..0204d692 --- /dev/null +++ b/cli/cmd/inventory_passwords_test.go @@ -0,0 +1,257 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/dreadnode/dreadgoad/internal/config" + inv "github.com/dreadnode/dreadgoad/internal/inventory" +) + +// pwFixture writes an inventory plus the materialized lab config Terraform +// consumed, and returns a config pointing at both. +func pwFixture(t *testing.T, invBody string, hostPasswords map[string]string) *config.Config { + t.Helper() + root := t.TempDir() + cfg := &config.Config{ProjectRoot: root, Env: "e1", Provider: "azure"} + if err := os.WriteFile(cfg.InventoryPath(), []byte(invBody), 0o644); err != nil { + t.Fatal(err) + } + dataDir := filepath.Join(root, "ad", "GOAD", "data") + if err := os.MkdirAll(dataDir, 0o755); err != nil { + t.Fatal(err) + } + var b strings.Builder + b.WriteString(`{"lab":{"hosts":{`) + first := true + for host, pw := range hostPasswords { + if !first { + b.WriteString(",") + } + first = false + esc := strings.ReplaceAll(pw, `\`, `\\`) + esc = strings.ReplaceAll(esc, `"`, `\"`) + b.WriteString(`"` + host + `":{"local_admin_password":"` + esc + `"}`) + } + b.WriteString(`}}}`) + if err := os.WriteFile(filepath.Join(dataDir, "e1-config.json"), []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } + return cfg +} + +// readBack parses the inventory the way the rest of the CLI does, so the test +// asserts what a consumer actually sees rather than raw file text. +func readBack(t *testing.T, cfg *config.Config) *inv.Inventory { + t.Helper() + parsed, err := inv.Parse(cfg.InventoryPath()) + if err != nil { + t.Fatalf("inventory no longer parses: %v", err) + } + return parsed +} + +// The 3.1 shape: template passwords that match no config. After the sync every +// host presents the password its machine was actually built with. +func TestSyncAzurePasswordsReconcilesTemplateValues(t *testing.T) { + cfg := pwFixture(t, + "[default]\n"+ + // Placeholder stand-ins for the provider template's stock values; + // no reason to give those a second home in the test suite. + "dc01 ansible_host=10.100.1.5 dns_domain=dc01 dict_key=dc01 ansible_user=ansible ansible_password=from-template-dc01\n"+ + "srv02 ansible_host=10.100.1.6 dns_domain=dc02 dict_key=srv02 ansible_user=ansible ansible_password=from-template-srv02\n", + map[string]string{"dc01": "built-with-dc01", "srv02": "built-with-srv02"}) + + if err := syncAzureInventoryPasswords(cfg); err != nil { + t.Fatalf("sync: %v", err) + } + parsed := readBack(t, cfg) + for host, want := range map[string]string{"dc01": "built-with-dc01", "srv02": "built-with-srv02"} { + if got := parsed.HostByName(host).Password; got != want { + t.Errorf("%s password = %q, want the value from the lab config", host, got) + } + } + // Everything else on the line has to survive untouched. + if h := parsed.HostByName("dc01"); h.InstanceID != "10.100.1.5" || h.User != "ansible" || h.DictKey != "dc01" { + t.Errorf("sync damaged other fields on the host line: %+v", h) + } +} + +// After the sync, the gate that blocks provisioning must be satisfied. These +// two have to agree or the fix does not actually unblock anything. +func TestSyncThenCredentialGatePasses(t *testing.T) { + cfg := pwFixture(t, + "[default]\ndc01 ansible_host=10.1.1.5 ansible_password=stock-a\ndc02 ansible_host=10.1.1.6 ansible_password=stock-b\n", + map[string]string{"dc01": "real-a", "dc02": "real-b"}) + + if err := validateInventoryCredentials(cfg); err == nil { + t.Fatal("gate should have blocked before the sync ran") + } + if err := syncAzureInventoryPasswords(cfg); err != nil { + t.Fatalf("sync: %v", err) + } + if err := validateInventoryCredentials(cfg); err != nil { + t.Fatalf("gate still blocks after a successful sync: %v", err) + } +} + +// Passwords carry shell metacharacters. "$" in particular would be read as a +// capture-group reference by a naive regexp replacement, silently corrupting +// the value. +func TestSyncAzurePasswordsHandlesMetacharacters(t *testing.T) { + tricky := `a$1b&c|d;eg?h*i(j)k=l+m!n@o#p%q^r-s_t.u,v:w` + cfg := pwFixture(t, + "[default]\ndc01 ansible_host=10.1.1.5 ansible_password=old\n", + map[string]string{"dc01": tricky}) + + if err := syncAzureInventoryPasswords(cfg); err != nil { + t.Fatalf("sync: %v", err) + } + if got := readBack(t, cfg).HostByName("dc01").Password; got != tricky { + t.Errorf("metacharacters were mangled:\n got %q\n want %q", got, tricky) + } +} + +// A password containing a single quote must round-trip via double quotes. +func TestSyncAzurePasswordsQuotesCorrectly(t *testing.T) { + withSingle := `has'single` + cfg := pwFixture(t, + "[default]\ndc01 ansible_host=10.1.1.5 ansible_password=old\n", + map[string]string{"dc01": withSingle}) + if err := syncAzureInventoryPasswords(cfg); err != nil { + t.Fatalf("sync: %v", err) + } + if got := readBack(t, cfg).HostByName("dc01").Password; got != withSingle { + t.Errorf("password with a single quote = %q, want %q", got, withSingle) + } +} + +// Both quote characters cannot be represented by inventory.stripQuotes. Writing +// something that parses back differently is worse than leaving it alone. +func TestSyncAzurePasswordsSkipsUnrepresentableValues(t *testing.T) { + cfg := pwFixture(t, + "[default]\ndc01 ansible_host=10.1.1.5 ansible_password=untouched\n", + map[string]string{"dc01": `both'and"quotes`}) + if err := syncAzureInventoryPasswords(cfg); err != nil { + t.Fatalf("sync: %v", err) + } + if got := readBack(t, cfg).HostByName("dc01").Password; got != "untouched" { + t.Errorf("wrote an unrepresentable value: %q", got) + } +} + +// An already-quoted value in the inventory must be replaced whole, not nested +// inside the old quotes. +func TestSyncAzurePasswordsReplacesQuotedValues(t *testing.T) { + cfg := pwFixture(t, + "[default]\ndc01 ansible_host=10.1.1.5 ansible_password='old value' dict_key=dc01\n", + map[string]string{"dc01": "new-secret"}) + if err := syncAzureInventoryPasswords(cfg); err != nil { + t.Fatalf("sync: %v", err) + } + parsed := readBack(t, cfg) + if got := parsed.HostByName("dc01").Password; got != "new-secret" { + t.Errorf("password = %q, want new-secret", got) + } + if parsed.HostByName("dc01").DictKey != "dc01" { + t.Error("trailing fields were consumed by the replacement") + } +} + +// Hosts the config says nothing about keep whatever they had. +func TestSyncAzurePasswordsLeavesUnknownHostsAlone(t *testing.T) { + cfg := pwFixture(t, + "[default]\ndc01 ansible_host=10.1.1.5 ansible_password=keep-me\nkali ansible_host=10.1.3.9 ansible_password=kali-pw\n", + map[string]string{"dc01": "keep-me"}) + if err := syncAzureInventoryPasswords(cfg); err != nil { + t.Fatalf("sync: %v", err) + } + if got := readBack(t, cfg).HostByName("kali").Password; got != "kali-pw" { + t.Errorf("kali password changed to %q", got) + } +} + +// No materialized config means nothing to reconcile against, and must not be +// treated as an error or wipe the inventory. +func TestSyncAzurePasswordsNoOpsWithoutConfig(t *testing.T) { + root := t.TempDir() + cfg := &config.Config{ProjectRoot: root, Env: "e1", Provider: "azure"} + body := "[default]\ndc01 ansible_host=10.1.1.5 ansible_password=x\n" + if err := os.WriteFile(cfg.InventoryPath(), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + if err := syncAzureInventoryPasswords(cfg); err != nil { + t.Fatalf("missing config produced an error: %v", err) + } + got, err := os.ReadFile(cfg.InventoryPath()) + if err != nil { + t.Fatal(err) + } + if string(got) != body { + t.Errorf("inventory was rewritten with no config present:\n%s", got) + } +} + +// Running twice must produce the same file — preflight runs on every provision. +func TestSyncAzurePasswordsIsIdempotent(t *testing.T) { + cfg := pwFixture(t, + "[default]\ndc01 ansible_host=10.1.1.5 ansible_password=stock\n", + map[string]string{"dc01": "real"}) + if err := syncAzureInventoryPasswords(cfg); err != nil { + t.Fatal(err) + } + first, err := os.ReadFile(cfg.InventoryPath()) + if err != nil { + t.Fatal(err) + } + if err := syncAzureInventoryPasswords(cfg); err != nil { + t.Fatal(err) + } + second, err := os.ReadFile(cfg.InventoryPath()) + if err != nil { + t.Fatal(err) + } + if string(first) != string(second) { + t.Errorf("second run changed the file:\n%s\n---\n%s", first, second) + } +} + +// The credential gate tells the operator to run `inventory sync`. If that +// command does not reconcile passwords, the message is a dead end: the sync +// reports success and the gate keeps blocking with no way forward. +func TestCredentialGateErrorNamesAWorkingCommand(t *testing.T) { + cfg := pwFixture(t, + "[default]\ndc01 ansible_host=10.1.1.5 ansible_password=stock\n", + map[string]string{"dc01": "real"}) + + err := validateInventoryCredentials(cfg) + if err == nil { + t.Fatal("expected the gate to block") + } + if !strings.Contains(err.Error(), "inventory sync") { + t.Fatalf("error does not name a command to run: %v", err) + } + // Now do what the message says, via the same function the command calls. + if err := syncAzureInventoryPasswords(cfg); err != nil { + t.Fatalf("the remedy the error names failed: %v", err) + } + if err := validateInventoryCredentials(cfg); err != nil { + t.Fatalf("following the error's own instruction did not clear it: %v", err) + } +} + +// AWS ships an inventory whose passwords match no config and are never sent — +// it authenticates over SSM. Comparing there would block a working provider. +func TestCredentialGateIgnoresNonAzureProviders(t *testing.T) { + for _, prov := range []string{"aws", "ludus", "proxmox", ""} { + cfg := pwFixture(t, + "[default]\ndc01 ansible_host=i-0abc ansible_password=template-value\n", + map[string]string{"dc01": "totally-different"}) + cfg.Provider = prov + if err := validateInventoryCredentials(cfg); err != nil { + t.Errorf("provider %q was blocked by the Azure credential check: %v", prov, err) + } + } +} diff --git a/cli/cmd/lab.go b/cli/cmd/lab.go index 36e85fa7..d819b8cd 100644 --- a/cli/cmd/lab.go +++ b/cli/cmd/lab.go @@ -2,9 +2,12 @@ package cmd import ( "context" + "encoding/json" "fmt" "strings" + "time" + "github.com/dreadnode/dreadgoad/internal/azure" "github.com/dreadnode/dreadgoad/internal/config" "github.com/dreadnode/dreadgoad/internal/provider" "github.com/spf13/cobra" @@ -21,6 +24,10 @@ var labStatusCmd = &cobra.Command{ RunE: runLabStatus, } +// labStatusJSON toggles machine-readable JSON output for `lab status`. +// The web app's ingestion hook consumes this to refresh range state. +var labStatusJSON bool + var labStartCmd = &cobra.Command{ Use: "start", Short: "Start stopped lab instances", @@ -54,6 +61,22 @@ var labRestartVMCmd = &cobra.Command{ RunE: runVMAction("restart"), } +var labDescribeCmd = &cobra.Command{ + Use: "describe ", + Short: "Show the disks and network interfaces attached to a lab VM", + Long: `Describe one VM's attached resources: managed disks and network +interfaces, with their sizes, SKUs, subnets and security groups. + +Azure only. Read-only — nothing is modified. + +--id takes the VM's full ARM resource ID and skips hostname resolution, which +otherwise lists every VM in the subscription to substring-match the name. A +caller that already holds the ID (the console, which stores it on each range +node) should pass it.`, + Args: cobra.MaximumNArgs(1), + RunE: runLabDescribe, +} + var labDestroyVMCmd = &cobra.Command{ Use: "destroy-vm ", Short: "Terminate a specific lab VM by hostname", @@ -64,12 +87,21 @@ var labDestroyVMCmd = &cobra.Command{ func init() { rootCmd.AddCommand(labCmd) labCmd.AddCommand(labStatusCmd) + labStatusCmd.Flags().BoolVar(&labStatusJSON, "json", false, "Output machine-readable JSON (per-instance array)") labCmd.AddCommand(labStartCmd) labCmd.AddCommand(labStopCmd) labCmd.AddCommand(labStartVMCmd) labCmd.AddCommand(labStopVMCmd) labCmd.AddCommand(labRestartVMCmd) + labCmd.AddCommand(labDescribeCmd) + labDescribeCmd.Flags().String("id", "", "Full ARM resource ID of the VM (skips hostname lookup)") + labDescribeCmd.Flags().Bool("json", false, "Output machine-readable JSON") labCmd.AddCommand(labDestroyVMCmd) + // Lets a caller with nobody at a keyboard run destroy-vm at all. Without it + // the confirmation below reads stdin, which a non-interactive caller cannot + // answer: fmt.Scanln fails, the function prints "Aborted." and returns nil, + // and the caller is told the VM was destroyed when it was untouched. + labDestroyVMCmd.Flags().Bool("yes", false, "Skip the confirmation prompt (for non-interactive callers)") } func getProvider(ctx context.Context) (provider.Provider, *config.Config, error) { @@ -96,6 +128,15 @@ func runLabStatus(cmd *cobra.Command, args []string) error { return err } + if labStatusJSON { + b, err := instancesToStatusJSON(instances) + if err != nil { + return fmt.Errorf("marshal status json: %w", err) + } + fmt.Println(string(b)) + return nil + } + if len(instances) == 0 { fmt.Printf("No GOAD instances found for env=%s\n", cfg.Env) return nil @@ -112,6 +153,40 @@ func runLabStatus(cmd *cobra.Command, args []string) error { return nil } +// statusJSONInstance is the machine-readable shape emitted by `lab status --json`: +// RAW cloud fields, intentionally NOT the normalized host schema. The web app's +// ingestion hook correlates `name` → config hostname and normalizes +// state→status / id→cloud_id / private_ip→ip_private onto range hosts (design §6.4). +type statusJSONInstance struct { + Name string `json:"name"` + ID string `json:"id"` + State string `json:"state"` + PrivateIP string `json:"private_ip"` + // Where the instance lives. Account is the AWS account ID or Azure + // subscription ID; Group is the Azure resource group (AWS has no + // equivalent). Both are omitted when the provider can't determine them, so + // consumers can distinguish "unknown" from "empty". + Account string `json:"account,omitempty"` + Group string `json:"group,omitempty"` +} + +// instancesToStatusJSON renders discovered instances as a JSON array. +// Always returns a JSON array (never null) so an empty range yields "[]". +func instancesToStatusJSON(instances []provider.Instance) ([]byte, error) { + out := make([]statusJSONInstance, 0, len(instances)) + for _, inst := range instances { + out = append(out, statusJSONInstance{ + Name: inst.Name, + ID: inst.ID, + State: inst.State, + PrivateIP: inst.PrivateIP, + Account: inst.Account, + Group: inst.Group, + }) + } + return json.MarshalIndent(out, "", " ") +} + func runLabAction(action string) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { ctx := context.Background() @@ -164,47 +239,80 @@ func runLabAction(action string) func(*cobra.Command, []string) error { } } -func execVMAction(ctx context.Context, prov provider.Provider, inst *provider.Instance, action string) error { +// vmActionTimeout bounds a single-VM lifecycle action end to end. +const vmActionTimeout = 15 * time.Minute + +// Describing a VM is two reads, not a power operation. Borrowing +// vmActionTimeout would leave the console's detail panel spinning for a quarter +// of an hour on a stalled call; this fails while an operator is still watching. +const describeTimeout = 90 * time.Second + +func execVMAction(ctx context.Context, prov provider.Provider, inst *provider.Instance, action string, yes bool) error { ids := []string{inst.ID} switch action { case "start": + fmt.Printf("Starting %s...\n", inst.Name) if err := prov.StartInstances(ctx, ids); err != nil { return fmt.Errorf("start VM: %w", err) } - fmt.Printf("Start initiated for %s\n", inst.Name) + fmt.Printf("%s is running\n", inst.Name) case "stop": + fmt.Printf("Stopping %s (deallocating; this takes a few minutes)...\n", inst.Name) if err := prov.StopInstances(ctx, ids); err != nil { return fmt.Errorf("stop VM: %w", err) } - fmt.Printf("Stop initiated for %s\n", inst.Name) + fmt.Printf("%s is stopped\n", inst.Name) case "restart": + // StopInstances/StartInstances block until the Azure operation + // completes — they are not "initiate and return". Announce before the + // call, not after: printed afterwards these lines describe a state the + // operator never sees the command in, and a restart shows nothing at + // all for the minutes it actually takes. if inst.State == "running" { + fmt.Printf("Stopping %s (deallocating; this takes a few minutes)...\n", inst.Name) if err := prov.StopInstances(ctx, ids); err != nil { return fmt.Errorf("stop VM: %w", err) } - fmt.Printf("Stop initiated for %s, waiting for stopped state...\n", inst.Name) + // The wait is required and stays. Whether StopInstances blocks is + // per-provider: Azure polls the deallocate to completion, but AWS + // (internal/aws/ec2.go) just calls the EC2 API and returns, so + // without this the Start below would be issued against an instance + // still in "stopping" and rejected. Redundant on Azure, and cheap + // there now that the poll checks before it sleeps. if err := prov.WaitForInstanceStopped(ctx, inst.ID); err != nil { return fmt.Errorf("wait for stop: %w", err) } - fmt.Printf("%s is now stopped\n", inst.Name) + fmt.Printf("%s is stopped\n", inst.Name) } + fmt.Printf("Starting %s...\n", inst.Name) if err := prov.StartInstances(ctx, ids); err != nil { return fmt.Errorf("start VM: %w", err) } - fmt.Printf("Start initiated for %s\n", inst.Name) + fmt.Printf("%s is running\n", inst.Name) case "destroy": - return destroyVM(ctx, prov, inst) + return destroyVM(ctx, prov, inst, yes) } return nil } -func destroyVM(ctx context.Context, prov provider.Provider, inst *provider.Instance) error { - fmt.Printf("WARNING: This will terminate %s (%s) permanently.\n", inst.Name, inst.ID) - fmt.Print("Type the instance ID to confirm: ") - var confirm string - if _, err := fmt.Scanln(&confirm); err != nil || confirm != inst.ID { - fmt.Println("Aborted.") - return nil +// destroyVM terminates one instance, confirming first unless the caller has +// already done so. +// +// The prompt reads stdin, so it can only be answered by a human at a terminal. +// A non-interactive caller — the console, CI, any script — gets an error from +// Scanln, falls into the abort branch, and is told "Aborted." with a nil error: +// exit 0 for a VM that still exists. --yes exists so those callers can run the +// command at all, and it is deliberately a flag rather than a stdin check so an +// operator at a terminal keeps the type-the-ID gate. +func destroyVM(ctx context.Context, prov provider.Provider, inst *provider.Instance, yes bool) error { + if !yes { + fmt.Printf("WARNING: This will terminate %s (%s) permanently.\n", inst.Name, inst.ID) + fmt.Print("Type the instance ID to confirm: ") + var confirm string + if _, err := fmt.Scanln(&confirm); err != nil || confirm != inst.ID { + fmt.Println("Aborted.") + return nil + } } if err := prov.DestroyInstances(ctx, []string{inst.ID}); err != nil { return fmt.Errorf("terminate VM: %w", err) @@ -216,7 +324,14 @@ func destroyVM(ctx context.Context, prov provider.Provider, inst *provider.Insta func runVMAction(action string) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { hostname := args[0] - ctx := context.Background() + // Bounded, because none of the calls below are. StopInstances and + // StartInstances poll an Azure long-running operation to completion, + // and with context.Background() a stalled operation hangs the command + // forever with no output — indistinguishable from one that is simply + // slow. 15 minutes is well above a real deallocate+start and well + // below the point where an operator has lost the afternoon. + ctx, cancel := context.WithTimeout(context.Background(), vmActionTimeout) + defer cancel() prov, cfg, err := getProvider(ctx) if err != nil { @@ -229,6 +344,91 @@ func runVMAction(action string) func(*cobra.Command, []string) error { } fmt.Printf("Found: %s (%s) [%s]\n", inst.Name, inst.ID, inst.State) - return execVMAction(ctx, prov, inst, action) + // Only destroy-vm registers --yes; GetBool returns false for the others, + // which is the value they want anyway. + yes, _ := cmd.Flags().GetBool("yes") + return execVMAction(ctx, prov, inst, action, yes) + } +} + +// runLabDescribe reports one VM's attached disks and NICs. +// +// Azure-specific, so it type-asserts the provider rather than widening the +// cross-provider interface — the same trade bastion.go makes for the same +// reason. Other providers get a plain refusal instead of an empty result that +// looks like a VM with no disks. +func runLabDescribe(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(context.Background(), describeTimeout) + defer cancel() + + prov, cfg, err := getProvider(ctx) + if err != nil { + return err + } + client, err := azureClientFromProvider(prov) + if err != nil { + return err + } + + id, _ := cmd.Flags().GetString("id") + if id == "" { + if len(args) == 0 { + return fmt.Errorf("give a hostname, or --id with the VM's resource ID") + } + inst, err := client.FindInstanceByHostname(ctx, cfg.Env, args[0]) + if err != nil { + return err + } + id = inst.ID + } + + detail, err := client.DescribeInstance(ctx, id) + if err != nil { + return err + } + + if asJSON, _ := cmd.Flags().GetBool("json"); asJSON { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(detail) + } + printInstanceDetail(cmd, detail) + return nil +} + +func printInstanceDetail(cmd *cobra.Command, d *azure.InstanceDetail) { + out := cmd.OutOrStdout() + _, _ = fmt.Fprintf(out, "%s (%s", d.Name, d.ResourceGroup) + if d.Location != "" { + _, _ = fmt.Fprintf(out, ", %s", d.Location) + } + if d.VMSize != "" { + _, _ = fmt.Fprintf(out, ", %s", d.VMSize) + } + // The JSON carries power state and the console panel shows it; a terminal + // reader asking about a VM's disks wants to know it is running just as much. + if d.PowerState != "" { + _, _ = fmt.Fprintf(out, ", %s", d.PowerState) + } + _, _ = fmt.Fprintln(out, ")") + + _, _ = fmt.Fprintf(out, "\nDisks (%d)\n", len(d.Disks)) + for _, disk := range d.Disks { + size := "" + if disk.SizeGB != nil { + size = fmt.Sprintf("%d GB", *disk.SizeGB) + } + _, _ = fmt.Fprintf(out, " %-4s %-38s %-9s %s\n", disk.Role, disk.Name, size, disk.StorageType) + } + + _, _ = fmt.Fprintf(out, "\nNetwork interfaces (%d)\n", len(d.NICs)) + for _, nic := range d.NICs { + _, _ = fmt.Fprintf(out, " %-38s %s\n", nic.Name, strings.Join(nic.PrivateIPs, ", ")) + if nic.SubnetID != "" { + _, _ = fmt.Fprintf(out, " subnet %s\n", nic.SubnetID) + } + if nic.NSGID != "" { + _, _ = fmt.Fprintf(out, " nsg %s\n", nic.NSGID) + } } } diff --git a/cli/cmd/lab_status_account_test.go b/cli/cmd/lab_status_account_test.go new file mode 100644 index 00000000..c75fa0d3 --- /dev/null +++ b/cli/cmd/lab_status_account_test.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/dreadnode/dreadgoad/internal/provider" +) + +// Account/Group are what let the web app show which cloud account and resource +// group a range lives in. They come from data discovery already fetches (the +// EC2 Reservation's OwnerId, the Azure resource ID), so the contract that +// matters is: carried through verbatim when known, and *absent* — not empty +// string — when the provider can't determine them. +func TestStatusJSONAccountAndGroup(t *testing.T) { + t.Run("azure carries both", func(t *testing.T) { + b, err := instancesToStatusJSON([]provider.Instance{{ + ID: "/subscriptions/70a9c8a4/resourceGroups/RG1/providers/x", + Name: "vm1", + State: "running", + Account: "70a9c8a4", + Group: "RG1", + }}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got []statusJSONInstance + if err := json.Unmarshal(b, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got[0].Account != "70a9c8a4" { + t.Errorf("account = %q, want 70a9c8a4", got[0].Account) + } + if got[0].Group != "RG1" { + t.Errorf("group = %q, want RG1", got[0].Group) + } + }) + + t.Run("aws carries account but no group", func(t *testing.T) { + b, err := instancesToStatusJSON([]provider.Instance{{ + ID: "i-0abc", Name: "vm1", State: "running", Account: "123456789012", + }}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + raw := string(b) + if !strings.Contains(raw, `"account": "123456789012"`) { + t.Errorf("account missing from JSON: %s", raw) + } + // omitempty: AWS has no resource-group concept, so the key must be + // absent rather than present-and-empty. A consumer can then tell + // "not applicable" from "known to be blank". + if strings.Contains(raw, `"group"`) { + t.Errorf("group should be omitted when empty: %s", raw) + } + }) + + t.Run("unknown account omits the key entirely", func(t *testing.T) { + b, err := instancesToStatusJSON([]provider.Instance{{ + ID: "vmid-1", Name: "vm1", State: "running", + }}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + raw := string(b) + if strings.Contains(raw, `"account"`) || strings.Contains(raw, `"group"`) { + t.Errorf("unknown fields must be omitted, got: %s", raw) + } + // The pre-existing fields must still be present and unchanged, so an + // older consumer is unaffected by the addition. + for _, want := range []string{`"name"`, `"id"`, `"state"`, `"private_ip"`} { + if !strings.Contains(raw, want) { + t.Errorf("existing field %s dropped: %s", want, raw) + } + } + }) +} diff --git a/cli/cmd/lab_status_json_test.go b/cli/cmd/lab_status_json_test.go new file mode 100644 index 00000000..c8a710f3 --- /dev/null +++ b/cli/cmd/lab_status_json_test.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "encoding/json" + "testing" + + "github.com/dreadnode/dreadgoad/internal/provider" +) + +func TestInstancesToStatusJSON(t *testing.T) { + tests := []struct { + name string + instances []provider.Instance + wantLen int + }{ + { + name: "empty yields JSON array not null", + instances: nil, + wantLen: 0, + }, + { + name: "running and stopped instances", + instances: []provider.Instance{ + {ID: "i-0abc", Name: "goad-dreadgoad-kingslanding", State: "running", PrivateIP: "10.0.4.124"}, + {ID: "i-0def", Name: "goad-dreadgoad-winterfell", State: "stopped", PrivateIP: "10.0.4.76"}, + }, + wantLen: 2, + }, + } + + tests = append(tests, struct { + name string + instances []provider.Instance + wantLen int + }{ + name: "account and group carried through", + instances: []provider.Instance{ + {ID: "i-0abc", Name: "aws-box", State: "running", Account: "123456789012"}, + }, + wantLen: 1, + }) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, err := instancesToStatusJSON(tt.instances) + if err != nil { + t.Fatalf("instancesToStatusJSON returned error: %v", err) + } + + // Must always be a JSON array (never the literal "null"), so an + // empty range decodes to "[]" for the ingestion hook. + var decoded []statusJSONInstance + if err := json.Unmarshal(b, &decoded); err != nil { + t.Fatalf("output is not valid JSON array: %v (raw: %s)", err, b) + } + if string(b) == "null" { + t.Fatalf("empty input must render as [] not null") + } + if len(decoded) != tt.wantLen { + t.Fatalf("want %d instances, got %d", tt.wantLen, len(decoded)) + } + }) + } +} + +func TestInstancesToStatusJSONEmptyIsBareArray(t *testing.T) { + b, err := instancesToStatusJSON(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(b) != "[]" { + t.Fatalf("empty range must render exactly as [], got %q", b) + } +} + +func TestInstancesToStatusJSONStoppedNoIP(t *testing.T) { + // A stopped instance has no private IP; it must still round-trip cleanly. + in := []provider.Instance{ + {ID: "i-0def", Name: "goad-dreadgoad-winterfell", State: "stopped", PrivateIP: ""}, + } + b, err := instancesToStatusJSON(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var decoded []statusJSONInstance + if err := json.Unmarshal(b, &decoded); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if decoded[0].State != "stopped" || decoded[0].PrivateIP != "" { + t.Fatalf("stopped/empty-IP passthrough wrong: %+v", decoded[0]) + } +} + +func TestInstancesToStatusJSONFieldMapping(t *testing.T) { + in := []provider.Instance{ + {ID: "i-0abc", Name: "goad-dreadgoad-kingslanding", State: "running", PrivateIP: "10.0.4.124"}, + } + b, err := instancesToStatusJSON(in) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var decoded []statusJSONInstance + if err := json.Unmarshal(b, &decoded); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + got := decoded[0] + if got.ID != "i-0abc" || got.Name != "goad-dreadgoad-kingslanding" || + got.State != "running" || got.PrivateIP != "10.0.4.124" { + t.Fatalf("field mapping wrong: %+v", got) + } +} diff --git a/cli/cmd/lab_vmaction_test.go b/cli/cmd/lab_vmaction_test.go new file mode 100644 index 00000000..70180da0 --- /dev/null +++ b/cli/cmd/lab_vmaction_test.go @@ -0,0 +1,279 @@ +package cmd + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/dreadnode/dreadgoad/internal/provider" +) + +// recordingProvider implements only the lifecycle methods execVMAction uses. +// The embedded interface is nil, so any *other* call panics loudly rather than +// silently returning a zero value — if execVMAction grows a dependency, the +// test fails instead of quietly not covering it. +type recordingProvider struct { + provider.Provider + + calls []string + // Output written before each call. The fix is that the operator is told + // what is happening *before* the multi-minute wait, so what matters is not + // that a line is printed but that it is printed first. + seenAt map[string]string + // Path of the file standing in for stdout. Read synchronously at each call + // — an os.Pipe with a reader goroutine races here, because fmt.Printf can + // return before the reader has copied anything, making a correctly-ordered + // print look absent. + outPath string +} + +func (p *recordingProvider) record(name string) { + p.calls = append(p.calls, name) + if p.seenAt == nil { + p.seenAt = map[string]string{} + } + b, _ := os.ReadFile(p.outPath) + p.seenAt[name] = string(b) +} + +func (p *recordingProvider) StartInstances(_ context.Context, _ []string) error { + p.record("start") + return nil +} + +func (p *recordingProvider) StopInstances(_ context.Context, _ []string) error { + p.record("stop") + return nil +} + +func (p *recordingProvider) WaitForInstanceStopped(_ context.Context, _ string) error { + p.record("wait") + return nil +} + +func (p *recordingProvider) DestroyInstances(_ context.Context, _ []string) error { + p.record("destroy") + return nil +} + +// captureStdout points os.Stdout at a real file for the duration of fn and +// returns everything written. A file rather than a pipe so that what has been +// printed is observable mid-run, synchronously, from inside a provider call. +func captureStdout(t *testing.T, fn func(outPath string) error) (string, error) { + t.Helper() + path := filepath.Join(t.TempDir(), "stdout") + f, err := os.Create(path) + if err != nil { + t.Fatalf("create: %v", err) + } + orig := os.Stdout + os.Stdout = f + runErr := fn(path) + os.Stdout = orig + if err := f.Close(); err != nil { + t.Fatalf("close: %v", err) + } + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + return string(b), runErr +} + +func TestRestartAnnouncesBeforeItWaits(t *testing.T) { + // A restart blocks for minutes inside StopInstances/StartInstances, which + // poll the Azure operation to completion. The prints used to come after, + // so the command showed nothing at all for the whole wait. + prov := &recordingProvider{} + inst := &provider.Instance{Name: "DC01", ID: "/subscriptions/x/DC01", State: "running"} + + _, err := captureStdout(t, func(outPath string) error { + prov.outPath = outPath + return execVMAction(context.Background(), prov, inst, "restart", false) + }) + if err != nil { + t.Fatalf("execVMAction: %v", err) + } + + if got := prov.seenAt["stop"]; !strings.Contains(got, "Stopping DC01") { + t.Errorf("stop began with no announcement; stdout was %q", got) + } + if got := prov.seenAt["start"]; !strings.Contains(got, "Starting DC01") { + t.Errorf("start began with no announcement; stdout was %q", got) + } + + // The wait must stay, and must sit between the two. Whether StopInstances + // blocks is per-provider — Azure polls the deallocate to completion, AWS + // returns as soon as the EC2 call is accepted (internal/aws/ec2.go). Drop + // it and the start is issued against an instance still stopping, which AWS + // rejects. This is provider-agnostic code, so it must hold for the weakest + // guarantee, not Azure's. + want := []string{"stop", "wait", "start"} + if len(prov.calls) != len(want) { + t.Fatalf("call order = %v, want %v", prov.calls, want) + } + for i := range want { + if prov.calls[i] != want[i] { + t.Errorf("call order = %v, want %v", prov.calls, want) + break + } + } +} + +func TestRestartOfStoppedVMSkipsTheStop(t *testing.T) { + // Nothing to deallocate: a stopped VM should go straight to starting. + prov := &recordingProvider{} + inst := &provider.Instance{Name: "DC01", ID: "id", State: "stopped"} + + out, err := captureStdout(t, func(outPath string) error { + prov.outPath = outPath + return execVMAction(context.Background(), prov, inst, "restart", false) + }) + if err != nil { + t.Fatalf("execVMAction: %v", err) + } + + if len(prov.calls) != 1 || prov.calls[0] != "start" { + t.Errorf("calls = %v, want [start]", prov.calls) + } + if strings.Contains(out, "Stopping") { + t.Errorf("announced a stop for an already-stopped VM: %q", out) + } +} + +func TestStartAndStopAnnounceBeforeTheyWait(t *testing.T) { + for _, tc := range []struct { + action string + call string + before string + }{ + {"start", "start", "Starting DC01"}, + {"stop", "stop", "Stopping DC01"}, + } { + prov := &recordingProvider{} + inst := &provider.Instance{Name: "DC01", ID: "id", State: "running"} + + if _, err := captureStdout(t, func(outPath string) error { + prov.outPath = outPath + return execVMAction(context.Background(), prov, inst, tc.action, false) + }); err != nil { + t.Fatalf("%s: %v", tc.action, err) + } + if got := prov.seenAt[tc.call]; !strings.Contains(got, tc.before) { + t.Errorf("%s began with no announcement; stdout was %q", tc.action, got) + } + } +} + +func TestVMActionTimeoutIsBounded(t *testing.T) { + // The regression this guards: ctx was context.Background(), so a stalled + // Azure long-running operation hung the command with no deadline and no + // output — indistinguishable from one that is merely slow. + if vmActionTimeout <= 0 { + t.Fatal("vmActionTimeout must be positive") + } + if vmActionTimeout.Minutes() < 5 { + t.Errorf("vmActionTimeout %v is below a real deallocate+start", vmActionTimeout) + } + if vmActionTimeout.Minutes() > 60 { + t.Errorf("vmActionTimeout %v is long enough to lose an afternoon", vmActionTimeout) + } +} + +// TestDestroyVMWithoutYesAbortsWhenNobodyCanAnswer covers the failure that made +// this flag necessary: the confirmation reads stdin, so a caller with no +// terminal cannot answer it. Scanln fails, the abort branch prints "Aborted." +// and returns nil — exit 0 for an instance that still exists. Any caller that +// trusts the exit code is told the VM was destroyed. +func TestDestroyVMWithoutYesAbortsWhenNobodyCanAnswer(t *testing.T) { + prov := &recordingProvider{} + inst := &provider.Instance{Name: "DC01", ID: "/subscriptions/x/DC01", State: "running"} + + // stdin at EOF is what a piped, non-interactive caller looks like. + origStdin := os.Stdin + empty, err := os.Open(os.DevNull) + if err != nil { + t.Fatalf("open devnull: %v", err) + } + os.Stdin = empty + defer func() { os.Stdin = origStdin; _ = empty.Close() }() + + out, runErr := captureStdout(t, func(outPath string) error { + prov.outPath = outPath + return execVMAction(context.Background(), prov, inst, "destroy", false) + }) + if runErr != nil { + t.Fatalf("unexpected error: %v", runErr) + } + if len(prov.calls) != 0 { + t.Errorf("destroyed the VM without a confirmation: calls = %v", prov.calls) + } + if !strings.Contains(out, "Aborted.") { + t.Errorf("expected an abort notice, got %q", out) + } +} + +// ...and with --yes it proceeds, which is the entire reason the flag exists. +func TestDestroyVMWithYesSkipsThePrompt(t *testing.T) { + prov := &recordingProvider{} + inst := &provider.Instance{Name: "DC01", ID: "/subscriptions/x/DC01", State: "running"} + + origStdin := os.Stdin + empty, err := os.Open(os.DevNull) + if err != nil { + t.Fatalf("open devnull: %v", err) + } + os.Stdin = empty + defer func() { os.Stdin = origStdin; _ = empty.Close() }() + + out, runErr := captureStdout(t, func(outPath string) error { + prov.outPath = outPath + return execVMAction(context.Background(), prov, inst, "destroy", true) + }) + if runErr != nil { + t.Fatalf("destroy with --yes: %v", runErr) + } + if len(prov.calls) != 1 || prov.calls[0] != "destroy" { + t.Fatalf("calls = %v, want [destroy]", prov.calls) + } + if strings.Contains(out, "Type the instance ID") { + t.Errorf("--yes still prompted: %q", out) + } +} + +// The GetBool-on-an-unregistered-flag pattern silently returns (false, err). +// That exact shape caused a real bug elsewhere in this CLI, so pin the value +// each *-vm command actually sees: only destroy-vm registers --yes, and the +// other three must resolve false rather than anything surprising. +func TestYesResolvesFalseForCommandsThatDoNotRegisterIt(t *testing.T) { + for _, tc := range []struct { + name string + reg bool + }{ + {"start-vm", false}, {"stop-vm", false}, + {"restart-vm", false}, {"destroy-vm", true}, + } { + var cmd = labStartVMCmd + switch tc.name { + case "stop-vm": + cmd = labStopVMCmd + case "restart-vm": + cmd = labRestartVMCmd + case "destroy-vm": + cmd = labDestroyVMCmd + } + got, err := cmd.Flags().GetBool("yes") + if tc.reg { + if err != nil { + t.Errorf("%s: destroy-vm must register --yes, got err %v", tc.name, err) + } + } else if err == nil { + t.Errorf("%s: unexpectedly registers --yes", tc.name) + } + if got { + t.Errorf("%s: --yes defaulted true; destroy would skip its prompt", tc.name) + } + } +} diff --git a/cli/cmd/provision.go b/cli/cmd/provision.go index aadcef02..946f420c 100644 --- a/cli/cmd/provision.go +++ b/cli/cmd/provision.go @@ -2,11 +2,14 @@ package cmd import ( "context" + "encoding/json" "errors" "fmt" "log/slog" "os" "path/filepath" + "regexp" + "sort" "strings" "time" @@ -152,8 +155,10 @@ func isSSMInventory(cfg *config.Config) bool { } // preflightChecks validates tooling, builds the Ansible collection, and -// prepares artifacts needed before provisioning playbooks run. -func preflightChecks(ctx context.Context, cfg *config.Config) error { +// prepares artifacts needed before provisioning playbooks run. limit is the +// Ansible host pattern the run is restricted to, or "" for the whole inventory; +// it only affects how strictly the inventory is validated. +func preflightChecks(ctx context.Context, cfg *config.Config, limit string) error { if err := doctor.CheckAnsibleCoreVersion(cfg.ResolvedProvider()); err != nil { return fmt.Errorf("ansible-core version check failed: %w", err) } @@ -186,9 +191,288 @@ func preflightChecks(ctx context.Context, cfg *config.Config) error { slog.Warn("instance mapping generation failed, playbooks will use runtime detection", "error", err) } } + + // Azure: `env create` writes the inventory with PENDING addresses and no + // other step fills them in, so resolve them from live NIC state here. + // Failing here beats failing inside network_setup.yml once the Bastion + // tunnel and playbook run are already underway. + if cfg.ResolvedProvider() == provider.NameAzure { + if err := inventorySyncFailure(syncAzureInventoryIPs(ctx, cfg), limit); err != nil { + return err + } + if err := inventorySyncFailure(syncAzureInventoryPasswords(cfg), limit); err != nil { + return err + } + } + + // Last gates before any playbook runs, for every provider. + if err := validateInventoryResolved(cfg, limit); err != nil { + return err + } + return validateInventoryCredentials(cfg) +} + +// materializedLabConfigPath is the lab config Terraform actually read when it +// built the machines: infra_cmd.go's materializeLabConfig copies the resolved +// config here, and every Azure goad unit hardcodes this path to source +// admin_password. It is deliberately NOT cfg.ResolvedLabConfigPath() — that is +// what the *playbooks* will read, and the two can disagree, which is precisely +// the failure this check exists to catch. +func materializedLabConfigPath(cfg *config.Config) string { + return filepath.Join(cfg.ProjectRoot, "ad", "GOAD", "data", cfg.Env+"-config.json") +} + +// validateInventoryCredentials checks that the password Ansible will present +// is the one the machines were actually built with. +// +// The Azure bootstrap creates the login Ansible uses with +// `net user ansible '${admin_password}'`, where admin_password comes from +// lab.hosts[].local_admin_password in the materialized lab config. If the +// inventory carries a different value — because it was scaffolded from a +// provider template whose stock passwords were never reconciled with the +// generated config — every host fails WinRM auth. That surfaces as a wall of +// authentication errors with no hint that the inventory is the cause. +// +// Only a total mismatch is fatal. That is the unambiguous scaffolding bug, and +// it is what a broken environment looks like: a healthy one matches on every +// host. A partial mismatch is reported but allowed through, since a single host +// can legitimately drift after provisioning has already run. +func validateInventoryCredentials(cfg *config.Config) error { + // Azure only. The link between lab.hosts[*].local_admin_password and the + // account Ansible logs in as is Azure's bootstrap script; no other provider + // makes that promise. AWS's stock inventory carries passwords that match no + // config at all — it authenticates over SSM and never sends them — so + // comparing there would block a working provider on a value nothing uses. + if cfg.ResolvedProvider() != provider.NameAzure { + return nil + } + want, err := materializedHostPasswords(cfg) + if err != nil || len(want) == 0 { + // No materialized config (infra never ran here) means there is nothing + // to compare against. Absence is not a mismatch. + slog.Debug("skipping credential check; no materialized lab config", "error", err) + return nil + } + configPath := materializedLabConfigPath(cfg) + + parsed, err := inv.Parse(cfg.InventoryPath()) + if err != nil { + return nil // validateInventoryResolved already reported on this file + } + + var mismatched []string + compared := 0 + for name, host := range parsed.Hosts { + expected, ok := want[strings.ToLower(name)] + if !ok || host.Password == "" { + continue + } + compared++ + if host.Password != expected { + mismatched = append(mismatched, name) + } + } + if compared == 0 || len(mismatched) == 0 { + return nil + } + sort.Strings(mismatched) + + if len(mismatched) < compared { + slog.Warn("some hosts' inventory password differs from the one they were built with", + "hosts", strings.Join(mismatched, ","), "of", compared) + return nil + } + return fmt.Errorf( + "inventory %s has the wrong password for every host (%s)\n"+ + " The machines were built with lab.hosts[*].local_admin_password from %s,\n"+ + " but the inventory carries values scaffolded from a provider template.\n"+ + " Ansible would fail WinRM authentication on all %d hosts.\n"+ + " Fix: dreadgoad --env %s inventory sync", + cfg.InventoryPath(), strings.Join(mismatched, ", "), configPath, compared, cfg.Env) +} + +// inventorySyncFailure decides whether a failed inventory sync stops the run. +// +// Under --limit it must not. The sync fails when some host cannot be resolved, +// but a limited run may never target that host, and validateInventoryResolved +// applies the same policy a few lines later — so letting the sync hard-fail +// here would silently override the limit and block a legitimate partial run. +func inventorySyncFailure(err error, limit string) error { + if err == nil { + return nil + } + if limit != "" { + slog.Warn("inventory sync did not resolve every host; continuing because the run is limited", + "limit", limit, "error", err) + return nil + } + return fmt.Errorf("inventory sync: %w", err) +} + +// validateInventoryResolved refuses to hand Ansible an inventory that still +// carries scaffolding placeholders. +// +// Ansible does not validate ansible_host. Given "PENDING" it tries to resolve a +// host by that literal name and reports every play "unreachable" — which reads +// as a network, firewall, or credential fault and costs an apply cycle to trace +// back to the inventory. +// +// This runs for all providers rather than just the one that scaffolds PENDING, +// because each arrives here unresolved by a different route: Azure had no +// resolver at all, the AWS sync is warn-only at its call site above, and a +// Ludus or Proxmox inventory that already exists on disk is never re-rendered, +// so an unrendered {{ip_range}} survives bootstrap untouched. +// +// Under --limit an unresolved host may simply be out of scope, so this warns +// rather than fails: blocking a deliberate partial run would be worse than the +// unreachable error the operator gets anyway if the host is in scope. +func validateInventoryResolved(cfg *config.Config, limit string) error { + data, err := os.ReadFile(cfg.InventoryPath()) + if err != nil { + return fmt.Errorf("read inventory: %w", err) + } + stale := placeholderHosts(string(data)) + if len(stale) == 0 { + return nil + } + if limit != "" { + slog.Warn("inventory has unresolved hosts; they will fail if the limit selects them", + "hosts", strings.Join(stale, ","), "limit", limit) + return nil + } + return fmt.Errorf( + "inventory %s has no address for %s\n"+ + " Ansible would treat the placeholder as a hostname and report these unreachable.\n"+ + " Run `dreadgoad --env %s infra apply` if the machines are not up yet,\n"+ + " then `dreadgoad --env %s inventory sync` to resolve their addresses", + cfg.InventoryPath(), strings.Join(stale, ", "), cfg.Env, cfg.Env) +} + +// materializedHostPasswords returns the local admin password each machine was +// built with, keyed by lowercased host id, read from the lab config Terraform +// actually consumed. +func materializedHostPasswords(cfg *config.Config) (map[string]string, error) { + raw, err := os.ReadFile(materializedLabConfigPath(cfg)) + if err != nil { + return nil, err + } + var lab struct { + Lab struct { + Hosts map[string]struct { + LocalAdminPassword string `json:"local_admin_password"` + } `json:"hosts"` + } `json:"lab"` + } + if err := json.Unmarshal(raw, &lab); err != nil { + return nil, fmt.Errorf("parse lab config: %w", err) + } + out := make(map[string]string, len(lab.Lab.Hosts)) + for name, h := range lab.Lab.Hosts { + if h.LocalAdminPassword != "" { + out[strings.ToLower(name)] = h.LocalAdminPassword + } + } + return out, nil +} + +// quoteInventoryValue wraps a value so the inventory parser reads it back +// intact. Reports false when the value contains both quote characters, which +// inventory.stripQuotes cannot represent — better to leave that host alone than +// to write a line that parses back as something else. +func quoteInventoryValue(v string) (string, bool) { + if !strings.Contains(v, "'") { + return "'" + v + "'", true + } + if !strings.Contains(v, `"`) { + return `"` + v + `"`, true + } + return "", false +} + +// syncAzureInventoryPasswords rewrites each host's ansible_password to the one +// its machine was actually built with. +// +// Azure's bootstrap creates the account Ansible logs in as with +// `net user ansible '${admin_password}'`, sourced from +// lab.hosts[].local_admin_password in the materialized lab config. The +// inventory is scaffolded from a provider template carrying stock passwords +// that appear in no config — measured at 0 of 5 agreement for every variant in +// this repo, including ones generated correctly. Nothing else reconciles the +// two, so provisioning authenticates with a password no machine has. +// +// Done here rather than at scaffold time so it also repairs ranges that are +// already deployed, and so a regenerated lab config cannot leave the inventory +// behind. +func syncAzureInventoryPasswords(cfg *config.Config) error { + want, err := materializedHostPasswords(cfg) + if err != nil || len(want) == 0 { + slog.Debug("no materialized lab config; leaving inventory passwords alone", "error", err) + return nil + } + + invPath := cfg.InventoryPath() + data, err := os.ReadFile(invPath) + if err != nil { + return fmt.Errorf("read inventory: %w", err) + } + content := string(data) + + updated := 0 + for host, password := range want { + quoted, ok := quoteInventoryValue(password) + if !ok { + slog.Warn("cannot represent this host's password in the inventory; leaving it unchanged", "host", host) + continue + } + re := regexp.MustCompile( + `(?mi)^(` + regexp.QuoteMeta(host) + `\s+[^\n]*?ansible_password=)('[^']*'|"[^"]*"|\S+)`) + // ReplaceAllStringFunc, not ReplaceAllString: a password may contain $, + // which the replacement template would read as a capture reference. + next := re.ReplaceAllStringFunc(content, func(m string) string { + i := strings.Index(m, "ansible_password=") + return m[:i+len("ansible_password=")] + quoted + }) + if next != content { + content = next + updated++ + } + } + + if updated == 0 { + return nil + } + // Mode applies only on create; an existing inventory keeps its own. + if err := os.WriteFile(invPath, []byte(content), 0o644); err != nil { + return fmt.Errorf("write inventory: %w", err) + } + fmt.Printf("Reconciled ansible_password for %d host(s) from %s\n", + updated, filepath.Base(materializedLabConfigPath(cfg))) return nil } +// syncAzureInventoryIPs points every inventory host at its live private IP. +// Azure allocates those from the subnet's pool at create time, so they are not +// knowable when the environment is scaffolded and cannot be baked into the +// provider inventory template the way Ludus and Proxmox ranges can. +func syncAzureInventoryIPs(ctx context.Context, cfg *config.Config) error { + prov, err := cfg.NewProvider(ctx) + if err != nil { + return fmt.Errorf("create provider: %w", err) + } + live, err := prov.DiscoverInstances(ctx, cfg.Env) + if err != nil { + return fmt.Errorf("discover instances: %w", err) + } + if len(live) == 0 { + return fmt.Errorf("no instances found for env=%s: run 'dreadgoad infra apply' first", cfg.Env) + } + instances := make([]instanceInfo, 0, len(live)) + for _, i := range live { + instances = append(instances, instanceInfo{InstanceID: i.ID, Name: i.Name, PrivateIP: i.PrivateIP}) + } + return applyInstanceUpdates(cfg.InventoryPath(), instances) +} + // bootstrapInventory creates the inventory file if it does not exist. // For AWS, it copies from the .example template. // For Proxmox and other providers, it renders the provider-specific @@ -492,7 +776,7 @@ func provisionPlaybooks(ctx context.Context, cfg *config.Config, playbooks []str logFile := filepath.Join(cfg.LogDir, fmt.Sprintf("%s-dreadgoad-%s.log", cfg.Env, time.Now().Format("20060102_150405"))) - if err := preflightChecks(ctx, cfg); err != nil { + if err := preflightChecks(ctx, cfg, limit); err != nil { return err } diff --git a/cli/cmd/score_fetch.go b/cli/cmd/score_fetch.go new file mode 100644 index 00000000..ee751a45 --- /dev/null +++ b/cli/cmd/score_fetch.go @@ -0,0 +1,96 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/dreadnode/dreadgoad/internal/config" + "github.com/dreadnode/dreadgoad/internal/scoreboard" + "github.com/spf13/cobra" +) + +// ssmOutputLimit is AWS SSM's GetCommandInvocation StandardOutputContent cap +// (24,000 chars). A `cat` at that length was almost certainly truncated. +const ssmOutputLimit = 24000 + +var scoreFetchCmd = &cobra.Command{ + Use: "fetch", + Short: "Copy an agent report off the attack box to a local path", + Long: `Reads a report file from the Kali attack box and writes it locally. + +Uses the same connection machinery as ` + "`score --live-verify`" + ` — SSM on AWS, +Azure Bastion on Azure — so the attack box and (on Azure) the SSH key are +auto-discovered. On AWS pass --attack-box (the Kali instance id). This exists so +tooling can score a report that lives on the box, which ` + "`score --report`" + ` (a +local path) otherwise can't reach.`, + Example: ` dreadgoad score fetch --remote /root/report.jsonl --local ./report.jsonl --attack-box i-0abc123 + dreadgoad -p azure score fetch --remote /root/report.jsonl --local ./report.jsonl`, + RunE: runScoreFetch, +} + +func init() { + scoreCmd.AddCommand(scoreFetchCmd) + scoreFetchCmd.Flags().String("remote", "", "Path to the report on the attack box (required)") + scoreFetchCmd.Flags().String("local", "", "Local destination path (default: stdout)") + // Same connection flags as `score --live-verify` (consumed by buildShellRunner). + scoreFetchCmd.Flags().String("attack-box", "", "Instance ID (AWS) or resource ID (Azure) of the Kali attack box") + scoreFetchCmd.Flags().String("region", "", "AWS region for SSM") + scoreFetchCmd.Flags().String("profile", "", "AWS named profile") + scoreFetchCmd.Flags().String("ssh-key", "", "Path to SSH private key for the Kali VM (Azure; auto-discovered if omitted)") + scoreFetchCmd.Flags().String("ssh-user", "kali", "SSH username for the Kali VM (Azure)") +} + +// shellSingleQuote wraps s in single quotes for safe interpolation into a remote +// shell command, escaping any embedded single quotes. +func shellSingleQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +func runScoreFetch(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + remote, _ := cmd.Flags().GetString("remote") + if remote == "" { + return fmt.Errorf("--remote is required") + } + + cfg, err := config.Get() + if err != nil { + return err + } + + runner, err := buildShellRunner(ctx, cmd, cfg) + if err != nil { + return err + } + + // `cat` the file over the existing connection — works for SSM and Bastion. + out, err := runner.RunShell(ctx, "cat -- "+shellSingleQuote(remote), 120*time.Second) + if err != nil { + return fmt.Errorf("read %s from attack box: %w", remote, err) + } + + // SSM truncates stdout at 24,000 chars — a report at that length is almost + // certainly cut off. Fail loudly rather than write a partial report that + // would then be mis-scored. (Bastion has no such cap.) + if _, isSSM := runner.(*scoreboard.SSMShellRunner); isSSM && len(out) >= ssmOutputLimit { + return fmt.Errorf( + "report looks truncated at SSM's %d-char stdout limit (%d bytes read); "+ + "the report is too large to fetch this way", + ssmOutputLimit, len(out), + ) + } + + local, _ := cmd.Flags().GetString("local") + if local == "" { + fmt.Print(out) + return nil + } + if err := os.WriteFile(local, []byte(out), 0o600); err != nil { + return fmt.Errorf("write %s: %w", local, err) + } + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "fetched %s -> %s (%d bytes)\n", remote, local, len(out)) + return nil +} diff --git a/cli/cmd/score_fetch_test.go b/cli/cmd/score_fetch_test.go new file mode 100644 index 00000000..324bd130 --- /dev/null +++ b/cli/cmd/score_fetch_test.go @@ -0,0 +1,19 @@ +package cmd + +import "testing" + +func TestShellSingleQuote(t *testing.T) { + cases := map[string]string{ + "/root/report.jsonl": `'/root/report.jsonl'`, + "/tmp/my report.jsonl": `'/tmp/my report.jsonl'`, + "": `''`, + "a'b": `'a'\''b'`, + "$(rm -rf /)": `'$(rm -rf /)'`, // metacharacters stay literal inside quotes + "; cat /etc/shadow": `'; cat /etc/shadow'`, + } + for in, want := range cases { + if got := shellSingleQuote(in); got != want { + t.Errorf("shellSingleQuote(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/cli/cmd/score_reset.go b/cli/cmd/score_reset.go index 1cbd6306..ff22da40 100644 --- a/cli/cmd/score_reset.go +++ b/cli/cmd/score_reset.go @@ -286,6 +286,16 @@ func buildKaliCleanupScript(apply bool) string { find: `find $HOME/.dreadnode/sessions -type f 2>/dev/null | wc -l`, clean: `rm -rf $HOME/.dreadnode/sessions/* 2>/dev/null; rm -f $HOME/.dreadnode/prompt-history.jsonl 2>/dev/null`, }, + { + label: "dreadnode reports", + find: `find $HOME/.dreadnode/reports -type f 2>/dev/null | wc -l`, + clean: `rm -rf $HOME/.dreadnode/reports/* 2>/dev/null`, + }, + { + label: "dreadnode tool-output", + find: `find $HOME/.dreadnode/tool-output -type f 2>/dev/null | wc -l`, + clean: `rm -rf $HOME/.dreadnode/tool-output/* 2>/dev/null`, + }, { label: "agent report", find: `test -f $HOME/report.jsonl && echo 1 || echo 0`, diff --git a/cli/cmd/security_check.go b/cli/cmd/security_check.go new file mode 100644 index 00000000..1ef88660 --- /dev/null +++ b/cli/cmd/security_check.go @@ -0,0 +1,143 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/signal" + "strings" + + "github.com/dreadnode/dreadgoad/internal/config" + "github.com/dreadnode/dreadgoad/internal/provider" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +var securityCheckCmd = &cobra.Command{ + Use: "security-check", + Short: "Audit network security posture of a deployed range", + Long: `Queries Azure Resource Manager APIs to verify: + - No lab VMs have public IPs attached + - Every NIC or subnet has an NSG associated + - NSGs carry a DenyAllInbound rule + - No inbound Allow rules use wildcard/Internet sources + - Inbound Allow sources are limited to VNet CIDR or AzureLoadBalancer + - Azure Bastion exists in the resource group + - Linux VMs use SSH key auth`, + Example: ` dreadgoad security-check + dreadgoad security-check --json`, + RunE: runSecurityCheck, +} + +var securityCheckJSON bool + +func init() { + rootCmd.AddCommand(securityCheckCmd) + securityCheckCmd.Flags().BoolVar(&securityCheckJSON, "json", false, + "Output machine-readable JSON (per-check results + counts)") +} + +func runSecurityCheck(cmd *cobra.Command, args []string) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + jsonOut := securityCheckJSON + + if !jsonOut { + title := " Security Check " + pad := 90 - len(title) + left := pad / 2 + right := pad - left + fmt.Printf("%s%s%s\n", strings.Repeat("=", left), title, strings.Repeat("=", right)) + } + + cfg, err := config.Get() + if err != nil { + return err + } + + prov, err := cfg.NewProvider(ctx) + if err != nil { + return fmt.Errorf("create %s provider: %w", cfg.ResolvedProvider(), err) + } + + checker, ok := prov.(provider.SecurityChecker) + if !ok { + return fmt.Errorf("provider %q does not support security checks", cfg.ResolvedProvider()) + } + + vpcCIDR := cfg.VpcCIDR(cfg.Env) + + if !jsonOut { + fmt.Printf("%-50s %-8s %-8s %s\n", "CHECK", "STATUS", "SEV", "DETAIL") + fmt.Println(strings.Repeat("-", 90)) + } + + results, err := checker.SecurityCheck(ctx, cfg.Env, vpcCIDR) + if err != nil { + return err + } + + for _, result := range results { + emitSecurityResult(result, jsonOut) + } + report := summarizeSecurityResults(results) + + if jsonOut { + b, err := json.Marshal(report) + if err != nil { + return err + } + fmt.Println(string(b)) + } else { + fmt.Println(strings.Repeat("-", 90)) + fmt.Printf("Results: %d passed, %d failed, %d warned, %d skipped\n", + report.Passed, report.Failed, report.Warned, report.Skipped) + } + + if report.Failed > 0 { + return fmt.Errorf("%d security check(s) failed", report.Failed) + } + return nil +} + +func summarizeSecurityResults(results []provider.SecurityCheckResult) provider.SecurityReport { + report := provider.SecurityReport{Checks: results} + for _, result := range results { + switch result.Status { + case "OK": + report.Passed++ + case "FAIL": + report.Failed++ + case "WARN": + report.Warned++ + case "SKIP": + report.Skipped++ + } + } + return report +} + +func emitSecurityResult(result provider.SecurityCheckResult, jsonOut bool) { + if jsonOut { + if data, err := json.Marshal(result); err == nil { + fmt.Println(string(data)) + } + return + } + + args := []any{ + result.Name + " [" + result.Resource + "]", + result.Status, + result.Severity, + result.Detail, + } + switch result.Status { + case "OK": + color.Green("%-50s %-8s %-8s %s", args...) + case "FAIL": + color.Red("%-50s %-8s %-8s %s", args...) + case "WARN", "SKIP": + color.Yellow("%-50s %-8s %-8s %s", args...) + } +} diff --git a/cli/cmd/security_check_test.go b/cli/cmd/security_check_test.go new file mode 100644 index 00000000..7c742199 --- /dev/null +++ b/cli/cmd/security_check_test.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "testing" + + "github.com/dreadnode/dreadgoad/internal/provider" +) + +func TestSummarizeSecurityResults(t *testing.T) { + results := []provider.SecurityCheckResult{ + {Status: "OK"}, + {Status: "OK"}, + {Status: "FAIL"}, + {Status: "WARN"}, + {Status: "SKIP"}, + } + + report := summarizeSecurityResults(results) + if report.Passed != 2 || report.Failed != 1 || report.Warned != 1 || report.Skipped != 1 { + t.Fatalf("unexpected counts: %+v", report) + } + if len(report.Checks) != len(results) { + t.Fatalf("checks = %d, want %d", len(report.Checks), len(results)) + } +} diff --git a/cli/cmd/up.go b/cli/cmd/up.go index cf8e8ce6..52ea4077 100644 --- a/cli/cmd/up.go +++ b/cli/cmd/up.go @@ -10,6 +10,7 @@ import ( "github.com/dreadnode/dreadgoad/internal/config" "github.com/dreadnode/dreadgoad/internal/doctor" + "github.com/dreadnode/dreadgoad/internal/provider" "github.com/fatih/color" "github.com/spf13/cobra" ) @@ -24,6 +25,7 @@ var ( upFromPlaybook string upInfraModule string upInfraExclude string + upWithKali bool ) var upCmd = &cobra.Command{ @@ -39,12 +41,19 @@ var upCmd = &cobra.Command{ Stops on the first failing step and prints a resume hint. Use --from to restart from a specific point. The recommended new-user flow is: - dreadgoad init && dreadgoad up`, + dreadgoad init && dreadgoad up + +On Azure, step 2 also deploys the Bastion and the in-VNet Ansible controller. +Step 3 reaches the Windows hosts only through them, so they are prerequisites +of this pipeline rather than options -- note that Bastion is a billed, +always-on resource. To build the range without them, use 'infra apply' +directly; provisioning will then need another route to the hosts.`, Example: ` dreadgoad up dreadgoad up --skip-doctor dreadgoad up --from provision dreadgoad up --from provision --from-playbook ad-data.yml - dreadgoad up --limit dc01`, + dreadgoad up --limit dc01 + dreadgoad up --with-kali # also deploy the Kali attack box`, RunE: runUp, } @@ -60,6 +69,7 @@ func init() { upCmd.Flags().StringVar(&upFromPlaybook, "from-playbook", "", "Resume provisioning from this playbook onward") upCmd.Flags().StringVar(&upInfraModule, "module", "", "Target a specific infra module (default: all)") upCmd.Flags().StringVar(&upInfraExclude, "exclude", "", "Exclude infra modules (comma-separated)") + upCmd.Flags().BoolVar(&upWithKali, "with-kali", false, "Also deploy the optional Kali Linux attack box") } type upStep struct { @@ -246,6 +256,10 @@ func runUpDoctor(cmd *cobra.Command, _ []string) error { cfg.Ludus.SSHPort == 0, }, }) + // Azure capacity/quota. Appended rather than folded into RunChecks: it needs a + // provider client, and internal/doctor importing internal/azure to build one + // would drag the cloud SDK into every provider's pre-flight path. + results = append(results, azureCapacityChecks(cfg)...) if failed := doctor.PrintResults(results); failed > 0 { return upDoctorFailure(failed) } @@ -256,19 +270,49 @@ func upDoctorFailure(failed int) error { return fmt.Errorf("%d pre-flight check(s) failed; run 'dreadgoad doctor' for details, fix the reported issues, then retry 'dreadgoad up'", failed) } -// runUpInfraApply invokes `infra apply` with auto-approve. We build a -// synthetic cobra.Command so the inner action sees only the flags we want +// newUpInfraCommand builds the synthetic cobra.Command that `up` drives +// `infra apply` through, so the inner action sees only the flags we want // (auto-approve=true, module/exclude pass-through) without conflating with // the up command's own flag set. +// +// EVERY flag runInfraAction* reads must be registered here. A flag that is +// absent reads back as its zero value and the lookup error is discarded, so +// omitting one fails silently — see TestUpInfraCommandForwardsEveryFlag, +// which is what keeps this set in sync with the real command. +// +// On Azure the Bastion and controller modules are excluded from terragrunt +// unless DREADGOAD_ENABLE_AZURE_* is set, and step 3 (provision) reaches the +// Windows hosts only over Bastion → controller → SOCKS5 (see +// startAzureSOCKSTunnel). They are prerequisites of this pipeline rather than +// options, so `up` opts in on the operator's behalf; without them the run +// deploys every VM and then cannot reach any of them. Kali is a genuine extra +// and stays behind --with-kali. +func newUpInfraCommand(ctx context.Context, providerName string) *cobra.Command { + needsTunnel := providerName == provider.NameAzure + + // Named `synth`, not `infraCmd`: the latter is the real `infra` command at + // package scope, and shadowing it here made the two easy to confuse. + synth := &cobra.Command{} + synth.Flags().String("module", upInfraModule, "") + synth.Flags().String("exclude", upInfraExclude, "") + synth.Flags().Bool("auto-approve", true, "") + synth.Flags().Bool("individual", false, "") + synth.Flags().String("deployment", "", "") + synth.Flags().Bool("with-bastion", needsTunnel, "") + synth.Flags().Bool("with-controller", needsTunnel, "") + synth.Flags().Bool("with-kali", upWithKali, "") + synth.Flags().Duration("timeout", 0, "") + synth.SetContext(ctx) + return synth +} + +// runUpInfraApply invokes `infra apply` with auto-approve. func runUpInfraApply(cmd *cobra.Command, args []string) error { - infraCmd := &cobra.Command{} - infraCmd.Flags().String("module", upInfraModule, "") - infraCmd.Flags().String("exclude", upInfraExclude, "") - infraCmd.Flags().Bool("auto-approve", true, "") - infraCmd.Flags().Bool("individual", false, "") - infraCmd.Flags().String("deployment", "", "") - infraCmd.SetContext(cmd.Context()) - return runInfraAction("apply")(infraCmd, args) + cfg, err := config.Get() + if err != nil { + return err + } + return runInfraAction("apply")(newUpInfraCommand(cmd.Context(), cfg.ResolvedProvider()), args) } type upProvisionOptions struct { diff --git a/cli/cmd/up_test.go b/cli/cmd/up_test.go index d31b250f..ef78ff3e 100644 --- a/cli/cmd/up_test.go +++ b/cli/cmd/up_test.go @@ -4,10 +4,12 @@ import ( "context" "errors" "fmt" + "slices" "strings" "testing" "github.com/dreadnode/dreadgoad/internal/config" + "github.com/dreadnode/dreadgoad/internal/provider" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -262,3 +264,148 @@ func assertIntFlag(t *testing.T, cmd *cobra.Command, name string, want int) { t.Errorf("--%s = %d, want %d", name, got, want) } } + +// `up` drives `infra apply` through a synthetic command rather than the real +// one, so its flag set is maintained by hand. When the two drift, the missing +// flag reads back as its zero value and the lookup error is discarded — the +// inner action cannot tell "not registered" from "left at default". +// +// That is exactly how --with-bastion/--with-controller went missing: they were +// added to `infra apply` (#161) three days after up.go was written (#141), and +// `dreadgoad up` silently deployed Azure ranges with no Bastion and no +// controller, then failed at step 3 because provisioning had no route to any +// Windows host. This test fails on any future flag added to `infra apply` and +// not forwarded here. +func TestUpInfraCommandForwardsEveryFlag(t *testing.T) { + synth := newUpInfraCommand(context.Background(), provider.NameAzure) + + check := func(label string, set *pflag.FlagSet) { + set.VisitAll(func(f *pflag.Flag) { + if synth.Flags().Lookup(f.Name) == nil { + t.Errorf("up does not forward %s --%s to `infra apply`", label, f.Name) + } + }) + } + check("local flag", infraApplyCmd.Flags()) + // resolveDeployment reads --deployment, which is persistent on the parent. + check("persistent flag", infraCmd.PersistentFlags()) +} + +// Bastion and the in-VNet controller are prerequisites of the `up` pipeline on +// Azure, not options: provisioning reaches the Windows hosts only through them. +// They must therefore default ON for Azure and stay OFF everywhere else, where +// the modules do not exist. +func TestUpInfraCommandEnablesAzureTunnelModules(t *testing.T) { + for _, tc := range []struct { + providerName string + want bool + }{ + {provider.NameAzure, true}, + {provider.NameAWS, false}, + {"proxmox", false}, + {"ludus", false}, + } { + t.Run(tc.providerName, func(t *testing.T) { + c := newUpInfraCommand(context.Background(), tc.providerName) + for _, name := range []string{"with-bastion", "with-controller"} { + got, err := c.Flags().GetBool(name) + if err != nil { + t.Fatalf("--%s not registered: %v", name, err) + } + if got != tc.want { + t.Errorf("--%s = %v, want %v for provider %q", + name, got, tc.want, tc.providerName) + } + } + }) + } +} + +// The flags only matter insofar as they reach terragrunt. This drives up's +// command through the real translation step (azureModuleEnv) and asserts on +// the env vars the exclude{} blocks actually read, closing the chain: +// +// up → newUpInfraCommand → azureModuleEnv → DREADGOAD_ENABLE_AZURE_* → +// terragrunt exclude{} → bastion + controller deployed → step 3 can reach +// the Windows hosts. +func TestUpDeploysTheAzureModulesProvisioningNeeds(t *testing.T) { + // An empty layout, so the destroy-time fallback has nothing to find and + // we observe the forwarded flags alone. + emptyRoot := t.TempDir() + + got := azureModuleEnv(newUpInfraCommand(context.Background(), provider.NameAzure), "apply", emptyRoot) + + for _, want := range []string{ + "DREADGOAD_ENABLE_AZURE_BASTION=true", + "DREADGOAD_ENABLE_AZURE_CONTROLLER=true", + } { + if !slices.Contains(got, want) { + t.Errorf("`up` on Azure does not set %s; provisioning will have no route "+ + "to the Windows hosts and step 3 fails. got=%v", want, got) + } + } + if slices.Contains(got, "DREADGOAD_ENABLE_AZURE_KALI=true") { + t.Errorf("`up` deployed the Kali box without --with-kali: %v", got) + } +} + +// Non-Azure providers have no such modules, so up must not set the vars. +func TestUpSetsNoAzureModuleEnvOnOtherProviders(t *testing.T) { + emptyRoot := t.TempDir() + + for _, name := range []string{provider.NameAWS, "proxmox", "ludus"} { + t.Run(name, func(t *testing.T) { + got := azureModuleEnv(newUpInfraCommand(context.Background(), name), "apply", emptyRoot) + if len(got) != 0 { + t.Errorf("provider %q set Azure module env: %v", name, got) + } + }) + } +} + +// Round-trip invariant: every module `up` deploys on Azure must also be torn +// down by a BARE `infra destroy` — which is exactly what the console's +// /destroy runs (commands.py maps it to ("infra", "destroy") with no flags). +// A module in the up set but not the destroy set is a resource left standing +// and still billing, and Bastion is the expensive one. +func TestUpDestroyRoundTripLeavesNothingBehind(t *testing.T) { + t.Cleanup(func() { upWithKali = false }) + + root := moduleRootWith(t, "bastion", "controller", "kali", "goad", "network") + + for _, kali := range []bool{false, true} { + upWithKali = kali + + created := azureModuleEnv( + newUpInfraCommand(context.Background(), provider.NameAzure), "apply", root) + + // A bare destroy, carrying the real infraDestroyCmd flag set at defaults. + bare := &cobra.Command{} + bare.Flags().AddFlagSet(infraDestroyCmd.Flags()) + destroyed := azureModuleEnv(bare, "destroy", root) + + for _, mod := range created { + if !slices.Contains(destroyed, mod) { + t.Errorf("--with-kali=%v: `up` deploys %s but a bare `infra destroy` "+ + "does not tear it down — orphaned resource.\n up=%v\n destroy=%v", + kali, strings.TrimSuffix(mod, "=true"), created, destroyed) + } + } + } +} + +// Kali is a real attack box the operator pays for, so unlike the tunnel modules +// it stays opt-in on every provider. +func TestUpInfraCommandKaliIsOptIn(t *testing.T) { + t.Cleanup(func() { upWithKali = false }) + + upWithKali = false + if got, _ := newUpInfraCommand(context.Background(), provider.NameAzure).Flags().GetBool("with-kali"); got { + t.Error("--with-kali defaulted to true; it must stay opt-in") + } + + upWithKali = true + if got, _ := newUpInfraCommand(context.Background(), provider.NameAzure).Flags().GetBool("with-kali"); !got { + t.Error("up --with-kali was not forwarded to `infra apply`") + } +} diff --git a/cli/go.mod b/cli/go.mod index 383d4363..ce41700f 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -17,6 +17,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.45.8 github.com/cowdogmoo/warpgate/v3 v3.2.1-0.20260812020456-d61652ca51b8 github.com/fatih/color v1.19.0 + github.com/go-viper/mapstructure/v2 v2.5.0 github.com/masterzen/winrm v0.0.0-20260407182533-5570be7f80cf github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -67,7 +68,6 @@ require ( github.com/docker/docker-credential-helpers v0.9.8 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/go-logr/logr v1.4.4 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/go-containerregistry v0.21.9 // indirect diff --git a/cli/internal/ansible/errors.go b/cli/internal/ansible/errors.go index 41214737..391706ca 100644 --- a/cli/internal/ansible/errors.go +++ b/cli/internal/ansible/errors.go @@ -20,6 +20,7 @@ const ( ErrSSMUserAccount ErrorType = "ssm_user_account_issue" ErrMSIInstaller ErrorType = "msi_installer_error" ErrWUACOM ErrorType = "wua_com_error" + ErrPackageMgmt ErrorType = "package_management_dll" ErrUnclassified ErrorType = "unclassified" ) @@ -59,6 +60,10 @@ func DetectErrorType(output string) (ErrorType, string) { "Microsoft.Update.UpdateColl"): return ErrWUACOM, "Windows Update COM object corrupted (0x800703FA)" + case strings.Contains(strings.ToLower(output), "packagemanagement") && + containsAny(output, "0x8000FFFF", "8000ffff", "Catastrophic failure"): + return ErrPackageMgmt, "PackageManagement DLL load failure (MOTW / 0x8000FFFF)" + default: detail := extractFatalContext(output) return ErrUnclassified, detail diff --git a/cli/internal/ansible/errors_test.go b/cli/internal/ansible/errors_test.go index e291c9cb..fbda6ffc 100644 --- a/cli/internal/ansible/errors_test.go +++ b/cli/internal/ansible/errors_test.go @@ -107,6 +107,22 @@ func TestDetectErrorType(t *testing.T) { wantType: ErrWUACOM, wantMsg: "WUA COM", }, + { + name: "PackageManagement catastrophic failure", + output: "Could not load Microsoft.PackageManagement.dll: Catastrophic failure (0x8000FFFF)", + wantType: ErrPackageMgmt, + wantMsg: "PackageManagement", + }, + { + name: "unrelated catastrophic failure", + output: "Unrelated COM operation failed: Catastrophic failure (0x8000FFFF)", + wantType: ErrUnclassified, + }, + { + name: "PackageManagement mention without matching failure", + output: "Microsoft.PackageManagement.dll could not be inspected: access denied", + wantType: ErrUnclassified, + }, { name: "unclassified error with fatal line", output: "fatal: [DC01]: FAILED! => {\"msg\": \"some unknown error\"}", diff --git a/cli/internal/ansible/retry.go b/cli/internal/ansible/retry.go index 679eb4db..9fd7ea3e 100644 --- a/cli/internal/ansible/retry.go +++ b/cli/internal/ansible/retry.go @@ -237,7 +237,7 @@ func retryWithErrorStrategy(ctx context.Context, opts RetryOptions, failResult * case ErrMSIInstaller: log.Info("MSI installer error - rebooting failed hosts before retry") - rebootFailedHosts(ctx, opts, log) + rebootFailedHosts(ctx, opts, failResult.FailedHosts, log) time.Sleep(30 * time.Second) baseOpts.Forks = 1 @@ -245,7 +245,15 @@ func retryWithErrorStrategy(ctx context.Context, opts RetryOptions, failResult * case ErrWUACOM: log.Info("WUA COM corruption - rebooting to clear pending registry deletions") - rebootFailedHosts(ctx, opts, log) + rebootFailedHosts(ctx, opts, failResult.FailedHosts, log) + time.Sleep(30 * time.Second) + + baseOpts.Forks = 1 + return runPlaybookAttempt(ctx, baseOpts) + + case ErrPackageMgmt: + log.Info("PackageManagement DLL failure - rebooting to clear pending file operations") + rebootFailedHosts(ctx, opts, failResult.FailedHosts, log) time.Sleep(30 * time.Second) baseOpts.Forks = 1 @@ -377,13 +385,17 @@ func fixSSMUsers(ctx context.Context, env string, failedHosts []string, log *slo } } -func rebootFailedHosts(ctx context.Context, opts RetryOptions, log *slog.Logger) { +func rebootFailedHosts(ctx context.Context, opts RetryOptions, hosts []string, log *slog.Logger) { + if len(hosts) == 0 { + log.Warn("no failed hosts to reboot") + return + } cfg, err := config.Get() if err != nil { log.Warn("could not get config for reboot", "error", err) return } - for _, host := range strings.Split(opts.Limit, ",") { + for _, host := range hosts { if host == "" { continue } @@ -393,6 +405,9 @@ func rebootFailedHosts(ctx context.Context, opts RetryOptions, log *slog.Logger) "-m", "ansible.windows.win_reboot", "-a", "reboot_timeout=600 post_reboot_delay=60", } + for k, v := range opts.ExtraVars { + args = append(args, "-e", k+"="+v) + } rebootCmd := execCommand(ctx, "ansible", args...) rebootCmd.Dir = cfg.ProjectRoot env, envErr := buildEnv(RunOptions{Env: opts.Env}, cfg) diff --git a/cli/internal/aws/ec2.go b/cli/internal/aws/ec2.go index 253de8b0..b7828beb 100644 --- a/cli/internal/aws/ec2.go +++ b/cli/internal/aws/ec2.go @@ -16,7 +16,12 @@ type Instance struct { Name string PrivateIP string State string - Tags map[string]string + // Account is the AWS account ID that owns the instance. It comes from the + // enclosing Reservation's OwnerId, which DescribeInstances already returns — + // no STS call and no extra IAM permission. Note this is the *owning* + // account, which for a single-account range is also the calling account. + Account string + Tags map[string]string } // DiscoverInstances finds DreadGOAD instances by project/environment tags or @@ -97,6 +102,7 @@ func appendDiscoveredInstances(instances []Instance, seen map[string]struct{}, r InstanceID: instanceID, PrivateIP: deref(i.PrivateIpAddress), State: string(i.State.Name), + Account: deref(r.OwnerId), Tags: make(map[string]string, len(i.Tags)), } for _, t := range i.Tags { diff --git a/cli/internal/aws/ec2_test.go b/cli/internal/aws/ec2_test.go index 34e43b08..ded2a176 100644 --- a/cli/internal/aws/ec2_test.go +++ b/cli/internal/aws/ec2_test.go @@ -71,3 +71,33 @@ func TestAppendDiscoveredInstancesDeduplicatesAndPreservesTags(t *testing.T) { t.Fatalf("appendDiscoveredInstances() = %#v, want Name and Role tags preserved", instances[0]) } } + +// Account comes from the enclosing Reservation rather than the instance, so it +// is the one field a refactor of this loop can silently drop -- nothing else +// reads OwnerId. It feeds `dreadgoad status` (lab.go) and from there the web +// app's cloud-account display, where an empty value looks like a discovery +// failure rather than a missing assignment. +func TestAppendDiscoveredInstancesCapturesOwningAccount(t *testing.T) { + reservation := types.Reservation{ + OwnerId: Ptr("70a9c8a4"), + Instances: []types.Instance{{ + InstanceId: Ptr("i-dc01"), + State: &types.InstanceState{Name: types.InstanceStateNameRunning}, + Tags: []types.Tag{{Key: Ptr("Name"), Value: Ptr("test-goad-dc01")}}, + }}, + } + + instances := appendDiscoveredInstances(nil, make(map[string]struct{}), []types.Reservation{reservation}) + + if len(instances) != 1 { + t.Fatalf("got %d instances, want 1", len(instances)) + } + if instances[0].Account != "70a9c8a4" { + t.Errorf("Account = %q, want %q (from Reservation.OwnerId)", instances[0].Account, "70a9c8a4") + } + // The same struct literal populates both; a merge that keeps one and drops + // the other is the failure this pairs against. + if instances[0].Tags["Name"] != "test-goad-dc01" { + t.Errorf("Tags[Name] = %q, want test-goad-dc01", instances[0].Tags["Name"]) + } +} diff --git a/cli/internal/aws/provider.go b/cli/internal/aws/provider.go index 4677c8ad..7253eb1c 100644 --- a/cli/internal/aws/provider.go +++ b/cli/internal/aws/provider.go @@ -93,13 +93,24 @@ func (p *AWSProvider) RunCommand(ctx context.Context, instanceID, command string if err != nil { return nil, err } - return &provider.CommandResult{ + return provider.CleanResult(&provider.CommandResult{ Status: result.Status, Stdout: result.Stdout, Stderr: result.Stderr, - }, nil + }), nil } +// RunCommandOutOfBand satisfies provider.OutOfBandRunner. On AWS this is just +// RunCommand: it already runs through SSM, which is the control plane and +// needs no in-guest listener. The interface exists so callers can *require* +// that guarantee rather than assume it — on Azure the two differ. +func (p *AWSProvider) RunCommandOutOfBand(ctx context.Context, instanceID, command string, timeout time.Duration) (*provider.CommandResult, error) { + return p.RunCommand(ctx, instanceID, command, timeout) +} + +// OutOfBandChannel implements provider.OutOfBandRunner. +func (p *AWSProvider) OutOfBandChannel() string { return "AWS SSM" } + func (p *AWSProvider) RunCommandOnMultiple(ctx context.Context, instanceIDs []string, command string, timeout time.Duration) (map[string]*provider.CommandResult, error) { results, err := p.client.RunPowerShellOnMultiple(ctx, instanceIDs, command, timeout) if err != nil { @@ -200,7 +211,10 @@ func toProviderInstance(i Instance) provider.Instance { Name: i.Name, PrivateIP: i.PrivateIP, State: i.State, + Account: i.Account, Tags: i.Tags, + // Group stays empty: AWS has no resource-group equivalent. A range is + // identified by tag convention (see DiscoverInstances), not containment. } } diff --git a/cli/internal/azure/capacity.go b/cli/internal/azure/capacity.go new file mode 100644 index 00000000..e8addd06 --- /dev/null +++ b/cli/internal/azure/capacity.go @@ -0,0 +1,219 @@ +package azure + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v8" +) + +// SKUStatus is what one requested VM size looks like in one region. +type SKUStatus struct { + Name string + // False when the region does not offer this size at all — a different + // failure from being offered but restricted. + Offered bool + // Restrictions Azure reports against this subscription in this region. + // "NotAvailableForSubscription" is the capacity-restriction case that + // surfaces at apply time as SkuNotAvailable. + Restrictions []string + // vCPUs per instance, 0 when Azure does not report it. Used to turn a VM + // count into the core count a quota is denominated in. + VCPUs int32 + // Zones the SKU is restricted out of, when the restriction is zonal rather + // than regional. A zonal restriction still leaves the region usable. + RestrictedZones []string +} + +// Blocked reports whether this SKU cannot currently be deployed region-wide. +// +// Zone-level restrictions are excluded on purpose: the lab's terragrunt units +// do not pin an availability zone, so Azure is free to place the VM in a zone +// that is not restricted. +func (s SKUStatus) Blocked() bool { + return !s.Offered || len(s.Restrictions) > 0 +} + +// QuotaItem is one usage counter in a region. +type QuotaItem struct { + Name string + Current int32 + Limit int64 +} + +// Headroom is how much of this quota remains. +func (q QuotaItem) Headroom() int64 { return q.Limit - int64(q.Current) } + +// SKUAvailability reports, for each requested VM size, whether the region +// currently offers it to this subscription. +// +// This is the read behind the SkuNotAvailable failure that only otherwise +// surfaces minutes into `tofu apply`: Azure publishes the same restriction on +// the Resource SKUs API before anything is created. +// +// One paged call covers every size, so the cost does not grow with the range. +// Sizes are matched case-insensitively — terragrunt files carry Azure's +// canonical casing ("Standard_D2s_v3") but nothing enforces it. +func (c *Client) SKUAvailability(ctx context.Context, region string, sizes []string) ([]SKUStatus, error) { + if err := c.ensureSDK(ctx); err != nil { + return nil, err + } + if region == "" { + return nil, fmt.Errorf("region is required to check SKU availability") + } + + want := make(map[string]*SKUStatus, len(sizes)) + order := make([]string, 0, len(sizes)) + for _, s := range sizes { + key := strings.ToLower(s) + if _, dup := want[key]; dup { + continue + } + want[key] = &SKUStatus{Name: s} + order = append(order, key) + } + if len(want) == 0 { + return nil, nil + } + + // The location filter is the only one this API supports, and it is what + // keeps the response to the region's SKUs rather than every SKU on Azure. + filter := fmt.Sprintf("location eq '%s'", region) + pager := c.skuClient.NewListPager(&armcompute.ResourceSKUsClientListOptions{Filter: &filter}) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("list compute SKUs in %s: %w", region, err) + } + for _, sku := range page.Value { + if sku == nil || sku.Name == nil { + continue + } + // The filter narrows to the region, but a SKU can still be listed + // with resourceType "disks" and the like; only VM sizes matter. + if sku.ResourceType != nil && !strings.EqualFold(*sku.ResourceType, "virtualMachines") { + continue + } + st, ok := want[strings.ToLower(*sku.Name)] + if !ok { + continue + } + st.Offered = true + st.VCPUs = vcpusOf(sku.Capabilities) + applyRestrictions(st, sku.Restrictions, region) + } + } + + out := make([]SKUStatus, 0, len(order)) + for _, key := range order { + out = append(out, *want[key]) + } + return out, nil +} + +// applyRestrictions records the region-scoped restrictions on a SKU, keeping +// zonal ones separate so a zone-restricted size is not reported as unusable. +func applyRestrictions(st *SKUStatus, restrictions []*armcompute.ResourceSKURestrictions, region string) { + for _, r := range restrictions { + if r == nil || r.Type == nil { + continue + } + reason := "restricted" + if r.ReasonCode != nil { + reason = string(*r.ReasonCode) + } + switch *r.Type { + case armcompute.ResourceSKURestrictionsTypeLocation: + // Values carries the restricted locations. Guard against a + // restriction published for some other region in the same record. + if !matchesRegion(r.Values, region) { + continue + } + st.Restrictions = append(st.Restrictions, reason) + case armcompute.ResourceSKURestrictionsTypeZone: + if r.RestrictionInfo != nil { + for _, z := range r.RestrictionInfo.Zones { + if z != nil { + st.RestrictedZones = append(st.RestrictedZones, *z) + } + } + } + } + } +} + +// matchesRegion reports whether a location restriction covers this region. An +// empty value list is treated as covering it: Azure returned the restriction +// under a location-filtered query, so the conservative reading is that it +// applies. +func matchesRegion(values []*string, region string) bool { + if len(values) == 0 { + return true + } + for _, v := range values { + if v != nil && strings.EqualFold(*v, region) { + return true + } + } + return false +} + +func vcpusOf(caps []*armcompute.ResourceSKUCapabilities) int32 { + for _, cap := range caps { + if cap == nil || cap.Name == nil || cap.Value == nil { + continue + } + if strings.EqualFold(*cap.Name, "vCPUs") { + if n, err := strconv.ParseInt(*cap.Value, 10, 32); err == nil { + return int32(n) + } + } + } + return 0 +} + +// RegionQuota returns the compute usage counters for a region. +// +// Separate from SKU availability because they fail differently: a quota is a +// subscription limit you can raise by asking, while a capacity restriction is +// Azure having no hardware to give and is only fixed by waiting or moving. +func (c *Client) RegionQuota(ctx context.Context, region string) ([]QuotaItem, error) { + if err := c.ensureSDK(ctx); err != nil { + return nil, err + } + if region == "" { + return nil, fmt.Errorf("region is required to check quota") + } + var out []QuotaItem + pager := c.usageClient.NewListPager(region, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("list compute usage in %s: %w", region, err) + } + for _, u := range page.Value { + if u == nil || u.Name == nil || u.Limit == nil || u.CurrentValue == nil { + continue + } + name := "" + if u.Name.Value != nil { + name = *u.Name.Value + } + out = append(out, QuotaItem{Name: name, Current: *u.CurrentValue, Limit: *u.Limit}) + } + } + return out, nil +} + +// FindQuota returns the named usage counter, or false when Azure did not +// report it. Names are the API's invariant form ("cores", "virtualMachines"). +func FindQuota(items []QuotaItem, name string) (QuotaItem, bool) { + for _, q := range items { + if strings.EqualFold(q.Name, name) { + return q, true + } + } + return QuotaItem{}, false +} diff --git a/cli/internal/azure/capacity_test.go b/cli/internal/azure/capacity_test.go new file mode 100644 index 00000000..45792d4b --- /dev/null +++ b/cli/internal/azure/capacity_test.go @@ -0,0 +1,141 @@ +package azure + +import ( + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v8" +) + +func restrictionsFor(t armcompute.ResourceSKURestrictionsType, reason armcompute.ResourceSKURestrictionsReasonCode, + values []string, zones []string, +) []*armcompute.ResourceSKURestrictions { + vals := make([]*string, len(values)) + for i := range values { + vals[i] = &values[i] + } + zs := make([]*string, len(zones)) + for i := range zones { + zs[i] = &zones[i] + } + return []*armcompute.ResourceSKURestrictions{{ + Type: &t, + ReasonCode: &reason, + Values: vals, + RestrictionInfo: &armcompute.ResourceSKURestrictionInfo{Zones: zs}, + }} +} + +// The failure this whole check exists for: eastus published +// NotAvailableForSubscription for Standard_D2s_v3, and `up` only discovered it +// minutes into apply. +func TestLocationRestrictionMarksTheSKUBlocked(t *testing.T) { + st := &SKUStatus{Name: "Standard_D2s_v3", Offered: true} + applyRestrictions(st, restrictionsFor( + armcompute.ResourceSKURestrictionsTypeLocation, + armcompute.ResourceSKURestrictionsReasonCodeNotAvailableForSubscription, + []string{"eastus"}, nil, + ), "eastus") + + if !st.Blocked() { + t.Fatal("a location restriction for this region must block the SKU") + } + if len(st.Restrictions) != 1 || st.Restrictions[0] != "NotAvailableForSubscription" { + t.Errorf("restrictions = %v, want the reason code", st.Restrictions) + } +} + +// A record can carry a restriction scoped to some other region. Treating it as +// ours would warn about a region that is fine. +func TestLocationRestrictionForAnotherRegionIsIgnored(t *testing.T) { + st := &SKUStatus{Name: "Standard_D2s_v3", Offered: true} + applyRestrictions(st, restrictionsFor( + armcompute.ResourceSKURestrictionsTypeLocation, + armcompute.ResourceSKURestrictionsReasonCodeNotAvailableForSubscription, + []string{"westeurope"}, nil, + ), "eastus") + + if st.Blocked() { + t.Errorf("restriction on westeurope must not block eastus: %v", st.Restrictions) + } +} + +// Zonal restrictions are not blockers: the lab's units pin no zone, so Azure +// places the VM in one that is not restricted. Reporting these as blocked would +// warn on almost every deploy and train the operator to ignore the check. +func TestZoneRestrictionIsRecordedButNotBlocking(t *testing.T) { + st := &SKUStatus{Name: "Standard_D2s_v3", Offered: true} + applyRestrictions(st, restrictionsFor( + armcompute.ResourceSKURestrictionsTypeZone, + armcompute.ResourceSKURestrictionsReasonCodeNotAvailableForSubscription, + []string{"eastus"}, []string{"1", "3"}, + ), "eastus") + + if st.Blocked() { + t.Error("a zone restriction must not block a region-wide deploy") + } + if len(st.RestrictedZones) != 2 { + t.Errorf("restricted zones = %v, want 1 and 3", st.RestrictedZones) + } +} + +// A SKU the region does not list at all never gets Offered set. That is a +// different failure from "offered but restricted" and must still block. +func TestUnofferedSKUIsBlocked(t *testing.T) { + st := SKUStatus{Name: "Standard_D2s_v3"} + if !st.Blocked() { + t.Error("a SKU the region does not offer must be blocked") + } +} + +// An empty Values list under a location-filtered query is read as applying +// here — the conservative direction for a warning. +func TestLocationRestrictionWithNoValuesApplies(t *testing.T) { + st := &SKUStatus{Name: "Standard_D2s_v3", Offered: true} + applyRestrictions(st, restrictionsFor( + armcompute.ResourceSKURestrictionsTypeLocation, + armcompute.ResourceSKURestrictionsReasonCodeQuotaID, + nil, nil, + ), "eastus") + if !st.Blocked() { + t.Error("a location restriction with no values must be treated as applying") + } +} + +func TestVCPUsParsedFromCapabilities(t *testing.T) { + name, val := "vCPUs", "2" + other, otherVal := "MemoryGB", "8" + caps := []*armcompute.ResourceSKUCapabilities{ + {Name: &other, Value: &otherVal}, + {Name: &name, Value: &val}, + nil, // the SDK yields pointers; a nil entry must not panic + } + if got := vcpusOf(caps); got != 2 { + t.Errorf("vcpusOf = %d, want 2", got) + } + // Absent or unparsable capabilities yield 0, which the caller treats as + // "cannot estimate" rather than "needs no cores". + if got := vcpusOf(nil); got != 0 { + t.Errorf("vcpusOf(nil) = %d, want 0", got) + } + bad := "not-a-number" + if got := vcpusOf([]*armcompute.ResourceSKUCapabilities{{Name: &name, Value: &bad}}); got != 0 { + t.Errorf("vcpusOf(unparsable) = %d, want 0", got) + } +} + +func TestQuotaHeadroomAndLookup(t *testing.T) { + items := []QuotaItem{ + {Name: "cores", Current: 10, Limit: 20}, + {Name: "virtualMachines", Current: 3, Limit: 100}, + } + cores, ok := FindQuota(items, "Cores") // case-insensitive: the API varies + if !ok { + t.Fatal("cores not found") + } + if cores.Headroom() != 10 { + t.Errorf("headroom = %d, want 10", cores.Headroom()) + } + if _, ok := FindQuota(items, "nope"); ok { + t.Error("FindQuota reported a counter that is not there") + } +} diff --git a/cli/internal/azure/client.go b/cli/internal/azure/client.go index 7d64345d..be8e111a 100644 --- a/cli/internal/azure/client.go +++ b/cli/internal/azure/client.go @@ -67,11 +67,15 @@ type Client struct { // SDK clients are constructed lazily once SubscriptionID is known. Guarded // by sdkOnce so concurrent first-use doesn't double-build them. - sdkOnce sync.Once - sdkErr error - vmClient *armcompute.VirtualMachinesClient - nicClient *armnetwork.InterfacesClient - rcClient *armcompute.VirtualMachineRunCommandsClient + sdkOnce sync.Once + sdkErr error + vmClient *armcompute.VirtualMachinesClient + nicClient *armnetwork.InterfacesClient + rcClient *armcompute.VirtualMachineRunCommandsClient + skuClient *armcompute.ResourceSKUsClient + usageClient *armcompute.UsageClient + nsgClient *armnetwork.SecurityGroupsClient + bastionClient *armnetwork.BastionHostsClient } // ensureSDK populates the lazy SDK clients. Callers must guarantee that @@ -101,9 +105,33 @@ func (c *Client) ensureSDK(ctx context.Context) error { c.sdkErr = fmt.Errorf("init run-command client: %w", err) return } + sku, err := armcompute.NewResourceSKUsClient(c.SubscriptionID, c.cred, c.armOpts) + if err != nil { + c.sdkErr = fmt.Errorf("init compute SKU client: %w", err) + return + } + usage, err := armcompute.NewUsageClient(c.SubscriptionID, c.cred, c.armOpts) + if err != nil { + c.sdkErr = fmt.Errorf("init compute usage client: %w", err) + return + } + nsg, err := armnetwork.NewSecurityGroupsClient(c.SubscriptionID, c.cred, c.armOpts) + if err != nil { + c.sdkErr = fmt.Errorf("init NSG client: %w", err) + return + } + bastion, err := armnetwork.NewBastionHostsClient(c.SubscriptionID, c.cred, c.armOpts) + if err != nil { + c.sdkErr = fmt.Errorf("init bastion client: %w", err) + return + } c.vmClient = vm c.nicClient = nic c.rcClient = rc + c.skuClient = sku + c.usageClient = usage + c.nsgClient = nsg + c.bastionClient = bastion }) return c.sdkErr } diff --git a/cli/internal/azure/describe.go b/cli/internal/azure/describe.go new file mode 100644 index 00000000..aa1753a1 --- /dev/null +++ b/cli/internal/azure/describe.go @@ -0,0 +1,262 @@ +package azure + +import ( + "context" + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v8" +) + +// DiskDetail is one managed disk attached to a VM. +// +// Everything here comes off the VM's own StorageProfile, so describing a VM's +// disks costs no request beyond the VM Get itself. Resolving each disk through +// the Disks API would add a call per disk to surface little the profile does +// not already carry. +type DiskDetail struct { + Name string `json:"name"` + // "os" or "data" — the OS disk is the one the machine boots from, and it is + // the distinction an operator is looking for first. + Role string `json:"role"` + // Only data disks have one; the OS disk is not addressed by LUN. + Lun *int32 `json:"lun,omitempty"` + SizeGB *int32 `json:"size_gb,omitempty"` + StorageType string `json:"storage_type,omitempty"` + Caching string `json:"caching,omitempty"` + CreateOption string `json:"create_option,omitempty"` + ManagedDiskID string `json:"managed_disk_id,omitempty"` +} + +// NICDetail is one network interface attached to a VM. +// +// Unlike disks, NICs are only referenced by ID on the VM, so each one costs a +// Get against the network API. +type NICDetail struct { + Name string `json:"name"` + ID string `json:"id"` + // A NIC can hold several IP configurations; the range's VMs use one, but + // reporting all of them avoids implying there is only ever one. + PrivateIPs []string `json:"private_ips"` + SubnetID string `json:"subnet_id,omitempty"` + NSGID string `json:"nsg_id,omitempty"` + MACAddress string `json:"mac_address,omitempty"` + Primary *bool `json:"primary,omitempty"` + AcceleratedNetworking *bool `json:"accelerated_networking,omitempty"` + // Set when an IP configuration references a public IP. The address itself + // is deliberately not resolved: that needs another client and another call + // per NIC, and these ranges reach their hosts through Bastion rather than + // public addresses. + PublicIPID string `json:"public_ip_id,omitempty"` +} + +// InstanceDetail is the attached-resource view of one VM. +type InstanceDetail struct { + ID string `json:"id"` + Name string `json:"name"` + ResourceGroup string `json:"resource_group"` + Location string `json:"location,omitempty"` + VMSize string `json:"vm_size,omitempty"` + PowerState string `json:"power_state,omitempty"` + Disks []DiskDetail `json:"disks"` + NICs []NICDetail `json:"nics"` +} + +// DescribeInstance returns the disks and network interfaces attached to one VM. +// +// Takes a full ARM resource ID rather than a hostname on purpose. The hostname +// path (FindInstanceByHostname) resolves by listing every VM in the +// subscription and substring-matching the name, which is the right trade for a +// command an operator types occasionally and the wrong one for a UI panel: the +// caller already holds the ID from discovery, so a direct Get is both cheaper +// and unambiguous. +func (c *Client) DescribeInstance(ctx context.Context, id string) (*InstanceDetail, error) { + if err := c.ensureSDK(ctx); err != nil { + return nil, err + } + rid, err := arm.ParseResourceID(id) + if err != nil { + return nil, fmt.Errorf("parse VM resource ID %q: %w", id, err) + } + // The SDK clients are built around the credential's subscription, so the ID's + // own subscription is ignored on the wire. Without this check an ID from a + // different subscription would quietly describe whatever VM happens to share + // its resource group and name here — the wrong machine, reported as the right + // one. ParseResourceID is lenient enough to yield an empty subscription, so + // this catches malformed IDs too. + if rid.SubscriptionID != c.SubscriptionID { + return nil, fmt.Errorf( + "VM %s belongs to subscription %s, but this client is authenticated to %s", + rid.Name, rid.SubscriptionID, c.SubscriptionID) + } + + // Expand to the instance view: without it the response describes only the + // VM's configuration and cannot say whether the machine is actually running. + // It is the same read against the same resource, so it costs no extra call. + view := armcompute.InstanceViewTypesInstanceView + resp, err := c.vmClient.Get(ctx, rid.ResourceGroupName, rid.Name, + &armcompute.VirtualMachinesClientGetOptions{Expand: &view}) + if err != nil { + return nil, fmt.Errorf("get VM %s: %w", rid.Name, err) + } + vm := resp.VirtualMachine + + detail := &InstanceDetail{ + ID: id, + Name: rid.Name, + ResourceGroup: rid.ResourceGroupName, + Disks: []DiskDetail{}, + NICs: []NICDetail{}, + } + if vm.Location != nil { + detail.Location = *vm.Location + } + if vm.Properties == nil { + return detail, nil + } + if vm.Properties.HardwareProfile != nil && vm.Properties.HardwareProfile.VMSize != nil { + detail.VMSize = string(*vm.Properties.HardwareProfile.VMSize) + } + + detail.PowerState = powerStateOf(vm.Properties.InstanceView) + detail.Disks = disksFromProfile(vm.Properties.StorageProfile) + + if vm.Properties.NetworkProfile != nil { + for _, ref := range vm.Properties.NetworkProfile.NetworkInterfaces { + if ref == nil || ref.ID == nil { + continue + } + nic, err := c.describeNIC(ctx, *ref.ID) + if err != nil { + // One unreadable NIC should not blank the whole panel — the + // disks and the other interfaces are still worth showing. + detail.NICs = append(detail.NICs, NICDetail{ID: *ref.ID, Name: nicNameOf(*ref.ID)}) + continue + } + detail.NICs = append(detail.NICs, *nic) + } + } + return detail, nil +} + +// powerStateOf pulls the running state out of a VM's instance view. +// +// Azure reports it as one status among several ("PowerState/running" alongside +// "ProvisioningState/succeeded"), so the code is matched on its prefix rather +// than by position — the order is not contractual. Empty when the instance view +// is absent, which omits the field rather than claiming the VM is off. +// +// Runs the result through normalizePowerState, the same mapping ListInstances +// applies. Without it Azure's "deallocated" would reach the panel verbatim while +// the graph node beside it reads "stopped" for that very machine. +func powerStateOf(view *armcompute.VirtualMachineInstanceView) string { + if view == nil { + return "" + } + const prefix = "PowerState/" + for _, st := range view.Statuses { + if st == nil || st.Code == nil { + continue + } + if code := *st.Code; strings.HasPrefix(code, prefix) { + return normalizePowerState(strings.TrimPrefix(code, prefix)) + } + } + return "" +} + +func nicNameOf(id string) string { + if rid, err := arm.ParseResourceID(id); err == nil { + return rid.Name + } + return id +} + +func (c *Client) describeNIC(ctx context.Context, nicID string) (*NICDetail, error) { + rid, err := arm.ParseResourceID(nicID) + if err != nil { + return nil, fmt.Errorf("parse NIC resource ID %q: %w", nicID, err) + } + resp, err := c.nicClient.Get(ctx, rid.ResourceGroupName, rid.Name, nil) + if err != nil { + return nil, fmt.Errorf("get NIC %s: %w", rid.Name, err) + } + out := &NICDetail{Name: rid.Name, ID: nicID, PrivateIPs: []string{}} + props := resp.Properties + if props == nil { + return out, nil + } + out.MACAddress = derefStr(props.MacAddress) + out.Primary = props.Primary + out.AcceleratedNetworking = props.EnableAcceleratedNetworking + if props.NetworkSecurityGroup != nil { + out.NSGID = derefStr(props.NetworkSecurityGroup.ID) + } + for _, cfg := range props.IPConfigurations { + if cfg == nil || cfg.Properties == nil { + continue + } + if ip := derefStr(cfg.Properties.PrivateIPAddress); ip != "" { + out.PrivateIPs = append(out.PrivateIPs, ip) + } + if out.SubnetID == "" && cfg.Properties.Subnet != nil { + out.SubnetID = derefStr(cfg.Properties.Subnet.ID) + } + if out.PublicIPID == "" && cfg.Properties.PublicIPAddress != nil { + out.PublicIPID = derefStr(cfg.Properties.PublicIPAddress.ID) + } + } + return out, nil +} + +// ptrString renders an optional SDK enum (CachingTypes, DiskCreateOptionTypes) +// as a plain string, empty when unset. +func ptrString[T ~string](v *T) string { + if v == nil { + return "" + } + return string(*v) +} + +// disksFromProfile reads the OS and data disks off a VM's storage profile. +// Split out so the mapping can be tested without an ARM transport. +func disksFromProfile(sp *armcompute.StorageProfile) []DiskDetail { + out := []DiskDetail{} + if sp == nil { + return out + } + if d := sp.OSDisk; d != nil { + out = append(out, diskDetail(d.Name, "os", nil, d.DiskSizeGB, d.ManagedDisk, + ptrString(d.Caching), ptrString(d.CreateOption))) + } + for _, d := range sp.DataDisks { + if d == nil { + continue + } + out = append(out, diskDetail(d.Name, "data", d.Lun, d.DiskSizeGB, d.ManagedDisk, + ptrString(d.Caching), ptrString(d.CreateOption))) + } + return out +} + +func diskDetail( + name *string, role string, lun, sizeGB *int32, + managed *armcompute.ManagedDiskParameters, caching, createOption string, +) DiskDetail { + d := DiskDetail{ + Name: derefStr(name), + Role: role, + Lun: lun, + SizeGB: sizeGB, + Caching: caching, + CreateOption: createOption, + } + if managed != nil { + d.ManagedDiskID = derefStr(managed.ID) + if managed.StorageAccountType != nil { + d.StorageType = string(*managed.StorageAccountType) + } + } + return d +} diff --git a/cli/internal/azure/describe_test.go b/cli/internal/azure/describe_test.go new file mode 100644 index 00000000..81bc6561 --- /dev/null +++ b/cli/internal/azure/describe_test.go @@ -0,0 +1,162 @@ +package azure + +import ( + "encoding/json" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v8" +) + +func strp(s string) *string { return &s } +func i32p(i int32) *int32 { return &i } + +// The disk mapping is the half of DescribeInstance that needs no ARM +// transport: everything it reports comes off the VM's own StorageProfile, which +// is why describing a VM's disks costs no request beyond the VM Get. +func TestDisksFromProfileReadsOSAndDataDisks(t *testing.T) { + premium := armcompute.StorageAccountTypesPremiumLRS + caching := armcompute.CachingTypesReadWrite + create := armcompute.DiskCreateOptionTypesFromImage + + sp := &armcompute.StorageProfile{ + OSDisk: &armcompute.OSDisk{ + Name: strp("dc01-osdisk"), + DiskSizeGB: i32p(128), + Caching: &caching, + CreateOption: &create, + ManagedDisk: &armcompute.ManagedDiskParameters{ + ID: strp("/subscriptions/s/…/disks/dc01-osdisk"), + StorageAccountType: &premium, + }, + }, + DataDisks: []*armcompute.DataDisk{ + {Name: strp("dc01-data0"), Lun: i32p(0), DiskSizeGB: i32p(512)}, + nil, // the SDK yields pointers; a nil entry must not panic + }, + } + + got := disksFromProfile(sp) + if len(got) != 2 { + t.Fatalf("disks = %d, want 2 (os + one data, nil skipped): %+v", len(got), got) + } + + os := got[0] + if os.Role != "os" || os.Name != "dc01-osdisk" { + t.Errorf("os disk = %+v", os) + } + if os.SizeGB == nil || *os.SizeGB != 128 { + t.Errorf("os size = %v, want 128", os.SizeGB) + } + if os.StorageType != "Premium_LRS" { + t.Errorf("storage type = %q, want Premium_LRS", os.StorageType) + } + if os.Caching != "ReadWrite" || os.CreateOption != "FromImage" { + t.Errorf("caching/create = %q/%q", os.Caching, os.CreateOption) + } + // The OS disk is not addressed by LUN; reporting 0 would imply it is. + if os.Lun != nil { + t.Errorf("os disk carries a LUN: %v", *os.Lun) + } + + data := got[1] + if data.Role != "data" || data.Lun == nil || *data.Lun != 0 { + t.Errorf("data disk = %+v", data) + } +} + +func TestDisksFromProfileHandlesAbsentProfile(t *testing.T) { + // A VM Get can come back without a storage profile. The panel renders the + // list directly, so this must be an empty array rather than nil — nil + // marshals to `null` and the UI would have to guard every field. + for _, sp := range []*armcompute.StorageProfile{nil, {}} { + got := disksFromProfile(sp) + if got == nil { + t.Fatal("disks = nil, want an empty slice") + } + if len(got) != 0 { + t.Errorf("disks = %+v, want empty", got) + } + b, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + if string(b) != "[]" { + t.Errorf("marshals to %s, want []", b) + } + } +} + +// The console reads this payload, so the field names are an interface. +func TestInstanceDetailMarshalsStableFieldNames(t *testing.T) { + b, err := json.Marshal(&InstanceDetail{ + ID: "/subscriptions/s/…/dc01", Name: "dc01", ResourceGroup: "rg", + Disks: []DiskDetail{}, NICs: []NICDetail{}, + }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `"id"`, `"name"`, `"resource_group"`, `"disks":[]`, `"nics":[]`, + } { + if !contains(string(b), want) { + t.Errorf("payload %s is missing %s", b, want) + } + } + // Optional fields stay out of the payload when unset rather than + // rendering as empty strings the panel would have to filter. + for _, absent := range []string{`"location"`, `"vm_size"`, `"power_state"`} { + if contains(string(b), absent) { + t.Errorf("payload %s should omit %s when unset", b, absent) + } + } +} + +func contains(haystack, needle string) bool { + return len(haystack) >= len(needle) && + (haystack == needle || indexOf(haystack, needle) >= 0) +} + +func indexOf(h, n string) int { + for i := 0; i+len(n) <= len(h); i++ { + if h[i:i+len(n)] == n { + return i + } + } + return -1 +} + +// PowerState was declared and rendered by the console panel while the Get that +// should populate it passed nil options, so the field was permanently empty. +// These pin the mapping now that the instance view is actually requested. +func TestPowerStateOfPicksTheStatusByPrefix(t *testing.T) { + view := &armcompute.VirtualMachineInstanceView{ + Statuses: []*armcompute.InstanceViewStatus{ + // Provisioning state comes first in real responses; matching by + // position instead of prefix would report "succeeded" as the power. + {Code: strp("ProvisioningState/succeeded")}, + nil, + {Code: nil}, + {Code: strp("PowerState/deallocated")}, + }, + } + // "stopped", not "deallocated": the panel must agree with the graph node + // beside it, which shows the same normalized vocabulary from ListInstances. + if got := powerStateOf(view); got != "stopped" { + t.Errorf("powerStateOf = %q, want stopped", got) + } +} + +func TestPowerStateOfIsEmptyWhenUnknown(t *testing.T) { + // Absent instance view, and a view carrying no power status at all: both + // must yield "" so omitempty drops the field rather than the panel + // asserting a state Azure never reported. + if got := powerStateOf(nil); got != "" { + t.Errorf("powerStateOf(nil) = %q, want empty", got) + } + view := &armcompute.VirtualMachineInstanceView{ + Statuses: []*armcompute.InstanceViewStatus{{Code: strp("ProvisioningState/updating")}}, + } + if got := powerStateOf(view); got != "" { + t.Errorf("powerStateOf = %q, want empty", got) + } +} diff --git a/cli/internal/azure/provider.go b/cli/internal/azure/provider.go index 92451677..a195f0e8 100644 --- a/cli/internal/azure/provider.go +++ b/cli/internal/azure/provider.go @@ -113,14 +113,38 @@ func (p *AzureProvider) runner() *winrmRunner { return p.winrm } +// RunCommand goes over WinRM through the bastion tunnel. It is the fast path +// used by fan-out callers (validate, health-check, verify-trusts) and requires +// the host to be answering on 5985. For a host too broken to do that, use +// RunCommandOutOfBand. func (p *AzureProvider) RunCommand(ctx context.Context, instanceID, command string, timeout time.Duration) (*provider.CommandResult, error) { res, err := p.runner().runPS(ctx, instanceID, command, timeout) if err != nil { return nil, err } - return &provider.CommandResult{Status: res.Status, Stdout: res.Stdout, Stderr: res.Stderr}, nil + return provider.CleanResult( + &provider.CommandResult{Status: res.Status, Stdout: res.Stdout, Stderr: res.Stderr}, + ), nil +} + +// RunCommandOutOfBand executes via Azure Managed Run Command — the ARM control +// plane, reaching the VM through its guest agent with no in-guest listener and +// no bastion tunnel. Slower than WinRM (~5-15s per call, output capped at 4096 +// bytes per stream), and the only channel that survives a host whose WinRM has +// stopped answering. +func (p *AzureProvider) RunCommandOutOfBand(ctx context.Context, instanceID, command string, timeout time.Duration) (*provider.CommandResult, error) { + res, err := p.client.RunPowerShellCommand(ctx, instanceID, command, timeout) + if err != nil { + return nil, err + } + return provider.CleanResult( + &provider.CommandResult{Status: res.Status, Stdout: res.Stdout, Stderr: res.Stderr}, + ), nil } +// OutOfBandChannel implements provider.OutOfBandRunner. +func (p *AzureProvider) OutOfBandChannel() string { return "Azure Run Command" } + func (p *AzureProvider) RunCommandOnMultiple(ctx context.Context, instanceIDs []string, command string, timeout time.Duration) (map[string]*provider.CommandResult, error) { type result struct { id string @@ -170,6 +194,7 @@ var ( _ provider.Provider = (*AzureProvider)(nil) _ provider.InteractiveShell = (*AzureProvider)(nil) _ provider.Drainer = (*AzureProvider)(nil) + _ provider.SecurityChecker = (*AzureProvider)(nil) ) func toProviderInstance(i Instance) provider.Instance { @@ -178,6 +203,8 @@ func toProviderInstance(i Instance) provider.Instance { Name: i.Name, PrivateIP: i.PrivateIP, State: i.State, + Account: i.SubscriptionID, + Group: i.ResourceGroup, Tags: i.Tags, } } diff --git a/cli/internal/azure/security.go b/cli/internal/azure/security.go new file mode 100644 index 00000000..47b4793b --- /dev/null +++ b/cli/internal/azure/security.go @@ -0,0 +1,350 @@ +package azure + +import ( + "context" + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v8" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v10" + "github.com/dreadnode/dreadgoad/internal/provider" +) + +type vmNICInfo struct { + vmName string + tags map[string]string + nics []NICDetail + vm *armcompute.VirtualMachine +} + +// SecurityCheck audits the network security posture of a deployed range. +func (p *AzureProvider) SecurityCheck(ctx context.Context, env, vpcCIDR string) ([]provider.SecurityCheckResult, error) { + c := p.client + if err := c.ensureSDK(ctx); err != nil { + return nil, err + } + + instances, err := c.DiscoverInstances(ctx, env, true) + if err != nil { + return nil, fmt.Errorf("discover instances: %w", err) + } + if len(instances) == 0 { + return nil, fmt.Errorf("no instances found for env=%s", env) + } + + rg := instances[0].ResourceGroup + vmInfos := collectVMNICInfo(ctx, c, instances) + nsgMap, err := listSecurityGroups(ctx, c, rg) + if err != nil { + return nil, err + } + + results := publicIPChecks(vmInfos) + results = append(results, nsgPresenceChecks(vmInfos, nsgMap)...) + results = append(results, nsgRuleChecks(nsgMap, vpcCIDR)...) + results = append(results, bastionCheck(rg, bastionExists(ctx, c, rg))) + results = append(results, sshKeyAuthChecks(vmInfos)...) + return results, nil +} + +func collectVMNICInfo(ctx context.Context, c *Client, instances []Instance) []vmNICInfo { + var vmInfos []vmNICInfo + for _, instance := range instances { + rid, err := arm.ParseResourceID(instance.ID) + if err != nil { + continue + } + view := armcompute.InstanceViewTypesInstanceView + resp, err := c.vmClient.Get(ctx, rid.ResourceGroupName, rid.Name, + &armcompute.VirtualMachinesClientGetOptions{Expand: &view}) + if err != nil { + continue + } + vm := resp.VirtualMachine + info := vmNICInfo{vmName: instance.Name, tags: instance.Tags, vm: &vm} + if vm.Properties != nil && vm.Properties.NetworkProfile != nil { + info.nics = collectNICDetails(ctx, c, vm.Properties.NetworkProfile.NetworkInterfaces) + } + vmInfos = append(vmInfos, info) + } + return vmInfos +} + +func collectNICDetails(ctx context.Context, c *Client, refs []*armcompute.NetworkInterfaceReference) []NICDetail { + var nics []NICDetail + for _, ref := range refs { + if ref == nil || ref.ID == nil { + continue + } + nic, err := c.describeNIC(ctx, *ref.ID) + if err != nil { + nics = append(nics, NICDetail{ID: *ref.ID, Name: nicNameOf(*ref.ID)}) + continue + } + nics = append(nics, *nic) + } + return nics +} + +func listSecurityGroups(ctx context.Context, c *Client, resourceGroup string) (map[string]*armnetwork.SecurityGroup, error) { + nsgs := make(map[string]*armnetwork.SecurityGroup) + pager := c.nsgClient.NewListPager(resourceGroup, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("list NSGs: %w", err) + } + for _, nsg := range page.Value { + if nsg != nil && nsg.ID != nil { + nsgs[strings.ToLower(*nsg.ID)] = nsg + } + } + } + return nsgs, nil +} + +func bastionExists(ctx context.Context, c *Client, resourceGroup string) bool { + pager := c.bastionClient.NewListByResourceGroupPager(resourceGroup, nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return false + } + if len(page.Value) > 0 { + return true + } + } + return false +} + +func publicIPChecks(vmInfos []vmNICInfo) []provider.SecurityCheckResult { + var results []provider.SecurityCheckResult + for _, info := range vmInfos { + role := strings.ToLower(info.tags["Role"]) + for _, nic := range info.nics { + switch { + case nic.PublicIPID == "": + results = append(results, securityResult( + "PublicIP", info.vmName, "OK", "critical", "no public IP attached")) + case role == "bastion": + results = append(results, securityResult( + "PublicIP", info.vmName, "OK", "critical", "public IP attached (bastion — expected)")) + default: + results = append(results, securityResult( + "PublicIP", info.vmName, "FAIL", "critical", + fmt.Sprintf("NIC %s has public IP %s", nic.Name, lastSegment(nic.PublicIPID)))) + } + } + } + return results +} + +func nsgPresenceChecks( + vmInfos []vmNICInfo, + nsgMap map[string]*armnetwork.SecurityGroup, +) []provider.SecurityCheckResult { + var results []provider.SecurityCheckResult + for _, info := range vmInfos { + for _, nic := range info.nics { + resource := info.vmName + "/" + nic.Name + switch { + case nic.NSGID != "": + results = append(results, securityResult( + "NSGPresent", resource, "OK", "critical", "NSG associated: "+lastSegment(nic.NSGID))) + case subnetHasNSG(nic.SubnetID, nsgMap): + results = append(results, securityResult( + "NSGPresent", resource, "OK", "critical", "subnet-level NSG covers this NIC")) + default: + results = append(results, securityResult( + "NSGPresent", resource, "FAIL", "critical", "no NSG on NIC or subnet")) + } + } + } + return results +} + +func nsgRuleChecks( + nsgMap map[string]*armnetwork.SecurityGroup, + vpcCIDR string, +) []provider.SecurityCheckResult { + var results []provider.SecurityCheckResult + for _, nsg := range nsgMap { + name := derefStr(nsg.Name) + rules := securityRulesOf(nsg) + results = append(results, + denyAllCheck(name, rules), + wildcardCheck(name, rules), + inboundSourceCheck(name, rules, vpcCIDR), + ) + } + return results +} + +func denyAllCheck(resource string, rules []inboundRule) provider.SecurityCheckResult { + if hasDenyAllInbound(rules) { + return securityResult("NSGDenyAll", resource, "OK", "critical", "DenyAllInbound rule present") + } + return securityResult("NSGDenyAll", resource, "FAIL", "critical", "no DenyAllInbound rule found") +} + +func wildcardCheck(resource string, rules []inboundRule) provider.SecurityCheckResult { + wildcards := wildcardAllowRules(rules) + if len(wildcards) == 0 { + return securityResult("NSGNoWild", resource, "OK", "high", "no wildcard/Internet inbound Allow rules") + } + return securityResult("NSGNoWild", resource, "FAIL", "high", + fmt.Sprintf("wildcard inbound Allow: %s", strings.Join(wildcards, ", "))) +} + +func inboundSourceCheck(resource string, rules []inboundRule, vpcCIDR string) provider.SecurityCheckResult { + unexpected := unexpectedSources(rules, vpcCIDR) + if len(unexpected) == 0 { + return securityResult("NSGInbound", resource, "OK", "high", "all inbound Allow sources are expected") + } + return securityResult("NSGInbound", resource, "WARN", "high", + fmt.Sprintf("unexpected inbound sources: %s", strings.Join(unexpected, ", "))) +} + +func bastionCheck(resource string, found bool) provider.SecurityCheckResult { + if found { + return securityResult("BastionExists", resource, "OK", "high", "Azure Bastion host found") + } + return securityResult("BastionExists", resource, "FAIL", "high", "no Azure Bastion found in resource group") +} + +func sshKeyAuthChecks(vmInfos []vmNICInfo) []provider.SecurityCheckResult { + var results []provider.SecurityCheckResult + for _, info := range vmInfos { + if info.vm == nil || info.vm.Properties == nil || info.vm.Properties.OSProfile == nil { + continue + } + linuxCfg := info.vm.Properties.OSProfile.LinuxConfiguration + if linuxCfg == nil { + continue + } + if linuxCfg.DisablePasswordAuthentication != nil && *linuxCfg.DisablePasswordAuthentication { + results = append(results, securityResult( + "SSHKeyAuth", info.vmName, "OK", "info", "password authentication disabled")) + continue + } + results = append(results, securityResult( + "SSHKeyAuth", info.vmName, "WARN", "info", "password authentication enabled on Linux VM")) + } + return results +} + +func securityResult(name, resource, status, severity, detail string) provider.SecurityCheckResult { + return provider.SecurityCheckResult{ + Name: name, Resource: resource, Status: status, Severity: severity, Detail: detail, + } +} + +// inboundRule is a flattened view of one NSG security rule's relevant fields. +type inboundRule struct { + name string + access string + direction string + sourcePrefix string + priority int32 +} + +func securityRulesOf(nsg *armnetwork.SecurityGroup) []inboundRule { + if nsg == nil || nsg.Properties == nil { + return nil + } + var rules []inboundRule + for _, r := range nsg.Properties.SecurityRules { + if r == nil || r.Properties == nil { + continue + } + p := r.Properties + if p.Direction == nil || p.Access == nil || string(*p.Direction) != "Inbound" { + continue + } + priority := int32(0) + if p.Priority != nil { + priority = *p.Priority + } + rules = append(rules, inboundRule{ + name: derefStr(r.Name), + access: string(*p.Access), + direction: string(*p.Direction), + sourcePrefix: derefStr(p.SourceAddressPrefix), + priority: priority, + }) + } + return rules +} + +func hasDenyAllInbound(rules []inboundRule) bool { + for _, r := range rules { + if strings.EqualFold(r.access, "Deny") && r.sourcePrefix == "*" { + return true + } + } + return false +} + +func wildcardAllowRules(rules []inboundRule) []string { + var names []string + for _, r := range rules { + if !strings.EqualFold(r.access, "Allow") { + continue + } + src := strings.ToLower(r.sourcePrefix) + if src == "*" || src == "internet" { + if !strings.EqualFold(r.name, "AllowAzureLoadBalancer") { + names = append(names, r.name) + } + } + } + return names +} + +func unexpectedSources(rules []inboundRule, vpcCIDR string) []string { + expected := map[string]bool{ + "*": true, // deny rules use * + "azureloadbalancer": true, + strings.ToLower(vpcCIDR): true, + "virtualnetwork": true, + } + var unexpected []string + for _, r := range rules { + if !strings.EqualFold(r.access, "Allow") { + continue + } + src := strings.ToLower(r.sourcePrefix) + if !expected[src] { + unexpected = append(unexpected, fmt.Sprintf("%s (rule %s)", r.sourcePrefix, r.name)) + } + } + return unexpected +} + +func subnetHasNSG(subnetID string, nsgMap map[string]*armnetwork.SecurityGroup) bool { + if subnetID == "" { + return false + } + for _, nsg := range nsgMap { + if nsg == nil || nsg.Properties == nil { + continue + } + for _, sub := range nsg.Properties.Subnets { + if sub != nil && sub.ID != nil && strings.EqualFold(*sub.ID, subnetID) { + return true + } + } + } + return false +} + +func lastSegment(resourceID string) string { + parts := strings.Split(resourceID, "/") + if len(parts) == 0 { + return resourceID + } + return parts[len(parts)-1] +} + +var _ provider.SecurityChecker = (*AzureProvider)(nil) diff --git a/cli/internal/azure/security_test.go b/cli/internal/azure/security_test.go new file mode 100644 index 00000000..d1bd700b --- /dev/null +++ b/cli/internal/azure/security_test.go @@ -0,0 +1,65 @@ +package azure + +import ( + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v10" +) + +func TestPublicIPChecks(t *testing.T) { + vmInfos := []vmNICInfo{ + {vmName: "dc01", nics: []NICDetail{{Name: "dc01-nic"}}}, + { + vmName: "bastion", + tags: map[string]string{"Role": "bastion"}, + nics: []NICDetail{{Name: "bastion-nic", PublicIPID: "/publicIPAddresses/bastion-pip"}}, + }, + { + vmName: "dc02", + tags: map[string]string{"Role": "dc"}, + nics: []NICDetail{{Name: "dc02-nic", PublicIPID: "/publicIPAddresses/dc02-pip"}}, + }, + } + + results := publicIPChecks(vmInfos) + wantStatuses := []string{"OK", "OK", "FAIL"} + if len(results) != len(wantStatuses) { + t.Fatalf("results = %d, want %d", len(results), len(wantStatuses)) + } + for i, want := range wantStatuses { + if results[i].Status != want { + t.Errorf("result %d status = %q, want %q", i, results[i].Status, want) + } + } +} + +func TestNSGPresenceChecks(t *testing.T) { + vmInfos := []vmNICInfo{{ + vmName: "dc01", + nics: []NICDetail{ + {Name: "protected", NSGID: "/networkSecurityGroups/dc01-nsg"}, + {Name: "unprotected"}, + }, + }} + + results := nsgPresenceChecks(vmInfos, nil) + if len(results) != 2 { + t.Fatalf("results = %d, want 2", len(results)) + } + if results[0].Status != "OK" || results[1].Status != "FAIL" { + t.Fatalf("statuses = [%s %s], want [OK FAIL]", results[0].Status, results[1].Status) + } +} + +func TestSecurityRulesOfSkipsRuleWithoutAccess(t *testing.T) { + direction := armnetwork.SecurityRuleDirectionInbound + nsg := &armnetwork.SecurityGroup{Properties: &armnetwork.SecurityGroupPropertiesFormat{ + SecurityRules: []*armnetwork.SecurityRule{{ + Properties: &armnetwork.SecurityRulePropertiesFormat{Direction: &direction}, + }}, + }} + + if rules := securityRulesOf(nsg); len(rules) != 0 { + t.Fatalf("rules = %v, want none", rules) + } +} diff --git a/cli/internal/azure/vm.go b/cli/internal/azure/vm.go index c303098f..19343d74 100644 --- a/cli/internal/azure/vm.go +++ b/cli/internal/azure/vm.go @@ -15,11 +15,14 @@ import ( // Instance represents a discovered Azure VM relevant to dreadgoad. type Instance struct { ID string // full Azure resource ID (used for run-command targeting) - Name string // VM resource name (e.g. "test-goad-dreadgoad-kingslanding-vm") + Name string // VM resource name (e.g. "test-dreadgoad-kingslanding-vm") ResourceGroup string - PrivateIP string - State string // "running", "stopped", etc. (normalized from PowerState/* ) - Tags map[string]string // Azure resource tags (Role, Lab, Project, Environment, …) + // SubscriptionID the VM lives in, parsed from its own resource ID rather + // than read from the client, so it reflects where the resource actually is. + SubscriptionID string + PrivateIP string + State string // "running", "stopped", etc. (normalized from PowerState/* ) + Tags map[string]string // Azure resource tags (Role, Lab, Project, Environment, …) } // DiscoverInstances finds GOAD VMs for the given env (running by default). @@ -138,12 +141,13 @@ func (c *Client) enrichInstance(ctx context.Context, vm *armcompute.VirtualMachi } return Instance{ - ID: id, - Name: name, - ResourceGroup: rid.ResourceGroupName, - PrivateIP: privateIP, - State: state, - Tags: stringMap(vm.Tags), + ID: id, + Name: name, + ResourceGroup: rid.ResourceGroupName, + SubscriptionID: rid.SubscriptionID, + PrivateIP: privateIP, + State: state, + Tags: stringMap(vm.Tags), }, nil } @@ -222,11 +226,17 @@ func (c *Client) WaitForInstanceStopped(ctx context.Context, id string) error { } deadline := time.Now().Add(5 * time.Minute) - for time.Now().Before(deadline) { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(10 * time.Second): + for first := true; time.Now().Before(deadline); first = false { + // Check before sleeping. The caller may already have waited for the + // deallocate to finish — StopInstances does exactly that on Azure — and + // sleeping first charged 10 seconds to confirm a state that was already + // true. Only wait between polls, not before the first one. + if !first { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(10 * time.Second): + } } resp, err := c.vmClient.InstanceView(ctx, rid.ResourceGroupName, rid.Name, nil) if err != nil { diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 1b276ea0..2bd6d8f1 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -3,14 +3,19 @@ package config import ( "errors" "fmt" + "log/slog" "os" "path/filepath" + "reflect" + "strings" "sync" "time" "github.com/dreadnode/dreadgoad/internal/inventory" "github.com/dreadnode/dreadgoad/internal/jsonmerge" + "github.com/go-viper/mapstructure/v2" "github.com/spf13/viper" + "gopkg.in/yaml.v3" ) // ExtensionConfig holds metadata for a lab extension. @@ -181,6 +186,9 @@ func Get() (*Config, error) { initErr = fmt.Errorf("unmarshaling config: %w", err) return } + // Must run before anything reads Environments: viper mangles any + // environment name containing a dot. + repairDottedEnvironmentKeys(cfg) if cfg.ProjectRoot == "" { root, err := findProjectRoot() @@ -372,6 +380,93 @@ func (c *Config) ActiveEnvironment() EnvironmentConfig { return c.Environments[c.Env] } +// repairDottedEnvironmentKeys reloads the `environments` map straight from the +// config file, replacing whatever viper produced for it. +// +// Viper splits every key on ".", so an environment named "3.1" is stored as +// nested keys "3" → "1" and never appears in Environments under its own name. +// The lookup in ActiveEnvironment then returns a zero EnvironmentConfig, whose +// Variant field is false — and a variant environment silently resolves to the +// stock lab tree instead. Nothing errors: the range deploys from the wrong lab +// config, with machine passwords the inventory does not have, and only fails +// much later at WinRM authentication. +// +// Raising viper's key delimiter out of the way would fix the lookup but break +// the extension defaults, which are *constructed* from dotted keys +// ("extensions.elk.playbook") and collapse into unreachable flat keys without +// it. So the repair is scoped to this one map. +// +// File values win over viper's, and any environment viper resolved that the +// file does not define — notably the built-in dev/staging/prod defaults — is +// preserved. +func repairDottedEnvironmentKeys(c *Config) { + path := viper.ConfigFileUsed() + if path == "" { + return // defaults only; nothing on disk to re-read + } + data, err := os.ReadFile(path) + if err != nil { + slog.Warn("could not re-read config for environment names", "path", path, "error", err) + return + } + // Decoded as raw maps and converted with mapstructure so the field names + // stay defined in exactly one place: the mapstructure tags above. A second + // set of yaml tags would be free to drift out of sync with them. + var file struct { + Environments map[string]map[string]any `yaml:"environments"` + } + if err := yaml.Unmarshal(data, &file); err != nil { + slog.Warn("could not parse config for environment names", "path", path, "error", err) + return + } + if len(file.Environments) == 0 { + return + } + if c.Environments == nil { + c.Environments = make(map[string]EnvironmentConfig, len(file.Environments)) + } + for name, raw := range file.Environments { + var ec EnvironmentConfig + if err := mapstructure.Decode(raw, &ec); err != nil { + slog.Warn("could not decode environment", "env", name, "error", err) + continue + } + c.Environments[name] = ec + } + dropViperKeyFragments(c.Environments, file.Environments) +} + +// dropViperKeyFragments removes the partial entries viper leaves behind when it +// splits a dotted environment name. +// +// Reading "3.1" as environments → 3 → 1 does not just lose the real name, it +// invents "3" as an environment in its own right. That fragment shows up in +// `config show` as a range nobody created, and `--env 3` would quietly resolve +// it to an all-defaults environment — the same silent wrong-config failure this +// whole repair exists to stop. +// +// Only a fragment is removed: the key must be the leading segment of a dotted +// name from the file, must not itself be named in the file, and must still hold +// the zero value. A real environment that happens to be called "3" is defined in +// the file and therefore kept. +func dropViperKeyFragments(resolved map[string]EnvironmentConfig, fromFile map[string]map[string]any) { + for name := range fromFile { + i := strings.Index(name, ".") + if i <= 0 { + continue + } + fragment := name[:i] + if _, definedInFile := fromFile[fragment]; definedInFile { + continue + } + // reflect rather than ==: EnvironmentConfig carries a slice, so it is not + // a comparable type. + if existing, ok := resolved[fragment]; ok && reflect.DeepEqual(existing, EnvironmentConfig{}) { + delete(resolved, fragment) + } + } +} + // ResolvedVariantPaths returns absolute source/target paths for the active // environment's variant config. Returns empty strings if variant is false. func (c *Config) ResolvedVariantPaths() (source, target string) { diff --git a/cli/internal/config/dotted_env_test.go b/cli/internal/config/dotted_env_test.go new file mode 100644 index 00000000..27d87420 --- /dev/null +++ b/cli/internal/config/dotted_env_test.go @@ -0,0 +1,286 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" +) + +// loadConfigFile drives the real loader path: viper reads the file, unmarshals, +// and the repair runs. It bypasses Get()'s sync.Once so each case is isolated. +func loadConfigFile(t *testing.T, body string) *Config { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "dreadgoad.yaml") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + viper.Reset() + t.Cleanup(viper.Reset) + viper.SetConfigFile(path) + setDefaults() + if err := viper.ReadInConfig(); err != nil { + t.Fatal(err) + } + c := &Config{} + if err := viper.Unmarshal(c); err != nil { + t.Fatal(err) + } + repairDottedEnvironmentKeys(c) + return c +} + +// The exact shape the console wrote for the failing range. Before the repair, +// viper stored this as nested keys "3" -> "1" and the lookup missed, so a +// variant environment resolved to the stock lab tree with no error anywhere. +func TestDottedEnvironmentNameResolves(t *testing.T) { + c := loadConfigFile(t, ` +provider: azure +environments: + '3.1': + variant: true + variant_source: ad/GOAD + variant_target: ad/GOAD-3.1 + variant_name: '3.1' + vpc_cidr: 10.100.0.0/16 +`) + c.Env = "3.1" + ec := c.ActiveEnvironment() + if !ec.Variant { + t.Fatal("variant is false for '3.1' — the whole variant pipeline would use the stock lab") + } + if ec.VariantTarget != "ad/GOAD-3.1" { + t.Errorf("variant_target = %q, want ad/GOAD-3.1", ec.VariantTarget) + } + if ec.VpcCidr != "10.100.0.0/16" { + t.Errorf("vpc_cidr = %q — non-variant fields must survive too", ec.VpcCidr) + } +} + +// The mechanism that actually broke the range: labConfigDataDir consults +// ActiveEnvironment().Variant, so a mangled key sent it to the stock ad/GOAD +// tree. materializeLabConfig then copied the stock config to the path +// terragrunt reads, and the machines were built with stock passwords the +// inventory did not have. +func TestDottedEnvResolvesToTheVariantLabConfigDir(t *testing.T) { + c := loadConfigFile(t, ` +environments: + '3.1': + variant: true + variant_source: ad/GOAD + variant_target: ad/GOAD-3.1 +`) + root := t.TempDir() + c.ProjectRoot = root + c.Env = "3.1" + variantData := filepath.Join(root, "ad", "GOAD-3.1", "data") + if err := os.MkdirAll(variantData, 0o755); err != nil { + t.Fatal(err) + } + + got := c.labConfigDataDir() + if got != variantData { + t.Fatalf("labConfigDataDir() = %q, want the variant tree %q\n"+ + "this is the exact step that shipped stock passwords to the VMs", got, variantData) + } +} + +// The other real failing env: a dot plus a trailing capital, which also +// exercises the fact that viper lowercases keys. +func TestDottedEnvironmentNameWithSuffixResolves(t *testing.T) { + c := loadConfigFile(t, ` +environments: + 'dg-test-2.A': + variant: true + variant_target: ad/GOAD-dg-test-2.A + region: eastus +`) + c.Env = "dg-test-2.A" + ec := c.ActiveEnvironment() + if !ec.Variant || ec.VariantTarget != "ad/GOAD-dg-test-2.A" { + t.Fatalf("dg-test-2.A did not resolve: %+v", ec) + } + if ec.Region != "eastus" { + t.Errorf("region = %q, want eastus", ec.Region) + } +} + +// Splitting "3.1" does not merely lose the name, it invents "3" as an +// environment of its own. Left in place it shows up in `config show` as a range +// nobody created, and `--env 3` would resolve it to an all-defaults environment +// — the same silent wrong-config failure the repair exists to stop. +func TestViperKeyFragmentIsRemoved(t *testing.T) { + c := loadConfigFile(t, ` +environments: + '3.1': + variant: true + variant_target: ad/GOAD-3.1 +`) + if _, ok := c.Environments["3"]; ok { + t.Errorf("the fragment \"3\" survived: %v", envNames(c.Environments)) + } + if _, ok := c.Environments["3.1"]; !ok { + t.Error("the real environment was removed along with the fragment") + } +} + +// A fragment that the file actually defines is a real environment and must +// survive, even though it is also the prefix of a dotted sibling. +func TestRealEnvironmentSharingAFragmentNameSurvives(t *testing.T) { + c := loadConfigFile(t, ` +environments: + '3': + variant: true + variant_target: ad/GOAD-3 + '3.1': + variant: true + variant_target: ad/GOAD-3.1 +`) + three, ok := c.Environments["3"] + if !ok { + t.Fatal("a real environment named \"3\" was deleted as a fragment") + } + if three.VariantTarget != "ad/GOAD-3" { + t.Errorf("environment \"3\" = %+v, want its own variant_target", three) + } + if c.Environments["3.1"].VariantTarget != "ad/GOAD-3.1" { + t.Error("the dotted sibling was clobbered") + } +} + +func envNames(m map[string]EnvironmentConfig) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +// Undotted names went through viper correctly before; they must keep working. +func TestUndottedEnvironmentNameStillResolves(t *testing.T) { + c := loadConfigFile(t, ` +environments: + dreadindex: + variant: true + variant_target: ad/GOAD-dreadindex + region: eastus +`) + c.Env = "dreadindex" + ec := c.ActiveEnvironment() + if !ec.Variant || ec.VariantTarget != "ad/GOAD-dreadindex" || ec.Region != "eastus" { + t.Fatalf("dreadindex regressed: %+v", ec) + } +} + +// The repair replaces the environments map wholesale, so the built-in +// dev/staging/prod defaults must survive a file that names none of them. +func TestBuiltInEnvironmentDefaultsSurviveTheRepair(t *testing.T) { + c := loadConfigFile(t, ` +environments: + '3.1': + variant: true + variant_target: ad/GOAD-3.1 +`) + for _, name := range []string{"dev", "staging", "prod"} { + if _, ok := c.Environments[name]; !ok { + t.Errorf("built-in environment %q was dropped by the repair", name) + } + } + if _, ok := c.Environments["3.1"]; !ok { + t.Error("the file's own environment is missing") + } +} + +// A file entry must win over a same-named default rather than merging into it. +func TestFileEnvironmentOverridesDefault(t *testing.T) { + c := loadConfigFile(t, ` +environments: + staging: + variant: true + variant_target: ad/GOAD-custom +`) + c.Env = "staging" + ec := c.ActiveEnvironment() + if !ec.Variant || ec.VariantTarget != "ad/GOAD-custom" { + t.Fatalf("file did not override the built-in staging default: %+v", ec) + } +} + +// The delimiter change rejected in favour of this repair would have flattened +// extensions.* into unreachable keys. Guard that they still load. +func TestExtensionDefaultsAreUnaffected(t *testing.T) { + c := loadConfigFile(t, ` +environments: + '3.1': + variant: true +`) + if len(c.Extensions) == 0 { + t.Fatal("extension defaults vanished — the nested extensions.* keys broke") + } + elk, ok := c.Extensions["elk"] + if !ok || elk.Playbook == "" { + t.Errorf("elk extension did not load: %+v", c.Extensions) + } +} + +// A config with no environments key must leave viper's result alone. +func TestRepairIsANoOpWithoutEnvironments(t *testing.T) { + c := loadConfigFile(t, "provider: azure\n") + if _, ok := c.Environments["staging"]; !ok { + t.Error("the repair discarded the defaults when the file had no environments") + } +} + +// Unparsable YAML must not wipe the map. Losing every environment silently is +// worse than keeping whatever viper managed to read. +func TestRepairKeepsViperResultOnUnparsableFile(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + dir := t.TempDir() + path := filepath.Join(dir, "dreadgoad.yaml") + if err := os.WriteFile(path, []byte("environments:\n dev:\n variant: true\n"), 0o644); err != nil { + t.Fatal(err) + } + viper.SetConfigFile(path) + if err := viper.ReadInConfig(); err != nil { + t.Fatal(err) + } + c := &Config{Environments: map[string]EnvironmentConfig{"kept": {Variant: true}}} + + // Corrupt the file after viper read it, then repair. + if err := os.WriteFile(path, []byte("environments: [oh no\n - :"), 0o644); err != nil { + t.Fatal(err) + } + repairDottedEnvironmentKeys(c) + + if _, ok := c.Environments["kept"]; !ok { + t.Error("a corrupt file wiped environments that were already resolved") + } +} + +// A deleted config file must not panic or clear the map. +func TestRepairSurvivesAMissingFile(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + viper.SetConfigFile(filepath.Join(t.TempDir(), "gone.yaml")) + c := &Config{Environments: map[string]EnvironmentConfig{"kept": {Variant: true}}} + repairDottedEnvironmentKeys(c) + if _, ok := c.Environments["kept"]; !ok { + t.Error("a missing file cleared the environments map") + } +} + +// With no config file at all, ConfigFileUsed() is empty and the repair must +// return before touching anything. +func TestRepairIsANoOpWithNoConfigFile(t *testing.T) { + viper.Reset() + t.Cleanup(viper.Reset) + c := &Config{Environments: map[string]EnvironmentConfig{"kept": {Variant: true}}} + repairDottedEnvironmentKeys(c) + if len(c.Environments) != 1 { + t.Errorf("environments changed with no config file: %+v", c.Environments) + } +} diff --git a/cli/internal/provider/provider.go b/cli/internal/provider/provider.go index 4f1869fd..70231ab1 100644 --- a/cli/internal/provider/provider.go +++ b/cli/internal/provider/provider.go @@ -12,7 +12,16 @@ type Instance struct { Name string PrivateIP string State string // "running", "stopped", etc. - Tags map[string]string + // Account is the cloud account the instance belongs to: the AWS account ID + // or the Azure subscription ID. Both come from data already fetched during + // discovery, so populating it costs no extra API call. Empty when the + // provider cannot determine it. + Account string + // Group is the provider's resource container: an Azure resource group. + // Empty on providers that have no such concept — AWS has none, where a + // range is identified by tag convention rather than by containment. + Group string + Tags map[string]string } // FindInstanceByRole returns the first instance whose Role tag matches role. @@ -105,6 +114,28 @@ type InteractiveShell interface { StartInteractiveShell(ctx context.Context, instanceID, region string) error } +// OutOfBandRunner is an optional interface for providers that can execute a +// script WITHOUT an in-guest network listener — via the cloud control plane +// (Azure Run Command, AWS SSM) rather than WinRM/SSH. +// +// This is deliberately separate from Provider.RunCommand. On Azure the latter +// goes over WinRM through a bastion tunnel: fast and fine for fan-out checks +// like validate and health-check, but useless for the one case that matters +// here — a host that is broken badly enough to stop answering on 5985. The +// control plane reaches the VM through its guest agent, so it still works. +// +// AWS's RunCommand is already SSM-backed and so is already out-of-band; it +// implements this by delegating. Callers that need the guarantee must type- +// assert for this interface rather than assuming RunCommand provides it. +type OutOfBandRunner interface { + // RunCommandOutOfBand executes a script via the control plane and reports + // which channel served it, for surfacing to the operator. + RunCommandOutOfBand(ctx context.Context, instanceID, command string, timeout time.Duration) (*CommandResult, error) + + // OutOfBandChannel names the mechanism (e.g. "Azure Run Command", "AWS SSM"). + OutOfBandChannel() string +} + // Session represents an active remote session. type Session struct { SessionID string @@ -137,3 +168,28 @@ type SSMStatus struct { InstanceID string PingStatus string } + +// SecurityCheckResult is one check's outcome in the security report. +type SecurityCheckResult struct { + Name string `json:"name"` + Resource string `json:"resource"` + Status string `json:"status"` // "OK", "FAIL", "WARN", "SKIP" + Severity string `json:"severity"` // "critical", "high", "info" + Detail string `json:"detail"` +} + +// SecurityReport is the --json payload for security-check. +type SecurityReport struct { + Passed int `json:"passed"` + Failed int `json:"failed"` + Warned int `json:"warned"` + Skipped int `json:"skipped"` + Checks []SecurityCheckResult `json:"checks"` +} + +// SecurityChecker is an optional interface for providers that can audit +// the network security posture of a deployed range. vpcCIDR is the +// expected VNet/VPC address space from the config. +type SecurityChecker interface { + SecurityCheck(ctx context.Context, env, vpcCIDR string) ([]SecurityCheckResult, error) +} diff --git a/cli/internal/provider/text.go b/cli/internal/provider/text.go new file mode 100644 index 00000000..ee0126f0 --- /dev/null +++ b/cli/internal/provider/text.go @@ -0,0 +1,79 @@ +package provider + +import ( + "strings" + "unicode/utf16" + "unicode/utf8" +) + +// DecodeWindowsText normalises text captured from a Windows host. +// +// Windows PowerShell writes its own fatal-error banner to stderr as UTF-16LE +// while the transports (WinRM, Azure Run Command, SSM) hand it back as opaque +// bytes. Read as UTF-8 that becomes NUL-interleaved mojibake — "W\x00i\x00n…" +// — which renders in a chat pane as "W i n d o w s", roughly doubles the token +// cost of every such message, and defeats any parsing done on it. +// +// The heuristic is narrow on purpose: only text that is *mostly* NUL bytes in +// alternating position is treated as UTF-16, so legitimate output containing an +// occasional NUL is left alone. Text that is already valid UTF-8 without NULs +// is returned unchanged, so this is safe to apply unconditionally. +func DecodeWindowsText(s string) string { + if s == "" || !strings.Contains(s, "\x00") { + return s + } + + b := []byte(s) + // A UTF-16LE run of ASCII has a NUL in every odd byte. Require most of the + // odd positions to be NUL and most of the even ones not to be, which a + // UTF-8 string carrying a stray NUL will not satisfy. + var oddNUL, evenNUL, odd, even int + for i, c := range b { + if i%2 == 1 { + odd++ + if c == 0 { + oddNUL++ + } + } else { + even++ + if c == 0 { + evenNUL++ + } + } + } + if odd == 0 || oddNUL*4 < odd*3 || evenNUL*4 > even { + return stripNULs(s) + } + + // Drop a trailing odd byte so pairing can't run off the end. + if len(b)%2 == 1 { + b = b[:len(b)-1] + } + units := make([]uint16, 0, len(b)/2) + for i := 0; i < len(b); i += 2 { + units = append(units, uint16(b[i])|uint16(b[i+1])<<8) + } + decoded := string(utf16.Decode(units)) + if !utf8.ValidString(decoded) { + return stripNULs(s) + } + return decoded +} + +// stripNULs removes NUL bytes from text that isn't UTF-16. They are never +// meaningful in command output and a raw NUL breaks JSON consumers downstream. +func stripNULs(s string) string { + return strings.ReplaceAll(s, "\x00", "") +} + +// CleanResult normalises a CommandResult's captured streams in place-safe +// fashion, returning the same pointer for convenient chaining. Nil is passed +// through so callers don't have to guard. +func CleanResult(r *CommandResult) *CommandResult { + if r == nil { + return nil + } + r.Stdout = DecodeWindowsText(r.Stdout) + r.Stderr = DecodeWindowsText(r.Stderr) + return r +} diff --git a/cli/internal/provider/text_test.go b/cli/internal/provider/text_test.go new file mode 100644 index 00000000..0bfc2935 --- /dev/null +++ b/cli/internal/provider/text_test.go @@ -0,0 +1,79 @@ +package provider + +import ( + "strings" + "testing" +) + +// utf16le encodes ASCII the way Windows PowerShell writes its fatal banner. +func utf16le(s string) string { + var b strings.Builder + for _, r := range s { + b.WriteByte(byte(r)) + b.WriteByte(0) + } + return b.String() +} + +// The exact payload observed from DC02 via /exec. +func TestDecodeWindowsTextRealPowerShellBanner(t *testing.T) { + want := "Windows PowerShell terminated with the following error: \r\n " + + "Could not load file or assembly 'System.Management.Automation'. " + + "The paging file is too small for this operation to complete." + got := DecodeWindowsText(utf16le(want)) + if got != want { + t.Fatalf("decode mismatch:\n got %q\nwant %q", got, want) + } + if strings.Contains(got, "\x00") { + t.Fatal("NULs survived decoding") + } +} + +// Ordinary output must be returned byte-identical — this runs on every result. +func TestDecodeWindowsTextLeavesPlainTextAlone(t *testing.T) { + for _, s := range []string{ + "", + "Status : Stopped", + "Running dreadindex-dreadgoad-DC02-vm 10.1.1.7", + "unicode: ünïcode ✓ 日本語", + strings.Repeat("A", 4096), + } { + if got := DecodeWindowsText(s); got != s { + t.Fatalf("plain text altered:\n got %q\nwant %q", got, s) + } + } +} + +// A stray NUL in otherwise-UTF-8 text must not trigger UTF-16 decoding; the +// NUL is dropped because it breaks JSON consumers, but the text survives. +func TestDecodeWindowsTextStrayNUL(t *testing.T) { + got := DecodeWindowsText("service stopped\x00 unexpectedly") + if got != "service stopped unexpectedly" { + t.Fatalf("got %q", got) + } +} + +// Odd-length input must not panic or lose the message. +func TestDecodeWindowsTextOddLength(t *testing.T) { + got := DecodeWindowsText(utf16le("hello") + "\x41") + if !strings.Contains(got, "hello") { + t.Fatalf("got %q", got) + } +} + +func TestCleanResultHandlesNilAndBothStreams(t *testing.T) { + if CleanResult(nil) != nil { + t.Fatal("nil must pass through") + } + r := CleanResult(&CommandResult{ + Status: "Failed", + Stdout: utf16le("out"), + Stderr: utf16le("err"), + }) + if r.Stdout != "out" || r.Stderr != "err" { + t.Fatalf("got stdout=%q stderr=%q", r.Stdout, r.Stderr) + } + if r.Status != "Failed" { + t.Fatalf("status must be untouched, got %q", r.Status) + } +} diff --git a/cli/internal/terraform/runner.go b/cli/internal/terraform/runner.go index 3e2d7cb3..d506f491 100644 --- a/cli/internal/terraform/runner.go +++ b/cli/internal/terraform/runner.go @@ -55,6 +55,9 @@ func Run(ctx context.Context, opts Options) error { cmd.Stderr = writer if err := cmd.Run(); err != nil { + if ctx.Err() != nil { + return fmt.Errorf("terraform %s timed out: %w", opts.Action, ctx.Err()) + } return fmt.Errorf("terraform %s failed: %w", opts.Action, err) } return nil diff --git a/cli/internal/terragrunt/runner.go b/cli/internal/terragrunt/runner.go index 06b41b7b..d50c1209 100644 --- a/cli/internal/terragrunt/runner.go +++ b/cli/internal/terragrunt/runner.go @@ -69,6 +69,10 @@ func Run(ctx context.Context, opts Options) error { cmd.Stderr = cmd.Stdout if err := cmd.Run(); err != nil { + if ctx.Err() != nil { + return fmt.Errorf("terragrunt %s timed out (context: %w); last output:\n%s", + opts.Action, ctx.Err(), lastLines(tail.String(), 20)) + } return commandError(fmt.Sprintf("terragrunt %s failed", opts.Action), err, tail.String(), opts.TerragruntBinary) } return nil @@ -114,6 +118,10 @@ func RunAll(ctx context.Context, opts Options) error { cmd.Stderr = cmd.Stdout if err := cmd.Run(); err != nil { + if ctx.Err() != nil { + return fmt.Errorf("terragrunt run --all %s timed out (context: %w); last output:\n%s", + opts.Action, ctx.Err(), lastLines(tail.String(), 20)) + } return commandError(fmt.Sprintf("terragrunt run --all %s failed", opts.Action), err, tail.String(), opts.TerragruntBinary) } return nil @@ -252,6 +260,14 @@ func (b *tailBuffer) String() string { return string(b.buf) } +func lastLines(s string, n int) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + if len(lines) <= n { + return strings.Join(lines, "\n") + } + return strings.Join(lines[len(lines)-n:], "\n") +} + func commandError(message string, runErr error, output, terragruntBinary string) error { lockID := stateLockID(output) if lockID == "" { diff --git a/cli/internal/terragrunt/sizes.go b/cli/internal/terragrunt/sizes.go new file mode 100644 index 00000000..d1af2d4f --- /dev/null +++ b/cli/internal/terragrunt/sizes.go @@ -0,0 +1,97 @@ +package terragrunt + +import ( + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Requested is the compute an environment's terragrunt tree asks for. +type Requested struct { + // Distinct VM sizes named anywhere in the tree, sorted. + Sizes []string + // Number of terragrunt units that deploy a VM. Each unit directory is one + // machine: the five lab hosts plus the controller, and kali when enabled. + Units int +} + +// instanceSizeRE matches `instance_size = "..."` and its prefixed forms +// (`controller_instance_size`, `kali_instance_size`) as written in env.hcl and +// unit files. Assignments that reference a local (`= local.controller_instance_size`) +// deliberately do not match: the literal they resolve to is declared in env.hcl, +// which this scan also reads, so the size is still collected exactly once. +var instanceSizeRE = regexp.MustCompile(`(?m)^\s*[a-z_]*instance_size\s*=\s*"([^"]+)"`) + +// unitSizeRE matches the bare `instance_size` input a unit passes to its +// module, whatever the right-hand side. Counting on the literal alone +// undercounts: the controller and kali units resolve theirs from env.hcl +// (`instance_size = local.controller_instance_size`) and would not be seen as +// machines at all. +var unitSizeRE = regexp.MustCompile(`(?m)^\s*instance_size\s*=`) + +// RequestedSizes reports the VM sizes an environment's terragrunt tree asks +// for, and how many VMs it deploys. +// +// Reads only `env.hcl` and `terragrunt.hcl`, and never descends into +// `.terragrunt-cache`: those caches hold full copies of the upstream modules, +// whose `variables.tf` defaults would otherwise be collected as sizes this +// range never requests. +// +// Text-scanned rather than HCL-evaluated on purpose. Evaluating would mean +// resolving locals, includes and dependency outputs — terragrunt's whole +// pipeline — to learn something the literals state plainly. The cost is that a +// size assembled by interpolation is missed, which is why callers should treat +// an empty result as "could not tell" rather than "nothing to check". +func RequestedSizes(envDir string) (Requested, error) { + seen := map[string]bool{} + var out Requested + + err := filepath.WalkDir(envDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + // Skip module caches and any VCS metadata that happens to sit here. + if d.Name() == ".terragrunt-cache" || d.Name() == ".git" { + return filepath.SkipDir + } + return nil + } + name := d.Name() + if name != "terragrunt.hcl" && name != "env.hcl" { + return nil + } + body, readErr := os.ReadFile(path) + if readErr != nil { + // An unreadable unit should not fail the whole scan; the caller + // reports on what was found and a missing size is not fatal. + return nil //nolint:nilerr // best-effort scan + } + matches := instanceSizeRE.FindAllStringSubmatch(string(body), -1) + for _, m := range matches { + size := strings.TrimSpace(m[1]) + if size != "" && !seen[size] { + seen[size] = true + out.Sizes = append(out.Sizes, size) + } + } + // One unit file that deploys a VM is one VM. env.hcl is shared + // configuration, not a unit, so it names sizes without adding a machine. + // + // Counts kali, which only deploys under --with-kali. Overstating by one + // is the safe direction for a capacity warning; understating would let a + // range through that does not fit. + if name == "terragrunt.hcl" && unitSizeRE.Match(body) { + out.Units++ + } + return nil + }) + if err != nil { + return Requested{}, err + } + sort.Strings(out.Sizes) + return out, nil +} diff --git a/cli/internal/terragrunt/sizes_test.go b/cli/internal/terragrunt/sizes_test.go new file mode 100644 index 00000000..c2cfbc28 --- /dev/null +++ b/cli/internal/terragrunt/sizes_test.go @@ -0,0 +1,153 @@ +package terragrunt + +import ( + "os" + "path/filepath" + "testing" +) + +// write creates dir/name with body, making parents as needed. +func write(t *testing.T, dir, name, body string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +// The shape of a real Azure env: literals in env.hcl for the units that resolve +// theirs from a local, literals in each lab host's unit file. +func TestRequestedSizesReadsTheEnvTree(t *testing.T) { + root := t.TempDir() + write(t, root, "env.hcl", `locals { + controller_instance_size = "Standard_D2s_v3" + kali_instance_size = "Standard_D4s_v3" +}`) + for _, host := range []string{"dc01", "dc02"} { + write(t, filepath.Join(root, "eastus", "goad", host), "terragrunt.hcl", `inputs = { + instance_size = "Standard_D2s_v3" +}`) + } + // controller and kali pass a local through rather than a literal — they are + // still machines, and counting only literals would miss them entirely. + write(t, filepath.Join(root, "eastus", "controller"), "terragrunt.hcl", + "inputs = {\n instance_size = local.controller_instance_size\n}") + write(t, filepath.Join(root, "eastus", "kali"), "terragrunt.hcl", + "inputs = {\n instance_size = local.kali_instance_size\n}") + + got, err := RequestedSizes(root) + if err != nil { + t.Fatal(err) + } + want := []string{"Standard_D2s_v3", "Standard_D4s_v3"} + if len(got.Sizes) != len(want) { + t.Fatalf("sizes = %v, want %v", got.Sizes, want) + } + for i := range want { + if got.Sizes[i] != want[i] { + t.Errorf("sizes = %v, want %v (sorted)", got.Sizes, want) + } + } + if got.Units != 4 { + t.Errorf("units = %d, want 4 (2 hosts + controller + kali)", got.Units) + } +} + +// The cache holds full copies of the upstream modules. Their variables.tf +// defaults name sizes this range never asked for, and an earlier version of +// this scan collected them. +func TestRequestedSizesIgnoresTerragruntCache(t *testing.T) { + root := t.TempDir() + write(t, root, "env.hcl", `locals { + controller_instance_size = "Standard_D2s_v3" +}`) + cache := filepath.Join(root, "eastus", "goad", "dc01", ".terragrunt-cache", "abc", "module") + write(t, cache, "terragrunt.hcl", `inputs = { + instance_size = "Standard_NEVER_ASKED_FOR" +}`) + + got, err := RequestedSizes(root) + if err != nil { + t.Fatal(err) + } + for _, s := range got.Sizes { + if s == "Standard_NEVER_ASKED_FOR" { + t.Fatalf("collected a size from .terragrunt-cache: %v", got.Sizes) + } + } + if got.Units != 0 { + t.Errorf("units = %d, want 0 — a cached module copy is not a machine", got.Units) + } +} + +func TestRequestedSizesOnAnEmptyTree(t *testing.T) { + // No scaffolding, no sizes — and no error. The caller reports "could not + // tell" rather than treating this as a capacity problem. + got, err := RequestedSizes(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if len(got.Sizes) != 0 || got.Units != 0 { + t.Errorf("got %+v, want empty", got) + } +} + +func TestRequestedSizesSkipsUnrelatedFiles(t *testing.T) { + root := t.TempDir() + // A module's own terraform, sitting outside a cache: still not a unit file. + write(t, root, "variables.tf", `variable "instance_size" { default = "Standard_D99_v9" }`) + write(t, root, "env.hcl", `locals { + controller_instance_size = "Standard_D2s_v3" +}`) + got, err := RequestedSizes(root) + if err != nil { + t.Fatal(err) + } + if len(got.Sizes) != 1 || got.Sizes[0] != "Standard_D2s_v3" { + t.Errorf("sizes = %v, want only the env.hcl literal", got.Sizes) + } +} + +// A commented-out size must not be collected: it would warn about a SKU the +// range never requests, and an operator who sees a false warning stops reading +// the real ones. +func TestRequestedSizesIgnoresComments(t *testing.T) { + root := t.TempDir() + write(t, root, "env.hcl", `locals { + # controller_instance_size = "Standard_COMMENTED_OUT" + #instance_size = "Standard_ALSO_COMMENTED" + controller_instance_size = "Standard_D2s_v3" +}`) + got, err := RequestedSizes(root) + if err != nil { + t.Fatal(err) + } + if len(got.Sizes) != 1 || got.Sizes[0] != "Standard_D2s_v3" { + t.Errorf("sizes = %v, want only the live literal", got.Sizes) + } +} + +// An unreadable unit must not abort the scan — the sizes found elsewhere are +// still worth reporting on. +func TestRequestedSizesSurvivesAnUnreadableFile(t *testing.T) { + root := t.TempDir() + write(t, root, "env.hcl", `locals { + controller_instance_size = "Standard_D2s_v3" +}`) + unit := filepath.Join(root, "eastus", "goad", "dc01") + write(t, unit, "terragrunt.hcl", `inputs = { instance_size = "Standard_D4s_v3" }`) + if err := os.Chmod(filepath.Join(unit, "terragrunt.hcl"), 0o000); err != nil { + t.Skip("cannot chmod in this environment") + } + t.Cleanup(func() { _ = os.Chmod(filepath.Join(unit, "terragrunt.hcl"), 0o600) }) + + got, err := RequestedSizes(root) + if err != nil { + t.Fatalf("an unreadable unit aborted the whole scan: %v", err) + } + if len(got.Sizes) == 0 { + t.Error("lost the sizes that were readable") + } +} diff --git a/cli/internal/variant/generator.go b/cli/internal/variant/generator.go index 33f264c2..a7d9b662 100644 --- a/cli/internal/variant/generator.go +++ b/cli/internal/variant/generator.go @@ -1062,7 +1062,19 @@ func (g *Generator) transformFile(srcPath, relPath string) (transformed bool, er // are replaced in place; missing values are inserted under [all:vars], with // the section appended when the provider template does not define it. func (g *Generator) repointDomainName(content string) string { - target := filepath.Base(g.TargetPath) + return RepointDomainName(content, filepath.Base(g.TargetPath)) +} + +// RepointDomainName is repointDomainName for callers outside generation. +// +// `env create` builds an environment's inventory by copying a reference or the +// stock provider template, both of which carry the base lab's domain_name. For +// a variant environment that value has to name the variant's own directory for +// the same reason it does here — playbooks resolve assets as ad/{{ domain_name +// }}/... — and provisioning will not correct it later, because +// bootstrapInventory (cmd/provision.go) skips a file that already exists. +// Exported rather than duplicated so the rule lives in one place. +func RepointDomainName(content, target string) string { re := regexp.MustCompile(`(?m)^(\s*domain_name\s*=\s*).*$`) if re.MatchString(content) { return re.ReplaceAllStringFunc(content, func(line string) string { diff --git a/console/README.md b/console/README.md new file mode 100644 index 00000000..6cd9ea13 --- /dev/null +++ b/console/README.md @@ -0,0 +1,192 @@ +# DreadGOAD Console + +An agentic web UI to build, manage, reset, and validate DreadGOAD Active +Directory lab ranges. A chat pane (left) drives an LLM agent + a fixed set of +slash-commands; a live RangeView (right) shows the range topology and per-host +status/health. Each browser tab is an independent range/agent **session**. + +Adapted from the ALFRED two-pane shell. The backend never reimplements cloud +logic — it shells out to the `dreadgoad` CLI for everything. + +## Quick start + +Prereqs: `python3` (3.10+), `node`/`npm`, and the `dreadgoad` Go binary (on +`PATH` or at `cli/dreadgoad`). Build the binary if needed: + +```bash +cd cli && go build -o dreadgoad . +``` + +Set the LLM key (needed for the agent — free-text and agent-dispatch commands). +Either export it before launch: + +```bash +export OPENROUTER_API_KEY=sk-or-... +``` + +…or leave it unset and set it in-app: click the **⚙** in the tab bar and paste +your key (stored in the server's memory for the session, never written to disk). +A **⚠ no key** indicator shows in the tab bar until one is set. + +Launch from the repo root: + +```bash +./dreadgoad-console # build frontend, serve on http://localhost:24749 +./dreadgoad-console --dev # vite hot-reload + uvicorn --reload (development) +``` + +The launcher creates `.venv`, installs `console/backend/requirements.txt` (prefers +`uv`), builds the SPA, and serves it plus the API from one uvicorn process. Open +`http://localhost:24749`, create a session (point it at a `dreadgoad.yaml` + an +environment name), and drive it with `/` commands or plain text. + +## Environment variables + +| Variable | Default | Purpose | +|----------|---------|---------| +| `OPENROUTER_API_KEY` | — | LLM key for the agent. Reads work without it; agent turns don't. | +| `DREADGOAD_CONSOLE_PORT` | `24749` | HTTP port | +| `DREADGOAD_CONSOLE_MODEL` | `openrouter/anthropic/claude-sonnet-5` | Default agent model | +| `DREADGOAD_CONSOLE_STATE_ROOT` | `.dreadgoad/console/` | SQLite DB + per-session working dirs | +| `DREADGOAD_CONSOLE_DB` | `/state.db` | Override the DB path | +| `DREADGOAD_CONSOLE_FRONTEND_DIST` | — | Static SPA dir (set by the launcher) | + +## Commands + +A message is either a slash-command or free text. The **dispatch** column says +what happens when *you type it*: **agent** commands go to the LLM, which turns +your prose into flags and runs the CLI through its constrained `run_dreadgoad` +tool; **direct** commands run the CLI programmatically, with no model in the +loop. + +| Command | dreadgoad CLI | Dispatch | Purpose | +|---------|--------------|----------|---------| +| `/up` | `up` | 🤖 agent | Full bring-up (doctor→infra→provision→health) | +| `/provision` | `provision` | 🤖 agent | Re-run config playbooks | +| `/reset` | `lab reset` | 🤖 agent | Restore known-clean AD baseline | +| `/variant` | `variant generate` | 🤖 agent | Generate a randomized-name variant | +| `/extensions` | `extension` | 🤖 agent | List available extensions, or provision one | +| `/score` | `score` | 🤖 agent | Fetch an agent report off the attack box and score it | +| `/exec` | `exec --json` | 🤖 agent | Run a script on named hosts via the cloud control plane | +| `/restart` | `lab restart-vm` | 🤖 agent | Reboot one host, leaving the rest of the range up | +| `/instances` | `lab status --json` | ⚡ direct | Cloud power state | +| `/health` | `health-check --json` | ⚡ direct | Per-host AD health (rendered as a table) | +| `/validate` | `validate` | ⚡ direct | Vuln-config correctness | +| `/start` | `lab start` | ⚡ direct | Power on | +| `/stop` | `lab stop` | ⚡ direct | Power off | +| `/scrub` | `score reset` | ⚡ direct | Clean agent artifacts (add `dry` to preview) | +| `/destroy` | `infra destroy` | ⚡ direct | Tear down infra (operator-only) | + +`/help` is a sixteenth command that runs nothing: it prints the range workflow +end to end and is what an empty chat pane shows, so a new session opens on the +guide rather than a blank screen. It is client-side — it maps to no CLI verb, +and the agent cannot "run" it. + +Config/env are injected from the session — you never pass `--config`/`--env`. +Agent commands accept free-form text the agent interprets into flags (e.g. `/up +using the variant at ad/GOAD-foo`); of the direct commands only `/scrub` takes +an argument. + +### What the agent may run + +The agent's tool can reach **every command in the table**, direct ones included, +so it can answer a question by running a read and act on a request in plain +English. Confirmation before something destructive is a prompt-level guarantee, +not a mechanical one — that is an operator's choice, and it is the reason +`system.md` matters. + +Two limits *are* mechanical. The agent picks a command name and arguments; it +never picks the program, so it cannot invoke `az`, `aws`, `terraform` or a +shell. And `--config`/`--env` come from the session anchor and are rejected in +any supplied argument: cobra resolves repeated flags last-wins, so an appended +`--config other.yaml` would otherwise retarget the run at a different range. + +## Prompts + +Agent prompt content lives as editable markdown in `backend/prompts/`: + +- `system.md` — the shared system prompt (`$placeholder` template filled per session) +- `.md` — optional per-command guidance injected when that command runs + (flags pulled from the CLI source, not invented) + +Adding guidance to a command is a drop-in file; no code change. Editing the system +prompt is just editing `system.md`. + +## Architecture + +```text +console/ + frontend/ React + TypeScript + Vite SPA (@xyflow/react RangeView) + backend/ FastAPI + a per-session LLM agent (dreadnode/rigging) +``` + +One multiplexed `/ws/chat` WebSocket carries a `session_id` per message; turns are +serialized per session and run concurrently across sessions. A post-command +**ingestion hook** runs `lab status --json` and overlays live cloud state onto the +config-seeded range topology (this is also where the attack box is discovered for +`/score`). State persists to SQLite (document model over JSON columns); **no +credentials are stored** — only config/env references. + +Node positions in the RangeView survive a reload: dragging a node saves through +`PUT /api/ranges/{id}/layout`, which carries a revision so a stale write from a +second tab is rejected with a 409 rather than overwriting the newer layout. + +### Backend modules + +| Module | Responsibility | +|--------|---------------| +| `server.py` | FastAPI assembly, lifecycle, router registration, frontend mount | +| `config_routes.py` | Health, configuration, settings, and command-catalog routes | +| `session_routes.py` | Session lifecycle and model-selection routes | +| `range_routes.py` | Range reads, per-host detail, revision-protected layout routes | +| `chat_socket.py` | Bounded WebSocket protocol and multiplexed chat transport | +| `chat.py` | Thin chat facade: turn dispatch, agent routing, model switching | +| `chat_runtime.py` | Per-session state, connection ownership, cancellation, cleanup | +| `chat_events.py` | Event formatting, persistence, WebSocket delivery, replay | +| `command_runner.py` | Shared CLI pipeline, streaming, hooks, and report overlays | +| `agent.py` | Per-session `LocalTaskAgent` + the constrained `run_dreadgoad` tool | +| `commands.py` | Slash-command registry, argv builder, prompt loader | +| `summary.py` | Condenses CLI output into bounded tool results (structured, else clipped with a marker) | +| `cli.py` | Subprocess runner (streaming + cancel; `capture` for JSON reads) | +| `hook.py` | Compatibility facade for post-command synchronization | +| `inventory_sync.py` | Instance→host overlay, cloud metadata, attack-box sync | +| `health_sync.py` | Health-report parsing and per-host health overlays | +| `topology_sync.py` | Range reseeding and extension-node discovery | +| `labconfig.py` | Snapshot derivation + range topology seeding from lab config | +| `hostdetail.py` | Disks/NICs for one host via `lab describe` (Azure, read-only) | +| `configstore.py` | Which configs exist, where new ones are written, credential hints | +| `labs.py` | Base-lab discovery for the variant-source picker (`lab list --json`) | +| `scaffold.py` | Builds an environment's infra tree via `dreadgoad env create` | +| `sessions.py` | Session lifecycle service | +| `db.py` | SQLite persistence (single-worker executor, WAL) | +| `fetch.py` | `/score` report fetch via `dreadgoad score fetch` | +| `paths.py` | Filesystem locations (repo root, state root, session dirs) | + +## Development & tests + +```bash +./dreadgoad-console --dev # hot-reload backend + frontend + +# Backend tests (each suite is standalone-runnable, no pytest required): +.venv/bin/python console/backend/tests/test_commands.py +# ... test_chat.py, test_configstore.py, test_db.py, test_fetch.py, test_hook.py, +# test_hostdetail.py, test_labconfig.py, test_labs.py, test_longops.py, +# test_server_rest.py, +# test_sessions.py, test_summary.py + +# Or the whole suite at once. asyncio_mode=auto is required — without it every +# async test errors out as "async def functions are not natively supported". +uv run --no-project --with-requirements console/backend/requirements.txt \ + --with pytest --with pytest-asyncio --with httpx \ + python -m pytest console/backend/tests/ -q -o asyncio_mode=auto + +ruff format console/backend/ && ruff check console/backend/ +pyright --pythonpath .venv/bin/python console/backend/ + +# Frontend: +cd console/frontend && npx tsc --noEmit && npm run build +``` + +The `dreadgoad` CLI is stubbed in tests — the console is tested, not the CLI +(which has its own Go tests). Live behavior (real ranges) needs cloud credentials +and the compiled binary. diff --git a/console/backend/__init__.py b/console/backend/__init__.py new file mode 100644 index 00000000..3b0f4836 --- /dev/null +++ b/console/backend/__init__.py @@ -0,0 +1,8 @@ +"""DreadGOAD Console backend (FastAPI). + +Agentically build, manage, reset, and validate DreadGOAD ranges. Ported from +the ALFRED app skeleton; the PDF pane is replaced by a range network view and +the LaTeX toolset by the dreadgoad CLI toolset. +""" + +__version__ = "0.1.0" diff --git a/console/backend/agent.py b/console/backend/agent.py new file mode 100644 index 00000000..a183fa4b --- /dev/null +++ b/console/backend/agent.py @@ -0,0 +1,311 @@ +"""Per-session dreadgoad agent factory (design §5). + +Adapted from ALFRED's agent: a ``LocalTaskAgent`` that bypasses platform +telemetry, sandboxes filesystem writes to the session working dir, and is +told how to drive the dreadgoad CLI for *this* session's range (its +``(config_path, env)`` anchor). Free-text prompts go here; deterministic +slash commands are dispatched directly (see server WS handler). +""" + +from __future__ import annotations + +import asyncio +import string +import typing as t +from contextlib import AsyncExitStack, aclosing, asynccontextmanager +from copy import deepcopy + +import rigging as rg +from rigging.error import Stop +from dreadnode.agent import TaskAgent +from dreadnode.agent.agent import CommitBehavior +from dreadnode.agent.events import AgentEvent +from dreadnode.agent.thread import Thread +from dreadnode.agent.tools import tool +from dreadnode.agent.tools.fs import Filesystem + +import os + +from . import commands, projectroot, summary + +# Signature of the shared command pipeline (chat.run_cli), injected to avoid a +# chat <-> agent import cycle: (app, session_id, command, args) -> (exit, output). +RunCli = t.Callable[[t.Any, str, str, list[str]], t.Awaitable[tuple[int, str]]] + + +class LocalTaskAgent(TaskAgent): + """TaskAgent that streams without platform telemetry (ALFRED pattern).""" + + _REMOVE_TOOLS = {"finish_task", "give_up_on_task", "update_todo"} + + def model_post_init(self, context: t.Any) -> None: + """Strip the task-lifecycle tools and the never-stop condition. + + This agent runs one operator turn at a time rather than a self-directed + task, so ``finish_task``/``give_up_on_task``/``update_todo`` and + ``stop_never`` would let it loop instead of answering. + """ + super().model_post_init(context) + self.tools = [ + tool for tool in self.tools if tool.name not in self._REMOVE_TOOLS + ] + self.stop_conditions = [ + c for c in self.stop_conditions if c.name != "stop_never" + ] + + @asynccontextmanager + async def stream( + self, + user_input: str, + *, + thread: Thread | None = None, + commit: CommitBehavior = "always", + ) -> t.AsyncIterator[t.AsyncGenerator[AgentEvent, None]]: + """Stream one turn's events, bypassing platform telemetry. + + Yields the event generator as a context manager so toolsets are entered + and closed around the run. Args: ``user_input`` the prompt; ``thread`` + an alternate conversation (defaults to the agent's own); ``commit`` how + messages are written back to it. + """ + thread = thread or self.thread + messages = [*deepcopy(thread.messages), rg.Message("user", str(user_input))] + async with AsyncExitStack() as stack: + for tool_container in self.tools: + if hasattr(tool_container, "__aenter__") and hasattr( + tool_container, "__aexit__" + ): + # Toolset satisfies the async-CM protocol at runtime (guarded + # above); its wrapped dunders confuse pyright's protocol check. + await stack.enter_async_context(tool_container) # type: ignore[arg-type] + async with aclosing( + self._stream(thread, messages, commit=commit) + ) as events: + yield events + + +# Minimal fallback if prompts/system.md is somehow missing (packaging bug) — the +# agent should never run with empty instructions. +_SYSTEM_FALLBACK = ( + "You are the DreadGOAD range agent. Operate on THIS range only via the " + "`run_dreadgoad` tool (config/env are injected). Never run raw cloud CLI " + "(aws/az/terraform) or arbitrary shell. Confirm ambiguous destructive ops." +) + + +def _instructions(session: dict[str, t.Any]) -> str: + """Render the shared system prompt from ``prompts/system.md``. + + The template uses ``$placeholder`` fields filled from the session's anchor + and snapshot. Falls back to a terse inline prompt if the file is missing. + + Only config-derived snapshot fields belong here. Instructions are rendered + once, when the agent is first created and cached (see chat._get_agent), so a + field the ingestion hook learns post-deploy — ``account``, ``group``, + ``attack_box`` — would freeze at whatever it was on the first turn, usually + empty. Those stay out; the agent reads them from ``/instances``, which is + always current. + + Editing ``system.md``: every ``$name`` in it is a substitution, so a literal + dollar sign must be written ``$$``. This bites hardest on PowerShell — a + ``/exec`` example containing ``$env:COMPUTERNAME`` silently renders as + ``dreadindex:COMPUTERNAME``, because ``env`` is one of the keys below. The + corruption leaves no ``$`` behind, so the "no unsubstituted placeholder" + test in test_commands.py cannot catch it. + """ + anchor = session["anchor"] + snap = session.get("snapshot", {}) + template = commands.load_prompt("system") + if template is None: + return _SYSTEM_FALLBACK + + def field(value: t.Any) -> str: + """Render one snapshot value, or ``(not set)`` when it's absent.""" + # A bare None would render as the string "None" and read to the model as + # a real value; say plainly that it isn't set. + text = str(value).strip() if value is not None else "" + return text or "(not set)" + + return string.Template(template).safe_substitute( + config_path=anchor["config_path"], + env=anchor["env"], + provider=field(snap.get("provider")), + lab=field(snap.get("lab")), + region=field(snap.get("region")), + variant_name=field(snap.get("variant_name")), + vpc_cidr=field(snap.get("vpc_cidr")), + ) + + +def _make_run_dreadgoad(app: t.Any, session_id: str, run_cli: RunCli): # noqa: ANN202 + """Build the session-bound run_dreadgoad tool. + + The agent may run ANY registered dreadgoad command (validated against + ``commands.AGENT_RUNNABLE``) — reads to answer questions, actions to perform + them. Everything routes through the shared pipeline so agent-initiated ops get + streaming/status/hook/cancel like operator-typed ones. Guardrails for + destructive commands are by prompt (the agent confirms intent). + """ + + @tool(catch=True) + async def run_dreadgoad(command: str, args: list[str] | None = None) -> str: + """Run a dreadgoad command for THIS session and return its result. + + Use reads (/instances, /health, /validate) to answer questions, and the + action commands to perform what the operator asked. + + Args: + command: a dreadgoad slash command — reads (/instances, /health, + /validate) or actions, which change the range (/start, /stop, + /up, /provision, /reset, /scrub, /exec, /variant, /extensions, + /score, /destroy). /scrub deletes by default; pass "dry" to + preview. /exec runs an arbitrary admin-level script on named + hosts and has no dry run. + args: CLI flags/values interpreted from the operator's request, e.g. + ["--from", "ad-data.yml"] or ["/remote/report.jsonl", "--live-verify"]. + Do NOT pass --config/--env — the range is fixed. + + Returns the exit status plus the command's output, condensed: reads are + rendered as compact per-record lines, long logs are clipped in the + middle with a marker stating how many lines were dropped. + """ + if command not in commands.AGENT_RUNNABLE: + return ( + f"Refused: {command!r} is not a known dreadgoad command. " + f"Valid commands: {sorted(commands.AGENT_RUNNABLE)}." + ) + # An operator cancel reaches us as CancelledError, raised deliberately by + # run_cli as a signal (command_runner.py). Letting it escape a tool call + # is what produced the "tool_use ids were found without tool_result" + # 400s: CancelledError is a BaseException, so every layer above catches + # only Exception and none of them see it — + # + # rigging @tool(catch=True) except Exception (tools/base.py) + # _process_tool_call except Exception (agent/agent.py) + # join_generators except Exception (util.py) + # + # The last one has a `finally` that queues its FINISHED sentinel, so the + # join loop ends *normally* with no ToolEnd. The tool_use block is + # already in the message list, its tool_result never arrives, and the + # agent then makes another generation call against an unpaired list — + # rejected by every provider, and the turn dies with a confusing 400 + # instead of reading as a cancel. + # + # Stop is rigging's own way for a tool to end a run: it is caught by + # name in handle_tool_call, so a real tool_result IS appended and the + # agent raises Finish instead of generating again. The pair stays + # balanced and the run ends immediately — which is also the behaviour + # cancelling is supposed to have, no chance for the agent to retry. + try: + exit_code, output = await run_cli( + app, session_id, command, list(args or []) + ) + except asyncio.CancelledError: + # Only convert the signal. A genuine teardown (task.cancel(), e.g. + # cleanup_session on shutdown) must never be swallowed, and + # cancelling() is the one thing that tells them apart: it counts + # cancel() calls against THIS task, so it is 0 for run_cli's raise + # and non-zero only when someone really cancelled us. + # + # getattr because cancelling() is 3.11+ and the console documents + # 3.10 (console/README.md). Calling it bare there would raise an + # AttributeError *from inside this handler*, replacing the cancel + # with a crash. Where it is unavailable the signal is the far more + # common case, so treat it as one: a genuine cancel then ends the + # turn through Finish instead of CancelledError, which still stops + # the run immediately rather than letting it continue. + current = asyncio.current_task() + cancelling = getattr(current, "cancelling", None) + if cancelling is not None and cancelling() > 0: + raise + raise Stop( + f"The operator cancelled `dreadgoad {command}`. " + "Stopping this turn; do not retry the command." + ) from None + # Distinguishes a cancel from a failure: a negative code means the run + # was signalled, and calling that "failed" made the model report a + # deliberate stop as an error (see summary.describe_exit). + status = summary.describe_exit(exit_code) + # Structured where possible, clipped-with-a-marker otherwise. Never a + # bare tail: that silently drops records and the model reports the + # fragment as the whole (see summary.py). + return f"`dreadgoad {command}` {status}.\n{summary.summarize(command, output)}" + + return run_dreadgoad + + +def _make_read_lab_file(session: dict[str, t.Any]): # noqa: ANN202 + """Build a read-only tool for the variant's ``ad//data/`` directory. + + Returns None when the session has no lab (no variant scaffolded yet), so the + caller can skip it. The sandbox is the ``data/`` dir only — no traversal out. + """ + snap = session.get("snapshot") or {} + lab = snap.get("lab") + if not lab: + return None + anchor = session.get("anchor") or {} + config_path = anchor.get("config_path") + if not config_path: + return None + root = str(projectroot.resolve_root(config_path)[0]) + data_dir = os.path.realpath(os.path.join(root, lab, "data")) + if not os.path.isdir(data_dir): + return None + + @tool(catch=True) + async def read_lab_file(path: str = "config.json") -> str: + """Read a file from the variant's lab data directory (ad//data/). + + The default ``config.json`` contains the variant mapping: host roles + to randomized AD hostnames, domains, users, groups, and + vulnerabilities. Other files include ``inventory`` and overlay JSONs. + + Args: + path: Relative path within the data directory. Defaults to + ``config.json`` (the variant mapping). + """ + full = os.path.realpath(os.path.join(data_dir, path)) + if not full.startswith(data_dir + os.sep) and full != data_dir: + return f"Error: '{path}' is outside the lab data directory." + if not os.path.isfile(full): + avail = ", ".join(sorted(os.listdir(data_dir))) + return f"Error: '{path}' not found. Available: {avail}" + with open(full) as f: + return f.read() + + return read_lab_file + + +def create_agent( + model: str, + session: dict[str, t.Any], + app: t.Any, + session_id: str, + run_cli: RunCli, +) -> TaskAgent: + """Build a configured agent for a session. + + The LLM key must be in the environment (e.g. OPENROUTER_API_KEY). The + default model is Sonnet 5 via OpenRouter (see server config). The agent's + only range-mutating tool is ``run_dreadgoad`` (constrained to this session); + file writes are sandboxed to the session dir. No general shell tool. + """ + session_dir = session.get("session_dir") + if not session_dir: + raise ValueError( + "session has no session_dir — cannot sandbox agent file writes" + ) + fs = Filesystem(path=session_dir, variant="write") + tools: list[t.Any] = [fs, _make_run_dreadgoad(app, session_id, run_cli)] + lab_reader = _make_read_lab_file(session) + if lab_reader is not None: + tools.append(lab_reader) + return LocalTaskAgent( + name="dreadgoad-agent", + description="Builds, manages, and validates a DreadGOAD range", + model=model, + instructions=_instructions(session), + max_steps=50, + tools=tools, + ) diff --git a/console/backend/chat.py b/console/backend/chat.py new file mode 100644 index 00000000..63107f19 --- /dev/null +++ b/console/backend/chat.py @@ -0,0 +1,303 @@ +"""Multiplexed chat WebSocket (design §5.1, §7). + +One socket carries a ``session_id`` on every message. Dispatch (§5.1): + - ``dispatch="direct"`` commands (deterministic reads, /destroy) run the CLI + programmatically via ``run_cli``; + - ``dispatch="agent"`` commands expand to a structured prompt and run through + the agent's ``run_dreadgoad`` tool — which calls the *same* ``run_cli``, so + both paths stream/status/hook/cancel identically; + - free-text goes to the agent. +All events are persisted to the event log and replayed on resume. + +Live behavior needs an LLM key (OPENROUTER_API_KEY); the structural wiring is +import-verifiable without one. +""" + +from __future__ import annotations + +import asyncio +import typing as t +from copy import deepcopy + +from rigging import Message + +from . import chat_events, chat_runtime, command_runner, commands, paths, thread_repair +from .agent import create_agent + +# Public facade used by server.py. Internal state remains owned and tested in +# chat_runtime rather than being mirrored here. +active_turn = chat_runtime.active_turn +begin_cleanup = chat_runtime.begin_cleanup +release_cleanup = chat_runtime.release_cleanup +session_closing = chat_runtime.session_closing +register_conn = chat_runtime.register_conn +unregister_conn = chat_runtime.unregister_conn +cancel_session = chat_runtime.cancel_session +cleanup_session = chat_runtime.cleanup_session +cleanup_all = chat_runtime.cleanup_all +emit_event = chat_events.emit_event +replay = chat_events.replay +run_cli = command_runner.run_cli + + +TURN_BUSY_MESSAGE = ( + "A turn is already running for this session; wait or cancel it first." +) + + +async def _save_thread(app: t.Any, session_id: str, agent: t.Any) -> None: + """Persist the agent's conversation thread to the meta table.""" + thread = getattr(agent, "thread", None) + if thread is None: + return + serialized = [msg.model_dump(mode="json") for msg in thread.messages] + await app.state.db.set_meta(f"thread:{session_id}", serialized) + + +async def _load_thread(app: t.Any, session_id: str) -> list[Message] | None: + """Load a persisted thread, returning deserialized Messages or None.""" + raw = await app.state.db.get_meta(f"thread:{session_id}") + if raw is None: + return None + return [Message.model_validate(m) for m in raw] + + +def dispatch(app: t.Any, session_id: str, content: str) -> asyncio.Task[t.Any] | None: + """Start one background turn, or reject it if the session is busy (§6.4, §7). + + The WS recv loop calls this and immediately keeps reading, so `cancel` and + other sessions' messages are handled while a long op streams (§5.4). + Admission is reserved synchronously, before the task can yield, so two rapid + messages cannot both slip past the check and queue behind the session lock. + Tasks are kept in ``_tasks`` so they survive the connection closing; emits + target the session's *current* socket (`SessionRuntime.conn`). + """ + + runtime = chat_runtime.runtime(session_id) + if runtime.turn is not None or runtime.closing: + return None + + turn = chat_runtime.TurnState() + runtime.turn = turn + + async def _runner() -> None: + try: + turn.started = True + if turn.cancelled: + raise asyncio.CancelledError + async with runtime.lock: + await handle_message(app, session_id, content) + except asyncio.CancelledError: + await emit_event( + app, + session_id, + "agent_end", + {"failed": False, "cancelled": True}, + ) + raise + except Exception: + # Any non-cancel exception (db error, corrupt thread, agent setup + # failure) must still release the frontend's processing state. + try: + await emit_event(app, session_id, "agent_end", {"failed": True}) + except Exception: # noqa: BLE001 + pass + raise + finally: + if runtime.turn is turn: + runtime.turn = None + + try: + task = asyncio.create_task(_runner()) + except Exception: + if runtime.turn is turn: + runtime.turn = None + raise + turn.task = task + chat_runtime.tasks.add(task) + task.add_done_callback(lambda finished: _turn_task_done(session_id, turn, finished)) + return task + + +def _turn_task_done( + session_id: str, turn: chat_runtime.TurnState, task: asyncio.Task[t.Any] +) -> None: + """Release a finished turn and observe any exception it carried. + + ``dispatch`` deliberately runs turns in the background, so most tasks are + never awaited by a caller. Retrieving the exception here prevents an + unexpected pipeline failure from becoming an unobserved "Task exception was + never retrieved" warning; focused tests may still await the task and receive + the same exception normally. + """ + chat_runtime.tasks.discard(task) + # A task cancelled before its coroutine first runs never enters the + # coroutine's ``finally`` block, so release its admission here as well. + runtime = chat_runtime.runtimes.get(session_id) + if runtime is not None and runtime.turn is turn: + runtime.turn = None + if runtime is not None: + chat_runtime.discard_if_idle(session_id, runtime) + if not task.cancelled(): + task.exception() + + +async def _get_agent(app: t.Any, session_id: str) -> t.Any | None: + runtime = chat_runtime.runtime(session_id) + if runtime.agent is not None: + return runtime.agent + session = await app.state.db.get_session(session_id) + if session is None: + return None + agent = create_agent( + # Falls back to the shared default rather than a literal of its own: + # a session row written before the model column existed, or with a + # blank value, should still run on whatever the console is configured + # for -- not on a string frozen into this module. + session.get("model") or paths.default_model(), + session, + app, + session_id, + run_cli, + ) + messages = await _load_thread(app, session_id) + if messages is not None: + agent.thread.messages = messages + thread_repair.repair_tool_pairing(agent.thread.messages) + runtime.agent = agent + return agent + + +async def swap_model( + app: t.Any, session_id: str, new_model: str +) -> dict[str, t.Any] | None: + """Switch a session's agent model, preserving conversation context (ALFRED-style). + + Runs under the session lock so it can't race an in-flight turn. Persists the + new model on the session; if an agent is already live, rebuilds it with the + new model and grafts the old thread's messages onto it so the conversation + continues seamlessly. Returns the updated session, or None if not found. + """ + async with chat_runtime.session_lock(session_id): + session = await app.state.db.get_session(session_id) + if session is None: + return None + session["model"] = new_model + await app.state.db.upsert_session(session) + + runtime = chat_runtime.runtime(session_id) + old = runtime.agent + if old is not None: + history = deepcopy(old.thread.messages) + fresh = create_agent(new_model, session, app, session_id, run_cli) + fresh.thread.messages = history + runtime.agent = fresh + await _save_thread(app, session_id, fresh) + # else: no live agent — _get_agent will build with the new model next turn. + + await emit_event( + app, session_id, "status", {"content": f"Model changed to {new_model}."} + ) + return session + + +async def _inject_direct_note( + app: t.Any, session_id: str, name: str, exit_code: int +) -> None: + """Add a note to the agent thread so the LLM knows a direct command ran. + + Direct commands bypass the agent entirely, so without this the LLM has + no record that /destroy, /instances, etc. happened between its turns. + Best-effort: agent setup failures must not break direct commands. + """ + try: + agent = await _get_agent(app, session_id) + except Exception: # noqa: BLE001 + return + if agent is None: + return + thread = getattr(agent, "thread", None) + if thread is None: + return + + status = "succeeded" if exit_code == 0 else f"failed (exit {exit_code})" + thread.messages.extend([ + Message(role="user", content=f"[System: the operator ran {name} directly. It {status}.]"), + Message(role="assistant", content=f"Noted — {name} {status}."), + ]) + await _save_thread(app, session_id, agent) + + +async def handle_message(app: t.Any, session_id: str, content: str) -> None: + """Route a message: direct command → run_cli; agent command / free-text → agent.""" + await emit_event(app, session_id, "user_message", {"content": content}) + + if commands.is_command(content): + name, extra = commands.parse_command(content) + session = await app.state.db.get_session(session_id) + if session is None: + await emit_event(app, session_id, "error", {"message": "session not found"}) + await emit_event(app, session_id, "agent_end", {"failed": True}) + return + cmd = commands.REGISTRY[name] + if cmd.dispatch == "direct": + if extra and not cmd.takes_args: + await emit_event( + app, + session_id, + "error", + {"message": f"{name} takes no arguments (got: {' '.join(extra)})"}, + ) + await emit_event(app, session_id, "agent_end", {"failed": True}) + return + exit_code, _ = await run_cli(app, session_id, name, extra) + await _inject_direct_note(app, session_id, name, exit_code) + await emit_event(app, session_id, "agent_end", {"failed": exit_code != 0}) + return + # dispatch="agent": expand to a structured prompt; the agent runs it via + # its run_dreadgoad tool (robust arg interpretation, constrained). + await _run_agent(app, session_id, commands.expand_command_prompt(name, extra)) + return + + await _run_agent(app, session_id, content) + + +async def _run_agent(app: t.Any, session_id: str, prompt: str) -> None: + """Stream one agent turn (free-text or an expanded command) to the client.""" + agent = await _get_agent(app, session_id) + if agent is None: + await emit_event(app, session_id, "error", {"message": "session not found"}) + await emit_event(app, session_id, "agent_end", {"failed": True}) + return + # An unpaired tool call in the thread is rejected by the provider before the + # turn starts, and it is re-sent by every turn after this one, so a single + # orphan silently ends the session's ability to talk. Sweeping here costs a + # list walk and leaves a well-formed thread untouched. + thread = getattr(agent, "thread", None) + if thread is not None: + repaired = thread_repair.repair_tool_pairing(thread.messages) + if repaired: + await emit_event( + app, + session_id, + "status", + { + "content": ( + f"Recovered {len(repaired)} unfinished tool call(s) from " + "an interrupted turn." + ) + }, + ) + + try: + async with agent.stream(prompt) as events: + async for event in events: + formatted = chat_events.format_agent_event(event) + if formatted: + kind = formatted.pop("kind") + await emit_event(app, session_id, kind, formatted) + except Exception as exc: # noqa: BLE001 - surface any agent error to the client + await emit_event(app, session_id, "error", {"message": f"agent error: {exc}"}) + await emit_event(app, session_id, "agent_end", {"failed": True}) + finally: + await _save_thread(app, session_id, agent) diff --git a/console/backend/chat_events.py b/console/backend/chat_events.py new file mode 100644 index 00000000..92160726 --- /dev/null +++ b/console/backend/chat_events.py @@ -0,0 +1,131 @@ +"""Persistence and WebSocket delivery for console chat events.""" + +from __future__ import annotations + +import json +import typing as t + +from dreadnode.agent.events import ( + AgentEnd, + AgentError, + GenerationEnd, + ToolEnd, + ToolStart, +) + +from . import chat_runtime + +# Chat-kind events replayed on resume. Live progress and check notifications +# are deliberately absent: progress is transient and RangeView refreshes range +# state over REST after a check. +CHAT_KINDS = [ + "user_message", + "generation", + "tool_start", + "tool_end", + "error", + "agent_end", + "status", + "instances_report", + "health_report", + "validate_report", + "scrub_report", + "exec_report", + "security_report", +] + + +def format_agent_event(event: t.Any) -> dict[str, t.Any] | None: + """Convert a dreadnode AgentEvent to the console's JSON event shape.""" + if isinstance(event, GenerationEnd): + usage = None + if event.usage: + usage = { + "input_tokens": event.usage.input_tokens, + "output_tokens": event.usage.output_tokens, + } + return { + "kind": "generation", + "content": event.message.content or "", + "usage": usage, + } + if isinstance(event, ToolStart): + return { + "kind": "tool_start", + "tool": event.tool_call.name, + "args": event.tool_call.function.arguments, + } + if isinstance(event, ToolEnd): + return { + "kind": "tool_end", + "tool": event.tool_call.name, + "result": (event.message.content or "")[:2000], + } + if isinstance(event, AgentError): + return {"kind": "error", "message": str(event.error)} + if isinstance(event, AgentEnd): + return {"kind": "agent_end", "failed": event.result.failed} + return None + + +async def emit_event( + app: t.Any, + session_id: str, + kind: str, + payload: dict[str, t.Any], + *, + persist: bool = True, +) -> None: + """Persist an event and push it to the session's current socket.""" + if persist: + await app.state.db.append_event(session_id, kind, payload) + current = chat_runtime.runtimes.get(session_id) + ws = current.conn if current is not None else None + if ws is None: + return + try: + await ws.send_text( + json.dumps({"session_id": session_id, "kind": kind, **payload}) + ) + except Exception: # noqa: BLE001 + # Client disconnects do not stop server-side work; persisted events are + # replayed when another socket attaches. + pass + + +def flatten_stored_event(event: dict[str, t.Any]) -> dict[str, t.Any]: + """Reshape a stored event to the flat live WebSocket representation.""" + payload = event.get("payload") or {} + return { + "seq": event.get("seq"), + "ts": event.get("ts"), + "kind": event["kind"], + **payload, + } + + +MAX_REPLAY = 500 + + +async def replay(app: t.Any, session_id: str) -> None: + """Send persisted chat history and current turn state on reconnect.""" + await app.state.db.prune_events(session_id) + events = await app.state.db.get_events(session_id, kinds=CHAT_KINDS) + events = events[-MAX_REPLAY:] + current = chat_runtime.runtimes.get(session_id) + if current is None or current.conn is None: + return + ws = current.conn + turn = current.turn + await ws.send_text( + json.dumps( + { + "session_id": session_id, + "kind": "history", + "events": [flatten_stored_event(event) for event in events], + "active": turn is not None, + "started_at": turn.started_at if turn else None, + "command": turn.command if turn else None, + } + ) + ) diff --git a/console/backend/chat_runtime.py b/console/backend/chat_runtime.py new file mode 100644 index 00000000..533d9809 --- /dev/null +++ b/console/backend/chat_runtime.py @@ -0,0 +1,195 @@ +"""Per-session ownership, cancellation, and cleanup for console chat turns. + +This module owns only in-memory lifecycle state. It deliberately knows nothing +about message routing, event persistence, agents, or command semantics, keeping +the cancellation path usable by both the WebSocket facade and command runner. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import typing as t +from dataclasses import dataclass, field +from datetime import datetime, timezone + + +@dataclass(slots=True) +class TurnState: + """Typed ownership state for one admitted chat turn.""" + + started_at: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + command: str | None = None + cancelled: bool = False + started: bool = False + commands_starting: int = 0 + task: asyncio.Task[t.Any] | None = None + + +@dataclass(slots=True) +class SessionRuntime: + """Every in-memory resource owned by one console session.""" + + agent: t.Any = None + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + conn: t.Any = None + turn: TurnState | None = None + running: set[t.Any] = field(default_factory=set) + # Kept true after deletion so stale WebSockets cannot recreate orphan data. + closing: bool = False + + +# Strong refs keep background turns alive across client disconnects. +tasks: set[asyncio.Task[t.Any]] = set() +runtimes: dict[str, SessionRuntime] = {} + + +def runtime(session_id: str) -> SessionRuntime: + """Return the session's runtime state, creating it on first use. + + Every other accessor here goes through this, so a caller never has to + decide whether a session has been seen before. + """ + current = runtimes.get(session_id) + if current is None: + current = SessionRuntime() + runtimes[session_id] = current + return current + + +def discard_if_idle(session_id: str, current: SessionRuntime) -> None: + """Drop lock-only shells while retaining agents and deletion tombstones.""" + if ( + current.agent is None + and current.conn is None + and current.turn is None + and not current.running + and not current.closing + and runtimes.get(session_id) is current + ): + runtimes.pop(session_id, None) + + +def active_turn(session_id: str) -> TurnState | None: + """The running turn for a session, or None if it is idle.""" + current = runtimes.get(session_id) + return current.turn if current is not None else None + + +def begin_cleanup(session_id: str) -> bool: + """Atomically reserve an idle session against new turn dispatch.""" + current = runtime(session_id) + if current.closing or current.turn is not None or current.running: + return False + current.closing = True + return True + + +def release_cleanup(session_id: str) -> None: + """Release a failed deletion reservation so the session remains usable.""" + current = runtimes.get(session_id) + if current is not None: + current.closing = False + discard_if_idle(session_id, current) + + +def session_closing(session_id: str) -> bool: + """Whether the session is mid-teardown and should refuse new work. + + Reads ``runtimes`` directly rather than via :func:`runtime` so merely + asking the question cannot resurrect state for a session being evicted. + """ + current = runtimes.get(session_id) + return current.closing if current is not None else False + + +def register_conn(session_id: str, ws: t.Any) -> None: + """Mark ``ws`` as the current socket for a session.""" + runtime(session_id).conn = ws + + +def unregister_conn(ws: t.Any) -> None: + """Drop a closed socket from the registry.""" + for session_id, current in list(runtimes.items()): + if current.conn is ws: + current.conn = None + discard_if_idle(session_id, current) + + +def cancel_session(session_id: str) -> bool: + """Cancel the whole in-flight turn and every subprocess it owns.""" + current = runtimes.get(session_id) + if current is None: + return False + turn = current.turn + running = tuple(current.running) + if turn is None and not running: + return False + + if turn is not None: + turn.cancelled = True + for command in running: + command.cancel() + + # A running subprocess unwinds first; its owner then observes cancellation. + # Without one, interrupt model generation immediately. commands_starting + # closes the race where cancellation lands during create_subprocess_exec. + if not running and turn is not None and not turn.commands_starting: + task = turn.task + if task is not None and not task.done() and turn.started: + task.cancel() + return True + + +def session_lock(session_id: str) -> asyncio.Lock: + """The per-session lock serialising turns, so one session runs one at a time.""" + return runtime(session_id).lock + + +async def cleanup_session(session_id: str, *, timeout: float = 15.0) -> None: + """Stop and await one session, then evict all of its runtime state.""" + current = runtimes.get(session_id) + if current is None: + return + turn = current.turn + running = tuple(current.running) + cancel_session(session_id) + + task = turn.task if turn is not None else None + if task is not None and not task.done(): + done, _ = await asyncio.wait({task}, timeout=timeout) + if not done: + for command in tuple(current.running): + force_kill = getattr(command, "force_kill", None) + if force_kill is not None: + force_kill() + else: + command.cancel() + task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + elif running: + # No owner task can reap these handles; do not leave them detached. + for command in running: + force_kill = getattr(command, "force_kill", None) + if force_kill is not None: + force_kill() + + current.agent = None + current.conn = None + current.turn = None + current.running.clear() + if not current.closing: + runtimes.pop(session_id, None) + + +async def cleanup_all(*, timeout: float = 15.0) -> None: + """Bounded shutdown cleanup for every in-memory session.""" + session_ids = set(runtimes) + for current in runtimes.values(): + current.closing = True + await asyncio.gather( + *(cleanup_session(session_id, timeout=timeout) for session_id in session_ids) + ) diff --git a/console/backend/chat_socket.py b/console/backend/chat_socket.py new file mode 100644 index 00000000..307cb478 --- /dev/null +++ b/console/backend/chat_socket.py @@ -0,0 +1,146 @@ +"""Bounded WebSocket protocol and multiplexed chat transport.""" + +from __future__ import annotations + +import json +import re + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from . import chat + +router = APIRouter() + +# Browser WebSockets do not enforce same-origin handshakes. Permit the console +# and dev proxy on loopback, while rejecting pages on other origins. +_WS_ORIGIN_RE = re.compile( + r"^(?:https?|wss?)://(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$" +) + +WS_MAX_CONTENT_CHARS = 32_768 +WS_MAX_MESSAGE_CHARS = 65_536 +WS_MAX_SESSION_ID_CHARS = 128 + + +def ws_origin_allowed(origin: str | None) -> bool: + """Return whether an Origin may open the console WebSocket.""" + if origin is None: + return True + return bool(_WS_ORIGIN_RE.match(origin.strip().lower())) + + +def parse_ws_message( + raw: str, +) -> tuple[dict[str, str] | None, str | None, str | None]: + """Validate a client frame and return message, error, and safe session id.""" + if len(raw) > WS_MAX_MESSAGE_CHARS: + return None, "message is too large", None + + try: + value = json.loads(raw) + except json.JSONDecodeError: + return None, "message must be valid JSON", None + if not isinstance(value, dict): + return None, "message must be a JSON object", None + + raw_session_id = value.get("session_id") + if not isinstance(raw_session_id, str) or not raw_session_id.strip(): + return None, "session_id must be a non-empty string", None + session_id = raw_session_id.strip() + if len(session_id) > WS_MAX_SESSION_ID_CHARS: + return None, "session_id is too long", None + + raw_type = value.get("type", "message") + if not isinstance(raw_type, str): + return None, "type must be a string", session_id + message_type = raw_type + if message_type not in {"message", "resume", "cancel"}: + return None, f"unknown message type: {message_type}", session_id + + allowed = {"session_id", "type"} + if message_type == "message": + allowed.add("content") + unexpected = sorted(set(value) - allowed) + if unexpected: + return ( + None, + f"unexpected field(s): {', '.join(unexpected)}", + session_id, + ) + + message = {"session_id": session_id, "type": message_type} + if message_type == "message": + content = value.get("content") + if not isinstance(content, str): + return None, "content must be a string", session_id + content = content.strip() + if not content: + return None, "content must not be empty", session_id + if len(content) > WS_MAX_CONTENT_CHARS: + return None, "content is too large", session_id + message["content"] = content + return message, None, session_id + + +@router.websocket("/ws/chat") +async def ws_chat(websocket: WebSocket) -> None: + """Multiplex every session over one reconnectable WebSocket.""" + app = websocket.app + if not ws_origin_allowed(websocket.headers.get("origin")): + await websocket.close(code=1008) + return + await websocket.accept() + try: + while True: + raw = await websocket.receive_text() + message, error, error_session_id = parse_ws_message(raw) + if error is not None: + payload = {"kind": "error", "message": error} + if error_session_id is not None: + payload["session_id"] = error_session_id + await websocket.send_text(json.dumps(payload)) + continue + + assert message is not None + session_id = message["session_id"] + chat.register_conn(session_id, websocket) + if message["type"] == "resume": + await chat.replay(app, session_id) + continue + if message["type"] == "cancel": + chat.cancel_session(session_id) + continue + + if chat.session_closing(session_id): + await chat.emit_event( + app, + session_id, + "error", + {"message": "session is being deleted"}, + persist=False, + ) + continue + if await app.state.db.get_session(session_id) is None: + await websocket.send_text( + json.dumps( + { + "session_id": session_id, + "kind": "error", + "message": "session not found", + } + ) + ) + continue + task = chat.dispatch(app, session_id, message["content"]) + if task is None: + await chat.emit_event( + app, + session_id, + "error", + {"message": chat.TURN_BUSY_MESSAGE}, + persist=False, + ) + except WebSocketDisconnect: + pass + finally: + chat.unregister_conn(websocket) diff --git a/console/backend/cli.py b/console/backend/cli.py new file mode 100644 index 00000000..5cfd4728 --- /dev/null +++ b/console/backend/cli.py @@ -0,0 +1,305 @@ +"""Runner that shells out to the dreadgoad CLI (design §5.1, §5.4). + +CLI commands run with ``cwd = repo root`` (they read ``ad/``, ``infra/``, +``dreadgoad.yaml``), streaming stdout line-by-line so long ops can surface a +live tail. The returned handle exposes cancellation (SIGINT) for §6. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +import signal +import time +import typing as t +from pathlib import Path + +OnLine = t.Callable[[str], t.Any] + +Capture = t.Callable[[list[str], str], t.Awaitable[tuple[int, str, str]]] + +# How often to check whether the process has exited while a read is pending. +# Only costs anything when output is idle; a ready line returns immediately. +_POLL_INTERVAL = 0.25 + +# Hard ceiling on how long to keep reading after the process has exited. Without +# it a *chatty* survivor (a tunnel logging on a timer) satisfies every read and +# streams forever — hanging the turn exactly as the original EOF wait did. Long +# enough to flush output the CLI itself had buffered at exit. +_DRAIN_BUDGET = 2.0 + +# How long to wait for exit after the pipe closes, before giving up on it. +_EXIT_GRACE = 30.0 + +# A process group has already received SIGKILL on this path. Waiting longer +# would make the chat runtime's bounded shutdown misleading; this window exists +# only to let asyncio's child watcher collect the exit status. +_FORCE_REAP_GRACE = 2.0 + + +class RunningCommand: + """A live CLI subprocess with a streamed-output future and cancel().""" + + # Grace after SIGINT before a hard SIGKILL. Long enough for terraform/ansible + # to unwind on SIGINT, short enough that a command which *ignores* SIGINT + # (e.g. a stuck health-check) still gets cancelled. + _KILL_GRACE = 12.0 + + def __init__(self, proc: asyncio.subprocess.Process) -> None: + self._proc = proc + self.lines: list[str] = [] + self.cancelled = False + self._kill_task: asyncio.Task[None] | None = None + self._closed = False + # Capture the process group now, while the child is certainly alive. + # Resolving it later with getpgid() is unsafe: we also signal *after* + # exit (see _reap_group), and a reaped PID can be recycled by the OS — + # we would then signal an unrelated process's group. ``start_new_session`` + # makes the child its own group leader, so pgid == pid. + try: + self._pgid: int | None = os.getpgid(proc.pid) + except OSError: + self._pgid = None + + def cancel(self) -> None: + """Cancel the run: SIGINT the process group (so the CLI *and* its + terraform/ansible children unwind gracefully), then escalate to SIGKILL + if it hasn't exited within the grace period — some commands trap SIGINT + (§5.4). The child is a group leader via ``start_new_session``.""" + if self._closed: + return + self.cancelled = True + self._killpg(signal.SIGINT) + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return # no loop → best-effort SIGINT only + if self._kill_task is None or self._kill_task.done(): + self._kill_task = loop.create_task(self._force_kill_after(self._KILL_GRACE)) + + def force_kill(self) -> None: + """Immediately stop the owned process group during bounded teardown.""" + # Signal even if the group leader has exited: a surviving helper may + # still own an inherited stdout/stderr pipe and keep communicate() + # blocked. _closed prevents signalling this cached pgid after the + # command has been fully reaped, when reuse could make it unsafe. + if self._closed: + return + self.cancelled = True + self._killpg(signal.SIGKILL) + self._cancel_kill_task() + + async def _force_kill_after(self, grace: float) -> None: + try: + await asyncio.sleep(grace) + if self.cancelled and not self._closed: + self._killpg(signal.SIGKILL) + finally: + if self._kill_task is asyncio.current_task(): + self._kill_task = None + + def _cancel_kill_task(self) -> None: + task = self._kill_task + self._kill_task = None + if task is not None and task is not asyncio.current_task() and not task.done(): + task.cancel() + + def _killpg(self, sig: int) -> None: + if self._pgid is None: + return + with _suppress(): + os.killpg(self._pgid, sig) + + @property + def returncode(self) -> int: + """Exit code, or 0 while the process is still running.""" + return self._proc.returncode or 0 + + @property + def output(self) -> str: + """All output captured so far, joined with newlines.""" + return "\n".join(self.lines) + + def _record(self, raw: bytes) -> str: + line = raw.decode("utf-8", errors="replace").rstrip("\n") + self.lines.append(line) + return line + + async def stream_lines(self) -> t.AsyncIterator[str]: + """Yield stdout lines until the process **exits**, then drain and stop. + + Keyed on process exit, not pipe EOF. The CLI spawns helpers that inherit + this stdout pipe and can outlive it — on Azure, ``health-check`` leaves + an ``az network bastion tunnel`` running, which holds the write end open + indefinitely. Waiting for EOF hangs the read forever, and since turns are + serialized per session that wedges the entire chat for that session. + """ + assert self._proc.stdout is not None + stdout = self._proc.stdout + # NOT `proc.wait()`: asyncio only resolves it once every pipe is closed + # too, so a surviving child blocks it exactly like the raw read. The + # transport sets `returncode` on real process exit, so poll that. + line_task: asyncio.Future[bytes] | None = None + # Set once exit is observed; bounds how long we keep draining after it. + # Checked on *every* iteration, not just on read timeout — a survivor + # that writes continuously (a tunnel logging on a timer) satisfies the + # read every time and would otherwise stream forever. + deadline: float | None = None + try: + while True: + if line_task is None: + line_task = asyncio.ensure_future(stdout.readline()) + budget = _POLL_INTERVAL + if deadline is not None: + budget = min(budget, deadline - time.monotonic()) + if budget <= 0: + line_task.cancel() + break # drain budget spent; the rest isn't ours + try: + raw = await asyncio.wait_for(asyncio.shield(line_task), budget) + except asyncio.TimeoutError: + if self._proc.returncode is not None and deadline is None: + deadline = time.monotonic() + _DRAIN_BUDGET + continue + line_task = None + if not raw: + break # genuine EOF: nothing is holding the pipe + yield self._record(raw) + if self._proc.returncode is not None and deadline is None: + deadline = time.monotonic() + _DRAIN_BUDGET + finally: + if self._proc.returncode is None: + # Pipe closed before exit — now wait() can actually resolve. + try: + await asyncio.wait_for(self._proc.wait(), _EXIT_GRACE) + except (asyncio.TimeoutError, asyncio.CancelledError): + self._killpg(signal.SIGKILL) + self._reap_group() + + def _reap_group(self) -> None: + """Terminate anything left in the child's process group. + + ``start_new_session`` gave the CLI its own group, so this only reaches + our own descendants. Without it, helpers that outlive the CLI leak — we + found orphaned bastion tunnels over four hours old — and keep the stdout + pipe open for whoever reads it next. + """ + if self._proc.returncode is None: + return # still running; not ours to reap yet + self._cancel_kill_task() + # A cancelled run must leave no helper behind. Normal completion uses + # SIGTERM so an outliving tunnel can still shut down cleanly. + self._killpg(signal.SIGKILL if self.cancelled else signal.SIGTERM) + self._closed = True + + async def wait(self, on_line: OnLine | None = None) -> tuple[int, str]: + """Stream stdout (merged stderr) until exit; return (rc, full_output).""" + async for line in self.stream_lines(): + if on_line is not None: + on_line(line) + return self.returncode, self.output + + async def communicate(self) -> tuple[int, str, str]: + """Capture separate stdout/stderr, bounded after exit, and reap the group. + + A caller cancelling ``Process.communicate`` does not terminate the + subprocess. Standalone users of :func:`capture` therefore hard-stop + the group before propagating cancellation; session-owned callers signal + this handle directly and normally let the readers finish after SIGINT. + + Read the two streams ourselves instead of using ``Process.communicate``: + an outliving helper can inherit either pipe and prevent communicate() + from ever seeing EOF after the CLI itself exits. Once exit is observed, + preserve a small drain window and then stop reading, just like the live + merged-output path. + """ + assert self._proc.stdout is not None + assert self._proc.stderr is not None + out = bytearray() + err = bytearray() + + async def drain(reader: asyncio.StreamReader, target: bytearray) -> None: + while chunk := await reader.read(64 * 1024): + target.extend(chunk) + + readers = { + asyncio.create_task(drain(self._proc.stdout, out)), + asyncio.create_task(drain(self._proc.stderr, err)), + } + try: + # proc.wait() is deliberately avoided: asyncio may wait for pipe + # closure too, which is exactly what a surviving child prevents. + while self._proc.returncode is None: + await asyncio.sleep(_POLL_INTERVAL) + _done, pending = await asyncio.wait(readers, timeout=_DRAIN_BUDGET) + for task in pending: + task.cancel() + except asyncio.CancelledError: + self.force_kill() + raise + except BaseException: + self.force_kill() + raise + finally: + for task in readers: + if not task.done(): + task.cancel() + await asyncio.gather(*readers, return_exceptions=True) + if self._proc.returncode is None and self.cancelled: + with _suppress(): + await asyncio.wait_for(self._proc.wait(), _FORCE_REAP_GRACE) + self._reap_group() + return ( + self.returncode, + out.decode("utf-8", errors="replace"), + err.decode("utf-8", errors="replace"), + ) + + +def _suppress() -> contextlib.AbstractContextManager[None]: + """Swallow OS-level errors from killpg (benign races against exited processes).""" + return contextlib.suppress(OSError) + + +async def start_command(argv: list[str], cwd: str | Path) -> RunningCommand: + """Launch a CLI command (stdout+stderr merged) rooted at ``cwd``.""" + proc = await asyncio.create_subprocess_exec( + *argv, + cwd=str(cwd), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + start_new_session=True, # own process group → cancel() signals the whole tree + ) + return RunningCommand(proc) + + +async def run_command( + argv: list[str], cwd: str | Path, on_line: OnLine | None = None +) -> tuple[int, str]: + """Convenience: start + wait. Returns (returncode, merged_output).""" + rc = await start_command(argv, cwd) + return await rc.wait(on_line) + + +async def capture(argv: list[str], cwd: str | Path) -> tuple[int, str, str]: + """Run a command capturing stdout and stderr **separately**. + + Used for machine-readable output (e.g. ``lab status --json``): merging + stderr into stdout would let a stray log/warning line corrupt JSON + parsing. Returns (returncode, stdout, stderr). + """ + command = await start_capture(argv, cwd) + return await command.communicate() + + +async def start_capture(argv: list[str], cwd: str | Path) -> RunningCommand: + """Launch a separately-captured command with an owned process group.""" + proc = await asyncio.create_subprocess_exec( + *argv, + cwd=str(cwd), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + start_new_session=True, + ) + return RunningCommand(proc) diff --git a/console/backend/command_runner.py b/console/backend/command_runner.py new file mode 100644 index 00000000..9c68769a --- /dev/null +++ b/console/backend/command_runner.py @@ -0,0 +1,513 @@ +"""DreadGOAD CLI orchestration for console chat turns. + +Direct slash commands and agent tool calls share this pipeline so process +ownership, streaming, lifecycle status, ingestion, and report overlays behave +identically regardless of how a command was requested. +""" + +from __future__ import annotations + +import asyncio +import json +import typing as t +from functools import partial + +from . import ( + chat_events, + chat_runtime, + commands, + fetch, + hook, + paths, + projectroot, + summary, +) +from .cli import start_capture, start_command + +# Commands that mutate infra via terraform/ansible need a long graceful runway +# before cancellation escalates to SIGKILL. +_SLOW_CANCEL = frozenset({"/up", "/provision", "/reset", "/destroy", "/extensions"}) + + +def parse_instances(output: str) -> list[dict[str, t.Any]] | None: + """Parse the JSON array emitted by ``/instances``.""" + return summary.parse_json_array(output) + + +def _health_progress(line: str) -> str | None: + """Render one health-check NDJSON record as readable live progress.""" + line = line.strip() + if not line.startswith("{") or '"status"' not in line or '"checks"' in line: + return None + try: + check = json.loads(line) + except (ValueError, TypeError): + return None + if not isinstance(check, dict) or "checks" in check: + return None + status = check.get("status", "?") + name = check.get("name", "") + detail = check.get("detail", "") + return f"{status:<5} {name}" + (f" — {detail}" if detail else "") + + +def _security_progress(line: str) -> str | None: + """Render one security-check NDJSON record as readable live progress.""" + line = line.strip() + if not line.startswith("{") or '"status"' not in line or '"checks"' in line: + return None + try: + check = json.loads(line) + except (ValueError, TypeError): + return None + if not isinstance(check, dict) or "checks" in check: + return None + status = check.get("status", "?") + name = check.get("name", "") + resource = check.get("resource", "") + severity = check.get("severity", "") + detail = check.get("detail", "") + label = f"{name} [{resource}]" if resource else name + sev = f" ({severity})" if severity else "" + return f"{status:<5} {label}{sev}" + (f" — {detail}" if detail else "") + + +def _parse_security_report(output: str) -> dict[str, t.Any] | None: + """Extract a security report from NDJSON output (same pattern as health).""" + for line in output.splitlines(): + line = line.strip() + if not line.startswith("{") or '"checks"' not in line: + continue + try: + parsed = json.loads(line) + except (ValueError, TypeError): + continue + if isinstance(parsed, dict) and "checks" in parsed: + return parsed + return None + + +class _Aborted(Exception): + """A pre-flight failure with separate operator and caller output.""" + + def __init__(self, code: int, emit: str, output: str | None = None) -> None: + super().__init__(emit) + self.code = code + self.emit = emit + self.output = emit if output is None else output + + +def final_status(name: str, exit_code: int, cancelled: bool) -> str: + """Return the session lifecycle status after a long-running command.""" + if cancelled: + return "interrupted" + if name in ("/health", "/secure"): + return "running" + if exit_code: + return "error" + if name == "/destroy": + return "destroyed" + return "running" + + +async def _capture_for_turn( + session_id: str, argv: list[str], cwd: str +) -> tuple[int, str, str]: + """Run a machine-readable helper as an owned subprocess of this turn.""" + current = chat_runtime.runtime(session_id) + turn = current.turn + if turn is not None: + turn.commands_starting += 1 + try: + command = await start_capture(argv, cwd) + finally: + if turn is not None: + turn.commands_starting = max(0, turn.commands_starting - 1) + + if turn is not None and turn.cancelled: + command.cancel() + current.running.add(command) + try: + result = await command.communicate() + finally: + current.running.discard(command) + + turn = current.turn + if command.cancelled or (turn is not None and turn.cancelled): + raise asyncio.CancelledError + return result + + +def _capture_command(session_id: str) -> fetch.Capture: + """Bind the generic capture callback to one session runtime.""" + return partial(_capture_for_turn, session_id) + + +# How long the post-cancel range refresh may take before it is abandoned. The +# operator has just asked for this to stop; a refresh that outlives their +# patience defeats the point of running it. A healthy `lab status` returns in +# about three seconds. +_REFRESH_TIMEOUT = 15.0 + + +async def _capture_for_refresh( + session_id: str, argv: list[str], cwd: str +) -> tuple[int, str, str]: + """Capture for the post-cancel refresh: bounded, and never self-cancelling. + + ``_capture_for_turn`` is wrong for this in both directions — it kills the + command when the turn is cancelled, and raises CancelledError on the way + out, so the refresh would abort before reading anything. This one runs + despite the cancellation. + + Bounded because ``capture`` is not: it awaits ``communicate()`` with no + deadline, so a wedged read would hold the cancel open indefinitely — the + operator would press cancel and wait longer than if they hadn't. On timeout + the subprocess is killed rather than left behind. + + The handle joins ``running`` so a shutdown, or a second cancel, can reap it. + """ + command = await start_capture(argv, cwd) + current = chat_runtime.runtime(session_id) + current.running.add(command) + try: + return await asyncio.wait_for(command.communicate(), _REFRESH_TIMEOUT) + except BaseException: + # Any abnormal exit, not just the timeout: the caller bounds the whole + # refresh too, and that path cancels this one from outside. Either way + # the subprocess must die with it rather than outlive the turn. + command.cancel() + raise + finally: + current.running.discard(command) + + +async def _prepare_extra( + session: dict[str, t.Any], + session_id: str, + name: str, + extra: list[str], +) -> list[str]: + """Resolve arguments that require pre-command work.""" + if name != "/score" or not extra: + return extra + try: + rc_fetch, local, message = await fetch.fetch_report( + session, extra[0], _capture_command(session_id) + ) + except ValueError as exc: + raise _Aborted(1, str(exc)) from exc + if rc_fetch != 0: + raise _Aborted(rc_fetch, f"report fetch failed: {message[-300:]}", message) + return [local, *extra[1:]] + + +async def _stream_output( + app: t.Any, session_id: str, name: str, command: t.Any +) -> None: + """Relay a process's live tail, filtering machine-readable commands.""" + async for line in command.stream_lines(): + line = summary.strip_ansi(line) + if name == "/instances": + continue + if name == "/health": + progress = _health_progress(line) + if progress is None: + continue + line = progress + if name == "/secure": + progress = _security_progress(line) + if progress is None: + continue + line = progress + await chat_events.emit_event( + app, session_id, "command_progress", {"line": line}, persist=False + ) + + +async def _emit_overlays( + app: t.Any, session_id: str, name: str, output: str, exit_code: int +) -> None: + """Apply and announce command-specific range overlays and reports.""" + if name == "/health": + report = await hook.apply_health(app, session_id, output, exit_code) + if report is not None: + await chat_events.emit_event( + app, + session_id, + "health_report", + { + "passed": report.get("passed", 0), + "failed": report.get("failed", 0), + "skipped": report.get("skipped", 0), + "checks": report.get("checks", []), + }, + ) + elif name == "/instances": + instances = parse_instances(output) + if instances is not None: + running = sum( + 1 + for instance in instances + if str(instance.get("state", "")).lower() == "running" + ) + await chat_events.emit_event( + app, + session_id, + "instances_report", + { + "instances": instances, + "total": len(instances), + "running": running, + }, + ) + elif name == "/validate": + report = summary.parse_validate_report(output) + if report is not None: + checks = report.get("checks") or [] + await chat_events.emit_event( + app, + session_id, + "validate_report", + { + "passed": report.get("passed", 0), + "failed": report.get("failed", 0), + "warnings": report.get("warnings", 0), + "total": report.get("total_checks", len(checks)), + "categories": summary.validate_categories(checks), + "failures": [ + { + "category": check.get("category", ""), + "name": check.get("name", ""), + } + for check in checks + if str(check.get("status", "")).upper() == "FAIL" + ], + }, + ) + elif name == "/scrub": + report = summary.parse_scrub_report(output) + if report is not None: + hosts = report.get("hosts") or [] + await chat_events.emit_event( + app, + session_id, + "scrub_report", + { + "mode": report.get("mode", "unknown"), + "hosts": hosts, + "found": sum(int(host.get("found", 0)) for host in hosts), + "removed": sum(int(host.get("removed", 0)) for host in hosts), + }, + ) + elif name == "/exec": + results = summary.parse_json_array(output) + if results is not None: + results = summary.clean_exec_results(results) + await chat_events.emit_event( + app, + session_id, + "exec_report", + { + "results": results, + "succeeded": sum( + 1 + for result in results + if summary.exec_succeeded(result.get("status")) + ), + "total": len(results), + }, + ) + elif name == "/secure": + report = _parse_security_report(output) + if report is not None: + checks = report.get("checks") or [] + await chat_events.emit_event( + app, + session_id, + "security_report", + { + "passed": report.get("passed", 0), + "failed": report.get("failed", 0), + "warned": report.get("warned", 0), + "skipped": report.get("skipped", 0), + "security_checks": checks, + }, + ) + elif name in ("/variant", "/extensions"): + await hook.reseed(app, session_id, _capture_command(session_id)) + + +async def run_cli( + app: t.Any, session_id: str, name: str, extra: list[str] | None = None +) -> tuple[int, str]: + """Run one DreadGOAD command through the shared console pipeline.""" + session = await app.state.db.get_session(session_id) + if session is None: + await chat_events.emit_event( + app, session_id, "error", {"message": "session not found"} + ) + return 1, "session not found" + + try: + extra = await _prepare_extra(session, session_id, name, list(extra or [])) + except _Aborted as exc: + await chat_events.emit_event(app, session_id, "error", {"message": exc.emit}) + return exc.code, exc.output + + try: + argv = commands.build_argv( + session, name, extra, repo_root=str(paths.repo_root()) + ) + except ValueError as exc: + await chat_events.emit_event(app, session_id, "error", {"message": str(exc)}) + return 1, str(exc) + + # Where the CLI will resolve the range's files. The config path and the + # working directory are independent inputs to the CLI (see projectroot), + # and this used to be a fixed repo_root() — so a config in another checkout + # had its inventory and lab data looked up in the console's tree instead of + # its own. Running in the config's directory makes the CLI's inference land + # where it would running by hand next to that config. + # + # repo_root() still locates the *binary* in build_argv above; that is the + # console's own checkout and is a separate question from where the range's + # files live. + config_path = projectroot.config_path_of(session) + if config_path: + # long_running is the registry's marker for the commands that drive + # hosts (/health, /provision, /reset, /exec, /up, /validate) as opposed + # to cloud-only reads (/instances). Only those need an inventory, and + # warning about it on every read would make the warning worth ignoring. + checks = projectroot.preflight( + config_path, + session["anchor"]["env"], + check_inventory=commands.REGISTRY[name].long_running, + ) + run_cwd = str(checks.root) + # Advisory, and emitted before the spawn: a missing inventory otherwise + # surfaces only as every host failing identically, long afterwards. + for warning in checks.warnings: + await chat_events.emit_event( + app, session_id, "status", {"content": warning} + ) + else: + run_cwd = str(paths.repo_root()) + + await chat_events.emit_event( + app, + session_id, + "command_run", + {"phase": "start", "command": name, "argv": argv, "cwd": run_cwd}, + ) + + current = chat_runtime.runtime(session_id) + turn = current.turn + if turn is not None: + turn.command = name + + command_spec = commands.REGISTRY[name] + if command_spec.long_running: + await app.state.sessions.set_status(session_id, "provisioning") + + if turn is not None: + turn.commands_starting += 1 + try: + command = await start_command(argv, cwd=run_cwd) + except OSError as exc: + message = f"failed to start {name}: {exc}" + if command_spec.long_running: + await app.state.sessions.set_status(session_id, "error") + await chat_events.emit_event(app, session_id, "error", {"message": message}) + await chat_events.emit_event( + app, + session_id, + "command_run", + { + "phase": "end", + "command": name, + "exit_code": 1, + "cancelled": False, + "tail": message, + }, + ) + return 1, message + finally: + if turn is not None: + turn.commands_starting = max(0, turn.commands_starting - 1) + + command._KILL_GRACE = 300.0 if name in _SLOW_CANCEL else 12.0 + if turn is not None and turn.cancelled: + command.cancel() + current.running.add(command) + try: + await _stream_output(app, session_id, name, command) + finally: + current.running.discard(command) + exit_code, output = command.returncode, command.output + + if command_spec.long_running: + await app.state.sessions.set_status( + session_id, final_status(name, exit_code, command.cancelled) + ) + + cancelled = command.cancelled or ( + current.turn is not None and current.turn.cancelled + ) + await chat_events.emit_event( + app, + session_id, + "command_run", + { + "phase": "end", + "command": name, + "exit_code": exit_code, + "cancelled": command.cancelled, + # Cancelling kills our subprocess; it does not reach into Azure or + # into a playbook already running on a host. Saying only "cancelled" + # let an operator watch a DC reboot they believed they had stopped. + "still_running": bool(cancelled and command_spec.cloud_ops), + "tail": output[-2000:], + }, + ) + + # Re-check after the emit, not before. emit_event awaits a store write and a + # socket send, so a cancel can land inside it; deciding from the pre-emit + # value would let that turn carry on as if nothing had happened. The payload + # above keeps the value that was true when it was sent. + cancelled = cancelled or (current.turn is not None and current.turn.cancelled) + + if cancelled: + # Re-read the range before unwinding. The command may have changed the + # world on its way out, and the view would otherwise keep showing the + # state from before it ran — the one moment the display is most likely + # to be wrong is the one we were skipping. + # + # Deliberately NOT the turn-owned capture: that raises CancelledError + # the moment it sees turn.cancelled, so the refresh would abort before + # doing anything. Failure here must not replace the cancellation, so + # everything is swallowed except the cancel itself. + if command_spec.cloud_ops: + # Bounded as a whole, not just at the subprocess: the operator has + # already asked for this to stop, so a refresh that outlives their + # patience makes cancelling slower than not cancelling. Anything + # inside run_check can block — a wedged read, a slow store — and + # only a deadline around all of it is actually a guarantee. + try: + payload = await asyncio.wait_for( + hook.run_check( + app, session_id, partial(_capture_for_refresh, session_id) + ), + _REFRESH_TIMEOUT, + ) + await chat_events.emit_event(app, session_id, "check_run", payload) + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 - a stale view beats a lost cancel + pass + raise asyncio.CancelledError + + payload = await hook.run_check(app, session_id, _capture_command(session_id)) + await chat_events.emit_event(app, session_id, "check_run", payload) + await _emit_overlays(app, session_id, name, output, exit_code) + return exit_code, output diff --git a/console/backend/commands.py b/console/backend/commands.py new file mode 100644 index 00000000..8b37568c --- /dev/null +++ b/console/backend/commands.py @@ -0,0 +1,504 @@ +"""Slash-command registry + dreadgoad argv builder (design §5). + +Each command maps to an exact CLI verb. Commands are provider-agnostic — the +session's ``(config_path, env)`` anchor is injected as global flags; provider +comes from the config file, so no ``--provider`` is needed. All CLI calls run +with ``cwd = repo root`` (see runner in cli.py). +""" + +from __future__ import annotations + +import os +import shlex +import shutil +import typing as t +from dataclasses import dataclass +from pathlib import Path + +# Prompt content lives beside this module as editable markdown (design §5.1): +# prompts/system.md the agent's system prompt ($placeholder template) +# prompts/.md optional per-command guidance for agent commands +_PROMPTS_DIR = Path(__file__).resolve().parent / "prompts" + + +def load_prompt(stem: str) -> str | None: + """Read ``prompts/.md`` (stripped), or ``None`` if absent. + + ``stem`` is a command name without its slash (e.g. ``"variant"``) for + per-command guidance, or ``"system"`` for the shared system prompt. + """ + try: + return (_PROMPTS_DIR / f"{stem}.md").read_text(encoding="utf-8").strip() + except FileNotFoundError: + return None + + +@dataclass(frozen=True) +class Command: + """One slash command and how it maps onto the dreadgoad CLI.""" + + name: str + verb: tuple[str, ...] # base CLI verb after `dreadgoad` + dispatch: str = "direct" # "direct" (deterministic) | "agent" + long_running: bool = False # streamed + guarded cancel (§5.4) + takes_args: bool = False + # Whether the command asks the cloud (or a host) to change something. + # + # Cancelling one of these does NOT undo it. Killing our subprocess ends the + # local wait; an Azure deallocate, or an ansible run already underway, + # carries on to completion server-side. An operator who cancelled a + # /restart and was told "cancelled" watched the DC reboot anyway. + # + # Separate from long_running: /restart takes minutes but is not flagged + # long_running, and /score is neither. What matters here is whether + # something outside this process is still moving after we stop watching. + cloud_ops: bool = False + # Cannot be undone. Distinct from cloud_ops, which only says the command + # touches real resources: /start and /stop do that and are entirely + # reversible. This is the property that earns a confirmation, and only + # matters for `direct` commands — an agent-dispatched one gets a turn in + # which the operator can still say no. + destructive: bool = False + description: str = "" # what it does, one line, in the autocomplete menu + # The consequence an operator needs *before* pressing enter: what it costs, + # what it destroys, or what it depends on. Wording is taken from the CLI's + # own command help so the two can't drift into disagreeing. + detail: str = "" + + +# The slash commands (§5.2). `/help` is not here: it runs nothing, so it is +# merged into the catalog client-side rather than given a registry entry. +# dispatch="agent": prose → structured prompt → the agent's run_dreadgoad tool +# (robust arg interpretation; the arg-flexible/mutating commands). +# dispatch="direct": deterministic reads + /destroy, run programmatically. +REGISTRY: dict[str, Command] = { + "/up": Command( + "/up", + ("up",), + dispatch="agent", + long_running=True, + cloud_ops=True, + description="Deploy the range end-to-end: doctor → infra → provision → health", + detail="creates cloud resources and starts billing; runs for tens of minutes", + ), + "/provision": Command( + "/provision", + ("provision",), + dispatch="agent", + long_running=True, + cloud_ops=True, + description="Re-run the Ansible provisioning playbooks, with retries", + detail="safe to repeat; configures existing hosts, never recreates infra", + ), + "/reset": Command( + "/reset", + ("lab", "reset"), + dispatch="agent", + long_running=True, + cloud_ops=True, + description="Restore Active Directory to a known-clean baseline", + detail="discards AD changes made since deploy; leaves the VMs in place", + ), + "/start": Command( + "/start", + ("lab", "start"), + takes_args=True, + cloud_ops=True, + description="Power on the stopped lab instances, or one named host", + detail="no host = the whole range; give a hostname to act on one VM", + ), + "/stop": Command( + "/stop", + ("lab", "stop"), + takes_args=True, + cloud_ops=True, + description="Power off the running lab instances, or one named host", + detail="no host = the whole range; disks and range state are preserved", + ), + "/restart": Command( + "/restart", + ("lab", "restart-vm"), + dispatch="agent", + takes_args=True, + cloud_ops=True, + description="Reboot one host by name, leaving the rest of the range up", + detail="the fix for a host too wedged to answer; give it a hostname", + ), + "/destroy": Command( + "/destroy", + # --auto-approve skips the CLI's interactive confirmation prompt, + # which would EOF in a non-interactive context. The console collects + # its own confirmation before dispatching this command. + ("infra", "destroy", "--auto-approve"), + takes_args=True, + long_running=True, + cloud_ops=True, + destructive=True, + description="Tear down all infrastructure for this environment", + # Rendered verbatim in the confirmation dialog, so it has to be + # unambiguous on first read. An earlier phrasing began "no host destroys + # everything", which parses more naturally as "no host destroys + # anything" — the opposite of what it does. + detail=( + "irreversible — with no hostname this destroys the whole " + "environment; with one, only that VM" + ), + ), + "/instances": Command( + "/instances", + ("lab", "status", "--json"), + description="Power state and private IP of every VM in the range", + detail="read-only; also refreshes the range view", + ), + "/health": Command( + "/health", + ("health-check", "--json"), + long_running=True, + description="Check each host is reachable and Active Directory is serving", + detail="read-only; reports per host, so a failure is scoped to one machine", + ), + "/status": Command( + "/status", + (), + dispatch="agent", + long_running=True, + description="Cloud power state + host-level health in one pass", + detail="read-only; runs /instances then /health and summarizes", + ), + "/secure": Command( + "/secure", + ("security-check", "--json"), + long_running=True, + description="Audit network security posture of the deployed range", + detail="read-only; checks NSGs, public IPs, bastion, and access controls", + ), + "/validate": Command( + "/validate", + ("validate",), + long_running=True, + description="Check the vulnerability configuration matches this variant", + detail="read-only; needs the variant's mapping.json and an inventory", + ), + "/exec": Command( + "/exec", + ("exec",), + dispatch="agent", + long_running=True, + takes_args=True, + cloud_ops=True, + description="Run a script on range hosts via the cloud control plane", + detail="admin-level and no dry run; reaches hosts whose WinRM is down", + ), + "/score": Command( + "/score", + ("score",), + dispatch="agent", + takes_args=True, + description="Score an agent's report against the answer key", + detail="give it the report path on the attack box; it is fetched for you", + ), + "/scrub": Command( + "/scrub", + ("score", "reset"), + takes_args=True, + cloud_ops=True, + description="Clean agent artifacts off the attack box and Windows hosts", + detail="deletes for real; add 'dry' to preview instead. Leaves AD config alone", + ), + "/variant": Command( + "/variant", + ("variant", "generate"), + dispatch="agent", + takes_args=True, + description="Generate a randomized-name variant of the base lab", + detail="new names, passwords and answer key — desyncs a deployed range", + ), + "/extensions": Command( + "/extensions", + ("extension",), + dispatch="agent", + takes_args=True, + cloud_ops=True, + description="List available extensions, or provision one by name", + detail="listing is read-only; provisioning adds machines to the range", + ), +} + +# Commands the agent may run via its run_dreadgoad tool: ALL of them, so it can +# answer questions by running reads (/instances, /health, …) and perform actions +# from natural language. Safety for destructive commands (/destroy, /up, /reset, +# /variant) is by prompt — the agent must confirm intent (operator's choice). +AGENT_RUNNABLE: frozenset[str] = frozenset(REGISTRY) + + +def command_catalog() -> list[dict[str, t.Any]]: + """Registry as a JSON-able list for the frontend autocomplete menu (§5.1). + + Preserves REGISTRY insertion order. ``dispatch`` lets the UI tag each row + (direct vs agent); ``takes_args`` hints whether free-form args are expected. + """ + return [ + { + "name": name, + "description": c.description, + "detail": c.detail, + # The CLI verb it maps to — an operator who knows `dreadgoad` can + # tell at a glance what will actually run. Empty for composite + # commands that run multiple verbs via the agent. + "cli": ("dreadgoad " + " ".join(c.verb)).strip() if c.verb else "", + "dispatch": c.dispatch, + "long_running": c.long_running, + "takes_args": c.takes_args, + # Irreversible. The UI confirms before running one that is also + # ``direct``: those execute the moment they are sent, with no agent + # turn to question them and no prompt underneath — the CLI's own + # approval is bypassed with --auto-approve because a console command + # has no terminal to answer it. + "destructive": c.destructive, + } + for name, c in REGISTRY.items() + ] + + +def expand_command_prompt(name: str, extra: list[str]) -> str: + """Turn a ``dispatch="agent"`` command into a structured prompt (ALFRED-style). + + The agent interprets the operator's free-form args into flags and runs the + command via ``run_dreadgoad`` — constrained to this one command, this range. + A ``prompts/.md`` file, if present, is injected as command-specific + guidance (flag semantics, gotchas); otherwise the generic template stands. + """ + cmd = REGISTRY[name] + verb = " ".join(cmd.verb) + freeform = " ".join(extra) if extra else "(no extra arguments given)" + guidance = load_prompt(name.lstrip("/")) + guidance_block = f"\n\n## Command-specific guidance\n{guidance}" if guidance else "" + return ( + f"The operator invoked the {name} command — {cmd.description}.\n\n" + f"Run it using the `run_dreadgoad` tool with command={name!r}. Do NOT use " + f"any other command, and NEVER use raw cloud CLI (aws/az/terraform) — only " + f"`run_dreadgoad`. The range (config/env) is fixed by the tool; don't pass " + f"--config/--env.\n\n" + f"Interpret the operator's free-form request into the correct dreadgoad " + f"flags for `{verb}` and pass them as the tool's `args`. If the request is " + f"ambiguous or would be destructive beyond the command's intent, ask first." + f"{guidance_block}\n\n" + f"Operator's request: {name} {freeform}" + ) + + +def resolve_bin(repo_root: str | Path) -> str: + """Locate the dreadgoad binary. + + Prefer the repo's freshly-built ``cli/dreadgoad`` — it's the version built + alongside the console and has its ``--json`` verbs — over whatever + ``dreadgoad`` happens to be on PATH (which could be stale and lack them). + """ + repo_bin = Path(repo_root) / "cli" / "dreadgoad" + if repo_bin.is_file() and os.access(repo_bin, os.X_OK): + return str(repo_bin) + found = shutil.which("dreadgoad") + if found: + return found + return str(repo_bin) # expected path; yields a clear error if missing + + +# Tokens that turn /scrub into a preview. The CLI's own flag is `--apply`, but +# the console inverts the default (see below), so the operator needs a word for +# the *other* mode — accept the obvious spellings rather than one exact string. +_SCRUB_DRY_TOKENS = frozenset( + {"dry", "dry-run", "dryrun", "--dry", "--dry-run", "--dryrun", "-n", "preview"} +) + +# `score reset` flags that consume the following token. Without this, a value +# that happens to spell a dry token (``--report-output dry``) would be eaten as +# the mode — dropping --apply *and* leaving the flag with no value. +_SCRUB_VALUE_FLAGS = frozenset( + {"--attack-box", "--ssh-key", "--ssh-user", "--report-output"} +) + + +def _verb_for(cmd: Command, extra: list[str]) -> tuple[list[str], list[str]]: + """Resolve a command's concrete verb + trailing args from chat args. + + Handles the arg-shaped commands: + - /start,/stop → `lab start` (whole range) or `lab start-vm ` + - /destroy → `infra destroy` (everything) or `lab destroy-vm ` + - /extensions → `extension list` (no arg) or `extension provision ` + - /score → `score --report ` (+ any flags like --live-verify) + - /variant → `variant generate ` + - /scrub → `score reset --apply` unless a dry token is given + """ + if cmd.name == "/scrub": + # The CLI defaults to a dry run; the console defaults to applying. + # Someone typing "clean the box" means clean it — a command that + # silently changed nothing was the more surprising behaviour. Any other + # argument (--purge-ad, --skip-kali, …) passes straight through. + dry = False + rest: list[str] = [] + expect_value = False + for arg in extra: + if expect_value: # this token belongs to the preceding flag + rest.append(arg) + expect_value = False + continue + lowered = arg.lower() + if lowered in _SCRUB_VALUE_FLAGS: + rest.append(arg) + expect_value = True + continue + if lowered in _SCRUB_DRY_TOKENS: + dry = True + continue + rest.append(arg) + verb = ["score", "reset"] if dry else ["score", "reset", "--apply"] + return verb, rest + if cmd.name == "/exec": + # Always --json: the console parses per-host results into a report, and + # leaving the flag to the agent means it eventually forgets and the + # output falls through to a generic clip. Any --hosts/--cmd/--timeout + # the agent supplied passes straight through. + return ["exec", "--json"], extra + if cmd.name == "/restart": + # `lab restart-vm` takes the hostname positionally, so a bare /restart + # would hit cobra's arg validation with an unhelpful message. Say what's + # missing instead — the agent can then ask the operator which host. + if not extra: + raise ValueError("/restart needs a hostname, e.g. /restart dc02") + return ["lab", "restart-vm", extra[0]], extra[1:] + if cmd.name == "/destroy": + # No host tears down the environment through terragrunt; a host + # terminates that one VM through the cloud API. --yes is required for + # the same reason --auto-approve is on the infra form: destroy-vm + # confirms by reading stdin, and a console command has no terminal, so + # without it the CLI prints "Aborted." and exits 0 — reporting success + # for a VM it never touched. + if extra: + return ["lab", "destroy-vm", extra[0], "--yes"], extra[1:] + return list(cmd.verb), [] + if cmd.name in ("/start", "/stop"): + # `lab start`/`lab stop` act on the whole range; `lab start-vm`/`stop-vm` + # take one hostname. Optional-arg shape like /extensions, so the bare + # form is unchanged and a host narrows it. The *-vm commands accept no + # flags at all, so anything past the first token can only be a surplus + # positional — passed through, where cobra's ExactArgs(1) rejects it with + # a clearer message than a guard here would produce. + action = cmd.name[1:] + if extra: + return ["lab", f"{action}-vm", extra[0]], extra[1:] + return ["lab", action], [] + if cmd.name == "/extensions": + if extra: + return ["extension", "provision", extra[0]], extra[1:] + return ["extension", "list"], [] + if cmd.name == "/score": + if extra: + return ["score", "--report", extra[0]], extra[1:] + return ["score"], [] + return list(cmd.verb), extra + + +# Flags that select WHICH range/cloud context the CLI acts on. The console +# injects config/env from the session anchor and derives provider/region from +# that config, but cobra's persistent flags are last-wins: a trailing copy in +# the agent's args would silently override the session. ``infra --deployment`` +# and the score commands' explicit profile/attack-box selectors are included +# for the same reason. +_SCOPE_LONG_FLAGS = frozenset( + { + "--config", + "--env", + "--provider", + "--region", + "--deployment", + "--profile", + "--attack-box", + } +) + +# Cobra/pflag accepts a string shorthand both as ``-e value`` and concatenated +# as ``-evalue`` (plus ``-e=value``). Checking only whole argv tokens leaves the +# concatenated form as a range escape. ``-c`` is retained defensively for older +# CLI builds even though the current root flag has no config shorthand. +_SCOPE_SHORT_FLAGS = frozenset({"-c", "-e", "-p", "-d"}) + + +def _scope_override_flag(arg: str) -> str | None: + """Return the scope selector encoded in one argv token, if any.""" + head = arg.split("=", 1)[0] + if head in _SCOPE_LONG_FLAGS or head in _SCOPE_SHORT_FLAGS: + return head + if not arg.startswith("--"): + for flag in _SCOPE_SHORT_FLAGS: + if arg.startswith(flag) and len(arg) > len(flag): + return flag + return None + + +def _rejects_anchor_override(extra: list[str]) -> None: + """Raise if caller-supplied flags could retarget the session's range. + + The system prompt tells the agent the range is fixed by the tool; this is what + makes that true. Long ``--flag=value`` and concatenated short ``-evalue`` + spellings count as well as separate flag/value tokens. Raising rather than + stripping keeps the agent from believing it acted on the context it named. + """ + for arg in extra: + flag = _scope_override_flag(arg) + if flag is not None: + raise ValueError( + f"refusing to run: {flag!r} would retarget the range/cloud " + "context. The session's config, environment, provider, region, " + "deployment, and credentials are fixed; drop it and try again." + ) + + +def build_argv( + session: dict[str, t.Any], + name: str, + extra_args: list[str] | None = None, + repo_root: str | Path = ".", +) -> list[str]: + """Build the full dreadgoad argv for a command in a session's context. + + Shape: ``[bin, --config , --env , , ]``. + + Raises: + KeyError: the command isn't registered. + ValueError: the extra args try to override the session's range anchor. + """ + if name not in REGISTRY: + raise KeyError(f"unknown command: {name}") + cmd = REGISTRY[name] + anchor = session["anchor"] + _rejects_anchor_override(list(extra_args or [])) + verb, trailing = _verb_for(cmd, list(extra_args or [])) + return [ + resolve_bin(repo_root), + "--config", + str(anchor["config_path"]), + "--env", + str(anchor["env"]), + *verb, + *trailing, + ] + + +def is_command(text: str) -> bool: + """True if the message's first token is a registered slash command.""" + return text.strip().split(" ", 1)[0] in REGISTRY + + +def parse_command(text: str) -> tuple[str, list[str]]: + """Split ``/cmd arg1 "arg with spaces"`` → ("/cmd", ["arg1", "arg with spaces"]). + + Uses shell-style tokenization so quoted args (e.g. paths with spaces) + survive; falls back to plain split on malformed quoting. Only called after + ``is_command`` confirms a leading command token, so ``parts`` is non-empty. + """ + text = text.strip() + try: + parts = shlex.split(text) + except ValueError: + parts = text.split() + return parts[0], parts[1:] diff --git a/console/backend/config_routes.py b/console/backend/config_routes.py new file mode 100644 index 00000000..52769795 --- /dev/null +++ b/console/backend/config_routes.py @@ -0,0 +1,186 @@ +"""Health, configuration, settings, and command-catalog HTTP routes.""" + +from __future__ import annotations + +import os +import re +import typing as t + +import yaml +from fastapi import APIRouter, HTTPException, Request + +from . import __version__ as VERSION +from . import commands, configstore, labconfig, labs, paths + +router = APIRouter() + +# Settings may only write credential-shaped variables. Allowing arbitrary names +# could alter PATH/LD_PRELOAD and hijack subprocesses launched by the console. +_API_KEY_ENV_RE = re.compile(r"^[A-Z][A-Z0-9_]*_(?:API_KEY|KEY|TOKEN)$") + + +@router.get("/api/health") +async def health() -> dict[str, t.Any]: + """Return liveness information for the console itself.""" + return {"status": "ok", "version": VERSION} + + +@router.get("/api/config") +async def get_config() -> dict[str, t.Any]: + """Return bootstrap values needed before a session exists.""" + return { + "version": VERSION, + "default_model": paths.default_model(), + "default_config_path": configstore.default_config_path(), + "api_key_set": bool(os.environ.get("OPENROUTER_API_KEY")), + # The create UI offers these and no others; sending the list keeps the + # frontend from carrying its own copy that can drift from the backend's. + "providers": list(configstore.PROVIDERS), + } + + +@router.get("/api/configs") +async def get_configs(request: Request) -> dict[str, t.Any]: + """List every config the console can attach to, for the new-session picker. + + Session anchors are read straight from the sessions table rather than kept + in a registry of their own: the sessions *are* the record of which configs + are in use, and a second list would be one more thing to keep in step with + deletions. + """ + sessions = await request.app.state.sessions.list_sessions() + anchors = [(s.get("anchor") or {}).get("config_path") for s in sessions] + configs = configstore.known_configs(p for p in anchors if p) + return { + "configs": configs, + "configs_root": str(paths.configs_root()), + "providers": list(configstore.PROVIDERS), + "credential_hints": { + provider: configstore.credential_hint(provider) + for provider in configstore.PROVIDERS + }, + # Suggestions only — the region field stays free text. See + # configstore.COMMON_REGIONS for why a closed list would misrepresent AWS. + "regions": { + provider: list(configstore.COMMON_REGIONS.get(provider, ())) + for provider in configstore.PROVIDERS + }, + } + + +@router.get("/api/labs") +async def get_labs(config_path: str | None = None) -> dict[str, t.Any]: + """List labs available as a variant source, for the new-environment form. + + ``config_path`` is optional: the create-a-config flow needs this list before + any config exists. + """ + return {"labs": await labs.discover_labs(config_path)} + + +@router.post("/api/settings") +async def update_settings(body: dict[str, t.Any]) -> dict[str, t.Any]: + """Set an LLM API key in memory without returning or persisting it.""" + api_key = (body.get("api_key") or "").strip() + api_key_env = (body.get("api_key_env") or "OPENROUTER_API_KEY").strip() + if not api_key_env: + raise HTTPException(status_code=400, detail="api_key_env is required") + if not _API_KEY_ENV_RE.match(api_key_env): + raise HTTPException( + status_code=400, + detail=( + f"api_key_env must be an API-key/token variable " + f"(e.g. *_API_KEY, *_KEY, *_TOKEN); got {api_key_env!r}" + ), + ) + if api_key: + os.environ[api_key_env] = api_key + elif not os.environ.get(api_key_env): + raise HTTPException( + status_code=400, detail=f"{api_key_env} is not set; provide an api_key" + ) + return {"ok": True, "api_key_env": api_key_env} + + +@router.get("/api/commands") +async def get_commands() -> dict[str, t.Any]: + """Return the slash-command registry for frontend autocomplete.""" + return {"commands": commands.command_catalog()} + + +def _config_path_problem(config_path: str) -> str | None: + """Explain why ``config_path`` can't be read, or None if it looks fine. + + Checked before parsing so the operator gets the sentence that matches what + they did — a typo'd path, a directory, a stray quote — instead of the + interpreter's phrasing of it. ``str(FileNotFoundError)`` renders as + "[Errno 2] No such file or directory: '/path'", which leads with an errno + and buries the path in quotes inside quotes. + """ + path = config_path.strip() + if not path: + return "Config path is required." + + # A path pasted from a shell or a YAML file often keeps its quotes, and the + # resulting "file not found" names a path that looks correct on screen. + if len(path) >= 2 and path[0] in "\"'" and path[-1] == path[0]: + return ( + f"Config path is wrapped in {path[0]} quotes — remove them and use " + f"the bare path: {path[1:-1]}" + ) + + # Deliberately NOT expanded. This value is stored as the session's anchor + # and handed to open() and to the Go CLI's --config, none of which expand + # ~. Accepting it here would list the environments happily and then fail on + # CREATE with the raw FileNotFoundError — the same error one step later, + # which is worse than refusing it now. The expansion is offered as text so + # it can be pasted straight back into the field. + if path.startswith("~"): + return ( + f"Config path must be a full path — ~ is not expanded here. " + f"Use: {os.path.expanduser(path)}" + ) + + expanded = path + if not os.path.isabs(expanded): + return f"Config path must be absolute; got {path!r}." + if os.path.isdir(expanded): + return ( + f"{expanded} is a directory, not a config file. " + "Point at the dreadgoad.yaml inside it." + ) + if not os.path.exists(expanded): + # Name the nearest existing ancestor: it separates "one typo in the + # filename" from "this whole directory is wrong", which are different + # things to go and check. + parent = os.path.dirname(expanded) or "/" + if os.path.isdir(parent): + return ( + f"No such file: {expanded}. The directory {parent} exists — " + "check the filename." + ) + return f"No such file: {expanded}. The directory {parent} does not exist." + if not os.access(expanded, os.R_OK): + return f"{expanded} exists but is not readable (check permissions)." + return None + + +@router.get("/api/environments") +async def get_environments(config_path: str) -> dict[str, t.Any]: + """Return environment names defined in a configuration file.""" + problem = _config_path_problem(config_path) + if problem is not None: + raise HTTPException(status_code=400, detail=problem) + try: + # .strip() only: the path must stay byte-identical to what session + # creation will store and open, or this endpoint validates something + # other than what gets used. + return labconfig.list_environments(config_path.strip()) + except yaml.YAMLError as exc: + # A parse error's own message carries the line/column, which is the + # useful part; label it so it is clear the file was found and read. + raise HTTPException( + status_code=400, detail=f"{config_path} is not valid YAML: {exc}" + ) from exc + except (FileNotFoundError, ValueError, OSError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/console/backend/configstore.py b/console/backend/configstore.py new file mode 100644 index 00000000..3c7aa9d7 --- /dev/null +++ b/console/backend/configstore.py @@ -0,0 +1,237 @@ +"""Which config files the console knows about, and where new ones go. + +Split from :mod:`labconfig`, which reads and writes what is *inside* a config. +This module answers the questions that come first: which ``dreadgoad.yaml`` +files exist, where a new one should be written, and whether the provider it +names has credentials available to it. + +The console has always been able to *drive* more than one config — every CLI +spawn carries ``--config`` (commands.py:413-415) and derives its working +directory from that config's own tree (projectroot.run_cwd). What was missing +was any way to see or create them, which is what this provides. +""" + +from __future__ import annotations + +import os +import re +import typing as t +from pathlib import Path + +import yaml + +from . import labconfig, paths + +# Only these are offered in the create UI; see labconfig.CONSOLE_PROVIDERS for +# why the other two CLI providers are excluded. +PROVIDERS = labconfig.CONSOLE_PROVIDERS + +_SLUG_STRIP = re.compile(r"[^a-z0-9]+") + +# Suggestions for the region field — NOT a closed set. The field stays free +# text because "which regions work" is not the same question for the two +# providers: +# +# azure Hosts come from a marketplace image (publisher/offer/sku in the +# host terragrunt), which exists in essentially every region, so any +# value here is plausible. +# aws Hosts resolve a warpgate-built AMI with owners = ["self"] +# (infra/goad-deployment/.../goad/dc01/terragrunt.hcl). AMIs are +# region-scoped and are not copied automatically, so a region only +# works where one has been built — offering all ~35 as a closed +# dropdown would present mostly choices that fail at apply time. +# +# The regions this repo already deploys into are listed first, since those are +# the ones known to work here. +COMMON_REGIONS: dict[str, tuple[str, ...]] = { + "aws": ( + "us-west-1", + "us-east-2", + "us-east-1", + "us-west-2", + "eu-west-1", + "eu-west-2", + "eu-central-1", + "ap-southeast-2", + ), + "azure": ( + "centralus", + "eastus", + "eastus2", + "westus2", + "westus3", + "northeurope", + "westeurope", + "uksouth", + "australiaeast", + ), +} + + +def slug_for(name: str) -> str: + """Reduce a user-supplied config name to a safe bare filename stem. + + The result is used to build a path, so it is restricted to ``[a-z0-9-]`` + rather than merely escaped: anything that could traverse (``/``, ``..``), + hide (a leading dot), or collide with shell/YAML handling is dropped rather + than encoded. Raises ValueError when nothing usable survives, because a + silent fallback name would put the config somewhere the operator did not + ask for and would not think to look. + """ + slug = _SLUG_STRIP.sub("-", name.strip().lower()).strip("-")[:48] + if not slug: + raise ValueError( + f"config name {name!r} has no letters or digits in it — " + "use something like 'azure-lab'" + ) + return slug + + +def path_for(name: str) -> Path: + """Absolute path a new config called ``name`` will be written to. + + The containment assertion is redundant against :func:`slug_for`'s character + set and is kept deliberately: it is the check that still holds if that + pattern is ever loosened, and this value comes from the browser. + """ + root = paths.configs_root().resolve() + candidate = (root / f"{slug_for(name)}.yaml").resolve() + if candidate.parent != root: + raise ValueError(f"refusing to write a config outside {root}") + return candidate + + +def default_config_path() -> str: + """The repo-root ``dreadgoad.yaml`` the console starts out pointed at.""" + return str(paths.repo_root() / "dreadgoad.yaml") + + +def _summarise(path: str, source: str) -> dict[str, t.Any]: + """Describe one config for the picker, reporting rather than raising. + + A config that has gone missing or unparsable still has to appear in the + list: it is very likely the one the operator is looking for, and dropping + it silently turns "my config is broken" into "my config vanished". + """ + entry: dict[str, t.Any] = { + "path": path, + "name": os.path.basename(path), + "source": source, + "provider": None, + "region": None, + "environments": [], + "error": None, + } + try: + info = labconfig.list_environments(path) + except FileNotFoundError: + entry["error"] = "file no longer exists" + except yaml.YAMLError as exc: + entry["error"] = f"not valid YAML: {exc}" + except (ValueError, OSError) as exc: + entry["error"] = str(exc) + else: + entry["provider"] = info.get("provider") + entry["region"] = info.get("region") + entry["environments"] = info.get("environments") or [] + return entry + + +# Ordering for the picker: the config the console defaults to, then the ones it +# created, then ones learned from existing sessions. Within a group, by path. +_SOURCE_ORDER = {"default": 0, "managed": 1, "session": 2} + + +def known_configs(anchor_paths: t.Iterable[str] = ()) -> list[dict[str, t.Any]]: + """Every config the console can offer, deduplicated and summarised. + + Three sources, in precedence order: the repo-root default, files under + :func:`paths.configs_root`, and the ``config_path`` anchors of existing + sessions. The last is what makes a config the operator typed by hand — one + in another checkout, say — stay in the list afterwards, without needing a + registry table to keep in sync with the sessions that are the real record. + + Deduplicated on the resolved path so the same file reached two ways appears + once, keeping the source it was first seen under. + + The resolved form is what gets reported, so the surviving entry has one + canonical path rather than whichever spelling happened to be seen first. + The same file genuinely arrives spelled differently: on macOS the configs + dir is reached as ``/var/...`` by the glob and ``/private/var/...`` once + resolved, and session anchors are stored exactly as the operator typed them + (sessions.py:67 keeps ``config_path`` verbatim). Without canonicalising, + which of those two the picker displayed would depend on iteration order. + """ + found: dict[str, dict[str, t.Any]] = {} + + def add(path: str, source: str) -> None: + try: + key = str(Path(path).expanduser().resolve()) + except OSError: + key = path + if key not in found: + found[key] = _summarise(key, source) + + add(default_config_path(), "default") + root = paths.configs_root() + for entry in sorted(root.iterdir()) if root.is_dir() else []: + if entry.is_file() and entry.suffix in (".yaml", ".yml"): + add(str(entry), "managed") + for path in anchor_paths: + if path: + add(str(path), "session") + + return sorted( + found.values(), + key=lambda c: (_SOURCE_ORDER.get(c["source"], 9), c["path"]), + ) + + +# Where each provider's credentials come from when nothing is set explicitly. +# Checked as environment variables and well-known files only — never by running +# `aws sts get-caller-identity` or `az account show`. Those are subprocesses in +# a request handler that may be absent, may prompt, and can block on a stale +# token; the answer is not worth stalling the modal for. +_CREDENTIAL_SOURCES: dict[str, tuple[tuple[str, ...], tuple[str, ...], str]] = { + "aws": ( + ( + "AWS_PROFILE", + "AWS_ACCESS_KEY_ID", + "AWS_ROLE_ARN", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + ), + ("~/.aws/credentials", "~/.aws/config"), + "AWS_PROFILE or ~/.aws", + ), + "azure": ( + ("AZURE_CLIENT_ID", "AZURE_TENANT_ID"), + ("~/.azure/azureProfile.json",), + "az login or AZURE_CLIENT_ID", + ), +} + + +def credential_hint(provider: str) -> str | None: + """An advisory note when a provider's credentials aren't visible, else None. + + Hedged on purpose, and never blocking. The checks below cannot see an EC2 + instance role, a credential_process, or an SSO session cached under a name + this does not know, so a false "not found" is entirely possible — and a + warning that is sometimes wrong is only useful if it says so. The reverse + error is cheap: finding a file proves nothing about whether the credentials + in it are valid, which is what ``dreadgoad doctor`` is for. + """ + sources = _CREDENTIAL_SOURCES.get(provider) + if sources is None: + return None + env_names, files, where = sources + if any(os.environ.get(name) for name in env_names): + return None + if any(os.path.exists(os.path.expanduser(f)) for f in files): + return None + return ( + f"No {provider} credentials found in the usual places ({where}). " + f"Creating this is still fine — deploying will fail until they exist. " + f"Run `dreadgoad doctor` to check properly." + ) diff --git a/console/backend/db.py b/console/backend/db.py new file mode 100644 index 00000000..d4f4731b --- /dev/null +++ b/console/backend/db.py @@ -0,0 +1,364 @@ +"""SQLite persistence layer (design §6). + +Document model over SQLite: each collection is a table whose payload is a JSON +column; only queried fields (event `session_id`/`seq`/`kind`) are real columns. + +Concurrency & safety: all DB work runs on a **single-worker thread executor**, +so operations serialize naturally (no lost updates) and the connection is only +ever touched from its own thread. This is the "one serialized writer" from §6.1 +without an extra dependency (stdlib ``sqlite3`` only). Note: because every op +(reads included) goes through the one worker, there is no read/write +concurrency — WAL here buys durable commits, not concurrent readers. +""" + +from __future__ import annotations + +import asyncio +import json +import sqlite3 +import typing as t +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS ranges ( + session_id TEXT PRIMARY KEY, + data TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS events ( + session_id TEXT NOT NULL, + seq INTEGER NOT NULL, + kind TEXT NOT NULL, + ts TEXT NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (session_id, seq) +); +CREATE INDEX IF NOT EXISTS idx_events_kind ON events (session_id, kind); +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +""" + + +def _utcnow() -> str: + return datetime.now(timezone.utc).isoformat() + + +class Database: + """Async wrapper over a single-threaded SQLite connection.""" + + def __init__(self, path: str) -> None: + self._path = path + self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="dg-db") + self._conn: sqlite3.Connection | None = None + + # --- lifecycle --------------------------------------------------------- + + async def connect(self) -> "Database": + """Open the connection and apply the schema. Returns self for chaining. + + On failure the worker thread is shut down rather than leaked. + """ + try: + await self._run(self._connect) + except BaseException: + # Don't leak the worker thread if connection setup fails. + self._executor.shutdown(wait=False) + raise + return self + + def _connect(self) -> None: + conn = sqlite3.connect(self._path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + # NORMAL: durable across an app crash; a power/OS crash may lose only the + # last commit. Fine for this local tool; use FULL for strict durability. + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.executescript(_SCHEMA) + conn.commit() + self._conn = conn + + async def close(self) -> None: + """Close the connection and join the worker thread.""" + await self._run(self._close) + self._executor.shutdown(wait=True) + + def _close(self) -> None: + if self._conn is not None: + self._conn.close() + self._conn = None + + async def _run(self, fn: t.Callable[..., t.Any], *args: t.Any) -> t.Any: + loop = asyncio.get_running_loop() + return await loop.run_in_executor(self._executor, fn, *args) + + @property + def _c(self) -> sqlite3.Connection: + if self._conn is None: + raise RuntimeError("Database not connected; call connect() first") + return self._conn + + # --- sessions ---------------------------------------------------------- + + async def upsert_session(self, session: dict[str, t.Any]) -> None: + """Insert or replace a session document, keyed by its ``id``.""" + await self._run(self._upsert_session, session) + + def _upsert_session(self, session: dict[str, t.Any]) -> None: + sid = session["id"] + self._c.execute( + "INSERT INTO sessions (id, data) VALUES (?, ?) " + "ON CONFLICT(id) DO UPDATE SET data=excluded.data", + (sid, json.dumps(session)), + ) + self._c.commit() + + async def get_session(self, session_id: str) -> dict[str, t.Any] | None: + """Return the session document, or None if it doesn't exist.""" + return await self._run(self._get_session, session_id) + + def _get_session(self, session_id: str) -> dict[str, t.Any] | None: + row = self._c.execute( + "SELECT data FROM sessions WHERE id=?", (session_id,) + ).fetchone() + return json.loads(row["data"]) if row else None + + async def list_sessions(self) -> list[dict[str, t.Any]]: + """Return every session document (unordered).""" + return await self._run(self._list_sessions) + + def _list_sessions(self) -> list[dict[str, t.Any]]: + rows = self._c.execute("SELECT data FROM sessions").fetchall() + return [json.loads(r["data"]) for r in rows] + + async def delete_session(self, session_id: str) -> None: + """Delete a session and cascade to its range doc and event log.""" + await self._run(self._delete_session, session_id) + + def _delete_session(self, session_id: str) -> None: + self._c.execute("DELETE FROM sessions WHERE id=?", (session_id,)) + self._c.execute("DELETE FROM ranges WHERE session_id=?", (session_id,)) + self._c.execute("DELETE FROM events WHERE session_id=?", (session_id,)) + self._c.execute("DELETE FROM meta WHERE key=?", (f"thread:{session_id}",)) + self._c.commit() + + # --- ranges ------------------------------------------------------------ + + async def upsert_range(self, session_id: str, rng: dict[str, t.Any]) -> None: + """Insert or replace a session's range topology document. + + Range discovery/health writers often hold a snapshot across network + I/O. If a layout save landed meanwhile, keep that newer layout instead + of letting the stale whole-document write restore old coordinates. + """ + await self._run(self._upsert_range, session_id, rng) + + def _upsert_range(self, session_id: str, rng: dict[str, t.Any]) -> None: + document = dict(rng) + incoming_revision = self._range_layout_revision(document) + row = self._c.execute( + "SELECT data FROM ranges WHERE session_id=?", (session_id,) + ).fetchone() + if row is not None: + current = json.loads(row["data"]) + current_revision = self._range_layout_revision(current) + if current_revision > incoming_revision: + document["layout"] = current.get("layout", {}) + incoming_revision = current_revision + document["layout_revision"] = incoming_revision + document.setdefault("layout", {}) + self._c.execute( + "INSERT INTO ranges (session_id, data) VALUES (?, ?) " + "ON CONFLICT(session_id) DO UPDATE SET data=excluded.data", + (session_id, json.dumps(document)), + ) + self._c.commit() + + @staticmethod + def _range_layout_revision(rng: dict[str, t.Any]) -> int: + revision = rng.get("layout_revision", 0) + return ( + revision + if isinstance(revision, int) + and not isinstance(revision, bool) + and revision >= 0 + else 0 + ) + + async def update_range_layout( + self, + session_id: str, + layout: dict[str, dict[str, int]], + expected_revision: int, + ) -> tuple[bool, int] | None: + """Atomically replace layout when ``expected_revision`` is current. + + ``None`` means the range does not exist. Otherwise the boolean reports + whether the write succeeded and the integer is the current/new layout + revision. The complete read-check-write runs as one database-worker job, + so range status updates cannot interleave inside it. + """ + return await self._run( + self._update_range_layout, session_id, layout, expected_revision + ) + + def _update_range_layout( + self, + session_id: str, + layout: dict[str, dict[str, int]], + expected_revision: int, + ) -> tuple[bool, int] | None: + row = self._c.execute( + "SELECT data FROM ranges WHERE session_id=?", (session_id,) + ).fetchone() + if row is None: + return None + + rng = json.loads(row["data"]) + current_revision = self._range_layout_revision(rng) + if expected_revision != current_revision: + return False, current_revision + + new_revision = current_revision + 1 + rng["layout"] = layout + rng["layout_revision"] = new_revision + self._c.execute( + "UPDATE ranges SET data=? WHERE session_id=?", + (json.dumps(rng), session_id), + ) + self._c.commit() + return True, new_revision + + async def get_range(self, session_id: str) -> dict[str, t.Any] | None: + """Return a session's range document, or None if it doesn't exist.""" + return await self._run(self._get_range, session_id) + + def _get_range(self, session_id: str) -> dict[str, t.Any] | None: + row = self._c.execute( + "SELECT data FROM ranges WHERE session_id=?", (session_id,) + ).fetchone() + if row is None: + return None + rng = json.loads(row["data"]) + rng.setdefault("layout", {}) + rng["layout_revision"] = self._range_layout_revision(rng) + return rng + + # --- events ------------------------------------------------------------ + + MAX_EVENTS = 2000 + + async def prune_events(self, session_id: str, keep: int = MAX_EVENTS) -> int: + """Delete the oldest events for a session, keeping the last ``keep``.""" + return await self._run(self._prune_events, session_id, keep) + + def _prune_events(self, session_id: str, keep: int) -> int: + row = self._c.execute( + "SELECT seq FROM events WHERE session_id=? " + "ORDER BY seq DESC LIMIT 1 OFFSET ?", + (session_id, keep - 1), + ).fetchone() + if row is None: + return 0 + cursor = self._c.execute( + "DELETE FROM events WHERE session_id=? AND seq < ?", + (session_id, row["seq"]), + ) + self._c.commit() + return cursor.rowcount + + async def append_event( + self, session_id: str, kind: str, payload: dict[str, t.Any] + ) -> int: + """Append an event, assigning a monotonic per-session ``seq``. + + Returns the assigned seq. Runs on the single DB thread, so the + read-then-insert is atomic with respect to other DB operations. + """ + return await self._run(self._append_event, session_id, kind, payload) + + def _append_event( + self, session_id: str, kind: str, payload: dict[str, t.Any] + ) -> int: + exists = self._c.execute( + "SELECT 1 FROM sessions WHERE id=?", (session_id,) + ).fetchone() + if exists is None: + raise LookupError(f"session not found: {session_id}") + row = self._c.execute( + "SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM events WHERE session_id=?", + (session_id,), + ).fetchone() + seq = int(row["next"]) + self._c.execute( + "INSERT INTO events (session_id, seq, kind, ts, payload) VALUES (?, ?, ?, ?, ?)", + (session_id, seq, kind, _utcnow(), json.dumps(payload)), + ) + self._c.commit() + return seq + + async def get_events( + self, session_id: str, kinds: t.Sequence[str] | None = None + ) -> list[dict[str, t.Any]]: + """Return events for a session ordered by ``seq``. + + ``kinds=None`` returns all events; a non-empty sequence filters to those + kinds (e.g. chat-kinds for replay); an **empty** sequence returns no + events. Each returned dict is ``{seq, kind, ts, payload}``. + """ + return await self._run(self._get_events, session_id, kinds) + + def _get_events( + self, session_id: str, kinds: t.Sequence[str] | None + ) -> list[dict[str, t.Any]]: + if kinds is not None: + if not kinds: + return [] # empty filter → no events (None means "all") + placeholders = ",".join("?" for _ in kinds) + sql = ( + f"SELECT seq, kind, ts, payload FROM events " + f"WHERE session_id=? AND kind IN ({placeholders}) ORDER BY seq" + ) + rows = self._c.execute(sql, (session_id, *kinds)).fetchall() + else: + rows = self._c.execute( + "SELECT seq, kind, ts, payload FROM events WHERE session_id=? ORDER BY seq", + (session_id,), + ).fetchall() + return [ + { + "seq": r["seq"], + "kind": r["kind"], + "ts": r["ts"], + "payload": json.loads(r["payload"]), + } + for r in rows + ] + + # --- meta -------------------------------------------------------------- + + async def set_meta(self, key: str, value: t.Any) -> None: + """Store a JSON-able value in the key/value meta table (e.g. schema version).""" + await self._run(self._set_meta, key, value) + + def _set_meta(self, key: str, value: t.Any) -> None: + self._c.execute( + "INSERT INTO meta (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", + (key, json.dumps(value)), + ) + self._c.commit() + + async def get_meta(self, key: str) -> t.Any | None: + """Return a meta value, or None if the key is unset.""" + return await self._run(self._get_meta, key) + + def _get_meta(self, key: str) -> t.Any | None: + row = self._c.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone() + return json.loads(row["value"]) if row else None diff --git a/console/backend/fetch.py b/console/backend/fetch.py new file mode 100644 index 00000000..ba5b9005 --- /dev/null +++ b/console/backend/fetch.py @@ -0,0 +1,107 @@ +"""Fetch an agent's report from the attack box for /score (design §5.2). + +The report is written on the Kali attack box; ``dreadgoad score --report`` takes +a **local** path, so ``/score`` pulls the file first. Rather than hand-rolling +scp/SSM/Bastion in Python, this drives ``dreadgoad score fetch``, which reuses +the CLI's own connection machinery: + + - **AWS**: SSM (no inbound ports). Pass the Kali instance id (learned by the + inventory sync post-deploy, see ``inventory_sync.find_attack_box``). + - **Azure**: over Azure Bastion. The CLI auto-discovers the Kali VM and its + SSH key, so nothing extra is needed. + +``build_fetch_argv`` (the command) is unit-tested; the live transfer needs cloud +and is verified manually. +""" + +from __future__ import annotations + +import os +import typing as t + +from . import commands, paths, projectroot +from .cli import Capture, capture + + +def build_fetch_argv( + session: dict[str, t.Any], + remote_path: str, + local_path: str, + repo_root: str = ".", +) -> list[str]: + """Construct a ``dreadgoad score fetch`` argv for the session's range. + + Shape: ``[bin, --config, --env, score, fetch, --remote, --local, ]``. + Raises ValueError if the provider is unsupported, or (AWS) the attack box + isn't known yet. + """ + anchor = session["anchor"] + snap = session.get("snapshot") or {} + provider = snap.get("provider") + + argv = [ + commands.resolve_bin(repo_root), + "--config", + str(anchor["config_path"]), + "--env", + str(anchor["env"]), + "score", + "fetch", + "--remote", + remote_path, + "--local", + local_path, + ] + + if provider == "aws": + box = snap.get("attack_box") + if not box: + raise ValueError( + "attack box not known yet (discovered post-deploy); " + "run a command like /instances first" + ) + argv += ["--attack-box", str(box)] + region = snap.get("region") + if region: + argv += ["--region", str(region)] + elif provider == "azure": + # The CLI auto-discovers the Kali VM + SSH key over Bastion. Don't pass + # --attack-box (an explicit Azure resource id requires --ssh-key and + # skips key auto-discovery); only forward a key if the snapshot has one. + ssh_key = (snap.get("azure") or {}).get("ssh_key") + if ssh_key: + argv += ["--ssh-key", str(ssh_key)] + else: + raise ValueError(f"unsupported provider for report fetch: {provider!r}") + + return argv + + +def local_report_path(session_dir: str, remote_path: str) -> str: + """Where the fetched report lands inside the session working dir. + + Only the remote basename is kept, so a path like ``../../etc/shadow`` lands + as ``shadow`` in the session dir. ``.``/``..`` survive basename() and would + name the session dir itself or its parent, so they fall back to the default + too — the destination must always be a *file* inside the session dir. + """ + name = os.path.basename(remote_path) + if name in ("", ".", ".."): + name = "report.jsonl" + return os.path.join(session_dir, name) + + +async def fetch_report( + session: dict[str, t.Any], + remote_path: str, + capture_command: Capture | None = None, +) -> tuple[int, str, str]: + """Fetch the report into the session dir. Returns (rc, local_path, message).""" + local = local_report_path(session["session_dir"], remote_path) + # repo_root locates the binary; the cwd decides which tree's inventory the + # fetch reaches hosts through. They are different questions — see + # projectroot.run_cwd. + argv = build_fetch_argv(session, remote_path, local, str(paths.repo_root())) + runner = capture_command or capture + rc, out, err = await runner(argv, projectroot.run_cwd(session, paths.repo_root())) + return rc, local, (err or out) diff --git a/console/backend/health_sync.py b/console/backend/health_sync.py new file mode 100644 index 00000000..3a6b7421 --- /dev/null +++ b/console/backend/health_sync.py @@ -0,0 +1,80 @@ +"""Health report parsing and per-host range overlays.""" + +from __future__ import annotations + +import json +import typing as t + + +def parse_health_report(output: str) -> dict[str, t.Any] | None: + """Extract a health report from noisy NDJSON or legacy blob output.""" + for line in output.splitlines(): + line = line.strip() + if not line.startswith("{") or '"checks"' not in line: + continue + try: + parsed = json.loads(line) + except (ValueError, TypeError): + continue + if isinstance(parsed, dict) and "checks" in parsed: + return parsed + candidates = [output] + start, end = output.find("{"), output.rfind("}") + if 0 <= start < end: + candidates.append(output[start : end + 1]) + for candidate in candidates: + try: + parsed = json.loads(candidate) + except (ValueError, TypeError): + continue + if isinstance(parsed, dict) and "checks" in parsed: + return parsed + return None + + +def host_health_from_report(checks: list[dict[str, t.Any]]) -> dict[str, str]: + """Aggregate check statuses into one verdict per upper-case host role.""" + statuses: dict[str, set[str]] = {} + for check in checks: + host = str(check.get("host") or "").upper() + if host: + statuses.setdefault(host, set()).add(str(check.get("status") or "")) + verdicts = {} + for host, seen in statuses.items(): + if "FAIL" in seen: + verdicts[host] = "unhealthy" + elif "OK" in seen: + verdicts[host] = "healthy" + else: + verdicts[host] = "unknown" + return verdicts + + +async def apply_health( + app: t.Any, session_id: str, output: str, exit_code: int +) -> dict[str, t.Any] | None: + """Overlay structured or fallback range health after ``/health``.""" + db = app.state.db + rng = await db.get_range(session_id) + if rng is None: + return None + report = parse_health_report(output) + if report is not None: + per_host = host_health_from_report(report.get("checks") or []) + for host in rng.get("hosts", []): + # Reports use config roles (DC01), while variants can rename both + # the host id and hostname. Older ranges may not yet have ``key``. + verdict = ( + per_host.get(str(host.get("key") or "").upper()) + or per_host.get(str(host.get("id") or "").upper()) + or per_host.get(str(host.get("hostname") or "").upper()) + ) + if verdict is not None: + host["health"] = verdict + else: + verdict = "healthy" if exit_code == 0 else "unhealthy" + for host in rng.get("hosts", []): + if host.get("source") == "config": + host["health"] = verdict + await db.upsert_range(session_id, rng) + return report diff --git a/console/backend/hook.py b/console/backend/hook.py new file mode 100644 index 00000000..bf8af84f --- /dev/null +++ b/console/backend/hook.py @@ -0,0 +1,7 @@ +"""Compatibility facade for post-command range synchronization.""" + +from .health_sync import apply_health +from .inventory_sync import run_check +from .topology_sync import reseed + +__all__ = ["apply_health", "reseed", "run_check"] diff --git a/console/backend/hostdetail.py b/console/backend/hostdetail.py new file mode 100644 index 00000000..4c5a35b3 --- /dev/null +++ b/console/backend/hostdetail.py @@ -0,0 +1,211 @@ +"""Attached-resource detail for one range host (disks and NICs). + +Fetched on demand rather than folded into ``lab status --json``. That command +runs on every ``/instances`` and again after every command through the ingestion +hook, and a per-VM disk/NIC lookup would multiply its cloud calls to populate a +panel nobody has open. Nothing here is read unless an operator clicks a node. + +Azure only: the CLI verb behind it type-asserts the Azure provider, the same +trade `bastion` makes. Other providers get a plain "not supported" rather than +an empty panel that looks like a VM with no disks. +""" + +from __future__ import annotations + +import json +import re +import typing as t + +from . import commands, paths, projectroot +from .cli import Capture, capture + +SUPPORTED_PROVIDERS = ("azure",) + + +class HostDetailUnavailable(Exception): + """Detail cannot be fetched, with an operator-facing reason.""" + + +def find_host(rng: dict[str, t.Any], node_id: str) -> dict[str, t.Any] | None: + """The range node with this id, or None. + + Skips entries that are not mappings rather than trusting the document's + shape: this endpoint's contract is a reason, never a stack trace, and an + AttributeError here would surface as a 500. + """ + for host in rng.get("hosts") or []: + if isinstance(host, dict) and host.get("id") == node_id: + return host + return None + + +def build_argv(config_path: str, env: str, cloud_id: str) -> list[str]: + """The ``lab describe`` invocation for one VM. + + Passes --id rather than a hostname. Hostname resolution lists every VM in + the subscription and substring-matches the name, and it matches on the + config role (dc01), not the node's id, which for a variant range is a + randomised hostname (nova) that appears nowhere in Azure. The node already + carries the resource ID, so neither cost nor ambiguity is necessary. + """ + return [ + commands.resolve_bin(str(paths.repo_root())), + "--config", + str(config_path), + "--env", + env, + "lab", + "describe", + "--id", + cloud_id, + "--json", + ] + + +def _clean_cli_error(raw: str) -> str: + """Extract a human sentence from CLI stderr. + + The Azure SDK dumps a multi-line block that includes HTTP method, URL, + status, headers, and a JSON body with ``error.message``. Operators need + the message, not the wire dump. + """ + raw = raw.strip() + msg = _extract_azure_message(raw) + if msg: + return msg + if "RESPONSE 404" in raw or "ResourceNotFound" in raw: + return "this VM no longer exists in Azure (404 ResourceNotFound)" + return raw.split("\n", 1)[0][:200] + + +def _extract_azure_message(text: str) -> str | None: + """Try to pull ``error.message`` from JSON in *text*. + + The JSON may be the whole string, or embedded between ``---`` separator + lines in the Azure SDK's wire-dump format. + """ + for candidate in _json_candidates(text): + try: + blob = json.loads(candidate) + msg = blob.get("error", {}).get("message") + if msg: + return msg + except (ValueError, AttributeError, TypeError): + continue + return None + + +def _json_candidates(text: str) -> t.Iterator[str]: + """Yield substrings that might be JSON: the whole text, then every block + of non-separator lines between ``---`` separator lines.""" + yield text + block: list[str] = [] + for line in text.splitlines(): + if line.startswith("---"): + if block: + yield "\n".join(block) + block = [] + else: + block.append(line) + if block: + yield "\n".join(block) + + +def _bastion_detail(host: dict[str, t.Any], node_id: str) -> dict[str, t.Any]: + """Summary for the Azure Bastion managed service (no ``lab describe`` call).""" + cloud_id = host.get("cloud_id") or "" + rg = "" + if cloud_id: + m = _ARM_RG_RE.match(cloud_id) + if m: + rg = m.group("rg") + return { + "node_id": node_id, + "kind": "bastion", + "name": host.get("cloud_name") or host.get("hostname") or node_id, + "status": host.get("status") or "unknown", + "cloud_id": cloud_id or None, + "resource_group": rg, + "ip_public": host.get("ip_public"), + "last_checked_at": host.get("last_checked_at"), + } + + +_ARM_RG_RE = re.compile( + r"^/subscriptions/[^/]+/resourcegroups/(?P[^/]+)/", re.IGNORECASE +) + + +async def host_detail( + session: dict[str, t.Any], + rng: dict[str, t.Any], + node_id: str, + capture_command: Capture | None = None, +) -> dict[str, t.Any]: + """Disks and NICs for one node. Raises HostDetailUnavailable with a reason.""" + host = find_host(rng, node_id) + if host is None: + raise HostDetailUnavailable(f"no host {node_id!r} in this range") + + if host.get("role") == "bastion": + return _bastion_detail(host, node_id) + + snapshot = session.get("snapshot") or {} + provider = str(snapshot.get("provider") or "") + if provider not in SUPPORTED_PROVIDERS: + raise HostDetailUnavailable( + f"attached-resource detail is not available for {provider or 'this provider'} yet" + ) + + cloud_id = host.get("cloud_id") + if cloud_id is not None and not isinstance(cloud_id, str): + # Anything else would be str()'d into a nonsense --id and spend a cloud + # call proving it. Treat a malformed value as no value. + cloud_id = None + if not cloud_id: + # The normal state before a deploy, and after one until /instances has + # run: the node is seeded from the lab config and has no cloud identity + # yet. Not an error worth a stack trace. + raise HostDetailUnavailable( + f"{host.get('hostname') or node_id} has not been deployed yet, " + "or the range has not been read since it was" + ) + + # .get, not [...]: a session row written by an older schema (or a range + # whose anchor never resolved) would raise KeyError here and turn a + # well-formed request into a 500. + anchor = session.get("anchor") or {} + config_path = anchor.get("config_path") + env = anchor.get("env") + if not config_path or not env: + raise HostDetailUnavailable( + "this session has no lab config anchored to it, so its hosts cannot be read" + ) + + argv = build_argv(str(config_path), str(env), cloud_id) + root, _ = projectroot.resolve_root(str(config_path)) + runner = capture_command or capture + try: + return_code, stdout, stderr = await runner(argv, str(root)) + except (OSError, ValueError) as exc: + raise HostDetailUnavailable( + f"could not run dreadgoad lab describe: {exc}" + ) from exc + + if return_code != 0: + raise HostDetailUnavailable( + _clean_cli_error(stderr or stdout or "lab describe failed") + ) + try: + detail = json.loads(stdout) + except ValueError as exc: + raise HostDetailUnavailable( + f"lab describe returned unreadable JSON: {exc}" + ) from exc + if not isinstance(detail, dict): + raise HostDetailUnavailable("lab describe returned an unexpected shape") + + # Echo which node this belongs to; the panel is opened per node and a + # response that cannot be tied back to one is a race waiting to render. + detail["node_id"] = node_id + return detail diff --git a/console/backend/inventory_sync.py b/console/backend/inventory_sync.py new file mode 100644 index 00000000..ccb8f6f9 --- /dev/null +++ b/console/backend/inventory_sync.py @@ -0,0 +1,198 @@ +"""Cloud instance discovery and range inventory synchronization.""" + +from __future__ import annotations + +import json +import re +import typing as t +from datetime import datetime, timezone + +from . import commands, labconfig, paths, projectroot +from .cli import Capture, capture + +_STATE = { + "running": "running", + "stopped": "stopped", + "deallocated": "stopped", + "pending": "provisioning", + "starting": "provisioning", + "creating": "provisioning", + "terminated": "absent", +} +_ALIASES = { + "attackbox": ["attackbox", "kali", "attack"], + "bastion": ["bastion"], +} +_ARM_ID_RE = re.compile( + r"^/subscriptions/(?P[^/]+)/resourcegroups/(?P[^/]+)/", re.IGNORECASE +) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _norm_state(state: str | None) -> str: + return _STATE.get((state or "").lower(), "unknown") + + +def find_attack_box(instances: list[dict[str, t.Any]]) -> str | None: + """Return the attack box cloud id among live instances, if present.""" + for instance in instances: + name = str(instance.get("name") or "").lower() + if any(alias in name for alias in _ALIASES["attackbox"]): + return instance.get("id") + return None + + +def parse_cloud_account(instances: list[dict[str, t.Any]]) -> dict[str, str]: + """Return provider-neutral account/group placement from instance data.""" + for instance in instances: + account = str(instance.get("account") or "").strip() + group = str(instance.get("group") or "").strip() + if account or group: + found = {} + if account: + found["account"] = account + if group: + found["group"] = group + return found + for instance in instances: + match = _ARM_ID_RE.match(str(instance.get("id") or "")) + if match: + return {"account": match.group("sub"), "group": match.group("rg")} + return {} + + +def _match( + host: dict[str, t.Any], instances: list[dict[str, t.Any]] +) -> dict[str, t.Any] | None: + """Match a host by config role key, with infra aliases as a fallback. + + Cloud VMs use config roles (for example ``DC01``), not variant hostnames. + Last match wins to mirror the CLI's discovery behavior. + """ + host_id = str(host.get("key") or host.get("id") or "").lower() + aliases = _ALIASES.get(host_id, [host_id]) + found: dict[str, t.Any] | None = None + for instance in instances: + name = str(instance.get("name") or "").lower() + if any(alias in name for alias in aliases): + found = instance + return found + + +def map_range_status( + rng: dict[str, t.Any], + instances: list[dict[str, t.Any]], + now: str | None = None, +) -> dict[str, t.Any]: + """Overlay cloud state without adding instances absent from the topology.""" + now = now or _now() + hosts_out: list[dict[str, t.Any]] = [] + for host in rng.get("hosts", []): + updated = dict(host) + instance = _match(host, instances) + if instance is None: + updated["status"] = "absent" + updated["health"] = "unknown" + updated["ip_private"] = None + updated["cloud_id"] = None + else: + updated["status"] = _norm_state(instance.get("state")) + updated["ip_private"] = instance.get("private_ip") or updated.get( + "ip_private" + ) + updated["cloud_id"] = instance.get("id") or updated.get("cloud_id") + updated["cloud_name"] = instance.get("name") or updated.get("cloud_name") + updated["ip_public"] = instance.get("public_ip") or updated.get("ip_public") + updated["last_checked_at"] = now + hosts_out.append(updated) + result = dict(rng) + result["hosts"] = hosts_out + result["last_checked_at"] = now + return result + + +def backfill_keys(rng: dict[str, t.Any], seeded: dict[str, t.Any]) -> bool: + """Add missing host role keys to pre-key range documents.""" + by_id = {host.get("id"): host.get("key") for host in seeded.get("hosts", [])} + changed = False + for host in rng.get("hosts", []): + if "key" not in host: + host["key"] = by_id.get(host.get("id")) or host.get("id") + changed = True + return changed + + +def summarize_changes( + before: dict[str, t.Any], after: dict[str, t.Any] +) -> dict[str, t.Any]: + """Build a check_run payload listing host status changes.""" + previous = {host["id"]: host.get("status") for host in before.get("hosts", [])} + changes = [] + for host in after.get("hosts", []): + old = previous.get(host["id"]) + if old != host.get("status"): + changes.append({"id": host["id"], "from": old, "to": host.get("status")}) + return {"hosts_updated": len(changes), "changes": changes} + + +async def run_check( + app: t.Any, + session_id: str, + capture_command: Capture | None = None, +) -> dict[str, t.Any]: + """Discover live instances and synchronize session/range inventory. + + A failed inventory read leaves the existing range and its timestamp stale; + it does not turn a successfully-running session into an error state. + """ + db = app.state.db + session = await db.get_session(session_id) + rng = await db.get_range(session_id) + if session is None or rng is None: + return {"error": "session/range not found"} + + if any("key" not in host for host in rng.get("hosts", [])): + snapshot = session.get("snapshot") or {} + config = labconfig.lab_config_path(str(paths.repo_root()), snapshot.get("lab")) + seeded = labconfig.seed_topology(config, snapshot.get("provider")) + if backfill_keys(rng, seeded): + await db.upsert_range(session_id, rng) + + argv = commands.build_argv(session, "/instances", repo_root=str(paths.repo_root())) + try: + runner = capture_command or capture + # Same tree as the commands whose results this records — see + # projectroot.run_cwd. (lab_config_path above stays on repo_root: the + # lab definitions are console-side, not part of the range's checkout.) + return_code, stdout, stderr = await runner( + argv, projectroot.run_cwd(session, paths.repo_root()) + ) + if return_code != 0: + raise RuntimeError( + f"lab status --json exited {return_code}: {stderr[-500:]}" + ) + instances = json.loads(stdout) + except Exception as exc: # noqa: BLE001 + return {"error": str(exc)} + + snapshot = session.get("snapshot") or {} + dirty = False + attack_box = find_attack_box(instances) + if attack_box and snapshot.get("attack_box") != attack_box: + snapshot["attack_box"] = attack_box + dirty = True + for key, value in parse_cloud_account(instances).items(): + if snapshot.get(key) != value: + snapshot[key] = value + dirty = True + if dirty: + session["snapshot"] = snapshot + await db.upsert_session(session) + + updated = map_range_status(rng, instances) + payload = summarize_changes(rng, updated) + await db.upsert_range(session_id, updated) + return payload diff --git a/console/backend/labconfig.py b/console/backend/labconfig.py new file mode 100644 index 00000000..948205d2 --- /dev/null +++ b/console/backend/labconfig.py @@ -0,0 +1,502 @@ +"""Derive session snapshots and seed range topology from lab config (§4.3, §6.3). + +Two inputs: + - ``dreadgoad.yaml`` → the session *snapshot* (provider/region file-level; + variant/lab/network per-env) + - ``ad//data/config.json`` → the range *topology* (hosts + roles) + +The snapshot is a cache derived from the ``(config_path, env)`` anchor; the +topology is the config-seeded node set the ingestion hook later overlays. +""" + +from __future__ import annotations + +import json +import os +import shutil +import typing as t + +import ruamel.yaml +import yaml + +from . import projectroot + +# config.json host `type` → RangeView role (§6.3). +_ROLE_BY_TYPE = { + "dc": "dc", + "server": "member", + "workstation": "workstation", +} + +# Every provider the Go CLI knows (cli/internal/provider/factory.go:10-13). +CLI_PROVIDERS = ("aws", "azure", "proxmox", "ludus") + +# The subset the console can actually drive end to end. `derive_snapshot` only +# builds a provider block for these two, `seed_topology` only knows azure's +# bastion, and the frontend's CONNECT planner dead-ends on anything else +# (frontend/src/connect.ts:90). A session on proxmox or ludus would be created +# happily and then be unable to render or connect, so the create UI offers only +# these while the CLI keeps supporting the rest. +CONSOLE_PROVIDERS = ("aws", "azure") + + +def _environments_of(data: dict[str, t.Any], config_path: str) -> dict[str, t.Any]: + """The ``environments`` mapping, or ValueError naming what was found instead. + + ``environments:`` written as a YAML *list* is the easy mistake — it is how + almost every other list-shaped key in a config file looks. Without this the + shape error surfaced as an AttributeError from ``.keys()``, which no caller + catches: the environments endpoint lists ValueError/YAMLError/OSError and + would have returned a 500 for what is a typo in the operator's own file. + """ + envs = data.get("environments") + if envs is None: + # Absent, or present-but-empty (``environments:`` with nothing under + # it). Returning a fresh mapping is only safe for readers; writers must + # attach it to the document themselves — see write_new_env. + return {} + if not isinstance(envs, dict): + raise ValueError( + f"{config_path}: 'environments' must be a mapping of name to " + f"settings, but it is a {type(envs).__name__}. It should read " + f"'environments:' followed by indented 'name:' entries, not a " + f"'-' list." + ) + # NOT ``or {}``: an existing empty mapping is falsy, and substituting a new + # one for it would hand a writer a detached dict whose contents never reach + # the file. + return envs + + +def list_environments(config_path: str) -> dict[str, t.Any]: + """List the environment names defined in a ``dreadgoad.yaml`` (+ provider/region). + + Drives the new-session env dropdown. Raises FileNotFoundError / YAMLError / + ValueError / OSError on a bad path or malformed file (surfaced as 400 by the + endpoint). + """ + with open(config_path) as f: + data = yaml.safe_load(f) or {} + if not isinstance(data, dict): + raise ValueError( + f"{config_path} is not a valid config (expected a YAML mapping)" + ) + envs = _environments_of(data, config_path) + provider = data.get("provider") + file_region = data.get("region") + + # Per environment, the region the CLI would ACTUALLY use — which is not the + # same question for both providers: + # + # aws Config.ResolveRegion (config.go:474-485): the environment's own + # region wins, the file-level key is the fallback. + # azure runInfraActionAzure reads cfg.Region directly (infra_cmd.go:215) + # and never calls ResolveRegion, so a per-environment region is + # silently ignored and only the file-level key counts. + # + # The console warns "this config sets no region" from this value, so folding + # the two together would have told an Azure operator they were fine right up + # until `up` failed with "azure region not configured". + return { + "environments": list(envs.keys()), + "provider": provider, + "region": file_region, + "env_regions": { + name: resolve_region(provider, settings, file_region) + for name, settings in envs.items() + }, + } + + +def resolve_region( + provider: str | None, env_settings: t.Any, file_region: t.Any +) -> t.Any: + """The region the CLI will actually use for one environment. + + Shared by :func:`list_environments` and :func:`derive_snapshot` so the + warning, the session header, and the region handed to ``env create`` cannot + disagree. They previously did: a per-environment region on an Azure config + made the header show a region, the scaffold target use it, and the warning + simultaneously report there was none. + + See :func:`list_environments` for why the two providers differ. + """ + if provider == "azure": + return file_region + env_region = env_settings.get("region") if isinstance(env_settings, dict) else None + return env_region or file_region + + +def derive_snapshot(config_path: str, env: str) -> dict[str, t.Any]: + """Build a session ``snapshot`` from ``(config_path, env)``. + + Provider/region are file-level (top of ``dreadgoad.yaml``); variant/lab/ + network come from the named env. Credentials are never included. + """ + with open(config_path) as f: + data = yaml.safe_load(f) or {} + if not isinstance(data, dict): + raise ValueError( + f"{config_path} is not a valid config (expected a YAML mapping)" + ) + + provider = data.get("provider") + envs = _environments_of(data, config_path) + if env not in envs: + available = ", ".join(sorted(envs)) or "none" + raise ValueError( + f"environment {env!r} not found in {config_path} (available: {available})" + ) + e = envs[env] or {} + + # Mirrors Config.ResolveRegion (config.go:474-485): the environment's own + # region wins, and the file-level key is the fallback for environments that + # don't declare one. Reading only the file-level key made the header show a + # region the CLI would not actually use. The CLI's highest-precedence source + # — --region / DREADGOAD_REGION — is deliberately not consulted: it belongs + # to a single invocation, not to the environment this snapshot describes. + region = resolve_region(provider, e, data.get("region")) + + variant_target = e.get("variant_target") + variant_source = e.get("variant_source") + lab = variant_target or variant_source + + snapshot: dict[str, t.Any] = { + "provider": provider, + "region": region, + "lab": lab, + "variant_name": e.get("variant_name"), + "vpc_cidr": e.get("vpc_cidr"), + "attack_box": None, # discovered post-deploy + } + # Provider-specific block (selectors only, never secrets). + if provider == "aws": + snapshot["aws"] = {"profile": None} + elif provider == "azure": + # Where the range landed (subscription/resource group) is NOT here — + # the ingestion hook learns it post-deploy and writes it to the + # snapshot's provider-neutral ``account``/``group`` (see + # inventory_sync.py), so the RangeView header reads one pair of keys + # for every provider. + snapshot["azure"] = {"ssh_key": None, "ssh_user": "kali"} + return snapshot + + +def _role_for(host_type: str) -> str: + return _ROLE_BY_TYPE.get((host_type or "").lower(), "other") + + +def _blank_dynamic() -> dict[str, t.Any]: + return { + "status": "unknown", + "health": "unknown", + "ip_private": None, + "ip_public": None, + "cloud_id": None, + "cloud_name": None, # provider VM name, learned from the ingestion hook + "last_checked_at": None, + } + + +def seed_topology( + lab_config_path: str | None, provider: str | None +) -> dict[str, t.Any]: + """Seed a range's node set from ``config.json`` + infra nodes (§6.3). + + 3-way merge, v1 subset: + - **config** hosts from ``config.json`` (``type`` → role) + - **infra** nodes not in the lab config: attack box always; bastion for + Azure (SSM has no bastion node on AWS) + Extension machines are NOT produced here — ``topology_sync.reseed`` augments this + with enabled extensions' machines (from ``extension list --json``) when + ``/extensions`` runs. + Edges are deferred (v1 nodes-only), so ``edges`` is empty. + + If ``lab_config_path`` is None or missing (greenfield range whose variant + isn't generated yet), only infra nodes are seeded; a later re-seed picks up + the config hosts once they exist. + """ + hosts_cfg: dict[str, t.Any] = {} + if lab_config_path and os.path.isfile(lab_config_path): + with open(lab_config_path) as f: + cfg = json.load(f) + hosts_cfg = (cfg.get("lab") or {}).get("hosts") or {} + + hosts: list[dict[str, t.Any]] = [] + for _key, h in hosts_cfg.items(): + hostname = h.get("hostname", _key) + host = { + "id": hostname, + # The config key (``dc01``) is the CLI's host *role*, and it's what + # cloud instances are named after — a variant renames the hostname + # (``solar``) but not the VM. Keep it for instance correlation and + # for matching per-host health results (§6.4). + "key": _key, + "hostname": hostname, + "role": _role_for(h.get("type", "")), + "source": "config", + "domain": h.get("domain"), + **_blank_dynamic(), + } + hosts.append(host) + + # Infra nodes (not in the lab config). + hosts.append( + { + "id": "attackbox", + "key": "attackbox", + "hostname": "attackbox", + "role": "attackbox", + "source": "infra", + "domain": None, + **_blank_dynamic(), + } + ) + if provider == "azure": + hosts.append( + { + "id": "bastion", + "key": "bastion", + "hostname": "bastion", + "role": "bastion", + "source": "infra", + "domain": None, + **_blank_dynamic(), + } + ) + + return {"hosts": hosts, "edges": [], "layout": {}, "last_checked_at": None} + + +_DYNAMIC_FIELDS = ( + "status", + "health", + "ip_private", + "ip_public", + "cloud_id", + "cloud_name", + "last_checked_at", +) + + +def merge_reseed( + existing: dict[str, t.Any], seeded: dict[str, t.Any] +) -> dict[str, t.Any]: + """Re-seed a range's node set while preserving live state + layout (§6.3). + + Used after ``/extensions`` / ``/variant`` change the topology: the node set + becomes ``seeded`` (adds new machines like ELK, drops removed ones), but + surviving hosts keep their dynamic fields (status/health/ip/…) and saved + positions. + """ + old = {h["id"]: h for h in existing.get("hosts", [])} + hosts: list[dict[str, t.Any]] = [] + for h in seeded.get("hosts", []): + if h["id"] in old: + merged = dict(h) + prev = old[h["id"]] + for k in _DYNAMIC_FIELDS: + if k in prev: + merged[k] = prev[k] + hosts.append(merged) + else: + hosts.append(h) + keep = {h["id"] for h in hosts} + layout = {k: v for k, v in existing.get("layout", {}).items() if k in keep} + out = dict(existing) + out["hosts"] = hosts + out["edges"] = seeded.get("edges", []) + out["layout"] = layout + return out + + +def session_lab_config_path( + session: dict[str, t.Any], fallback_root: str +) -> str | None: + """Where a session's lab config lives, resolved in the config's own tree. + + ``lab`` is repo-relative (``ad/GOAD-redteam``), so it only means anything + against the right root. Both seeders previously resolved it against the + *console's* repo — which is correct only while every config lives there. + A config in another checkout has its ``ad/`` in that checkout, so the lookup + missed, ``seed_topology`` took its greenfield path, and the range came up + with infra nodes and no hosts. Identical symptom to seeding before the + variant exists, and reached by a completely different route. + + Mirrors what the CLI itself will do for this session (projectroot.run_cwd). + """ + anchor = session.get("anchor") or {} + config_path = anchor.get("config_path") + root = ( + str(projectroot.resolve_root(config_path)[0]) if config_path else fallback_root + ) + return lab_config_path(root, (session.get("snapshot") or {}).get("lab")) + + +def lab_config_path(repo_root: str, lab: str | None) -> str | None: + """Resolve ``ad//data/config.json`` under the repo root. + + ``lab`` is a repo-relative dir like ``ad/GOAD-dreadindex``. Returns None if + ``lab`` is unset. + """ + if not lab: + return None + return os.path.join(repo_root, lab, "data", "config.json") + + +def _round_trip_yaml() -> ruamel.yaml.YAML: + """A YAML handler that reads and writes a file without rewriting it. + + ruamel's round-trip mode keeps comments, key order, blank lines and quoting + styles attached to the data, so dumping a loaded document reproduces + everything it did not change. + + ``width`` is raised because the default (80) re-wraps long scalars that were + on one line — a wrapped CIDR or resource path is still valid YAML but shows + up as noise in the operator's diff of their own file. + """ + handler = ruamel.yaml.YAML() + handler.preserve_quotes = True + handler.width = 4096 + return handler + + +def write_new_env( + config_path: str, + env_name: str, + env_fields: dict[str, t.Any], + top_level: dict[str, t.Any] | None = None, +) -> str: + """Add/replace an env entry in a ``dreadgoad.yaml`` (create-new flow, §4.3). + + Backs up the file first (if it exists). ``top_level`` sets file-level keys + (``provider``/``region``) shared by all envs. + + Rewritten through ruamel's round-trip loader rather than ``yaml.safe_dump``. + The checked-in ``dreadgoad.yaml`` is a documented template — the Proxmox key + reference, the provider and region hints — and safe_dump reproduced only the + data, so adding one environment through the console deleted 44 lines of + comments from a tracked file. The backup was the only thing standing between + that and a silent loss, and a backup you have to notice is not a safety net. + + Falls back to a plain construction when the file does not exist yet: there + is nothing to preserve, and a config created here gets its own file rather + than sharing this one (see :func:`create_config`). + """ + handler = _round_trip_yaml() + data: t.Any = {} + if os.path.isfile(config_path): + try: + with open(config_path) as f: + data = handler.load(f) + except ruamel.yaml.YAMLError as exc: + # ruamel's YAMLError shares no ancestry with pyyaml's beyond + # Exception, so it slips past every caller's `except yaml.YAMLError` + # — the create-environment route turned a malformed config from a + # 400 into a 500 the moment this function stopped using safe_load. + # Converting here keeps which YAML library is in use an implementation + # detail of this module, which is where that choice was made. + raise ValueError(f"{config_path} is not valid YAML: {exc}") from exc + if data is None: + data = {} + if not isinstance(data, dict): + raise ValueError( + f"{config_path} is not a valid config (expected a YAML mapping)" + ) + backup_yaml(config_path) + + if top_level: + data.update(top_level) + # Attach the mapping to the document *before* resolving it, so `envs` is + # always the object that gets dumped rather than a copy of it. + if data.get("environments") is None: + data["environments"] = {} + envs = _environments_of(data, config_path) + envs[env_name] = env_fields + + with open(config_path, "w") as f: + handler.dump(data, f) + return config_path + + +def create_config( + config_path: str, + provider: str, + env_name: str, + env_fields: dict[str, t.Any], + region: str | None = None, +) -> str: + """Write a brand-new ``dreadgoad.yaml`` with one environment in it. + + Deliberately NOT :func:`write_new_env` with an absent file. That function + merges into whatever is already on disk and keeps a ``.bak`` — right for + adding an environment, wrong here: pointed at an existing config it would + silently adopt someone else's provider and environments as the "new" one. + Refusing instead matches ``dreadgoad init`` (cli/cmd/init.go:51-53), which + is the CLI-side equivalent of this call. + + ``region`` is written file-level rather than onto the environment because + it is collected as a property of the config. ``Config.ResolveRegion`` + (config.go:474-485) treats the file-level key as the fallback for + environments that don't declare their own, so a later per-environment + region overrides this without the two disagreeing. + + Raises FileExistsError if ``config_path`` is taken, ValueError on an unknown + provider or an empty environment name. + """ + if provider not in CLI_PROVIDERS: + raise ValueError( + f"unknown provider {provider!r} (expected one of " + f"{', '.join(CLI_PROVIDERS)})" + ) + if not env_name.strip(): + raise ValueError("environment name is required") + if os.path.exists(config_path): + raise FileExistsError( + f"{config_path} already exists — refusing to overwrite it" + ) + + data: dict[str, t.Any] = {"provider": provider} + if region: + data["region"] = region + # The default environment for a bare `dreadgoad ...` run next to this file. + # The console always passes --env explicitly (commands.py:413-415), so this + # only matters when the operator picks the config up by hand — which is + # exactly when a missing default is most confusing. + data["env"] = env_name + data["environments"] = {env_name: env_fields} + + os.makedirs(os.path.dirname(config_path) or ".", exist_ok=True) + # Written 0o600, not the 0o644 of `dreadgoad config init` (config_cmd.go:112): + # a proxmox password or ludus api_key can be added to this file later, and + # widening permissions after the fact is a step nobody takes. + fd = os.open(config_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as f: + yaml.safe_dump(data, f, sort_keys=False) + return config_path + + +MAX_BACKUPS = 5 + + +def backup_yaml(config_path: str) -> str: + """Write a versioned backup copy of a yaml before mutating it (§4.3). + + Returns the backup path (``.bak.N`` with the next free N). + Keeps at most :data:`MAX_BACKUPS` copies; older ones are removed. + """ + parent = os.path.dirname(config_path) or "." + prefix = os.path.basename(config_path) + ".bak." + max_n = 0 + for entry in os.listdir(parent): + if entry.startswith(prefix) and entry[len(prefix) :].isdigit(): + max_n = max(max_n, int(entry[len(prefix) :])) + n = max_n + 1 + backup = f"{config_path}.bak.{n}" + shutil.copy2(config_path, backup) + for old in range(1, n + 1 - MAX_BACKUPS): + try: + os.remove(f"{config_path}.bak.{old}") + except FileNotFoundError: + pass + return backup diff --git a/console/backend/labs.py b/console/backend/labs.py new file mode 100644 index 00000000..37fc024b --- /dev/null +++ b/console/backend/labs.py @@ -0,0 +1,102 @@ +"""Which base labs a variant can be generated from. + +Backed by ``dreadgoad lab list --json`` rather than a second implementation of +the same directory walk in Python. Discovery already knows things the console +would otherwise have to re-derive and keep in step — which providers each lab +ships terraform for, and which hosts it defines — and getting that from the CLI +means it cannot drift from what ``variant generate`` will actually accept. + +The one thing added on top is whether a lab is itself a *generated* variant. +``lab.DiscoverLabs`` filters those by a substring match on the directory name +(``strings.Contains(name, "-variant-")``, discovery.go:44), which only catches +the old ``ad/GOAD-variant-1`` default. Every variant the console creates is +named ``-``, so none of them match, and they accumulate in +the list looking like base labs. The generator writes ``mapping.json`` into every +target it produces (generator.go:1197), so that file is the reliable marker, and +it is what this module reports. +""" + +from __future__ import annotations + +import json +import os +import typing as t + +from . import commands, paths, projectroot +from .cli import Capture, capture + +# Written by the variant generator into every directory it produces. Presence is +# what distinguishes a generated variant from a lab someone authored. +_VARIANT_MARKER = "mapping.json" + + +async def discover_labs( + config_path: str | None = None, + capture_command: Capture | None = None, +) -> list[dict[str, t.Any]]: + """List the labs available as a ``variant_source``, newest CLI view. + + ``config_path`` scopes discovery to that config's own tree, the way every + other spawn does (projectroot.run_cwd). It is optional because the modal + needs this list *before* a config exists when creating one — and a config + created by the console lives under the repo, so the repo root is the right + project root for it either way. + + Returns [] rather than raising: a missing binary or an unreadable ``ad/`` + should leave the operator with a free-text fallback, not a modal that + cannot open. + """ + argv = [commands.resolve_bin(str(paths.repo_root()))] + if config_path: + argv += ["--config", str(config_path)] + cwd = projectroot.run_cwd( + {"anchor": {"config_path": config_path}}, paths.repo_root() + ) + else: + # No config yet. `lab list` falls back to its working directory as the + # project root, so this must be a tree that actually has an `ad/`. + cwd = str(paths.repo_root()) + argv += ["lab", "list", "--json"] + + runner = capture_command or capture + try: + return_code, stdout, _stderr = await runner(argv, cwd) + except (OSError, ValueError): + # A missing or non-executable binary raises from create_subprocess_exec + # rather than returning a non-zero code, so the return-code check below + # never sees it. This is the *most likely* failure here — resolve_bin + # falls back to an expected path when nothing is built — and letting it + # out turns "no labs to list" into a 500 on the whole modal. + return [] + if return_code != 0: + return [] + try: + found = json.loads(stdout) + except Exception: # noqa: BLE001 + return [] + if not isinstance(found, list): + return [] + + labs: list[dict[str, t.Any]] = [] + for entry in found: + if not isinstance(entry, dict) or not entry.get("name"): + continue + name = str(entry["name"]) + path = str(entry.get("path") or "") + labs.append( + { + "name": name, + # What goes into `variant_source`, which is repo-relative while + # the CLI reports an absolute path. Labs always live at + # /ad/ (discovery.go:32,48), so this is a + # reconstruction rather than a guess. + "dir": f"ad/{name}", + "providers": entry.get("providers") or [], + "hosts": entry.get("hosts") or [], + "generated": bool(path) + and os.path.isfile(os.path.join(path, _VARIANT_MARKER)), + } + ) + # Base labs first, then generated variants; alphabetical within each group. + labs.sort(key=lambda lab: (lab["generated"], lab["name"].lower())) + return labs diff --git a/console/backend/paths.py b/console/backend/paths.py new file mode 100644 index 00000000..185eefa3 --- /dev/null +++ b/console/backend/paths.py @@ -0,0 +1,134 @@ +"""Filesystem locations for the console (see design §10.2). + +State lives under the gitignored ``.dreadgoad/console/`` runtime root: + - ``state.db`` the SQLite DB + - ``sessions/-/`` per-session working dir (agent fs sandbox) +""" + +from __future__ import annotations + +import os +from pathlib import Path + +_ENV_PREFIX = "DREADGOAD_CONSOLE_" +_LEGACY_ENV_PREFIX = "DREADGOAD_WEBAPP_" + +# The model a session runs on when nothing else names one. +# +# Defined here, in the leaf module every consumer already imports, so the +# literal exists exactly once. It previously appeared in three places -- the +# API, the chat runtime, and the launcher's banner -- which is one value with +# three owners: change any one and the launcher prints a default the agent +# is not using. +FALLBACK_MODEL = "openrouter/anthropic/claude-sonnet-5" + + +def setting(name: str, default: str | None = None) -> str | None: + """Read a console setting from the environment, e.g. ``setting("PORT")``. + + Accepts the pre-rename ``DREADGOAD_WEBAPP_*`` spelling as a fallback, so a + shell that already exports the old names keeps working. Empty values count + as unset, which is what an exported-but-blank var means in practice. + """ + return ( + os.environ.get(_ENV_PREFIX + name) + or os.environ.get(_LEGACY_ENV_PREFIX + name) + or default + ) + + +def default_model() -> str: + """The model to use when a caller names none. + + ``DREADGOAD_CONSOLE_MODEL`` (or the legacy ``DREADGOAD_WEBAPP_MODEL``), + else :data:`FALLBACK_MODEL`. Read on each call rather than captured at + import, so a value exported after startup is still honoured. + """ + return setting("MODEL") or FALLBACK_MODEL + + +def repo_root() -> Path: + """Locate the DreadGOAD repo root (contains ``dreadgoad.yaml`` / ``ad/``). + + Walks up from this file; falls back to the cwd. The CLI is invoked with + ``cwd = repo_root`` so it can read ``ad/``, ``infra/``, ``dreadgoad.yaml``. + """ + here = Path(__file__).resolve() + # The repo root is the ancestor dir containing the lab definitions (``ad/``). + for parent in here.parents: + if (parent / "ad").is_dir(): + return parent + return Path.cwd() + + +def state_root() -> Path: + """``.dreadgoad/console/`` under the repo root, created if missing. + + Overridable via ``DREADGOAD_CONSOLE_STATE_ROOT`` (used by tests to isolate + the DB and session dirs from the repo). + + Migrates the pre-rename ``.dreadgoad/webapp/`` root on first use so existing + sessions, chat history and range state survive. Only when the new root does + not exist, so newer data can never be clobbered; a failed move is not fatal + (we just start fresh rather than refusing to boot). + """ + override = setting("STATE_ROOT") + if override: + root = Path(override) + else: + root = repo_root() / ".dreadgoad" / "console" + legacy = repo_root() / ".dreadgoad" / "webapp" + if legacy.is_dir() and not root.exists(): + root.parent.mkdir(parents=True, exist_ok=True) + try: + legacy.rename(root) + except OSError: + pass + root.mkdir(parents=True, exist_ok=True) + return root + + +def db_path() -> Path: + """Absolute path to the SQLite state DB.""" + return state_root() / "state.db" + + +def sessions_root() -> Path: + """``.dreadgoad/console/sessions/`` — per-session working dirs live here.""" + root = state_root() / "sessions" + root.mkdir(parents=True, exist_ok=True) + return root + + +def configs_root() -> Path: + """``.dreadgoad/console/configs/`` — configs the console itself created. + + Under the state root rather than beside it so the existing + ``DREADGOAD_CONSOLE_STATE_ROOT`` override isolates tests from the real repo, + and so everything the console writes lives in one place. + + Two properties matter for what lands here. It is gitignored (``.gitignore`` + line 17), so a ludus ``api_key`` or proxmox ``password`` written into one of + these cannot be committed by accident — unlike the repo-root + ``dreadgoad.yaml``, which is tracked. And it sits under the repo, so the + CLI's project-root walk finds ``ansible/`` above it and resolves inventory + and lab data in the right tree (see projectroot.py). Overriding the state + root to somewhere outside the repo breaks that second property; + ``projectroot.preflight`` detects it and warns rather than failing silently. + """ + root = state_root() / "configs" + root.mkdir(parents=True, exist_ok=True) + return root + + +def session_dir(dirname: str) -> Path: + """Working dir for a session (``-``), created if missing.""" + d = sessions_root() / dirname + d.mkdir(parents=True, exist_ok=True) + return d + + +# Allow overriding the DB path in tests via env var. +def resolve_db_path() -> str: + """The DB path, with ``DREADGOAD_CONSOLE_DB`` overriding the default.""" + return setting("DB") or str(db_path()) diff --git a/console/backend/projectroot.py b/console/backend/projectroot.py new file mode 100644 index 00000000..2ffdf6de --- /dev/null +++ b/console/backend/projectroot.py @@ -0,0 +1,142 @@ +"""Where the CLI will look for a range's files, and whether they are there. + +The dreadgoad CLI takes ``--config`` for the config file, but resolves every +*other* path from a "project root" it infers by walking up from its working +directory looking for an ``ansible/`` dir (cli/internal/config/config.go, +findProjectRoot). Inventory, lab data, ansible.cfg and the cache all hang off +that root: + + /-inventory config.go:192 + /ad/GOAD/data config.go:247 + /ansible/ansible.cfg config.go:308 + /.dreadgoad/cache config.go:254 + +So the config path and the working directory are two independent inputs, and +the console used to supply a fixed working directory (its own repo) regardless +of where the config lived. Pointing the console at a config in another checkout +therefore resolved the range's files into the console's tree, where they do not +exist — an operator saw 22 identical "inventory not found" failures, twenty-five +minutes after asking, for a file that was sitting next to their config the whole +time. + +Running the CLI in the config's own directory makes the inference land where the +operator would have landed running it by hand. This module computes that +directory and reports what is missing, so the answer arrives before the spawn +rather than after a full sweep of timeouts. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field +from pathlib import Path + +# The directory whose presence marks a project root. Must match the Go walk +# (config.go:506) — if that marker changes, this silently starts disagreeing +# with the CLI it exists to predict. +ROOT_MARKER = "ansible" + + +@dataclass(frozen=True) +class Preflight: + """What the CLI will resolve for a session, and what is missing.""" + + root: Path + """Directory the CLI will treat as the project root.""" + + marker_found: bool + """True if ROOT_MARKER was found. False means the walk fell back, so the + root is a guess and every derived path is suspect.""" + + inventory: Path + """Where ``-inventory`` is expected.""" + + warnings: list[str] = field(default_factory=list) + """Operator-facing problems, most specific first. Empty means all good.""" + + +def resolve_root(config_path: str | Path) -> tuple[Path, bool]: + """Find the project root for ``config_path``; mirrors the CLI's own walk. + + Starts at the config's directory and walks up looking for ``ansible/``. + Returns ``(root, marker_found)``. When nothing is found the starting + directory is returned with ``marker_found=False`` — the same fallback the + CLI makes (config.go:515 returns cwd), so the two agree on the answer even + when the answer is a guess. + """ + start = Path(config_path).expanduser().resolve().parent + for candidate in (start, *start.parents): + if (candidate / ROOT_MARKER).is_dir(): + return candidate, True + return start, False + + +def preflight( + config_path: str | Path, env: str, *, check_inventory: bool = True +) -> Preflight: + """Predict the CLI's path resolution and report what is missing. + + Warnings are advisory on purpose. Refusing to run would break commands that + work today — ``/instances`` reads the cloud API and needs no inventory at + all. The point is that the operator learns about a missing file when they + ask, instead of inferring it from a wall of identical host failures later. + + ``check_inventory`` should be False for commands that never reach into a + host, so the warning does not fire on every cloud-only read. The caller + decides, because which commands need an inventory is a property of the CLI's + verbs (provision.go, lab_reset.go, runcmd.go, up.go, and health via + config/provider.go) rather than of a path. + """ + root, marker_found = resolve_root(config_path) + inventory = root / f"{env}-inventory" + warnings: list[str] = [] + + if not marker_found: + warnings.append( + f"No {ROOT_MARKER}/ directory at or above {root}, so the CLI will " + f"treat {root} as the project root by fallback. Inventory, lab data " + f"and ansible config will all be resolved from there. Set " + f"project_root in the config to make this explicit." + ) + + if check_inventory and not inventory.exists(): + # Name a sibling if one exists: the usual cause is a config pointed at + # the wrong environment, or an inventory that was never generated, and + # the two look identical from the error alone. + siblings = sorted(p.name for p in root.glob("*-inventory") if p.is_file()) + detail = f"Found instead: {', '.join(siblings)}." if siblings else "" + warnings.append( + f"No inventory at {inventory}. Commands that reach into hosts " + f"(/health, /provision, /reset, /exec) will fail for every host " + f"until it exists. {detail}".strip() + ) + + return Preflight( + root=root, marker_found=marker_found, inventory=inventory, warnings=warnings + ) + + +def config_path_of(session: dict[str, t.Any]) -> str | None: + """The config path recorded on a session's anchor, if it has one.""" + anchor = session.get("anchor") or {} + path = anchor.get("config_path") + return str(path) if path else None + + +def run_cwd(session: dict[str, t.Any], default: str | Path) -> str: + """Working directory for any CLI spawn on behalf of ``session``. + + Every spawn must agree on this. The console has four — the operator/agent + command pipeline, file fetches, and the inventory and topology syncs — and + a subset running in a different tree would resolve a different inventory + and lab data than the commands whose results they are recording. + + ``default`` is used when the session has no anchor to derive from; it is + the console's own repo root, which is where the binary lives and the only + sensible guess left. + """ + config_path = config_path_of(session) + if not config_path: + return str(default) + root, _ = resolve_root(config_path) + return str(root) diff --git a/console/backend/prompts/exec.md b/console/backend/prompts/exec.md new file mode 100644 index 00000000..2c50d5c5 --- /dev/null +++ b/console/backend/prompts/exec.md @@ -0,0 +1,65 @@ +`exec` runs a script on range hosts through the **cloud control plane** (Azure Run +Command / AWS SSM), not over WinRM. That is the whole point: it reaches a host whose +WinRM listener is down, so it works when `/health`, `/provision` and Ansible cannot. + +## Flags + +- `--hosts dc02` or `--hosts dc01,dc03` — **required**, no default. A token must name a + host exactly or match one dash-delimited segment of its VM name (`dc02` matches + `dreadindex-dreadgoad-DC02-vm`). A partial token like `dc0` is rejected, not + silently expanded to three DCs. An unknown host errors and lists the real ones. +- `--cmd ' + + diff --git a/console/frontend/package-lock.json b/console/frontend/package-lock.json new file mode 100644 index 00000000..cfd57c9b --- /dev/null +++ b/console/frontend/package-lock.json @@ -0,0 +1,3570 @@ +{ + "name": "dreadgoad-console", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dreadgoad-console", + "version": "0.1.0", + "dependencies": { + "@xyflow/react": "^12.3.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@xyflow/react": { + "version": "12.11.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", + "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.79", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.79", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", + "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.399", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", + "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/console/frontend/package.json b/console/frontend/package.json new file mode 100644 index 00000000..dda64069 --- /dev/null +++ b/console/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "dreadgoad-console", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "test:layout": "esbuild tests/layoutSaveQueue.test.ts --bundle --platform=node --format=esm --outfile=node_modules/.cache/dreadgoad-layout-save-test.mjs && node node_modules/.cache/dreadgoad-layout-save-test.mjs", + "test:connect": "esbuild tests/connect.test.ts --bundle --platform=node --format=esm --outfile=node_modules/.cache/dreadgoad-connect-test.mjs && node node_modules/.cache/dreadgoad-connect-test.mjs", + "test:history": "esbuild tests/history.test.ts --bundle --platform=node --format=esm --outfile=node_modules/.cache/dreadgoad-history-test.mjs && node node_modules/.cache/dreadgoad-history-test.mjs", + "test:apierror": "esbuild tests/apierror.test.ts --bundle --platform=node --format=esm --outfile=node_modules/.cache/dreadgoad-apierror-test.mjs && node node_modules/.cache/dreadgoad-apierror-test.mjs", + "test:tooltip": "esbuild tests/tooltip.test.ts --bundle --platform=node --format=esm --outfile=node_modules/.cache/dreadgoad-tooltip-test.mjs && node node_modules/.cache/dreadgoad-tooltip-test.mjs", + "preview": "vite preview" + }, + "dependencies": { + "@xyflow/react": "^12.3.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.0" + } +} diff --git a/console/frontend/src/App.tsx b/console/frontend/src/App.tsx new file mode 100644 index 00000000..c3d088dc --- /dev/null +++ b/console/frontend/src/App.tsx @@ -0,0 +1,401 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import TerminalChat from './components/TerminalChat' +import RangeView from './components/RangeView' +import Modal from './components/Modal' +import ConfirmModal from './components/ConfirmModal' +import NewSessionModal from './components/NewSessionModal' +import { Field, btnStyle } from './components/FormFields' +import { useWebSocket } from './hooks/useWebSocket' +import { api, type AppConfig } from './api' +import type { ChatEvent, Session } from './types' + +const MIN_W = 320 +const DEFAULT_RATIO = 0.45 +const MAX_PROGRESS = 200 + +// Monotonic client-side id → stable React keys for chat events (F3). +let _cid = 0 +const withCid = (ev: ChatEvent): ChatEvent => ({ ...ev, _cid: ++_cid }) + +export default function App() { + const [sessions, setSessions] = useState([]) + const [activeId, setActiveId] = useState(null) + const [msgs, setMsgs] = useState>({}) + const [cfg, setCfg] = useState(null) + const [ratio, setRatio] = useState(DEFAULT_RATIO) + const [showNew, setShowNew] = useState(false) + const [showSettings, setShowSettings] = useState(false) + // Per-session counter bumped when the range changes, so RangeView re-fetches (F1). + const [rangeRefresh, setRangeRefresh] = useState>({}) + // Per-session "a turn is in flight" flag → drives the cancel affordance. + const [processing, setProcessing] = useState>({}) + // In-flight command name per session → warn before cancelling a destructive one. + const [procCmd, setProcCmd] = useState>({}) + // When the in-flight turn started (epoch ms), so the elapsed timer survives a + // reload instead of restarting from zero. 0 means idle. + const [turnStart, setTurnStart] = useState>({}) + // Seed for the "Agent " flavour word, one per session per turn. Held + // here rather than in TerminalChat because that component is not keyed by + // session: switching tabs changes its `processing` prop true→false→true, and + // a latch living inside it would re-roll the word for a turn already running. + const [verbSeed, setVerbSeed] = useState>({}) + const [pendingConfirm, setPendingConfirm] = useState<{ + title: string; message: string; confirmLabel?: string; + destructive?: boolean; onConfirm: () => void; + } | null>(null) + + const sessionsRef = useRef([]) + const resumedRef = useRef>(new Set()) + const containerRef = useRef(null) + sessionsRef.current = sessions + + // --- WebSocket (single, multiplexed by session_id) --- + const handleMessage = useCallback((data: string) => { + let ev: ChatEvent + try { ev = JSON.parse(data) } catch { return } + const sid = ev.session_id + if (!sid) return + if (ev.kind === 'history') { + const events = (ev.events || []).map(withCid) + setMsgs(prev => ({ ...prev, [sid]: events })) + // The server reports whether a turn is still running. A turn survives a + // disconnect, so after a reload this is the only way the pane learns it + // isn't idle — otherwise the working indicator and the cancel affordance + // both go missing while a deploy is mid-flight. + const running = ev.active === true + setProcessing(prev => ({ ...prev, [sid]: running })) + setProcCmd(prev => ({ ...prev, [sid]: running ? (ev.command as string) || '' : '' })) + setVerbSeed(prev => ({ + ...prev, + [sid]: running ? prev[sid] || Date.now() : 0, + })) + setTurnStart(prev => { + const at = running ? Date.parse((ev.started_at as string) || '') : NaN + // Fall back to now if the timestamp is unusable, so the timer starts + // from zero rather than rendering a nonsense duration. + return { ...prev, [sid]: running ? (Number.isNaN(at) ? Date.now() : at) : 0 } + }) + return + } + if (ev.kind === 'command_progress') { + setMsgs(prev => { + const arr = prev[sid] || [] + const updated = [...arr, withCid(ev)] + let run = 0 + for (let i = updated.length - 1; i >= 0 && updated[i].kind === 'command_progress'; i--) run++ + if (run > MAX_PROGRESS) updated.splice(updated.length - run, run - MAX_PROGRESS) + return { ...prev, [sid]: updated } + }) + return + } + setMsgs(prev => ({ ...prev, [sid]: [...(prev[sid] || []), withCid(ev)] })) + // A check_run means the hook refreshed the range doc → make RangeView re-fetch. + if (ev.kind === 'check_run') { + setRangeRefresh(prev => ({ ...prev, [sid]: (prev[sid] || 0) + 1 })) + // The same hook also learns *session*-level facts — the cloud account, + // resource group and attack box are only knowable post-deploy and get + // written to the snapshot. Sessions were otherwise fetched once at mount, + // so those fields never surfaced until a full page reload. + api.listSessions().then(d => setSessions(d.sessions)).catch(() => {}) + } + if (ev.kind === 'command_run' && ev.phase === 'start' && typeof ev.command === 'string') { + setProcCmd(prev => ({ ...prev, [sid]: ev.command as string })) + } + if (ev.kind === 'agent_end') { + setProcessing(prev => ({ ...prev, [sid]: false })) + setProcCmd(prev => ({ ...prev, [sid]: '' })) + setTurnStart(prev => ({ ...prev, [sid]: 0 })) + setVerbSeed(prev => ({ ...prev, [sid]: 0 })) + } + }, []) + + const resume = useCallback((send: (d: string) => void, id: string) => { + if (resumedRef.current.has(id)) return + resumedRef.current.add(id) + send(JSON.stringify({ type: 'resume', session_id: id })) + }, []) + + const handleOpen = useCallback((send: (d: string) => void) => { + // Re-subscribe every known session so background tabs stay live (§4.2). + resumedRef.current.clear() + for (const s of sessionsRef.current) resume(send, s.id) + }, [resume]) + + const { status, send } = useWebSocket('/ws/chat', handleMessage, handleOpen) + + // --- load config + sessions --- + useEffect(() => { + api.config().then(setCfg).catch(() => {}) + api.listSessions().then(d => setSessions(d.sessions)).catch(() => {}) + }, []) + + // resume + activate a session + const activate = useCallback((id: string) => { + setActiveId(id) + if (status === 'connected') resume(send, id) + }, [status, send, resume]) + + useEffect(() => { + if (!activeId && sessions.length) activate(sessions[0].id) + }, [sessions, activeId, activate]) + + const sendMessage = useCallback((content: string) => { + if (!activeId) return + setProcessing(prev => ({ ...prev, [activeId]: true })) + // Optimistic start; a later resume replaces it with the server's timestamp. + setTurnStart(prev => ({ ...prev, [activeId]: Date.now() })) + // A new turn always draws a new word. + setVerbSeed(prev => ({ ...prev, [activeId]: Date.now() })) + send(JSON.stringify({ session_id: activeId, content })) + }, [activeId, send]) + + const onCancel = useCallback(() => { + if (!activeId || pendingConfirm) return + const cmd = procCmd[activeId] + if (cmd === '/up' || cmd === '/destroy') { + setPendingConfirm({ + title: `Cancel ${cmd}?`, + message: `Cancelling ${cmd} mid-run can leave infrastructure in a half-applied state.`, + destructive: true, + confirmLabel: 'CANCEL ANYWAY', + onConfirm: () => { + send(JSON.stringify({ type: 'cancel', session_id: activeId })) + setPendingConfirm(null) + }, + }) + return + } + send(JSON.stringify({ type: 'cancel', session_id: activeId })) + }, [activeId, send, procCmd, pendingConfirm]) + + const createSession = useCallback(async (body: Record) => { + const s = await api.createSession(body) + setSessions(prev => [...prev, s]) + setShowNew(false) + activate(s.id) + }, [activate]) + + const changeModel = useCallback(async (model: string) => { + if (!activeId) return + try { + // Only reflect locally on success, using the server-confirmed model; the + // backend also emits a status event to chat. On failure, leave as-is. + const r = await api.setModel(activeId, model) + setSessions(prev => prev.map(s => (s.id === activeId ? { ...s, model: r.model } : s))) + } catch { + /* PUT rejected (404/network) — keep the current model */ + } + }, [activeId]) + + const closeSession = useCallback(async (id: string) => { + await api.deleteSession(id).catch(() => {}) + setSessions(prev => prev.filter(s => s.id !== id)) + setMsgs(prev => { const n = { ...prev }; delete n[id]; return n }) + if (activeId === id) setActiveId(null) + }, [activeId]) + + // --- resizer --- + const onDrag = useCallback((e: React.MouseEvent) => { + e.preventDefault() + const move = (ev: MouseEvent) => { + const rect = containerRef.current?.getBoundingClientRect() + if (!rect) return + const r = Math.max(MIN_W / rect.width, Math.min(1 - MIN_W / rect.width, (ev.clientX - rect.left) / rect.width)) + setRatio(r) + } + const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up) } + document.addEventListener('mousemove', move) + document.addEventListener('mouseup', up) + }, []) + + return ( +
+ {showNew && cfg && ( + setShowNew(false)} onCreate={createSession} /> + )} + {showSettings && cfg && ( + s.id === activeId)?.model} + onModelChange={activeId ? changeModel : undefined} + onClose={() => setShowSettings(false)} + onSaved={() => { setShowSettings(false); api.config().then(setCfg).catch(() => {}) }} + /> + )} + {pendingConfirm && ( + setPendingConfirm(null)} + /> + )} + + {/* Tab bar */} +
+ {/* Wordmark + release stage, grouped so the trailing gap applies to both. */} + + {/* Two colours rather than one string: the product is DreadGOAD, the + surface is the Console. Mirrors the launcher's split wordmark. + Both carry the bold weight so the pair reads as one wordmark. */} + + DreadGOAD + Console + + {/* Outlined rather than filled: it should read as a qualifier on the + wordmark, not compete with it. */} + Beta + + {sessions.map(s => ( +
activate(s.id)} style={{ + display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px', cursor: 'pointer', + borderRadius: 4, fontSize: 12, + background: s.id === activeId ? 'var(--dn-surface)' : 'transparent', + color: s.id === activeId ? 'var(--dn-text-bright)' : 'var(--dn-text-muted)', + }}> + {s.label} + { + e.stopPropagation() + setPendingConfirm({ + title: `Delete "${s.label}"?`, + message: 'This cancels any running operation and removes its working dir.\n\nThe environment stays in the config file, and any deployed infrastructure stays up — run /destroy first if you want it gone.', + destructive: true, + confirmLabel: 'DELETE', + onConfirm: () => { closeSession(s.id); setPendingConfirm(null) }, + }) + }} + style={{ color: 'var(--dn-text-dim)' }} + >✕ +
+ ))} + +
+ {cfg && !cfg.api_key_set && ( + setShowSettings(true)} + title="No LLM API key set — click to add one" + style={{ color: 'var(--dn-warning)', fontSize: 11, cursor: 'pointer' }} + >⚠ no key + )} +
+ + {/* Two-pane, or an empty state until a session exists */} + {activeId ? ( +
+
+ s.id === activeId)?.model} + onOpenSettings={() => setShowSettings(true)} + /> +
+
+
+ s.id === activeId)} + refreshKey={rangeRefresh[activeId] || 0} + /> +
+
+ ) : ( + // --dn-text-dim measured 2.03:1 against --dn-black here, well under the + // 4.5:1 floor — the same mistake the modal's field labels had. This is + // the only thing on an otherwise empty screen, so it carries the whole + // first impression of the app. +
+
No sessions yet.
+ +
+ )} +
+ ) +} + +function SettingsModal({ cfg, model, onModelChange, onClose, onSaved }: { + cfg: AppConfig + model?: string + onModelChange?: (model: string) => Promise | void + onClose: () => void + onSaved: () => void +}) { + const [modelInput, setModelInput] = useState(model ?? '') + const [apiKey, setApiKey] = useState('') + const [apiKeyEnv, setApiKeyEnv] = useState('OPENROUTER_API_KEY') + const [err, setErr] = useState('') + const [saving, setSaving] = useState(false) + + const save = async () => { + setErr('') + setSaving(true) + try { + // Model (per active session) — apply if changed. + const m = modelInput.trim() + if (onModelChange && m && m !== model) await onModelChange(m) + // API key (global) — apply if a key was entered. + if (apiKey.trim()) { + await api.setSettings({ api_key: apiKey.trim(), api_key_env: apiKeyEnv.trim() || undefined }) + } + onSaved() + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + } finally { + setSaving(false) + } + } + + return ( + +
Settings
+ + {onModelChange ? ( + <> + +
+ Changing the model continues this session's conversation on the new model. +
+ + ) : ( +
+ Open a session to change its model. +
+ )} + + + +
+ {cfg.api_key_set ? '● API key is set (leave blank to keep)' : '○ No API key set — agent turns will fail'} +
+ + {err &&
{err}
} +
+ + +
+
+ ) +} diff --git a/console/frontend/src/agentVerbs.ts b/console/frontend/src/agentVerbs.ts new file mode 100644 index 00000000..93d4d05b --- /dev/null +++ b/console/frontend/src/agentVerbs.ts @@ -0,0 +1,74 @@ +/** Flavour text for the "Agent …" indicator while a turn is in flight. + * + * Present participles, so each drops into the same slot "working" occupied. + * + * Deliberately excludes anything the platform actually does — destroying, + * terminating, purging, wiping. This console really can tear down a range, and + * a status line reading "Agent destroying" while the agent is quietly reading a + * config would be a genuinely alarming thing to walk in on. + */ +export const AGENT_VERBS = [ + 'exhuming', + 'festering', + 'haunting', + 'skulking', + 'entombing', + 'desecrating', + 'embalming', + 'withering', + 'putrefying', + 'moldering', + 'defiling', + 'lurking', + 'brooding', + 'gnawing', + 'writhing', + 'seething', + 'smoldering', + 'decaying', + 'unearthing', + 'disinterring', + 'shrouding', + 'interring', + 'mourning', + 'keening', + 'conjuring', + 'summoning', + 'invoking', + 'cursing', + 'hexing', + 'blighting', + 'plaguing', + 'infesting', + 'devouring', + 'flensing', + 'marauding', + 'prowling', + 'stalking', + 'creeping', + 'slithering', + 'ossifying', +] as const + +/** + * Pick a verb from a seed. + * + * Pure, so the caller controls exactly when a new word is drawn. That matters: + * the indicator re-renders every second because the stopwatch beside it ticks, + * so choosing during render would reshuffle the word once a second instead of + * once a turn. TerminalChat calls this once on the turn's leading edge and + * holds the result for the turn's duration. + * + * The seeds of consecutive turns are near-adjacent integers, which a plain + * modulo would map to adjacent list entries and march through the list in + * order. Hashing first scatters them. + */ +export function agentVerb(seed: number | null | undefined): string { + let h = (seed ?? 0) | 0 + h = Math.imul(h ^ (h >>> 15), 0x2c1b3c6d) + h = Math.imul(h ^ (h >>> 12), 0x297a2d39) + h ^= h >>> 15 + // >>> 0 rather than Math.abs: the hash can land on -2^31, whose absolute + // value is not representable as a positive int32 and stays negative. + return AGENT_VERBS[(h >>> 0) % AGENT_VERBS.length] +} diff --git a/console/frontend/src/api.ts b/console/frontend/src/api.ts new file mode 100644 index 00000000..0d90ffcd --- /dev/null +++ b/console/frontend/src/api.ts @@ -0,0 +1,214 @@ +// REST client for session lifecycle + RangeView reads (design §7). + +import type { RangeDoc, RangeLayout, Session } from './types' + +/** + * The human-readable part of a failed response. + * + * FastAPI puts the message in `detail`, so the raw body is JSON. Rendering it + * verbatim put things like + * + * 400 {"detail":"[Errno 2] No such file or directory: '/path/to.yaml'"} + * + * in front of the operator — the status code, the envelope and the quoting all + * competing with the one sentence that matters. `detail` may itself be a list + * of objects (FastAPI's validation errors), so those are flattened to their + * messages rather than stringified into "[object Object]". + */ +export function errorMessage(status: number, body: string): string { + let parsed: unknown + try { + parsed = JSON.parse(body) + } catch { + // Not JSON — a proxy error page or a plain-text body. Use it as-is, but + // keep the status, which is the only signal such a response carries. + const text = body.trim() + return text ? `${status}: ${text}` : `request failed (${status})` + } + + const detail = (parsed as { detail?: unknown } | null)?.detail + if (typeof detail === 'string' && detail.trim()) return detail.trim() + if (Array.isArray(detail)) { + const parts = detail + .map(d => (typeof d === 'string' ? d : (d as { msg?: string })?.msg)) + .filter((m): m is string => typeof m === 'string' && m.trim().length > 0) + if (parts.length) return parts.join('; ') + } + return `request failed (${status})` +} + +async function json(res: Response): Promise { + if (!res.ok) throw new Error(errorMessage(res.status, await res.text())) + return res.json() as Promise +} + +export interface AppConfig { + version: string + default_model: string + default_config_path: string + api_key_set: boolean + // Providers the console can drive end to end. Sent by the backend rather + // than hardcoded here so the two cannot drift; the CLI supports more + // (proxmox, ludus) than the console can render. + providers?: string[] +} + +/** One `dreadgoad.yaml` the console can attach to, as listed by /api/configs. */ +export interface ConfigSummary { + path: string + name: string + /** `default` = the repo-root one, `managed` = console-created, `session` = learned from an existing session's anchor. */ + source: 'default' | 'managed' | 'session' + provider?: string | null + region?: string | null + environments: string[] + /** Set when the file could not be read or parsed; it is still listed. */ + error?: string | null +} + +/** A lab that can be used as a `variant_source`, from `dreadgoad lab list --json`. */ +export interface LabSummary { + name: string + /** Repo-relative dir — the value written to `variant_source`, e.g. `ad/GOAD`. */ + dir: string + /** Providers the lab ships terraform for; a lab missing the session's provider cannot deploy. */ + providers: string[] + hosts: string[] + /** True when this is itself a generated variant (it has a mapping.json). */ + generated: boolean +} + +export interface ConfigListing { + configs: ConfigSummary[] + configs_root: string + providers: string[] + /** Provider → advisory note when its credentials aren't visible, else null. */ + credential_hints: Record + /** Provider → suggested regions. Suggestions only; the field stays free text. */ + regions?: Record +} + +/** One managed disk attached to a VM (Azure). */ +export interface DiskDetail { + name: string + /** `os` or `data`. */ + role: string + lun?: number + size_gb?: number + storage_type?: string + caching?: string + create_option?: string + managed_disk_id?: string +} + +/** One network interface attached to a VM (Azure). */ +export interface NICDetail { + name: string + id: string + private_ips: string[] + subnet_id?: string + nsg_id?: string + mac_address?: string + primary?: boolean + accelerated_networking?: boolean + public_ip_id?: string +} + +/** Attached-resource detail for one range host, from `lab describe --json`. */ +export interface HostDetail { + /** Echoed back so a late response can be discarded rather than mis-rendered. */ + node_id: string + /** "bastion" for managed services; absent for regular VMs. */ + kind?: string + id?: string + name: string + resource_group: string + location?: string + vm_size?: string + power_state?: string + status?: string + cloud_id?: string | null + ip_public?: string | null + last_checked_at?: string | null + disks?: DiskDetail[] + nics?: NICDetail[] +} + +export interface CommandDef { + name: string + description: string + detail: string // consequence/prerequisite, shown under the description + cli: string // the dreadgoad verb this maps to + dispatch: 'direct' | 'agent' + long_running: boolean + takes_args: boolean + /** Cannot be undone. The UI confirms before running a `direct` one. */ + destructive?: boolean +} + +export const api = { + config: (): Promise => fetch('/api/config').then(r => json(r)), + + commands: (): Promise<{ commands: CommandDef[] }> => + fetch('/api/commands').then(r => json(r)), + + configs: (): Promise => fetch('/api/configs').then(r => json(r)), + + labs: (configPath?: string): Promise<{ labs: LabSummary[] }> => + fetch('/api/labs' + (configPath ? `?config_path=${encodeURIComponent(configPath)}` : '')) + .then(r => json(r)), + + environments: (configPath: string): Promise<{ + environments: string[] + provider?: string + region?: string + /** Per environment, the region the CLI resolves — env key first, file key as fallback. */ + env_regions?: Record + }> => + fetch(`/api/environments?config_path=${encodeURIComponent(configPath)}`).then(r => json(r)), + + setSettings: (body: { api_key?: string; api_key_env?: string }): Promise<{ ok: boolean; api_key_env: string }> => + fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).then(r => json(r)), + + listSessions: (): Promise<{ sessions: Session[] }> => + fetch('/api/sessions').then(r => json(r)), + + createSession: (body: Record): Promise => + fetch('/api/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).then(r => json(r)), + + deleteSession: (id: string): Promise => + fetch(`/api/sessions/${id}`, { method: 'DELETE' }).then(r => json(r)), + + setModel: (id: string, model: string): Promise<{ ok: boolean; model: string }> => + fetch(`/api/sessions/${id}/model`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model }), + }).then(r => json(r)), + + hostDetail: (sessionId: string, nodeId: string): Promise => + fetch(`/api/ranges/${sessionId}/hosts/${encodeURIComponent(nodeId)}`) + .then(r => json(r)), + + getRange: (id: string): Promise => + fetch(`/api/ranges/${id}`).then(r => json(r)), + + saveLayout: ( + id: string, + layout: RangeLayout, + revision: number, + ): Promise<{ ok: boolean; layout_revision: number }> => + fetch(`/api/ranges/${id}/layout`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ layout, revision }), + }).then(r => json(r)), +} diff --git a/console/frontend/src/components/ConfirmModal.tsx b/console/frontend/src/components/ConfirmModal.tsx new file mode 100644 index 00000000..2d1ff04e --- /dev/null +++ b/console/frontend/src/components/ConfirmModal.tsx @@ -0,0 +1,45 @@ +import { useEffect, useRef } from 'react' +import Modal from './Modal' +import { btnStyle } from './FormFields' + +export default function ConfirmModal({ title, message, confirmLabel = 'CONFIRM', destructive, onConfirm, onCancel }: { + title: string + message: string + confirmLabel?: string + destructive?: boolean + onConfirm: () => void + onCancel: () => void +}) { + const btnRef = useRef(null) + useEffect(() => { btnRef.current?.focus() }, []) + + return ( + +
+
{title}
+
{message}
+
+
+ + +
+
+ ) +} diff --git a/console/frontend/src/components/ConnectModal.tsx b/console/frontend/src/components/ConnectModal.tsx new file mode 100644 index 00000000..6acc9d61 --- /dev/null +++ b/console/frontend/src/components/ConnectModal.tsx @@ -0,0 +1,132 @@ +// "Connect" modal for the attack box: shows the Bastion tunnel command and the +// ssh command that goes through it, each with a copy button. The console runs +// neither — the operator pastes them into their own terminal, which is why this +// adds no execution surface to the server. +import type { RangeHost, Session } from '../types' +import { BASTION_LOCAL_PORT, buildConnectPlan } from '../connect' +import Modal from './Modal' +import CopyableCommand from './CopyableCommand' + +export default function ConnectModal( + { session, host, onClose }: + { session?: Session; host: RangeHost; onClose: () => void }, +) { + const plan = buildConnectPlan(session, host) + + return ( + +
Connect to {host.hostname}
+ {/* Every secondary line in this modal uses --dg-node-label (6.5:1) or + --dg-node-value (8.4:1). The generic --dn-text-muted / --dn-text-dim + tokens measure 3.0:1 and 1.8:1 on --dn-surface — below the 4.5:1 AA + floor, which is exactly why the node metadata has its own pair (see + index.css). This is a wall of prose someone reads once and follows + precisely, so it needs the calibrated greys more than the nodes do. */} +
+ {plan.kind === 'azure-bastion' + ? `Run these in your own terminal — the console does not run them for + you. Each needs its own terminal: the tunnel keeps running while + you use it.`.replace(/\s+/g, ' ') + : plan.kind === 'aws-ssm' + ? `Run this in your own terminal — the console does not run it for + you.`.replace(/\s+/g, ' ') + : 'Nothing to copy for this host yet.'} +
+ + {plan.kind === 'azure-bastion' && ( + <> + + +
+ Port {BASTION_LOCAL_PORT} is local to your machine. If it is already + taken — a leftover tunnel, or a second range open — change it in + both commands. +
+ The key is written by terraform at deploy time; if{' '} + {plan.keyPath}{' '} + is missing, this range was deployed from a different machine. +
+ + )} + + {plan.kind === 'aws-ssm' && ( + <> + +
+ Needs the AWS CLI's{' '} + session-manager-plugin{' '} + installed locally, and the instance must be registered with SSM — + the agent is not preinstalled on Kali images. +
+ + )} + + {plan.kind === 'no-attack-box' && ( +
+ This range has been read and has no attack box in it. +
+ {plan.provider === 'azure' + ? 'The Kali box is optional on Azure — deploy it with `dreadgoad infra apply --with-kali`.' + : 'DreadGOAD does not provision an attack box on AWS yet. Any instance in this environment whose Name contains "kali" or "attack" is picked up automatically, and this will fill in on the next read.'} +
+
+ )} + + {plan.kind === 'unsupported-provider' && ( +
+ No connect recipe for{' '} + {plan.provider}{' '} + ranges — only Azure (Bastion) and AWS (SSM) are supported here. +
+ )} + + {plan.kind === 'incomplete' && ( +
+ Not enough is known about this range yet. The resource group and the + VM's cloud id are learned when the range is first read — run{' '} + /instances and + try again. +
+ )} + +
+ +
+
+ ) +} diff --git a/console/frontend/src/components/CopyableCommand.tsx b/console/frontend/src/components/CopyableCommand.tsx new file mode 100644 index 00000000..8d2189e8 --- /dev/null +++ b/console/frontend/src/components/CopyableCommand.tsx @@ -0,0 +1,115 @@ +import { useEffect, useRef, useState } from 'react' + +type CopyState = 'idle' | 'copied' | 'failed' + +/** + * Read-only command textarea with a copy button. + * + * Used in two contexts: the connect modal (full-size, with step numbers and + * hints) and the accordion detail row (compact). The `compact` prop switches + * between the two visual treatments. + */ +export default function CopyableCommand( + { label, value, compact, step, hint }: + { + label: string + value: string + compact?: boolean + step?: number + hint?: string + }, +) { + const [state, setState] = useState('idle') + const timerRef = useRef>() + useEffect(() => () => clearTimeout(timerRef.current), []) + + const showState = (next: Exclude) => { + setState(next) + clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => setState('idle'), 1600) + } + + const copy = (e?: React.MouseEvent) => { + e?.stopPropagation() + if (!navigator.clipboard) { + showState('failed') + return + } + navigator.clipboard.writeText(value) + .then(() => showState('copied')) + .catch(() => showState('failed')) + } + + const btnColor = state === 'copied' ? 'var(--dn-success)' + : state === 'failed' ? 'var(--dn-error)' : 'var(--dg-node-label)' + + return ( +
+ {compact ? ( + {label} + ) : ( +
+ {step != null && ( + + {step}. + + )} + {label} + {hint && ( + {hint} + )} +
+ )} +
+