---
title: "Running the Full GOAD Lab on Modal: Five Windows VMs in One Pay-Per-Use Sandbox"
author: "Karthik"
date: "2026-08-28"
description: "How I adapted the full five-machine Game of Active Directory lab to Modal using QEMU TCG, persistent volumes, Ansible, noVNC, and an ephemeral OpenVPN gateway."
tags:
  - GOAD
  - Modal
  - Active Directory
  - QEMU
  - Ansible
  - OpenVPN
  - Infrastructure
---

# Running the Full GOAD Lab on Modal: Five Windows VMs in One Pay-Per-Use Sandbox

When I began this project, the obvious answer was that it could not be done.

[Game of Active Directory (GOAD)](https://github.com/Orange-Cyberdefense/GOAD) expects a conventional virtualization provider such as VirtualBox, VMware, Proxmox, Azure, AWS, or Ludus. [Modal](https://modal.com/) is a serverless compute platform, not a supported GOAD provider. GOAD needs five Windows virtual machines, private networking, persistent disks, several reboots, and a long Ansible provisioning sequence. Those requirements do not resemble a normal short-lived serverless workload.

The breakthrough was to stop asking, “How do I make Modal a GOAD provider?” and ask a different question:

> Can one on-demand Modal VM Sandbox act as the Linux hypervisor for the entire lab?

The answer was yes—with an important qualification. In my deployment, nested KVM was unavailable, so the Windows guests had to run through QEMU’s software accelerator, TCG. That is slower than hardware-assisted virtualization, but it gave me a real, complete five-machine GOAD environment that starts only when someone needs it, preserves its disks between sessions, and shuts itself down on a timer.

This article explains the architecture, the build procedure, the mistakes, and the operational controls I used. It is not an official GOAD provider and it is not an official Orange Cyberdefense or Modal reference architecture. It is a practical account of the custom implementation I built.

> **Safety warning:** GOAD is intentionally and extremely vulnerable. Its maintainers explicitly warn against exposing it to the Internet without isolation. Use a dedicated Modal environment, publish only authenticated access services, restrict the VPN to the lab subnet, and never connect the GOAD bridge to a production network. The Windows images are evaluation systems; review the [GOAD licensing note](https://github.com/Orange-Cyberdefense/GOAD#licenses) and Microsoft’s terms before use.

> **Public-template note:** Every externally visible App, Environment, Volume, Secret, and Sandbox name in this article is a generic example and does not identify the live Transilience deployment. Choose new, non-production names for your own installation. The bundled local-account portal is an instructional starting point and permits self-service signup; place it behind organizational SSO, an invite/allowlist control, or another access gateway before exposing a cost-bearing lab to the public Internet.

## What “full GOAD” means

I deployed the full GOAD lab—not GOAD-Light or GOAD-Mini. The [official GOAD topology](https://orange-cyberdefense.github.io/GOAD/labs/GOAD/) contains five Windows servers, two forests, and three domains.

| Guest | Lab IP | Guest RAM | Base image |
|---|---:|---:|---|
| `dc01` | `192.168.56.10` | 3 GB | Windows Server 2019 |
| `dc02` | `192.168.56.11` | 3 GB | Windows Server 2019 |
| `dc03` | `192.168.56.12` | 3 GB | Windows Server 2016 |
| `srv02` | `192.168.56.22` | 6 GB | Windows Server 2019 |
| `srv03` | `192.168.56.23` | 5 GB | Windows Server 2016 |

Each guest receives two virtual CPUs. The guests consume 20 GB of RAM in total, so I gave the outer Modal Sandbox 16 physical CPU cores and 32 GB of memory. GOAD’s own installation guidance recommends at least 24 GB of RAM for the full lab and roughly 115 GB of available storage when base images and working disks are included. See the [GOAD installation documentation](https://orange-cyberdefense.github.io/GOAD/installation/linux/) before sizing your deployment.

## The architecture

The final design uses one Modal App and two persistent Modal Volumes:

```mermaid
flowchart TB
    U[Authenticated user] --> P[React + FastAPI portal]
    P --> C[Scale-to-zero control functions]
    C --> S[One Modal VM Sandbox\n16 CPU / 32 GiB]

    S --> H[Ubuntu 24.04 host]
    H --> Q1[QEMU: dc01]
    H --> Q2[QEMU: dc02]
    H --> Q3[QEMU: dc03]
    H --> Q4[QEMU: srv02]
    H --> Q5[QEMU: srv03]
    Q1 & Q2 & Q3 & Q4 & Q5 --- B[br-goad\n192.168.56.0/24]

    S --> N[noVNC / XFCE\nencrypted web tunnel]
    S --> V[OpenVPN\nraw TCP tunnel]
    V --> B

    D[(goad-lab-state)] --> S
    A[(goad-web-data)] --> P
```

The important separation is:

- **Ephemeral compute:** the 16-core Sandbox exists only while a lab session is active.
- **Durable lab state:** Windows base disks, copy-on-write guest disks, Ansible markers, logs, and status live on `goad-lab-state`.
- **Durable portal state:** users, login sessions, and the current lab lease live on `goad-web-data`.
- **Ephemeral secrets:** the VPN CA, server certificate, client certificate, and current tunnel address live only inside the active Sandbox.
- **Scale-to-zero control plane:** start, stop, status, reset, extend, logs, and the web portal use small Modal Functions with no permanently warm containers.

This preserved Modal’s pay-per-use model without pretending that five Windows guests are lightweight serverless functions.

## Why the straightforward approaches failed

### Treating Modal as Proxmox

GOAD’s Proxmox provider assumes that a Proxmox cluster already exists. Running Terraform or the GOAD Proxmox scripts inside a Modal container does not create that hypervisor. The scripts still need a reachable Proxmox API, storage pool, bridge, templates, and a network path to the resulting Windows machines.

That route was useful for understanding GOAD’s provider boundary, but it did not produce a Modal-only deployment.

### Dockerizing GOAD

Docker can package the Linux controller, Ansible, and the portal. It cannot turn Windows Server guests into Linux containers. Active Directory behavior, Windows reboots, services, domain joins, trusts, and AD CS still require Windows kernels.

### One Modal container per Windows machine

This makes persistence and private Layer 2 networking harder, and it multiplies orchestration and billing complexity. I instead placed all five guests in one large Sandbox and created the lab network locally with a Linux bridge and TAP devices.

### Assuming `/dev/kvm` would exist

[Modal VM Sandboxes](https://modal.com/docs/guide/vm-sandboxes) provide a real Linux kernel, which is why bridge, TAP, iptables, OpenVPN, and QEMU work normally. In my Sandbox, however, nested KVM was not exposed. I therefore used:

```text
-accel tcg,thread=multi,tb-size=256
-cpu max
```

TCG is the main performance trade-off in this design. Initial provisioning is measured in hours rather than minutes, and Windows servicing or reboots need patient retry logic.

## Step 1: Prepare Modal and isolate the deployment

Install the Modal CLI and authenticate, following Modal’s [getting-started guide](https://modal.com/docs/guide):

```bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade modal
modal setup
```

Create a dedicated environment. I used `goad-lab`; choose a different name if that is already used by another application.

```bash
modal environment create goad-lab
```

Modal Environments separate Apps, Secrets, and storage namespaces. I passed `-e goad-lab` to every destructive or deployment command rather than relying on a default profile. Modal documents this behavior in its [Environments guide](https://modal.com/docs/guide/environments).

Create the two Volumes and the VNC secret:

```bash
modal volume create -e goad-lab goad-lab-state
modal volume create -e goad-lab goad-web-data
VNC_PASSWORD="$(openssl rand -hex 4)"
modal secret create -e goad-lab goad-lab-auth \
  GOAD_VNC_PASSWORD="$VNC_PASSWORD"
unset VNC_PASSWORD
```

Do not commit the password to source control. Modal’s [Secrets documentation](https://modal.com/docs/guide/secrets) explains how named secrets are injected into functions and Sandboxes.

In the Modal App, bind every lookup explicitly to the same environment:

```python
import modal

APP_NAME = "goad-on-modal"
ENVIRONMENT = "goad-lab"

app = modal.App(APP_NAME)
state = modal.Volume.from_name(
    "goad-lab-state",
    environment_name=ENVIRONMENT,
    create_if_missing=True,
)
portal_data = modal.Volume.from_name(
    "goad-web-data",
    environment_name=ENVIRONMENT,
    create_if_missing=True,
)
auth = modal.Secret.from_name(
    "goad-lab-auth",
    environment_name=ENVIRONMENT,
    required_keys=["GOAD_VNC_PASSWORD"],
)
```

Avoid cross-environment lookups and generic Volume names. A vulnerable lab should never share state or credentials with another application by accident.

## Step 2: Use a reproducible source layout

My reference implementation is organized like this:

```text
modal_lab.py              # Modal App, images, controller functions, ASGI endpoint
lab_host.py               # QEMU, networking, VPN, desktop, Ansible, shutdown supervisor
inventory.goad-modal      # Static GOAD IP/WinRM inventory overlay
qemu_probe.py             # One-VM proof and Server 2019 base-image import
import_goad_boxes.py       # Remaining Server 2016 imports
lab_client.py             # Small operator CLI
portal_api.py              # FastAPI auth, lease, and controller API
goad-patches/              # Pinned compatibility fixes
goad-portal/               # React/Vite user interface
```

I pinned GOAD to a tested commit instead of building from a moving default branch:

```python
GOAD_COMMIT = "992307adf944b934a3b76a2f56a637104c54b805"
```

That pin matters because this implementation patches a few provisioning tasks for the slower TCG environment. If you update GOAD, test the entire provisioning sequence again and re-evaluate every patch. Do not silently apply old patches to new upstream code.

## Step 3: Build the Ubuntu host image

The outer host is Ubuntu 24.04 with Python 3.11. Its image includes:

- QEMU system emulation and `qemu-img`;
- bridge, TAP, `iproute2`, and iptables tooling;
- OpenVPN and OpenSSL;
- TigerVNC, noVNC, websockify, and XFCE;
- Git and the pinned GOAD checkout;
- Ansible Core 2.18, `pywinrm`, NTLM support, and GOAD’s Galaxy dependencies.

A shortened version of the image definition is:

```python
import modal

host_image = (
    modal.Image.from_registry("ubuntu:24.04", add_python="3.11")
    .env({"DEBIAN_FRONTEND": "noninteractive"})
    .apt_install(
        "bridge-utils", "curl", "git", "iproute2", "iptables",
        "openvpn", "openssl", "qemu-system-x86", "qemu-utils",
        "tigervnc-standalone-server", "novnc", "websockify",
        "xfce4", "xfce4-terminal",
    )
    .run_commands(
        "git init /opt/GOAD",
        "git -C /opt/GOAD remote add origin "
        "https://github.com/Orange-Cyberdefense/GOAD.git",
        f"git -C /opt/GOAD fetch --depth 1 origin {GOAD_COMMIT}",
        "git -C /opt/GOAD checkout --detach FETCH_HEAD",
        "python -m venv /opt/GOAD/.venv",
        "/opt/GOAD/.venv/bin/pip install ansible-core==2.18.0 "
        "ansible_runner pywinrm requests-ntlm rich psutil Jinja2 pyyaml",
        "cd /opt/GOAD/ansible && "
        "/opt/GOAD/.venv/bin/ansible-galaxy install -r requirements_311.yml",
    )
    .add_local_file("lab_host.py", "/opt/goad-modal/lab_host.py", copy=True)
    .add_local_file("inventory.goad-modal", "/opt/goad-modal/inventory", copy=True)
    .add_local_dir("goad-patches", "/opt/goad-modal/patches", copy=True)
)
```

Building GOAD into the Modal Image keeps the large, mostly immutable Linux toolchain out of the persistent lab Volume. Only mutable lab data goes under `/lab`.

## Step 4: Import the Windows base boxes once

GOAD normally lets Vagrant download and create its machines. I separated that “providing” phase from the later Ansible “provisioning” phase.

The importer runs in a temporary, small VM Sandbox. It downloads the Vagrant boxes with resume and retry support, extracts the first VMDK or VDI, validates it with `qemu-img info`, synchronizes the filesystem, and exits. The results are stored under:

```text
/lab/base/windows_2019_2021.05.15/
/lab/base/windows_2016_2017.12.14/
/lab/base/windows_2016_2019.02.14/
```

These are the exact Vagrant box releases used by my pinned build:

| Local base directory | Source box |
|---|---|
| `windows_2019_2021.05.15` | [`StefanScherer/windows_2019` 2021.05.15, VirtualBox](https://vagrantcloud.com/StefanScherer/boxes/windows_2019/versions/2021.05.15/providers/virtualbox/unknown/vagrant.box) |
| `windows_2016_2017.12.14` | [`StefanScherer/windows_2016` 2017.12.14, VirtualBox](https://vagrantcloud.com/StefanScherer/boxes/windows_2016/versions/2017.12.14/providers/virtualbox/unknown/vagrant.box) |
| `windows_2016_2019.02.14` | [`StefanScherer/windows_2016` 2019.02.14, VirtualBox](https://vagrantcloud.com/StefanScherer/boxes/windows_2016/versions/2019.02.14/providers/virtualbox/unknown/vagrant.box) |

In my implementation the import commands are:

```bash
python qemu_probe.py
python qemu_boot_probe.py
python import_goad_boxes.py
```

The proof script deliberately imports only one Windows Server 2019 box first. Before downloading every image, I used it to prove four assumptions:

1. the Modal VM runtime exposed `/dev/net/tun`;
2. QEMU TCG could boot the Windows disk;
3. the Modal Volume could preserve large base files;
4. WinRM could be reached through QEMU’s user-mode NAT forwarding.

Only after that test succeeded did I import the remaining boxes. This staged approach saved a great deal of time while debugging.

Treat downloaded images as third-party artifacts: verify their source, hashes when available, license, and evaluation lifetime. The GOAD project currently states that its free Windows VMs have a 180-day evaluation period.

## Step 5: Create persistent copy-on-write disks

The base disks must remain immutable. Each guest receives a persistent qcow2 overlay on the Modal Volume:

```bash
qemu-img create \
  -f qcow2 \
  -F vmdk \
  -b /lab/base/windows_2019_2021.05.15/base-disk.vmdk \
  /lab/vms/dc01/disk.qcow2
```

The real implementation detects whether the backing disk is VMDK or VDI. The critical rules are:

- keep the base and its overlay on the same durable Volume;
- never rename or modify a base disk after overlays reference it;
- create an overlay only when it does not already exist;
- call `sync` before terminating the Sandbox;
- perform a graceful Windows shutdown whenever possible.

This gives fast restarts and avoids duplicating several full base images for five guests.

## Step 6: Build the private GOAD network

Inside the Sandbox I create one Linux bridge and five TAP interfaces:

```bash
ip link add br-goad type bridge
ip addr replace 192.168.56.1/24 dev br-goad
ip link set br-goad up

ip tuntap add dev tap-dc01 mode tap
ip link set tap-dc01 master br-goad
ip link set tap-dc01 up
```

Repeat the TAP steps for all five machines. Every guest then receives two emulated E1000 adapters:

1. **A QEMU user-mode NAT adapter** with host-local forwarding for WinRM and RDP. The host supervisor uses this path during early boot and network repair.
2. **A TAP adapter on `br-goad`** with the static `192.168.56.x` address used by GOAD and by learners.

The NAT ports bind to `127.0.0.1`, not to the Sandbox’s public interface. The vulnerable Windows services therefore remain private.

A representative QEMU command looks like this:

```text
qemu-system-x86_64
  -machine pc-i440fx-8.2
  -accel tcg,thread=multi,tb-size=256
  -cpu max
  -smp 2,sockets=1,cores=2,threads=1
  -m 3000
  -drive file=/lab/vms/dc01/disk.qcow2,if=ide,format=qcow2,cache=writeback
  -netdev user,id=nat0,hostfwd=tcp:127.0.0.1:15985-:5985
  -device e1000,netdev=nat0
  -netdev tap,id=lab0,ifname=tap-dc01,script=no,downscript=no
  -device e1000,netdev=lab0,mac=52:54:00:56:00:0a
  -display none
  -monitor unix:/run/goad-modal/dc01/monitor.sock,server=on,wait=off
  -daemonize
```

I configure the static lab NIC inside Windows through PowerShell over the NAT-side WinRM connection. This avoids depending on the private NIC being correctly configured before Ansible starts.

## Step 7: Reuse GOAD’s Ansible provisioning

GOAD’s installation model separates template preparation, VM providing, and Ansible provisioning. Modal replaced the provider layer; I deliberately kept GOAD’s Ansible logic.

The host waits for WinRM on all five guests, merges GOAD’s generated inventory with my static IP overlay and runtime credentials, and then runs the playbooks in order:

```ini
[default]
dc01 ansible_host=192.168.56.10
dc02 ansible_host=192.168.56.11
dc03 ansible_host=192.168.56.12
srv02 ansible_host=192.168.56.22
srv03 ansible_host=192.168.56.23

[all:vars]
ansible_port=5985
ansible_winrm_transport=ntlm
ansible_winrm_server_cert_validation=ignore
```

Passwords are added to a mode-`0600` runtime inventory under `/run`; they are not written into this static file or committed to source control.

```python
PLAYBOOKS = (
    "build.yml",
    "ad-servers.yml",
    "ad-parent_domain.yml",
    "ad-child_domain.yml",
    "wait5m.yml",
    "ad-members.yml",
    "ad-trusts.yml",
    "ad-data.yml",
    "ad-gmsa.yml",
    "laps.yml",
    "ad-relations.yml",
    "adcs.yml",
    "ad-acl.yml",
    "servers.yml",
    "security.yml",
    "vulnerabilities.yml",
)
```

TCG changes the timing assumptions. Windows cumulative updates, domain promotion, AD replication, .NET installation, and SSMS setup may cause delayed reboots or temporary WinRM failures. My supervisor therefore:

- waits for all five machines concurrently;
- retries a failed playbook up to a bounded maximum;
- waits for WinRM again between attempts;
- refreshes the usable credential for each host after domain changes;
- records each successful playbook as a durable marker.

The markers are stored as files such as:

```text
/lab/state/playbooks/01-build.yml.done
/lab/state/playbooks/02-ad-servers.yml.done
...
/lab/state/playbooks/16-vulnerabilities.yml.done
```

On restart, completed playbooks are skipped. This transformed first-time provisioning from one fragile, all-or-nothing job into a resumable workflow.

I also keep a small set of compatibility patches for the pinned GOAD revision. These handle tasks whose timeout, reboot, or prerequisite behavior was unreliable under slow software virtualization. Every patch is narrowly scoped and applied only after checking the expected upstream file. A mismatch should stop the build, not modify an unknown version.

## Step 8: Start the metered Sandbox

The core Modal controller creates one named Sandbox:

```python
sandbox = modal.Sandbox.create(
    "/opt/GOAD/.venv/bin/python",
    "/opt/goad-modal/lab_host.py",
    app=deployed_app,
    name="goad-host",
    image=host_image,
    secrets=[auth],
    cpu=16,
    memory=32768,
    timeout=23 * 60 * 60,
    volumes={"/lab": state},
    encrypted_ports=[6080],
    unencrypted_ports=[1194],
    readiness_probe=modal.Probe(tcp_port=6080, interval_ms=1000),
    experimental_options={"vm_runtime": True},
)
```

The `vm_runtime` option is essential for the real Linux networking and filesystem behavior this design uses. Modal documents VM Sandboxes as a beta feature, so verify the API before deploying a copy.

I set the Sandbox timeout to 23 hours, leaving margin below Modal’s documented 24-hour maximum. A separate in-guest timer defaults to six hours and can be extended in one-hour increments, but never beyond the outer 23-hour deadline. Modal’s [Sandbox documentation](https://modal.com/docs/guide/sandboxes) describes timeouts and readiness probes.

The TCP readiness probe only means that the noVNC service is listening. It does **not** mean that five Windows machines are fully booted or provisioned. A separate durable status file reports the actual state:

```text
starting -> booting -> network-ready -> provisioning -> ready
                                           |              |
                                           v              v
                                         error    stopping -> stopped
```

The portal polls that state and shows playbook progress instead of presenting a misleading “online” message too early.

## Step 9: Provide browser desktop access

The host starts an XFCE desktop in TigerVNC on loopback and uses websockify/noVNC on port 6080:

```bash
vncserver :9 -localhost yes -geometry 1600x900 -depth 24
websockify --web=/usr/share/novnc 0.0.0.0:6080 127.0.0.1:5909
```

Port 6080 is exposed through an encrypted Modal Sandbox tunnel. The VNC password comes from the named Modal Secret. The resulting desktop is an isolated operator workstation from which a learner can use RDP, PowerShell, browsers, and assessment tools without connecting the lab subnet to their physical network.

Remember that Modal tunnel URLs are public if someone knows the URL. Authentication at the application or protocol layer is still required; Modal says the same in its [tunnel security guidance](https://modal.com/docs/guide/tunnels).

## Step 10: Add external access with an ephemeral VPN

For users who want to work from their own machine, the Sandbox also runs OpenVPN on TCP port 1194.

At every lab start, the supervisor generates:

- a new short-lived CA;
- a server certificate and key;
- one owner client certificate and key;
- a `tls-crypt` key;
- a client profile containing those materials.

The VPN network is `10.77.0.0/24`. The server pushes only this route:

```text
route 192.168.56.0 255.255.255.0
```

The client profile also ignores `redirect-gateway`, so ordinary Internet traffic does not pass through the lab. The host enables forwarding only between `tun-vpn` and `br-goad` and applies source NAT for VPN clients reaching the GOAD subnet.

Modal’s [raw TCP tunnel](https://modal.com/docs/guide/tunnels#advanced-unencrypted-tcp-tunnels) supplies a public hostname and random port. “Unencrypted” here means Modal does not wrap the TCP stream in its own TLS. OpenVPN still performs its own authenticated encryption.

A valid profile cannot be generated before the Sandbox starts because both its Modal tunnel endpoint and its VPN key material are session-specific. The UI can present VPN instructions before start, but the backend should enable the actual profile download only after the active Sandbox reports the VPN ready. Stopping the Sandbox destroys the keys and invalidates that downloaded profile.

On Linux or macOS with the OpenVPN client installed, the user connects with:

```bash
sudo openvpn --config transilience-goad.ovpn
```

After connection, verify the route and test a service:

```bash
ip route get 192.168.56.10
nc -vz 192.168.56.10 5985
```

On macOS, use `route -n get 192.168.56.10` instead of `ip route`. A failed ping alone is not conclusive because Windows Firewall may block ICMP while TCP services remain reachable.

Do not expose RDP, SMB, WinRM, LDAP, Kerberos, or the bridge itself as separate public Modal tunnels. The authenticated VPN should be the only external path to `192.168.56.0/24`.

## Step 11: Build a small control plane

I exposed seven controller operations:

| Operation | Behavior |
|---|---|
| `start` | Reuses the named Sandbox if it is already running; otherwise creates it with a requested session duration. |
| `status` | Combines the persistent status file with live Sandbox and tunnel information. |
| `vpn_config` | Inserts the current random tunnel host and port into the ephemeral client template. |
| `reset` | Sends `system_reset` to every QEMU monitor socket without deleting disks. |
| `extend` | Adds time to the in-guest session timer without exceeding the Sandbox deadline. |
| `stop` | Requests Windows shutdown, waits, syncs disks, and terminates the billable Sandbox. |
| `logs` | Returns a bounded tail of the host/provisioning log. |

These functions need only 0.25–0.5 CPU and 512 MB–1 GB RAM. I configured `min_containers=0`, `max_containers=1`, and a short scaledown window so they disappear after use. Modal’s [autoscaling guide](https://modal.com/docs/guide/scale) explains the cost/latency trade-off of warm containers.

The lab host is also limited to one named Sandbox. This prevents two users from unknowingly starting duplicate 16-core environments against the same disks.

## Step 12: Put an authenticated portal in front of it

My final user experience is a React catalog and a FastAPI backend served from the same Modal App. The current catalog exposes only full GOAD; other labs are marked “Coming Soon.”

The reference portal includes:

- signup and login;
- PBKDF2-HMAC-SHA256 password hashing with per-user salts;
- random session tokens stored only as hashes;
- `HttpOnly`, `Secure`, `SameSite=Lax` cookies;
- rate limiting on authentication attempts;
- one exclusive lab lease;
- owner-only start, stop, reset, extend, desktop-password, and VPN access;
- a six-hour default countdown with a 23-hour hard ceiling;
- persistent SQLite data on the separate portal Volume.

The lease is important because the infrastructure is shared. If Karthik starts the lab, another logged-in user can see that it is occupied but cannot reset, stop, or download the owner’s VPN profile.

For a larger or public deployment, I would replace the local account system with organizational OIDC/SSO, add CSRF protection to every state-changing route, add audit events, enforce account approval, and store authorization policy outside the frontend. The browser is never the security boundary; the FastAPI routes must verify ownership again.

Modal supports FastAPI through an ASGI web function and assigns a public URL when the App is deployed. See [Web Functions](https://modal.com/docs/guide/webhooks) and [Web Function URLs](https://modal.com/docs/guide/webhook-urls).

## Step 13: Build, deploy, and operate

Build the frontend before deploying because the Modal Image copies the compiled Vite assets:

```bash
cd goad-portal
npm ci
npm run build
cd ..
```

Deploy the App to the isolated environment:

```bash
modal deploy -e goad-lab modal_lab.py
```

The CLI prints the portal’s public Web Function URL. Do not publish it until authentication, ownership checks, and secret handling have been tested.

Start and inspect the lab directly through Modal:

```bash
modal run -e goad-lab modal_lab.py --action start --session-hours 6
modal run -e goad-lab modal_lab.py --action status
modal run -e goad-lab modal_lab.py --action logs --lines 200
```

Or use the small client wrapper:

```bash
python lab_client.py start --session-hours 6
python lab_client.py status
python lab_client.py logs --lines 200
python lab_client.py extend
python lab_client.py reset
python lab_client.py stop
```

The first start is the expensive one: it creates overlays, boots all five evaluation servers, configures their private NICs, and runs the full Ansible chain. Later sessions reuse the completed overlays and durable markers, so they normally boot instead of reprovisioning everything.

## Making “pay per use” real

Serverless does not automatically mean inexpensive. While this lab is active, it reserves a 16-core, 32-GB Sandbox. Modal says compute is billed by usage duration and that VM Sandbox memory is statically provisioned at the requested size. Check the current [Modal pricing page](https://modal.com/pricing) and [Sandbox resource documentation](https://modal.com/docs/guide/sandbox-resources) before estimating a session price.

These controls made the cost model predictable:

1. **One outer Sandbox, not five:** all guests share the host and bridge.
2. **Default six-hour session:** the lab shuts down without relying on the learner to remember.
3. **Twenty-three-hour hard maximum:** no extension can outlive the Modal Sandbox.
4. **Scale-to-zero controllers and portal:** no permanently warm CPU pool.
5. **One active lease:** prevents accidental duplicate labs.
6. **Copy-on-write disks:** base images are downloaded once and reused.
7. **Graceful stop with forced fallback:** billing stops even if one guest hangs.
8. **Separate status and logs:** operators can diagnose progress through tiny functions instead of keeping a shell open.
9. **Environment billing checks:** review usage regularly with Modal’s environment billing tooling.

The persistent Volumes and stored images can still incur storage charges after compute stops. “Pay per use” here means the large 16-core host is metered only during a session, not that the whole deployment has zero idle cost.

## Validation checklist

Before calling the build complete, I validated the system end to end:

- [ ] The App, Secrets, and Volumes exist only in the intended Modal environment.
- [ ] `/dev/net/tun` exists in the VM Sandbox.
- [ ] All three base-image directories contain a readable VMDK or VDI.
- [ ] All five qcow2 overlays reference the correct immutable base.
- [ ] Five QEMU processes remain alive after boot.
- [ ] Every guest answers WinRM through the host-local NAT forward.
- [ ] Every guest has its expected `192.168.56.x` lab address.
- [ ] All 16 Ansible marker files exist and status is `ready`.
- [ ] Domain relationships, trusts, DNS, AD CS, and intended GOAD vulnerabilities are present.
- [ ] noVNC requires the secret-backed password.
- [ ] The VPN reaches `192.168.56.0/24` but does not redirect ordinary Internet traffic.
- [ ] A stopped session’s old `.ovpn` file no longer connects.
- [ ] A non-owner portal account cannot stop, reset, extend, or download VPN access.
- [ ] Stop performs Windows shutdown, syncs the Volume, and removes the large Sandbox.
- [ ] Restart preserves the configured domain and skips completed playbooks.
- [ ] Automatic expiry stops the lab even when the browser is closed.

## Troubleshooting lessons

| Symptom | Likely cause | What I check |
|---|---|---|
| `/dev/net/tun` is missing | The Sandbox is not using the VM runtime | Confirm `experimental_options={"vm_runtime": True}` and retest the current Modal API. |
| QEMU reports that KVM is unavailable | Nested KVM is not exposed | Remove KVM flags and use `-accel tcg`; do not treat this as a transient Windows error. |
| A guest does not boot | Broken backing chain or wrong image format | Run `qemu-img info --backing-chain` and verify VMDK/VDI paths before recreating anything. |
| WinRM disappears during provisioning | Expected reboot or slow servicing | Wait, probe again, refresh credentials, and retry the current playbook only. |
| A domain playbook repeatedly fails | AD/DNS is not settled or an earlier reboot is pending | Check the per-playbook log, Windows event logs, DNS, and the previous marker. Do not skip forward. |
| Status looks stale | Volume state was not reloaded or committed | Reload before reads and commit after status/marker/database writes. |
| noVNC opens but the lab is not usable | Port 6080 became ready before Windows/Ansible | Use the durable lifecycle state; do not equate tunnel readiness with GOAD readiness. |
| VPN connects but targets do not respond | Missing route, forwarding, firewall, or NAT rule | Check the client route, `net.ipv4.ip_forward`, OpenVPN status, and `iptables` counters. Test TCP, not only ping. |
| A session vanishes near one day | Modal’s Sandbox lifetime limit was reached | Persist disks and start a new session; never design the lab around an indefinitely running Sandbox. |
| An upstream GOAD update breaks a role | The custom patches no longer match | Return to the pinned commit, diff upstream, and retest patches individually. |

The most useful debugging tactic was to reduce the problem. I first proved one disk, one QEMU process, one NAT-forwarded WinRM port, and one persistent overlay. Only then did I add the bridge, the other four machines, Ansible, noVNC, VPN, and finally the portal.

## What I would improve next

The working system is deliberately practical, but I would make several changes before offering it as a large multi-user service:

- replace local accounts with company SSO and role-based access control;
- allocate one isolated state Volume and Sandbox per approved tenant;
- generate a separate revocable VPN identity per user rather than one session-owner profile;
- add structured audit logs and alerts for long-running sessions;
- verify downloaded base images with published checksums or an internal artifact registry;
- build automated health tests for DNS, trusts, AD CS, and every expected machine;
- add a controlled “restore clean lab” workflow that creates new overlays without deleting the only recoverable copy;
- benchmark whether a future Modal runtime exposes safe hardware acceleration or whether a different provider is better for sustained use.

## Final lesson

The project became possible when I separated GOAD’s real requirements from the assumptions in its existing providers.

GOAD needed Windows machines, a private network, durable disks, and Ansible. It did not fundamentally require Proxmox or VirtualBox. Modal supplied an on-demand Linux VM, persistent storage, public access tunnels, and a programmable control plane. QEMU supplied the missing Windows virtualization layer. Linux bridge/TAP networking recreated the isolated LAN. Ansible remained the source of truth for the vulnerable Active Directory configuration.

The result is unconventional and slower than GOAD on a hardware-accelerated hypervisor, but it is full GOAD, persistent across sessions, browser-accessible, VPN-accessible, and metered only while the large host is running.

That was the central engineering lesson for me: when a platform does not support the provider you expect, identify the smallest missing primitive and rebuild only that layer.

## Reproduce the deployment from this Markdown file alone

The appendix below contains the complete **text source bundle** used by the Modal-only implementation. It includes the runtime, provisioning patches, portal backend, frontend, exact npm lockfile, local tests, and proof utilities. Generated folders, local databases, caches, screenshots, and the abandoned Proxmox experiment are intentionally not included because they are outputs or belong to a different architecture.

The three public GOAD images are fetched by the included `bootstrap_assets.py` from the same pinned GOAD commit and checked with SHA-256. Organization-owned hero and logo files are not redistributed as opaque binary blobs in a technical article. The bootstrapper preserves approved brand files when they already exist and otherwise creates portable fallbacks, so the extracted project still builds without private assets.

### 1. Extract every embedded file

Save this article as `blog.md`, open a terminal in the same directory, and run the following command. It accepts only relative paths, verifies every embedded SHA-256 hash, and writes into a new `goad-modal-from-blog` directory.

```bash
python3 - blog.md goad-modal-from-blog <<'PY'
from __future__ import annotations

import hashlib
import re
import sys
from pathlib import Path

source = Path(sys.argv[1]).resolve()
root = Path(sys.argv[2]).resolve()
root.mkdir(parents=True, exist_ok=True)
lines = source.read_text(encoding="utf-8").splitlines(keepends=True)
marker = re.compile(r'^<!-- BUNDLE-FILE path="([^"]+)" sha256="([0-9a-f]{64})" -->\n?$')
written = 0
index = 0

while index < len(lines):
    match = marker.match(lines[index])
    if not match:
        index += 1
        continue
    relative, expected = match.groups()
    if relative.startswith(("/", "~")) or ".." in Path(relative).parts:
        raise SystemExit(f"unsafe bundled path: {relative}")
    index += 1
    fence = lines[index].rstrip("\r\n")
    if not fence.startswith("``````"):
        raise SystemExit(f"missing source fence for {relative}")
    fence_token = fence[:6]
    index += 1
    body: list[str] = []
    while index < len(lines) and lines[index].rstrip("\r\n") != fence_token:
        body.append(lines[index])
        index += 1
    if index == len(lines):
        raise SystemExit(f"unterminated source fence for {relative}")
    data = "".join(body).encode("utf-8")
    actual = hashlib.sha256(data).hexdigest()
    if actual != expected:
        raise SystemExit(f"checksum mismatch for {relative}: {actual} != {expected}")
    destination = (root / relative).resolve()
    if root != destination and root not in destination.parents:
        raise SystemExit(f"path escaped output root: {relative}")
    destination.parent.mkdir(parents=True, exist_ok=True)
    destination.write_bytes(data)
    print(f"wrote {relative}")
    written += 1
    index += 1

if written == 0:
    raise SystemExit("no embedded files found")
print(f"extracted and verified {written} files into {root}")
PY
```

### 2. Install local build prerequisites

Use Python 3.11 or newer, Node.js 20 or newer, npm, Git, OpenSSL, and enough local free space for source/build artifacts. The large Windows boxes are downloaded directly into Modal storage, not onto the local workstation.

```bash
cd goad-modal-from-blog
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -r portal-requirements.txt
python bootstrap_assets.py

cd goad-portal
npm ci
npm run build
npm run test:sites
cd ..

python -m pytest -q tests/test_portal_api.py
```

Do not continue until the frontend build and backend tests pass. The build must create `goad-portal/dist/client/index.html`, because `modal_lab.py` copies that directory into the portal image.

If this is an authorized Transilience deployment, place the approved SVGs at these paths before running `bootstrap_assets.py`; the script preserves them:

```text
goad-portal/public/assets/transilience-logo-dark.svg
goad-portal/public/assets/transilience-logo-light.svg
```

### 3. Authenticate and create isolated Modal resources

```bash
modal setup
modal environment list
```

Create `goad-lab` only if it does not already exist:

```bash
modal environment create goad-lab
modal volume create -e goad-lab goad-lab-state
modal volume create -e goad-lab goad-web-data
VNC_PASSWORD="$(openssl rand -hex 4)"
modal secret create -e goad-lab goad-lab-auth \
  GOAD_VNC_PASSWORD="$VNC_PASSWORD"
unset VNC_PASSWORD
```

Traditional VNC authentication uses only the first eight characters. The command above generates exactly eight random hexadecimal characters, sends them directly to Modal, and removes the shell variable afterward. Never put the generated value in the Markdown file or reuse a company password.

If you choose a different environment or resource name, change it consistently in `modal_lab.py`, `lab_host.py`, `lab_client.py`, `qemu_probe.py`, `qemu_boot_probe.py`, `import_goad_boxes.py`, and `portal_api.py` before deployment.

### 4. Import and prove the Windows base image

The following commands download into the dedicated Modal Volume. They can take a long time and consume network, storage, and Sandbox compute.

```bash
python qemu_probe.py
python qemu_boot_probe.py
python import_goad_boxes.py
modal volume ls -e goad-lab goad-lab-state /base
```

Do not skip `qemu_boot_probe.py`. It proves that the Server 2019 box, QEMU TCG, its persistent overlay, NAT-forwarded WinRM, and the Modal VM runtime work together before the full lab consumes 16 cores.

The proof and host bootstrap code contain the upstream Vagrant box’s publicly documented `vagrant`/`vagrant` credential so they can reach a freshly imported evaluation image. It is not a Transilience or Modal credential. It is tried only inside the isolated lab while GOAD transitions the guests to its generated credentials; never reuse that pair for a real system.

The expected base directories are:

```text
/base/windows_2019_2021.05.15
/base/windows_2016_2017.12.14
/base/windows_2016_2019.02.14
```

### 5. Deploy the App

```bash
modal deploy -e goad-lab modal_lab.py
```

Record the portal URL printed by Modal. The generated `*.modal.run` address is public, so verify authentication before sharing it.

### 6. Start the lab and follow first-time provisioning

```bash
python lab_client.py start --session-hours 6
python lab_client.py status
python lab_client.py logs --lines 300
```

Repeat `status` and `logs` periodically. A clean first run progresses through `starting`, `booting`, `network-ready`, sixteen provisioning playbooks, and finally `ready`. Because QEMU uses TCG, first-time provisioning may take several hours and may legitimately retry around Windows reboots.

Do not launch a second Sandbox because provisioning appears slow. The named-Sandbox check, persistent markers, and logs are there to make one run resumable.

### 7. Perform the end-to-end acceptance test

When status reports `ready`:

1. Sign up through the portal and confirm a second user cannot take ownership of the same active session.
2. Launch the noVNC desktop and authenticate with the VNC secret.
3. Verify the five hosts at `.10`, `.11`, `.12`, `.22`, and `.23` from the isolated desktop.
4. Download the session-specific `.ovpn` profile and connect from an external test machine.
5. Confirm that `192.168.56.0/24` is routed through the VPN while ordinary Internet traffic is not.
6. Test WinRM or RDP instead of relying only on ICMP.
7. Exercise Extend and Reset as the session owner, then verify a different account receives `403` for owner-only actions.
8. Stop the lab and confirm the old VPN profile no longer connects.
9. Start it again and verify the AD configuration persists and completed Ansible playbooks are skipped.

Stop the metered host when testing is complete:

```bash
python lab_client.py stop
python lab_client.py status
```

The second command must report `running: false`. Also confirm in the Modal dashboard that `goad-host` has terminated.

### 8. Know what the bundle intentionally does not contain

The bundle contains no real passwords, Modal API tokens, portal database, VPN private keys, generated Windows disks, or evaluation Windows binaries. Those items must be created at deployment time or downloaded from their licensed upstream source. It also excludes the old `modal_app.py`/`client.py` Proxmox experiment because the working architecture does not use Proxmox.

The source appendix is large because it is designed for reproduction, not as pseudocode. Readers who only want the engineering narrative can stop here.

## Licensing and attribution

GOAD is published by Orange Cyberdefense under the [GNU General Public License v3.0](https://github.com/Orange-Cyberdefense/GOAD/blob/main/LICENSE). The compatibility patches in this article target the pinned GOAD revision and should retain the upstream project’s copyright and license notices when redistributed. Review the license yourself before incorporating modified GOAD material into another product.

The Windows Vagrant boxes and Windows evaluation installations are separate third-party artifacts with their own terms and evaluation periods; they are linked but not embedded in this article. Modal, Microsoft, Vagrant, OpenVPN, QEMU, and other project names belong to their respective owners. Transilience brand assets are also not embedded in the public bundle; authorized deployments should supply approved copies separately.

## References

- [GOAD repository and safety warning](https://github.com/Orange-Cyberdefense/GOAD)
- [GOAD installation architecture](https://orange-cyberdefense.github.io/GOAD/installation/)
- [GOAD Linux installation guide](https://orange-cyberdefense.github.io/GOAD/installation/linux/)
- [Full GOAD lab topology](https://orange-cyberdefense.github.io/GOAD/labs/GOAD/)
- [Modal VM Sandboxes](https://modal.com/docs/guide/vm-sandboxes)
- [Modal Sandboxes, timeouts, and readiness probes](https://modal.com/docs/guide/sandboxes)
- [Modal persistent Volumes](https://modal.com/docs/guide/volumes)
- [Modal Sandbox filesystem and Volume mounts](https://modal.com/docs/guide/sandbox-files)
- [Modal tunnels](https://modal.com/docs/guide/tunnels)
- [Modal Sandbox resources](https://modal.com/docs/guide/sandbox-resources)
- [Modal Environments](https://modal.com/docs/guide/environments)
- [Modal Secrets](https://modal.com/docs/guide/secrets)
- [Modal Web Functions](https://modal.com/docs/guide/webhooks)

<!-- SOURCE-BUNDLE:START -->
## Complete embedded source bundle

The following 30 files are the complete public-template text inputs for the reproducible Modal-only build. Production resource names and test PII have been replaced with generic examples. Each block is verbatim for this public template and carries the SHA-256 digest used by the extractor above.

<details>
<summary><code>bootstrap_assets.py</code> — <code>5f8a1e522ad273104fe71f5e0e512e1d569657529b8f1fdee8e84b106575a2bd</code></summary>

<!-- BUNDLE-FILE path="bootstrap_assets.py" sha256="5f8a1e522ad273104fe71f5e0e512e1d569657529b8f1fdee8e84b106575a2bd" -->
``````python
"""Fetch the public GOAD images and create portable portal brand fallbacks.

The two GOAD diagrams are downloaded from the same pinned GOAD commit used by
the runtime and verified before being written. Existing Transilience brand
assets are never overwritten; authorized internal deployments should place the
official SVG files at the documented paths before running this script.
"""

from __future__ import annotations

import hashlib
import os
import urllib.request
from pathlib import Path


GOAD_COMMIT = "992307adf944b934a3b76a2f56a637104c54b805"
RAW_ROOT = f"https://raw.githubusercontent.com/Orange-Cyberdefense/GOAD/{GOAD_COMMIT}/docs/img"
ASSET_ROOT = Path(__file__).parent / "goad-portal" / "public" / "assets"

DOWNLOADS = (
    (
        "goad-schema.png",
        f"{RAW_ROOT}/GOAD_schema.png",
        "d0ee378ba8074852e9300886351e0db4c0f68a6089af32a702d511910a5f3a0a",
        False,
    ),
    (
        "goad-compromise-paths.png",
        f"{RAW_ROOT}/diagram-GOAD_compromission_Path_dark.png",
        "80b0652dc134676078c500a6e765b60e9f6343716d73340573a1a2def9e57d2a",
        False,
    ),
    (
        # Portable public fallback for the catalog/hero image. The production
        # portal may replace this with an organization-owned illustration.
        "goad-fortress.png",
        f"{RAW_ROOT}/GOAD.png",
        "fe02d9cc3f0833c1f2894dc8a96bc7f5a671c61ace6b8f6a98436926a5cca1ef",
        True,
    ),
)


def digest(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def atomic_write(path: Path, data: bytes) -> None:
    temporary = path.with_suffix(path.suffix + ".tmp")
    temporary.write_bytes(data)
    os.replace(temporary, path)


def fetch(name: str, url: str, expected_sha256: str, preserve_existing: bool) -> None:
    destination = ASSET_ROOT / name
    if destination.exists():
        if digest(destination.read_bytes()) == expected_sha256:
            print(f"verified {destination}")
            return
        if preserve_existing:
            print(f"preserved existing organization-owned asset {destination}")
            return
    request = urllib.request.Request(url, headers={"User-Agent": "goad-modal-blog-bootstrap/1"})
    with urllib.request.urlopen(request, timeout=120) as response:
        data = response.read()
    actual = digest(data)
    if actual != expected_sha256:
        raise RuntimeError(f"SHA-256 mismatch for {url}: expected {expected_sha256}, got {actual}")
    atomic_write(destination, data)
    print(f"downloaded {destination}")


def fallback_logo(foreground: str) -> bytes:
    return f"""<svg xmlns="http://www.w3.org/2000/svg" width="800" height="109" viewBox="0 0 800 109">
  <rect width="800" height="109" fill="none"/>
  <g fill="none" stroke="#8cff00" stroke-width="9">
    <path d="M18 55c30-54 70-54 100 0s70 54 100 0"/>
    <path d="M18 55c30 54 70 54 100 0s70-54 100 0"/>
  </g>
  <text x="250" y="69" fill="{foreground}" font-family="Arial, sans-serif" font-size="44" font-weight="700">Transilience AI LABS</text>
</svg>
""".encode("utf-8")


def ensure_brand_fallbacks() -> None:
    for name, foreground in (
        ("transilience-logo-dark.svg", "#ffffff"),
        ("transilience-logo-light.svg", "#101827"),
    ):
        destination = ASSET_ROOT / name
        if destination.exists() and destination.stat().st_size > 0:
            print(f"preserved existing brand asset {destination}")
            continue
        atomic_write(destination, fallback_logo(foreground))
        print(f"created portable brand fallback {destination}")


def main() -> None:
    ASSET_ROOT.mkdir(parents=True, exist_ok=True)
    for item in DOWNLOADS:
        fetch(*item)
    ensure_brand_fallbacks()


if __name__ == "__main__":
    main()
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>modal_lab.py</code> — <code>ee45ce97d5446496616c617016dba23d4466d6efbf2a4f5a28f1c78a58205341</code></summary>

<!-- BUNDLE-FILE path="modal_lab.py" sha256="ee45ce97d5446496616c617016dba23d4466d6efbf2a4f5a28f1c78a58205341" -->
``````python
"""On-demand, Modal-only full GOAD lab for `your-workspace/goad-lab`.

Deploy with:
    modal deploy -e goad-lab modal_lab.py

Operate with:
    modal run -e goad-lab modal_lab.py --action start
    modal run -e goad-lab modal_lab.py --action status
    modal run -e goad-lab modal_lab.py --action stop
"""

from __future__ import annotations

import json
import time
from pathlib import Path
from typing import Any

import modal


APP_NAME = "goad-on-modal"
ENVIRONMENT = "goad-lab"
VOLUME_NAME = "goad-lab-state"
PORTAL_VOLUME_NAME = "goad-web-data"
SANDBOX_NAME = "goad-host"
GOAD_COMMIT = "992307adf944b934a3b76a2f56a637104c54b805"
VPN_PORT = 1194
VPN_PROFILE_PATH = "/run/goad-modal/vpn/client-template.ovpn"
SESSION_RUNTIME_PATH = "/run/goad-modal/session.json"
MAX_SANDBOX_SECONDS = 23 * 60 * 60
QEMU_POWERDOWN_SCRIPT = r"""
import pathlib
import socket

for name in ("dc01", "dc02", "dc03", "srv02", "srv03"):
    monitor = pathlib.Path(f"/run/goad-modal/{name}/monitor.sock")
    if not monitor.exists():
        continue
    try:
        client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        client.settimeout(5)
        client.connect(str(monitor))
        client.sendall(b"system_powerdown\n")
        client.close()
    except OSError:
        pass
"""
QEMU_RESET_SCRIPT = r"""
import json
import pathlib
import socket

reset = []
for name in ("dc01", "dc02", "dc03", "srv02", "srv03"):
    monitor = pathlib.Path(f"/run/goad-modal/{name}/monitor.sock")
    if not monitor.exists():
        continue
    client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    client.settimeout(5)
    client.connect(str(monitor))
    client.sendall(b"system_reset\n")
    client.close()
    reset.append(name)
print(json.dumps({"reset": reset}))
"""
EXTEND_SESSION_SCRIPT = r"""
import json
import os
import pathlib
import sys
import time

path = pathlib.Path("/run/goad-modal/session.json")
payload = json.loads(path.read_text(encoding="utf-8"))
extension_seconds = int(sys.argv[1])
old_expiry = int(payload["expires_at"])
base_expiry = max(old_expiry, int(time.time()))
new_expiry = min(base_expiry + extension_seconds, int(payload["max_expires_at"]))
payload["expires_at"] = new_expiry
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(payload) + "\n", encoding="utf-8")
os.replace(temporary, path)
payload["extended_seconds"] = max(0, new_expiry - old_expiry)
print(json.dumps(payload))
"""

app = modal.App(APP_NAME)
state = modal.Volume.from_name(
    VOLUME_NAME,
    environment_name=ENVIRONMENT,
    create_if_missing=True,
)
auth = modal.Secret.from_name(
    "goad-lab-auth",
    environment_name=ENVIRONMENT,
    required_keys=["GOAD_VNC_PASSWORD"],
)
portal_data = modal.Volume.from_name(
    PORTAL_VOLUME_NAME,
    environment_name=ENVIRONMENT,
    create_if_missing=True,
)

host_image = (
    modal.Image.from_registry("ubuntu:24.04", add_python="3.11")
    .env({"DEBIAN_FRONTEND": "noninteractive"})
    .apt_install(
        "bridge-utils",
        "ca-certificates",
        "curl",
        "dbus-x11",
        "git",
        "iproute2",
        "iptables",
        "netcat-openbsd",
        "novnc",
        "openvpn",
        "openssl",
        "qemu-system-x86",
        "qemu-utils",
        "samba-common-bin",
        "tigervnc-standalone-server",
        "tigervnc-tools",
        "tigervnc-viewer",
        "websockify",
        "xfce4",
        "xfce4-terminal",
        "xterm",
    )
    .run_commands(
        "git init /opt/GOAD",
        "git -C /opt/GOAD remote add origin https://github.com/Orange-Cyberdefense/GOAD.git",
        f"git -C /opt/GOAD fetch --depth 1 origin {GOAD_COMMIT}",
        "git -C /opt/GOAD checkout --detach FETCH_HEAD",
        "python -m venv /opt/GOAD/.venv",
        "/opt/GOAD/.venv/bin/pip install --no-cache-dir --upgrade pip",
        "/opt/GOAD/.venv/bin/pip install --no-cache-dir rich psutil Jinja2 pyyaml "
        "setuptools ansible_runner ansible-core==2.18.0 pywinrm requests-ntlm",
        "cd /opt/GOAD/ansible && /opt/GOAD/.venv/bin/ansible-galaxy install -r requirements_311.yml",
    )
    .add_local_file("lab_host.py", "/opt/goad-modal/lab_host.py", copy=True)
    .add_local_file("inventory.goad-modal", "/opt/goad-modal/inventory", copy=True)
    .add_local_dir("goad-patches", "/opt/goad-modal/patches", copy=True)
)

portal_image = (
    modal.Image.debian_slim(python_version="3.12")
    .pip_install("fastapi==0.116.1")
    .env(
        {
            "PORTAL_DB_PATH": "/portal-data/portal.db",
            "PORTAL_STATIC_DIR": "/opt/goad-portal/static",
        }
    )
    .add_local_file("portal_api.py", "/opt/goad-portal/portal_api.py", copy=True)
    .add_local_dir("goad-portal/dist/client", "/opt/goad-portal/static", copy=True)
)


@app.function(
    image=portal_image,
    volumes={"/portal-data": portal_data},
    secrets=[auth],
    cpu=0.25,
    memory=512,
    min_containers=0,
    max_containers=1,
    scaledown_window=60,
    timeout=900,
)
@modal.concurrent(max_inputs=100)
@modal.asgi_app()
def portal():
    """Serve the authenticated GOAD control portal from the same Modal app."""
    import sys

    sys.path.insert(0, "/opt/goad-portal")
    import portal_api

    portal_api.configure_persistence(portal_data.commit)
    return portal_api.app


def _running_sandbox() -> modal.Sandbox | None:
    try:
        sandbox = modal.Sandbox.from_name(
            APP_NAME,
            SANDBOX_NAME,
            environment_name=ENVIRONMENT,
        )
    except modal.exception.NotFoundError:
        return None
    return sandbox if sandbox.poll() is None else None


def _sandbox_is_stopping(exc: Exception) -> bool:
    return exc.__class__.__name__ == "ConflictError" and any(
        phrase in str(exc).lower() for phrase in ("shutting down", "already finished", "terminated")
    )


def _read_status() -> dict[str, Any]:
    status_path = Path("/lab/state/status.json")
    if not status_path.exists():
        return {"phase": "not-initialized"}
    try:
        return json.loads(status_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        return {"phase": "unknown", "status_error": str(exc)}


def _sandbox_session(sandbox: modal.Sandbox) -> dict[str, Any]:
    """Read ephemeral session timing, tolerating pre-feature Sandboxes."""
    try:
        process = sandbox.exec("cat", SESSION_RUNTIME_PATH, timeout=15)
        content = process.stdout.read()
        process.wait()
        if process.returncode != 0:
            return {}
        return json.loads(content)
    except Exception as exc:
        if _sandbox_is_stopping(exc):
            return {}
        return {}


def _sandbox_response(sandbox: modal.Sandbox) -> dict[str, Any]:
    try:
        tunnels = sandbox.tunnels(timeout=120)
    except Exception as exc:
        if _sandbox_is_stopping(exc):
            state.reload()
            return {
                "running": False,
                "environment": ENVIRONMENT,
                "app": APP_NAME,
                "status": _read_status(),
            }
        raise
    desktop = tunnels.get(6080)
    vpn = tunnels.get(VPN_PORT)
    session = _sandbox_session(sandbox)
    return {
        "running": sandbox.poll() is None,
        "sandbox_id": sandbox.object_id,
        "sandbox_dashboard": sandbox.get_dashboard_url(),
        "desktop_url": (
            f"{desktop.url}/vnc.html?autoconnect=1&resize=scale&reconnect=1"
            if desktop
            else None
        ),
        "vpn_available": bool(vpn and vpn.tcp_socket),
        "session_expires_at": session.get("expires_at"),
        "session_max_expires_at": session.get("max_expires_at"),
        "session_extend_available": bool(session.get("expires_at") and session.get("max_expires_at")),
        "environment": ENVIRONMENT,
        "app": APP_NAME,
        "status": _read_status(),
    }


def _mark_stopped_status() -> dict[str, Any]:
    state.reload()
    durable = _read_status()
    durable.update(
        {
            "phase": "stopped",
            "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
            "provisioned": durable.get("provisioned", False),
        }
    )
    Path("/lab/state/status.json").write_text(json.dumps(durable, indent=2) + "\n", encoding="utf-8")
    state.commit()
    return durable


@app.function(
    image=host_image,
    volumes={"/lab": state},
    cpu=0.5,
    memory=1024,
    min_containers=0,
    max_containers=1,
    scaledown_window=5,
    timeout=300,
)
def start(session_hours: float = 6.0) -> dict[str, Any]:
    """Start or return the running, persistent five-VM GOAD host."""
    if not 0.5 <= session_hours <= 23:
        raise ValueError("session_hours must be between 0.5 and 23")
    running = _running_sandbox()
    if running is not None:
        return _sandbox_response(running)
    deployed_app = modal.App.lookup(APP_NAME, environment_name=ENVIRONMENT)
    sandbox = modal.Sandbox.create(
        "/opt/GOAD/.venv/bin/python",
        "/opt/goad-modal/lab_host.py",
        app=deployed_app,
        name=SANDBOX_NAME,
        tags={"purpose": "full-goad", "environment": ENVIRONMENT},
        image=host_image,
        env={
            "GOAD_MAX_SESSION_SECONDS": str(int(session_hours * 3600)),
            "GOAD_MAX_SANDBOX_SECONDS": str(MAX_SANDBOX_SECONDS),
        },
        secrets=[auth],
        cpu=16,
        memory=32768,
        timeout=23 * 60 * 60,
        volumes={"/lab": state},
        encrypted_ports=[6080],
        unencrypted_ports=[VPN_PORT],
        readiness_probe=modal.Probe(tcp_port=6080, interval_ms=1000),
        experimental_options={"vm_runtime": True},
    )
    sandbox.wait_until_ready(timeout=180)
    return _sandbox_response(sandbox)


@app.function(
    image=host_image,
    volumes={"/lab": state},
    cpu=0.25,
    memory=512,
    min_containers=0,
    max_containers=1,
    scaledown_window=5,
    timeout=180,
)
def status() -> dict[str, Any]:
    """Return durable provisioning state and live tunnel information."""
    state.reload()
    running = _running_sandbox()
    if running is None:
        return {
            "running": False,
            "environment": ENVIRONMENT,
            "app": APP_NAME,
            "status": _read_status(),
        }
    return _sandbox_response(running)


@app.function(
    image=host_image,
    volumes={"/lab": state},
    cpu=0.25,
    memory=512,
    min_containers=0,
    max_containers=1,
    scaledown_window=5,
    timeout=180,
)
def vpn_config() -> dict[str, str]:
    """Return the current session's ephemeral OpenVPN client profile."""
    running = _running_sandbox()
    if running is None:
        raise RuntimeError("The GOAD lab is not running")
    tunnel = running.tunnels(timeout=120).get(VPN_PORT)
    if tunnel is None or tunnel.tcp_socket is None:
        raise RuntimeError("The VPN tunnel is not available")
    process = running.exec("cat", VPN_PROFILE_PATH, timeout=30)
    profile = process.stdout.read()
    process.wait()
    if process.returncode != 0 or not profile.strip():
        raise RuntimeError("The VPN profile is not ready")
    host, port = tunnel.tcp_socket
    rendered = profile.replace("__VPN_HOST__", host).replace("__VPN_PORT__", str(port))
    if "__VPN_" in rendered:
        raise RuntimeError("The VPN profile could not be rendered")
    return {"filename": "transilience-goad.ovpn", "profile": rendered}


@app.function(
    image=host_image,
    volumes={"/lab": state},
    cpu=0.25,
    memory=512,
    min_containers=0,
    max_containers=1,
    scaledown_window=5,
    timeout=180,
)
def reset() -> dict[str, Any]:
    """Hard-reset all five guests while preserving the paid Sandbox session."""
    running = _running_sandbox()
    if running is None:
        raise RuntimeError("The GOAD lab is not running")
    process = running.exec("python3", "-c", QEMU_RESET_SCRIPT, timeout=30)
    output = process.stdout.read()
    process.wait()
    if process.returncode != 0:
        raise RuntimeError("The GOAD machines could not be reset")
    payload = json.loads(output)
    if len(payload.get("reset", [])) != 5:
        raise RuntimeError("Not all GOAD machines accepted the reset request")
    response = _sandbox_response(running)
    response["reset_requested"] = True
    return response


@app.function(
    image=host_image,
    volumes={"/lab": state},
    cpu=0.25,
    memory=512,
    min_containers=0,
    max_containers=1,
    scaledown_window=5,
    timeout=180,
)
def extend(extension_hours: float = 1.0) -> dict[str, Any]:
    """Extend the current session without exceeding the Sandbox hard limit."""
    if not 0.25 <= extension_hours <= 6:
        raise ValueError("extension_hours must be between 0.25 and 6")
    running = _running_sandbox()
    if running is None:
        raise RuntimeError("The GOAD lab is not running")
    current = _sandbox_session(running)
    if not current.get("expires_at") or not current.get("max_expires_at"):
        raise RuntimeError("Session extension is available after the lab is restarted")
    process = running.exec(
        "python3",
        "-c",
        EXTEND_SESSION_SCRIPT,
        str(int(extension_hours * 3600)),
        timeout=30,
    )
    output = process.stdout.read()
    process.wait()
    if process.returncode != 0:
        raise RuntimeError("The GOAD session could not be extended")
    session = json.loads(output)
    response = _sandbox_response(running)
    response.update(
        {
            "session_expires_at": session["expires_at"],
            "session_max_expires_at": session["max_expires_at"],
            "session_extend_available": session["expires_at"] < session["max_expires_at"],
            "extended_seconds": session["extended_seconds"],
        }
    )
    return response


@app.function(
    image=host_image,
    volumes={"/lab": state},
    cpu=0.25,
    memory=512,
    min_containers=0,
    max_containers=1,
    scaledown_window=5,
    timeout=600,
)
def stop() -> dict[str, Any]:
    """Request ACPI shutdown for every guest, then terminate the billable host."""
    running = _running_sandbox()
    if running is None:
        return {"running": False, "status": _mark_stopped_status()}
    try:
        process = running.exec("touch", "/run/goad-modal/stop", timeout=30)
        process.wait()
    except Exception as exc:
        if not _sandbox_is_stopping(exc):
            raise
    try:
        powerdown = running.exec("python3", "-c", QEMU_POWERDOWN_SCRIPT, timeout=30)
        powerdown.wait()
    except Exception as exc:
        if not _sandbox_is_stopping(exc):
            raise
    deadline = time.monotonic() + 180
    while time.monotonic() < deadline:
        try:
            if running.poll() is not None:
                break
            guests = running.exec("pgrep", "-f", "[q]emu-system-x86_64", timeout=15)
            guests.wait()
            if guests.returncode != 0:
                break
        except Exception as exc:
            if _sandbox_is_stopping(exc):
                break
            raise
        time.sleep(5)
    try:
        if running.poll() is None:
            running.terminate(wait=True)
    except Exception as exc:
        if not _sandbox_is_stopping(exc):
            raise
    durable = _mark_stopped_status()
    return {"running": False, "status": durable}


@app.function(
    image=host_image,
    volumes={"/lab": state},
    cpu=0.25,
    memory=512,
    min_containers=0,
    max_containers=1,
    scaledown_window=5,
    timeout=180,
)
def logs(lines: int = 120) -> dict[str, Any]:
    """Return recent host/provisioning progress without exposing disk contents."""
    if not 1 <= lines <= 1000:
        raise ValueError("lines must be between 1 and 1000")
    state.reload()
    log_path = Path("/lab/logs/host.log")
    content = log_path.read_text(encoding="utf-8", errors="replace") if log_path.exists() else ""
    return {"lines": content.splitlines()[-lines:], "status": _read_status()}


@app.local_entrypoint()
def main(action: str = "status", session_hours: float = 6.0, lines: int = 120) -> None:
    if action == "start":
        result = start.remote(session_hours)
    elif action == "status":
        result = status.remote()
    elif action == "stop":
        result = stop.remote()
    elif action == "reset":
        result = reset.remote()
    elif action == "extend":
        result = extend.remote(1.0)
    elif action == "logs":
        result = logs.remote(lines)
    else:
        raise ValueError("action must be one of: start, status, stop, reset, extend, logs")
    print(json.dumps(result, indent=2))
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>lab_host.py</code> — <code>51ee005c0b6b42526461abbf8618d1d66000ccf65d8b6dff2349558439eac4e9</code></summary>

<!-- BUNDLE-FILE path="lab_host.py" sha256="51ee005c0b6b42526461abbf8618d1d66000ccf65d8b6dff2349558439eac4e9" -->
``````python
"""Supervisor for the five-machine GOAD lab inside a Modal VM Sandbox.

The host uses QEMU TCG because Modal's nested VM runtime does not expose KVM.
All durable guest writes live under /lab on the dedicated Modal Volume.
"""

from __future__ import annotations

import concurrent.futures
import base64
import gzip
import glob
import json
import os
import signal
import socket
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path

import winrm


LAB_ROOT = Path("/lab")
STATE_ROOT = LAB_ROOT / "state"
VM_ROOT = LAB_ROOT / "vms"
LOG_ROOT = LAB_ROOT / "logs"
RUNTIME_ROOT = Path("/run/goad-modal")
GOAD_ROOT = Path("/opt/GOAD")
NETWORK_PREFIX = "192.168.56"
VPN_ROOT = RUNTIME_ROOT / "vpn"
VPN_PORT = 1194
VPN_NETWORK = "10.77.0.0"
VPN_NETMASK = "255.255.255.0"
SESSION_RUNTIME_PATH = RUNTIME_ROOT / "session.json"
MAX_SANDBOX_SECONDS = 23 * 60 * 60


@dataclass(frozen=True)
class Vm:
    name: str
    ip_last: int
    memory_mb: int
    base_dir: str
    nat_winrm_port: int
    nat_rdp_port: int
    vnc_display: int

    @property
    def lab_ip(self) -> str:
        return f"{NETWORK_PREFIX}.{self.ip_last}"

    @property
    def lab_mac(self) -> str:
        return f"52:54:00:56:00:{self.ip_last:02x}"

    @property
    def tap(self) -> str:
        return f"tap-{self.name}"


VMS = (
    Vm("dc01", 10, 3000, "windows_2019_2021.05.15", 15985, 13389, 1),
    Vm("dc02", 11, 3000, "windows_2019_2021.05.15", 25985, 23389, 2),
    Vm("dc03", 12, 3000, "windows_2016_2017.12.14", 35985, 33389, 3),
    Vm("srv02", 22, 6000, "windows_2019_2021.05.15", 45985, 43389, 4),
    Vm("srv03", 23, 5000, "windows_2016_2019.02.14", 55985, 53389, 5),
)

PLAYBOOKS = (
    "build.yml",
    "ad-servers.yml",
    "ad-parent_domain.yml",
    "ad-child_domain.yml",
    "wait5m.yml",
    "ad-members.yml",
    "ad-trusts.yml",
    "ad-data.yml",
    "ad-gmsa.yml",
    "laps.yml",
    "ad-relations.yml",
    "adcs.yml",
    "ad-acl.yml",
    "servers.yml",
    "security.yml",
    "vulnerabilities.yml",
)

# Windows cumulative-update servicing can perform several delayed reboots while
# a large role such as IIS is being applied under QEMU TCG.  Keep retries
# bounded, but allow enough clean, idempotent passes for those reboot cycles to
# settle before declaring the durable lab failed.
MAX_PLAYBOOK_ATTEMPTS = 8

STOP_REQUESTED = False
AUTH_CACHE: dict[str, tuple[str, str]] = {}

SSMS_22_ROLE_TASKS = r"""---
- name: Read the installed .NET Framework release
  win_shell: |
    $release = (Get-ItemProperty \
      'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' \
      -Name Release -ErrorAction SilentlyContinue).Release
    if ($null -eq $release) { Write-Output '0' } else { Write-Output $release }
  register: dotnet_framework_release
  changed_when: false

- name: Install the SSMS .NET Framework 4.8 prerequisite when needed
  block:
    - name: Ensure the SQL tooling setup directory exists
      win_file:
        path: 'C:\setup\mssql'
        state: directory

    - name: Download the .NET Framework 4.8 offline installer
      win_get_url:
        url: 'https://go.microsoft.com/fwlink/?linkid=2088631'
        dest: 'C:\setup\mssql\NDP48-x86-x64-AllOS-ENU.exe'
        force: false

    - name: Install .NET Framework 4.8
      win_shell: |
        $ErrorActionPreference = 'Stop'
        $installer = 'C:\setup\mssql\NDP48-x86-x64-AllOS-ENU.exe'
        $process = Start-Process -FilePath $installer -ArgumentList '/q /norestart' -Wait -PassThru
        Set-Content -LiteralPath 'C:\setup\mssql\dotnet48-last-exit-code' -Value $process.ExitCode -Force
        if ($process.ExitCode -notin @(0, 1641, 3010)) {
          throw ".NET Framework 4.8 installer exited with code $($process.ExitCode)"
        }
      changed_when: true

    - name: Reboot to activate .NET Framework 4.8
      win_reboot:
        reboot_timeout: 1800
  when: (dotnet_framework_release.stdout | trim | int) < 528040

- name: Verify the SSMS .NET Framework prerequisite
  win_shell: |
    $release = (Get-ItemProperty \
      'HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full' \
      -Name Release -ErrorAction Stop).Release
    Write-Output $release
  register: dotnet_framework_release_after
  changed_when: false
  failed_when: (dotnet_framework_release_after.stdout | trim | int) < 528040

- name: Detect an existing SQL Server Management Studio installation
  win_shell: |
    $ErrorActionPreference = 'Stop'
    $registryRoots = @(
      'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
      'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
    )
    $registryMatch = Get-ItemProperty -Path $registryRoots -ErrorAction SilentlyContinue |
      Where-Object { $_.DisplayName -match '^(Microsoft )?SQL Server Management Studio' } |
      Select-Object -First 1

    $programRoots = @($env:ProgramFiles, ${env:ProgramFiles(x86)}) |
      Where-Object { $_ -and (Test-Path -LiteralPath $_) }
    $patterns = foreach ($root in $programRoots) {
      Join-Path $root 'Microsoft SQL Server Management Studio *\Common7\IDE\Ssms.exe'
      Join-Path $root 'Microsoft SQL Server Management Studio *\Release\Common7\IDE\Ssms.exe'
    }
    $executableMatch = $patterns |
      ForEach-Object { Get-Item -Path $_ -ErrorAction SilentlyContinue } |
      Select-Object -First 1

    # A failed Visual Studio bootstrapper can leave an uninstall-registry entry
    # without installing Ssms.exe.  Only the executable proves SSMS is usable.
    if ($executableMatch) {
      Write-Output 'installed'
    } else {
      Write-Output 'missing'
    }
  register: ssms_installation
  changed_when: false

- name: Install SSMS 22 when Management Studio is missing
  block:
    - name: Ensure the SSMS setup directory exists
      win_file:
        path: 'C:\setup\mssql'
        state: directory

    - name: Clear a stale SSMS installer exit marker
      win_file:
        path: 'C:\setup\mssql\ssms-last-exit-code'
        state: absent

    - name: Download the current SSMS installer
      win_get_url:
        url: 'https://aka.ms/ssmsfullsetup'
        dest: 'C:\setup\mssql\SSMS_installer.exe'
        force: false

    - name: Install SSMS 22 with the supported bootstrapper arguments
      win_shell: |
        $ErrorActionPreference = 'Stop'
        $installer = 'C:\setup\mssql\SSMS_installer.exe'
        $registryRoots = @(
          'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
          'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
        )
        $registryMatch = Get-ItemProperty -Path $registryRoots -ErrorAction SilentlyContinue |
          Where-Object { $_.DisplayName -match '^(Microsoft )?SQL Server Management Studio' } |
          Select-Object -First 1

        $installPath = 'C:\Program Files\Microsoft SQL Server Management Studio 22\Release'
        if ($registryMatch -and $registryMatch.InstallLocation) {
          $installPath = $registryMatch.InstallLocation.TrimEnd('\\')
        }

        # A bootstrapper crash can register SSMS before Ssms.exe exists.  Repair
        # that product in place; otherwise perform a normal explicit-path install.
        if ($registryMatch) {
          $arguments = 'repair --installPath "' + $installPath + '" --quiet --wait --norestart'
        } else {
          $arguments = '--installPath "' + $installPath + '" --quiet --wait --norestart'
        }
        $process = Start-Process -FilePath $installer -ArgumentList $arguments -Wait -PassThru
        Set-Content -LiteralPath 'C:\setup\mssql\ssms-last-exit-code' -Value $process.ExitCode -Force
        if ($process.ExitCode -notin @(0, 3010)) {
          throw "SSMS installer exited with code $($process.ExitCode)"
        }
        Set-Content -LiteralPath 'C:\setup\mssql\ssms-reboot-required' -Value $process.ExitCode -Force
        Write-Output "SSMS installer completed with code $($process.ExitCode)"
      register: install_ssms
      changed_when: true
  rescue:
    - name: Clean up a broken partial SSMS 22 product registration
      win_shell: |
        $ErrorActionPreference = 'Stop'
        $exitMarker = 'C:\setup\mssql\ssms-last-exit-code'
        $shouldCleanup = $false

        if (Test-Path -LiteralPath $exitMarker) {
          $lastExitCode = [int](Get-Content -LiteralPath $exitMarker -Raw).Trim()
          if ($lastExitCode -eq 1603) {
            $registryRoots = @(
              'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
              'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
            )
            $registryMatch = Get-ItemProperty -Path $registryRoots -ErrorAction SilentlyContinue |
              Where-Object {
                $_.DisplayName -match '^(Microsoft )?SQL Server Management Studio' -and
                ([string]$_.DisplayVersion) -match '^22(?:\.|$)'
              } |
              Select-Object -First 1

            $programRoots = @($env:ProgramFiles, ${env:ProgramFiles(x86)}) |
              Where-Object { $_ -and (Test-Path -LiteralPath $_) }
            $patterns = foreach ($root in $programRoots) {
              Join-Path $root 'Microsoft SQL Server Management Studio *\Common7\IDE\Ssms.exe'
              Join-Path $root 'Microsoft SQL Server Management Studio *\Release\Common7\IDE\Ssms.exe'
            }
            $executableMatch = $patterns |
              ForEach-Object { Get-Item -Path $_ -ErrorAction SilentlyContinue } |
              Select-Object -First 1
            $shouldCleanup = [bool]($registryMatch -and -not $executableMatch)
          }
        }

        $cleanup = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\InstallCleanup.exe"
        $cleanupExit = $null
        if ($shouldCleanup -and (Test-Path -LiteralPath $cleanup)) {
          $process = Start-Process -FilePath $cleanup -ArgumentList '-i 22' -Wait -PassThru
          $cleanupExit = $process.ExitCode
          Write-Output "cleanup-ran-exit=$($process.ExitCode)"
        } elseif ($shouldCleanup) {
          Write-Output 'cleanup-unavailable'
        } else {
          Write-Output 'cleanup-skipped'
        }

        # InstallCleanup can itself crash against a partially registered SSMS
        # instance.  Remove only the proven-orphaned SSMS 22 uninstall key when
        # the Visual Studio installer has no corresponding product instance.
        if ($shouldCleanup -and $cleanupExit -ne 0 -and $registryMatch) {
          $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
          $instanceIds = @()
          if (Test-Path -LiteralPath $vswhere) {
            $instanceIds = @(& $vswhere -all -prerelease -property instanceId 2>$null) |
              Where-Object { $_ -and $_.Trim() }
          }

          if ($instanceIds.Count -eq 0 -and
              $registryMatch.DisplayName -eq 'SQL Server Management Studio 22' -and
              ([string]$registryMatch.DisplayVersion) -match '^22(?:\.|$)') {
            $nativeKey = ([string]$registryMatch.PSPath) -replace '^Microsoft\.PowerShell\.Core\\Registry::', ''
            $backup = 'C:\setup\mssql\orphaned-ssms22-uninstall.reg'
            & reg.exe export $nativeKey $backup /y | Out-Null
            if ($LASTEXITCODE -ne 0) {
              throw 'Unable to back up the orphaned SSMS 22 uninstall record.'
            }
            Remove-Item -LiteralPath $registryMatch.PSPath -Recurse -Force
            Write-Output 'cleanup-orphan-removed'
          }
        }
      register: ssms_cleanup
      changed_when: >-
        'cleanup-ran-exit=0' in (ssms_cleanup.stdout | default('')) or
        'cleanup-orphan-removed' in (ssms_cleanup.stdout | default(''))
      failed_when: false

    - name: Remove a failed cached SSMS installer so a retry redownloads it
      win_file:
        path: 'C:\setup\mssql\SSMS_installer.exe'
        state: absent

    - name: Fail after cleaning up the SSMS installer
      fail:
        msg: "SSMS 22 installation failed; cached installer removed for a safe retry: {{ ansible_failed_result.msg | default('unknown error') }}"
  when: (ssms_installation.stdout | trim) != 'installed'

- name: Check whether SSMS installation requires a reboot
  win_stat:
    path: 'C:\setup\mssql\ssms-reboot-required'
  register: ssms_reboot_marker

- name: Reboot after a successful SSMS installation
  win_reboot:
    reboot_timeout: 1200
  when: ssms_reboot_marker.stat.exists

- name: Clear the completed SSMS reboot marker
  win_file:
    path: 'C:\setup\mssql\ssms-reboot-required'
    state: absent
  when: ssms_reboot_marker.stat.exists

- name: Verify SQL Server Management Studio is installed
  win_shell: |
    $ErrorActionPreference = 'Stop'
    $registryRoots = @(
      'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
      'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
    )
    $registryMatch = Get-ItemProperty -Path $registryRoots -ErrorAction SilentlyContinue |
      Where-Object { $_.DisplayName -match '^(Microsoft )?SQL Server Management Studio' } |
      Select-Object -First 1

    $programRoots = @($env:ProgramFiles, ${env:ProgramFiles(x86)}) |
      Where-Object { $_ -and (Test-Path -LiteralPath $_) }
    $patterns = foreach ($root in $programRoots) {
      Join-Path $root 'Microsoft SQL Server Management Studio *\Common7\IDE\Ssms.exe'
      Join-Path $root 'Microsoft SQL Server Management Studio *\Release\Common7\IDE\Ssms.exe'
    }
    $executableMatch = $patterns |
      ForEach-Object { Get-Item -Path $_ -ErrorAction SilentlyContinue } |
      Select-Object -First 1

    if ($executableMatch) {
      Write-Output 'installed'
    } else {
      Write-Output 'missing'
    }
  register: ssms_installation_after
  changed_when: false

- name: Require a usable SQL Server Management Studio installation
  assert:
    that:
      - (ssms_installation_after.stdout | trim) == 'installed'
    fail_msg: 'SSMS was not detected in the uninstall registry or expected executable locations after installation.'
"""


def log(message: str) -> None:
    timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
    line = f"[{timestamp}] {message}"
    print(line, flush=True)
    LOG_ROOT.mkdir(parents=True, exist_ok=True)
    with (LOG_ROOT / "host.log").open("a", encoding="utf-8") as output:
        output.write(line + "\n")


def run(command: list[str], *, check: bool = True, **kwargs: object) -> subprocess.CompletedProcess[str]:
    return subprocess.run(command, check=check, text=True, **kwargs)


def patch_goad_ssms_22_role() -> None:
    """Install the retry-safe SSMS 22 role patch into the runtime GOAD tree."""
    role_path = GOAD_ROOT / "ansible/roles/mssql_ssms/tasks/main.yml"
    if not role_path.parent.is_dir():
        raise RuntimeError(f"GOAD SSMS role directory is missing: {role_path.parent}")
    if role_path.exists() and role_path.read_text(encoding="utf-8") == SSMS_22_ROLE_TASKS:
        log("GOAD SSMS 22 compatibility patch is already applied")
        return

    temporary = role_path.with_name(f".{role_path.name}.tmp")
    temporary.write_text(SSMS_22_ROLE_TASKS, encoding="utf-8")
    if role_path.exists():
        os.chmod(temporary, role_path.stat().st_mode & 0o777)
    temporary.replace(role_path)
    log("Applied durable GOAD SSMS 22 compatibility patch")


def patch_goad_tcg_roles() -> None:
    """Install retry-safe GOAD task patches required by the TCG runtime."""
    patch_root = Path("/opt/goad-modal/patches")
    targets = {
        "security.yml": GOAD_ROOT / "ansible/security.yml",
        "windows_defender-main.yml": GOAD_ROOT / "ansible/roles/settings/windows_defender/tasks/main.yml",
        "adcs_esc7-main.yml": GOAD_ROOT / "ansible/roles/vulns/adcs_esc7/tasks/main.yml",
        "esc13.ps1": GOAD_ROOT / "ansible/roles/vulns/adcs_esc13/files/esc13.ps1",
        "shares-main.yml": GOAD_ROOT / "ansible/roles/vulns/shares/tasks/main.yml",
    }
    for source_name, target in targets.items():
        source = patch_root / source_name
        if not source.is_file():
            raise RuntimeError(f"Required GOAD patch is missing: {source}")
        content = source.read_bytes()
        if target.exists() and target.read_bytes() == content:
            continue
        temporary = target.with_name(f".{target.name}.tmp")
        temporary.write_bytes(content)
        if target.exists():
            os.chmod(temporary, target.stat().st_mode & 0o777)
        temporary.replace(target)
        log(f"Applied durable GOAD compatibility patch: {source_name}")

    config_path = GOAD_ROOT / "ad/GOAD/data/config.json"
    config = json.loads(config_path.read_text(encoding="utf-8"))
    srv02_vulns = config["lab"]["hosts"]["srv02"].setdefault("vulns", [])
    if "shares" not in srv02_vulns:
        srv02_vulns.append("shares")
        temporary = config_path.with_name(f".{config_path.name}.tmp")
        temporary.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
        temporary.replace(config_path)
        log("Enabled the intended srv02 thewall share tasks")


def write_status(phase: str, **extra: object) -> None:
    STATE_ROOT.mkdir(parents=True, exist_ok=True)
    payload = {
        "phase": phase,
        "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        "network": f"{NETWORK_PREFIX}.0/24",
        "hosts": {vm.name: vm.lab_ip for vm in VMS},
        **extra,
    }
    temporary = STATE_ROOT / "status.json.tmp"
    temporary.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
    temporary.replace(STATE_ROOT / "status.json")


def first_disk(base_dir: str) -> Path:
    candidates = sorted(glob.glob(str(LAB_ROOT / "base" / base_dir / "*.vmdk")))
    candidates += sorted(glob.glob(str(LAB_ROOT / "base" / base_dir / "*.vdi")))
    if not candidates:
        raise RuntimeError(f"No base disk found in {base_dir}")
    return Path(candidates[0])


def setup_bridge() -> None:
    if not Path("/dev/net/tun").exists():
        raise RuntimeError("Modal VM runtime did not expose /dev/net/tun")
    if run(["ip", "link", "show", "br-goad"], check=False, stdout=subprocess.DEVNULL).returncode:
        run(["ip", "link", "add", "br-goad", "type", "bridge"])
    run(["ip", "addr", "replace", f"{NETWORK_PREFIX}.1/24", "dev", "br-goad"])
    run(["ip", "link", "set", "br-goad", "up"])
    for vm in VMS:
        if run(["ip", "link", "show", vm.tap], check=False, stdout=subprocess.DEVNULL).returncode:
            run(["ip", "tuntap", "add", "dev", vm.tap, "mode", "tap"])
        run(["ip", "link", "set", vm.tap, "master", "br-goad"])
        run(["ip", "link", "set", vm.tap, "up"])
    log("Shared GOAD bridge br-goad is ready")


def _openssl(*arguments: str) -> None:
    run(
        ["openssl", *arguments],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )


def _ensure_iptables(table: str | None, rule: list[str]) -> None:
    prefix = ["iptables"] + (["-t", table] if table else [])
    if run(prefix + ["-C", *rule], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode:
        run(prefix + ["-A", *rule])


def setup_vpn() -> None:
    """Create an ephemeral owner profile and route it only to the GOAD subnet."""
    VPN_ROOT.mkdir(parents=True, exist_ok=True)
    os.chmod(VPN_ROOT, 0o700)
    ca_key = VPN_ROOT / "ca.key"
    ca_cert = VPN_ROOT / "ca.crt"
    server_key = VPN_ROOT / "server.key"
    server_csr = VPN_ROOT / "server.csr"
    server_cert = VPN_ROOT / "server.crt"
    client_key = VPN_ROOT / "client.key"
    client_csr = VPN_ROOT / "client.csr"
    client_cert = VPN_ROOT / "client.crt"
    tls_crypt = VPN_ROOT / "tls-crypt.key"

    _openssl("genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", str(ca_key))
    _openssl(
        "req", "-x509", "-new", "-sha256", "-days", "2", "-key", str(ca_key),
        "-subj", "/CN=Transilience AI LABS Ephemeral VPN CA", "-out", str(ca_cert),
    )
    _openssl("genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", str(server_key))
    _openssl("req", "-new", "-key", str(server_key), "-subj", "/CN=goad-vpn-server", "-out", str(server_csr))
    server_ext = VPN_ROOT / "server.ext"
    server_ext.write_text(
        "basicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\n"
        "extendedKeyUsage=serverAuth\nsubjectAltName=DNS:goad-vpn-server\n",
        encoding="utf-8",
    )
    _openssl(
        "x509", "-req", "-in", str(server_csr), "-CA", str(ca_cert), "-CAkey", str(ca_key),
        "-CAcreateserial", "-days", "2", "-sha256", "-extfile", str(server_ext), "-out", str(server_cert),
    )
    _openssl("genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", str(client_key))
    _openssl("req", "-new", "-key", str(client_key), "-subj", "/CN=goad-session-owner", "-out", str(client_csr))
    client_ext = VPN_ROOT / "client.ext"
    client_ext.write_text(
        "basicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\n"
        "extendedKeyUsage=clientAuth\n",
        encoding="utf-8",
    )
    _openssl(
        "x509", "-req", "-in", str(client_csr), "-CA", str(ca_cert), "-CAkey", str(ca_key),
        "-days", "2", "-sha256", "-extfile", str(client_ext), "-out", str(client_cert),
    )
    run(
        ["openvpn", "--genkey", "secret", str(tls_crypt)],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )

    server_config = VPN_ROOT / "server.conf"
    server_config.write_text(
        f"""port {VPN_PORT}
proto tcp-server
dev tun-vpn
topology subnet
server {VPN_NETWORK} {VPN_NETMASK}
push \"route {NETWORK_PREFIX}.0 255.255.255.0\"
ca {ca_cert}
cert {server_cert}
key {server_key}
dh none
ecdh-curve prime256v1
tls-crypt {tls_crypt}
tls-version-min 1.2
data-ciphers AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305
data-ciphers-fallback AES-256-GCM
auth SHA256
keepalive 10 60
persist-key
persist-tun
user nobody
group nogroup
status {VPN_ROOT / 'status.log'}
log-append {LOG_ROOT / 'openvpn.log'}
verb 3
""",
        encoding="utf-8",
    )

    client_template = VPN_ROOT / "client-template.ovpn"
    client_template.write_text(
        f"""client
dev tun
proto tcp-client
remote __VPN_HOST__ __VPN_PORT__
resolv-retry infinite
nobind
persist-key
persist-tun
remote-cert-tls server
verify-x509-name goad-vpn-server name
auth-nocache
tls-version-min 1.2
data-ciphers AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305
data-ciphers-fallback AES-256-GCM
auth SHA256
pull-filter ignore \"redirect-gateway\"
verb 3
<ca>
{ca_cert.read_text(encoding='utf-8').strip()}
</ca>
<cert>
{client_cert.read_text(encoding='utf-8').strip()}
</cert>
<key>
{client_key.read_text(encoding='utf-8').strip()}
</key>
<tls-crypt>
{tls_crypt.read_text(encoding='utf-8').strip()}
</tls-crypt>
""",
        encoding="utf-8",
    )
    os.chmod(client_template, 0o600)

    run(["sysctl", "-w", "net.ipv4.ip_forward=1"], stdout=subprocess.DEVNULL)
    _ensure_iptables(None, ["FORWARD", "-i", "tun-vpn", "-o", "br-goad", "-j", "ACCEPT"])
    _ensure_iptables(
        None,
        ["FORWARD", "-i", "br-goad", "-o", "tun-vpn", "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT"],
    )
    _ensure_iptables(
        "nat",
        ["POSTROUTING", "-s", "10.77.0.0/24", "-d", f"{NETWORK_PREFIX}.0/24", "-o", "br-goad", "-j", "MASQUERADE"],
    )
    subprocess.Popen(
        ["openvpn", "--config", str(server_config)],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        start_new_session=True,
    )
    deadline = time.monotonic() + 30
    while time.monotonic() < deadline:
        try:
            with socket.create_connection(("127.0.0.1", VPN_PORT), timeout=1):
                log("Ephemeral OpenVPN gateway is ready")
                return
        except OSError:
            time.sleep(1)
    raise RuntimeError("OpenVPN did not start within 30 seconds")


def setup_desktop() -> None:
    password = os.environ["GOAD_VNC_PASSWORD"][:8]
    if not password:
        raise RuntimeError("GOAD_VNC_PASSWORD must not be empty")
    vnc_dir = Path("/root/.vnc")
    vnc_dir.mkdir(parents=True, exist_ok=True)
    passwd = subprocess.run(
        ["vncpasswd", "-f"],
        input=(password + "\n").encode(),
        stdout=subprocess.PIPE,
        check=True,
    ).stdout
    (vnc_dir / "passwd").write_bytes(passwd)
    os.chmod(vnc_dir / "passwd", 0o600)
    (vnc_dir / "xstartup").write_text(
        "#!/bin/sh\nunset SESSION_MANAGER\nunset DBUS_SESSION_BUS_ADDRESS\n"
        "exec dbus-launch --exit-with-session startxfce4\n",
        encoding="utf-8",
    )
    os.chmod(vnc_dir / "xstartup", 0o755)
    run(["vncserver", ":9", "-localhost", "yes", "-geometry", "1600x900", "-depth", "24"])
    subprocess.Popen(
        ["websockify", "--web=/usr/share/novnc", "0.0.0.0:6080", "127.0.0.1:5909"],
        stdout=(LOG_ROOT / "novnc.log").open("a", encoding="utf-8"),
        stderr=subprocess.STDOUT,
        start_new_session=True,
    )
    log("Ubuntu/XFCE noVNC desktop is listening on port 6080")


def launch_vm(vm: Vm) -> None:
    base = first_disk(vm.base_dir)
    vm_dir = VM_ROOT / vm.name
    runtime_dir = RUNTIME_ROOT / vm.name
    vm_dir.mkdir(parents=True, exist_ok=True)
    runtime_dir.mkdir(parents=True, exist_ok=True)
    overlay = vm_dir / "disk.qcow2"
    if not overlay.exists():
        run(
            [
                "qemu-img",
                "create",
                "-f",
                "qcow2",
                "-F",
                "vmdk" if base.suffix == ".vmdk" else "vdi",
                "-b",
                str(base),
                str(overlay),
            ]
        )
    pid_file = runtime_dir / "qemu.pid"
    qemu_log = LOG_ROOT / f"qemu-{vm.name}.log"
    command = [
        "qemu-system-x86_64",
        "-name",
        f"goad-{vm.name}",
        "-machine",
        "pc-i440fx-8.2",
        "-accel",
        "tcg,thread=multi,tb-size=256",
        "-cpu",
        "max",
        "-smp",
        "2,sockets=1,cores=2,threads=1",
        "-m",
        str(vm.memory_mb),
        "-rtc",
        "base=localtime,clock=host",
        "-boot",
        "order=c",
        "-drive",
        f"file={overlay},if=ide,format=qcow2,cache=writeback",
        "-netdev",
        f"user,id=nat0,hostfwd=tcp:127.0.0.1:{vm.nat_winrm_port}-:5985,"
        f"hostfwd=tcp:127.0.0.1:{vm.nat_rdp_port}-:3389",
        "-device",
        f"e1000,netdev=nat0,mac=08:00:27:7a:{vm.ip_last:02x}:01",
        "-netdev",
        f"tap,id=lab0,ifname={vm.tap},script=no,downscript=no",
        "-device",
        f"e1000,netdev=lab0,mac={vm.lab_mac}",
        "-display",
        "none",
        "-vnc",
        f"127.0.0.1:{vm.vnc_display}",
        "-monitor",
        f"unix:{runtime_dir / 'monitor.sock'},server=on,wait=off",
        "-serial",
        f"file:{LOG_ROOT / f'serial-{vm.name}.log'}",
        "-pidfile",
        str(pid_file),
        "-D",
        str(qemu_log),
        "-daemonize",
    ]
    run(command)
    log(f"Started {vm.name} ({vm.lab_ip}, QEMU pid {pid_file.read_text().strip()})")


def raw_session(vm: Vm, username: str, password: str, *, short: bool = False) -> winrm.Session:
    return winrm.Session(
        f"http://127.0.0.1:{vm.nat_winrm_port}/wsman",
        auth=(username, password),
        transport="ntlm",
        read_timeout_sec=50 if short else 180,
        operation_timeout_sec=40 if short else 150,
    )


def domain_credential(vm: Vm) -> tuple[str, str]:
    config = json.loads((GOAD_ROOT / "ad/GOAD/data/config.json").read_text(encoding="utf-8"))
    domain = config["lab"]["hosts"][vm.name]["domain"]
    password = config["lab"]["domains"][domain]["domain_password"]
    return f"administrator@{domain}", password


def local_credential(vm: Vm) -> tuple[str, str]:
    config = json.loads((GOAD_ROOT / "ad/GOAD/data/config.json").read_text(encoding="utf-8"))
    host = config["lab"]["hosts"][vm.name]
    return f"{host['hostname']}\\administrator", host["local_admin_password"]


def credential_candidates(vm: Vm) -> list[tuple[str, str]]:
    candidates = []
    if vm.name in AUTH_CACHE:
        candidates.append(AUTH_CACHE[vm.name])
    candidates.extend([("vagrant", "vagrant"), local_credential(vm), domain_credential(vm)])
    return list(dict.fromkeys(candidates))


def remember_auth(vm: Vm, credential: tuple[str, str]) -> None:
    previous = AUTH_CACHE.get(vm.name)
    AUTH_CACHE[vm.name] = credential
    if previous != credential:
        log(f"WinRM authentication selected for {vm.name}: {credential[0]}")


def winrm_run_cmd(
    vm: Vm,
    executable: str,
    arguments: list[str],
    *,
    short: bool = False,
) -> winrm.Response:
    errors: list[str] = []
    for credential in credential_candidates(vm):
        try:
            result = raw_session(vm, *credential, short=short).run_cmd(executable, arguments)
            remember_auth(vm, credential)
            return result
        except Exception as exc:
            errors.append(f"{credential[0]}: {type(exc).__name__}: {exc}")
    raise RuntimeError(f"WinRM command failed on {vm.name}: {'; '.join(errors)}")


def winrm_run_ps(vm: Vm, script: str, *, short: bool = False) -> winrm.Response:
    errors: list[str] = []
    for credential in credential_candidates(vm):
        try:
            result = raw_session(vm, *credential, short=short).run_ps(script)
            remember_auth(vm, credential)
            return result
        except Exception as exc:
            errors.append(f"{credential[0]}: {type(exc).__name__}: {exc}")
    raise RuntimeError(f"WinRM PowerShell failed on {vm.name}: {'; '.join(errors)}")


def vm_named(name: str) -> Vm:
    return next(vm for vm in VMS if vm.name == name)


def ps_literal(value: str) -> str:
    return "'" + value.replace("'", "''") + "'"


def child_domain_ready() -> bool:
    """Confirm that DC02 rebooted into the NORTH domain with NTDS online."""
    script = r"""
$ErrorActionPreference = 'Stop'
$computer = Get-CimInstance Win32_ComputerSystem
$ntds = Get-Service NTDS -ErrorAction SilentlyContinue
if ($computer.Domain -eq 'north.sevenkingdoms.local' -and $ntds.Status -eq 'Running') {
  Write-Output 'GOAD_CHILD_READY'
}
"""
    try:
        result = winrm_run_ps(vm_named("dc02"), script, short=True)
        return result.status_code == 0 and b"GOAD_CHILD_READY" in result.std_out
    except Exception:
        return False


def ensure_child_domain_bootstrap() -> None:
    """Promote DC02 outside WinRM, then let GOAD finish idempotently.

    Install-ADDSDomain can deadlock its own WSMan transport while the machine
    identity is changing. Running the cmdlet as a local SYSTEM scheduled task
    avoids coupling domain promotion to the remoting session that launched it.
    """
    markers = STATE_ROOT / "playbooks"
    if not (markers / "03-ad-parent_domain.yml.done").exists():
        return
    if (markers / "04-ad-child_domain.yml.done").exists() or child_domain_ready():
        return

    config = json.loads((GOAD_ROOT / "ad/GOAD/data/config.json").read_text(encoding="utf-8"))
    domains = config["lab"]["domains"]
    parent_name = "sevenkingdoms.local"
    child_name = "north.sevenkingdoms.local"
    parent = domains[parent_name]
    child = domains[child_name]
    child_label = child_name.split(".", 1)[0]
    source_dc = f"{config['lab']['hosts'][parent['dc']]['hostname']}.{parent_name}"

    bootstrap = f"""
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$log = 'C:\\goad-child-bootstrap.log'
try {{
  ('START ' + (Get-Date -Format o)) | Set-Content -Path $log -Force -Encoding UTF8
  $parametersPath = 'HKLM:\\SYSTEM\\CurrentControlSet\\Services\\NTDS\\Parameters'
  Remove-ItemProperty -Path $parametersPath -Name 'DSA Database Epoch' -Force -ErrorAction SilentlyContinue
  $labAdapter = Get-NetAdapter | Where-Object {{ $_.MacAddress -eq '52-54-00-56-00-0B' }} | Select-Object -First 1
  $natAdapter = Get-NetAdapter | Where-Object {{ $_.MacAddress -eq '08-00-27-7A-0B-01' }} | Select-Object -First 1
  if (-not $labAdapter) {{ throw 'DC02 lab adapter was not found' }}
  Set-DnsClientServerAddress -InterfaceIndex $labAdapter.ifIndex -ServerAddresses '192.168.56.10'
  if ($natAdapter) {{
    Set-DnsClient -InterfaceIndex $natAdapter.ifIndex -RegisterThisConnectionsAddress $false
    Disable-NetAdapter -InputObject $natAdapter -Confirm:$false
  }}
  Clear-DnsClientCache
  $locator = nltest.exe /dsgetdc:sevenkingdoms.local /force 2>&1
  $locator | Add-Content -Path $log -Encoding UTF8
  if ($LASTEXITCODE -ne 0) {{ throw "Parent domain locator failed: $locator" }}
  Import-Module ADDSDeployment -ErrorAction Stop
  $parentPassword = ConvertTo-SecureString {ps_literal(parent['domain_password'])} -AsPlainText -Force
  $credential = New-Object System.Management.Automation.PSCredential ({ps_literal('administrator@' + parent_name)}, $parentPassword)
  $safePassword = ConvertTo-SecureString {ps_literal(child['domain_password'])} -AsPlainText -Force
  Install-ADDSDomain -Credential $credential -SkipPreChecks `
    -NewDomainName {ps_literal(child_label)} `
    -NewDomainNetbiosName {ps_literal(child['netbios_name'])} `
    -ParentDomainName {ps_literal(parent_name)} `
    -ReplicationSourceDC {ps_literal(source_dc)} `
    -DatabasePath 'C:\\Windows\\NTDS' -SYSVOLPath 'C:\\Windows\\SYSVOL' `
    -LogPath 'C:\\Windows\\Logs' -SafeModeAdministratorPassword $safePassword `
    -Force -NoRebootOnCompletion *>&1 | Out-File -FilePath $log -Append -Encoding UTF8
  ('SUCCESS ' + (Get-Date -Format o)) | Add-Content -Path $log -Encoding UTF8
  shutdown.exe /r /t 5 /f /d p:2:4
}} catch {{
  ('ERROR ' + $_.Exception.ToString()) | Add-Content -Path $log -Encoding UTF8
}} finally {{
  if ($natAdapter) {{ Enable-NetAdapter -InputObject $natAdapter -Confirm:$false -ErrorAction SilentlyContinue }}
  Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
}}
"""
    compressed = base64.b64encode(gzip.compress(bootstrap.encode("utf-8"), compresslevel=9)).decode("ascii")
    task_argument = ps_literal(
        '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "C:\\goad-child-bootstrap.ps1"'
    )
    launcher = f"""
$ErrorActionPreference = 'Stop'
Unregister-ScheduledTask -TaskName 'GOAD-Child-Bootstrap' -Confirm:$false -ErrorAction SilentlyContinue
Remove-Item 'C:\\goad-child-bootstrap.log' -Force -ErrorAction SilentlyContinue
Remove-Item 'C:\\goad-child-bootstrap.ps1' -Force -ErrorAction SilentlyContinue
$compressed = [Convert]::FromBase64String({ps_literal(compressed)})
$inputStream = New-Object System.IO.MemoryStream(,$compressed)
$gzipStream = New-Object System.IO.Compression.GzipStream($inputStream, [System.IO.Compression.CompressionMode]::Decompress)
$outputStream = New-Object System.IO.MemoryStream
$gzipStream.CopyTo($outputStream)
[System.IO.File]::WriteAllBytes('C:\\goad-child-bootstrap.ps1', $outputStream.ToArray())
$gzipStream.Dispose()
$inputStream.Dispose()
$outputStream.Dispose()
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument {task_argument}
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddHours(1)
Register-ScheduledTask -TaskName 'GOAD-Child-Bootstrap' -Action $action -Trigger $trigger -User 'SYSTEM' -RunLevel Highest -Force | Out-Null
Start-ScheduledTask -TaskName 'GOAD-Child-Bootstrap'
Write-Output 'GOAD_CHILD_BOOTSTRAP_STARTED'
"""
    result = winrm_run_ps(vm_named("dc02"), launcher, short=True)
    if result.status_code != 0 or b"GOAD_CHILD_BOOTSTRAP_STARTED" not in result.std_out:
        detail = (result.std_err or result.std_out).decode("utf-8", "replace").strip()
        raise RuntimeError(f"Could not launch the local DC02 child-domain bootstrap: {detail[-2000:]}")
    log("Started DC02 child-domain promotion as a local SYSTEM task")

    deadline = time.monotonic() + 45 * 60
    last_log = ""
    while time.monotonic() < deadline and not STOP_REQUESTED:
        if child_domain_ready():
            log("DC02 child domain is promoted, rebooted, and NTDS is running")
            return
        failure = ""
        try:
            probe = winrm_run_ps(
                vm_named("dc02"),
                "if (Test-Path 'C:\\goad-child-bootstrap.log') { Get-Content 'C:\\goad-child-bootstrap.log' -Tail 20 }",
                short=True,
            )
            if probe.status_code == 0:
                current = probe.std_out.decode("utf-8", "replace").strip()
                if current and current != last_log:
                    last_log = current
                    if "ERROR " in current:
                        failure = current[-2000:]
        except Exception:
            # WinRM is expected to disappear while DC02 changes identity/reboots.
            pass
        if failure:
            raise RuntimeError(f"DC02 child-domain bootstrap failed: {failure}")
        time.sleep(20)
    raise RuntimeError("Timed out waiting for the DC02 child-domain bootstrap")


def wait_for_winrm(vm: Vm, timeout: int = 60 * 45) -> None:
    deadline = time.monotonic() + timeout
    attempt = 0
    last_error: Exception | None = None
    while time.monotonic() < deadline and not STOP_REQUESTED:
        attempt += 1
        try:
            result = winrm_run_cmd(vm, "cmd.exe", ["/c", "echo", "ready"])
            if result.status_code == 0:
                log(f"WinRM ready on {vm.name} after {attempt} attempts")
                return
            last_error = RuntimeError(f"status {result.status_code}")
        except Exception as exc:
            last_error = exc
        time.sleep(15)
    raise RuntimeError(f"WinRM timeout on {vm.name}: {last_error}")


def configure_lab_nic(vm: Vm) -> None:
    mac = vm.lab_mac.replace(":", "-").upper()
    script = rf"""
$ErrorActionPreference = 'Stop'
$adapter = Get-NetAdapter | Where-Object {{ $_.MacAddress -eq '{mac}' }} | Select-Object -First 1
if (-not $adapter) {{ throw 'GOAD E1000 adapter not found ({mac})' }}
Set-NetIPInterface -InterfaceIndex $adapter.ifIndex -Dhcp Disabled -ErrorAction SilentlyContinue
$current = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue
$wanted = $current | Where-Object {{ $_.IPAddress -eq '{vm.lab_ip}' }}
if (-not $wanted) {{
  $current | Where-Object {{ $_.PrefixOrigin -ne 'WellKnown' }} | Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue
  New-NetIPAddress -InterfaceIndex $adapter.ifIndex -IPAddress '{vm.lab_ip}' -PrefixLength 24 | Out-Null
}}
Set-NetConnectionProfile -InterfaceIndex $adapter.ifIndex -NetworkCategory Private -ErrorAction SilentlyContinue
Get-NetFirewallRule -Name 'WINRM-HTTP-In-TCP*' -ErrorAction SilentlyContinue | Set-NetFirewallRule -Enabled True -RemoteAddress Any
Write-Output ('CONFIGURED {vm.name} {vm.lab_ip} ' + $adapter.MacAddress)
"""
    last_detail = ""
    for attempt in range(1, 7):
        wait_for_winrm(vm, timeout=15 * 60)
        try:
            result = winrm_run_ps(vm, script)
            output = result.std_out.decode("utf-8", "replace").strip()
            error = result.std_err.decode("utf-8", "replace").strip()
            if result.status_code == 0 and f"CONFIGURED {vm.name}" in output:
                log(output)
                return
            last_detail = (error or output or f"status {result.status_code}")[-2000:]
        except Exception as exc:
            last_detail = f"{type(exc).__name__}: {exc}"[-2000:]
        log(
            f"Network configuration attempt {attempt}/6 failed on {vm.name}; "
            "waiting for post-boot services before retry"
        )
        time.sleep(30)
    raise RuntimeError(f"Could not configure the GOAD NIC on {vm.name}: {last_detail}")


def verify_ansible() -> None:
    refresh_auth_inventory()
    command = ansible_command("all", module="ansible.windows.win_ping")
    for attempt in range(1, 21):
        completed = run(command, check=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
        if completed.returncode == 0:
            log("Ansible WinRM connectivity passed for all five machines")
            return
        log(f"Ansible connectivity attempt {attempt} failed; retrying")
        time.sleep(20)
    raise RuntimeError(f"Ansible could not reach all machines:\n{completed.stdout[-8000:]}")


def ansible_command(target: str, *, module: str | None = None) -> list[str]:
    inventories = [
        str(GOAD_ROOT / "ad/GOAD/data/inventory"),
        "/opt/goad-modal/inventory",
        str(RUNTIME_ROOT / "inventory-auth.yml"),
        str(GOAD_ROOT / "globalsettings.ini"),
    ]
    command = [str(GOAD_ROOT / ".venv/bin/ansible" if module else GOAD_ROOT / ".venv/bin/ansible-playbook")]
    for inventory in inventories:
        command.extend(["-i", inventory])
    if module:
        command.extend([target, "-m", module])
    else:
        command.append(str(GOAD_ROOT / "ansible" / target))
    return command


def refresh_auth_inventory() -> None:
    """Select working credentials and expose them to Ansible as host vars."""
    hosts: dict[str, dict[str, str]] = {}
    for vm in VMS:
        result = winrm_run_cmd(vm, "cmd.exe", ["/c", "echo", "GOAD_AUTH_READY"], short=True)
        if result.status_code != 0 or b"GOAD_AUTH_READY" not in result.std_out:
            raise RuntimeError(f"Could not validate WinRM credentials for {vm.name}")
        username, password = AUTH_CACHE[vm.name]
        hosts[vm.name] = {"ansible_user": username, "ansible_password": password}
    path = RUNTIME_ROOT / "inventory-auth.yml"
    path.write_text(json.dumps({"all": {"hosts": hosts}}, indent=2) + "\n", encoding="utf-8")
    os.chmod(path, 0o600)
    log("Refreshed per-machine Ansible authentication")


def provision() -> None:
    marker_root = STATE_ROOT / "playbooks"
    marker_root.mkdir(parents=True, exist_ok=True)
    env = os.environ.copy()
    env.update(
        {
            "ANSIBLE_CONFIG": str(GOAD_ROOT / "ansible/ansible.cfg"),
            "ANSIBLE_COLLECTIONS_PATH": "/root/.ansible/collections:/usr/share/ansible/collections",
            "ANSIBLE_ROLES_PATH": f"/root/.ansible/roles:{GOAD_ROOT}/ansible/roles",
            "ANSIBLE_HOST_KEY_CHECKING": "False",
        }
    )
    for index, playbook in enumerate(PLAYBOOKS, start=1):
        marker = marker_root / f"{index:02d}-{playbook}.done"
        if marker.exists():
            log(f"Skipping completed playbook {playbook}")
            continue
        refresh_auth_inventory()
        write_status("provisioning", playbook=playbook, playbook_index=index, playbook_total=len(PLAYBOOKS))
        log(f"Running GOAD playbook {index}/{len(PLAYBOOKS)}: {playbook}")
        for attempt in range(1, MAX_PLAYBOOK_ATTEMPTS + 1):
            with (LOG_ROOT / f"ansible-{index:02d}-{playbook}.log").open("a", encoding="utf-8") as output:
                completed = subprocess.run(
                    ansible_command(playbook),
                    cwd=GOAD_ROOT / "ansible",
                    env=env,
                    stdout=output,
                    stderr=subprocess.STDOUT,
                    text=True,
                    check=False,
                )
            if completed.returncode == 0:
                break
            if attempt == MAX_PLAYBOOK_ATTEMPTS:
                raise RuntimeError(f"GOAD playbook failed: {playbook} (exit {completed.returncode})")
            log(f"GOAD playbook {playbook} attempt {attempt} failed; waiting for guests before retry")
            time.sleep(30)
            with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool:
                list(pool.map(wait_for_winrm, VMS))
            refresh_auth_inventory()
            log(
                f"Retrying GOAD playbook {playbook} "
                f"(attempt {attempt + 1}/{MAX_PLAYBOOK_ATTEMPTS})"
            )
        marker.write_text(time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + "\n", encoding="utf-8")
        log(f"Completed GOAD playbook: {playbook}")
    (STATE_ROOT / "ready").write_text(time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + "\n")
    write_status("ready", provisioned=True)
    log("Full GOAD provisioning is complete")


def qemu_alive(vm: Vm) -> bool:
    pid_file = RUNTIME_ROOT / vm.name / "qemu.pid"
    if not pid_file.exists():
        return False
    try:
        pid = int(pid_file.read_text().strip())
        os.kill(pid, 0)
        # A terminated daemonized QEMU can remain briefly as a zombie.  Signal
        # probing still succeeds for zombies, so treat the kernel state as
        # stopped instead of spending the full shutdown timeout waiting for it.
        stat_path = Path(f"/proc/{pid}/stat")
        if stat_path.exists() and stat_path.read_text(encoding="utf-8").split()[2] == "Z":
            return False
        return True
    except (IndexError, OSError, ValueError):
        return False


def graceful_shutdown() -> None:
    log("Stopping Windows guests")
    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool:
        futures = []
        for vm in VMS:
            if qemu_alive(vm):
                futures.append(pool.submit(winrm_run_ps, vm, "Stop-Computer -Force"))
        concurrent.futures.wait(futures, timeout=180)
    deadline = time.monotonic() + 180
    while time.monotonic() < deadline and any(qemu_alive(vm) for vm in VMS):
        time.sleep(5)
    for vm in VMS:
        if qemu_alive(vm):
            pid = int((RUNTIME_ROOT / vm.name / "qemu.pid").read_text().strip())
            os.kill(pid, signal.SIGTERM)
    subprocess.run(["sync"], check=False)
    write_status("stopped", provisioned=(STATE_ROOT / "ready").exists())
    log("GOAD host stopped cleanly")


def handle_stop(_signum: int, _frame: object) -> None:
    global STOP_REQUESTED
    STOP_REQUESTED = True


def initialize_session_runtime() -> None:
    started_at = int(time.time())
    requested_seconds = int(os.environ.get("GOAD_MAX_SESSION_SECONDS", str(6 * 60 * 60)))
    maximum_seconds = int(os.environ.get("GOAD_MAX_SANDBOX_SECONDS", str(MAX_SANDBOX_SECONDS)))
    payload = {
        "started_at": started_at,
        "expires_at": started_at + min(requested_seconds, maximum_seconds),
        "max_expires_at": started_at + maximum_seconds,
    }
    SESSION_RUNTIME_PATH.write_text(json.dumps(payload) + "\n", encoding="utf-8")


def session_expired() -> bool:
    try:
        payload = json.loads(SESSION_RUNTIME_PATH.read_text(encoding="utf-8"))
        return int(time.time()) >= int(payload["expires_at"])
    except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as exc:
        raise RuntimeError(f"Session timer state is invalid: {exc}") from exc


def main() -> int:
    global STOP_REQUESTED
    signal.signal(signal.SIGTERM, handle_stop)
    signal.signal(signal.SIGINT, handle_stop)
    STATE_ROOT.mkdir(parents=True, exist_ok=True)
    VM_ROOT.mkdir(parents=True, exist_ok=True)
    LOG_ROOT.mkdir(parents=True, exist_ok=True)
    RUNTIME_ROOT.mkdir(parents=True, exist_ok=True)
    write_status("starting", provisioned=(STATE_ROOT / "ready").exists())
    try:
        patch_goad_ssms_22_role()
        patch_goad_tcg_roles()
        setup_bridge()
        initialize_session_runtime()
        setup_vpn()
        setup_desktop()
        for vm in VMS:
            launch_vm(vm)
        write_status("booting", provisioned=(STATE_ROOT / "ready").exists())
        with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool:
            list(pool.map(configure_lab_nic, VMS))
        write_status("network-ready", provisioned=(STATE_ROOT / "ready").exists())
        verify_ansible()
        if not (STATE_ROOT / "ready").exists():
            ensure_child_domain_bootstrap()
            provision()
        else:
            write_status("ready", provisioned=True)
            log("GOAD was already provisioned; all guests are online")

        while not STOP_REQUESTED and not session_expired():
            if (RUNTIME_ROOT / "stop").exists():
                STOP_REQUESTED = True
                break
            failed = [vm.name for vm in VMS if not qemu_alive(vm)]
            if failed:
                raise RuntimeError(f"QEMU guests exited unexpectedly: {', '.join(failed)}")
            time.sleep(10)
        return 0
    except Exception as exc:
        log(f"FATAL: {type(exc).__name__}: {exc}")
        write_status("error", error=f"{type(exc).__name__}: {exc}")
        return 1
    finally:
        graceful_shutdown()


if __name__ == "__main__":
    sys.exit(main())
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>portal_api.py</code> — <code>30d67ad1077e07dd6853737977c9464ee9b47fe206bbdbadfe3e567065d28b76</code></summary>

<!-- BUNDLE-FILE path="portal_api.py" sha256="30d67ad1077e07dd6853737977c9464ee9b47fe206bbdbadfe3e567065d28b76" -->
``````python
"""Authenticated web portal for the existing Modal GOAD controller.

The portal owns only user/session metadata. It invokes the already-deployed
``goad-on-modal`` start/status/stop functions in the ``goad-lab`` environment.
"""

from __future__ import annotations

import hashlib
import hmac
import os
import re
import secrets
import sqlite3
import threading
import time
from collections import defaultdict, deque
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterator

import modal
from fastapi import Depends, FastAPI, HTTPException, Request, Response, status
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from starlette.concurrency import run_in_threadpool


APP_NAME = "goad-on-modal"
ENVIRONMENT = "goad-lab"
COOKIE_NAME = "goad_portal_session"
SESSION_SECONDS = 7 * 24 * 60 * 60
START_GRACE_SECONDS = 10 * 60
DEFAULT_SESSION_SECONDS = 6 * 60 * 60
SESSION_EXTENSION_SECONDS = 60 * 60
MAX_SESSION_SECONDS = 23 * 60 * 60
EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
DB_PATH = Path(os.getenv("PORTAL_DB_PATH", Path(__file__).parent / ".portal-data" / "portal.db"))
STATIC_DIR = Path(os.getenv("PORTAL_STATIC_DIR", Path(__file__).parent / "goad-portal" / "dist" / "client"))

app = FastAPI(title="Transilience AI LABS", docs_url=None, redoc_url=None)
_db_lock = threading.RLock()
_rate_lock = threading.Lock()
_attempts: dict[str, deque[float]] = defaultdict(deque)
_commit_hook = None
_UNSET = object()


class SignupBody(BaseModel):
    name: str = Field(min_length=2, max_length=48)
    email: str = Field(min_length=5, max_length=254)
    password: str = Field(min_length=10, max_length=256)


class LoginBody(BaseModel):
    email: str = Field(min_length=5, max_length=254)
    password: str = Field(min_length=1, max_length=256)


class StartBody(BaseModel):
    session_hours: float = Field(default=6, ge=0.5, le=23)


def _now() -> int:
    return int(time.time())


def _normal_email(value: str) -> str:
    return value.strip().lower()


def _password_hash(password: str, salt: bytes) -> bytes:
    return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 310_000, dklen=32)


def _token_hash(token: str) -> str:
    return hashlib.sha256(token.encode("utf-8")).hexdigest()


@contextmanager
def _db(write: bool = False) -> Iterator[sqlite3.Connection]:
    DB_PATH.parent.mkdir(parents=True, exist_ok=True)
    with _db_lock:
        connection = sqlite3.connect(DB_PATH, timeout=30)
        connection.row_factory = sqlite3.Row
        connection.execute("PRAGMA foreign_keys=ON")
        committed = False
        try:
            if write:
                connection.execute("BEGIN IMMEDIATE")
            yield connection
            if write:
                connection.commit()
                committed = True
        except Exception:
            if write:
                connection.rollback()
            raise
        finally:
            connection.close()
            if committed and _commit_hook is not None:
                _commit_hook()


def configure_persistence(commit_hook) -> None:
    """Attach Modal Volume persistence after the ASGI container starts."""
    global _commit_hook
    _commit_hook = commit_hook


def _init_db() -> None:
    with _db(write=True) as connection:
        connection.executescript(
            """
            CREATE TABLE IF NOT EXISTS users (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL,
                email TEXT NOT NULL UNIQUE,
                salt BLOB NOT NULL,
                password_hash BLOB NOT NULL,
                created_at INTEGER NOT NULL
            );
            CREATE TABLE IF NOT EXISTS sessions (
                token_hash TEXT PRIMARY KEY,
                user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
                expires_at INTEGER NOT NULL,
                created_at INTEGER NOT NULL
            );
            CREATE TABLE IF NOT EXISTS lease (
                id INTEGER PRIMARY KEY CHECK (id = 1),
                user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
                state TEXT NOT NULL,
                updated_at INTEGER NOT NULL,
                session_expires_at INTEGER
            );
            INSERT OR IGNORE INTO lease (id, user_id, state, updated_at)
            VALUES (1, NULL, 'stopped', 0);
            """
        )
        lease_columns = {row["name"] for row in connection.execute("PRAGMA table_info(lease)")}
        if "session_expires_at" not in lease_columns:
            connection.execute("ALTER TABLE lease ADD COLUMN session_expires_at INTEGER")


_init_db()


def _user_json(row: sqlite3.Row) -> dict[str, Any]:
    return {"id": row["id"], "name": row["name"], "email": row["email"]}


def _client_key(request: Request) -> str:
    forwarded = request.headers.get("x-forwarded-for", "")
    return (forwarded.split(",", 1)[0].strip() or (request.client.host if request.client else "unknown"))


def _check_rate_limit(request: Request) -> None:
    key = _client_key(request)
    cutoff = time.time() - 300
    with _rate_lock:
        queue = _attempts[key]
        while queue and queue[0] < cutoff:
            queue.popleft()
        if len(queue) >= 15:
            raise HTTPException(status_code=429, detail="Too many attempts. Try again in a few minutes.")
        queue.append(time.time())


def _secure_cookie(request: Request) -> bool:
    return request.headers.get("x-forwarded-proto", request.url.scheme).split(",", 1)[0] == "https"


def _set_session_cookie(response: Response, request: Request, token: str) -> None:
    response.set_cookie(
        COOKIE_NAME,
        token,
        max_age=SESSION_SECONDS,
        httponly=True,
        secure=_secure_cookie(request),
        samesite="lax",
        path="/",
    )


def _new_session(connection: sqlite3.Connection, user_id: int) -> str:
    token = secrets.token_urlsafe(36)
    connection.execute(
        "INSERT INTO sessions (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)",
        (_token_hash(token), user_id, _now() + SESSION_SECONDS, _now()),
    )
    return token


def current_user(request: Request) -> sqlite3.Row:
    token = request.cookies.get(COOKIE_NAME)
    if not token:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Sign in required")
    with _db() as connection:
        row = connection.execute(
            """
            SELECT users.id, users.name, users.email
            FROM sessions JOIN users ON users.id = sessions.user_id
            WHERE sessions.token_hash = ? AND sessions.expires_at > ?
            """,
            (_token_hash(token), _now()),
        ).fetchone()
    if row is None:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired")
    return row


def _lease() -> sqlite3.Row:
    with _db() as connection:
        return connection.execute(
            """
            SELECT lease.user_id, lease.state, lease.updated_at, lease.session_expires_at,
                   users.name AS owner_name
            FROM lease LEFT JOIN users ON users.id = lease.user_id WHERE lease.id = 1
            """
        ).fetchone()


def _set_lease(
    user_id: int | None,
    state_value: str,
    session_expires_at: int | None | object = _UNSET,
) -> None:
    with _db(write=True) as connection:
        if session_expires_at is _UNSET:
            connection.execute(
                "UPDATE lease SET user_id = ?, state = ?, updated_at = ? WHERE id = 1",
                (user_id, state_value, _now()),
            )
        else:
            connection.execute(
                """
                UPDATE lease
                SET user_id = ?, state = ?, updated_at = ?, session_expires_at = ?
                WHERE id = 1
                """,
                (user_id, state_value, _now(), session_expires_at),
            )


def _invoke_controller(action: str, *args: Any) -> dict[str, Any]:
    function = modal.Function.from_name(APP_NAME, action, environment_name=ENVIRONMENT)
    return function.remote(*args)


def _spawn_controller(action: str, *args: Any) -> str:
    function = modal.Function.from_name(APP_NAME, action, environment_name=ENVIRONMENT)
    call = function.spawn(*args)
    return call.object_id


def _present_status(controller: dict[str, Any], user_id: int) -> dict[str, Any]:
    lease = _lease()
    running = bool(controller.get("running"))
    lease_state = lease["state"]
    lease_age = max(0, _now() - lease["updated_at"])

    if running:
        visible_state = lease_state if lease_state in {"stopping", "resetting"} else "running"
    elif lease_state == "starting" and lease_age < START_GRACE_SECONDS:
        visible_state = "starting"
    else:
        if lease["user_id"] is not None or lease_state != "stopped":
            _set_lease(None, "stopped", None)
            lease = _lease()
        visible_state = "stopped"

    if lease["user_id"] == user_id:
        ownership = "mine"
    elif lease["user_id"] is not None:
        ownership = "other"
    elif running:
        ownership = "external"
    else:
        ownership = "none"

    durable = controller.get("status") or {}
    session_expires_at = controller.get("session_expires_at") or lease["session_expires_at"]
    session_max_expires_at = controller.get("session_max_expires_at")
    if running and session_expires_at is None and lease["user_id"] is not None:
        session_expires_at = lease["updated_at"] + DEFAULT_SESSION_SECONDS
    remaining_seconds = max(0, int(session_expires_at) - _now()) if session_expires_at else None
    can_extend = (
        running
        and ownership == "mine"
        and bool(controller.get("session_extend_available"))
        and (session_max_expires_at is None or int(session_expires_at) < int(session_max_expires_at))
    )
    return {
        "state": visible_state,
        "running": running,
        "ownership": ownership,
        "owner_name": lease["owner_name"] if ownership == "other" else None,
        "desktop_url": controller.get("desktop_url"),
        "desktop_password": (
            os.getenv("GOAD_VNC_PASSWORD") if running and ownership == "mine" else None
        ),
        "vpn_available": bool(controller.get("vpn_available")) and running and ownership == "mine",
        "session_expires_at": session_expires_at,
        "session_remaining_seconds": remaining_seconds,
        "session_extend_available": can_extend,
        "session_extension_seconds": SESSION_EXTENSION_SECONDS,
        "target_ip": "192.168.56.10",
        "network": durable.get("network", "192.168.56.0/24"),
        "provisioned": bool(durable.get("provisioned", False)),
        "phase": durable.get("phase", "unknown"),
        "hosts": durable.get("hosts", {}),
        "max_session_hours": MAX_SESSION_SECONDS // 3600,
    }


@app.middleware("http")
async def security_headers(request: Request, call_next):
    response = await call_next(request)
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["X-Frame-Options"] = "DENY"
    response.headers["Referrer-Policy"] = "same-origin"
    response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
    response.headers["Content-Security-Policy"] = (
        "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
        "font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
    )
    return response


@app.get("/api/health")
def health() -> dict[str, str]:
    return {"status": "ok", "app": APP_NAME, "environment": ENVIRONMENT}


@app.post("/api/auth/signup", status_code=201)
def signup(body: SignupBody, request: Request, response: Response) -> dict[str, Any]:
    _check_rate_limit(request)
    email = _normal_email(body.email)
    name = body.name.strip()
    if not EMAIL_RE.match(email):
        raise HTTPException(status_code=422, detail="Enter a valid email address")
    if len(name) < 2:
        raise HTTPException(status_code=422, detail="Display name is too short")
    salt = secrets.token_bytes(16)
    password_hash = _password_hash(body.password, salt)
    try:
        with _db(write=True) as connection:
            cursor = connection.execute(
                "INSERT INTO users (name, email, salt, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
                (name, email, salt, password_hash, _now()),
            )
            token = _new_session(connection, cursor.lastrowid)
            user = connection.execute("SELECT id, name, email FROM users WHERE id = ?", (cursor.lastrowid,)).fetchone()
    except sqlite3.IntegrityError as exc:
        raise HTTPException(status_code=409, detail="An account with this email already exists") from exc
    _set_session_cookie(response, request, token)
    return {"user": _user_json(user)}


@app.post("/api/auth/login")
def login(body: LoginBody, request: Request, response: Response) -> dict[str, Any]:
    _check_rate_limit(request)
    email = _normal_email(body.email)
    with _db(write=True) as connection:
        user = connection.execute(
            "SELECT id, name, email, salt, password_hash FROM users WHERE email = ?", (email,)
        ).fetchone()
        valid = user is not None and hmac.compare_digest(_password_hash(body.password, user["salt"]), user["password_hash"])
        if not valid:
            raise HTTPException(status_code=401, detail="Incorrect email or password")
        token = _new_session(connection, user["id"])
    _set_session_cookie(response, request, token)
    return {"user": _user_json(user)}


@app.post("/api/auth/logout")
def logout(request: Request, response: Response) -> dict[str, bool]:
    token = request.cookies.get(COOKIE_NAME)
    if token:
        with _db(write=True) as connection:
            connection.execute("DELETE FROM sessions WHERE token_hash = ?", (_token_hash(token),))
    response.delete_cookie(COOKIE_NAME, path="/")
    return {"ok": True}


@app.get("/api/auth/me")
def me(user: sqlite3.Row = Depends(current_user)) -> dict[str, Any]:
    return {"user": _user_json(user)}


@app.get("/api/labs/goad/status")
async def lab_status(user: sqlite3.Row = Depends(current_user)) -> dict[str, Any]:
    try:
        controller = await run_in_threadpool(_invoke_controller, "status")
    except Exception as exc:
        raise HTTPException(status_code=503, detail="Unable to reach the GOAD controller") from exc
    return _present_status(controller, user["id"])


@app.get("/api/labs/goad/vpn-config")
async def download_vpn_config(user: sqlite3.Row = Depends(current_user)) -> Response:
    lease = _lease()
    if lease["user_id"] != user["id"]:
        raise HTTPException(status_code=403, detail="Only the current session owner can download the VPN profile")
    try:
        controller = await run_in_threadpool(_invoke_controller, "status")
    except Exception as exc:
        raise HTTPException(status_code=503, detail="Unable to reach the GOAD controller") from exc
    if not controller.get("running"):
        raise HTTPException(status_code=409, detail="Start the lab before downloading the VPN profile")
    if not controller.get("vpn_available"):
        raise HTTPException(status_code=409, detail="VPN access will be available after this lab session is restarted")
    try:
        payload = await run_in_threadpool(_invoke_controller, "vpn_config")
    except Exception as exc:
        raise HTTPException(status_code=502, detail="The VPN profile is not ready. Try again shortly.") from exc
    filename = re.sub(r"[^A-Za-z0-9._-]", "-", payload.get("filename", "transilience-goad.ovpn"))
    return Response(
        content=payload["profile"],
        media_type="application/x-openvpn-profile",
        headers={
            "Content-Disposition": f'attachment; filename="{filename}"',
            "Cache-Control": "no-store, private",
        },
    )


@app.post("/api/labs/goad/start")
async def start_lab(body: StartBody, user: sqlite3.Row = Depends(current_user)) -> dict[str, Any]:
    try:
        current = await run_in_threadpool(_invoke_controller, "status")
    except Exception as exc:
        raise HTTPException(status_code=503, detail="Unable to reach the GOAD controller") from exc

    with _db(write=True) as connection:
        lease = connection.execute("SELECT user_id, state FROM lease WHERE id = 1").fetchone()
        if lease["user_id"] not in (None, user["id"]):
            raise HTTPException(status_code=409, detail="The GOAD lab is currently in use by another operator")
        if current.get("running") and lease["user_id"] is None:
            raise HTTPException(status_code=409, detail="The GOAD lab is running outside the portal and cannot be claimed")
        connection.execute(
            """
            UPDATE lease
            SET user_id = ?, state = 'starting', updated_at = ?, session_expires_at = ?
            WHERE id = 1
            """,
            (user["id"], _now(), _now() + int(body.session_hours * 3600)),
        )

    try:
        controller = await run_in_threadpool(_invoke_controller, "start", body.session_hours)
        _set_lease(
            user["id"],
            "running",
            int(controller.get("session_expires_at") or (_now() + body.session_hours * 3600)),
        )
        return _present_status(controller, user["id"])
    except Exception as exc:
        _set_lease(None, "stopped", None)
        raise HTTPException(status_code=502, detail="GOAD did not start successfully. Please try again.") from exc


@app.post("/api/labs/goad/reset")
async def reset_lab(user: sqlite3.Row = Depends(current_user)) -> dict[str, Any]:
    lease = _lease()
    if lease["user_id"] != user["id"]:
        raise HTTPException(status_code=403, detail="Only the current session owner can reset the machines")
    if lease["state"] in {"starting", "stopping", "resetting"}:
        raise HTTPException(status_code=409, detail="Wait for the current lab operation to finish")
    _set_lease(user["id"], "resetting")
    try:
        controller = await run_in_threadpool(_invoke_controller, "reset")
        _set_lease(user["id"], "running")
        return _present_status(controller, user["id"])
    except Exception as exc:
        _set_lease(user["id"], "running")
        raise HTTPException(status_code=502, detail="The GOAD machines could not be reset. Please try again.") from exc


@app.post("/api/labs/goad/extend")
async def extend_lab(user: sqlite3.Row = Depends(current_user)) -> dict[str, Any]:
    lease = _lease()
    if lease["user_id"] != user["id"]:
        raise HTTPException(status_code=403, detail="Only the current session owner can extend the lab")
    if lease["state"] != "running":
        raise HTTPException(status_code=409, detail="Wait for the current lab operation to finish")
    try:
        current = await run_in_threadpool(_invoke_controller, "status")
    except Exception as exc:
        raise HTTPException(status_code=503, detail="Unable to reach the GOAD controller") from exc
    if not current.get("running"):
        raise HTTPException(status_code=409, detail="Start the lab before extending the session")
    if not current.get("session_extend_available"):
        raise HTTPException(status_code=409, detail="Session extension will be available after this lab is restarted")
    try:
        controller = await run_in_threadpool(_invoke_controller, "extend", SESSION_EXTENSION_SECONDS / 3600)
    except Exception as exc:
        raise HTTPException(status_code=502, detail="The GOAD session could not be extended. Please try again.") from exc
    if int(controller.get("extended_seconds", 0)) <= 0:
        raise HTTPException(status_code=409, detail="This session has reached its maximum duration")
    _set_lease(user["id"], "running", int(controller["session_expires_at"]))
    return _present_status(controller, user["id"])


@app.post("/api/labs/goad/stop")
async def stop_lab(user: sqlite3.Row = Depends(current_user)) -> dict[str, Any]:
    lease = _lease()
    if lease["user_id"] != user["id"]:
        raise HTTPException(status_code=403, detail="Only the operator who started this session can stop it")
    if lease["state"] == "starting":
        raise HTTPException(status_code=409, detail="Wait for startup to finish before stopping the lab")
    _set_lease(user["id"], "stopping")
    try:
        await run_in_threadpool(_spawn_controller, "stop")
        controller = await run_in_threadpool(_invoke_controller, "status")
        return _present_status(controller, user["id"])
    except Exception as exc:
        _set_lease(user["id"], "running")
        raise HTTPException(status_code=502, detail="The stop request could not be dispatched. Please try again.") from exc


if STATIC_DIR.exists():
    assets = STATIC_DIR / "assets"
    if assets.exists():
        app.mount("/assets", StaticFiles(directory=assets), name="assets")

    @app.get("/{path:path}", include_in_schema=False)
    def spa(path: str) -> FileResponse:
        candidate = (STATIC_DIR / path).resolve()
        if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents:
            return FileResponse(candidate)
        return FileResponse(STATIC_DIR / "index.html")
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>lab_client.py</code> — <code>548ca06266687f16cb14c4b617c065ea49574f485321d5c88220078e07408d47</code></summary>

<!-- BUNDLE-FILE path="lab_client.py" sha256="548ca06266687f16cb14c4b617c065ea49574f485321d5c88220078e07408d47" -->
``````python
"""Invoke the deployed Modal-only GOAD controller in `goad-lab`."""

from __future__ import annotations

import argparse
import json

import modal


APP_NAME = "goad-on-modal"
ENVIRONMENT = "goad-lab"


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("action", choices=("start", "status", "stop", "reset", "extend", "logs"))
    parser.add_argument("--session-hours", type=float, default=6.0)
    parser.add_argument("--lines", type=int, default=120)
    args = parser.parse_args()

    function = modal.Function.from_name(
        APP_NAME,
        args.action,
        environment_name=ENVIRONMENT,
    )
    if args.action == "start":
        result = function.remote(args.session_hours)
    elif args.action == "extend":
        result = function.remote(1.0)
    elif args.action == "logs":
        result = function.remote(args.lines)
    else:
        result = function.remote()
    print(json.dumps(result, indent=2))


if __name__ == "__main__":
    main()
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>qemu_probe.py</code> — <code>a8ef4021cbf09556c3d8961085b051ef8711371f47bfd3964fc372f435430b72</code></summary>

<!-- BUNDLE-FILE path="qemu_probe.py" sha256="a8ef4021cbf09556c3d8961085b051ef8711371f47bfd3964fc372f435430b72" -->
``````python
"""One-machine QEMU/TCG proof for the Modal-only GOAD host.

This is intentionally scoped to the Modal `goad-lab` environment and writes
only to the dedicated `goad-lab-state` Volume.
"""

from __future__ import annotations

import modal


ENVIRONMENT = "goad-lab"
APP_NAME = "goad-on-modal"
VOLUME_NAME = "goad-lab-state"
WINDOWS_2019_BOX = (
    "https://vagrantcloud.com/StefanScherer/boxes/windows_2019/versions/"
    "2021.05.15/providers/virtualbox/unknown/vagrant.box"
)


def main() -> None:
    app = modal.App.lookup(
        APP_NAME,
        environment_name=ENVIRONMENT,
        create_if_missing=True,
    )
    volume = modal.Volume.from_name(
        VOLUME_NAME,
        environment_name=ENVIRONMENT,
        create_if_missing=True,
    )
    image = (
        modal.Image.from_registry("ubuntu:24.04", add_python="3.11")
        .env({"DEBIAN_FRONTEND": "noninteractive"})
        .apt_install(
            "ca-certificates",
            "curl",
            "iproute2",
            "netcat-openbsd",
            "p7zip-full",
            "qemu-system-x86",
            "qemu-utils",
            "tar",
        )
    )
    script = f"""
set -euo pipefail
mkdir -p /lab/base/windows_2019_2021.05.15
cd /lab/base/windows_2019_2021.05.15
if [ ! -s windows_2019.box ]; then
  curl -fsSL --retry 8 --retry-all-errors --continue-at - \
    -o windows_2019.box {WINDOWS_2019_BOX!r}
fi
if [ ! -f .extracted ]; then
  tar -xf windows_2019.box
  touch .extracted
fi
echo BOX_BYTES=$(stat -c %s windows_2019.box)
find . -maxdepth 2 -type f -printf '%p %s bytes\n' | sort
disk=$(find . -maxdepth 2 -type f \\( -name '*.vmdk' -o -name '*.vdi' \\) | head -n1)
test -n "$disk"
qemu-img info "$disk"
sync
"""
    with modal.enable_output():
        sandbox = modal.Sandbox.create(
            "bash",
            "-lc",
            script,
            app=app,
            image=image,
            cpu=2,
            memory=4096,
            timeout=2 * 60 * 60,
            volumes={"/lab": volume},
            experimental_options={"vm_runtime": True},
        )
    try:
        for line in sandbox.stdout:
            print(line, end="")
        for line in sandbox.stderr:
            print(line, end="")
        sandbox.wait()
        returncode = sandbox.poll()
        print(f"sandbox_id={sandbox.object_id}")
        print(f"returncode={returncode}")
        if returncode != 0:
            raise SystemExit(returncode)
    finally:
        sandbox.terminate()
        sandbox.detach()


if __name__ == "__main__":
    main()
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>qemu_boot_probe.py</code> — <code>b0b29cbaec7524dff91678778783808bcf3e194683079a59220f7ee48e93d98d</code></summary>

<!-- BUNDLE-FILE path="qemu_boot_probe.py" sha256="b0b29cbaec7524dff91678778783808bcf3e194683079a59220f7ee48e93d98d" -->
``````python
"""Boot the imported GOAD Windows 2019 base image with QEMU TCG on Modal."""

from __future__ import annotations

import modal


ENVIRONMENT = "goad-lab"
APP_NAME = "goad-on-modal"
VOLUME_NAME = "goad-lab-state"


def main() -> None:
    app = modal.App.lookup(APP_NAME, environment_name=ENVIRONMENT)
    volume = modal.Volume.from_name(VOLUME_NAME, environment_name=ENVIRONMENT)
    image = (
        modal.Image.from_registry("ubuntu:24.04", add_python="3.11")
        .env({"DEBIAN_FRONTEND": "noninteractive"})
        .apt_install(
            "ca-certificates",
            "iproute2",
            "netcat-openbsd",
            "qemu-system-x86",
            "qemu-utils",
        )
        .pip_install("pywinrm")
    )

    script = r"""
set -euo pipefail
base=/lab/base/windows_2019_2021.05.15/WindowsServer2019-disk001.vmdk
vm_dir=/lab/proof/windows_2019
overlay=$vm_dir/disk.qcow2
qemu_log=$vm_dir/qemu.log
runtime_dir=/run/goad-windows-proof
pid_file=$runtime_dir/qemu.pid
mkdir -p "$vm_dir"
mkdir -p "$runtime_dir"
test -s "$base"

if [ ! -s "$overlay" ]; then
  qemu-img create -f qcow2 -F vmdk -b "$base" "$overlay"
fi

qemu-img info --backing-chain "$overlay"
truncate -s 0 "$qemu_log"

qemu-system-x86_64 \
  -name goad-windows-proof \
  -machine pc-i440fx-8.2 \
  -accel tcg,thread=multi,tb-size=1024 \
  -cpu max \
  -smp 2,sockets=1,cores=2,threads=1 \
  -m 4096 \
  -rtc base=localtime,clock=host \
  -boot order=c,menu=on \
  -drive file="$overlay",if=ide,format=qcow2,cache=writeback \
  -netdev user,id=nat0,hostfwd=tcp:127.0.0.1:55985-:5985,hostfwd=tcp:127.0.0.1:53389-:3389 \
  -device e1000,netdev=nat0,mac=08:00:27:7a:a2:fc \
  -display none \
  -vnc 127.0.0.1:1 \
  -monitor unix:"$runtime_dir/monitor.sock",server=on,wait=off \
  -serial file:"$vm_dir/serial.log" \
  -pidfile "$pid_file" \
  -D "$qemu_log" \
  -daemonize

echo "QEMU_PID=$(cat "$pid_file")"
echo "BOOT_STARTED=$(date -u +%FT%TZ)"

ready=0
for attempt in $(seq 1 240); do
  if nc -z 127.0.0.1 55985; then
    echo "WINRM_TCP_READY_ATTEMPT=$attempt"
    ready=1
    break
  fi
  if ! kill -0 "$(cat "$pid_file")" 2>/dev/null; then
    echo "QEMU_EXITED_BEFORE_WINRM"
    tail -n 200 "$qemu_log"
    exit 20
  fi
  if [ $((attempt % 4)) -eq 0 ]; then
    echo "BOOT_WAIT_SECONDS=$((attempt * 15))"
  fi
  sleep 15
done

if [ "$ready" -ne 1 ]; then
  echo "WINRM_TIMEOUT"
  tail -n 200 "$qemu_log"
  exit 21
fi

python - <<'PY'
import time
import winrm

endpoint = "http://127.0.0.1:55985/wsman"
last_error = None
for attempt in range(1, 41):
    try:
        session = winrm.Session(
            endpoint,
            auth=("vagrant", "vagrant"),
            transport="ntlm",
            read_timeout_sec=90,
            operation_timeout_sec=75,
        )
        result = session.run_ps(
            "$PSVersionTable.PSVersion.ToString(); "
            "whoami; hostname; "
            "Get-NetIPAddress -AddressFamily IPv4 | "
            "Select-Object InterfaceAlias,IPAddress | Format-Table -HideTableHeaders"
        )
        print(f"WINRM_STATUS={result.status_code}")
        print(result.std_out.decode("utf-8", "replace"))
        print(result.std_err.decode("utf-8", "replace"))
        if result.status_code == 0:
            raise SystemExit(0)
        last_error = RuntimeError(f"PowerShell status {result.status_code}")
    except Exception as exc:
        last_error = exc
        print(f"WINRM_RETRY={attempt} ERROR={type(exc).__name__}: {exc}", flush=True)
    time.sleep(15)
raise SystemExit(f"WinRM authentication failed after retries: {last_error}")
PY

echo "PROOF_COMPLETE=$(date -u +%FT%TZ)"
sync
sleep 20
"""

    with modal.enable_output():
        sandbox = modal.Sandbox.create(
            "bash",
            "-lc",
            script,
            app=app,
            name="goad-windows-proof",
            tags={"purpose": "goad-windows-proof", "environment": ENVIRONMENT},
            image=image,
            cpu=4,
            memory=6144,
            timeout=70 * 60,
            volumes={"/lab": volume},
            experimental_options={"vm_runtime": True},
        )
    print(f"sandbox_id={sandbox.object_id}", flush=True)
    try:
        for line in sandbox.stdout:
            print(line, end="", flush=True)
        for line in sandbox.stderr:
            print(line, end="", flush=True)
        sandbox.wait()
        returncode = sandbox.poll()
        print(f"returncode={returncode}")
        if returncode != 0:
            raise SystemExit(returncode)
    finally:
        sandbox.terminate(wait=True)
        sandbox.detach()


if __name__ == "__main__":
    main()
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>import_goad_boxes.py</code> — <code>48441e2f8916c8e5b884966fb174de717cc1970cf32d6264065423e9a2996b67</code></summary>

<!-- BUNDLE-FILE path="import_goad_boxes.py" sha256="48441e2f8916c8e5b884966fb174de717cc1970cf32d6264065423e9a2996b67" -->
``````python
"""Import the remaining GOAD Windows base boxes into the dedicated Modal Volume."""

from __future__ import annotations

import modal


ENVIRONMENT = "goad-lab"
APP_NAME = "goad-on-modal"
VOLUME_NAME = "goad-lab-state"

BOXES = (
    (
        "windows_2016_2017.12.14",
        "https://vagrantcloud.com/StefanScherer/boxes/windows_2016/versions/"
        "2017.12.14/providers/virtualbox/unknown/vagrant.box",
    ),
    (
        "windows_2016_2019.02.14",
        "https://vagrantcloud.com/StefanScherer/boxes/windows_2016/versions/"
        "2019.02.14/providers/virtualbox/unknown/vagrant.box",
    ),
)


def main() -> None:
    app = modal.App.lookup(APP_NAME, environment_name=ENVIRONMENT)
    volume = modal.Volume.from_name(VOLUME_NAME, environment_name=ENVIRONMENT)
    image = (
        modal.Image.from_registry("ubuntu:24.04", add_python="3.11")
        .env({"DEBIAN_FRONTEND": "noninteractive"})
        .apt_install("ca-certificates", "curl", "qemu-utils", "tar")
    )

    jobs = []
    for name, url in BOXES:
        jobs.append(
            f"""
(
  set -euo pipefail
  target=/lab/base/{name}
  mkdir -p "$target"
  cd "$target"
  if [ ! -s windows.box ]; then
    echo "DOWNLOAD_START={name}"
    curl -fsSL --retry 8 --retry-all-errors --continue-at - -o windows.box {url!r}
    echo "DOWNLOAD_COMPLETE={name} BYTES=$(stat -c %s windows.box)"
  fi
  if [ ! -f .extracted ]; then
    echo "EXTRACT_START={name}"
    tar -xf windows.box
    touch .extracted
    echo "EXTRACT_COMPLETE={name}"
  fi
  disk=$(find . -maxdepth 2 -type f \\( -name '*.vmdk' -o -name '*.vdi' \\) | head -n1)
  test -n "$disk"
  qemu-img info "$disk"
  sync
) &
"""
        )
    script = "set -euo pipefail\n" + "\n".join(jobs) + "\nwait\necho IMPORT_COMPLETE\n"

    with modal.enable_output():
        sandbox = modal.Sandbox.create(
            "bash",
            "-lc",
            script,
            app=app,
            name="goad-box-import",
            tags={"purpose": "goad-box-import", "environment": ENVIRONMENT},
            image=image,
            cpu=2,
            memory=4096,
            timeout=3 * 60 * 60,
            volumes={"/lab": volume},
            experimental_options={"vm_runtime": True},
        )
    print(f"sandbox_id={sandbox.object_id}", flush=True)
    try:
        for line in sandbox.stdout:
            print(line, end="", flush=True)
        for line in sandbox.stderr:
            print(line, end="", flush=True)
        sandbox.wait()
        returncode = sandbox.poll()
        print(f"returncode={returncode}")
        if returncode != 0:
            raise SystemExit(returncode)
    finally:
        sandbox.terminate(wait=True)
        sandbox.detach()


if __name__ == "__main__":
    main()
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>inventory.goad-modal</code> — <code>3f35b605fa3c5fbd8c7d240898715ecadc6151618f68acda2fb4f226f8b44a26</code></summary>

<!-- BUNDLE-FILE path="inventory.goad-modal" sha256="3f35b605fa3c5fbd8c7d240898715ecadc6151618f68acda2fb4f226f8b44a26" -->
``````text
[default]
dc01 ansible_host=192.168.56.10 dns_domain=dc01 dict_key=dc01
dc02 ansible_host=192.168.56.11 dns_domain=dc01 dict_key=dc02
dc03 ansible_host=192.168.56.12 dns_domain=dc03 dict_key=dc03
srv02 ansible_host=192.168.56.22 dns_domain=dc02 dict_key=srv02
srv03 ansible_host=192.168.56.23 dns_domain=dc03 dict_key=srv03

[all:vars]
ansible_port=5985
ansible_winrm_transport=ntlm
ansible_winrm_server_cert_validation=ignore
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>portal-requirements.txt</code> — <code>e84a5f4251e7c0c9f77ae5f8fff4681e5a48ee6d1eab70cf00b7d868ef1f21b2</code></summary>

<!-- BUNDLE-FILE path="portal-requirements.txt" sha256="e84a5f4251e7c0c9f77ae5f8fff4681e5a48ee6d1eab70cf00b7d868ef1f21b2" -->
``````text
fastapi==0.116.1
httpx==0.28.1
modal==1.2.6
pytest==8.4.1
uvicorn[standard]==0.35.0
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>.gitignore</code> — <code>1acad4a9ea0758af2209ae0a6a09b94290c50cf4e57869e8f54d6cabfd0594d6</code></summary>

<!-- BUNDLE-FILE path=".gitignore" sha256="1acad4a9ea0758af2209ae0a6a09b94290c50cf4e57869e8f54d6cabfd0594d6" -->
``````text
__pycache__/
.portal-data/
.portal-venv/
goad-portal/node_modules/
goad-portal/dist/
*.pyc
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>goad-patches/adcs_esc7-main.yml</code> — <code>592a397e374da8ccbc5349aa073987cea4dd6b25ba82eb61612bf15529342a1d</code></summary>

<!-- BUNDLE-FILE path="goad-patches/adcs_esc7-main.yml" sha256="592a397e374da8ccbc5349aa073987cea4dd6b25ba82eb61612bf15529342a1d" -->
``````yaml
# Configure the intentional ESC7 CA ACL vulnerability in a retry-safe way.
- name: Ensure module PSPKI is installed
  ansible.windows.win_powershell:
    script: |
      [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
      if (Get-Module -ListAvailable -Name PSPKI) {
        $Ansible.Changed = $false
      } else {
        if (-not (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue)) {
          Install-PackageProvider -Name NuGet -Force -Scope AllUsers | Out-Null
        }
        Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
        Install-Module PSPKI -Force -AllowClobber -Scope AllUsers
        $Ansible.Changed = $true
      }
    error_action: stop
  # ESSOS-CA is hosted on BRAAVOS.  Running PSPKI locally avoids remote
  # CA-management RPC/DCOM failures in the containerized TCG network.
  delegate_to: srv03

- name: Add ManageCA rights
  ansible.windows.win_powershell:
    script: |
      [CmdletBinding()]
      param ([String] $caManagerUser)
      Import-Module -Name PSPKI -Force
      $certSvc = Get-Service -Name CertSvc -ErrorAction Stop
      if ($certSvc.Status -ne 'Running') {
        Start-Service -Name CertSvc
        $certSvc.WaitForStatus('Running', [TimeSpan]::FromMinutes(2))
      }
      $ca = Get-CertificationAuthority | Select-Object -First 1
      if (-not $ca) {
        throw 'No certification authority was discovered by PSPKI'
      }
      $ca |
        Get-CertificationAuthorityAcl |
        Add-CertificationAuthorityAcl -Identity $caManagerUser -AccessType Allow -AccessMask ManageCa |
        Set-CertificationAuthorityAcl -RestartCA
      $Ansible.Changed = $true
    error_action: stop
    parameters:
      caManagerUser: "{{item.value.ca_manager}}"
  delegate_to: srv03
  vars:
    ansible_become: yes
    ansible_become_method: runas
    domain_name: "{{domain}}"
    ansible_become_user: "{{domain_username}}"
    ansible_become_password: "{{domain_password}}"
  with_dict: "{{ vulns_vars }}"
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>goad-patches/esc13.ps1</code> — <code>2393666ef94a0ee30f4a9b0371147ac6d0bd500924571cedbc6fa3e1455744c9</code></summary>

<!-- BUNDLE-FILE path="goad-patches/esc13.ps1" sha256="2393666ef94a0ee30f4a9b0371147ac6d0bd500924571cedbc6fa3e1455744c9" -->
``````powershell
# Retry-safe ESC13 issuance-policy setup for the intentional GOAD vulnerability.
[CmdletBinding()]
param(
    [Parameter(Mandatory=$true)][string]$esc13group,
    [Parameter(Mandatory=$true)][string]$esc13templateName
)
$ErrorActionPreference = 'Stop'
Import-Module ADCSTemplate -Force
Import-Module ActiveDirectory -Force

function Get-RandomHex {
    param ([int]$Length)
    $Hex = '0123456789ABCDEF'
    $Return = ''
    1..$Length | ForEach-Object {
        $Return += $Hex.Substring((Get-Random -Minimum 0 -Maximum 16), 1)
    }
    return $Return
}

function Test-UniqueOID {
    param ($cn, $TemplateOID, $ConfigNC)
    $Search = Get-ADObject -Filter {cn -eq $cn -and msPKI-Cert-Template-OID -eq $TemplateOID} `
        -SearchBase "CN=OID,CN=Public Key Services,CN=Services,$ConfigNC"
    return -not [bool]$Search
}

function New-TemplateOID {
    param ($ConfigNC)
    do {
        $Part1 = Get-Random -Minimum 10000000 -Maximum 99999999
        $Part2 = Get-Random -Minimum 10000000 -Maximum 99999999
        $Part3 = Get-RandomHex -Length 32
        $ForestOID = Get-ADObject -Identity "CN=OID,CN=Public Key Services,CN=Services,$ConfigNC" `
            -Properties msPKI-Cert-Template-OID | Select-Object -ExpandProperty msPKI-Cert-Template-OID
        $Value = "$ForestOID.$Part1.$Part2"
        $Name = "$Part2.$Part3"
    } until (Test-UniqueOID -cn $Name -TemplateOID $Value -ConfigNC $ConfigNC)
    return @{ TemplateOID = $Value; TemplateName = $Name }
}

$ConfigNC = (Get-ADRootDSE).configurationNamingContext
$IssuanceName = 'IssuancePolicyESC13'
$OIDContainer = "CN=OID,CN=Public Key Services,CN=Services,$ConfigNC"
$ESC13Template = "CN=$esc13templateName,CN=Certificate Templates,CN=Public Key Services,CN=Services,$ConfigNC"

$existing = @(Get-ADObject -LDAPFilter "(&(objectClass=msPKI-Enterprise-Oid)(displayName=$IssuanceName))" `
    -SearchBase $OIDContainer -Properties DisplayName,msPKI-Cert-Template-OID,msDS-OIDToGroupLink)
if ($existing.Count -gt 0) {
    $oidObject = $existing[0]
} else {
    $oid = New-TemplateOID -ConfigNC $ConfigNC
    $attributes = @{
        DisplayName = $IssuanceName
        flags = [int]2
        'msPKI-Cert-Template-OID' = $oid.TemplateOID
    }
    $created = New-ADObject -Path $OIDContainer -OtherAttributes $attributes `
        -Name $oid.TemplateName -Type 'msPKI-Enterprise-Oid' -PassThru
    $oidObject = Get-ADObject -Identity $created.DistinguishedName `
        -Properties DisplayName,msPKI-Cert-Template-OID,msDS-OIDToGroupLink
}

$oidValue = [string]$oidObject.'msPKI-Cert-Template-OID'
if ([string]::IsNullOrWhiteSpace($oidValue)) {
    throw 'ESC13 issuance-policy OID has no value'
}
$template = Get-ADObject -Identity $ESC13Template -Properties msPKI-Certificate-Policy
Set-ADObject -Identity $template.DistinguishedName -Replace @{'msPKI-Certificate-Policy' = @($oidValue)}

$groupDN = (Get-ADGroup -Identity $esc13group).DistinguishedName
$entry = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$($oidObject.DistinguishedName)")
$currentLink = [string]$entry.Properties['msDS-OIDToGroupLink'].Value
if ($currentLink -ne $groupDN) {
    $entry.Properties['msDS-OIDToGroupLink'].Value = $groupDN
    $entry.CommitChanges()
    $entry.RefreshCache()
}

$verifiedOID = Get-ADObject -Identity $oidObject.DistinguishedName -Properties msDS-OIDToGroupLink
$verifiedTemplate = Get-ADObject -Identity $ESC13Template -Properties msPKI-Certificate-Policy
if ([string]$verifiedOID.'msDS-OIDToGroupLink' -ne $groupDN) {
    throw 'ESC13 issuance-policy group link verification failed'
}
if (@($verifiedTemplate.'msPKI-Certificate-Policy') -notcontains $oidValue) {
    throw 'ESC13 template policy verification failed'
}
Write-Output "ESC13 configured with OID $oidValue and group $groupDN"
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>goad-patches/security.yml</code> — <code>68531ab830dfd6897af2d6775e4db34da44fffb5653d39b574595ed0b6952e12</code></summary>

<!-- BUNDLE-FILE path="goad-patches/security.yml" sha256="68531ab830dfd6897af2d6775e4db34da44fffb5653d39b574595ed0b6952e12" -->
``````yaml
---
# Load datas
- import_playbook: data.yml
  vars:
    data_path: "../ad/{{domain_name}}/data/"
  tags: 'data'

- name: "Setup enable defender"
  hosts: defender_on
  serial: 1
  roles:
    - { role: 'settings/windows_defender', tags: 'windows_defender', windows_defender_status: 'on' }
  vars:
    script_path: "../ad/{{domain_name}}/scripts"

- name: Setup disable defender
  hosts: defender_off
  serial: 1
  roles:
    - { role: 'settings/windows_defender', tags: 'windows_defender', windows_defender_status: 'off' }
  vars:
    script_path: "../ad/{{domain_name}}/scripts"

- name: "Setup security with tasks"
  hosts: domain
  tasks:
    - include_role:
        name: "security/{{secu}}"
      vars:
        security_vars : "{{ lab.hosts[dict_key].security_vars[secu] | default({}) }}"
        domain: "{{lab.hosts[dict_key].domain}}"
        domain_username: '{{domain}}\{{admin_user}}'
        domain_password: "{{lab.domains[domain].domain_password}}"
      loop: "{{lab.hosts[dict_key].security | default([]) }}"
      loop_control:
        loop_var: secu
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>goad-patches/shares-main.yml</code> — <code>1fb1f4597c975c6657847c826418d3a5c03680b785e465a00bd2d3593db385ce</code></summary>

<!-- BUNDLE-FILE path="goad-patches/shares-main.yml" sha256="1fb1f4597c975c6657847c826418d3a5c03680b785e465a00bd2d3593db385ce" -->
``````yaml
# SMB share users only
- name: Create directory if not exist
  win_file:
    path: "{{item.value.path}}"
    state: directory
  with_dict: "{{ vulns_vars }}"

- name: Create share
  ansible.windows.win_share:
    name: "{{item.key}}"
    description: "{{item.value.description | default(item.key) }}"
    path: "{{item.value.path}}"
    list: "{{item.value.list | default('yes')}}"
    full: "{{item.value.full | default('Administrators')}}"
    change: "{{item.value.change | default('')}}"
    read: "{{item.value.read | default('')}}"
    deny: "{{item.value.deny | default('')}}"
  register: shareregister
  with_dict: "{{ vulns_vars }}"

- include_tasks: perm.yml
  vars:
    path: "{{item.value.path}}"
    users: "{{ item.value.full | default('') | split(',') | map('trim') | reject('equalto', '') | list }}"
    perm: "FullControl"
    type: "allow"
  with_dict: "{{ vulns_vars }}"

- include_tasks: perm.yml
  vars:
    path: "{{item.value.path}}"
    users: "{{ item.value.change | default('') | split(',') | map('trim') | reject('equalto', '') | list }}"
    perm: "Read,Write,Modify,Delete"
    type: "allow"
  with_dict: "{{ vulns_vars }}"

- include_tasks: perm.yml
  vars:
    path: "{{item.value.path}}"
    users: "{{ item.value.read | default('') | split(',') | map('trim') | reject('equalto', '') | list }}"
    perm: "Read"
    type: "allow"
  with_dict: "{{ vulns_vars }}"

- include_tasks: perm.yml
  vars:
    path: "{{item.value.path}}"
    users: "{{ item.value.deny | default('') | split(',') | map('trim') | reject('equalto', '') | list }}"
    perm: "Read,Write,Modify,FullControl,Delete"
    type: "deny"
  with_dict: "{{ vulns_vars }}"
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>goad-patches/windows_defender-main.yml</code> — <code>5369a8ca1a79ede9c8dab9e60ec0e0f65b444ab6d84f79f578a9531e199ba6cf</code></summary>

<!-- BUNDLE-FILE path="goad-patches/windows_defender-main.yml" sha256="5369a8ca1a79ede9c8dab9e60ec0e0f65b444ab6d84f79f578a9531e199ba6cf" -->
``````yaml
# Windows feature servicing is slow under QEMU TCG; allow the guest to complete a clean reboot.
- name: Install windows defender
  win_feature:
    name: Windows-Defender
  register: win_defender_install

- name: Reboot if needed
  win_reboot:
    reboot_timeout: 1800
    post_reboot_delay: 30
  when: win_defender_install.reboot_required

- name: Disable windows defender sending sample
  win_shell: Set-MpPreference -MAPSReporting 0

- name: Disable windows defender sending sample #2 : never send
  win_shell: Set-MpPreference -SubmitSamplesConsent 2
  register: defender_sample_consent
  changed_when: defender_sample_consent.rc == 0
  # Some Server builds reject this secondary value after MAPS is disabled.
  # MAPSReporting=0 is the controlling no-upload setting, verified below.
  failed_when: false

- name: Verify Defender cloud sample reporting is disabled
  ansible.windows.win_powershell:
    script: |
      $preference = Get-MpPreference
      if ([int]$preference.MAPSReporting -ne 0) {
        throw "Defender MAPS reporting is not disabled"
      }
      $Ansible.Changed = $false
    error_action: stop

- name: Disable network drive scanning
  win_shell: Set-MpPreference -DisableScanningMappedNetworkDrivesForFullScan $true
  when: windows_defender_status == "off"

- name: Disable realtime monitoring
  win_shell: Set-MpPreference -DisableRealtimeMonitoring $true
  when: windows_defender_status == "off"
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>goad-portal/.npmrc</code> — <code>f7dbda001b627b3a792fb6929303b517f047f03916f2c55227e81216d38a2008</code></summary>

<!-- BUNDLE-FILE path="goad-portal/.npmrc" sha256="f7dbda001b627b3a792fb6929303b517f047f03916f2c55227e81216d38a2008" -->
``````text
fund=false
audit=false
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>goad-portal/.openai/hosting.json</code> — <code>d532abb65cf9ae20634b464d954cb4a08a0de9f3cd3cdf7f9c3ec8948826d947</code></summary>

<!-- BUNDLE-FILE path="goad-portal/.openai/hosting.json" sha256="d532abb65cf9ae20634b464d954cb4a08a0de9f3cd3cdf7f9c3ec8948826d947" -->
``````json
{
  "d1": null,
  "r2": null
}
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>goad-portal/package.json</code> — <code>3ec354db1c6445b5956ed18370339ebb87c2d80e8c6e279c8fc0eef3825facd8</code></summary>

<!-- BUNDLE-FILE path="goad-portal/package.json" sha256="3ec354db1c6445b5956ed18370339ebb87c2d80e8c6e279c8fc0eef3825facd8" -->
``````json
{
  "name": "goad-portal",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build && node scripts/prepare-sites-build.mjs",
    "preview": "vite preview",
    "test:sites": "node --test tests/sites-worker.test.mjs"
  },
  "dependencies": {
    "@phosphor-icons/react": "^2.1.10",
    "@vitejs/plugin-react": "5.0.4",
    "vite": "6.4.2",
    "react": "19.2.0",
    "react-dom": "19.2.0"
  },
  "devDependencies": {}
}
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>goad-portal/package-lock.json</code> — <code>40ba119a3e63730d5fe662b5c1a85c454e36b999c34ac34c771463f94ab1981e</code></summary>

<!-- BUNDLE-FILE path="goad-portal/package-lock.json" sha256="40ba119a3e63730d5fe662b5c1a85c454e36b999c34ac34c771463f94ab1981e" -->
``````json
{
  "name": "goad-portal",
  "version": "0.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "goad-portal",
      "version": "0.0.0",
      "dependencies": {
        "@phosphor-icons/react": "^2.1.10",
        "@vitejs/plugin-react": "5.0.4",
        "react": "19.2.0",
        "react-dom": "19.2.0",
        "vite": "6.4.2"
      },
      "devDependencies": {}
    },
    "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==",
      "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==",
      "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==",
      "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.7",
      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
      "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
      "license": "MIT",
      "dependencies": {
        "@babel/parser": "^7.29.7",
        "@babel/types": "^7.29.7",
        "@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==",
      "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==",
      "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==",
      "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==",
      "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==",
      "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==",
      "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==",
      "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==",
      "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==",
      "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.7",
      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
      "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
      "license": "MIT",
      "dependencies": {
        "@babel/types": "^7.29.7"
      },
      "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==",
      "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==",
      "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==",
      "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.7",
      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
      "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
      "license": "MIT",
      "dependencies": {
        "@babel/code-frame": "^7.29.7",
        "@babel/generator": "^7.29.7",
        "@babel/helper-globals": "^7.29.7",
        "@babel/parser": "^7.29.7",
        "@babel/template": "^7.29.7",
        "@babel/types": "^7.29.7",
        "debug": "^4.3.1"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/types": {
      "version": "7.29.7",
      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
      "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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"
      ],
      "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==",
      "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==",
      "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==",
      "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==",
      "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==",
      "license": "MIT",
      "dependencies": {
        "@jridgewell/resolve-uri": "^3.1.0",
        "@jridgewell/sourcemap-codec": "^1.4.14"
      }
    },
    "node_modules/@phosphor-icons/react": {
      "version": "2.1.10",
      "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
      "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==",
      "license": "MIT",
      "engines": {
        "node": ">=10"
      },
      "peerDependencies": {
        "react": ">= 16.8",
        "react-dom": ">= 16.8"
      }
    },
    "node_modules/@rolldown/pluginutils": {
      "version": "1.0.0-beta.38",
      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz",
      "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==",
      "license": "MIT"
    },
    "node_modules/@rollup/rollup-android-arm-eabi": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
      "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
      "cpu": [
        "arm"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ]
    },
    "node_modules/@rollup/rollup-android-arm64": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
      "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ]
    },
    "node_modules/@rollup/rollup-darwin-arm64": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
      "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ]
    },
    "node_modules/@rollup/rollup-darwin-x64": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
      "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
      "cpu": [
        "x64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ]
    },
    "node_modules/@rollup/rollup-freebsd-arm64": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
      "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "freebsd"
      ]
    },
    "node_modules/@rollup/rollup-freebsd-x64": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
      "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
      "cpu": [
        "x64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "freebsd"
      ]
    },
    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
      "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
      "cpu": [
        "arm"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
      "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
      "cpu": [
        "arm"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-arm64-gnu": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
      "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-arm64-musl": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
      "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-loong64-gnu": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
      "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
      "cpu": [
        "loong64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-loong64-musl": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
      "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
      "cpu": [
        "loong64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
      "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
      "cpu": [
        "ppc64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-ppc64-musl": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
      "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
      "cpu": [
        "ppc64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
      "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
      "cpu": [
        "riscv64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-riscv64-musl": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
      "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
      "cpu": [
        "riscv64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-s390x-gnu": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
      "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
      "cpu": [
        "s390x"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-x64-gnu": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
      "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
      "cpu": [
        "x64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-x64-musl": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
      "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
      "cpu": [
        "x64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-openbsd-x64": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
      "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
      "cpu": [
        "x64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "openbsd"
      ]
    },
    "node_modules/@rollup/rollup-openharmony-arm64": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
      "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "openharmony"
      ]
    },
    "node_modules/@rollup/rollup-win32-arm64-msvc": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
      "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ]
    },
    "node_modules/@rollup/rollup-win32-ia32-msvc": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
      "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
      "cpu": [
        "ia32"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ]
    },
    "node_modules/@rollup/rollup-win32-x64-gnu": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
      "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
      "cpu": [
        "x64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ]
    },
    "node_modules/@rollup/rollup-win32-x64-msvc": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
      "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
      "cpu": [
        "x64"
      ],
      "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==",
      "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==",
      "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==",
      "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==",
      "license": "MIT",
      "dependencies": {
        "@babel/types": "^7.28.2"
      }
    },
    "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/@vitejs/plugin-react": {
      "version": "5.0.4",
      "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz",
      "integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==",
      "license": "MIT",
      "dependencies": {
        "@babel/core": "^7.28.4",
        "@babel/plugin-transform-react-jsx-self": "^7.27.1",
        "@babel/plugin-transform-react-jsx-source": "^7.27.1",
        "@rolldown/pluginutils": "1.0.0-beta.38",
        "@types/babel__core": "^7.20.5",
        "react-refresh": "^0.17.0"
      },
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      },
      "peerDependencies": {
        "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
      }
    },
    "node_modules/baseline-browser-mapping": {
      "version": "2.10.42",
      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
      "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==",
      "license": "Apache-2.0",
      "bin": {
        "baseline-browser-mapping": "dist/cli.cjs"
      },
      "engines": {
        "node": ">=6.0.0"
      }
    },
    "node_modules/browserslist": {
      "version": "4.28.5",
      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz",
      "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==",
      "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.42",
        "caniuse-lite": "^1.0.30001800",
        "electron-to-chromium": "^1.5.387",
        "node-releases": "^2.0.50",
        "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.30001803",
      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz",
      "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==",
      "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/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==",
      "license": "MIT"
    },
    "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/electron-to-chromium": {
      "version": "1.5.389",
      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz",
      "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==",
      "license": "ISC"
    },
    "node_modules/esbuild": {
      "version": "0.25.12",
      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
      "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
      "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==",
      "license": "MIT",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/fdir": {
      "version": "6.5.0",
      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
      "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==",
      "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==",
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "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==",
      "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==",
      "license": "MIT",
      "bin": {
        "json5": "lib/cli.js"
      },
      "engines": {
        "node": ">=6"
      }
    },
    "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==",
      "license": "ISC",
      "dependencies": {
        "yallist": "^3.0.2"
      }
    },
    "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.15",
      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
      "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
      "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.50",
      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
      "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
      "license": "MIT",
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/picocolors": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
      "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==",
      "license": "MIT",
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://github.com/sponsors/jonschlinkert"
      }
    },
    "node_modules/postcss": {
      "version": "8.5.16",
      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
      "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
      "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.12",
        "picocolors": "^1.1.1",
        "source-map-js": "^1.2.1"
      },
      "engines": {
        "node": "^10 || ^12 || >=14"
      }
    },
    "node_modules/react": {
      "version": "19.2.0",
      "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
      "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
      "license": "MIT",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/react-dom": {
      "version": "19.2.0",
      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
      "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
      "license": "MIT",
      "dependencies": {
        "scheduler": "^0.27.0"
      },
      "peerDependencies": {
        "react": "^19.2.0"
      }
    },
    "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==",
      "license": "MIT",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/rollup": {
      "version": "4.62.2",
      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
      "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
      "license": "MIT",
      "dependencies": {
        "@types/estree": "1.0.9"
      },
      "bin": {
        "rollup": "dist/bin/rollup"
      },
      "engines": {
        "node": ">=18.0.0",
        "npm": ">=8.0.0"
      },
      "optionalDependencies": {
        "@rollup/rollup-android-arm-eabi": "4.62.2",
        "@rollup/rollup-android-arm64": "4.62.2",
        "@rollup/rollup-darwin-arm64": "4.62.2",
        "@rollup/rollup-darwin-x64": "4.62.2",
        "@rollup/rollup-freebsd-arm64": "4.62.2",
        "@rollup/rollup-freebsd-x64": "4.62.2",
        "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
        "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
        "@rollup/rollup-linux-arm64-gnu": "4.62.2",
        "@rollup/rollup-linux-arm64-musl": "4.62.2",
        "@rollup/rollup-linux-loong64-gnu": "4.62.2",
        "@rollup/rollup-linux-loong64-musl": "4.62.2",
        "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
        "@rollup/rollup-linux-ppc64-musl": "4.62.2",
        "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
        "@rollup/rollup-linux-riscv64-musl": "4.62.2",
        "@rollup/rollup-linux-s390x-gnu": "4.62.2",
        "@rollup/rollup-linux-x64-gnu": "4.62.2",
        "@rollup/rollup-linux-x64-musl": "4.62.2",
        "@rollup/rollup-openbsd-x64": "4.62.2",
        "@rollup/rollup-openharmony-arm64": "4.62.2",
        "@rollup/rollup-win32-arm64-msvc": "4.62.2",
        "@rollup/rollup-win32-ia32-msvc": "4.62.2",
        "@rollup/rollup-win32-x64-gnu": "4.62.2",
        "@rollup/rollup-win32-x64-msvc": "4.62.2",
        "fsevents": "~2.3.2"
      }
    },
    "node_modules/scheduler": {
      "version": "0.27.0",
      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
      "license": "MIT"
    },
    "node_modules/semver": {
      "version": "6.3.1",
      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
      "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==",
      "license": "BSD-3-Clause",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/tinyglobby": {
      "version": "0.2.17",
      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
      "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/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==",
      "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/vite": {
      "version": "6.4.2",
      "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
      "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
      "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==",
      "license": "ISC"
    }
  }
}
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>goad-portal/vite.config.mjs</code> — <code>a6939b40aa3b02f8f0142977cb2e54957bf9a59276a55661982f60b827c92a68</code></summary>

<!-- BUNDLE-FILE path="goad-portal/vite.config.mjs" sha256="a6939b40aa3b02f8f0142977cb2e54957bf9a59276a55661982f60b827c92a68" -->
``````javascript
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  build: {
    outDir: "dist/client",
  },
  optimizeDeps: {
    include: ["react", "react-dom/client"],
  },
  server: {
    host: "0.0.0.0",
    allowedHosts: ["terminal.local"],
    proxy: {
      "/api": "http://127.0.0.1:8787",
    },
    warmup: {
      clientFiles: ["./src/main.jsx"],
    },
  },
  plugins: [react()],
});
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>goad-portal/index.html</code> — <code>a4b2cd67481402903c3f4471a5446e3460d3bf410c91da32de9d369a25fde31d</code></summary>

<!-- BUNDLE-FILE path="goad-portal/index.html" sha256="a4b2cd67481402903c3f4471a5446e3460d3bf410c91da32de9d369a25fde31d" -->
``````html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Transilience AI LABS</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>goad-portal/src/App.jsx</code> — <code>627e53e3729189727d1c61bc45dd0897547d594216cee651464170cf09de8373</code></summary>

<!-- BUNDLE-FILE path="goad-portal/src/App.jsx" sha256="627e53e3729189727d1c61bc45dd0897547d594216cee651464170cf09de8373" -->
``````jsx
import { useCallback, useEffect, useMemo, useState } from "react";
import {
  ArrowCounterClockwise, ArrowLeft, ArrowRight, CaretDown, CheckCircle, Clock, ClockCountdown, Copy,
  Desktop, DownloadSimple, GlobeHemisphereWest, Keyhole, LinkSimple, LockKey, Network, Play, Power,
  ShieldCheck, SignOut, SpinnerGap, SquaresFour, Star, StopCircle, TreeStructure, UserCircle,
  UsersThree, WarningCircle, X,
} from "@phosphor-icons/react";

const LABS = [
  { slug: "goad", name: "GOAD", category: "practice", eyebrow: "PRACTICE LAB", level: "Intermediate", machines: 5, forests: 2, domains: 3, ready: true },
  { slug: "goad-light", name: "GOAD-Light", category: "practice", eyebrow: "PRACTICE LAB", level: "Intermediate", machines: 3, forests: 1, domains: 2 },
  { slug: "goad-mini", name: "GOAD-Mini", category: "practice", eyebrow: "PRACTICE LAB", level: "Beginner", machines: 1, forests: 0, domains: 1 },
  { slug: "nha", name: "NHA", category: "challenge", eyebrow: "CHALLENGE LAB", level: "Advanced", machines: 5, forests: 0, domains: 2 },
  { slug: "sccm", name: "SCCM", category: "practice", eyebrow: "PRACTICE LAB", level: "Advanced", machines: 4, forests: 1, domains: 1 },
  { slug: "minilab", name: "MINILAB", category: "poc", eyebrow: "PROOF OF CONCEPT", level: "Beginner", machines: 2, forests: 1, domains: 1 },
];

const MACHINES = [
  { name: "KINGSLANDING", role: "DC01 · sevenkingdoms.local", ip: "192.168.56.10", os: "Windows Server 2019" },
  { name: "WINTERFELL", role: "DC02 · north.sevenkingdoms.local", ip: "192.168.56.11", os: "Windows Server 2019" },
  { name: "MEEREEN", role: "DC03 · essos.local", ip: "192.168.56.12", os: "Windows Server 2016" },
  { name: "CASTELBLACK", role: "SRV02 · IIS, MSSQL, SMB", ip: "192.168.56.22", os: "Windows Server 2019" },
  { name: "BRAAVOS", role: "SRV03 · MSSQL, SMB, AD CS", ip: "192.168.56.23", os: "Windows Server 2016" },
];

const OBJECTIVE_TRACKS = [
  { icon: Network, title: "Map the realms", copy: "Enumerate five servers, three domains, two forests, trusts, users, groups, shares, and exposed services." },
  { icon: TreeStructure, title: "Build attack paths", copy: "Correlate credentials, delegated rights, Kerberos weaknesses, relay opportunities, and cross-forest relationships." },
  { icon: ShieldCheck, title: "Reach domain control", copy: "Move through realistic privilege paths until you can demonstrate control across North, Seven Kingdoms, and Essos." },
];

const REALM_OBJECTIVES = [
  {
    realm: "NORTH.SEVENKINGDOMS.LOCAL",
    summary: "Identity discovery, relay paths, delegation, SQL execution, GPO abuse, and local administration.",
    groups: [
      {
        name: "Starks",
        access: "RDP access to Winterfell and Castelblack",
        paths: [
          "Arya Stark — MSSQL execute-as-user and credentials exposed across file shares.",
          "Eddard Stark — North domain admin and a recurring LLMNR request suitable for responder or NTLM relay practice.",
          "Robb Stark — recurring LLMNR activity plus an interactive session recoverable from LSASS.",
          "Sansa Stark — keyboard-pattern credential discovery and unconstrained delegation.",
          "Brandon Stark — AS-REP roasting.",
          "Rickon Stark — seasonal WinterYYYY password-spray pattern.",
          "Jon Snow — MSSQL administration, Kerberoasting, and a trusted SQL link.",
          "Hodor — user-equals-password spray scenario.",
        ],
      },
      {
        name: "Night Watch",
        access: "RDP access to Castelblack",
        paths: [
          "Samwell Tarly — credential in an LDAP description, MSSQL execute-as-login, and edit rights on the STARKWALLPAPER GPO.",
          "Jon Snow — shares the Stark SQL and Kerberos paths.",
          "Jeor Mormont — shares the Mormont local-administrator path.",
        ],
      },
      {
        name: "Mormont",
        access: "RDP access to Castelblack",
        paths: ["Jeor Mormont — local administrator on Castelblack with a recoverable secret in a SYSVOL script."],
      },
      {
        name: "AcrossTheSea",
        access: "Cross-forest group relationship",
        paths: ["Trace membership and trust relationships that bridge North with the other forest."],
      },
    ],
  },
  {
    realm: "SEVENKINGDOMS.LOCAL",
    summary: "Directory ACL chains, protected identities, group ownership, computer control, and AdminSDHolder abuse.",
    groups: [
      {
        name: "Lannisters",
        access: "Directory-control chain",
        paths: [
          "Tywin Lannister — ForceChangePassword over Jaime plus a recoverable encrypted secret in SYSVOL.",
          "Jaime Lannister — GenericWrite over Joffrey Baratheon.",
          "Tyrion Lannister — self-membership rights on Small Council.",
          "Cersei Lannister — Seven Kingdoms domain administrator.",
        ],
      },
      {
        name: "Baratheon",
        access: "RDP access to Kingslanding",
        paths: [
          "Robert Baratheon — protected Seven Kingdoms domain administrator.",
          "Joffrey Baratheon — WriteDACL over Tyrion Lannister.",
          "Renly Baratheon — WriteDACL on a container while marked as a sensitive user.",
          "Stannis Baratheon — GenericAll over the Kingslanding computer account.",
        ],
      },
      {
        name: "Small Council",
        access: "RDP to Kingslanding and add-member rights over Dragonstone",
        paths: [
          "Lord Varys — GenericAll over Domain Admins and an AdminSDHolder path.",
          "Petyr Baelish and Maester Pycelle — identities to correlate through group relationships.",
        ],
      },
      {
        name: "Dragonstone → Kingsguard",
        access: "Nested group-control chain",
        paths: ["Dragonstone has WriteOwner over Kingsguard; Kingsguard has GenericAll over Stannis Baratheon."],
      },
      {
        name: "AcrossTheNarrowSea",
        access: "Cross-forest group relationship",
        paths: ["Use group nesting and trust context to discover paths that cross into Essos."],
      },
    ],
  },
  {
    realm: "ESSOS.LOCAL",
    summary: "AS-REP roasting, SQL trust links, LAPS access, shadow credentials, certificate services, and cross-forest groups.",
    groups: [
      {
        name: "Targaryen",
        access: "RDP access to Meereen",
        paths: [
          "Missandei — AS-REP roasting and GenericAll over Khal Drogo.",
          "Daenerys Targaryen — Essos domain administrator.",
          "Viserys Targaryen — write-property permission over Jorah Mormont.",
          "Jorah Mormont — MSSQL execute-as-login, a trusted SQL link, and permission to read a LAPS password.",
        ],
      },
      {
        name: "Dothraki",
        access: "RDP access to Braavos",
        paths: ["Khal Drogo — MSSQL administration, GenericAll over Viserys for shadow credentials, and control of an ESC4 certificate template path."],
      },
      {
        name: "DragonsFriends",
        access: "Cross-forest group relationship",
        paths: ["Follow nested membership into cross-forest privilege paths."],
      },
      {
        name: "Spys",
        access: "Cross-forest reconnaissance group",
        paths: ["Read LAPS credentials and exercise GenericAll over Jorah Mormont."],
      },
    ],
  },
];

async function api(path, options = {}) {
  const response = await fetch(path, {
    credentials: "same-origin",
    headers: { "Content-Type": "application/json", ...(options.headers || {}) },
    ...options,
  });
  const body = await response.json().catch(() => ({}));
  if (!response.ok) throw new Error(body.detail || "Something went wrong");
  return body;
}

async function downloadVpnProfile() {
  const link = document.createElement("a");
  link.href = "/api/labs/goad/vpn-config";
  link.download = "transilience-goad.ovpn";
  document.body.appendChild(link);
  link.click();
  link.remove();
}

function Logo() {
  return (
    <div className="brand" aria-label="Transilience AI LABS">
      <img className="brand-logo" src="/assets/transilience-logo-dark.svg" alt="Transilience AI" />
      <span className="brand-suffix" aria-hidden="true">LABS</span>
    </div>
  );
}

function AuthPage({ initialMode = "login", onAuth }) {
  const [mode, setMode] = useState(initialMode);
  const [form, setForm] = useState({ name: "", email: "", password: "" });
  const [error, setError] = useState("");
  const [busy, setBusy] = useState(false);

  async function submit(event) {
    event.preventDefault();
    setBusy(true);
    setError("");
    try {
      const payload = mode === "signup" ? form : { email: form.email, password: form.password };
      const result = await api(`/api/auth/${mode}`, { method: "POST", body: JSON.stringify(payload) });
      onAuth(result.user);
    } catch (err) {
      setError(err.message);
    } finally {
      setBusy(false);
    }
  }

  return (
    <main className="auth-page">
      <section className="auth-visual">
        <div className="auth-shade" />
        <div className="auth-brand"><Logo /></div>
        <div className="auth-copy">
          <span className="eyebrow lime">FULL ACTIVE DIRECTORY LAB</span>
          <h1>Enter the realm.<br />Break the domain.</h1>
          <p>Launch a complete five-machine GOAD environment on demand. Persistent lab state, isolated networking, and browser access—ready when you are.</p>
          <div className="auth-stats">
            <span><strong>5</strong> machines</span>
            <span><strong>3</strong> domains</span>
            <span><strong>2</strong> forests</span>
          </div>
        </div>
      </section>
      <section className="auth-panel">
        <div className="auth-box">
          <Logo />
          <div className="auth-heading">
            <span className="eyebrow">SECURE ACCESS</span>
            <h2>{mode === "signup" ? "Create your account" : "Welcome back"}</h2>
            <p>{mode === "signup" ? "Create an account to access the lab portal." : "Sign in to continue to Transilience AI LABS."}</p>
          </div>
          <form onSubmit={submit}>
            {mode === "signup" && (
              <label>Display name<input required minLength="2" autoComplete="name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="Your name" /></label>
            )}
            <label>Email address<input required type="email" autoComplete="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} placeholder="you@example.com" /></label>
            <label>Password<input required minLength="10" type="password" autoComplete={mode === "signup" ? "new-password" : "current-password"} value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} placeholder="Minimum 10 characters" /></label>
            {error && <div className="form-error" role="alert">{error}</div>}
            <button className="primary full" disabled={busy} type="submit">
              {busy ? <SpinnerGap className="spin" size={20} /> : mode === "signup" ? <UserCircle size={20} /> : <ArrowRight size={20} />}
              {busy ? "Please wait" : mode === "signup" ? "Create account" : "Sign in"}
            </button>
          </form>
          <p className="auth-switch">
            {mode === "signup" ? "Already have an account?" : "New to Transilience AI LABS?"}
            <button onClick={() => { setMode(mode === "signup" ? "login" : "signup"); setError(""); }}>
              {mode === "signup" ? "Sign in" : "Create an account"}
            </button>
          </p>
        </div>
      </section>
    </main>
  );
}

function Header({ user, onLogout, onCatalog }) {
  return (
    <header className="topbar">
      <button className="brand-button" onClick={onCatalog}><Logo /></button>
      <nav><button onClick={onCatalog}><SquaresFour size={18} /> Labs</button></nav>
      <div className="account"><span className="avatar">{user.name.slice(0, 1).toUpperCase()}</span><span><strong>{user.name}</strong><small>{user.email}</small></span><button className="icon-button" aria-label="Sign out" onClick={onLogout}><SignOut size={20} /></button></div>
    </header>
  );
}

function LabCard({ lab, onOpen }) {
  return (
    <article className={`lab-card ${lab.ready ? "available" : "locked"}`} onClick={() => lab.ready && onOpen()}>
      <div className="lab-art">
        {lab.ready ? <img src="/assets/goad-fortress.png" alt="Five connected medieval fortresses representing the GOAD network" /> : <div className="future-art"><LockKey size={42} weight="duotone" /></div>}
        <span className={`card-state ${lab.ready ? "live" : "soon"}`}>{lab.ready ? "AVAILABLE" : "COMING SOON"}</span>
      </div>
      <div className="card-body">
        <span className="eyebrow"><Network size={16} /> {lab.eyebrow}</span>
        <h3>{lab.name}</h3>
        <p>{lab.level}</p>
        <div className="card-divider" />
        <div className="card-meta">
          <span><Desktop size={18} /> {lab.machines} VMs</span>
          <span><TreeStructure size={18} /> {lab.domains} domains</span>
          {lab.ready ? <button aria-label="Open GOAD lab"><ArrowRight size={20} /></button> : <LockKey size={18} />}
        </div>
      </div>
    </article>
  );
}

function Catalog({ user, onLogout, onOpenLab }) {
  const [filter, setFilter] = useState("all");
  const visibleLabs = filter === "all" ? LABS : LABS.filter((lab) => lab.category === filter);
  return (
    <div className="app-shell">
      <Header user={user} onLogout={onLogout} onCatalog={() => {}} />
      <main className="catalog-page page-width">
        <div className="catalog-heading">
          <div><span className="eyebrow lime">TRANSILIENCE AI LABS</span><h1>Practice Labs</h1><p>Launch isolated enterprise environments, build attack paths, and sharpen your Active Directory tradecraft.</p></div>
          <div className="availability"><span className="status-dot" /><strong>GOAD ready</strong><small>Pay only while running</small></div>
        </div>
        <div className="filter-row">
          {[['all', 'All labs', 6], ['practice', 'Practice', 4], ['challenge', 'Challenge', 1], ['poc', 'POC', 1]].map(([value, label, count]) => (
            <button key={value} className={filter === value ? "filter-active" : ""} onClick={() => setFilter(value)}>{label} <span>{count}</span></button>
          ))}
        </div>
        <section className="lab-grid" aria-label="Lab catalog">
          {visibleLabs.map((lab) => <LabCard key={lab.slug} lab={lab} onOpen={() => onOpenLab(lab.slug)} />)}
        </section>
      </main>
    </div>
  );
}

function formatDuration(totalSeconds) {
  if (!Number.isFinite(totalSeconds)) return "--:--:--";
  const safeSeconds = Math.max(0, Math.floor(totalSeconds));
  const hours = Math.floor(safeSeconds / 3600);
  const minutes = Math.floor((safeSeconds % 3600) / 60);
  const seconds = safeSeconds % 60;
  return [hours, minutes, seconds].map((value) => String(value).padStart(2, "0")).join(":");
}

function VpnDrawer({ open, onClose, labStatus, busy, error, onPrepare }) {
  const state = labStatus?.state || "stopped";
  const running = state === "running";
  const transitioning = busy || ["starting", "stopping", "resetting"].includes(state);
  const ownedByMe = labStatus?.ownership === "mine";
  const ownedByOther = labStatus?.ownership === "other" || labStatus?.ownership === "external";
  const available = running && ownedByMe && labStatus?.vpn_available;
  const statusLabel = available ? "ONLINE" : transitioning ? "STARTING" : ownedByOther ? "IN USE" : "OFFLINE";

  useEffect(() => {
    if (!open) return undefined;
    const previousOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    const closeOnEscape = (event) => event.key === "Escape" && onClose();
    window.addEventListener("keydown", closeOnEscape);
    return () => {
      document.body.style.overflow = previousOverflow;
      window.removeEventListener("keydown", closeOnEscape);
    };
  }, [open, onClose]);

  if (!open) return null;

  return (
    <div className="vpn-overlay" onMouseDown={(event) => event.target === event.currentTarget && onClose()}>
      <aside className="vpn-drawer" role="dialog" aria-modal="true" aria-labelledby="vpn-title">
        <header className="vpn-drawer-header">
          <button type="button" aria-label="Back to lab" onClick={onClose}><ArrowLeft size={24} /></button>
          <h2 id="vpn-title">Connect to GOAD with OpenVPN</h2>
          <button type="button" aria-label="Close VPN setup" onClick={onClose}><X size={24} /></button>
        </header>

        <div className={`vpn-status-band ${available ? "is-online" : transitioning ? "is-starting" : "is-offline"}`}><span>{statusLabel}</span></div>
        <div className={`vpn-keyhole ${available ? "is-online" : ""}`}><Keyhole size={35} weight="fill" /></div>

        <div className="vpn-setup-card">
          <div className="vpn-intro">
            <h3>{available ? "Your VPN gateway is ready" : "Connect to the GOAD network"}</h3>
            <p>{available ? "Download the profile and import it into any OpenVPN-compatible client." : "Prepare an isolated gateway and download a session-specific profile before the Windows machines finish booting."}</p>
          </div>

          <label className="vpn-field">
            <span>VPN Access</span>
            <div className="vpn-select-wrap"><select value="goad" disabled aria-label="VPN access network"><option value="goad">GOAD — Lab Network</option></select><CaretDown size={18} /></div>
          </label>
          <label className="vpn-field">
            <span>VPN Gateway</span>
            <div className="vpn-select-wrap"><select value="ondemand" disabled aria-label="VPN gateway"><option value="ondemand">On-demand Gateway 1</option></select><CaretDown size={18} /></div>
          </label>

          {ownedByOther ? (
            <div className="vpn-notice is-warning"><WarningCircle size={25} weight="fill" /><div><strong>Lab currently in use</strong><p>{labStatus.owner_name || "Another operator"} owns the active session. A VPN profile can only be issued to that session owner.</p></div></div>
          ) : available ? (
            <div className="vpn-notice is-ready"><CheckCircle size={25} weight="fill" /><div><strong>Encrypted route ready</strong><p>This profile is private to your session and routes only <b>{labStatus.network || "192.168.56.0/24"}</b>.</p></div></div>
          ) : (
            <div className="vpn-notice is-warning"><WarningCircle size={25} weight="fill" /><div><strong>On-demand access</strong><p>Preparing the profile starts your paid lab session. The profile is generated first; all five Windows machines continue booting in the background.</p></div></div>
          )}

          <fieldset className="vpn-protocol">
            <legend>Protocol</legend>
            <label className="disabled"><input type="radio" name="vpn-protocol" disabled /> <span>UDP</span><small>Unavailable</small></label>
            <label><input type="radio" name="vpn-protocol" checked readOnly /> <span>TCP 1194</span></label>
          </fieldset>

          {error && <div className="vpn-error" role="alert">{error}</div>}
          {available ? (
            <a className="vpn-download-primary" href="/api/labs/goad/vpn-config" download="transilience-goad.ovpn"><DownloadSimple size={21} weight="bold" /> Download VPN</a>
          ) : (
            <button className="vpn-download-primary" type="button" disabled={transitioning || ownedByOther || running} onClick={onPrepare}>
              {transitioning ? <SpinnerGap className="spin" size={21} /> : <DownloadSimple size={21} weight="bold" />}
              {transitioning ? "Preparing encrypted gateway…" : running ? "VPN unavailable for this session" : "Prepare VPN & Start Lab"}
            </button>
          )}
          <p className="vpn-footnote">Requires OpenVPN Connect or another OpenVPN-compatible client. The profile expires when your lab session stops.</p>
        </div>
      </aside>
    </div>
  );
}

function ObjectiveWorkspace({ tab }) {
  return (
    <section className="main-panel objective-workspace">
      <div className="panel-heading">
        <div><span className="eyebrow">{tab === "info" ? "LAB REFERENCE" : "OBJECTIVES"}</span><h2>{tab === "info" ? "Inside the GOAD environment" : "Compromise the three realms"}</h2></div>
        <span className="difficulty">INTERMEDIATE</span>
      </div>
      <p>GOAD is a deliberately vulnerable Active Directory environment for practicing end-to-end enterprise attack paths. The full lab spans two forests, three domains, five Windows servers, domain trusts, SQL links, shared services, and intentionally delegated privileges.</p>
      <p>Start from the isolated operator desktop or your own machine through OpenVPN. Enumerate the network, validate relationships, chain weaknesses safely, and document how control can move from an initial foothold to each domain.</p>

      <div className="mission-grid">
        {OBJECTIVE_TRACKS.map(({ icon: Icon, title, copy }) => <div key={title}><Icon size={24} /><strong>{title}</strong><span>{copy}</span></div>)}
      </div>

      <section className="objective-section">
        <div className="objective-heading"><span>01</span><div><h3>Understand the full topology</h3><p>Use the official GOAD network map to orient each host, domain boundary, trust, group, and server role.</p></div></div>
        <a className="diagram-frame light-diagram" href="/assets/goad-schema.png" target="_blank" rel="noreferrer" aria-label="Open the full GOAD topology diagram">
          <img src="/assets/goad-schema.png" alt="Official GOAD topology showing five Windows servers across North, Seven Kingdoms, and Essos" />
          <span><LinkSimple size={16} /> Open full-size topology</span>
        </a>
        <div className="realm-summary-grid">
          <article><span>FOREST 01</span><h4>sevenkingdoms.local</h4><p>Kingslanding is the root domain controller, with North as a child domain containing Winterfell and Castelblack.</p></article>
          <article><span>CHILD DOMAIN</span><h4>north.sevenkingdoms.local</h4><p>Winterfell is the domain controller; Castelblack hosts IIS, MSSQL, SMB, and a trusted SQL link to Braavos.</p></article>
          <article><span>FOREST 02</span><h4>essos.local</h4><p>Meereen is the domain controller; Braavos hosts MSSQL, SMB, certificate-service scenarios, and the return trusted SQL link.</p></article>
        </div>
      </section>

      <section className="objective-section">
        <div className="objective-heading"><span>02</span><div><h3>Profile every server</h3><p>Verify host roles, operating systems, exposed services, administrative access, and relationships before attempting lateral movement.</p></div></div>
        <div className="server-objective-list">
          {MACHINES.map((machine) => (
            <article key={machine.name}>
              <span className="machine-icon"><Desktop size={19} /></span>
              <div><h4>{machine.name}</h4><p>{machine.role}</p></div>
              <div><strong>{machine.ip}</strong><span>{machine.os}</span></div>
            </article>
          ))}
        </div>
      </section>

      <section className="objective-section">
        <div className="objective-heading"><span>03</span><div><h3>Investigate users, groups, and delegated paths</h3><p>The objectives below cover every scenario family represented in the official GOAD reference. Treat them as areas to validate, not a prescribed solution order.</p></div></div>
        <div className="realm-objectives">
          {REALM_OBJECTIVES.map((realm, realmIndex) => (
            <details key={realm.realm} open={realmIndex === 0}>
              <summary><div><span>{String(realmIndex + 1).padStart(2, "0")}</span><strong>{realm.realm}</strong><small>{realm.summary}</small></div><CaretDown size={20} /></summary>
              <div className="realm-body">
                {realm.groups.map((group) => (
                  <article key={group.name}>
                    <div><h4>{group.name}</h4><span>{group.access}</span></div>
                    <ul>{group.paths.map((path) => <li key={path}>{path}</li>)}</ul>
                  </article>
                ))}
              </div>
            </details>
          ))}
        </div>
      </section>

      <section className="objective-section">
        <div className="objective-heading"><span>04</span><div><h3>Correlate complete compromise paths</h3><p>Compare your discoveries with the official relationship map as the environment opens up. It includes intended credential, ACL, delegation, SQL, certificate, and cross-forest paths.</p></div></div>
        <a className="diagram-frame dark-diagram" href="/assets/goad-compromise-paths.png" target="_blank" rel="noreferrer" aria-label="Open the full GOAD compromise-path diagram">
          <img src="/assets/goad-compromise-paths.png" alt="Official GOAD compromise path map across North, Seven Kingdoms, and Essos" />
          <span><LinkSimple size={16} /> Open full-size attack-path map</span>
        </a>
      </section>

      <div className="engagement">
        <h3>Rules of engagement</h3>
        <ul>
          <li>The lab network is isolated to <strong>192.168.56.0/24</strong>; access it only through the browser desktop or your session-specific VPN profile.</li>
          <li>Use the environment only for authorized practice. Do not expose internal Windows services directly to the public internet.</li>
          <li>Stop the lab when finished. Provisioned disks persist, while paid compute and the ephemeral VPN gateway scale to zero.</li>
        </ul>
      </div>
      <p className="source-note">Topology and scenario coverage are based on the <a href="https://orange-cyberdefense.github.io/GOAD/labs/GOAD/" target="_blank" rel="noreferrer">official Orange Cyberdefense GOAD documentation <LinkSimple size={14} /></a>.</p>
    </section>
  );
}

function StartPanel({ labStatus, loading, action, error, onStart, onStop, onReset, onExtend, onOpenVpn }) {
  const state = labStatus?.state || "stopped";
  const running = state === "running";
  const transitioning = state === "starting" || state === "stopping" || state === "resetting" || ["start", "stop", "reset"].includes(action);
  const ownedByMe = labStatus?.ownership === "mine";
  const ownedByOther = labStatus?.ownership === "other" || labStatus?.ownership === "external";
  const [clockNow, setClockNow] = useState(Date.now());
  const expiryMilliseconds = Number(labStatus?.session_expires_at || 0) * 1000;
  const remainingSeconds = expiryMilliseconds ? (expiryMilliseconds - clockNow) / 1000 : Number.NaN;

  useEffect(() => {
    if (!running || !expiryMilliseconds) return undefined;
    setClockNow(Date.now());
    const timer = window.setInterval(() => setClockNow(Date.now()), 1000);
    return () => window.clearInterval(timer);
  }, [running, expiryMilliseconds]);

  if (loading && !labStatus) {
    return <div className="control-card control-loading"><SpinnerGap className="spin" size={26} /><span>Checking lab availability…</span></div>;
  }

  return (
    <section className="control-card">
      <div className="control-main">
        <div className={`power-icon ${running ? "running" : transitioning ? "starting" : ""}`}>
          {transitioning ? <SpinnerGap className="spin" size={28} /> : <Power size={28} weight="bold" />}
        </div>
        <div className="control-copy">
          <span className="eyebrow">LAB CONTROL</span>
          <h2>{action === "reset" || state === "resetting" ? "Resetting all five GOAD machines…" : running ? "GOAD is online" : state === "starting" ? "Machine is starting. Please stand by…" : state === "stopping" ? "Stopping the lab safely…" : ownedByOther ? "Lab is currently in use" : "Your lab is offline"}</h2>
          <p>{running ? "The full five-machine lab is available from the browser desktop." : ownedByOther ? `${labStatus.owner_name || "Another operator"} currently owns this session.` : "Start all five machines together. Compute billing ends when you stop the lab."}</p>
        </div>
      </div>
      <div className="control-actions">
        {!running && !transitioning && !ownedByOther && <button className="primary start-button" onClick={onStart}><Play size={21} weight="fill" /> Start Lab</button>}
        {running && labStatus.desktop_url && <a className="primary" href={labStatus.desktop_url} target="_blank" rel="noreferrer"><Desktop size={21} /> Launch Lab Desktop</a>}
        <button className="vpn-button" type="button" onClick={onOpenVpn}><DownloadSimple size={20} /> Download VPN</button>
        {running && ownedByMe && (
          <div className="machine-controls" aria-label="Machine controls">
            <button className="machine-action stop-machine" type="button" onClick={onStop} disabled={Boolean(action)} aria-label="Stop machine" data-tooltip="Stop Machine">
              <StopCircle size={25} weight="bold" />
            </button>
            <button className="machine-action" type="button" onClick={onReset} disabled={Boolean(action)} aria-label="Reset machine" data-tooltip="Reset Machine">
              <ArrowCounterClockwise className={action === "reset" ? "spin" : ""} size={25} weight="bold" />
            </button>
            <button
              className="machine-action session-timer"
              type="button"
              onClick={onExtend}
              disabled={Boolean(action) || !labStatus.session_extend_available}
              aria-label={`Extend machine session. ${formatDuration(remainingSeconds)} remaining`}
              data-tooltip={labStatus.session_extend_available ? "Extend Machine" : "Available after restart"}
            >
              {action === "extend" ? <SpinnerGap className="spin" size={24} /> : <ClockCountdown size={25} weight="bold" />}
              <time aria-live="polite">{formatDuration(remainingSeconds)}</time>
            </button>
          </div>
        )}
      </div>
      {error && <div className="control-error">{error}</div>}
      {running && (
        <div className="target-strip">
          <div><span className="eyebrow">PRIMARY TARGET IP</span><strong><span className="target-pulse" />192.168.56.10</strong></div>
          <button onClick={() => navigator.clipboard?.writeText("192.168.56.10")}><Copy size={18} /> Copy</button>
          {labStatus.desktop_password && <div className="desktop-access"><LockKey size={20} /><span><small>Desktop password</small><strong>••••••••••</strong></span><button onClick={() => navigator.clipboard?.writeText(labStatus.desktop_password)}><Copy size={17} /> Copy password</button></div>}
          <div className="session-owner"><UsersThree size={20} /><span><small>Session owner</small><strong>{ownedByMe ? "You" : labStatus.owner_name || "External session"}</strong></span></div>
        </div>
      )}
    </section>
  );
}

function LabDetail({ user, onLogout, onBack }) {
  const [labStatus, setLabStatus] = useState(null);
  const [loading, setLoading] = useState(true);
  const [action, setAction] = useState("");
  const [error, setError] = useState("");
  const [tab, setTab] = useState("play");
  const [vpnOpen, setVpnOpen] = useState(false);
  const [vpnBusy, setVpnBusy] = useState(false);
  const [vpnError, setVpnError] = useState("");
  const closeVpn = useCallback(() => setVpnOpen(false), []);

  const refresh = useCallback(async (silent = false) => {
    if (!silent) setLoading(true);
    try {
      const value = await api("/api/labs/goad/status");
      setLabStatus(value);
      setError("");
    } catch (err) {
      setError(err.message);
    } finally {
      if (!silent) setLoading(false);
    }
  }, []);

  useEffect(() => {
    refresh();
    const timer = window.setInterval(() => refresh(true), 15000);
    return () => window.clearInterval(timer);
  }, [refresh]);

  async function runAction(name) {
    setAction(name);
    setError("");
    if (name === "start") setLabStatus((current) => ({ ...(current || {}), state: "starting", ownership: "mine" }));
    if (name === "stop") setLabStatus((current) => ({ ...(current || {}), state: "stopping" }));
    try {
      const value = await api(`/api/labs/goad/${name}`, { method: "POST", body: name === "start" ? JSON.stringify({ session_hours: 6 }) : "{}" });
      setLabStatus(value);
      return value;
    } catch (err) {
      setError(err.message);
      await refresh(true);
      return { error: err.message };
    } finally {
      setAction("");
    }
  }

  async function prepareAndDownloadVpn() {
    setVpnBusy(true);
    setVpnError("");
    const value = await runAction("start");
    if (value?.error) {
      setVpnError(value.error);
      setVpnBusy(false);
      return;
    }
    if (!value?.vpn_available) {
      setVpnError("The encrypted gateway is still preparing. Keep this panel open and try again shortly.");
      setVpnBusy(false);
      return;
    }
    try {
      await downloadVpnProfile();
    } catch (err) {
      setVpnError(err.message);
    } finally {
      setVpnBusy(false);
    }
  }

  const provisioned = labStatus?.provisioned !== false;

  return (
    <div className="app-shell">
      <Header user={user} onLogout={onLogout} onCatalog={onBack} />
      <main className="detail-page">
        <section className="lab-hero">
          <img src="/assets/goad-fortress.png" alt="GOAD fortress network" />
          <div className="hero-overlay" />
          <div className="hero-content page-width">
            <button className="back-button" onClick={onBack}><ArrowLeft size={18} /> Back to labs</button>
            <div className="hero-title"><span className="hero-crest"><ShieldCheck size={44} weight="duotone" /></span><div><span className="eyebrow lime">FULL ACTIVE DIRECTORY LAB</span><h1>GOAD</h1><p>Game of Active Directory</p></div></div>
            <div className="hero-rating"><Star size={21} weight="fill" /><strong>5 VMs</strong><span>2 forests · 3 domains</span></div>
          </div>
        </section>
        <div className="tabs"><div className="page-width"><button className={tab === "play" ? "active" : ""} onClick={() => setTab("play")}>Play Lab</button><button className={tab === "info" ? "active" : ""} onClick={() => setTab("info")}>Lab Info</button></div></div>
        <div className="detail-content page-width">
          <StartPanel
            labStatus={labStatus}
            loading={loading}
            action={action}
            error={error}
            onStart={() => runAction("start")}
            onStop={() => runAction("stop")}
            onReset={() => runAction("reset")}
            onExtend={() => runAction("extend")}
            onOpenVpn={() => { setVpnError(""); setVpnOpen(true); }}
          />
          <div className="detail-columns">
            <ObjectiveWorkspace tab={tab} />
            <aside>
              <section className="side-panel">
                <div className="side-heading"><div><span className="eyebrow">TOPOLOGY</span><h2>Machines</h2></div><span>{MACHINES.length}</span></div>
                <div className="machine-list">
                  {MACHINES.map((machine) => <div className="machine" key={machine.name}><span className="machine-icon"><Desktop size={19} /></span><div><strong>{machine.name}</strong><small>{machine.role}</small></div><span><strong>{machine.ip}</strong><small>{machine.os}</small></span></div>)}
                </div>
              </section>
              <section className="side-panel readiness">
                <span className="eyebrow">DEPLOYMENT</span><h3><CheckCircle size={21} weight="fill" /> {provisioned ? "Provisioned and ready" : "Provisioning required"}</h3><p>The complete GOAD configuration and Windows disks are stored in Modal.</p>
                <div><Clock size={18} /><span><small>Session limit</small><strong>Up to 23 hours</strong></span></div>
                <div><GlobeHemisphereWest size={18} /><span><small>Access</small><strong>Encrypted noVNC</strong></span></div>
              </section>
            </aside>
          </div>
        </div>
      </main>
      <VpnDrawer
        open={vpnOpen}
        onClose={closeVpn}
        labStatus={labStatus}
        busy={vpnBusy}
        error={vpnError}
        onPrepare={prepareAndDownloadVpn}
      />
    </div>
  );
}

export function App() {
  const [user, setUser] = useState(null);
  const [checking, setChecking] = useState(true);
  const initialView = useMemo(() => window.location.pathname.includes("/labs/goad") ? "goad" : "catalog", []);
  const [view, setView] = useState(initialView);

  useEffect(() => {
    api("/api/auth/me").then((result) => setUser(result.user)).catch(() => setUser(null)).finally(() => setChecking(false));
  }, []);

  function navigate(next) {
    const path = next === "goad" ? "/labs/goad" : "/labs";
    window.history.pushState({}, "", path);
    setView(next);
    window.scrollTo(0, 0);
  }

  async function logout() {
    await api("/api/auth/logout", { method: "POST", body: "{}" }).catch(() => {});
    setUser(null);
    setView("catalog");
  }

  if (checking) return <main className="splash"><Logo /><SpinnerGap className="spin" size={28} /></main>;
  if (!user) return <AuthPage onAuth={setUser} />;
  if (view === "goad") return <LabDetail user={user} onLogout={logout} onBack={() => navigate("catalog")} />;
  return <Catalog user={user} onLogout={logout} onOpenLab={(slug) => slug === "goad" && navigate("goad")} />;
}
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>goad-portal/src/main.jsx</code> — <code>832f752c6b6a454a26dbc4f2654f5bd633f2b103516c8c0abac648308c133a7e</code></summary>

<!-- BUNDLE-FILE path="goad-portal/src/main.jsx" sha256="832f752c6b6a454a26dbc4f2654f5bd633f2b103516c8c0abac648308c133a7e" -->
``````jsx
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.jsx";
import "./styles.css";

createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>goad-portal/src/styles.css</code> — <code>469f5d65f19691323986f2f6697512dc3e1d7338b5aba15541e65c3cc1cc14be</code></summary>

<!-- BUNDLE-FILE path="goad-portal/src/styles.css" sha256="469f5d65f19691323986f2f6697512dc3e1d7338b5aba15541e65c3cc1cc14be" -->
``````css
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Manrope:wght@600;700;800&display=swap");

:root {
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  color: #f3f7fc;
  background: #0b1626;
  font-synthesis: none;
  --bg: #0b1626;
  --surface: #111e30;
  --surface-2: #172438;
  --surface-3: #1d2a3e;
  --line: #27364a;
  --muted: #91a3bd;
  --muted-2: #667890;
  --lime: #9cff00;
  --red: #ff626e;
}

* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--bg); -webkit-font-smoothing: antialiased; }
button, input { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
a { color: inherit; text-decoration: none; }
h1, h2, h3, p { margin-top: 0; }
h1, h2, h3 { font-family: Manrope, Inter, sans-serif; }
.page-width { width: min(1870px, calc(100% - 64px)); margin-inline: auto; }
.eyebrow { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 11px; font-weight: 700; letter-spacing: .11em; }
.lime { color: var(--lime); }
.spin { animation: spin .85s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }

.brand { display: inline-flex; align-items: center; gap: 9px; }
.brand-logo { width: 188px; height: auto; display: block; }
.brand-suffix { padding-left: 9px; border-left: 1px solid #40516a; color: var(--lime); font-size: 8px; font-weight: 800; letter-spacing: .18em; line-height: 1; }
.auth-brand .brand-logo { width: 218px; }
.splash { min-height: 100vh; display: grid; place-content: center; justify-items: center; gap: 28px; background: radial-gradient(circle at 50% 25%, #172940, var(--bg) 55%); }
.splash > svg { color: var(--lime); }

.auth-page { min-height: 100vh; display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(440px, .85fr); background: #0a1422; }
.auth-visual { position: relative; min-height: 720px; overflow: hidden; background: url('/assets/goad-fortress.png') center/cover no-repeat; }
.auth-shade { position: absolute; inset: 0; background: linear-gradient(90deg, rgba(7,15,26,.42), rgba(7,15,26,.14)), linear-gradient(0deg, rgba(7,15,26,.96) 0%, rgba(7,15,26,.15) 65%); }
.auth-brand { position: absolute; left: 56px; top: 42px; }
.auth-copy { position: absolute; left: 56px; right: 80px; bottom: 62px; max-width: 700px; }
.auth-copy h1 { margin: 15px 0 18px; max-width: 650px; font-size: clamp(46px, 5vw, 78px); line-height: .98; letter-spacing: -.055em; }
.auth-copy > p { max-width: 650px; color: #b2bfd1; font-size: 16px; line-height: 1.75; }
.auth-stats { display: flex; gap: 14px; margin-top: 30px; }
.auth-stats span { min-width: 128px; padding: 15px 17px; color: var(--muted); border: 1px solid rgba(255,255,255,.13); background: rgba(15,29,47,.72); backdrop-filter: blur(12px); border-radius: 7px; font-size: 12px; }
.auth-stats strong { color: white; font-size: 22px; margin-right: 5px; }
.auth-panel { display: grid; place-items: center; padding: 50px; border-left: 1px solid var(--line); background: #0f1a2a; }
.auth-box { width: min(100%, 430px); }
.auth-box > .brand { display: none; }
.auth-heading { margin: 55px 0 34px; }
.auth-heading h2 { margin: 12px 0 8px; font-size: 34px; letter-spacing: -.035em; }
.auth-heading p { color: var(--muted); line-height: 1.6; }
.auth-box form { display: grid; gap: 19px; }
.auth-box label { display: grid; gap: 9px; color: #c7d1df; font-size: 13px; font-weight: 600; }
.auth-box input { width: 100%; padding: 15px 16px; color: white; outline: 0; border: 1px solid var(--line); border-radius: 7px; background: #172337; transition: border-color .2s, box-shadow .2s; }
.auth-box input::placeholder { color: #5f7189; }
.auth-box input:focus { border-color: var(--lime); box-shadow: 0 0 0 3px rgba(156,255,0,.09); }
.form-error, .control-error { padding: 12px 14px; color: #ffb5ba; border: 1px solid rgba(255,98,110,.35); border-radius: 6px; background: rgba(255,98,110,.08); font-size: 13px; }
.auth-switch { margin: 28px 0 0; color: var(--muted); text-align: center; font-size: 13px; }
.auth-switch button { padding: 5px 7px; border: 0; color: var(--lime); background: transparent; cursor: pointer; font-weight: 700; }
.primary { min-height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 10px; padding: 0 19px; border: 0; border-radius: 6px; color: #0c1726; background: var(--lime); font-weight: 800; cursor: pointer; transition: transform .16s, filter .16s; }
.primary:hover { filter: brightness(1.08); transform: translateY(-1px); }
.primary:disabled { cursor: wait; opacity: .65; }
.primary.full { width: 100%; margin-top: 4px; }

.topbar { height: 76px; display: flex; align-items: center; padding: 0 32px; gap: 48px; border-bottom: 1px solid var(--line); background: #0d1929; position: sticky; z-index: 30; top: 0; }
.brand-button { border: 0; padding: 0; background: transparent; cursor: pointer; }
.topbar nav { height: 100%; display: flex; align-items: stretch; }
.topbar nav button { display: inline-flex; align-items: center; gap: 8px; padding: 0 16px; border: 0; border-bottom: 2px solid var(--lime); color: white; background: transparent; cursor: pointer; font-weight: 600; }
.account { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.avatar { width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid #557220; border-radius: 50%; color: var(--lime); background: #213415; font-weight: 800; }
.account > span:nth-child(2) { display: flex; flex-direction: column; min-width: 140px; }
.account strong { font-size: 12px; }
.account small { max-width: 150px; color: var(--muted); font-size: 10px; overflow: hidden; text-overflow: ellipsis; }
.icon-button { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); background: var(--surface); cursor: pointer; }

.catalog-page { padding: 62px 0 90px; }
.catalog-heading { display: flex; justify-content: space-between; gap: 30px; align-items: end; margin-bottom: 42px; }
.catalog-heading h1 { margin: 11px 0 10px; font-size: 44px; letter-spacing: -.045em; }
.catalog-heading p { margin: 0; max-width: 690px; color: var(--muted); line-height: 1.65; }
.availability { min-width: 220px; display: grid; grid-template-columns: 10px 1fr; column-gap: 10px; padding: 15px 17px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface); }
.availability .status-dot { grid-row: 1 / 3; align-self: center; }
.availability strong { font-size: 12px; }
.availability small { color: var(--muted); font-size: 10px; margin-top: 3px; }
.status-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--lime); box-shadow: 0 0 0 5px rgba(156,255,0,.08); }
.filter-row { display: flex; gap: 6px; margin-bottom: 22px; }
.filter-row button { padding: 10px 13px; border: 1px solid transparent; border-radius: 5px; color: var(--muted); background: transparent; cursor: pointer; font-size: 12px; font-weight: 600; }
.filter-row button span { margin-left: 5px; color: var(--muted-2); }
.filter-row .filter-active { border-color: var(--line); color: white; background: var(--surface-2); }
.filter-row .filter-active span { color: var(--lime); }
.lab-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 22px; }
.lab-card { overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-2); transition: transform .2s, border-color .2s, box-shadow .2s; }
.lab-card.available { cursor: pointer; }
.lab-card.available:hover { transform: translateY(-4px); border-color: #5a7530; box-shadow: 0 16px 38px rgba(0,0,0,.24); }
.lab-card.locked { opacity: .77; }
.lab-art { position: relative; aspect-ratio: 16 / 8.5; overflow: hidden; margin: 14px 14px 0; border-radius: 7px; background: #101b2c; }
.lab-art img { width: 100%; height: 100%; display: block; object-fit: cover; }
.lab-art::after { content: ""; position: absolute; inset: 0; background: linear-gradient(0deg, rgba(8,17,29,.42), transparent 60%); }
.future-art { width: 100%; height: 100%; display: grid; place-items: center; color: #4c5f78; background: radial-gradient(circle at 50% 42%, #26354a 0, #162235 46%, #101a2a 100%); }
.future-art::before, .future-art::after { content: ""; position: absolute; width: 1px; height: 130%; background: #2a3a50; transform: rotate(58deg); }
.future-art::after { transform: rotate(-58deg); }
.future-art svg { position: relative; z-index: 1; }
.card-state { position: absolute; z-index: 2; top: 13px; right: 13px; padding: 7px 9px; border-radius: 4px; font-size: 9px; font-weight: 800; letter-spacing: .1em; }
.card-state.live { color: #1a2800; background: var(--lime); }
.card-state.soon { color: #aab7c9; border: 1px solid #3a4a5f; background: rgba(11,22,38,.84); }
.card-body { padding: 20px; }
.card-body h3 { margin: 15px 0 6px; font-size: 23px; letter-spacing: -.025em; }
.card-body > p { margin: 0; color: var(--muted); font-size: 13px; }
.card-divider { height: 1px; margin: 20px 0 14px; background: var(--line); }
.card-meta { min-height: 30px; display: flex; align-items: center; gap: 18px; color: var(--muted); font-size: 11px; }
.card-meta span { display: inline-flex; align-items: center; gap: 6px; }
.card-meta button { width: 30px; height: 30px; display: grid; place-items: center; margin-left: auto; border: 0; border-radius: 5px; color: #0d1828; background: var(--lime); cursor: pointer; }
.card-meta > svg { margin-left: auto; }

.lab-hero { height: 330px; position: relative; overflow: hidden; }
.lab-hero > img { width: 100%; height: 100%; object-fit: cover; object-position: center 46%; filter: saturate(.75) brightness(.72); }
.hero-overlay { position: absolute; inset: 0; background: linear-gradient(90deg, #0b1626 0%, rgba(11,22,38,.7) 40%, rgba(11,22,38,.36)), linear-gradient(0deg, #0b1626 0%, transparent 44%); }
.hero-content { position: absolute; inset: 0; display: flex; align-items: flex-end; padding-bottom: 42px; }
.back-button { position: absolute; top: 26px; left: 0; display: inline-flex; align-items: center; gap: 8px; padding: 10px 13px; border: 1px solid rgba(255,255,255,.13); border-radius: 5px; color: #dbe3ee; background: rgba(14,27,44,.72); backdrop-filter: blur(8px); cursor: pointer; }
.hero-title { display: flex; align-items: center; gap: 19px; }
.hero-crest { width: 72px; height: 72px; display: grid; place-items: center; border: 1px solid #536a2d; border-radius: 16px; color: var(--lime); background: rgba(25,45,27,.82); }
.hero-title h1 { margin: 7px 0 0; font-size: 46px; line-height: 1; letter-spacing: -.05em; }
.hero-title p { margin: 8px 0 0; color: #a5b5c9; }
.hero-rating { display: grid; grid-template-columns: 22px auto; gap: 2px 8px; margin-left: auto; align-items: center; }
.hero-rating svg { grid-row: 1 / 3; color: #ffd447; }
.hero-rating strong { font-size: 14px; }
.hero-rating span { color: var(--muted); font-size: 11px; }
.tabs { height: 62px; border-bottom: 1px solid var(--line); background: #0c1828; }
.tabs .page-width { height: 100%; display: flex; gap: 28px; }
.tabs button { padding: 0 12px; border: 0; border-bottom: 2px solid transparent; color: var(--muted); background: transparent; cursor: pointer; font-size: 12px; font-weight: 700; }
.tabs button.active { color: white; border-color: var(--lime); }
.detail-content { padding: 28px 0 90px; }
.control-card { overflow: hidden; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 24px; padding: 25px; border: 1px solid #30425a; border-radius: 8px; background: var(--surface-2); }
.control-loading { min-height: 130px; grid-template-columns: auto 1fr; justify-content: start; color: var(--muted); }
.control-loading svg { color: var(--lime); }
.control-main { display: flex; align-items: center; gap: 18px; }
.power-icon { flex: 0 0 auto; width: 64px; height: 64px; display: grid; place-items: center; border-radius: 12px; color: var(--muted); background: #202e42; }
.power-icon.running { color: var(--lime); background: #263b15; }
.power-icon.starting { color: var(--lime); background: #2c4312; }
.control-copy h2 { margin: 7px 0 6px; font-size: 22px; letter-spacing: -.025em; }
.control-copy p { margin: 0; color: var(--muted); line-height: 1.45; font-size: 12px; }
.control-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 10px; }
.start-button { min-width: 164px; min-height: 54px; font-size: 15px; }
.vpn-button { min-height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 16px; border: 1px solid #536a2d; border-radius: 6px; color: var(--lime); background: #1d2e16; cursor: pointer; font-weight: 700; white-space: nowrap; }
.vpn-button:hover { filter: brightness(1.1); }
.vpn-button:disabled { cursor: not-allowed; color: var(--muted); border-color: var(--line); background: var(--surface); filter: none; }
.machine-controls { display: grid; grid-template-columns: 56px 56px minmax(150px, auto); gap: 8px; padding-top: 36px; margin-top: -36px; }
.machine-action { position: relative; height: 56px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #2c3c52; border-radius: 6px; color: #f3f7fc; background: #202d42; cursor: pointer; transition: border-color .16s, background .16s, transform .16s; }
.machine-action:hover:not(:disabled), .machine-action:focus-visible { border-color: #51627a; background: #29364b; transform: translateY(-1px); }
.machine-action:focus-visible { outline: 2px solid var(--lime); outline-offset: 2px; }
.machine-action:disabled { cursor: not-allowed; opacity: .68; }
.machine-action::after { content: attr(data-tooltip); position: absolute; z-index: 10; left: 50%; bottom: calc(100% + 9px); width: max-content; max-width: 210px; padding: 8px 10px; border-radius: 4px; color: #f4f7fb; background: #344157; box-shadow: 0 8px 20px rgba(0,0,0,.22); font-size: 12px; font-weight: 500; line-height: 1.2; opacity: 0; pointer-events: none; transform: translate(-50%, 4px); transition: opacity .14s, transform .14s; }
.machine-action:hover::after, .machine-action:focus-visible::after { opacity: 1; transform: translate(-50%, 0); }
.stop-machine { color: var(--red); }
.session-timer { min-width: 150px; justify-content: flex-start; gap: 10px; padding: 0 15px; background: #344157; }
.session-timer time { min-width: 78px; font-family: Manrope, Inter, sans-serif; font-size: 15px; font-weight: 700; letter-spacing: .015em; font-variant-numeric: tabular-nums; }
.control-error { grid-column: 1 / -1; }
.target-strip { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(250px, 1fr) auto minmax(255px, auto) minmax(150px, auto); align-items: center; gap: 15px; margin: 2px -25px -25px; padding: 19px 25px; border-top: 1px solid var(--line); background: #111d2e; }
.target-strip > div:first-child { display: grid; gap: 8px; }
.target-strip > div:first-child strong { display: flex; align-items: center; gap: 10px; font-family: Manrope, sans-serif; font-size: 28px; letter-spacing: .02em; }
.target-pulse { width: 13px; height: 13px; border: 4px solid var(--lime); border-radius: 50%; box-shadow: 0 0 0 6px rgba(156,255,0,.25); }
.target-strip > button { display: inline-flex; align-items: center; gap: 7px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 5px; color: var(--muted); background: var(--surface-2); cursor: pointer; }
.desktop-access { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; padding-left: 20px; border-left: 1px solid var(--line); color: var(--muted); }
.desktop-access > span { display: grid; gap: 3px; }
.desktop-access small { color: var(--muted); }
.desktop-access strong { color: white; font-size: 12px; letter-spacing: .12em; }
.desktop-access button { display: inline-flex; align-items: center; gap: 6px; padding: 8px 10px; border: 1px solid #43552b; border-radius: 5px; color: var(--lime); background: #1d2e16; cursor: pointer; font-size: 10px; font-weight: 700; }
.session-owner { display: flex; align-items: center; gap: 10px; padding-left: 20px; border-left: 1px solid var(--line); color: var(--muted); }
.session-owner > span { display: grid; gap: 3px; }
.session-owner small { color: var(--muted); }
.session-owner strong { color: white; font-size: 12px; }

.detail-columns { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(350px, .8fr); gap: 20px; margin-top: 20px; align-items: start; }
.main-panel, .side-panel { border: 1px solid var(--line); border-radius: 8px; background: var(--surface); }
.main-panel { padding: 28px; }
.panel-heading { display: flex; justify-content: space-between; align-items: start; gap: 20px; margin-bottom: 22px; }
.panel-heading h2 { margin: 8px 0 0; font-size: 25px; }
.difficulty { padding: 7px 9px; border: 1px solid #536a2d; border-radius: 4px; color: var(--lime); background: #1f3214; font-size: 9px; font-weight: 800; letter-spacing: .08em; }
.main-panel > p { color: var(--muted); font-size: 14px; line-height: 1.75; }
.mission-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 25px 0; }
.mission-grid > div { display: grid; min-height: 150px; align-content: start; padding: 18px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-2); }
.mission-grid svg { color: var(--lime); margin-bottom: 18px; }
.mission-grid strong { margin-bottom: 7px; font-size: 13px; }
.mission-grid span { color: var(--muted); font-size: 12px; line-height: 1.55; }
.engagement { padding-top: 22px; border-top: 1px solid var(--line); }
.engagement h3 { font-size: 15px; }
.engagement ul { margin: 0; padding-left: 18px; color: var(--muted); font-size: 12px; line-height: 1.8; }
.engagement strong { color: white; }
.detail-columns aside { display: grid; gap: 20px; }
.side-panel { padding: 22px; }
.side-heading { display: flex; align-items: end; justify-content: space-between; margin-bottom: 17px; }
.side-heading h2 { margin: 7px 0 0; font-size: 20px; }
.side-heading > span { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 5px; color: var(--lime); background: #223515; font-size: 12px; font-weight: 800; }
.machine-list { border-top: 1px solid var(--line); }
.machine { display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 10px; padding: 14px 0; border-bottom: 1px solid var(--line); }
.machine:last-child { border-bottom: 0; }
.machine-icon { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 5px; color: var(--lime); background: #213316; }
.machine > div, .machine > span:last-child { display: grid; gap: 4px; }
.machine strong { font-size: 11px; letter-spacing: .02em; }
.machine small { color: var(--muted); font-size: 9px; }
.machine > span:last-child { text-align: right; }
.readiness h3 { display: flex; align-items: center; gap: 8px; margin: 10px 0; font-size: 15px; }
.readiness h3 svg { color: var(--lime); }
.readiness > p { color: var(--muted); font-size: 11px; line-height: 1.6; }
.readiness > div { display: flex; gap: 10px; align-items: center; padding: 12px 0; border-top: 1px solid var(--line); color: var(--muted); }
.readiness > div span { display: grid; gap: 3px; }
.readiness > div small { color: var(--muted); font-size: 9px; }
.readiness > div strong { color: white; font-size: 11px; }

.objective-workspace { min-width: 0; }
.objective-section { padding: 30px 0; border-top: 1px solid var(--line); }
.objective-heading { display: grid; grid-template-columns: 38px 1fr; gap: 14px; align-items: start; margin-bottom: 20px; }
.objective-heading > span { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 5px; color: var(--lime); background: #213416; font-family: Manrope, sans-serif; font-size: 10px; font-weight: 800; }
.objective-heading h3 { margin: 1px 0 5px; font-size: 17px; }
.objective-heading p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.55; }
.diagram-frame { position: relative; display: block; overflow: hidden; border: 1px solid var(--line); border-radius: 7px; background: #08111d; }
.diagram-frame img { width: 100%; height: auto; display: block; }
.diagram-frame > span { position: absolute; right: 12px; bottom: 12px; display: inline-flex; align-items: center; gap: 6px; padding: 8px 10px; border: 1px solid rgba(255,255,255,.18); border-radius: 5px; color: white; background: rgba(8,17,29,.85); backdrop-filter: blur(8px); font-size: 10px; font-weight: 700; }
.light-diagram { background: #fff; }
.dark-diagram { background: #000; }
.realm-summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 12px; }
.realm-summary-grid article { min-width: 0; padding: 16px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-2); }
.realm-summary-grid article > span { color: var(--lime); font-size: 8px; font-weight: 800; letter-spacing: .12em; }
.realm-summary-grid h4 { margin: 8px 0 6px; overflow-wrap: anywhere; font-family: Manrope, sans-serif; font-size: 12px; }
.realm-summary-grid p { margin: 0; color: var(--muted); font-size: 10px; line-height: 1.55; }
.server-objective-list { overflow: hidden; border: 1px solid var(--line); border-radius: 7px; }
.server-objective-list article { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 13px 15px; border-bottom: 1px solid var(--line); background: var(--surface-2); }
.server-objective-list article:last-child { border-bottom: 0; }
.server-objective-list h4 { margin: 0 0 4px; font-size: 12px; }
.server-objective-list p { margin: 0; color: var(--muted); font-size: 10px; }
.server-objective-list article > div:last-child { display: grid; gap: 4px; text-align: right; }
.server-objective-list article > div:last-child strong { font-size: 11px; }
.server-objective-list article > div:last-child span { color: var(--muted); font-size: 9px; }
.realm-objectives { display: grid; gap: 9px; }
.realm-objectives details { overflow: hidden; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-2); }
.realm-objectives summary { min-height: 68px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 14px 16px; cursor: pointer; list-style: none; }
.realm-objectives summary::-webkit-details-marker { display: none; }
.realm-objectives summary > div { min-width: 0; display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 3px 9px; align-items: center; }
.realm-objectives summary > div > span { grid-row: 1 / 3; width: 26px; height: 26px; display: grid; place-items: center; border-radius: 4px; color: var(--lime); background: #263a19; font-size: 9px; font-weight: 800; }
.realm-objectives summary strong { overflow-wrap: anywhere; font-size: 12px; }
.realm-objectives summary small { color: var(--muted); font-size: 9px; line-height: 1.4; }
.realm-objectives summary > svg { flex: 0 0 auto; color: var(--muted); transition: transform .2s; }
.realm-objectives details[open] summary > svg { transform: rotate(180deg); }
.realm-body { display: grid; gap: 1px; padding: 1px; border-top: 1px solid var(--line); background: var(--line); }
.realm-body article { display: grid; grid-template-columns: minmax(140px, .32fr) minmax(0, 1fr); gap: 18px; padding: 16px; background: #131f31; }
.realm-body h4 { margin: 0 0 5px; font-size: 12px; }
.realm-body article > div span { color: var(--lime); font-size: 9px; line-height: 1.4; }
.realm-body ul { margin: 0; padding-left: 17px; color: var(--muted); font-size: 10px; line-height: 1.65; }
.source-note { margin: 20px 0 0 !important; font-size: 10px !important; }
.source-note a { display: inline-flex; align-items: center; gap: 4px; color: var(--lime); font-weight: 700; }

.vpn-overlay { position: fixed; z-index: 100; inset: 0; display: flex; justify-content: flex-end; background: rgba(3,8,15,.7); backdrop-filter: blur(3px); animation: vpn-fade .18s ease-out; }
.vpn-drawer { width: min(610px, 100%); height: 100%; overflow-y: auto; border-left: 1px solid #334258; background: #0d1929; box-shadow: -24px 0 70px rgba(0,0,0,.4); animation: vpn-slide .24s ease-out; }
.vpn-drawer-header { position: sticky; z-index: 3; top: 0; height: 90px; display: grid; grid-template-columns: 48px minmax(0, 1fr) 48px; align-items: center; gap: 10px; padding: 0 38px; background: #0d1929; }
.vpn-drawer-header h2 { margin: 0; font-family: Inter, sans-serif; font-size: 20px; font-weight: 600; text-align: center; letter-spacing: -.02em; }
.vpn-drawer-header button { width: 42px; height: 42px; display: grid; place-items: center; border: 0; color: var(--muted); background: transparent; cursor: pointer; }
.vpn-drawer-header button:hover { color: white; }
.vpn-status-band { height: 54px; display: flex; align-items: center; padding: 0 44px; color: #ff454f; background: #3a1c28; font-size: 13px; font-weight: 700; letter-spacing: .08em; }
.vpn-status-band.is-online { color: var(--lime); background: #213319; }
.vpn-status-band.is-starting { color: #ffd85c; background: #382d18; }
.vpn-keyhole { position: relative; z-index: 2; width: 78px; height: 78px; display: grid; place-items: center; margin: -39px auto 23px; border: 4px solid #db292f; border-radius: 50%; color: #ff4b54; background: #102033; }
.vpn-keyhole.is-online { border-color: #6fbf21; color: var(--lime); }
.vpn-setup-card { width: calc(100% - 96px); display: grid; gap: 20px; margin: 0 auto 46px; padding: 32px; border: 1px solid #1e2b3e; border-radius: 6px; background: #172438; }
.vpn-intro { text-align: center; }
.vpn-intro h3 { margin: 0 0 8px; font-size: 16px; }
.vpn-intro p { margin: 0 auto; max-width: 390px; color: var(--muted); font-size: 13px; line-height: 1.55; }
.vpn-field { display: grid; gap: 8px; color: var(--muted); font-size: 13px; }
.vpn-select-wrap { position: relative; }
.vpn-select-wrap select { width: 100%; height: 62px; padding: 0 48px 0 18px; appearance: none; border: 1px solid transparent; border-radius: 6px; color: #f4f7fb; opacity: 1; background: #1d2a3e; font: inherit; font-size: 16px; -webkit-text-fill-color: #f4f7fb; }
.vpn-select-wrap svg { position: absolute; top: 50%; right: 18px; color: var(--muted); pointer-events: none; transform: translateY(-50%); }
.vpn-notice { display: grid; grid-template-columns: auto 1fr; gap: 12px; padding: 19px; border-radius: 6px; }
.vpn-notice > svg { margin-top: 1px; }
.vpn-notice strong { font-size: 14px; }
.vpn-notice p { margin: 6px 0 0; font-size: 12px; line-height: 1.65; }
.vpn-notice.is-warning { color: #ff6971; background: #490c10; }
.vpn-notice.is-ready { color: #bfff59; background: #1f3812; }
.vpn-notice.is-ready p, .vpn-notice.is-warning p { color: inherit; }
.vpn-protocol { display: flex; align-items: center; flex-wrap: wrap; gap: 14px 24px; margin: 0; padding: 0; border: 0; }
.vpn-protocol legend { width: 100%; margin-bottom: 2px; color: var(--muted); font-size: 13px; }
.vpn-protocol label { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; }
.vpn-protocol input { width: 19px; height: 19px; margin: 0; accent-color: var(--lime); }
.vpn-protocol label.disabled { color: var(--muted-2); }
.vpn-protocol small { color: var(--muted-2); font-size: 9px; }
.vpn-download-primary { min-height: 56px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 0; border-radius: 5px; color: #0a1522; background: var(--lime); cursor: pointer; font-weight: 800; }
.vpn-download-primary:hover:not(:disabled) { filter: brightness(1.07); }
.vpn-download-primary:disabled { cursor: not-allowed; color: #738399; background: #26344a; }
.vpn-error { padding: 11px 13px; border: 1px solid rgba(255,98,110,.4); border-radius: 5px; color: #ffb4ba; background: rgba(255,98,110,.08); font-size: 11px; line-height: 1.5; }
.vpn-footnote { margin: -6px 0 0; color: var(--muted-2); font-size: 9px; line-height: 1.5; text-align: center; }
@keyframes vpn-fade { from { opacity: 0; } }
@keyframes vpn-slide { from { transform: translateX(36px); opacity: .7; } }

@media (max-width: 1100px) {
  .lab-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .detail-columns { grid-template-columns: 1fr; }
  .detail-columns aside { grid-template-columns: 1.5fr 1fr; }
  .auth-page { grid-template-columns: 1fr 480px; }
  .auth-copy h1 { font-size: 50px; }
  .realm-summary-grid { grid-template-columns: 1fr; }
}

@media (max-width: 1550px) and (min-width: 1101px) {
  .lab-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

@media (max-width: 1450px) {
  .control-card { grid-template-columns: 1fr; }
  .control-actions { justify-content: flex-start; }
}

@media (max-width: 780px) {
  .page-width { width: min(100% - 32px, 620px); }
  .auth-page { display: block; }
  .auth-visual { min-height: 300px; }
  .auth-brand { left: 24px; top: 24px; }
  .auth-brand .brand-logo { width: 180px; }
  .auth-copy { left: 24px; right: 24px; bottom: 28px; }
  .auth-copy h1 { font-size: 37px; }
  .auth-copy > p, .auth-stats { display: none; }
  .auth-panel { min-height: calc(100vh - 300px); padding: 35px 24px; }
  .auth-heading { margin-top: 10px; }
  .topbar { padding: 0 16px; gap: 16px; }
  .topbar .brand { gap: 6px; }
  .topbar .brand-logo { width: 128px; }
  .topbar .brand-suffix { padding-left: 6px; font-size: 7px; }
  .topbar nav, .account > span:nth-child(2) { display: none; }
  .account { margin-left: auto; }
  .catalog-page { padding-top: 38px; }
  .catalog-heading { display: grid; align-items: start; }
  .catalog-heading h1 { font-size: 36px; }
  .availability { min-width: 0; width: 100%; }
  .lab-grid { grid-template-columns: 1fr; }
  .filter-row { overflow-x: auto; }
  .lab-hero { height: 300px; }
  .hero-content { padding-bottom: 30px; }
  .hero-rating { display: none; }
  .hero-title h1 { font-size: 38px; }
  .hero-crest { width: 60px; height: 60px; }
  .control-card { grid-template-columns: 1fr; }
  .control-actions { width: 100%; display: grid; grid-template-columns: 1fr; }
  .control-actions > * { width: 100%; flex: none; }
  .machine-controls { grid-template-columns: 56px 56px minmax(0, 1fr); margin-top: 0; }
  .target-strip { grid-template-columns: 1fr auto; }
  .desktop-access { grid-column: 1 / -1; padding: 14px 0 0; border-left: 0; border-top: 1px solid var(--line); }
  .session-owner { grid-column: 1 / -1; padding: 14px 0 0; border-left: 0; border-top: 1px solid var(--line); }
  .mission-grid { grid-template-columns: 1fr; }
  .detail-columns aside { grid-template-columns: 1fr; }
  .main-panel { padding: 21px; }
  .objective-heading { grid-template-columns: 34px 1fr; }
  .diagram-frame { margin-inline: -6px; }
  .diagram-frame img { min-width: 720px; }
  .diagram-frame { overflow-x: auto; }
  .diagram-frame > span { position: sticky; left: 12px; float: left; margin: -48px 0 12px 12px; }
  .server-objective-list article { grid-template-columns: 34px minmax(0, 1fr); }
  .server-objective-list article > div:last-child { grid-column: 2; text-align: left; }
  .realm-body article { grid-template-columns: 1fr; gap: 10px; }
  .vpn-drawer-header { height: 78px; padding: 0 14px; }
  .vpn-drawer-header h2 { font-size: 16px; }
  .vpn-status-band { padding-inline: 25px; }
  .vpn-setup-card { width: calc(100% - 30px); padding: 24px 20px; }
  .vpn-select-wrap select { height: 56px; font-size: 14px; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>goad-portal/scripts/prepare-sites-build.mjs</code> — <code>b6a6adaa4fab3234676116dd1c9cb6611275ab9d92dd26f5bf402393e3744bf6</code></summary>

<!-- BUNDLE-FILE path="goad-portal/scripts/prepare-sites-build.mjs" sha256="b6a6adaa4fab3234676116dd1c9cb6611275ab9d92dd26f5bf402393e3744bf6" -->
``````javascript
#!/usr/bin/env node
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const dist = path.join(root, "dist");
const index = path.join(dist, "client", "index.html");
const worker = path.join(root, "worker", "index.js");
const hosting = path.join(root, ".openai", "hosting.json");

for (const file of [index, worker, hosting]) {
  if (!existsSync(file)) throw new Error("Missing Sites build input: " + file);
}

mkdirSync(path.join(dist, "server"), { recursive: true });
mkdirSync(path.join(dist, ".openai"), { recursive: true });
copyFileSync(worker, path.join(dist, "server", "index.js"));
copyFileSync(hosting, path.join(dist, ".openai", "hosting.json"));

console.log("Prepared Sites build: dist/server/index.js and dist/.openai/hosting.json");
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>goad-portal/worker/index.js</code> — <code>2dd0615a445143933d88d4271f54f5d63ee951421fcd08c5a7617bb09c564389</code></summary>

<!-- BUNDLE-FILE path="goad-portal/worker/index.js" sha256="2dd0615a445143933d88d4271f54f5d63ee951421fcd08c5a7617bb09c564389" -->
``````javascript
export default {
  async fetch(request, env) {
    const response = await env.ASSETS.fetch(request);
    const acceptsHtml = request.headers.get("accept")?.includes("text/html");

    if (response.status !== 404 || !acceptsHtml || !["GET", "HEAD"].includes(request.method)) {
      return response;
    }

    const indexUrl = new URL(request.url);
    indexUrl.pathname = "/index.html";
    indexUrl.search = "";
    return env.ASSETS.fetch(new Request(indexUrl, request));
  },
};
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>goad-portal/tests/sites-worker.test.mjs</code> — <code>96af7b48906c6460c793356d7b6952f7d5026dbf5a502bec0d9297ff04201c26</code></summary>

<!-- BUNDLE-FILE path="goad-portal/tests/sites-worker.test.mjs" sha256="96af7b48906c6460c793356d7b6952f7d5026dbf5a502bec0d9297ff04201c26" -->
``````javascript
import assert from "node:assert/strict";
import { access } from "node:fs/promises";
import test from "node:test";
import worker from "../worker/index.js";

test("serves existing static assets without a fallback", async () => {
  const calls = [];
  const response = await worker.fetch(new Request("https://example.test/assets/app.js"), {
    ASSETS: {
      fetch: async (request) => {
        calls.push(new URL(request.url).pathname);
        return new Response("asset", { status: 200 });
      },
    },
  });

  assert.equal(response.status, 200);
  assert.deepEqual(calls, ["/assets/app.js"]);
});

test("falls back to index.html for an unknown app route", async () => {
  const calls = [];
  const response = await worker.fetch(
    new Request("https://example.test/flow/step-two?source=share", {
      headers: { accept: "text/html" },
    }),
    {
      ASSETS: {
        fetch: async (request) => {
          const url = new URL(request.url);
          calls.push(url.pathname + url.search);
          return new Response(url.pathname === "/index.html" ? "app" : "missing", {
            status: url.pathname === "/index.html" ? 200 : 404,
          });
        },
      },
    },
  );

  assert.equal(response.status, 200);
  assert.deepEqual(calls, ["/flow/step-two?source=share", "/index.html"]);
});

test("does not turn missing API or write requests into the app shell", async () => {
  for (const request of [
    new Request("https://example.test/api/missing", { headers: { accept: "application/json" } }),
    new Request("https://example.test/flow", { method: "POST", headers: { accept: "text/html" } }),
  ]) {
    let calls = 0;
    const response = await worker.fetch(request, {
      ASSETS: {
        fetch: async () => {
          calls += 1;
          return new Response("missing", { status: 404 });
        },
      },
    });

    assert.equal(response.status, 404);
    assert.equal(calls, 1);
  }
});

test("emits the files required by Sites packaging", async () => {
  await access(new URL("../dist/client/index.html", import.meta.url));
  await access(new URL("../dist/server/index.js", import.meta.url));
  await access(new URL("../dist/.openai/hosting.json", import.meta.url));
});
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>tests/test_portal_api.py</code> — <code>40f6e3ccc04e4823bfdeaf2d4395a785f484c1e68cb98802599b0b7995284420</code></summary>

<!-- BUNDLE-FILE path="tests/test_portal_api.py" sha256="40f6e3ccc04e4823bfdeaf2d4395a785f484c1e68cb98802599b0b7995284420" -->
``````python
from __future__ import annotations

import time

from fastapi.testclient import TestClient

import portal_api


def _controller(running: bool = False, expires_at: int | None = None) -> dict:
    return {
        "running": running,
        "desktop_url": "https://desktop.example/vnc.html" if running else None,
        "vpn_available": running,
        "session_expires_at": expires_at if running else None,
        "session_max_expires_at": expires_at + 17 * 3600 if running and expires_at else None,
        "session_extend_available": running,
        "status": {
            "phase": "ready" if running else "stopped",
            "provisioned": True,
            "network": "192.168.56.0/24",
        },
    }


def test_auth_and_exclusive_lab_lease(tmp_path, monkeypatch):
    portal_api.DB_PATH = tmp_path / "portal.db"
    portal_api._commit_hook = None
    portal_api._init_db()
    state = {"running": False, "expires_at": None}

    def invoke(action, *args):
        if action == "status":
            return _controller(state["running"], state["expires_at"])
        if action == "start":
            state["running"] = True
            state["expires_at"] = int(time.time()) + int(args[0] * 3600)
            return _controller(True, state["expires_at"])
        if action == "stop":
            state["running"] = False
            return _controller(False)
        if action == "reset":
            result = _controller(True, state["expires_at"])
            result["reset_requested"] = True
            return result
        if action == "extend":
            state["expires_at"] += int(args[0] * 3600)
            result = _controller(True, state["expires_at"])
            result["extended_seconds"] = int(args[0] * 3600)
            return result
        if action == "vpn_config":
            return {"filename": "transilience-goad.ovpn", "profile": "client\nremote vpn.example 443\n"}
        raise AssertionError(action)

    def spawn(action, *args):
        assert action == "stop"
        state["running"] = False
        return "fc-test-stop"

    monkeypatch.setattr(portal_api, "_invoke_controller", invoke)
    monkeypatch.setattr(portal_api, "_spawn_controller", spawn)
    monkeypatch.setenv("GOAD_VNC_PASSWORD", "test-vnc-password")
    first = TestClient(portal_api.app)
    second = TestClient(portal_api.app)

    created = first.post(
        "/api/auth/signup",
        json={"name": "Arya", "email": "arya@example.com", "password": "needle-sword-10"},
    )
    assert created.status_code == 201
    assert created.json()["user"]["name"] == "Arya"
    assert first.get("/api/auth/me").status_code == 200

    duplicate = second.post(
        "/api/auth/signup",
        json={"name": "Other", "email": "ARYA@example.com", "password": "another-pass-10"},
    )
    assert duplicate.status_code == 409

    assert second.post(
        "/api/auth/signup",
        json={"name": "Jon", "email": "jon@example.com", "password": "longclaw-pass-10"},
    ).status_code == 201

    started = first.post("/api/labs/goad/start", json={"session_hours": 6})
    assert started.status_code == 200
    assert started.json()["state"] == "running"
    assert started.json()["ownership"] == "mine"
    assert started.json()["desktop_password"] == "test-vnc-password"
    assert started.json()["vpn_available"] is True
    assert started.json()["session_extend_available"] is True

    initial_expiry = started.json()["session_expires_at"]
    extended = first.post("/api/labs/goad/extend", json={})
    assert extended.status_code == 200
    assert extended.json()["session_expires_at"] == initial_expiry + 3600

    reset = first.post("/api/labs/goad/reset", json={})
    assert reset.status_code == 200
    assert reset.json()["state"] == "running"

    vpn = first.get("/api/labs/goad/vpn-config")
    assert vpn.status_code == 200
    assert vpn.text.startswith("client\n")
    assert vpn.headers["content-type"].startswith("application/x-openvpn-profile")
    assert 'filename="transilience-goad.ovpn"' in vpn.headers["content-disposition"]

    blocked = second.post("/api/labs/goad/start", json={"session_hours": 6})
    assert blocked.status_code == 409
    other_view = second.get("/api/labs/goad/status").json()
    assert other_view["ownership"] == "other"
    assert other_view["owner_name"] == "Arya"
    assert other_view["desktop_password"] is None

    assert second.post("/api/labs/goad/stop", json={}).status_code == 403
    assert second.post("/api/labs/goad/reset", json={}).status_code == 403
    assert second.post("/api/labs/goad/extend", json={}).status_code == 403
    assert second.get("/api/labs/goad/vpn-config").status_code == 403
    stopped = first.post("/api/labs/goad/stop", json={})
    assert stopped.status_code == 200
    assert stopped.json()["state"] == "stopped"


def test_login_rejects_wrong_password(tmp_path):
    portal_api.DB_PATH = tmp_path / "portal.db"
    portal_api._commit_hook = None
    portal_api._init_db()
    client = TestClient(portal_api.app)
    assert client.post(
        "/api/auth/signup",
        json={"name": "Sansa", "email": "sansa@example.com", "password": "winterfell-10"},
    ).status_code == 201
    client.post("/api/auth/logout", json={})
    rejected = client.post(
        "/api/auth/login",
        json={"email": "sansa@example.com", "password": "wrong-password"},
    )
    assert rejected.status_code == 401
``````
<!-- /BUNDLE-FILE -->

</details>

<details>
<summary><code>tests/mock_portal_server.py</code> — <code>093e4b2ba059019ea129664e3f9bd081b1d72a2803af09ea74092e19cf3276cc</code></summary>

<!-- BUNDLE-FILE path="tests/mock_portal_server.py" sha256="093e4b2ba059019ea129664e3f9bd081b1d72a2803af09ea74092e19cf3276cc" -->
``````python
"""Local visual-QA server for the authenticated GOAD portal."""

from __future__ import annotations

import time

import uvicorn

import portal_api


state = {"running": False, "expires_at": None}


def controller(running: bool) -> dict:
    expires_at = state["expires_at"] if running else None
    return {
        "running": running,
        "desktop_url": "https://desktop.example/vnc.html" if running else None,
        "vpn_available": running,
        "session_expires_at": expires_at,
        "session_max_expires_at": expires_at + 17 * 3600 if expires_at else None,
        "session_extend_available": running,
        "status": {
            "phase": "ready" if running else "stopped",
            "provisioned": True,
            "network": "192.168.56.0/24",
        },
    }


def invoke(action: str, *args):
    if action == "status":
        return controller(state["running"])
    if action == "start":
        state["running"] = True
        state["expires_at"] = int(time.time()) + int(args[0] * 3600)
        return controller(True)
    if action == "reset":
        result = controller(True)
        result["reset_requested"] = True
        return result
    if action == "extend":
        state["expires_at"] += int(args[0] * 3600)
        result = controller(True)
        result["extended_seconds"] = int(args[0] * 3600)
        return result
    if action == "stop":
        state["running"] = False
        return controller(False)
    if action == "vpn_config":
        return {"filename": "transilience-goad.ovpn", "profile": "client\nremote vpn.example 443\n"}
    raise AssertionError(action)


def spawn(action: str, *args):
    if action != "stop":
        raise AssertionError(action)
    state["running"] = False
    return "fc-local-stop"


portal_api._invoke_controller = invoke
portal_api._spawn_controller = spawn


def qa_user():
    with portal_api._db() as connection:
        return connection.execute("SELECT id, name, email FROM users WHERE id = 1").fetchone()


with portal_api._db(write=True) as connection:
    connection.execute(
        """
        INSERT OR IGNORE INTO users (id, name, email, salt, password_hash, created_at)
        VALUES (1, 'QA Operator', 'qa@example.test', X'00', X'00', ?)
        """,
        (int(time.time()),),
    )
portal_api.app.dependency_overrides[portal_api.current_user] = qa_user


if __name__ == "__main__":
    uvicorn.run(portal_api.app, host="127.0.0.1", port=4173)
``````
<!-- /BUNDLE-FILE -->

</details>

<!-- SOURCE-BUNDLE:END -->
