diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..021cf9a --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# API Options +DECNET_API_HOST=0.0.0.0 +DECNET_API_PORT=8000 +DECNET_JWT_SECRET=supersecretkey12345678901234567 +DECNET_INGEST_LOG_FILE=/var/log/decnet/decnet.log + +# Web Dashboard Options +DECNET_WEB_HOST=0.0.0.0 +DECNET_WEB_PORT=8080 +DECNET_ADMIN_USER=admin +DECNET_ADMIN_PASSWORD=admin +DECNET_DEVELOPER=False diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 97330d4..16fa5a0 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - run: pip install -e . + - run: pip install -e .[dev] - run: pytest tests/ -v --tb=short bandit: @@ -53,21 +53,42 @@ jobs: with: python-version: "3.11" - run: pip install pip-audit - - run: pip install -e . + - run: pip install -e .[dev] - run: pip-audit --skip-editable + merge-to-testing: + name: Merge dev → testing + runs-on: ubuntu-latest + needs: [lint, test, bandit, pip-audit] + if: github.ref == 'refs/heads/dev' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.DECNET_PR_TOKEN }} + - name: Configure git + run: | + git config user.name "DECNET CI" + git config user.email "ci@decnet.local" + - name: Merge dev into testing + run: | + git fetch origin testing + git checkout testing + git merge origin/dev --no-ff -m "ci: auto-merge dev → testing" + git push origin testing + open-pr: name: Open PR to main runs-on: ubuntu-latest needs: [lint, test, bandit, pip-audit] - if: github.ref == 'refs/heads/dev' + if: github.ref == 'refs/heads/testing' steps: - name: Open PR via Gitea API run: | echo "--- Checking for existing open PRs ---" LIST_RESPONSE=$(curl -s \ -H "Authorization: token ${{ secrets.DECNET_PR_TOKEN }}" \ - "https://git.resacachile.cl/api/v1/repos/anti/DECNET/pulls?state=open&head=anti:dev&base=main&limit=5") + "https://git.resacachile.cl/api/v1/repos/anti/DECNET/pulls?state=open&head=anti:testing&base=main&limit=5") echo "$LIST_RESPONSE" EXISTING=$(echo "$LIST_RESPONSE" | python3 -c "import sys, json; print(len(json.load(sys.stdin)))") echo "Open PRs found: $EXISTING" @@ -80,10 +101,10 @@ jobs: -H "Authorization: token ${{ secrets.DECNET_PR_TOKEN }}" \ -H "Content-Type: application/json" \ -d '{ - "title": "Auto PR: dev → main", - "head": "dev", + "title": "Auto PR: testing → main", + "head": "testing", "base": "main", - "body": "All CI and security checks passed. Review and merge when ready." + "body": "All CI and security checks passed on both dev and testing. Review and merge when ready." }' \ "https://git.resacachile.cl/api/v1/repos/anti/DECNET/pulls") echo "$CREATE_RESPONSE" diff --git a/.gitea/workflows/pr.yml b/.gitea/workflows/pr.yml index b942694..9c2a677 100644 --- a/.gitea/workflows/pr.yml +++ b/.gitea/workflows/pr.yml @@ -30,5 +30,28 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - run: pip install -e . + - run: pip install -e .[dev] - run: pytest tests/ -v --tb=short + + bandit: + name: SAST (bandit) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install bandit + - run: bandit -r decnet/ -ll -x decnet/services/registry.py + + pip-audit: + name: Dependency audit (pip-audit) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install pip-audit + - run: pip install -e .[dev] + - run: pip-audit --skip-editable diff --git a/.gitignore b/.gitignore index f432c66..2301154 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .venv/ +.claude/ __pycache__/ *.pyc *.pyo @@ -13,6 +14,11 @@ decnet.log* *.loggy *.nmap linterfails.log -test-scan webmail windows1 +*.db +decnet.json +.env +.env.local +.coverage +.hypothesis/ diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md deleted file mode 100644 index 3d1143a..0000000 --- a/DEVELOPMENT.md +++ /dev/null @@ -1,75 +0,0 @@ -# TODO - -This is a list of DEVELOPMENT TODOs. Features, development experience, usage, documentation, etcetera. - -## Core / Hardening - -- [ ] **Attacker fingerprinting** — Beyond IP logging: capture TLS JA3/JA4 hashes, TCP window sizes, User-Agent strings, SSH client banners, and tool signatures (nmap, masscan, Metasploit, Cobalt Strike). Build attacker profiles across sessions. -- [ ] **Canary tokens** — Embed canary URLs, fake AWS keys, fake API tokens, and honeydocs (PDF/DOCX with phone-home URLs) into decky filesystems. Fire an alert the moment one is used. -- [ ] **Tarpit mode** — Slow down attackers by making services respond extremely slowly (e.g., SSH that takes 60s to reject, HTTP that drip-feeds bytes). Wastes attacker time and resources. -- [ ] **Dynamic decky mutation** — Deckies that change their exposed services or OS fingerprint over time to confuse port-scan caching and appear more "alive." -- [ ] **Credential harvesting DB** — Every username/password attempt across all services lands in a queryable database. Expose via CLI (`decnet creds`) and flag reuse across deckies. -- [ ] **Session recording** — Full session capture for SSH/Telnet (keystroke logs, commands run, files downloaded). Cowrie already does this — surface it better in the CLI and correlation engine. -- [ ] **Payload capture** — Store every file uploaded or command executed by an attacker. Hash and auto-submit to VirusTotal or a local sandbox. - -## Detection & Intelligence - -- [ ] **Real-time alerting** — Webhook/Slack/Telegram notifications when an attacker hits a decky for the first time, crosses N deckies (lateral movement), or uses a known bad IP. -- [ ] **Threat intel enrichment** — Auto-lookup attacker IPs against AbuseIPDB, Shodan, GreyNoise, and AlienVault OTX. Tag known scanners vs. targeted attackers. -- [ ] **Attack campaign clustering** — Group attacker sessions by tooling signatures, timing patterns, and credential sets. Identify coordinated campaigns hitting multiple deckies. -- [ ] **GeoIP mapping** — Attacker origin on a world map. Correlate with ASN data to identify cloud exit nodes, VPNs, and Tor exits. -- [ ] **TTPs tagging** — Map observed attacker behaviors to MITRE ATT&CK techniques automatically. Tag events in the correlation engine. -- [ ] **Honeypot interaction scoring** — Score attackers on a scale: casual scanner vs. persistent targeted attacker, based on depth of interaction and commands run. - -## Dashboard & Visibility - -- [ ] **Web dashboard** — Real-time web UI showing live decky status, attacker activity, traversal graphs, and credential stats. Could be a simple FastAPI + HTMX or a full React app. -- [ ] **Pre-built Kibana/Grafana dashboards** — Ship dashboard JSON exports out of the box so ELK/Grafana deployments are plug-and-play. -- [ ] **CLI live feed** — `decnet watch` command: tail all decky logs in a unified, colored terminal stream (like `docker-compose logs -f` but prettier). -- [ ] **Traversal graph export** — Export attacker traversal graphs as DOT/Graphviz or JSON for visualization in external tools. -- [ ] **Daily digest** — Automated daily summary email/report: new attackers, top credentials tried, most-hit services. - -## Deployment & Infrastructure - -- [ ] **SWARM / multihost mode** — Full Ansible-based orchestration for deploying deckies across N real hosts. -- [ ] **Terraform/Pulumi provider** — Spin up cloud-hosted deckies on AWS/GCP/Azure with one command. Useful for internet-facing honeynets. -- [ ] **Auto-scaling** — When attack traffic increases, automatically spawn more deckies to absorb and log more activity. -- [ ] **Kubernetes deployment mode** — Run deckies as Kubernetes pods for environments already running k8s. -- [ ] **Proxmox/libvirt backend** — Full VM-based deckies instead of containers, for even more realistic OS fingerprints and behavior. Docker for speed; VMs for realism. -- [ ] **Raspberry Pi / ARM support** — Low-cost physical honeynets using RPis. Validate ARM image builds. -- [ ] **Decky health monitoring** — Watchdog that auto-restarts crashed deckies and alerts if a service goes dark. - -## Services & Realism - -- [ ] **HTTPS/TLS support** — HTTP honeypot with a self-signed or Let's Encrypt cert. Many real-world services use HTTPS; plain HTTP stands out. -- [ ] **Fake Active Directory** — A convincing fake AD/LDAP with fake users, groups, and GPOs. Attacker tools like BloodHound should get juicy (fake) data. -- [ ] **Fake file shares** — SMB/NFS shares pre-populated with enticing but fake files: "passwords.xlsx", "vpn_config.ovpn", "backup_keys.tar.gz". All instrumented to detect access. -- [ ] **Realistic web apps** — HTTP honeypot serving convincing fake apps: a fake WordPress, a fake phpMyAdmin, a fake Grafana login — all logging every interaction. -- [ ] **OT/ICS profiles** — Expand Conpot support: Modbus, DNP3, BACnet, EtherNet/IP. Convincing industrial control system decoys. -- [ ] **Printer/IoT archetypes** — Expand existing printer/camera archetypes with actual service emulation (IPP, ONVIF, WS-Discovery). -- [ ] **Service interaction depth** — Some services currently just log the connection. Deepen interaction: fake MySQL that accepts queries and returns realistic fake data, fake Redis that stores and retrieves dummy keys. - -## Developer Experience - -- [ ] **Plugin SDK docs** — Full documentation and an example plugin for adding custom services. Lower the barrier for community contributions. -- [ ] **Integration tests** — Full deploy/teardown cycle tests against a real Docker daemon (not just unit tests). -- [ ] **Per-service tests** — Each of the 29 service implementations deserves its own test coverage. -- [x] **CI/CD pipeline** — GitHub/Gitea Actions: run tests on push, lint, build Docker images, publish releases. - - ci.yaml contains several steps for the CI/CD pipeline. Mainly: - - Trivy checks for Docker containers. - - Ruff linting. - - Pytests. - - Bandit SAST. - - pip-audit. -- [ ] **Config validation CLI** — `decnet validate my.ini` to dry-check an INI config before deploying. -- [ ] **Config generator wizard** — `decnet wizard` interactive prompt to generate an INI config without writing one by hand. -- [ ] **Gitea Wiki** — Set up the repository wiki with structured docs across the following pages: - - **Home** — Project overview, goals, and navigation index. - - **Architecture** — UNIHOST vs SWARM models, the two-network design (decoy-facing vs isolated logging), MACVLAN/IPVLAN, log pipeline (Cowrie → Logstash → ELK → SIEM), WSL limitations. - - **General Usage** — What DECNET can do and how: deploying deckies, choosing services, using `--randomize-services`, reading status, tearing down. Archetypes explained (what they are, how they group services into realistic machine personas — e.g. a Windows workstation archetype exposes RDP+SMB+LDAP, a Linux server exposes SSH+FTP+MySQL). List of built-in archetypes. How to pick an archetype vs. manually specifying services. - - **Custom Services** — How the plugin registry works, anatomy of a service plugin, step-by-step guide to writing and registering a custom service, how to package it for reuse. - - **Configuration Reference** — Full INI config option breakdown, all CLI flags (`--mode`, `--deckies`, `--interface`, `--log-target`, `--randomize-services`, etc.), environment variables. - - **Deployment Guides** — UNIHOST quickstart (bare metal/VM), SWARM/multihost with Ansible (once implemented), cloud deployment via Terraform (once implemented), Raspberry Pi / ARM builds. - - **Service Reference** — Full table of all 29 services: port, protocol, base image, interaction depth, and any known fingerprint quirks. - - **Attacker Intelligence** — Credential harvesting (`decnet creds`), session recording playback, threat intel enrichment (AbuseIPDB, GreyNoise, Shodan, OTX), MITRE ATT&CK tagging, campaign clustering. - - **Operations** — Health monitoring, watchdog behavior, teardown procedures, log rotation, troubleshooting common issues. diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..a46089f --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,103 @@ +# DECNET (Deception Network) Project Context + +DECNET is a high-fidelity honeypot framework designed to deploy heterogeneous fleets of fake machines (called **deckies**) that appear as real hosts on a local network. + +## Project Overview + +- **Core Purpose:** To lure, profile, and log attacker interactions within a controlled, deceptive environment. +- **Key Technology:** Linux-native container networking (MACVLAN/IPvlan) combined with Docker to give each decoy its own MAC address, IP, and realistic TCP/IP stack behavior. +- **Main Components:** + - **Deckies:** Group of containers sharing a network namespace (one base container + multiple service containers). + - **Archetypes:** Pre-defined machine profiles (e.g., `windows-workstation`, `linux-server`) that bundle services and OS fingerprints. + - **Services:** Modular honeypot plugins (SSH, SMB, RDP, etc.) built as `BaseService` subclasses. + - **OS Fingerprinting:** Sysctl-based TCP/IP stack tuning to spoof OS detection (nmap). + - **Logging Pipeline:** RFC 5424 syslog forwarding to an isolated SIEM/ELK stack. + +## Technical Stack + +- **Language:** Python 3.11+ +- **CLI Framework:** [Typer](https://typer.tiangolo.com/) +- **Data Validation:** [Pydantic v2](https://docs.pydantic.dev/) +- **Orchestration:** Docker Engine 24+ (via Docker SDK for Python) +- **Networking:** MACVLAN (default) or IPvlan L2 (for WiFi/restricted environments). +- **Testing:** Pytest (100% pass requirement). +- **Formatting/Linting:** Ruff, Bandit (SAST), pip-audit. + +## Architecture + +```text +Host NIC (eth0) + └── MACVLAN Bridge + ├── Decky-01 (192.168.1.10) -> [Base] + [SSH] + [HTTP] + ├── Decky-02 (192.168.1.11) -> [Base] + [SMB] + [RDP] + └── ... +``` + +- **Base Container:** Owns the IP/MAC, sets `sysctls` for OS spoofing, and runs `sleep infinity`. +- **Service Containers:** Use `network_mode: service:` to share the identity and networking of the base container. +- **Isolation:** Decoy traffic is strictly separated from the logging network. + +## Key Commands + +### Development & Maintenance +- **Install (Dev):** + - `rm .venv -rf` + - `python3 -m venv .venv` + - `source .venv/bin/activate` + - `pip install -e .` +- **Run Tests:** `pytest` (Run before any commit) +- **Linting:** `ruff check .` +- **Security Scan:** `bandit -r decnet/` +- **Web Git:** git.resacachile.cl (Gitea) + +### CLI Usage +- **List Services:** `decnet services` +- **List Archetypes:** `decnet archetypes` +- **Dry Run (Compose Gen):** `decnet deploy --deckies 3 --randomize-services --dry-run` +- **Deploy (Full):** `sudo .venv/bin/decnet deploy --interface eth0 --deckies 5 --randomize-services` +- **Status:** `decnet status` +- **Teardown:** `sudo .venv/bin/decnet teardown --all` + +## Development Conventions + +- **Code Style:** + - Strict adherence to Ruff/PEP8. + - **Always use typed variables**. If any non-types variables are found, they must be corrected. + - The correct way is `x: int = 1`, never `x : int = 1`. + - If assignment is present, always use a space between the type and the equal sign `x: int = 1`. + - **Never** use lowercase L (l), uppercase o (O) or uppercase i (i) in single-character names. + - **Internal vars are to be declared with an underscore** (_internal_variable_name). + - **Internal to internal vars are to be declared with double underscore** (__internal_variable_name). + - Always use snake_case for code. + - Always use PascalCase for classes and generics. +- **Testing:** New features MUST include a `pytest` case. 100% test pass rate is mandatory before merging. +- **Plugin System:** + - New services go in `decnet/services/.py`. + - Subclass `decnet.services.base.BaseService`. + - The registry uses auto-discovery; no manual registration required. +- **Configuration:** + - Use Pydantic models in `decnet/config.py` for any new settings. + - INI file parsing is handled in `decnet/ini_loader.py`. +- **State Management:** + - Runtime state is persisted in `decnet-state.json`. + - Do not modify this file manually. +- **General Development Guidelines**: + - **Never** commit broken code, or before running `pytest`s or `bandit` at the project level. + - **No matter how small** the changes, they must be committed. + - **If new features are addedd** new tests must be added, too. + - **Never present broken code to the user**. Test, validate, then present. + - **Extensive testing** for every function must be created. + - **Always develop in the `dev` branch, never in `main`.** + - **Test in the `testing` branch.** + +## Directory Structure + +- `decnet/`: Main source code. + - `services/`: Honeypot service implementations. + - `logging/`: Syslog formatting and forwarding logic. + - `correlation/`: (In Progress) Logic for grouping attacker events. +- `templates/`: Dockerfiles and entrypoint scripts for services. +- `tests/`: Pytest suite. +- `pyproject.toml`: Dependency and entry point definitions. +- `CLAUDE.md`: Claude-specific environment guidance. +- `DEVELOPMENT.md`: Roadmap and TODOs. diff --git a/README.md b/README.md index 765f569..a17674d 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ From the outside a decky looks identical to a real machine: it has its own MAC a ## Installation ```bash -git clone DECNET +git clone https://git.resacachile.cl/anti/DECNET cd DECNET pip install -e . ``` @@ -208,6 +208,26 @@ sudo decnet deploy --deckies 4 --archetype windows-workstation [corp-workstations] archetype = windows-workstation amount = 4 + +[win-fileserver] +services = ftp +nmap_os = windows +os_version = Windows Server 2019 + +[dbsrv01] +ip = 192.168.1.112 +services = mysql, http +nmap_os = linux + +[dbsrv01.http] +server_header = Apache/2.4.54 (Debian) +response_code = 200 +fake_app = wordpress + +[dbsrv01.mysql] +mysql_version = 5.7.38-log +mysql_banner = MySQL Community Server + ``` --- @@ -460,7 +480,7 @@ Key/value pairs are passed directly to the service plugin as persona config. Com | `mongodb` | `mongo_version` | | `elasticsearch` | `es_version`, `cluster_name` | | `ldap` | `base_dn`, `domain` | -| `snmp` | `snmp_community`, `sys_descr` | +| `snmp` | `snmp_community`, `sys_descr`, `snmp_archetype` (picks predefined sysDescr for `water_plant`, `hospital`, etc.) | | `mqtt` | `mqtt_version` | | `sip` | `sip_server`, `sip_domain` | | `k8s` | `k8s_version` | @@ -476,6 +496,30 @@ See [`test-full.ini`](test-full.ini) — covers all 25 services across 10 role-t --- +## Environment Configuration (.env) + +DECNET supports loading configuration from `.env.local` and `.env` files located in the project root. This is useful for securing secrets like the JWT key and configuring default ports without passing flags every time. + +An example `.env.example` is provided: + +```ini +# API Options +DECNET_API_HOST=0.0.0.0 +DECNET_API_PORT=8000 +DECNET_JWT_SECRET=supersecretkey12345 +DECNET_INGEST_LOG_FILE=/var/log/decnet/decnet.log + +# Web Dashboard Options +DECNET_WEB_HOST=0.0.0.0 +DECNET_WEB_PORT=8080 +DECNET_ADMIN_USER=admin +DECNET_ADMIN_PASSWORD=admin +``` + +Copy `.env.example` to `.env.local` and modify it to suit your environment. + +--- + ## Logging All attacker interactions are forwarded off the decoy network to an isolated logging sink. The log pipeline lives on a separate internal Docker bridge (`decnet_logs`) that is not reachable from the fake LAN. diff --git a/decnet/archetypes.py b/decnet/archetypes.py index e4145c8..00f9c41 100644 --- a/decnet/archetypes.py +++ b/decnet/archetypes.py @@ -148,7 +148,7 @@ ARCHETYPES: dict[str, Archetype] = { slug="deaddeck", display_name="Deaddeck (Entry Point)", description="Internet-facing entry point with real interactive SSH — no honeypot emulation", - services=["real_ssh"], + services=["ssh"], preferred_distros=["debian", "ubuntu22"], nmap_os="linux", ), @@ -167,4 +167,4 @@ def all_archetypes() -> dict[str, Archetype]: def random_archetype() -> Archetype: - return random.choice(list(ARCHETYPES.values())) + return random.choice(list(ARCHETYPES.values())) # nosec B311 diff --git a/decnet/cli.py b/decnet/cli.py index 9d3de7d..90bc1c2 100644 --- a/decnet/cli.py +++ b/decnet/cli.py @@ -8,21 +8,27 @@ Usage: decnet services """ -import random +import signal from typing import Optional import typer from rich.console import Console from rich.table import Table +from decnet.env import ( + DECNET_API_HOST, + DECNET_API_PORT, + DECNET_INGEST_LOG_FILE, + DECNET_WEB_HOST, + DECNET_WEB_PORT, +) from decnet.archetypes import Archetype, all_archetypes, get_archetype from decnet.config import ( - DeckyConfig, DecnetConfig, - random_hostname, ) -from decnet.distros import all_distros, get_distro, random_distro -from decnet.ini_loader import IniConfig, load_ini +from decnet.distros import all_distros, get_distro +from decnet.fleet import all_service_names, build_deckies, build_deckies_from_ini +from decnet.ini_loader import load_ini from decnet.network import detect_interface, detect_subnet, allocate_ips, get_host_ip from decnet.services.registry import all_services @@ -33,167 +39,56 @@ app = typer.Typer( ) console = Console() -def _all_service_names() -> list[str]: - """Return all registered service names from the live plugin registry.""" - return sorted(all_services().keys()) + +def _kill_api() -> None: + """Find and kill any running DECNET API (uvicorn) or mutator processes.""" + import psutil + import os + + _killed: bool = False + for _proc in psutil.process_iter(['pid', 'name', 'cmdline']): + try: + _cmd = _proc.info['cmdline'] + if not _cmd: + continue + if "uvicorn" in _cmd and "decnet.web.api:app" in _cmd: + console.print(f"[yellow]Stopping DECNET API (PID {_proc.info['pid']})...[/]") + os.kill(_proc.info['pid'], signal.SIGTERM) + _killed = True + elif "decnet.cli" in _cmd and "mutate" in _cmd and "--watch" in _cmd: + console.print(f"[yellow]Stopping DECNET Mutator Watcher (PID {_proc.info['pid']})...[/]") + os.kill(_proc.info['pid'], signal.SIGTERM) + _killed = True + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + if _killed: + console.print("[green]Background processes stopped.[/]") -def _resolve_distros( - distros_explicit: list[str] | None, - randomize_distros: bool, - n: int, - archetype: Archetype | None = None, -) -> list[str]: - """Return a list of n distro slugs based on CLI flags or archetype preference.""" - if distros_explicit: - return [distros_explicit[i % len(distros_explicit)] for i in range(n)] - if randomize_distros: - return [random_distro().slug for _ in range(n)] - if archetype: - pool = archetype.preferred_distros - return [pool[i % len(pool)] for i in range(n)] - # Default: cycle through all distros to maximize heterogeneity - slugs = list(all_distros().keys()) - return [slugs[i % len(slugs)] for i in range(n)] +@app.command() +def api( + port: int = typer.Option(DECNET_API_PORT, "--port", help="Port for the backend API"), + host: str = typer.Option(DECNET_API_HOST, "--host", help="Host IP for the backend API"), + log_file: str = typer.Option(DECNET_INGEST_LOG_FILE, "--log-file", help="Path to the DECNET log file to monitor"), +) -> None: + """Run the DECNET API and Web Dashboard in standalone mode.""" + import subprocess # nosec B404 + import sys + import os - -def _build_deckies( - n: int, - ips: list[str], - services_explicit: list[str] | None, - randomize_services: bool, - distros_explicit: list[str] | None = None, - randomize_distros: bool = False, - archetype: Archetype | None = None, -) -> list[DeckyConfig]: - deckies = [] - used_combos: set[frozenset] = set() - distro_slugs = _resolve_distros(distros_explicit, randomize_distros, n, archetype) - - for i, ip in enumerate(ips): - name = f"decky-{i + 1:02d}" - distro = get_distro(distro_slugs[i]) - hostname = random_hostname(distro.slug) - - if services_explicit: - svc_list = services_explicit - elif archetype: - svc_list = list(archetype.services) - elif randomize_services: - svc_pool = _all_service_names() - attempts = 0 - while True: - count = random.randint(1, min(3, len(svc_pool))) - chosen = frozenset(random.sample(svc_pool, count)) - attempts += 1 - if chosen not in used_combos or attempts > 20: - break - svc_list = list(chosen) - used_combos.add(chosen) - else: - typer.echo("Error: provide --services, --archetype, or --randomize-services.", err=True) - raise typer.Exit(1) - - deckies.append( - DeckyConfig( - name=name, - ip=ip, - services=svc_list, - distro=distro.slug, - base_image=distro.image, - build_base=distro.build_base, - hostname=hostname, - archetype=archetype.slug if archetype else None, - nmap_os=archetype.nmap_os if archetype else "linux", - ) + console.print(f"[green]Starting DECNET API on {host}:{port}...[/]") + _env: dict[str, str] = os.environ.copy() + _env["DECNET_INGEST_LOG_FILE"] = str(log_file) + try: + subprocess.run( # nosec B603 B404 + [sys.executable, "-m", "uvicorn", "decnet.web.api:app", "--host", host, "--port", str(port)], + env=_env ) - return deckies - - -def _build_deckies_from_ini( - ini: IniConfig, - subnet_cidr: str, - gateway: str, - host_ip: str, - randomize: bool, -) -> list[DeckyConfig]: - """Build DeckyConfig list from an IniConfig, auto-allocating missing IPs.""" - from ipaddress import IPv4Address, IPv4Network - - explicit_ips: set[IPv4Address] = { - IPv4Address(s.ip) for s in ini.deckies if s.ip - } - - net = IPv4Network(subnet_cidr, strict=False) - reserved = { - net.network_address, - net.broadcast_address, - IPv4Address(gateway), - IPv4Address(host_ip), - } | explicit_ips - - auto_pool = (str(addr) for addr in net.hosts() if addr not in reserved) - - deckies: list[DeckyConfig] = [] - for spec in ini.deckies: - # Resolve archetype (if any) — explicit services/distro override it - arch: Archetype | None = None - if spec.archetype: - try: - arch = get_archetype(spec.archetype) - except ValueError as e: - console.print(f"[red]{e}[/]") - raise typer.Exit(1) - - # Distro: archetype preferred list → random → global cycle - distro_pool = arch.preferred_distros if arch else list(all_distros().keys()) - distro = get_distro(distro_pool[len(deckies) % len(distro_pool)]) - hostname = random_hostname(distro.slug) - - ip = spec.ip or next(auto_pool, None) - if ip is None: - raise RuntimeError( - f"Not enough free IPs in {subnet_cidr} while assigning IP for '{spec.name}'." - ) - - if spec.services: - known = set(_all_service_names()) - unknown = [s for s in spec.services if s not in known] - if unknown: - console.print( - f"[red]Unknown service(s) in [{spec.name}]: {unknown}. " - f"Available: {_all_service_names()}[/]" - ) - raise typer.Exit(1) - svc_list = spec.services - elif arch: - svc_list = list(arch.services) - elif randomize: - svc_pool = _all_service_names() - count = random.randint(1, min(3, len(svc_pool))) - svc_list = random.sample(svc_pool, count) - else: - console.print( - f"[red]Decky '[{spec.name}]' has no services= in config. " - "Add services=, archetype=, or use --randomize-services.[/]" - ) - raise typer.Exit(1) - - # nmap_os priority: explicit INI key > archetype default > "linux" - resolved_nmap_os = spec.nmap_os or (arch.nmap_os if arch else "linux") - deckies.append(DeckyConfig( - name=spec.name, - ip=ip, - services=svc_list, - distro=distro.slug, - base_image=distro.image, - build_base=distro.build_base, - hostname=hostname, - archetype=arch.slug if arch else None, - service_config=spec.service_config, - nmap_os=resolved_nmap_os, - )) - return deckies + except KeyboardInterrupt: + pass + except (FileNotFoundError, subprocess.SubprocessError): + console.print("[red]Failed to start API. Ensure 'uvicorn' is installed in the current environment.[/]") @app.command() @@ -207,15 +102,19 @@ def deploy( randomize_services: bool = typer.Option(False, "--randomize-services", help="Assign random services to each decky"), distro: Optional[str] = typer.Option(None, "--distro", help="Comma-separated distro slugs, e.g. debian,ubuntu22,rocky9"), randomize_distros: bool = typer.Option(False, "--randomize-distros", help="Assign a random distro to each decky"), - log_target: Optional[str] = typer.Option(None, "--log-target", help="Forward logs to ip:port (e.g. 192.168.1.5:5140)"), - log_file: Optional[str] = typer.Option(None, "--log-file", help="Write RFC 5424 syslog to this path inside containers (e.g. /var/log/decnet/decnet.log)"), + log_file: Optional[str] = typer.Option(DECNET_INGEST_LOG_FILE, "--log-file", help="Host path for the collector to write RFC 5424 logs (e.g. /var/log/decnet/decnet.log)"), archetype_name: Optional[str] = typer.Option(None, "--archetype", "-a", help="Machine archetype slug (e.g. linux-server, windows-workstation)"), + mutate_interval: Optional[int] = typer.Option(30, "--mutate-interval", help="Automatically rotate services every N minutes"), dry_run: bool = typer.Option(False, "--dry-run", help="Generate compose file without starting containers"), no_cache: bool = typer.Option(False, "--no-cache", help="Force rebuild all images, ignoring Docker layer cache"), + parallel: bool = typer.Option(False, "--parallel", help="Build all images concurrently (enables BuildKit, separates build from up)"), ipvlan: bool = typer.Option(False, "--ipvlan", help="Use IPvlan L2 instead of MACVLAN (required on WiFi interfaces)"), config_file: Optional[str] = typer.Option(None, "--config", "-c", help="Path to INI config file"), + api: bool = typer.Option(False, "--api", help="Start the FastAPI backend to ingest and serve logs"), + api_port: int = typer.Option(8000, "--api-port", help="Port for the backend API"), ) -> None: """Deploy deckies to the LAN.""" + import os if mode not in ("unihost", "swarm"): console.print("[red]--mode must be 'unihost' or 'swarm'[/]") raise typer.Exit(1) @@ -230,7 +129,6 @@ def deploy( console.print(f"[red]{e}[/]") raise typer.Exit(1) - # CLI flags override INI values when explicitly provided iface = interface or ini.interface or detect_interface() subnet_cidr = subnet or ini.subnet effective_gateway = ini.gateway @@ -244,7 +142,6 @@ def deploy( f"[dim]Subnet:[/] {subnet_cidr} [dim]Gateway:[/] {effective_gateway} " f"[dim]Host IP:[/] {host_ip}") - # Register bring-your-own services from INI before validation if ini.custom_services: from decnet.custom_service import CustomService from decnet.services.registry import register_custom_service @@ -258,11 +155,14 @@ def deploy( ) ) - effective_log_target = log_target or ini.log_target effective_log_file = log_file - decky_configs = _build_deckies_from_ini( - ini, subnet_cidr, effective_gateway, host_ip, randomize_services - ) + try: + decky_configs = build_deckies_from_ini( + ini, subnet_cidr, effective_gateway, host_ip, randomize_services, cli_mutate_interval=mutate_interval + ) + except ValueError as e: + console.print(f"[red]{e}[/]") + raise typer.Exit(1) # ------------------------------------------------------------------ # # Classic CLI path # # ------------------------------------------------------------------ # @@ -273,13 +173,12 @@ def deploy( services_list = [s.strip() for s in services.split(",")] if services else None if services_list: - known = set(_all_service_names()) + known = set(all_service_names()) unknown = [s for s in services_list if s not in known] if unknown: - console.print(f"[red]Unknown service(s): {unknown}. Available: {_all_service_names()}[/]") + console.print(f"[red]Unknown service(s): {unknown}. Available: {all_service_names()}[/]") raise typer.Exit(1) - # Resolve archetype if provided arch: Archetype | None = None if archetype_name: try: @@ -313,39 +212,113 @@ def deploy( raise typer.Exit(1) ips = allocate_ips(subnet_cidr, effective_gateway, host_ip, deckies, ip_start) - decky_configs = _build_deckies( + decky_configs = build_deckies( deckies, ips, services_list, randomize_services, distros_explicit=distros_list, randomize_distros=randomize_distros, - archetype=arch, + archetype=arch, mutate_interval=mutate_interval, ) - effective_log_target = log_target effective_log_file = log_file + if api and not effective_log_file: + effective_log_file = os.path.join(os.getcwd(), "decnet.log") + console.print(f"[cyan]API mode enabled: defaulting log-file to {effective_log_file}[/]") + config = DecnetConfig( mode=mode, interface=iface, subnet=subnet_cidr, gateway=effective_gateway, deckies=decky_configs, - log_target=effective_log_target, log_file=effective_log_file, ipvlan=ipvlan, + mutate_interval=mutate_interval, ) - if effective_log_target and not dry_run: - from decnet.logging.forwarder import probe_log_target - if not probe_log_target(effective_log_target): - console.print(f"[yellow]Warning: log target {effective_log_target} is unreachable. " - "Logs will be lost if it stays down.[/]") + from decnet.engine import deploy as _deploy + _deploy(config, dry_run=dry_run, no_cache=no_cache, parallel=parallel) - from decnet.deployer import deploy as _deploy - _deploy(config, dry_run=dry_run, no_cache=no_cache) + if mutate_interval is not None and not dry_run: + import subprocess # nosec B404 + import sys + console.print(f"[green]Starting DECNET Mutator watcher in the background (interval: {mutate_interval}m)...[/]") + try: + subprocess.Popen( # nosec B603 + [sys.executable, "-m", "decnet.cli", "mutate", "--watch"], + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + except (FileNotFoundError, subprocess.SubprocessError): + console.print("[red]Failed to start mutator watcher.[/]") + + if effective_log_file and not dry_run and not api: + import subprocess # noqa: F811 # nosec B404 + import sys + from pathlib import Path as _Path + _collector_err = _Path(effective_log_file).with_suffix(".collector.log") + console.print(f"[bold cyan]Starting log collector[/] → {effective_log_file}") + subprocess.Popen( # nosec B603 + [sys.executable, "-m", "decnet.cli", "collect", "--log-file", str(effective_log_file)], + stdin=subprocess.DEVNULL, + stdout=open(_collector_err, "a"), # nosec B603 + stderr=subprocess.STDOUT, + start_new_session=True, + ) + + if api and not dry_run: + import subprocess # nosec B404 + import sys + console.print(f"[green]Starting DECNET API on port {api_port}...[/]") + _env: dict[str, str] = os.environ.copy() + _env["DECNET_INGEST_LOG_FILE"] = str(effective_log_file or "") + try: + subprocess.Popen( # nosec B603 + [sys.executable, "-m", "uvicorn", "decnet.web.api:app", "--host", DECNET_API_HOST, "--port", str(api_port)], + env=_env, + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT + ) + console.print(f"[dim]API running at http://{DECNET_API_HOST}:{api_port}[/]") + except (FileNotFoundError, subprocess.SubprocessError): + console.print("[red]Failed to start API. Ensure 'uvicorn' is installed in the current environment.[/]") + + +@app.command() +def collect( + log_file: str = typer.Option(DECNET_INGEST_LOG_FILE, "--log-file", "-f", help="Path to write RFC 5424 syslog lines and .json records"), +) -> None: + """Stream Docker logs from all running decky service containers to a log file.""" + import asyncio + from decnet.collector import log_collector_worker + console.print(f"[bold cyan]Collector starting[/] → {log_file}") + asyncio.run(log_collector_worker(log_file)) + + +@app.command() +def mutate( + watch: bool = typer.Option(False, "--watch", "-w", help="Run continuously and mutate deckies according to their interval"), + decky_name: Optional[str] = typer.Option(None, "--decky", "-d", help="Force mutate a specific decky immediately"), + force_all: bool = typer.Option(False, "--all", help="Force mutate all deckies immediately"), +) -> None: + """Manually trigger or continuously watch for decky mutation.""" + from decnet.mutator import mutate_decky, mutate_all, run_watch_loop + + if watch: + run_watch_loop() + return + + if decky_name: + mutate_decky(decky_name) + elif force_all: + mutate_all(force=True) + else: + mutate_all(force=False) @app.command() def status() -> None: """Show running deckies and their status.""" - from decnet.deployer import status as _status + from decnet.engine import status as _status _status() @@ -359,9 +332,12 @@ def teardown( console.print("[red]Specify --all or --id .[/]") raise typer.Exit(1) - from decnet.deployer import teardown as _teardown + from decnet.engine import teardown as _teardown _teardown(decky_id=id_) + if all_: + _kill_api() + @app.command(name="services") def list_services() -> None: @@ -459,3 +435,40 @@ def list_archetypes() -> None: arch.description, ) console.print(table) + + +@app.command(name="web") +def serve_web( + web_port: int = typer.Option(DECNET_WEB_PORT, "--web-port", help="Port to serve the DECNET Web Dashboard"), + host: str = typer.Option(DECNET_WEB_HOST, "--host", help="Host IP to serve the Web Dashboard"), +) -> None: + """Serve the DECNET Web Dashboard frontend.""" + import http.server + import socketserver + from pathlib import Path + + dist_dir = Path(__file__).parent.parent / "decnet_web" / "dist" + + if not dist_dir.exists(): + console.print(f"[red]Frontend build not found at {dist_dir}. Make sure you run 'npm run build' inside 'decnet_web'.[/]") + raise typer.Exit(1) + + class SPAHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + path = self.translate_path(self.path) + if not Path(path).exists() or Path(path).is_dir(): + self.path = "/index.html" + return super().do_GET() + + import os + os.chdir(dist_dir) + + with socketserver.TCPServer((host, web_port), SPAHTTPRequestHandler) as httpd: + console.print(f"[green]Serving DECNET Web Dashboard on http://{host}:{web_port}[/]") + try: + httpd.serve_forever() + except KeyboardInterrupt: + console.print("\n[dim]Shutting down dashboard server.[/]") + +if __name__ == '__main__': # pragma: no cover + app() diff --git a/decnet/collector/__init__.py b/decnet/collector/__init__.py new file mode 100644 index 0000000..5dcaf34 --- /dev/null +++ b/decnet/collector/__init__.py @@ -0,0 +1,13 @@ +from decnet.collector.worker import ( + is_service_container, + is_service_event, + log_collector_worker, + parse_rfc5424, +) + +__all__ = [ + "is_service_container", + "is_service_event", + "log_collector_worker", + "parse_rfc5424", +] diff --git a/decnet/collector/worker.py b/decnet/collector/worker.py new file mode 100644 index 0000000..69e2c6b --- /dev/null +++ b/decnet/collector/worker.py @@ -0,0 +1,200 @@ +""" +Host-side Docker log collector. + +Streams stdout from all running decky service containers via the Docker SDK, +writes RFC 5424 lines to and parsed JSON records to .json. +The ingester tails the .json file; rsyslog can consume the .log file independently. +""" + +import asyncio +import json +import logging +import re +from datetime import datetime +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger("decnet.collector") + +# ─── RFC 5424 parser ────────────────────────────────────────────────────────── + +_RFC5424_RE = re.compile( + r"^<\d+>1 " + r"(\S+) " # 1: TIMESTAMP + r"(\S+) " # 2: HOSTNAME (decky name) + r"(\S+) " # 3: APP-NAME (service) + r"- " # PROCID always NILVALUE + r"(\S+) " # 4: MSGID (event_type) + r"(.+)$", # 5: SD element + optional MSG +) +_SD_BLOCK_RE = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL) +_PARAM_RE = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"') +_IP_FIELDS = ("src_ip", "src", "client_ip", "remote_ip", "ip") + + +def parse_rfc5424(line: str) -> Optional[dict[str, Any]]: + """ + Parse an RFC 5424 DECNET log line into a structured dict. + Returns None if the line does not match the expected format. + """ + m = _RFC5424_RE.match(line) + if not m: + return None + ts_raw, decky, service, event_type, sd_rest = m.groups() + + fields: dict[str, str] = {} + msg: str = "" + + if sd_rest.startswith("-"): + msg = sd_rest[1:].lstrip() + elif sd_rest.startswith("["): + block = _SD_BLOCK_RE.search(sd_rest) + if block: + for k, v in _PARAM_RE.findall(block.group(1)): + fields[k] = v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]") + msg_match = re.search(r'\]\s+(.+)$', sd_rest) + if msg_match: + msg = msg_match.group(1).strip() + else: + msg = sd_rest + + attacker_ip = "Unknown" + for fname in _IP_FIELDS: + if fname in fields: + attacker_ip = fields[fname] + break + + try: + ts_formatted = datetime.fromisoformat(ts_raw).strftime("%Y-%m-%d %H:%M:%S") + except ValueError: + ts_formatted = ts_raw + + return { + "timestamp": ts_formatted, + "decky": decky, + "service": service, + "event_type": event_type, + "attacker_ip": attacker_ip, + "fields": fields, + "msg": msg, + "raw_line": line, + } + + +# ─── Container helpers ──────────────────────────────────────────────────────── + +def _load_service_container_names() -> set[str]: + """ + Return the exact set of service container names from decnet-state.json. + Format: {decky_name}-{service_name}, e.g. 'omega-decky-smtp'. + Returns an empty set if no state file exists. + """ + from decnet.config import load_state + state = load_state() + if state is None: + return set() + config, _ = state + names: set[str] = set() + for decky in config.deckies: + for svc in decky.services: + names.add(f"{decky.name}-{svc.replace('_', '-')}") + return names + + +def is_service_container(container) -> bool: + """Return True if this Docker container is a known DECNET service container.""" + name = (container if isinstance(container, str) else container.name).lstrip("/") + return name in _load_service_container_names() + + +def is_service_event(attrs: dict) -> bool: + """Return True if a Docker start event is for a known DECNET service container.""" + name = attrs.get("name", "").lstrip("/") + return name in _load_service_container_names() + + +# ─── Blocking stream worker (runs in a thread) ──────────────────────────────── + +def _stream_container(container_id: str, log_path: Path, json_path: Path) -> None: + """Stream logs from one container and append to the host log files.""" + import docker # type: ignore[import] + + try: + client = docker.from_env() + container = client.containers.get(container_id) + log_stream = container.logs(stream=True, follow=True, stdout=True, stderr=False) + buf = "" + with ( + open(log_path, "a", encoding="utf-8") as lf, + open(json_path, "a", encoding="utf-8") as jf, + ): + for chunk in log_stream: + buf += chunk.decode("utf-8", errors="replace") + while "\n" in buf: + line, buf = buf.split("\n", 1) + line = line.rstrip() + if not line: + continue + lf.write(line + "\n") + lf.flush() + parsed = parse_rfc5424(line) + if parsed: + jf.write(json.dumps(parsed) + "\n") + jf.flush() + except Exception as exc: + logger.debug("Log stream ended for container %s: %s", container_id, exc) + + +# ─── Async collector ────────────────────────────────────────────────────────── + +async def log_collector_worker(log_file: str) -> None: + """ + Background task: streams Docker logs from all running decky service + containers, writing RFC 5424 lines to log_file and parsed JSON records + to log_file.json for the ingester to consume. + + Watches Docker events to pick up containers started after initial scan. + """ + import docker # type: ignore[import] + + log_path = Path(log_file) + json_path = log_path.with_suffix(".json") + log_path.parent.mkdir(parents=True, exist_ok=True) + + active: dict[str, asyncio.Task[None]] = {} + loop = asyncio.get_running_loop() + + def _spawn(container_id: str, container_name: str) -> None: + if container_id not in active or active[container_id].done(): + active[container_id] = asyncio.ensure_future( + asyncio.to_thread(_stream_container, container_id, log_path, json_path), + loop=loop, + ) + logger.info("Collecting logs from container: %s", container_name) + + try: + client = docker.from_env() + + for container in client.containers.list(): + if is_service_container(container): + _spawn(container.id, container.name.lstrip("/")) + + def _watch_events() -> None: + for event in client.events( + decode=True, + filters={"type": "container", "event": "start"}, + ): + attrs = event.get("Actor", {}).get("Attributes", {}) + cid = event.get("id", "") + name = attrs.get("name", "") + if cid and is_service_event(attrs): + loop.call_soon_threadsafe(_spawn, cid, name) + + await asyncio.to_thread(_watch_events) + + except asyncio.CancelledError: + for task in active.values(): + task.cancel() + raise + except Exception as exc: + logger.error("Collector error: %s", exc) diff --git a/decnet/composer.py b/decnet/composer.py index 6e12dca..973762e 100644 --- a/decnet/composer.py +++ b/decnet/composer.py @@ -6,6 +6,12 @@ Network model: All service containers for that decky share the base's network namespace via `network_mode: "service:"`. From the outside, every service on a given decky appears to come from the same IP — exactly like a real host. + +Logging model: + Service containers write RFC 5424 lines to stdout. Docker captures them + via the json-file driver. The host-side collector (decnet.web.collector) + streams those logs and writes them to the host log file for the ingester + and rsyslog to consume. No bind mounts or shared volumes are needed. """ from pathlib import Path @@ -17,35 +23,19 @@ from decnet.network import MACVLAN_NETWORK_NAME from decnet.os_fingerprint import get_os_sysctls from decnet.services.registry import get_service -_CONTAINER_LOG_DIR = "/var/log/decnet" - -_LOG_NETWORK = "decnet_logs" - - -def _resolve_log_file(log_file: str) -> tuple[str, str]: - """ - Return (host_dir, container_log_path) for a user-supplied log file path. - - The host path is resolved to absolute so Docker can bind-mount it. - All containers share the same host directory, mounted at _CONTAINER_LOG_DIR. - """ - host_path = Path(log_file).resolve() - host_dir = str(host_path.parent) - container_path = f"{_CONTAINER_LOG_DIR}/{host_path.name}" - return host_dir, container_path +_DOCKER_LOGGING = { + "driver": "json-file", + "options": { + "max-size": "10m", + "max-file": "5", + }, +} def generate_compose(config: DecnetConfig) -> dict: """Build and return the full docker-compose data structure.""" services: dict = {} - log_host_dir: str | None = None - log_container_path: str | None = None - if config.log_file: - log_host_dir, log_container_path = _resolve_log_file(config.log_file) - # Ensure the host log directory exists so Docker doesn't create it as root-owned - Path(log_host_dir).mkdir(parents=True, exist_ok=True) - for decky in config.deckies: base_key = decky.name # e.g. "decky-01" @@ -62,8 +52,6 @@ def generate_compose(config: DecnetConfig) -> dict: } }, } - if config.log_target: - base["networks"][_LOG_NETWORK] = {} # Inject TCP/IP stack sysctls to spoof the claimed OS fingerprint. # Only the base container needs this — service containers inherit the @@ -77,23 +65,18 @@ def generate_compose(config: DecnetConfig) -> dict: for svc_name in decky.services: svc = get_service(svc_name) svc_cfg = decky.service_config.get(svc_name, {}) - fragment = svc.compose_fragment( - decky.name, log_target=config.log_target, service_cfg=svc_cfg - ) + fragment = svc.compose_fragment(decky.name, service_cfg=svc_cfg) # Inject the per-decky base image into build services so containers # vary by distro and don't all fingerprint as debian:bookworm-slim. + # Services that need a fixed upstream image (e.g. conpot) can pre-set + # build.args.BASE_IMAGE in their compose_fragment() to opt out. if "build" in fragment: - fragment["build"].setdefault("args", {})["BASE_IMAGE"] = decky.build_base + args = fragment["build"].setdefault("args", {}) + args.setdefault("BASE_IMAGE", decky.build_base) fragment.setdefault("environment", {}) fragment["environment"]["HOSTNAME"] = decky.hostname - if log_host_dir and log_container_path: - fragment["environment"]["DECNET_LOG_FILE"] = log_container_path - fragment.setdefault("volumes", []) - mount = f"{log_host_dir}:{_CONTAINER_LOG_DIR}" - if mount not in fragment["volumes"]: - fragment["volumes"].append(mount) # Share the base container's network — no own IP needed fragment["network_mode"] = f"service:{base_key}" @@ -103,6 +86,9 @@ def generate_compose(config: DecnetConfig) -> dict: fragment.pop("hostname", None) fragment.pop("networks", None) + # Rotate Docker logs so disk usage is bounded + fragment["logging"] = _DOCKER_LOGGING + services[f"{decky.name}-{svc_name}"] = fragment # Network definitions @@ -111,8 +97,6 @@ def generate_compose(config: DecnetConfig) -> dict: "external": True, # created by network.py before compose up } } - if config.log_target: - networks[_LOG_NETWORK] = {"driver": "bridge", "internal": True} return { "version": "3.8", diff --git a/decnet/config.py b/decnet/config.py index 44cec70..62ffc06 100644 --- a/decnet/config.py +++ b/decnet/config.py @@ -7,11 +7,14 @@ import json from pathlib import Path from typing import Literal -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, field_validator # field_validator used by DeckyConfig from decnet.distros import random_hostname as _random_hostname -STATE_FILE = Path("decnet-state.json") +# Calculate absolute path to the project root (where the config file resides) +_ROOT: Path = Path(__file__).parent.parent.absolute() +STATE_FILE: Path = _ROOT / "decnet-state.json" +DEFAULT_MUTATE_INTERVAL: int = 30 # default rotation interval in minutes def random_hostname(distro_slug: str = "debian") -> str: @@ -29,6 +32,8 @@ class DeckyConfig(BaseModel): archetype: str | None = None # archetype slug if spawned from an archetype profile service_config: dict[str, dict] = {} # optional per-service persona config nmap_os: str = "linux" # OS family for TCP/IP stack spoofing (see os_fingerprint.py) + mutate_interval: int | None = None # automatic rotation interval in minutes + last_mutated: float = 0.0 # timestamp of last mutation @field_validator("services") @classmethod @@ -44,19 +49,9 @@ class DecnetConfig(BaseModel): subnet: str gateway: str deckies: list[DeckyConfig] - log_target: str | None = None # "ip:port" or None - log_file: str | None = None # path for RFC 5424 syslog file output + log_file: str | None = None # host path where the collector writes the log file ipvlan: bool = False # use IPvlan L2 instead of MACVLAN (WiFi-friendly) - - @field_validator("log_target") - @classmethod - def validate_log_target(cls, v: str | None) -> str | None: - if v is None: - return v - parts = v.rsplit(":", 1) - if len(parts) != 2 or not parts[1].isdigit(): - raise ValueError("log_target must be in ip:port format, e.g. 192.168.1.5:5140") - return v + mutate_interval: int | None = DEFAULT_MUTATE_INTERVAL # global automatic rotation interval in minutes def save_state(config: DecnetConfig, compose_path: Path) -> None: diff --git a/decnet/distros.py b/decnet/distros.py index 40fc8ab..809f37a 100644 --- a/decnet/distros.py +++ b/decnet/distros.py @@ -97,8 +97,8 @@ def random_hostname(distro_slug: str = "debian") -> str: """Generate a plausible hostname for the given distro style.""" profile = DISTROS.get(distro_slug) style = profile.hostname_style if profile else "generic" - word = random.choice(_NAME_WORDS) - num = random.randint(10, 99) + word = random.choice(_NAME_WORDS) # nosec B311 + num = random.randint(10, 99) # nosec B311 if style == "rhel": # RHEL/CentOS/Fedora convention: word+num.localdomain @@ -107,7 +107,7 @@ def random_hostname(distro_slug: str = "debian") -> str: return f"{word}-{num}" elif style == "rolling": # Kali/Arch: just a word, no suffix - return f"{word}-{random.choice(_NAME_WORDS)}" + return f"{word}-{random.choice(_NAME_WORDS)}" # nosec B311 else: # Debian/Ubuntu: SRV-WORD-nn return f"SRV-{word.upper()}-{num}" @@ -122,7 +122,7 @@ def get_distro(slug: str) -> DistroProfile: def random_distro() -> DistroProfile: - return random.choice(list(DISTROS.values())) + return random.choice(list(DISTROS.values())) # nosec B311 def all_distros() -> dict[str, DistroProfile]: diff --git a/decnet/engine/__init__.py b/decnet/engine/__init__.py new file mode 100644 index 0000000..f2edfb1 --- /dev/null +++ b/decnet/engine/__init__.py @@ -0,0 +1,15 @@ +from decnet.engine.deployer import ( + COMPOSE_FILE, + _compose_with_retry, + deploy, + status, + teardown, +) + +__all__ = [ + "COMPOSE_FILE", + "_compose_with_retry", + "deploy", + "status", + "teardown", +] diff --git a/decnet/deployer.py b/decnet/engine/deployer.py similarity index 76% rename from decnet/deployer.py rename to decnet/engine/deployer.py index c2324f2..3f03c63 100644 --- a/decnet/deployer.py +++ b/decnet/engine/deployer.py @@ -2,7 +2,8 @@ Deploy, teardown, and status via Docker SDK + subprocess docker compose. """ -import subprocess +import shutil +import subprocess # nosec B404 import time from pathlib import Path @@ -27,11 +28,32 @@ from decnet.network import ( console = Console() COMPOSE_FILE = Path("decnet-compose.yml") +_CANONICAL_LOGGING = Path(__file__).parent.parent.parent / "templates" / "decnet_logging.py" -def _compose(*args: str, compose_file: Path = COMPOSE_FILE) -> None: +def _sync_logging_helper(config: DecnetConfig) -> None: + """Copy the canonical decnet_logging.py into every active template build context.""" + from decnet.services.registry import get_service + seen: set[Path] = set() + for decky in config.deckies: + for svc_name in decky.services: + svc = get_service(svc_name) + if svc is None: + continue + ctx = svc.dockerfile_context() + if ctx is None or ctx in seen: + continue + seen.add(ctx) + dest = ctx / "decnet_logging.py" + if not dest.exists() or dest.read_bytes() != _CANONICAL_LOGGING.read_bytes(): + shutil.copy2(_CANONICAL_LOGGING, dest) + + +def _compose(*args: str, compose_file: Path = COMPOSE_FILE, env: dict | None = None) -> None: + import os cmd = ["docker", "compose", "-f", str(compose_file), *args] - subprocess.run(cmd, check=True) + merged = {**os.environ, **(env or {})} + subprocess.run(cmd, check=True, env=merged) # nosec B603 _PERMANENT_ERRORS = ( @@ -48,12 +70,15 @@ def _compose_with_retry( compose_file: Path = COMPOSE_FILE, retries: int = 3, delay: float = 5.0, + env: dict | None = None, ) -> None: """Run a docker compose command, retrying on transient failures.""" + import os last_exc: subprocess.CalledProcessError | None = None cmd = ["docker", "compose", "-f", str(compose_file), *args] + merged = {**os.environ, **(env or {})} for attempt in range(1, retries + 1): - result = subprocess.run(cmd, capture_output=True, text=True) + result = subprocess.run(cmd, capture_output=True, text=True, env=merged) # nosec B603 if result.returncode == 0: if result.stdout: print(result.stdout, end="") @@ -80,10 +105,9 @@ def _compose_with_retry( raise last_exc -def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False) -> None: +def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False, parallel: bool = False) -> None: client = docker.from_env() - # --- Network setup --- ip_list = [d.ip for d in config.deckies] decky_range = ips_to_range(ip_list) host_ip = get_host_ip(config.interface) @@ -110,7 +134,8 @@ def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False) ) setup_host_macvlan(config.interface, host_ip, decky_range) - # --- Compose generation --- + _sync_logging_helper(config) + compose_path = write_compose(config, COMPOSE_FILE) console.print(f"[bold cyan]Compose file written[/] → {compose_path}") @@ -118,16 +143,24 @@ def deploy(config: DecnetConfig, dry_run: bool = False, no_cache: bool = False) console.print("[yellow]Dry run — no containers started.[/]") return - # --- Save state before bring-up --- save_state(config, compose_path) - # --- Bring up --- - console.print("[bold cyan]Building images and starting deckies...[/]") - if no_cache: - _compose_with_retry("build", "--no-cache", compose_file=compose_path) - _compose_with_retry("up", "--build", "-d", compose_file=compose_path) + build_env = {"DOCKER_BUILDKIT": "1"} if parallel else {} + + console.print("[bold cyan]Building images and starting deckies...[/]") + build_args = ["build"] + if no_cache: + build_args.append("--no-cache") + + if parallel: + console.print("[bold cyan]Parallel build enabled — building all images concurrently...[/]") + _compose_with_retry(*build_args, compose_file=compose_path, env=build_env) + _compose_with_retry("up", "-d", compose_file=compose_path, env=build_env) + else: + if no_cache: + _compose_with_retry("build", "--no-cache", compose_file=compose_path) + _compose_with_retry("up", "--build", "-d", compose_file=compose_path) - # --- Status summary --- _print_status(config) @@ -141,7 +174,6 @@ def teardown(decky_id: str | None = None) -> None: client = docker.from_env() if decky_id: - # Bring down only the services matching this decky svc_names = [f"{decky_id}-{svc}" for svc in [d.services for d in config.deckies if d.name == decky_id]] if not svc_names: console.print(f"[red]Decky '{decky_id}' not found in current deployment.[/]") @@ -159,6 +191,7 @@ def teardown(decky_id: str | None = None) -> None: teardown_host_macvlan(decky_range) remove_macvlan_network(client) clear_state() + net_driver = "IPvlan" if config.ipvlan else "MACVLAN" console.print(f"[green]All deckies torn down. {net_driver} network removed.[/]") @@ -179,7 +212,7 @@ def status() -> None: table.add_column("Hostname") table.add_column("Status") - running = {c.name: c.status for c in client.containers.list(all=True)} + running = {c.name: c.status for c in client.containers.list(all=True, ignore_removed=True)} for decky in config.deckies: statuses = [] diff --git a/decnet/env.py b/decnet/env.py new file mode 100644 index 0000000..59e694f --- /dev/null +++ b/decnet/env.py @@ -0,0 +1,64 @@ +import os +from pathlib import Path +from dotenv import load_dotenv + +# Calculate absolute path to the project root +_ROOT: Path = Path(__file__).parent.parent.absolute() + +# Load .env.local first, then fallback to .env +load_dotenv(_ROOT / ".env.local") +load_dotenv(_ROOT / ".env") + + +def _port(name: str, default: int) -> int: + raw = os.environ.get(name, str(default)) + try: + value = int(raw) + except ValueError: + raise ValueError(f"Environment variable '{name}' must be an integer, got '{raw}'.") + if not (1 <= value <= 65535): + raise ValueError(f"Environment variable '{name}' must be 1–65535, got {value}.") + return value + + +def _require_env(name: str) -> str: + """Return the env var value or raise at startup if it is unset or a known-bad default.""" + _KNOWN_BAD = {"fallback-secret-key-change-me", "admin", "secret", "password", "changeme"} + value = os.environ.get(name) + if not value: + raise ValueError( + f"Required environment variable '{name}' is not set. " + f"Set it in .env.local or export it before starting DECNET." + ) + + if any(k.startswith("PYTEST") for k in os.environ): + return value + + if value.lower() in _KNOWN_BAD: + raise ValueError( + f"Environment variable '{name}' is set to an insecure default ('{value}'). " + f"Choose a strong, unique value before starting DECNET." + ) + return value + + +# API Options +DECNET_API_HOST: str = os.environ.get("DECNET_API_HOST", "0.0.0.0") # nosec B104 +DECNET_API_PORT: int = _port("DECNET_API_PORT", 8000) +DECNET_JWT_SECRET: str = _require_env("DECNET_JWT_SECRET") +DECNET_INGEST_LOG_FILE: str | None = os.environ.get("DECNET_INGEST_LOG_FILE", "/var/log/decnet/decnet.log") + +# Web Dashboard Options +DECNET_WEB_HOST: str = os.environ.get("DECNET_WEB_HOST", "0.0.0.0") # nosec B104 +DECNET_WEB_PORT: int = _port("DECNET_WEB_PORT", 8080) +DECNET_ADMIN_USER: str = os.environ.get("DECNET_ADMIN_USER", "admin") +DECNET_ADMIN_PASSWORD: str = os.environ.get("DECNET_ADMIN_PASSWORD", "admin") +DECNET_DEVELOPER: bool = os.environ.get("DECNET_DEVELOPER", "False").lower() == "true" + +# CORS — comma-separated list of allowed origins for the web dashboard API. +# Defaults to the configured web host/port. Override with DECNET_CORS_ORIGINS if needed. +# Example: DECNET_CORS_ORIGINS=http://192.168.1.50:9090,https://dashboard.example.com +_web_hostname: str = "localhost" if DECNET_WEB_HOST in ("0.0.0.0", "127.0.0.1", "::") else DECNET_WEB_HOST # nosec B104 +_cors_default: str = f"http://{_web_hostname}:{DECNET_WEB_PORT}" +_cors_raw: str = os.environ.get("DECNET_CORS_ORIGINS", _cors_default) +DECNET_CORS_ORIGINS: list[str] = [o.strip() for o in _cors_raw.split(",") if o.strip()] diff --git a/decnet/fleet.py b/decnet/fleet.py new file mode 100644 index 0000000..cd9984e --- /dev/null +++ b/decnet/fleet.py @@ -0,0 +1,179 @@ +""" +Fleet builder — shared logic for constructing DeckyConfig lists. + +Used by both the CLI and the web API router to build deckies from +flags or INI config. Lives here (not in cli.py) so that the web layer +and the mutation engine can import it without depending on the CLI. +""" + +import random +from typing import Optional + +from decnet.archetypes import Archetype, get_archetype +from decnet.config import DeckyConfig, random_hostname +from decnet.distros import all_distros, get_distro, random_distro +from decnet.ini_loader import IniConfig +from decnet.services.registry import all_services + + +def all_service_names() -> list[str]: + """Return all registered service names from the live plugin registry.""" + return sorted(all_services().keys()) + + +def resolve_distros( + distros_explicit: list[str] | None, + randomize_distros: bool, + n: int, + archetype: Archetype | None = None, +) -> list[str]: + """Return a list of n distro slugs based on flags or archetype preference.""" + if distros_explicit: + return [distros_explicit[i % len(distros_explicit)] for i in range(n)] + if randomize_distros: + return [random_distro().slug for _ in range(n)] + if archetype: + pool = archetype.preferred_distros + return [pool[i % len(pool)] for i in range(n)] + slugs = list(all_distros().keys()) + return [slugs[i % len(slugs)] for i in range(n)] + + +def build_deckies( + n: int, + ips: list[str], + services_explicit: list[str] | None, + randomize_services: bool, + distros_explicit: list[str] | None = None, + randomize_distros: bool = False, + archetype: Archetype | None = None, + mutate_interval: Optional[int] = None, +) -> list[DeckyConfig]: + """Build a list of DeckyConfigs from CLI-style flags.""" + deckies = [] + used_combos: set[frozenset] = set() + distro_slugs = resolve_distros(distros_explicit, randomize_distros, n, archetype) + + for i, ip in enumerate(ips): + name = f"decky-{i + 1:02d}" + distro = get_distro(distro_slugs[i]) + hostname = random_hostname(distro.slug) + + if services_explicit: + svc_list = services_explicit + elif archetype: + svc_list = list(archetype.services) + elif randomize_services: + svc_pool = all_service_names() + attempts = 0 + while True: + count = random.randint(1, min(3, len(svc_pool))) # nosec B311 + chosen = frozenset(random.sample(svc_pool, count)) # nosec B311 + attempts += 1 + if chosen not in used_combos or attempts > 20: + break + svc_list = list(chosen) + used_combos.add(chosen) + else: + raise ValueError("Provide services_explicit, archetype, or randomize_services=True.") + + deckies.append( + DeckyConfig( + name=name, + ip=ip, + services=svc_list, + distro=distro.slug, + base_image=distro.image, + build_base=distro.build_base, + hostname=hostname, + archetype=archetype.slug if archetype else None, + nmap_os=archetype.nmap_os if archetype else "linux", + mutate_interval=mutate_interval, + ) + ) + return deckies + + +def build_deckies_from_ini( + ini: IniConfig, + subnet_cidr: str, + gateway: str, + host_ip: str, + randomize: bool, + cli_mutate_interval: int | None = None, +) -> list[DeckyConfig]: + """Build DeckyConfig list from an IniConfig, auto-allocating missing IPs.""" + from ipaddress import IPv4Address, IPv4Network + import time + now = time.time() + + explicit_ips: set[IPv4Address] = { + IPv4Address(s.ip) for s in ini.deckies if s.ip + } + + net = IPv4Network(subnet_cidr, strict=False) + reserved = { + net.network_address, + net.broadcast_address, + IPv4Address(gateway), + IPv4Address(host_ip), + } | explicit_ips + + auto_pool = (str(addr) for addr in net.hosts() if addr not in reserved) + + deckies: list[DeckyConfig] = [] + for spec in ini.deckies: + arch: Archetype | None = None + if spec.archetype: + arch = get_archetype(spec.archetype) + + distro_pool = arch.preferred_distros if arch else list(all_distros().keys()) + distro = get_distro(distro_pool[len(deckies) % len(distro_pool)]) + hostname = random_hostname(distro.slug) + + ip = spec.ip or next(auto_pool, None) + if ip is None: + raise ValueError(f"Not enough free IPs in {subnet_cidr} while assigning IP for '{spec.name}'.") + + if spec.services: + known = set(all_service_names()) + unknown = [s for s in spec.services if s not in known] + if unknown: + raise ValueError( + f"Unknown service(s) in [{spec.name}]: {unknown}. " + f"Available: {all_service_names()}" + ) + svc_list = spec.services + elif arch: + svc_list = list(arch.services) + elif randomize: + svc_pool = all_service_names() + count = random.randint(1, min(3, len(svc_pool))) # nosec B311 + svc_list = random.sample(svc_pool, count) # nosec B311 + else: + raise ValueError( + f"Decky '[{spec.name}]' has no services= in config. " + "Add services=, archetype=, or use --randomize-services." + ) + + resolved_nmap_os = spec.nmap_os or (arch.nmap_os if arch else "linux") + + decky_mutate_interval = cli_mutate_interval + if decky_mutate_interval is None: + decky_mutate_interval = spec.mutate_interval if spec.mutate_interval is not None else ini.mutate_interval + + deckies.append(DeckyConfig( + name=spec.name, + ip=ip, + services=svc_list, + distro=distro.slug, + base_image=distro.image, + build_base=distro.build_base, + hostname=hostname, + archetype=arch.slug if arch else None, + service_config=spec.service_config, + nmap_os=resolved_nmap_os, + mutate_interval=decky_mutate_interval, + last_mutated=now, + )) + return deckies diff --git a/decnet/ini_loader.py b/decnet/ini_loader.py index 1ecb493..81bfda9 100644 --- a/decnet/ini_loader.py +++ b/decnet/ini_loader.py @@ -6,7 +6,6 @@ Format: net=192.168.1.0/24 gw=192.168.1.1 interface=wlp6s0 - log_target=192.168.1.5:5140 # optional [hostname-1] ip=192.168.1.82 # optional @@ -54,6 +53,7 @@ class DeckySpec: archetype: str | None = None service_config: dict[str, dict] = field(default_factory=dict) nmap_os: str | None = None # explicit OS family override (linux/windows/bsd/embedded/cisco) + mutate_interval: int | None = None @dataclass @@ -70,7 +70,7 @@ class IniConfig: subnet: str | None = None gateway: str | None = None interface: str | None = None - log_target: str | None = None + mutate_interval: int | None = None deckies: list[DeckySpec] = field(default_factory=list) custom_services: list[CustomServiceSpec] = field(default_factory=list) @@ -81,7 +81,33 @@ def load_ini(path: str | Path) -> IniConfig: read = cp.read(str(path)) if not read: raise FileNotFoundError(f"Config file not found: {path}") + return _parse_configparser(cp) + +def load_ini_from_string(content: str) -> IniConfig: + """Parse a DECNET INI string and return an IniConfig.""" + validate_ini_string(content) + cp = configparser.ConfigParser() + cp.read_string(content) + return _parse_configparser(cp) + + +def validate_ini_string(content: str) -> None: + """Perform safety and sanity checks on raw INI content string.""" + # 1. Size limit (e.g. 512KB) + if len(content) > 512 * 1024: + raise ValueError("INI content too large (max 512KB).") + + # 2. Ensure it's not empty + if not content.strip(): + raise ValueError("INI content is empty.") + + # 3. Basic structure check (must contain at least one section header) + if "[" not in content or "]" not in content: + raise ValueError("Invalid INI format: no sections found.") + + +def _parse_configparser(cp: configparser.ConfigParser) -> IniConfig: cfg = IniConfig() if cp.has_section("general"): @@ -89,14 +115,24 @@ def load_ini(path: str | Path) -> IniConfig: cfg.subnet = g.get("net") cfg.gateway = g.get("gw") cfg.interface = g.get("interface") - cfg.log_target = g.get("log_target") or g.get("log-target") + + from decnet.services.registry import all_services + known_services = set(all_services().keys()) # First pass: collect decky sections and custom service definitions for section in cp.sections(): if section == "general": continue + + # A service sub-section is identified if the section name has at least one dot + # AND the last segment is a known service name. + # e.g. "decky-01.ssh" -> sub-section + # e.g. "decky.webmail" -> decky section (if "webmail" is not a service) if "." in section: - continue # subsections handled in second pass + _, _, last_segment = section.rpartition(".") + if last_segment in known_services: + continue # sub-section handled in second pass + if section.startswith("custom-"): # Bring-your-own service definition s = cp[section] @@ -115,17 +151,30 @@ def load_ini(path: str | Path) -> IniConfig: services = [sv.strip() for sv in svc_raw.split(",")] if svc_raw else None archetype = s.get("archetype") nmap_os = s.get("nmap_os") or s.get("nmap-os") or None + + mi_raw = s.get("mutate_interval") or s.get("mutate-interval") + mutate_interval = None + if mi_raw: + try: + mutate_interval = int(mi_raw) + except ValueError: + raise ValueError(f"[{section}] mutate_interval= must be an integer, got '{mi_raw}'") + amount_raw = s.get("amount", "1") try: amount = int(amount_raw) if amount < 1: raise ValueError - except ValueError: + if amount > 100: + raise ValueError(f"[{section}] amount={amount} exceeds maximum allowed (100).") + except ValueError as e: + if "exceeds maximum" in str(e): + raise e raise ValueError(f"[{section}] amount= must be a positive integer, got '{amount_raw}'") if amount == 1: cfg.deckies.append(DeckySpec( - name=section, ip=ip, services=services, archetype=archetype, nmap_os=nmap_os, + name=section, ip=ip, services=services, archetype=archetype, nmap_os=nmap_os, mutate_interval=mutate_interval, )) else: # Expand into N deckies; explicit ip is ignored (can't share one IP) @@ -141,6 +190,7 @@ def load_ini(path: str | Path) -> IniConfig: services=services, archetype=archetype, nmap_os=nmap_os, + mutate_interval=mutate_interval, )) # Second pass: collect per-service subsections [decky-name.service] @@ -149,7 +199,11 @@ def load_ini(path: str | Path) -> IniConfig: for section in cp.sections(): if "." not in section: continue - decky_name, _, svc_name = section.partition(".") + + decky_name, dot, svc_name = section.rpartition(".") + if svc_name not in known_services: + continue # not a service sub-section + svc_cfg = {k: v for k, v in cp[section].items()} if decky_name in decky_map: # Direct match — single decky diff --git a/decnet/logging/file_handler.py b/decnet/logging/file_handler.py index 98d0f83..50a83d1 100644 --- a/decnet/logging/file_handler.py +++ b/decnet/logging/file_handler.py @@ -49,11 +49,10 @@ def _get_logger() -> logging.Logger: def write_syslog(line: str) -> None: """Write a single RFC 5424 syslog line to the rotating log file.""" try: - _get_logger().info(line) - except Exception: + _get_logger().info(line) + except Exception: # nosec B110 pass - def get_log_path() -> Path: """Return the configured log file path (for tests/inspection).""" return Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) diff --git a/decnet/mutator/__init__.py b/decnet/mutator/__init__.py new file mode 100644 index 0000000..41d5792 --- /dev/null +++ b/decnet/mutator/__init__.py @@ -0,0 +1,3 @@ +from decnet.mutator.engine import mutate_all, mutate_decky, run_watch_loop + +__all__ = ["mutate_all", "mutate_decky", "run_watch_loop"] diff --git a/decnet/mutator/engine.py b/decnet/mutator/engine.py new file mode 100644 index 0000000..eadbb70 --- /dev/null +++ b/decnet/mutator/engine.py @@ -0,0 +1,122 @@ +""" +Mutation Engine for DECNET. +Handles dynamic rotation of exposed honeypot services over time. +""" + +import random +import time +from typing import Optional + +from rich.console import Console + +from decnet.archetypes import get_archetype +from decnet.fleet import all_service_names +from decnet.composer import write_compose +from decnet.config import DeckyConfig, load_state, save_state +from decnet.engine import _compose_with_retry + +import subprocess # nosec B404 + +console = Console() + + +def mutate_decky(decky_name: str) -> bool: + """ + Perform an Intra-Archetype Shuffle for a specific decky. + Returns True if mutation succeeded, False otherwise. + """ + state = load_state() + if state is None: + console.print("[red]No active deployment found (no decnet-state.json).[/]") + return False + + config, compose_path = state + decky: Optional[DeckyConfig] = next((d for d in config.deckies if d.name == decky_name), None) + + if not decky: + console.print(f"[red]Decky '{decky_name}' not found in state.[/]") + return False + + if decky.archetype: + try: + arch = get_archetype(decky.archetype) + svc_pool = list(arch.services) + except ValueError: + svc_pool = all_service_names() + else: + svc_pool = all_service_names() + + if not svc_pool: + console.print(f"[yellow]No services available for mutating '{decky_name}'.[/]") + return False + + current_services = set(decky.services) + + attempts = 0 + while True: + count = random.randint(1, min(3, len(svc_pool))) # nosec B311 + chosen = set(random.sample(svc_pool, count)) # nosec B311 + attempts += 1 + if chosen != current_services or attempts > 20: + break + + decky.services = list(chosen) + decky.last_mutated = time.time() + + save_state(config, compose_path) + write_compose(config, compose_path) + + console.print(f"[cyan]Mutating '{decky_name}' to services: {', '.join(decky.services)}[/]") + + try: + _compose_with_retry("up", "-d", "--remove-orphans", compose_file=compose_path) + except subprocess.CalledProcessError as e: + console.print(f"[red]Failed to mutate '{decky_name}': {e.stderr}[/]") + return False + + return True + + +def mutate_all(force: bool = False) -> None: + """ + Check all deckies and mutate those that are due. + If force=True, mutates all deckies regardless of schedule. + """ + state = load_state() + if state is None: + console.print("[red]No active deployment found.[/]") + return + + config, _ = state + now = time.time() + + mutated_count = 0 + for decky in config.deckies: + interval_mins = decky.mutate_interval or config.mutate_interval + if interval_mins is None and not force: + continue + + if force: + due = True + else: + elapsed_secs = now - decky.last_mutated + due = elapsed_secs >= (interval_mins * 60) + + if due: + success = mutate_decky(decky.name) + if success: + mutated_count += 1 + + if mutated_count == 0 and not force: + console.print("[dim]No deckies are due for mutation.[/]") + + +def run_watch_loop(poll_interval_secs: int = 10) -> None: + """Run an infinite loop checking for deckies that need mutation.""" + console.print(f"[green]DECNET Mutator Watcher started (polling every {poll_interval_secs}s).[/]") + try: + while True: + mutate_all(force=False) + time.sleep(poll_interval_secs) + except KeyboardInterrupt: + console.print("\n[dim]Mutator watcher stopped.[/]") diff --git a/decnet/network.py b/decnet/network.py index a68ca7f..aa88432 100644 --- a/decnet/network.py +++ b/decnet/network.py @@ -9,7 +9,7 @@ Handles: """ import os -import subprocess +import subprocess # nosec B404 from ipaddress import IPv4Address, IPv4Interface, IPv4Network import docker @@ -24,7 +24,7 @@ HOST_IPVLAN_IFACE = "decnet_ipvlan0" # --------------------------------------------------------------------------- def _run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess: - return subprocess.run(cmd, capture_output=True, text=True, check=check) + return subprocess.run(cmd, capture_output=True, text=True, check=check) # nosec B603 B404 def detect_interface() -> str: diff --git a/decnet/os_fingerprint.py b/decnet/os_fingerprint.py index d8f088e..094bbc0 100644 --- a/decnet/os_fingerprint.py +++ b/decnet/os_fingerprint.py @@ -5,17 +5,31 @@ Maps an nmap OS family slug to a dict of Linux kernel sysctls that, when applied to a container's network namespace, make its TCP/IP stack behaviour resemble the claimed OS as closely as possible within the Linux kernel's constraints. +All sysctls listed here are network-namespace-scoped and safe to set per-container +without --privileged (beyond the NET_ADMIN capability already granted). + Primary discriminator leveraged by nmap: net.ipv4.ip_default_ttl (TTL) Linux → 64 Windows → 128 BSD (FreeBSD/macOS)→ 64 (different TCP options, but same TTL as Linux) Embedded / network → 255 -Secondary tuning (TCP behaviour): - net.ipv4.tcp_syn_retries – SYN retransmits before giving up +Secondary discriminators (nmap OPS / WIN / ECN / T2–T6 probe groups): + net.ipv4.tcp_syn_retries – SYN retransmits before giving up + net.ipv4.tcp_timestamps – TCP timestamp option (OPS probes); Windows = off + net.ipv4.tcp_window_scaling – Window scale option; embedded/Cisco typically off + net.ipv4.tcp_sack – Selective ACK option; absent on most embedded stacks + net.ipv4.tcp_ecn – ECN negotiation; Linux offers (2), Windows off (0) + net.ipv4.ip_no_pmtu_disc – DF bit in ICMP replies (IE probes); embedded on + net.ipv4.tcp_fin_timeout – FIN_WAIT_2 seconds (T2–T6 timing); Windows shorter + +ICMP tuning (nmap IE / U1 probe groups): + net.ipv4.icmp_ratelimit – Min ms between ICMP error replies; Windows = 0 (none) + net.ipv4.icmp_ratemask – Bitmask of ICMP types subject to rate limiting Note: net.core.rmem_default is a global (non-namespaced) sysctl and cannot be -set per-container without --privileged; it is intentionally excluded. +set per-container without --privileged; TCP window size is already correct for +Windows (64240) from the kernel's default tcp_rmem settings. """ from __future__ import annotations @@ -24,27 +38,69 @@ OS_SYSCTLS: dict[str, dict[str, str]] = { "linux": { "net.ipv4.ip_default_ttl": "64", "net.ipv4.tcp_syn_retries": "6", + "net.ipv4.tcp_timestamps": "1", + "net.ipv4.tcp_window_scaling": "1", + "net.ipv4.tcp_sack": "1", + "net.ipv4.tcp_ecn": "2", + "net.ipv4.ip_no_pmtu_disc": "0", + "net.ipv4.tcp_fin_timeout": "60", + "net.ipv4.icmp_ratelimit": "1000", + "net.ipv4.icmp_ratemask": "6168", }, "windows": { "net.ipv4.ip_default_ttl": "128", "net.ipv4.tcp_syn_retries": "2", + "net.ipv4.tcp_timestamps": "0", + "net.ipv4.tcp_window_scaling": "1", + "net.ipv4.tcp_sack": "1", + "net.ipv4.tcp_ecn": "0", + "net.ipv4.ip_no_pmtu_disc": "0", + "net.ipv4.tcp_fin_timeout": "30", + "net.ipv4.icmp_ratelimit": "0", + "net.ipv4.icmp_ratemask": "0", }, "bsd": { "net.ipv4.ip_default_ttl": "64", "net.ipv4.tcp_syn_retries": "6", + "net.ipv4.tcp_timestamps": "1", + "net.ipv4.tcp_window_scaling": "1", + "net.ipv4.tcp_sack": "1", + "net.ipv4.tcp_ecn": "0", + "net.ipv4.ip_no_pmtu_disc": "0", + "net.ipv4.tcp_fin_timeout": "60", + "net.ipv4.icmp_ratelimit": "250", + "net.ipv4.icmp_ratemask": "6168", }, "embedded": { "net.ipv4.ip_default_ttl": "255", "net.ipv4.tcp_syn_retries": "3", + "net.ipv4.tcp_timestamps": "0", + "net.ipv4.tcp_window_scaling": "0", + "net.ipv4.tcp_sack": "0", + "net.ipv4.tcp_ecn": "0", + "net.ipv4.ip_no_pmtu_disc": "1", + "net.ipv4.tcp_fin_timeout": "15", + "net.ipv4.icmp_ratelimit": "0", + "net.ipv4.icmp_ratemask": "0", }, "cisco": { "net.ipv4.ip_default_ttl": "255", "net.ipv4.tcp_syn_retries": "2", + "net.ipv4.tcp_timestamps": "0", + "net.ipv4.tcp_window_scaling": "0", + "net.ipv4.tcp_sack": "0", + "net.ipv4.tcp_ecn": "0", + "net.ipv4.ip_no_pmtu_disc": "1", + "net.ipv4.tcp_fin_timeout": "15", + "net.ipv4.icmp_ratelimit": "0", + "net.ipv4.icmp_ratemask": "0", }, } _DEFAULT_OS = "linux" +_REQUIRED_SYSCTLS: frozenset[str] = frozenset(OS_SYSCTLS["linux"].keys()) + def get_os_sysctls(nmap_os: str) -> dict[str, str]: """Return the sysctl dict for *nmap_os*. Falls back to Linux on unknown slugs.""" @@ -54,3 +110,4 @@ def get_os_sysctls(nmap_os: str) -> dict[str, str]: def all_os_families() -> list[str]: """Return all registered nmap OS family slugs.""" return list(OS_SYSCTLS.keys()) + diff --git a/decnet/services/conpot.py b/decnet/services/conpot.py index 073d8dc..643eac6 100644 --- a/decnet/services/conpot.py +++ b/decnet/services/conpot.py @@ -1,26 +1,35 @@ +from pathlib import Path from decnet.services.base import BaseService class ConpotService(BaseService): """ICS/SCADA honeypot covering Modbus (502), SNMP (161 UDP), and HTTP (80). - Uses the official honeynet/conpot image which ships a default ICS profile - that emulates a Siemens S7-200 PLC. + Uses a custom build context wrapping the official honeynet/conpot image + to fix Modbus binding to port 502. """ name = "conpot" ports = [502, 161, 80] - default_image = "honeynet/conpot" + default_image = "build" def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: + env = { + "CONPOT_TEMPLATE": "default", + "NODE_NAME": decky_name, + } + if log_target: + env["LOG_TARGET"] = log_target + return { - "image": "honeynet/conpot", + "build": { + "context": str(self.dockerfile_context()), + "args": {"BASE_IMAGE": "honeynet/conpot:latest"}, + }, "container_name": f"{decky_name}-conpot", "restart": "unless-stopped", - "environment": { - "CONPOT_TEMPLATE": "default", - }, + "environment": env, } def dockerfile_context(self): - return None + return Path(__file__).parent.parent.parent / "templates" / "conpot" diff --git a/decnet/services/real_ssh.py b/decnet/services/real_ssh.py deleted file mode 100644 index 328fb30..0000000 --- a/decnet/services/real_ssh.py +++ /dev/null @@ -1,46 +0,0 @@ -from pathlib import Path - -from decnet.services.base import BaseService - -TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "real_ssh" - - -class RealSSHService(BaseService): - """ - Fully interactive OpenSSH server — no honeypot emulation. - - Used for the deaddeck (entry-point machine). Attackers get a real shell. - Credentials are intentionally weak to invite exploitation. - - service_cfg keys: - password Root password (default: "admin") - hostname Override container hostname - """ - - name = "real_ssh" - ports = [22] - default_image = "build" - - def compose_fragment( - self, - decky_name: str, - log_target: str | None = None, - service_cfg: dict | None = None, - ) -> dict: - cfg = service_cfg or {} - env: dict = { - "SSH_ROOT_PASSWORD": cfg.get("password", "admin"), - } - if "hostname" in cfg: - env["SSH_HOSTNAME"] = cfg["hostname"] - - return { - "build": {"context": str(TEMPLATES_DIR)}, - "container_name": f"{decky_name}-real-ssh", - "restart": "unless-stopped", - "cap_add": ["NET_BIND_SERVICE"], - "environment": env, - } - - def dockerfile_context(self) -> Path: - return TEMPLATES_DIR diff --git a/decnet/services/smtp_relay.py b/decnet/services/smtp_relay.py new file mode 100644 index 0000000..7656e19 --- /dev/null +++ b/decnet/services/smtp_relay.py @@ -0,0 +1,43 @@ +from pathlib import Path + +from decnet.services.base import BaseService + +# Reuses the same template as the smtp service — only difference is +# SMTP_OPEN_RELAY=1 in the environment, which enables the open relay persona. +_TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "smtp" + + +class SMTPRelayService(BaseService): + """SMTP open relay bait — accepts any RCPT TO and delivers messages.""" + + name = "smtp_relay" + ports = [25, 587] + default_image = "build" + + def compose_fragment( + self, + decky_name: str, + log_target: str | None = None, + service_cfg: dict | None = None, + ) -> dict: + cfg = service_cfg or {} + fragment: dict = { + "build": {"context": str(_TEMPLATES_DIR)}, + "container_name": f"{decky_name}-smtp_relay", + "restart": "unless-stopped", + "cap_add": ["NET_BIND_SERVICE"], + "environment": { + "NODE_NAME": decky_name, + "SMTP_OPEN_RELAY": "1", + }, + } + if log_target: + fragment["environment"]["LOG_TARGET"] = log_target + if "banner" in cfg: + fragment["environment"]["SMTP_BANNER"] = cfg["banner"] + if "mta" in cfg: + fragment["environment"]["SMTP_MTA"] = cfg["mta"] + return fragment + + def dockerfile_context(self) -> Path: + return _TEMPLATES_DIR diff --git a/decnet/services/ssh.py b/decnet/services/ssh.py index 2377d57..db2ce54 100644 --- a/decnet/services/ssh.py +++ b/decnet/services/ssh.py @@ -1,12 +1,26 @@ from pathlib import Path + from decnet.services.base import BaseService -TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "cowrie" +TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "ssh" class SSHService(BaseService): + """ + Interactive OpenSSH server for general-purpose deckies. + + Replaced Cowrie emulation with a real sshd so fingerprinting tools and + experienced attackers cannot trivially identify the honeypot. Auth events, + sudo activity, and interactive commands are all forwarded to stdout as + RFC 5424 via the rsyslog bridge baked into the image. + + service_cfg keys: + password Root password (default: "admin") + hostname Override container hostname + """ + name = "ssh" - ports = [22, 2222] + ports = [22] default_image = "build" def compose_fragment( @@ -17,28 +31,10 @@ class SSHService(BaseService): ) -> dict: cfg = service_cfg or {} env: dict = { - "NODE_NAME": decky_name, - "COWRIE_HOSTNAME": decky_name, - "COWRIE_HONEYPOT_LISTEN_ENDPOINTS": "tcp:22:interface=0.0.0.0 tcp:2222:interface=0.0.0.0", - "COWRIE_SSH_LISTEN_ENDPOINTS": "tcp:22:interface=0.0.0.0 tcp:2222:interface=0.0.0.0", + "SSH_ROOT_PASSWORD": cfg.get("password", "admin"), } - if log_target: - host, port = log_target.rsplit(":", 1) - env["COWRIE_OUTPUT_TCP_ENABLED"] = "true" - env["COWRIE_OUTPUT_TCP_HOST"] = host - env["COWRIE_OUTPUT_TCP_PORT"] = port - - # Optional persona overrides - if "kernel_version" in cfg: - env["COWRIE_HONEYPOT_KERNEL_VERSION"] = cfg["kernel_version"] - if "kernel_build_string" in cfg: - env["COWRIE_HONEYPOT_KERNEL_BUILD_STRING"] = cfg["kernel_build_string"] - if "hardware_platform" in cfg: - env["COWRIE_HONEYPOT_HARDWARE_PLATFORM"] = cfg["hardware_platform"] - if "ssh_banner" in cfg: - env["COWRIE_SSH_VERSION"] = cfg["ssh_banner"] - if "users" in cfg: - env["COWRIE_USERDB_ENTRIES"] = cfg["users"] + if "hostname" in cfg: + env["SSH_HOSTNAME"] = cfg["hostname"] return { "build": {"context": str(TEMPLATES_DIR)}, diff --git a/decnet/services/telnet.py b/decnet/services/telnet.py index 9395d34..f022fac 100644 --- a/decnet/services/telnet.py +++ b/decnet/services/telnet.py @@ -1,31 +1,47 @@ +from pathlib import Path + from decnet.services.base import BaseService +TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" / "telnet" + class TelnetService(BaseService): + """ + Real telnetd using busybox telnetd + rsyslog logging pipeline. + + Replaced Cowrie emulation (which also started an SSH daemon on port 22) + with a real busybox telnetd so only port 23 is exposed and auth events + are logged as RFC 5424 via the same rsyslog bridge used by the SSH service. + + service_cfg keys: + password Root password (default: "admin") + hostname Override container hostname + """ + name = "telnet" ports = [23] - default_image = "cowrie/cowrie" + default_image = "build" - def compose_fragment(self, decky_name: str, log_target: str | None = None, service_cfg: dict | None = None) -> dict: + def compose_fragment( + self, + decky_name: str, + log_target: str | None = None, + service_cfg: dict | None = None, + ) -> dict: + cfg = service_cfg or {} env: dict = { - "COWRIE_HONEYPOT_HOSTNAME": decky_name, - "COWRIE_TELNET_ENABLED": "true", - "COWRIE_TELNET_LISTEN_ENDPOINTS": "tcp:23:interface=0.0.0.0", - # Disable SSH so this container is telnet-only - "COWRIE_SSH_ENABLED": "false", + "TELNET_ROOT_PASSWORD": cfg.get("password", "admin"), } - if log_target: - host, port = log_target.rsplit(":", 1) - env["COWRIE_OUTPUT_TCP_ENABLED"] = "true" - env["COWRIE_OUTPUT_TCP_HOST"] = host - env["COWRIE_OUTPUT_TCP_PORT"] = port + if "hostname" in cfg: + env["TELNET_HOSTNAME"] = cfg["hostname"] + return { - "image": "cowrie/cowrie", + "build": {"context": str(TEMPLATES_DIR)}, "container_name": f"{decky_name}-telnet", "restart": "unless-stopped", "cap_add": ["NET_BIND_SERVICE"], "environment": env, } - def dockerfile_context(self): - return None + def dockerfile_context(self) -> Path: + return TEMPLATES_DIR diff --git a/decnet/web/api.py b/decnet/web/api.py new file mode 100644 index 0000000..29529df --- /dev/null +++ b/decnet/web/api.py @@ -0,0 +1,72 @@ +import asyncio +import logging +import os +from contextlib import asynccontextmanager +from typing import Any, AsyncGenerator, Optional + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from decnet.env import DECNET_CORS_ORIGINS, DECNET_DEVELOPER, DECNET_INGEST_LOG_FILE +from decnet.web.dependencies import repo +from decnet.collector import log_collector_worker +from decnet.web.ingester import log_ingestion_worker +from decnet.web.router import api_router + +log = logging.getLogger(__name__) +ingestion_task: Optional[asyncio.Task[Any]] = None +collector_task: Optional[asyncio.Task[Any]] = None + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + global ingestion_task, collector_task + + for attempt in range(1, 6): + try: + await repo.initialize() + break + except Exception as exc: + log.warning("DB init attempt %d/5 failed: %s", attempt, exc) + if attempt == 5: + log.error("DB failed to initialize after 5 attempts — startup may be degraded") + await asyncio.sleep(0.5) + + # Start background ingestion task + if ingestion_task is None or ingestion_task.done(): + ingestion_task = asyncio.create_task(log_ingestion_worker(repo)) + + # Start Docker log collector (writes to log file; ingester reads from it) + _log_file = os.environ.get("DECNET_INGEST_LOG_FILE", DECNET_INGEST_LOG_FILE) + if _log_file and (collector_task is None or collector_task.done()): + collector_task = asyncio.create_task(log_collector_worker(_log_file)) + else: + log.warning("DECNET_INGEST_LOG_FILE not set — Docker log collection disabled.") + + yield + + # Shutdown background tasks + for task in (ingestion_task, collector_task): + if task: + task.cancel() + + +app: FastAPI = FastAPI( + title="DECNET Web Dashboard API", + version="1.0.0", + lifespan=lifespan, + docs_url="/docs" if DECNET_DEVELOPER else None, + redoc_url="/redoc" if DECNET_DEVELOPER else None, + openapi_url="/openapi.json" if DECNET_DEVELOPER else None +) + +app.add_middleware( + CORSMiddleware, + allow_origins=DECNET_CORS_ORIGINS, + allow_credentials=False, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "Last-Event-ID"], +) + +# Include the modular API router +app.include_router(api_router, prefix="/api/v1") diff --git a/decnet/web/auth.py b/decnet/web/auth.py new file mode 100644 index 0000000..546ba0b --- /dev/null +++ b/decnet/web/auth.py @@ -0,0 +1,38 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional, Any +import jwt +import bcrypt + +from decnet.env import DECNET_JWT_SECRET + +SECRET_KEY: str = DECNET_JWT_SECRET +ALGORITHM: str = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return bcrypt.checkpw( + plain_password.encode("utf-8")[:72], + hashed_password.encode("utf-8") + ) + + +def get_password_hash(password: str) -> str: + # Use a cost factor of 12 (default for passlib/bcrypt) + _salt: bytes = bcrypt.gensalt(rounds=12) + _hashed: bytes = bcrypt.hashpw(password.encode("utf-8")[:72], _salt) + return _hashed.decode("utf-8") + + +def create_access_token(data: dict[str, Any], expires_delta: Optional[timedelta] = None) -> str: + _to_encode: dict[str, Any] = data.copy() + _expire: datetime + if expires_delta: + _expire = datetime.now(timezone.utc) + expires_delta + else: + _expire = datetime.now(timezone.utc) + timedelta(minutes=15) + + _to_encode.update({"exp": _expire}) + _to_encode.update({"iat": datetime.now(timezone.utc)}) + _encoded_jwt: str = jwt.encode(_to_encode, SECRET_KEY, algorithm=ALGORITHM) + return _encoded_jwt diff --git a/decnet/web/db/models.py b/decnet/web/db/models.py new file mode 100644 index 0000000..4a758d1 --- /dev/null +++ b/decnet/web/db/models.py @@ -0,0 +1,75 @@ +from datetime import datetime, timezone +from typing import Optional, Any, List +from sqlmodel import SQLModel, Field +from pydantic import BaseModel, Field as PydanticField + +# --- Database Tables (SQLModel) --- + +class User(SQLModel, table=True): + __tablename__ = "users" + uuid: str = Field(primary_key=True) + username: str = Field(index=True, unique=True) + password_hash: str + role: str = Field(default="viewer") + must_change_password: bool = Field(default=False) + +class Log(SQLModel, table=True): + __tablename__ = "logs" + id: Optional[int] = Field(default=None, primary_key=True) + timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), index=True) + decky: str = Field(index=True) + service: str = Field(index=True) + event_type: str = Field(index=True) + attacker_ip: str = Field(index=True) + raw_line: str + fields: str + msg: Optional[str] = None + +class Bounty(SQLModel, table=True): + __tablename__ = "bounty" + id: Optional[int] = Field(default=None, primary_key=True) + timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), index=True) + decky: str = Field(index=True) + service: str = Field(index=True) + attacker_ip: str = Field(index=True) + bounty_type: str = Field(index=True) + payload: str + +# --- API Request/Response Models (Pydantic) --- + +class Token(BaseModel): + access_token: str + token_type: str + must_change_password: bool = False + +class LoginRequest(BaseModel): + username: str + password: str = PydanticField(..., max_length=72) + +class ChangePasswordRequest(BaseModel): + old_password: str = PydanticField(..., max_length=72) + new_password: str = PydanticField(..., max_length=72) + +class LogsResponse(BaseModel): + total: int + limit: int + offset: int + data: List[dict[str, Any]] + +class BountyResponse(BaseModel): + total: int + limit: int + offset: int + data: List[dict[str, Any]] + +class StatsResponse(BaseModel): + total_logs: int + unique_attackers: int + active_deckies: int + deployed_deckies: int + +class MutateIntervalRequest(BaseModel): + mutate_interval: Optional[int] = None + +class DeployIniRequest(BaseModel): + ini_content: str = PydanticField(..., min_length=5, max_length=512 * 1024) diff --git a/decnet/web/db/repository.py b/decnet/web/db/repository.py new file mode 100644 index 0000000..91226b9 --- /dev/null +++ b/decnet/web/db/repository.py @@ -0,0 +1,82 @@ +from abc import ABC, abstractmethod +from typing import Any, Optional + + +class BaseRepository(ABC): + """Abstract base class for DECNET web dashboard data storage.""" + + @abstractmethod + async def initialize(self) -> None: + """Initialize the database schema.""" + pass + + @abstractmethod + async def add_log(self, log_data: dict[str, Any]) -> None: + """Add a new log entry to the database.""" + pass + + @abstractmethod + async def get_logs( + self, + limit: int = 50, + offset: int = 0, + search: Optional[str] = None + ) -> list[dict[str, Any]]: + """Retrieve paginated log entries.""" + pass + + @abstractmethod + async def get_total_logs(self, search: Optional[str] = None) -> int: + """Retrieve the total count of logs, optionally filtered by search.""" + pass + + @abstractmethod + async def get_stats_summary(self) -> dict[str, Any]: + """Retrieve high-level dashboard metrics.""" + pass + + @abstractmethod + async def get_deckies(self) -> list[dict[str, Any]]: + """Retrieve the list of currently deployed deckies.""" + pass + + @abstractmethod + async def get_user_by_username(self, username: str) -> Optional[dict[str, Any]]: + """Retrieve a user by their username.""" + pass + + @abstractmethod + async def get_user_by_uuid(self, uuid: str) -> Optional[dict[str, Any]]: + """Retrieve a user by their UUID.""" + pass + + @abstractmethod + async def create_user(self, user_data: dict[str, Any]) -> None: + """Create a new dashboard user.""" + pass + + @abstractmethod + async def update_user_password(self, uuid: str, password_hash: str, must_change_password: bool = False) -> None: + """Update a user's password and change the must_change_password flag.""" + pass + + @abstractmethod + async def add_bounty(self, bounty_data: dict[str, Any]) -> None: + """Add a new harvested artifact (bounty) to the database.""" + pass + + @abstractmethod + async def get_bounties( + self, + limit: int = 50, + offset: int = 0, + bounty_type: Optional[str] = None, + search: Optional[str] = None + ) -> list[dict[str, Any]]: + """Retrieve paginated bounty entries.""" + pass + + @abstractmethod + async def get_total_bounties(self, bounty_type: Optional[str] = None, search: Optional[str] = None) -> int: + """Retrieve the total count of bounties, optionally filtered.""" + pass diff --git a/decnet/web/db/sqlite/database.py b/decnet/web/db/sqlite/database.py new file mode 100644 index 0000000..bb51467 --- /dev/null +++ b/decnet/web/db/sqlite/database.py @@ -0,0 +1,33 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy import create_engine +from sqlmodel import SQLModel + +# We need both sync and async engines for SQLite +# Sync for initialization (DDL) and async for standard queries + +def get_async_engine(db_path: str): + # If it's a memory URI, don't add the extra slash that turns it into a relative file + prefix = "sqlite+aiosqlite:///" + if db_path.startswith("file:"): + prefix = "sqlite+aiosqlite:///" + return create_async_engine(f"{prefix}{db_path}", echo=False, connect_args={"uri": True}) + +def get_sync_engine(db_path: str): + prefix = "sqlite:///" + return create_engine(f"{prefix}{db_path}", echo=False, connect_args={"uri": True}) + +def init_db(db_path: str): + """Synchronously create all tables.""" + engine = get_sync_engine(db_path) + # Ensure WAL mode is set + with engine.connect() as conn: + conn.exec_driver_sql("PRAGMA journal_mode=WAL") + conn.exec_driver_sql("PRAGMA synchronous=NORMAL") + SQLModel.metadata.create_all(engine) + +async def get_session(engine) -> AsyncSession: + async_session = async_sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False + ) + async with async_session() as session: + yield session diff --git a/decnet/web/db/sqlite/repository.py b/decnet/web/db/sqlite/repository.py new file mode 100644 index 0000000..a2cf745 --- /dev/null +++ b/decnet/web/db/sqlite/repository.py @@ -0,0 +1,352 @@ +import asyncio +import json +import uuid +from datetime import datetime +from typing import Any, Optional, List + +from sqlalchemy import func, select, desc, asc, text, or_, update, literal_column +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from decnet.config import load_state, _ROOT +from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD +from decnet.web.auth import get_password_hash +from decnet.web.db.repository import BaseRepository +from decnet.web.db.models import User, Log, Bounty +from decnet.web.db.sqlite.database import get_async_engine, init_db + + +class SQLiteRepository(BaseRepository): + """SQLite implementation using SQLModel and SQLAlchemy Async.""" + + def __init__(self, db_path: str = str(_ROOT / "decnet.db")) -> None: + self.db_path = db_path + self.engine = get_async_engine(db_path) + self.session_factory = async_sessionmaker( + self.engine, class_=AsyncSession, expire_on_commit=False + ) + self._initialize_sync() + + def _initialize_sync(self) -> None: + """Initialize the database schema synchronously.""" + init_db(self.db_path) + + from decnet.web.db.sqlite.database import get_sync_engine + engine = get_sync_engine(self.db_path) + with engine.connect() as conn: + conn.execute( + text( + "INSERT OR IGNORE INTO users (uuid, username, password_hash, role, must_change_password) " + "VALUES (:uuid, :u, :p, :r, :m)" + ), + { + "uuid": str(uuid.uuid4()), + "u": DECNET_ADMIN_USER, + "p": get_password_hash(DECNET_ADMIN_PASSWORD), + "r": "admin", + "m": 1, + }, + ) + conn.commit() + + async def initialize(self) -> None: + """Async warm-up / verification.""" + async with self.session_factory() as session: + await session.execute(text("SELECT 1")) + + async def reinitialize(self) -> None: + """Initialize the database schema asynchronously (useful for tests).""" + from sqlmodel import SQLModel + async with self.engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + async with self.session_factory() as session: + result = await session.execute( + select(User).where(User.username == DECNET_ADMIN_USER) + ) + if not result.scalar_one_or_none(): + session.add(User( + uuid=str(uuid.uuid4()), + username=DECNET_ADMIN_USER, + password_hash=get_password_hash(DECNET_ADMIN_PASSWORD), + role="admin", + must_change_password=True, + )) + await session.commit() + + # ------------------------------------------------------------------ logs + + async def add_log(self, log_data: dict[str, Any]) -> None: + data = log_data.copy() + if "fields" in data and isinstance(data["fields"], dict): + data["fields"] = json.dumps(data["fields"]) + if "timestamp" in data and isinstance(data["timestamp"], str): + try: + data["timestamp"] = datetime.fromisoformat( + data["timestamp"].replace("Z", "+00:00") + ) + except ValueError: + pass + + async with self.session_factory() as session: + session.add(Log(**data)) + await session.commit() + + def _apply_filters( + self, + statement, + search: Optional[str], + start_time: Optional[str], + end_time: Optional[str], + ): + import re + import shlex + + if start_time: + statement = statement.where(Log.timestamp >= start_time) + if end_time: + statement = statement.where(Log.timestamp <= end_time) + + if search: + try: + tokens = shlex.split(search) + except ValueError: + tokens = search.split() + + core_fields = { + "decky": Log.decky, + "service": Log.service, + "event": Log.event_type, + "attacker": Log.attacker_ip, + "attacker-ip": Log.attacker_ip, + "attacker_ip": Log.attacker_ip, + } + + for token in tokens: + if ":" in token: + key, val = token.split(":", 1) + if key in core_fields: + statement = statement.where(core_fields[key] == val) + else: + key_safe = re.sub(r"[^a-zA-Z0-9_]", "", key) + statement = statement.where( + text(f"json_extract(fields, '$.{key_safe}') = :val") + ).params(val=val) + else: + lk = f"%{token}%" + statement = statement.where( + or_( + Log.raw_line.like(lk), + Log.decky.like(lk), + Log.service.like(lk), + Log.attacker_ip.like(lk), + ) + ) + return statement + + async def get_logs( + self, + limit: int = 50, + offset: int = 0, + search: Optional[str] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + ) -> List[dict]: + statement = ( + select(Log) + .order_by(desc(Log.timestamp)) + .offset(offset) + .limit(limit) + ) + statement = self._apply_filters(statement, search, start_time, end_time) + + async with self.session_factory() as session: + results = await session.execute(statement) + return [log.model_dump(mode='json') for log in results.scalars().all()] + + async def get_max_log_id(self) -> int: + async with self.session_factory() as session: + result = await session.execute(select(func.max(Log.id))) + val = result.scalar() + return val if val is not None else 0 + + async def get_logs_after_id( + self, + last_id: int, + limit: int = 50, + search: Optional[str] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + ) -> List[dict]: + statement = ( + select(Log).where(Log.id > last_id).order_by(asc(Log.id)).limit(limit) + ) + statement = self._apply_filters(statement, search, start_time, end_time) + + async with self.session_factory() as session: + results = await session.execute(statement) + return [log.model_dump(mode='json') for log in results.scalars().all()] + + async def get_total_logs( + self, + search: Optional[str] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + ) -> int: + statement = select(func.count()).select_from(Log) + statement = self._apply_filters(statement, search, start_time, end_time) + + async with self.session_factory() as session: + result = await session.execute(statement) + return result.scalar() or 0 + + async def get_log_histogram( + self, + search: Optional[str] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + interval_minutes: int = 15, + ) -> List[dict]: + bucket_seconds = interval_minutes * 60 + bucket_expr = literal_column( + f"datetime((strftime('%s', timestamp) / {bucket_seconds}) * {bucket_seconds}, 'unixepoch')" + ).label("bucket_time") + + statement = select(bucket_expr, func.count().label("count")).select_from(Log) + statement = self._apply_filters(statement, search, start_time, end_time) + statement = statement.group_by(literal_column("bucket_time")).order_by( + literal_column("bucket_time") + ) + + async with self.session_factory() as session: + results = await session.execute(statement) + return [{"time": r[0], "count": r[1]} for r in results.all()] + + async def get_stats_summary(self) -> dict[str, Any]: + async with self.session_factory() as session: + total_logs = ( + await session.execute(select(func.count()).select_from(Log)) + ).scalar() or 0 + unique_attackers = ( + await session.execute( + select(func.count(func.distinct(Log.attacker_ip))) + ) + ).scalar() or 0 + active_deckies = ( + await session.execute( + select(func.count(func.distinct(Log.decky))) + ) + ).scalar() or 0 + + _state = await asyncio.to_thread(load_state) + deployed_deckies = len(_state[0].deckies) if _state else 0 + + return { + "total_logs": total_logs, + "unique_attackers": unique_attackers, + "active_deckies": active_deckies, + "deployed_deckies": deployed_deckies, + } + + async def get_deckies(self) -> List[dict]: + _state = await asyncio.to_thread(load_state) + return [_d.model_dump() for _d in _state[0].deckies] if _state else [] + + # ------------------------------------------------------------------ users + + async def get_user_by_username(self, username: str) -> Optional[dict]: + async with self.session_factory() as session: + result = await session.execute( + select(User).where(User.username == username) + ) + user = result.scalar_one_or_none() + return user.model_dump() if user else None + + async def get_user_by_uuid(self, uuid: str) -> Optional[dict]: + async with self.session_factory() as session: + result = await session.execute( + select(User).where(User.uuid == uuid) + ) + user = result.scalar_one_or_none() + return user.model_dump() if user else None + + async def create_user(self, user_data: dict[str, Any]) -> None: + async with self.session_factory() as session: + session.add(User(**user_data)) + await session.commit() + + async def update_user_password( + self, uuid: str, password_hash: str, must_change_password: bool = False + ) -> None: + async with self.session_factory() as session: + await session.execute( + update(User) + .where(User.uuid == uuid) + .values( + password_hash=password_hash, + must_change_password=must_change_password, + ) + ) + await session.commit() + + # ---------------------------------------------------------------- bounties + + async def add_bounty(self, bounty_data: dict[str, Any]) -> None: + data = bounty_data.copy() + if "payload" in data and isinstance(data["payload"], dict): + data["payload"] = json.dumps(data["payload"]) + + async with self.session_factory() as session: + session.add(Bounty(**data)) + await session.commit() + + def _apply_bounty_filters(self, statement, bounty_type: Optional[str], search: Optional[str]): + if bounty_type: + statement = statement.where(Bounty.bounty_type == bounty_type) + if search: + lk = f"%{search}%" + statement = statement.where( + or_( + Bounty.decky.like(lk), + Bounty.service.like(lk), + Bounty.attacker_ip.like(lk), + Bounty.payload.like(lk), + ) + ) + return statement + + async def get_bounties( + self, + limit: int = 50, + offset: int = 0, + bounty_type: Optional[str] = None, + search: Optional[str] = None, + ) -> List[dict]: + statement = ( + select(Bounty) + .order_by(desc(Bounty.timestamp)) + .offset(offset) + .limit(limit) + ) + statement = self._apply_bounty_filters(statement, bounty_type, search) + + async with self.session_factory() as session: + results = await session.execute(statement) + final = [] + for item in results.scalars().all(): + d = item.model_dump(mode='json') + try: + d["payload"] = json.loads(d["payload"]) + except (json.JSONDecodeError, TypeError): + pass + final.append(d) + return final + + async def get_total_bounties( + self, bounty_type: Optional[str] = None, search: Optional[str] = None + ) -> int: + statement = select(func.count()).select_from(Bounty) + statement = self._apply_bounty_filters(statement, bounty_type, search) + + async with self.session_factory() as session: + result = await session.execute(statement) + return result.scalar() or 0 diff --git a/decnet/web/dependencies.py b/decnet/web/dependencies.py new file mode 100644 index 0000000..eee8bd9 --- /dev/null +++ b/decnet/web/dependencies.py @@ -0,0 +1,73 @@ +from typing import Any, Optional +from pathlib import Path + +import jwt +from fastapi import HTTPException, status, Request +from fastapi.security import OAuth2PasswordBearer + +from decnet.web.auth import ALGORITHM, SECRET_KEY +from decnet.web.db.sqlite.repository import SQLiteRepository + +# Root directory for database +_ROOT_DIR = Path(__file__).parent.parent.parent.absolute() +DB_PATH = _ROOT_DIR / "decnet.db" + +# Shared repository instance +repo = SQLiteRepository(db_path=str(DB_PATH)) + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + + +async def get_stream_user(request: Request, token: Optional[str] = None) -> str: + """Auth dependency for SSE endpoints — accepts Bearer header OR ?token= query param. + EventSource does not support custom headers, so the query-string fallback is intentional here only. + """ + _credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + auth_header = request.headers.get("Authorization") + resolved: str | None = ( + auth_header.split(" ", 1)[1] + if auth_header and auth_header.startswith("Bearer ") + else token + ) + if not resolved: + raise _credentials_exception + + try: + _payload: dict[str, Any] = jwt.decode(resolved, SECRET_KEY, algorithms=[ALGORITHM]) + _user_uuid: Optional[str] = _payload.get("uuid") + if _user_uuid is None: + raise _credentials_exception + return _user_uuid + except jwt.PyJWTError: + raise _credentials_exception + + +async def get_current_user(request: Request) -> str: + _credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + auth_header = request.headers.get("Authorization") + token: str | None = ( + auth_header.split(" ", 1)[1] + if auth_header and auth_header.startswith("Bearer ") + else None + ) + if not token: + raise _credentials_exception + + try: + _payload: dict[str, Any] = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + _user_uuid: Optional[str] = _payload.get("uuid") + if _user_uuid is None: + raise _credentials_exception + return _user_uuid + except jwt.PyJWTError: + raise _credentials_exception diff --git a/decnet/web/ingester.py b/decnet/web/ingester.py new file mode 100644 index 0000000..cdf4bfd --- /dev/null +++ b/decnet/web/ingester.py @@ -0,0 +1,94 @@ +import asyncio +import os +import logging +import json +from typing import Any +from pathlib import Path + +from decnet.web.db.repository import BaseRepository + +logger: logging.Logger = logging.getLogger("decnet.web.ingester") + +async def log_ingestion_worker(repo: BaseRepository) -> None: + """ + Background task that tails the DECNET_INGEST_LOG_FILE.json and + inserts structured JSON logs into the SQLite repository. + """ + _base_log_file: str | None = os.environ.get("DECNET_INGEST_LOG_FILE") + if not _base_log_file: + logger.warning("DECNET_INGEST_LOG_FILE not set. Log ingestion disabled.") + return + + _json_log_path: Path = Path(_base_log_file).with_suffix(".json") + _position: int = 0 + + logger.info(f"Starting JSON log ingestion from {_json_log_path}") + + while True: + try: + if not _json_log_path.exists(): + await asyncio.sleep(2) + continue + + _stat: os.stat_result = _json_log_path.stat() + if _stat.st_size < _position: + # File rotated or truncated + _position = 0 + + if _stat.st_size == _position: + # No new data + await asyncio.sleep(1) + continue + + with open(_json_log_path, "r", encoding="utf-8", errors="replace") as _f: + _f.seek(_position) + while True: + _line: str = _f.readline() + if not _line: + break # EOF reached + + if not _line.endswith('\n'): + # Partial line read, don't process yet, don't advance position + break + + try: + _log_data: dict[str, Any] = json.loads(_line.strip()) + await repo.add_log(_log_data) + await _extract_bounty(repo, _log_data) + except json.JSONDecodeError: + logger.error(f"Failed to decode JSON log line: {_line}") + continue + + # Update position after successful line read + _position = _f.tell() + + except Exception as _e: + logger.error(f"Error in log ingestion worker: {_e}") + await asyncio.sleep(5) + + await asyncio.sleep(1) + + +async def _extract_bounty(repo: BaseRepository, log_data: dict[str, Any]) -> None: + """Detect and extract valuable artifacts (bounties) from log entries.""" + _fields = log_data.get("fields") + if not isinstance(_fields, dict): + return + + # 1. Credentials (User/Pass) + _user = _fields.get("username") + _pass = _fields.get("password") + + if _user and _pass: + await repo.add_bounty({ + "decky": log_data.get("decky"), + "service": log_data.get("service"), + "attacker_ip": log_data.get("attacker_ip"), + "bounty_type": "credential", + "payload": { + "username": _user, + "password": _pass + } + }) + + # 2. Add more extractors here later (e.g. file hashes, crypto keys) diff --git a/decnet/web/router/__init__.py b/decnet/web/router/__init__.py new file mode 100644 index 0000000..b1bd92e --- /dev/null +++ b/decnet/web/router/__init__.py @@ -0,0 +1,36 @@ +from fastapi import APIRouter + +from .auth.api_login import router as login_router +from .auth.api_change_pass import router as change_pass_router +from .logs.api_get_logs import router as logs_router +from .logs.api_get_histogram import router as histogram_router +from .bounty.api_get_bounties import router as bounty_router +from .stats.api_get_stats import router as stats_router +from .fleet.api_get_deckies import router as get_deckies_router +from .fleet.api_mutate_decky import router as mutate_decky_router +from .fleet.api_mutate_interval import router as mutate_interval_router +from .fleet.api_deploy_deckies import router as deploy_deckies_router +from .stream.api_stream_events import router as stream_router + +api_router = APIRouter() + +# Authentication +api_router.include_router(login_router) +api_router.include_router(change_pass_router) + +# Logs & Analytics +api_router.include_router(logs_router) +api_router.include_router(histogram_router) + +# Bounty Vault +api_router.include_router(bounty_router) + +# Fleet Management +api_router.include_router(get_deckies_router) +api_router.include_router(mutate_decky_router) +api_router.include_router(mutate_interval_router) +api_router.include_router(deploy_deckies_router) + +# Observability +api_router.include_router(stats_router) +api_router.include_router(stream_router) diff --git a/decnet/web/router/auth/api_change_pass.py b/decnet/web/router/auth/api_change_pass.py new file mode 100644 index 0000000..0e56a89 --- /dev/null +++ b/decnet/web/router/auth/api_change_pass.py @@ -0,0 +1,27 @@ +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, status + +from decnet.web.auth import get_password_hash, verify_password +from decnet.web.dependencies import get_current_user, repo +from decnet.web.db.models import ChangePasswordRequest + +router = APIRouter() + + +@router.post( + "/auth/change-password", + tags=["Authentication"], + responses={401: {"description": "Invalid or expired token / wrong old password"}, 422: {"description": "Validation error"}}, +) +async def change_password(request: ChangePasswordRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]: + _user: Optional[dict[str, Any]] = await repo.get_user_by_uuid(current_user) + if not _user or not verify_password(request.old_password, _user["password_hash"]): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect old password", + ) + + _new_hash: str = get_password_hash(request.new_password) + await repo.update_user_password(current_user, _new_hash, must_change_password=False) + return {"message": "Password updated successfully"} diff --git a/decnet/web/router/auth/api_login.py b/decnet/web/router/auth/api_login.py new file mode 100644 index 0000000..67f1aad --- /dev/null +++ b/decnet/web/router/auth/api_login.py @@ -0,0 +1,41 @@ +from datetime import timedelta +from typing import Any, Optional + +from fastapi import APIRouter, HTTPException, status + +from decnet.web.auth import ( + ACCESS_TOKEN_EXPIRE_MINUTES, + create_access_token, + verify_password, +) +from decnet.web.dependencies import repo +from decnet.web.db.models import LoginRequest, Token + +router = APIRouter() + + +@router.post( + "/auth/login", + response_model=Token, + tags=["Authentication"], + responses={401: {"description": "Incorrect username or password"}, 422: {"description": "Validation error"}}, +) +async def login(request: LoginRequest) -> dict[str, Any]: + _user: Optional[dict[str, Any]] = await repo.get_user_by_username(request.username) + if not _user or not verify_password(request.password, _user["password_hash"]): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + + _access_token_expires: timedelta = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + # Token uses uuid instead of sub + _access_token: str = create_access_token( + data={"uuid": _user["uuid"]}, expires_delta=_access_token_expires + ) + return { + "access_token": _access_token, + "token_type": "bearer", # nosec B105 + "must_change_password": bool(_user.get("must_change_password", False)) + } diff --git a/decnet/web/router/bounty/api_get_bounties.py b/decnet/web/router/bounty/api_get_bounties.py new file mode 100644 index 0000000..ad7710a --- /dev/null +++ b/decnet/web/router/bounty/api_get_bounties.py @@ -0,0 +1,28 @@ +from typing import Any, Optional + +from fastapi import APIRouter, Depends, Query + +from decnet.web.dependencies import get_current_user, repo +from decnet.web.db.models import BountyResponse + +router = APIRouter() + + +@router.get("/bounty", response_model=BountyResponse, tags=["Bounty Vault"], + responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}},) +async def get_bounties( + limit: int = Query(50, ge=1, le=1000), + offset: int = Query(0, ge=0), + bounty_type: Optional[str] = None, + search: Optional[str] = None, + current_user: str = Depends(get_current_user) +) -> dict[str, Any]: + """Retrieve collected bounties (harvested credentials, payloads, etc.).""" + _data = await repo.get_bounties(limit=limit, offset=offset, bounty_type=bounty_type, search=search) + _total = await repo.get_total_bounties(bounty_type=bounty_type, search=search) + return { + "total": _total, + "limit": limit, + "offset": offset, + "data": _data + } diff --git a/decnet/web/router/fleet/api_deploy_deckies.py b/decnet/web/router/fleet/api_deploy_deckies.py new file mode 100644 index 0000000..c6d011d --- /dev/null +++ b/decnet/web/router/fleet/api_deploy_deckies.py @@ -0,0 +1,79 @@ +import logging +import os + +from fastapi import APIRouter, Depends, HTTPException + +from decnet.config import DEFAULT_MUTATE_INTERVAL, DecnetConfig, load_state +from decnet.engine import deploy as _deploy +from decnet.ini_loader import load_ini_from_string +from decnet.network import detect_interface, detect_subnet, get_host_ip +from decnet.web.dependencies import get_current_user +from decnet.web.db.models import DeployIniRequest + +router = APIRouter() + + +@router.post("/deckies/deploy", tags=["Fleet Management"]) +async def api_deploy_deckies(req: DeployIniRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]: + from decnet.fleet import build_deckies_from_ini + + try: + ini = load_ini_from_string(req.ini_content) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to parse INI: {e}") + + state = load_state() + ingest_log_file = os.environ.get("DECNET_INGEST_LOG_FILE") + + if state: + config, _ = state + subnet_cidr = ini.subnet or config.subnet + gateway = ini.gateway or config.gateway + host_ip = get_host_ip(config.interface) + randomize_services = False + # Always sync config log_file with current API ingestion target + if ingest_log_file: + config.log_file = ingest_log_file + else: + # If no state exists, we need to infer network details + iface = ini.interface or detect_interface() + subnet_cidr, gateway = ini.subnet, ini.gateway + if not subnet_cidr or not gateway: + detected_subnet, detected_gateway = detect_subnet(iface) + subnet_cidr = subnet_cidr or detected_subnet + gateway = gateway or detected_gateway + host_ip = get_host_ip(iface) + randomize_services = False + config = DecnetConfig( + mode="unihost", + interface=iface, + subnet=subnet_cidr, + gateway=gateway, + deckies=[], + log_file=ingest_log_file, + ipvlan=False, + mutate_interval=ini.mutate_interval or DEFAULT_MUTATE_INTERVAL + ) + + try: + new_decky_configs = build_deckies_from_ini( + ini, subnet_cidr, gateway, host_ip, randomize_services, cli_mutate_interval=None + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # Merge deckies + existing_deckies_map = {d.name: d for d in config.deckies} + for new_decky in new_decky_configs: + existing_deckies_map[new_decky.name] = new_decky + + config.deckies = list(existing_deckies_map.values()) + + # We call deploy(config) which regenerates docker-compose and runs `up -d --remove-orphans`. + try: + _deploy(config) + except Exception as e: + logging.getLogger("decnet.web.api").exception("Deployment failed: %s", e) + raise HTTPException(status_code=500, detail="Deployment failed. Check server logs for details.") + + return {"message": "Deckies deployed successfully"} diff --git a/decnet/web/router/fleet/api_get_deckies.py b/decnet/web/router/fleet/api_get_deckies.py new file mode 100644 index 0000000..dbd4bcf --- /dev/null +++ b/decnet/web/router/fleet/api_get_deckies.py @@ -0,0 +1,13 @@ +from typing import Any + +from fastapi import APIRouter, Depends + +from decnet.web.dependencies import get_current_user, repo + +router = APIRouter() + + +@router.get("/deckies", tags=["Fleet Management"], + responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}},) +async def get_deckies(current_user: str = Depends(get_current_user)) -> list[dict[str, Any]]: + return await repo.get_deckies() diff --git a/decnet/web/router/fleet/api_mutate_decky.py b/decnet/web/router/fleet/api_mutate_decky.py new file mode 100644 index 0000000..06a0a2f --- /dev/null +++ b/decnet/web/router/fleet/api_mutate_decky.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, Depends, HTTPException, Path + +from decnet.mutator import mutate_decky +from decnet.web.dependencies import get_current_user + +router = APIRouter() + + +@router.post("/deckies/{decky_name}/mutate", tags=["Fleet Management"]) +async def api_mutate_decky( + decky_name: str = Path(..., pattern=r"^[a-z0-9\-]{1,64}$"), + current_user: str = Depends(get_current_user), +) -> dict[str, str]: + success = mutate_decky(decky_name) + if success: + return {"message": f"Successfully mutated {decky_name}"} + raise HTTPException(status_code=404, detail=f"Decky {decky_name} not found or failed to mutate") diff --git a/decnet/web/router/fleet/api_mutate_interval.py b/decnet/web/router/fleet/api_mutate_interval.py new file mode 100644 index 0000000..71bb298 --- /dev/null +++ b/decnet/web/router/fleet/api_mutate_interval.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends, HTTPException + +from decnet.config import load_state, save_state +from decnet.web.dependencies import get_current_user +from decnet.web.db.models import MutateIntervalRequest + +router = APIRouter() + + +@router.put("/deckies/{decky_name}/mutate-interval", tags=["Fleet Management"], + responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}},) +async def api_update_mutate_interval(decky_name: str, req: MutateIntervalRequest, current_user: str = Depends(get_current_user)) -> dict[str, str]: + state = load_state() + if not state: + raise HTTPException(status_code=500, detail="No active deployment") + config, compose_path = state + decky = next((d for d in config.deckies if d.name == decky_name), None) + if not decky: + raise HTTPException(status_code=404, detail="Decky not found") + decky.mutate_interval = req.mutate_interval + save_state(config, compose_path) + return {"message": "Mutation interval updated"} diff --git a/decnet/web/router/logs/api_get_histogram.py b/decnet/web/router/logs/api_get_histogram.py new file mode 100644 index 0000000..9858ddd --- /dev/null +++ b/decnet/web/router/logs/api_get_histogram.py @@ -0,0 +1,19 @@ +from typing import Any, Optional + +from fastapi import APIRouter, Depends, Query + +from decnet.web.dependencies import get_current_user, repo + +router = APIRouter() + + +@router.get("/logs/histogram", tags=["Logs"], + responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}},) +async def get_logs_histogram( + search: Optional[str] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + interval_minutes: int = Query(15, ge=1), + current_user: str = Depends(get_current_user) +) -> list[dict[str, Any]]: + return await repo.get_log_histogram(search=search, start_time=start_time, end_time=end_time, interval_minutes=interval_minutes) diff --git a/decnet/web/router/logs/api_get_logs.py b/decnet/web/router/logs/api_get_logs.py new file mode 100644 index 0000000..097b6c4 --- /dev/null +++ b/decnet/web/router/logs/api_get_logs.py @@ -0,0 +1,29 @@ +from typing import Any, Optional + +from fastapi import APIRouter, Depends, Query + +from decnet.web.dependencies import get_current_user, repo +from decnet.web.db.models import LogsResponse + +router = APIRouter() + +_DATETIME_RE = r"^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}$" + + +@router.get("/logs", response_model=LogsResponse, tags=["Logs"]) +async def get_logs( + limit: int = Query(50, ge=1, le=1000), + offset: int = Query(0, ge=0), + search: Optional[str] = Query(None, max_length=512), + start_time: Optional[str] = Query(None, pattern=_DATETIME_RE), + end_time: Optional[str] = Query(None, pattern=_DATETIME_RE), + current_user: str = Depends(get_current_user) +) -> dict[str, Any]: + _logs: list[dict[str, Any]] = await repo.get_logs(limit=limit, offset=offset, search=search, start_time=start_time, end_time=end_time) + _total: int = await repo.get_total_logs(search=search, start_time=start_time, end_time=end_time) + return { + "total": _total, + "limit": limit, + "offset": offset, + "data": _logs + } diff --git a/decnet/web/router/stats/api_get_stats.py b/decnet/web/router/stats/api_get_stats.py new file mode 100644 index 0000000..4b92fb2 --- /dev/null +++ b/decnet/web/router/stats/api_get_stats.py @@ -0,0 +1,14 @@ +from typing import Any + +from fastapi import APIRouter, Depends + +from decnet.web.dependencies import get_current_user, repo +from decnet.web.db.models import StatsResponse + +router = APIRouter() + + +@router.get("/stats", response_model=StatsResponse, tags=["Observability"], + responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}},) +async def get_stats(current_user: str = Depends(get_current_user)) -> dict[str, Any]: + return await repo.get_stats_summary() diff --git a/decnet/web/router/stream/api_stream_events.py b/decnet/web/router/stream/api_stream_events.py new file mode 100644 index 0000000..e76a0de --- /dev/null +++ b/decnet/web/router/stream/api_stream_events.py @@ -0,0 +1,75 @@ +import json +import asyncio +import logging +from typing import AsyncGenerator, Optional + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import StreamingResponse + +from decnet.web.dependencies import get_stream_user, repo + +log = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/stream", tags=["Observability"], + responses={401: {"description": "Not authenticated"}, 422: {"description": "Validation error"}},) +async def stream_events( + request: Request, + last_event_id: int = Query(0, alias="lastEventId"), + search: Optional[str] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + current_user: str = Depends(get_stream_user) +) -> StreamingResponse: + + async def event_generator() -> AsyncGenerator[str, None]: + last_id = last_event_id + stats_interval_sec = 10 + loops_since_stats = 0 + try: + if last_id == 0: + last_id = await repo.get_max_log_id() + + # Emit initial snapshot immediately so the client never needs to poll /stats + stats = await repo.get_stats_summary() + yield f"event: message\ndata: {json.dumps({'type': 'stats', 'data': stats})}\n\n" + histogram = await repo.get_log_histogram( + search=search, start_time=start_time, + end_time=end_time, interval_minutes=15, + ) + yield f"event: message\ndata: {json.dumps({'type': 'histogram', 'data': histogram})}\n\n" + + while True: + if await request.is_disconnected(): + break + + new_logs = await repo.get_logs_after_id( + last_id, limit=50, search=search, + start_time=start_time, end_time=end_time, + ) + if new_logs: + last_id = max(entry["id"] for entry in new_logs) + yield f"event: message\ndata: {json.dumps({'type': 'logs', 'data': new_logs})}\n\n" + loops_since_stats = stats_interval_sec + + if loops_since_stats >= stats_interval_sec: + stats = await repo.get_stats_summary() + yield f"event: message\ndata: {json.dumps({'type': 'stats', 'data': stats})}\n\n" + histogram = await repo.get_log_histogram( + search=search, start_time=start_time, + end_time=end_time, interval_minutes=15, + ) + yield f"event: message\ndata: {json.dumps({'type': 'histogram', 'data': histogram})}\n\n" + loops_since_stats = 0 + + loops_since_stats += 1 + await asyncio.sleep(1) + except asyncio.CancelledError: + pass + except Exception: + log.exception("SSE stream error for user %s", last_event_id) + yield f"event: error\ndata: {json.dumps({'type': 'error', 'message': 'Stream interrupted'})}\n\n" + + return StreamingResponse(event_generator(), media_type="text/event-stream") diff --git a/decnet_web/.gitignore b/decnet_web/.gitignore new file mode 100644 index 0000000..a0f88c9 --- /dev/null +++ b/decnet_web/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.env +.env.local diff --git a/decnet_web/README.md b/decnet_web/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/decnet_web/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/decnet_web/eslint.config.js b/decnet_web/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/decnet_web/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/decnet_web/index.html b/decnet_web/index.html new file mode 100644 index 0000000..121eecd --- /dev/null +++ b/decnet_web/index.html @@ -0,0 +1,13 @@ + + + + + + + decnet_web + + +
+ + + diff --git a/decnet_web/package-lock.json b/decnet_web/package-lock.json new file mode 100644 index 0000000..913bd2a --- /dev/null +++ b/decnet_web/package-lock.json @@ -0,0 +1,3727 @@ +{ + "name": "decnet_web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "decnet_web", + "version": "0.0.0", + "dependencies": { + "axios": "^1.14.0", + "lucide-react": "^1.7.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router-dom": "^7.14.0", + "recharts": "^3.8.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.0", + "vite": "^8.0.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@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.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@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.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "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.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz", + "integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz", + "integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz", + "integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.1", + "@emnapi/runtime": "1.9.1", + "@napi-rs/wasm-runtime": "^1.1.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", + "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001786", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz", + "integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.332", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.332.tgz", + "integrity": "sha512-7OOtytmh/rINMLwaFTbcMVvYXO3AUm029X0LcyfYk0B557RlPkdpTpnH9+htMlfu5dKwOmT0+Zs2Aw+lnn6TeQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", + "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", + "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz", + "integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz", + "integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.123.0", + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-x64": "1.0.0-rc.13", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz", + "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.0", + "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz", + "integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.13", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "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/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/decnet_web/package.json b/decnet_web/package.json new file mode 100644 index 0000000..4e82f89 --- /dev/null +++ b/decnet_web/package.json @@ -0,0 +1,34 @@ +{ + "name": "decnet_web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.14.0", + "lucide-react": "^1.7.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router-dom": "^7.14.0", + "recharts": "^3.8.1" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.58.0", + "vite": "^8.0.4" + } +} diff --git a/decnet_web/public/favicon.svg b/decnet_web/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/decnet_web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/decnet_web/public/icons.svg b/decnet_web/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/decnet_web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/decnet_web/src/App.css b/decnet_web/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/decnet_web/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx new file mode 100644 index 0000000..8748ef2 --- /dev/null +++ b/decnet_web/src/App.tsx @@ -0,0 +1,57 @@ +import { useState, useEffect } from 'react'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import Login from './components/Login'; +import Layout from './components/Layout'; +import Dashboard from './components/Dashboard'; +import DeckyFleet from './components/DeckyFleet'; +import LiveLogs from './components/LiveLogs'; +import Attackers from './components/Attackers'; +import Config from './components/Config'; +import Bounty from './components/Bounty'; + +function App() { + const [token, setToken] = useState(localStorage.getItem('token')); + const [searchQuery, setSearchQuery] = useState(''); + + useEffect(() => { + const savedToken = localStorage.getItem('token'); + if (savedToken) { + setToken(savedToken); + } + }, []); + + const handleLogin = (newToken: string) => { + setToken(newToken); + }; + + const handleLogout = () => { + localStorage.removeItem('token'); + setToken(null); + }; + + const handleSearch = (query: string) => { + setSearchQuery(query); + }; + + if (!token) { + return ; + } + + return ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} + +export default App; diff --git a/decnet_web/src/assets/hero.png b/decnet_web/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/decnet_web/src/assets/hero.png differ diff --git a/decnet_web/src/assets/react.svg b/decnet_web/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/decnet_web/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/decnet_web/src/assets/vite.svg b/decnet_web/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/decnet_web/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/decnet_web/src/components/Attackers.tsx b/decnet_web/src/components/Attackers.tsx new file mode 100644 index 0000000..0ed1ce9 --- /dev/null +++ b/decnet_web/src/components/Attackers.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Activity } from 'lucide-react'; +import './Dashboard.css'; + +const Attackers: React.FC = () => { + return ( +
+
+ +

ATTACKER PROFILES

+
+
+

NO ACTIVE THREATS PROFILED YET.

+

(Attackers view placeholder)

+
+
+ ); +}; + +export default Attackers; diff --git a/decnet_web/src/components/Bounty.tsx b/decnet_web/src/components/Bounty.tsx new file mode 100644 index 0000000..29c11c9 --- /dev/null +++ b/decnet_web/src/components/Bounty.tsx @@ -0,0 +1,191 @@ +import React, { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Archive, Search, ChevronLeft, ChevronRight, Filter } from 'lucide-react'; +import api from '../utils/api'; +import './Dashboard.css'; + +interface BountyEntry { + id: number; + timestamp: string; + decky: string; + service: string; + attacker_ip: string; + bounty_type: string; + payload: any; +} + +const Bounty: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const query = searchParams.get('q') || ''; + const typeFilter = searchParams.get('type') || ''; + const page = parseInt(searchParams.get('page') || '1'); + + const [bounties, setBounties] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [searchInput, setSearchInput] = useState(query); + + const limit = 50; + + const fetchBounties = async () => { + setLoading(true); + try { + const offset = (page - 1) * limit; + let url = `/bounty?limit=${limit}&offset=${offset}`; + if (query) url += `&search=${encodeURIComponent(query)}`; + if (typeFilter) url += `&bounty_type=${typeFilter}`; + + const res = await api.get(url); + setBounties(res.data.data); + setTotal(res.data.total); + } catch (err) { + console.error('Failed to fetch bounties', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchBounties(); + }, [query, typeFilter, page]); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setSearchParams({ q: searchInput, type: typeFilter, page: '1' }); + }; + + const setPage = (p: number) => { + setSearchParams({ q: query, type: typeFilter, page: p.toString() }); + }; + + const setType = (t: string) => { + setSearchParams({ q: query, type: t, page: '1' }); + }; + + const totalPages = Math.ceil(total / limit); + + return ( +
+ {/* Page Header */} +
+
+ +

BOUNTY VAULT

+
+ +
+
+ + +
+ +
+ + setSearchInput(e.target.value)} + style={{ background: 'transparent', border: 'none', padding: '4px', fontSize: '0.8rem', width: '200px' }} + /> + +
+
+ +
+
+
+ {total} ARTIFACTS CAPTURED +
+ +
+ + Page {page} of {totalPages || 1} + +
+ + +
+
+
+ +
+ + + + + + + + + + + + + {bounties.length > 0 ? bounties.map((b) => ( + + + + + + + + + )) : ( + + + + )} + +
TIMESTAMPDECKYSERVICEATTACKERTYPEDATA
{new Date(b.timestamp).toLocaleString()}{b.decky}{b.service}{b.attacker_ip} + + {b.bounty_type.toUpperCase()} + + +
+ {b.bounty_type === 'credential' ? ( +
+ user:{b.payload.username} + pass:{b.payload.password} +
+ ) : ( + {JSON.stringify(b.payload)} + )} +
+
+ {loading ? 'RETRIEVING ARTIFACTS...' : 'THE VAULT IS EMPTY'} +
+
+
+
+ ); +}; + +export default Bounty; diff --git a/decnet_web/src/components/Config.tsx b/decnet_web/src/components/Config.tsx new file mode 100644 index 0000000..5c41911 --- /dev/null +++ b/decnet_web/src/components/Config.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Settings } from 'lucide-react'; +import './Dashboard.css'; + +const Config: React.FC = () => { + return ( +
+
+ +

SYSTEM CONFIGURATION

+
+
+

CONFIGURATION READ-ONLY MODE ACTIVE.

+

(Config view placeholder)

+
+
+ ); +}; + +export default Config; diff --git a/decnet_web/src/components/Dashboard.css b/decnet_web/src/components/Dashboard.css new file mode 100644 index 0000000..773fcd9 --- /dev/null +++ b/decnet_web/src/components/Dashboard.css @@ -0,0 +1,129 @@ +.dashboard { + display: flex; + flex-direction: column; + gap: 32px; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; +} + +.stat-card { + background-color: var(--secondary-color); + border: 1px solid var(--border-color); + padding: 24px; + display: flex; + align-items: center; + gap: 20px; + transition: all 0.3s ease; +} + +.stat-card:hover { + border-color: var(--text-color); + box-shadow: var(--matrix-green-glow); + transform: translateY(-2px); +} + +.stat-icon { + color: var(--accent-color); + filter: drop-shadow(var(--violet-glow)); +} + +.stat-content { + display: flex; + flex-direction: column; +} + +.stat-label { + font-size: 0.7rem; + opacity: 0.6; + letter-spacing: 1px; +} + +.stat-value { + font-size: 1.8rem; + font-weight: bold; +} + +.logs-section { + background-color: var(--secondary-color); + border: 1px solid var(--border-color); + display: flex; + flex-direction: column; +} + +.section-header { + padding: 16px 24px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + gap: 12px; +} + +.section-header h2 { + font-size: 0.9rem; + letter-spacing: 2px; +} + +.logs-table-container { + overflow-x: auto; +} + +.logs-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; + text-align: left; +} + +.logs-table th { + padding: 12px 24px; + border-bottom: 1px solid var(--border-color); + opacity: 0.5; + font-weight: normal; +} + +.logs-table td { + padding: 12px 24px; + border-bottom: 1px solid rgba(48, 54, 61, 0.5); +} + +.logs-table tr:hover { + background-color: rgba(0, 255, 65, 0.03); +} + +.raw-line { + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dim { + opacity: 0.5; +} + +.loader { + display: flex; + align-items: center; + justify-content: center; + height: 200px; + letter-spacing: 4px; + animation: pulse 1s infinite alternate; +} + +@keyframes pulse { + from { opacity: 0.5; } + to { opacity: 1; } +} + +.spin { + animation: spin 1.5s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/decnet_web/src/components/Dashboard.tsx b/decnet_web/src/components/Dashboard.tsx new file mode 100644 index 0000000..c32717d --- /dev/null +++ b/decnet_web/src/components/Dashboard.tsx @@ -0,0 +1,175 @@ +import React, { useEffect, useState } from 'react'; +import './Dashboard.css'; +import { Shield, Users, Activity, Clock } from 'lucide-react'; + +interface Stats { + total_logs: number; + unique_attackers: number; + active_deckies: number; + deployed_deckies: number; +} + +interface LogEntry { + id: number; + timestamp: string; + decky: string; + service: string; + event_type: string | null; + attacker_ip: string; + raw_line: string; + fields: string | null; + msg: string | null; +} + +interface DashboardProps { + searchQuery: string; +} + +const Dashboard: React.FC = ({ searchQuery }) => { + const [stats, setStats] = useState(null); + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem('token'); + const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1'; + let url = `${baseUrl}/stream?token=${token}`; + if (searchQuery) { + url += `&search=${encodeURIComponent(searchQuery)}`; + } + + const eventSource = new EventSource(url); + + eventSource.onmessage = (event) => { + try { + const payload = JSON.parse(event.data); + if (payload.type === 'logs') { + setLogs(prev => [...payload.data, ...prev].slice(0, 100)); + } else if (payload.type === 'stats') { + setStats(payload.data); + setLoading(false); + } + } catch (err) { + console.error('Failed to parse SSE payload', err); + } + }; + + eventSource.onerror = (err) => { + console.error('SSE connection error, attempting to reconnect...', err); + }; + + return () => { + eventSource.close(); + }; + }, [searchQuery]); + + if (loading && !stats) return
INITIALIZING SENSORS...
; + + return ( +
+
+ } + label="TOTAL INTERACTIONS" + value={stats?.total_logs || 0} + /> + } + label="UNIQUE ATTACKERS" + value={stats?.unique_attackers || 0} + /> + } + label="ACTIVE DECKIES" + value={`${stats?.active_deckies || 0} / ${stats?.deployed_deckies || 0}`} + /> +
+ +
+
+ +

LIVE INTERACTION LOG

+
+
+ + + + + + + + + + + + {logs.length > 0 ? logs.map(log => { + let parsedFields: Record = {}; + if (log.fields) { + try { + parsedFields = JSON.parse(log.fields); + } catch (e) { + // Ignore parsing errors + } + } + + return ( + + + + + + + + ); + }) : ( + + + + )} + +
TIMESTAMPDECKYSERVICEATTACKER IPEVENT
{new Date(log.timestamp).toLocaleString()}{log.decky}{log.service}{log.attacker_ip} +
+
+ {log.event_type} {log.msg && log.msg !== '-' && — {log.msg}} +
+ {Object.keys(parsedFields).length > 0 && ( +
+ {Object.entries(parsedFields).map(([k, v]) => ( + + {k}: {v} + + ))} +
+ )} +
+
NO INTERACTION DETECTED
+
+
+
+ ); +}; + +interface StatCardProps { + icon: React.ReactNode; + label: string; + value: string | number; +} + +const StatCard: React.FC = ({ icon, label, value }) => ( +
+
{icon}
+
+ {label} + {value.toLocaleString()} +
+
+); + +export default Dashboard; diff --git a/decnet_web/src/components/DeckyFleet.tsx b/decnet_web/src/components/DeckyFleet.tsx new file mode 100644 index 0000000..a6f99a9 --- /dev/null +++ b/decnet_web/src/components/DeckyFleet.tsx @@ -0,0 +1,278 @@ +import React, { useEffect, useState } from 'react'; +import api from '../utils/api'; +import './Dashboard.css'; // Re-use common dashboard styles +import { Server, Cpu, Globe, Database, Clock, RefreshCw, Upload } from 'lucide-react'; + +interface Decky { + name: string; + ip: string; + services: string[]; + distro: string; + hostname: string; + archetype: string | null; + service_config: Record>; + mutate_interval: number | null; + last_mutated: number; +} + +const DeckyFleet: React.FC = () => { + const [deckies, setDeckies] = useState([]); + const [loading, setLoading] = useState(true); + const [mutating, setMutating] = useState(null); + const [showDeploy, setShowDeploy] = useState(false); + const [iniContent, setIniContent] = useState(''); + const [deploying, setDeploying] = useState(false); + + const fetchDeckies = async () => { + try { + const _res = await api.get('/deckies'); + setDeckies(_res.data); + } catch (err) { + console.error('Failed to fetch decky fleet', err); + } finally { + setLoading(false); + } + }; + + const handleMutate = async (name: string) => { + setMutating(name); + try { + await api.post(`/deckies/${name}/mutate`, {}, { timeout: 120000 }); + await fetchDeckies(); + } catch (err: any) { + console.error('Failed to mutate', err); + if (err.code === 'ECONNABORTED') { + alert('Mutation is still running in the background but the UI timed out.'); + } else { + alert('Mutation failed'); + } + } finally { + setMutating(null); + } + }; + + const handleIntervalChange = async (name: string, current: number | null) => { + const _val = prompt(`Enter new mutation interval in minutes for ${name} (leave empty to disable):`, current?.toString() || ''); + if (_val === null) return; + const mutate_interval = _val.trim() === '' ? null : parseInt(_val); + try { + await api.put(`/deckies/${name}/mutate-interval`, { mutate_interval }); + fetchDeckies(); + } catch (err) { + console.error('Failed to update interval', err); + alert('Update failed'); + } + }; + + const handleDeploy = async () => { + if (!iniContent.trim()) return; + setDeploying(true); + try { + await api.post('/deckies/deploy', { ini_content: iniContent }, { timeout: 120000 }); + setIniContent(''); + setShowDeploy(false); + fetchDeckies(); + } catch (err: any) { + console.error('Deploy failed', err); + alert(`Deploy failed: ${err.response?.data?.detail || err.message}`); + } finally { + setDeploying(false); + } + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + const content = event.target?.result as string; + setIniContent(content); + }; + reader.readAsText(file); + }; + + useEffect(() => { + fetchDeckies(); + const _interval = setInterval(fetchDeckies, 10000); // Fleet state updates less frequently than logs + return () => clearInterval(_interval); + }, []); + + if (loading) return
SCANNING NETWORK FOR DECOYS...
; + + return ( +
+
+
+ +

DECOY FLEET ASSET INVENTORY

+
+ +
+ + {showDeploy && ( +
+
+

Deploy via INI Configuration

+
+ + +
+
+