From 636fd247fbf7e7294d796bce583c31cfae6515e4 Mon Sep 17 00:00:00 2001 From: banxian1987 Date: Wed, 26 Aug 2026 14:59:35 +0800 Subject: [PATCH] feat(hub): add Strix Hub multi-tenant task orchestration & dual-channel model routing console --- strix/interface/cli_args.py | 3 + strix/interface/main.py | 7 + strix_hub/README.md | 94 ++ strix_hub/__init__.py | 3 + strix_hub/db.py | 402 +++++++++ strix_hub/main.py | 29 + strix_hub/model_router.py | 313 +++++++ strix_hub/server.py | 1661 +++++++++++++++++++++++++++++++++++ strix_hub/task_manager.py | 342 ++++++++ 9 files changed, 2854 insertions(+) create mode 100644 strix_hub/README.md create mode 100644 strix_hub/__init__.py create mode 100644 strix_hub/db.py create mode 100644 strix_hub/main.py create mode 100644 strix_hub/model_router.py create mode 100644 strix_hub/server.py create mode 100644 strix_hub/task_manager.py diff --git a/strix/interface/cli_args.py b/strix/interface/cli_args.py index 42ebf18d..8ecae154 100644 --- a/strix/interface/cli_args.py +++ b/strix/interface/cli_args.py @@ -97,6 +97,9 @@ Examples: # Extra files placed in the sandbox workspace strix --target ./my-project --workspace-file ./wordlist.txt strix --target https://app.com --workspace-file ./openapi.yaml:specs/openapi.yaml + + # Launch Strix Hub web management & dual-channel model routing console + strix hub --port 8888 """, ) diff --git a/strix/interface/main.py b/strix/interface/main.py index 06966f4c..9a11620f 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -431,6 +431,13 @@ def main() -> None: sys.exit(run_auth(sys.argv[2:])) + # `strix hub …` launches the multi-tenant task orchestration and dual-channel router console. + if len(sys.argv) > 1 and sys.argv[1] == "hub": + from strix_hub.main import main as run_hub + + run_hub() + return + args = parse_arguments() start_background_check() diff --git a/strix_hub/README.md b/strix_hub/README.md new file mode 100644 index 00000000..7d58fd3a --- /dev/null +++ b/strix_hub/README.md @@ -0,0 +1,94 @@ +# 🦅 Strix Hub (Multi-Tenant & Dual-Channel Orchestration Console) + +[English](./README.md) | [简体中文](#简体中文) + +--- + +## English + +**Strix Hub** is an open-source, multi-tenant task orchestration and dual-channel model routing platform designed for [Strix](https://github.com/usestrix/strix) autonomous AI penetration testing. + +### ✨ Key Features + +1. **Zero-Intrusion Facade Architecture**: + - Operates completely outside the core `strix/` codebase as a management facade. + - 100% compatible with current and future upstream Strix versions without code merge conflicts. +2. **Independent Dual-Channel Model Routing**: + - **Root Agent (Brain)**: High-reasoning cloud models (e.g. Gemini 3.1 Pro, Claude 3.7 Sonnet, GPT-4o) for strategic planning and vulnerability discovery. + - **Sub-agents (Muscles)**: Private/local models (e.g. Qwen 3.8 / DeepSeek / Ollama / vLLM / SGLang) for high-concurrency port scanning, fuzzing, and payload execution with **zero token costs and zero rate limits**. + - Built-in **Auto Tool-Call Recovery** to seamlessly convert text-based XML tool outputs into standard OpenAI function calls. +3. **Live Hot-Reloading**: + - Switch model providers, base URLs, and API keys on-the-fly without interrupting running penetration tasks. +4. **OS-Level Process Control**: + - True process group lifecycle management supporting **Start**, **Pause (`SIGSTOP` with 0 CPU & 0 Token consumption)**, **Resume (`SIGCONT`)**, and **Terminate**. +5. **Multi-Tenancy & Role-Based Access Control (RBAC)**: + - Built-in SQLite database with user isolation (Users manage their own scans; Admins monitor the entire fleet). +6. **Zero External Dependencies**: + - Powered purely by Python standard libraries (`http.server`, `sqlite3`, `subprocess`, `threading`) and a single-file modern Dark Mode React SPA. + +### 🚀 Quick Start + +```bash +# 1. Install & build Strix +git clone https://github.com/usestrix/strix.git +cd strix +make dev-install + +# 2. Launch Strix Hub (default port 8888) +python -m strix_hub.main --port 8888 +``` + +Open `http://localhost:8888` in your browser. +- **Default Admin Account**: `admin` / `admin123` + +### ⚙️ Environment Variables (Optional) + +```bash +export LOCAL_LLM_MODEL="openai/Qwen3.8-27B-abliterated" +export LOCAL_LLM_URL="http://127.0.0.1:8000/v1" +export LOCAL_LLM_KEY="your-api-key" +``` + +--- + + +## 简体中文 + +**Strix Hub** 是专为 [Strix](https://github.com/usestrix/strix) 打造的现代化、零侵入、多租户渗透测试任务编排与双渠道模型网关管理平台。 + +### 🌟 核心特性 + +1. **零侵入门面架构 (Zero-Intrusion Facade)**: + - 100% 独立于官方 `strix/` 核心代码运行,完全解耦。 + - 官方上游仓库执行 `git pull` 升级时**零代码合并冲突**,天然适配当前与未来所有 Strix 版本。 +2. **独立双渠道模型路由网关 (Dual-Channel Routing)**: + - **主控大脑 (Root Agent)**:可自由配置云端顶尖推理模型(如 Gemini 3.1 Pro、Claude 3.7 Sonnet、GPT-4o),负责全局渗透决策与漏洞挖掘。 + - **探测打手 (Sub-agents)**:可无缝对接局域网私有化模型(如本地部署的 Qwen 3.8-27B 无审查特化版 / DeepSeek / SGLang / vLLM),负责高并发端口扫描与 Web 模糊测试,**零 Token 成本、零外网限流**! + - 内置 **Tool-Call 自动解析自愈器**,自动将开源模型输出的 XML 格式工具调用转为标准 OpenAI 协议,确保子智能体触手稳定并发派生。 +3. **运行时配置热更新 (Live Hot-Reload)**: + - 任务在运行中或暂停中,均可在 Web 控制台一键热更模型渠道、Base URL 与 API Key,底层网关实时生效。 +4. **操作系统进程级精准调度**: + - 采用进程组信号控制,真正实现任务 **一键暂停 (`SIGSTOP` 瞬间零 CPU、零 Token 消耗挂起)**、**继续 (`SIGCONT` 唤醒)** 与 **强力终止**。 +5. **多租户与 RBAC 权限隔离**: + - 内置轻量 SQLite 持久化,普通用户仅能查看与操作自己创建的任务,管理员全局统一纳管。 +6. **零额外依赖 & 极速部署**: + - 后端基于 Python 标准库实现,前端内置现代化 Single-File React SPA 暗黑大屏,一键即可启动。 + +### 🚀 快速启动 + +```bash +# 1. 克隆并安装 Strix +git clone https://github.com/usestrix/strix.git +cd strix +make dev-install + +# 2. 启动 Strix Hub 服务 (默认端口 8888) +python -m strix_hub.main --port 8888 +``` + +浏览器访问 `http://localhost:8888` 即可开始使用。 +- **默认管理员账户**:`admin` / `admin123`(登录后可在用户管理中修改或新增成员) + +### 📄 License + +Apache License 2.0 diff --git a/strix_hub/__init__.py b/strix_hub/__init__.py new file mode 100644 index 00000000..5d890efc --- /dev/null +++ b/strix_hub/__init__.py @@ -0,0 +1,3 @@ +"""Strix Hub — Zero-Intrusion Web Task & Multi-Model Management Platform.""" + +__version__ = "1.0.0" diff --git a/strix_hub/db.py b/strix_hub/db.py new file mode 100644 index 00000000..dc6f7329 --- /dev/null +++ b/strix_hub/db.py @@ -0,0 +1,402 @@ +"""SQLite database persistence for Strix Hub (users, tasks, sessions).""" + +from __future__ import annotations + +import hashlib +import hmac +import os +import secrets +import sqlite3 +import time +from pathlib import Path +from typing import Any + +DB_PATH = Path(os.environ.get("STRIX_HUB_DB", "/opt/strix/strix_hub.db")) +if not DB_PATH.parent.exists(): + try: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + except Exception: + DB_PATH = Path.home() / ".strix" / "strix_hub.db" + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + + +def get_connection() -> sqlite3.Connection: + conn = sqlite3.connect(str(DB_PATH), check_same_thread=False) + conn.row_factory = sqlite3.Row + return conn + + +def init_db() -> None: + """Initialize database tables and create default admin account if not exists.""" + with get_connection() as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + owner_username TEXT NOT NULL, + target TEXT NOT NULL, + scan_mode TEXT NOT NULL DEFAULT 'deep', + instruction TEXT DEFAULT '', + root_model TEXT NOT NULL DEFAULT 'openai/gemini-3.1-pro-preview', + root_api_base TEXT DEFAULT '', + root_api_key_masked TEXT DEFAULT '', + root_api_key_raw TEXT DEFAULT '', + subagent_model TEXT NOT NULL DEFAULT 'openai/gemini-3.5-flash', + subagent_api_base TEXT DEFAULT '', + subagent_api_key_masked TEXT DEFAULT '', + subagent_api_key_raw TEXT DEFAULT '', + api_base TEXT DEFAULT '', + api_key_masked TEXT DEFAULT '', + api_key_raw TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + pid INTEGER DEFAULT NULL, + run_dir_name TEXT DEFAULT '', + vulns_count INTEGER DEFAULT 0, + duration_seconds INTEGER DEFAULT 0, + log_preview TEXT DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE + ); + """) + conn.commit() + + # Dynamic Schema Migration (add columns if older table exists) + cursor = conn.cursor() + existing_cols = {row["name"] for row in cursor.execute("PRAGMA table_info(tasks)").fetchall()} + new_columns = [ + ("root_api_base", "TEXT DEFAULT ''"), + ("root_api_key_masked", "TEXT DEFAULT ''"), + ("root_api_key_raw", "TEXT DEFAULT ''"), + ("subagent_api_base", "TEXT DEFAULT ''"), + ("subagent_api_key_masked", "TEXT DEFAULT ''"), + ("subagent_api_key_raw", "TEXT DEFAULT ''"), + ] + for col_name, col_type in new_columns: + if col_name not in existing_cols: + try: + cursor.execute(f"ALTER TABLE tasks ADD COLUMN {col_name} {col_type}") + except Exception: + pass + conn.commit() + + # Seed default admin user if table is empty + ensure_admin_user() + + +def hash_password(password: str, salt: str | None = None) -> tuple[str, str]: + if salt is None: + salt = secrets.token_hex(16) + key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 100_000) + return key.hex(), salt + + +def verify_password(password: str, password_hash: str, salt: str) -> bool: + key, _ = hash_password(password, salt) + return hmac.compare_digest(key, password_hash) + + +def ensure_admin_user() -> None: + with get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1") + if cursor.fetchone() is None: + admin_id = f"user_{secrets.token_hex(6)}" + p_hash, salt = hash_password("admin123") + cursor.execute( + "INSERT INTO users (id, username, password_hash, salt, role, created_at) VALUES (?, ?, ?, ?, ?, ?)", + (admin_id, "admin", p_hash, salt, "admin", int(time.time())), + ) + conn.commit() + + +# --- User Operations --- + +def create_user(username: str, password: str, role: str = "user") -> dict[str, Any] | None: + user_id = f"user_{secrets.token_hex(6)}" + p_hash, salt = hash_password(password) + now = int(time.time()) + try: + with get_connection() as conn: + conn.execute( + "INSERT INTO users (id, username, password_hash, salt, role, created_at) VALUES (?, ?, ?, ?, ?, ?)", + (user_id, username.strip(), p_hash, salt, role, now), + ) + conn.commit() + return {"id": user_id, "username": username.strip(), "role": role, "created_at": now} + except sqlite3.IntegrityError: + return None + + +def get_user_by_username(username: str) -> dict[str, Any] | None: + with get_connection() as conn: + row = conn.execute("SELECT * FROM users WHERE username = ?", (username.strip(),)).fetchone() + return dict(row) if row else None + + +def get_user_by_id(user_id: str) -> dict[str, Any] | None: + with get_connection() as conn: + row = conn.execute("SELECT id, username, role, created_at FROM users WHERE id = ?", (user_id,)).fetchone() + return dict(row) if row else None + + +def list_users() -> list[dict[str, Any]]: + with get_connection() as conn: + rows = conn.execute("SELECT id, username, role, created_at FROM users ORDER BY created_at ASC").fetchall() + return [dict(r) for r in rows] + + +def delete_user(user_id: str) -> bool: + with get_connection() as conn: + cursor = conn.execute("DELETE FROM users WHERE id = ? AND role != 'admin'", (user_id,)) + conn.commit() + return cursor.rowcount > 0 + + +# --- Session Operations --- + +def create_session(user_id: str, ttl_seconds: int = 86400 * 7) -> str: + token = secrets.token_urlsafe(32) + expires_at = int(time.time()) + ttl_seconds + with get_connection() as conn: + conn.execute( + "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)", + (token, user_id, expires_at), + ) + conn.commit() + return token + + +def validate_session(token: str) -> dict[str, Any] | None: + now = int(time.time()) + with get_connection() as conn: + row = conn.execute( + """ + SELECT u.id, u.username, u.role + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.token = ? AND s.expires_at > ? + """, + (token, now), + ).fetchone() + return dict(row) if row else None + + +def delete_session(token: str) -> None: + with get_connection() as conn: + conn.execute("DELETE FROM sessions WHERE token = ?", (token,)) + conn.commit() + + +# --- Task Operations --- + +def mask_api_key(key: str) -> str: + if not key: + return "" + if len(key) <= 8: + return "****" + return f"{key[:4]}...{key[-4:]}" + + +def create_task( + owner_id: str, + owner_username: str, + target: str, + scan_mode: str = "deep", + instruction: str = "", + root_model: str = "openai/gemini-3.1-pro-preview", + root_api_base: str = "", + root_api_key: str = "", + subagent_model: str = "openai/gemini-3.5-flash", + subagent_api_base: str = "", + subagent_api_key: str = "", +) -> dict[str, Any]: + task_id = f"task_{secrets.token_hex(6)}" + now = int(time.time()) + root_masked = mask_api_key(root_api_key) + sub_masked = mask_api_key(subagent_api_key) + + with get_connection() as conn: + conn.execute( + """ + INSERT INTO tasks ( + id, owner_id, owner_username, target, scan_mode, instruction, + root_model, root_api_base, root_api_key_masked, root_api_key_raw, + subagent_model, subagent_api_base, subagent_api_key_masked, subagent_api_key_raw, + status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?) + """, + ( + task_id, + owner_id, + owner_username, + target.strip(), + scan_mode, + instruction.strip(), + root_model.strip(), + root_api_base.strip(), + root_masked, + root_api_key.strip(), + subagent_model.strip(), + subagent_api_base.strip(), + sub_masked, + subagent_api_key.strip(), + now, + now, + ), + ) + conn.commit() + return get_task_by_id(task_id) or {} + + +def get_task_by_id(task_id: str) -> dict[str, Any] | None: + with get_connection() as conn: + row = conn.execute( + """ + SELECT id, owner_id, owner_username, target, scan_mode, instruction, + root_model, root_api_base, root_api_key_masked, + subagent_model, subagent_api_base, subagent_api_key_masked, + status, pid, run_dir_name, vulns_count, duration_seconds, + log_preview, created_at, updated_at + FROM tasks WHERE id = ? + """, + (task_id,), + ).fetchone() + return dict(row) if row else None + + +def get_task_full(task_id: str) -> dict[str, Any] | None: + """Internal use: returns raw api_keys for task runner.""" + with get_connection() as conn: + row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone() + return dict(row) if row else None + + +def list_tasks(user_id: str | None = None, is_admin: bool = False) -> list[dict[str, Any]]: + with get_connection() as conn: + if is_admin or user_id is None: + rows = conn.execute( + """ + SELECT id, owner_id, owner_username, target, scan_mode, instruction, + root_model, root_api_base, root_api_key_masked, + subagent_model, subagent_api_base, subagent_api_key_masked, + status, pid, run_dir_name, vulns_count, duration_seconds, + created_at, updated_at + FROM tasks ORDER BY created_at DESC + """ + ).fetchall() + else: + rows = conn.execute( + """ + SELECT id, owner_id, owner_username, target, scan_mode, instruction, + root_model, root_api_base, root_api_key_masked, + subagent_model, subagent_api_base, subagent_api_key_masked, + status, pid, run_dir_name, vulns_count, duration_seconds, + created_at, updated_at + FROM tasks WHERE owner_id = ? ORDER BY created_at DESC + """, + (user_id,), + ).fetchall() + return [dict(r) for r in rows] + + +def update_task_status( + task_id: str, + status: str, + pid: int | None = None, + run_dir_name: str | None = None, + vulns_count: int | None = None, + duration_seconds: int | None = None, + log_preview: str | None = None, +) -> None: + now = int(time.time()) + updates = ["status = ?", "updated_at = ?"] + params: list[Any] = [status, now] + + if pid is not None: + updates.append("pid = ?") + params.append(pid) + if run_dir_name is not None: + updates.append("run_dir_name = ?") + params.append(run_dir_name) + if vulns_count is not None: + updates.append("vulns_count = ?") + params.append(vulns_count) + if duration_seconds is not None: + updates.append("duration_seconds = ?") + params.append(duration_seconds) + if log_preview is not None: + updates.append("log_preview = ?") + params.append(log_preview) + + params.append(task_id) + with get_connection() as conn: + conn.execute(f"UPDATE tasks SET {', '.join(updates)} WHERE id = ?", params) + conn.commit() + + +def update_task_model_config( + task_id: str, + root_model: str | None = None, + root_api_base: str | None = None, + root_api_key: str | None = None, + subagent_model: str | None = None, + subagent_api_base: str | None = None, + subagent_api_key: str | None = None, +) -> None: + """Hot update task model and channel configuration.""" + now = int(time.time()) + updates = ["updated_at = ?"] + params: list[Any] = [now] + + if root_model is not None: + updates.append("root_model = ?") + params.append(root_model.strip()) + if root_api_base is not None: + updates.append("root_api_base = ?") + params.append(root_api_base.strip()) + if root_api_key is not None: + updates.append("root_api_key_masked = ?") + updates.append("root_api_key_raw = ?") + params.append(mask_api_key(root_api_key)) + params.append(root_api_key.strip()) + + if subagent_model is not None: + updates.append("subagent_model = ?") + params.append(subagent_model.strip()) + if subagent_api_base is not None: + updates.append("subagent_api_base = ?") + params.append(subagent_api_base.strip()) + if subagent_api_key is not None: + updates.append("subagent_api_key_masked = ?") + updates.append("subagent_api_key_raw = ?") + params.append(mask_api_key(subagent_api_key)) + params.append(subagent_api_key.strip()) + + params.append(task_id) + with get_connection() as conn: + conn.execute(f"UPDATE tasks SET {', '.join(updates)} WHERE id = ?", params) + conn.commit() + + +def delete_task(task_id: str) -> bool: + with get_connection() as conn: + cursor = conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,)) + conn.commit() + return cursor.rowcount > 0 diff --git a/strix_hub/main.py b/strix_hub/main.py new file mode 100644 index 00000000..bbbc898b --- /dev/null +++ b/strix_hub/main.py @@ -0,0 +1,29 @@ +"""Main CLI Entry Point for Strix Hub.""" + +from __future__ import annotations + +import argparse +import logging +import sys + +from strix_hub.server import serve + + +def main() -> None: + parser = argparse.ArgumentParser(description="Strix Hub — Multi-Tenant Web Task & Model Control Platform") + parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)") + parser.add_argument("--port", "-p", type=int, default=8888, help="Port to listen on (default: 8888)") + parser.add_argument("--debug", action="store_true", help="Enable debug logging") + + args = parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.debug else logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + + serve(host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/strix_hub/model_router.py b/strix_hub/model_router.py new file mode 100644 index 00000000..7a0a7b5d --- /dev/null +++ b/strix_hub/model_router.py @@ -0,0 +1,313 @@ +"""Smart Dual-Channel Model Router Gateway for Strix Hub. + +Intercepts requests from Strix and dynamically routes across DIFFERENT providers/channels: +- Root Agent (Commander/Orchestrator) -> Dispatches to (root_model, root_api_base, root_api_key) +- Subagents (Recon/Fuzzing/Testers) -> Dispatches to (subagent_model, subagent_api_base, subagent_api_key) + +Supports hot-reloading channel URLs, keys, and model identifiers dynamically. +""" + +from __future__ import annotations + +import json +import logging +import threading +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse +from urllib.request import Request, urlopen + +logger = logging.getLogger("strix_hub.model_router") + + +class ModelRouterServer: + def __init__( + self, + host: str = "127.0.0.1", + port: int = 18880, + root_model: str = "openai/gemini-3.1-pro-preview", + root_api_base: str = "", + root_api_key: str = "", + subagent_model: str = "openai/gemini-3.5-flash", + subagent_api_base: str = "", + subagent_api_key: str = "", + ): + self.host = host + self.port = port + self.config_lock = threading.Lock() + self.config: dict[str, Any] = { + "root_model": root_model, + "root_api_base": root_api_base.rstrip("/"), + "root_api_key": root_api_key, + "subagent_model": subagent_model, + "subagent_api_base": subagent_api_base.rstrip("/"), + "subagent_api_key": subagent_api_key, + } + self.server: ThreadingHTTPServer | None = None + self.thread: threading.Thread | None = None + + def update_config(self, **kwargs: Any) -> None: + """Hot-reload routing channels and credentials on the fly.""" + with self.config_lock: + for k, v in kwargs.items(): + if k in self.config and v is not None: + if k.endswith("_api_base") and isinstance(v, str): + self.config[k] = v.rstrip("/") + else: + self.config[k] = v + logger.info("ModelRouter config hot-updated: %s", self.config) + + def start(self) -> None: + handler = _create_router_handler(self) + self.server = ThreadingHTTPServer((self.host, self.port), handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + logger.info("Dual-Channel Model Router Gateway listening on http://%s:%d/v1", self.host, self.port) + + def stop(self) -> None: + if self.server: + self.server.shutdown() + self.server.server_close() + self.server = None + + +def _clean_model_name(model_str: str) -> str: + """Strip litellm/openai prefixes like 'openai/Qwen3.6-35B-A3B' -> 'Qwen3.6-35B-A3B'.""" + if "/" in model_str: + return model_str.split("/", 1)[1] + return model_str + + +def _is_root_agent_payload(body: dict[str, Any]) -> bool: + """Detect if the LLM request originated from Root Agent vs a Sub-agent.""" + messages = body.get("messages", []) + if not isinstance(messages, list): + return True + + text_corpus = "" + for msg in messages: + if isinstance(msg, dict): + content = msg.get("content", "") + if isinstance(content, str): + text_corpus += " " + content + elif isinstance(content, list): + for part in content: + if isinstance(part, dict) and "text" in part: + text_corpus += " " + str(part["text"]) + + text_lower = text_corpus.lower() + if "root agent" in text_lower or "overall mission" in text_lower or "spawn_child_agent" in text_lower: + return True + if "subagent" in text_lower or "child agent" in text_lower or "agent_finish" in text_lower: + return False + return True + + +def _create_router_handler(router_instance: ModelRouterServer) -> type[BaseHTTPRequestHandler]: + class RouterHandler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: Any) -> None: + logger.debug("Router %s - %s", self.address_string(), format % args) + + def do_OPTIONS(self) -> None: + self.send_response(HTTPStatus.NO_CONTENT) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "*") + self.end_headers() + + def do_GET(self) -> None: + with router_instance.config_lock: + cfg = dict(router_instance.config) + + clean_root = _clean_model_name(cfg["root_model"]) + clean_sub = _clean_model_name(cfg["subagent_model"]) + + if self.path.endswith("/models"): + models_payload = { + "object": "list", + "data": [ + {"id": clean_root, "object": "model", "owned_by": "strix_hub"}, + {"id": clean_sub, "object": "model", "owned_by": "strix_hub"}, + {"id": cfg["root_model"], "object": "model", "owned_by": "strix_hub"}, + {"id": cfg["subagent_model"], "object": "model", "owned_by": "strix_hub"}, + ], + } + body = json.dumps(models_payload).encode("utf-8") + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"status": "strix_hub_dual_channel_router_ready"}') + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length") or 0) + raw_body = self.rfile.read(length) if length else b"" + + try: + payload = json.loads(raw_body.decode("utf-8")) if raw_body else {} + except Exception: + payload = {} + + with router_instance.config_lock: + cfg = dict(router_instance.config) + + # 1. Determine if this request is for Root Agent or Sub-agent + is_root = _is_root_agent_payload(payload) if isinstance(payload, dict) else True + + if is_root: + target_model = _clean_model_name(cfg["root_model"]) + target_base = cfg["root_api_base"] + target_key = cfg["root_api_key"] + role_label = "Root Agent (主控大脑)" + else: + target_model = _clean_model_name(cfg["subagent_model"]) + target_base = cfg["subagent_api_base"] + target_key = cfg["subagent_api_key"] + role_label = "Subagent (执行打手)" + + # 2. Rewrite model in payload + if isinstance(payload, dict): + payload["model"] = target_model + forward_body = json.dumps(payload).encode("utf-8") + else: + forward_body = raw_body + + logger.info("ModelRouter: Forwarding [%s] -> Model: %s @ Channel: %s", role_label, target_model, target_base or "Default") + + # 3. Determine Upstream URL + req_path = self.path + if req_path.startswith("/v1/"): + sub_path = req_path[len("/v1") :] + else: + sub_path = req_path + + upstream_url = f"{target_base}{sub_path}" if target_base else f"https://api.openai.com/v1{sub_path}" + + # 4. Determine Authorization Header + auth_header = f"Bearer {target_key}" if target_key else self.headers.get("Authorization", "") + + headers = { + "Content-Type": "application/json", + "User-Agent": "StrixHub-DualChannelRouter/1.0", + } + if auth_header: + headers["Authorization"] = auth_header + + is_stream = payload.get("stream", False) if isinstance(payload, dict) else False + + try: + req = Request(upstream_url, data=forward_body, headers=headers, method="POST") + with urlopen(req, timeout=300) as response: + self.send_response(response.status) + for k, v in response.getheaders(): + if k.lower() not in ["content-length", "transfer-encoding", "content-encoding"]: + self.send_header(k, v) + + if is_stream: + self.send_header("Transfer-Encoding", "chunked") + self.end_headers() + while True: + chunk = response.read(4096) + if not chunk: + break + self.wfile.write(f"{len(chunk):X}\r\n".encode("ascii") + chunk + b"\r\n") + self.wfile.flush() + self.wfile.write(b"0\r\n\r\n") + self.wfile.flush() + else: + raw_data = response.read() + try: + json_obj = json.loads(raw_data.decode("utf-8")) + if isinstance(json_obj, dict) and "choices" in json_obj and json_obj["choices"]: + msg = json_obj["choices"][0].get("message", {}) + content = msg.get("content", "") or "" + if not msg.get("tool_calls") and "" in content: + extracted = _parse_qwen_tool_calls(content) + if extracted: + msg["tool_calls"] = extracted + json_obj["choices"][0]["finish_reason"] = "tool_calls" + logger.info("ModelRouter: Auto-extracted %d tool calls from Qwen text output!", len(extracted)) + raw_data = json.dumps(json_obj, ensure_ascii=False).encode("utf-8") + except Exception: + pass + + self.send_header("Content-Length", str(len(raw_data))) + self.end_headers() + self.wfile.write(raw_data) + except HTTPError as e: + err_content = e.read() + self.send_response(e.code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(err_content))) + self.end_headers() + self.wfile.write(err_content) + except Exception as exc: + logger.exception("Router forwarding error to %s", upstream_url) + err_msg = json.dumps({"error": {"message": str(exc), "type": "dual_channel_router_error"}}).encode("utf-8") + self.send_response(HTTPStatus.BAD_GATEWAY) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(err_msg))) + self.end_headers() + self.wfile.write(err_msg) + + return RouterHandler + + +def _parse_qwen_tool_calls(content: str) -> list[dict[str, Any]]: + """Extract tool calls from plain text Qwen XML/JSON tags.""" + if not content or "" not in content: + return [] + + import re + import uuid + + tool_calls: list[dict[str, Any]] = [] + matches = re.findall(r"(.*?)", content, re.DOTALL) + for block in matches: + block = block.strip() + func_match = re.search(r"", block) + if func_match: + func_name = func_match.group(1) + params: dict[str, Any] = {} + param_matches = re.findall(r"\s*(.*?)\s*", block, re.DOTALL) + for k, v in param_matches: + v = v.strip() + try: + params[k] = json.loads(v) + except Exception: + params[k] = v + tool_calls.append({ + "id": f"call_{uuid.uuid4().hex[:8]}", + "type": "function", + "function": { + "name": func_name, + "arguments": json.dumps(params, ensure_ascii=False), + }, + }) + continue + + clean_json = re.sub(r"^```(json)?|```$", "", block, flags=re.MULTILINE).strip() + try: + parsed = json.loads(clean_json) + if isinstance(parsed, dict) and "name" in parsed: + args = parsed.get("arguments", {}) + args_str = json.dumps(args, ensure_ascii=False) if isinstance(args, dict) else str(args) + tool_calls.append({ + "id": f"call_{uuid.uuid4().hex[:8]}", + "type": "function", + "function": { + "name": parsed["name"], + "arguments": args_str, + }, + }) + except Exception: + pass + + return tool_calls diff --git a/strix_hub/server.py b/strix_hub/server.py new file mode 100644 index 00000000..df280f68 --- /dev/null +++ b/strix_hub/server.py @@ -0,0 +1,1661 @@ +"""Standalone HTTP REST Server and SPA host for Strix Hub. + +Built with Python standard library for zero external dependencies. +Supports Independent Dual-Channel Providers, Local Qwen3.6 Presets & Live Hot-Reloading. +""" + +from __future__ import annotations + +import json +import logging +import mimetypes +import os +import secrets +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, unquote, urlsplit + +from strix_hub import db, task_manager + +logger = logging.getLogger("strix_hub.server") + +STATIC_DIR = Path(__file__).resolve().parent / "static" + +# Configurable Local LLM environment (reads from env vars or defaults to generic placeholders) +LOCAL_LLM_MODEL = os.environ.get("LOCAL_LLM_MODEL", "openai/Qwen3.8-27B-abliterated") +LOCAL_LLM_URL = os.environ.get("LOCAL_LLM_URL", os.environ.get("OPENAI_BASE_URL", "http://127.0.0.1:8000/v1")) +LOCAL_LLM_KEY = os.environ.get("LOCAL_LLM_KEY", os.environ.get("OPENAI_API_KEY", "")) + +MODEL_PRESETS = [ + { + "id": "hybrid-gemini-qwen", + "name": "🚀 顶配混合动力 (Gemini 3.1 Pro 大脑 + 本地 Qwen 3.8 打手)", + "root_model": "openai/gemini-3.1-pro-preview", + "root_api_base": "", + "root_api_key": "", + "subagent_model": LOCAL_LLM_MODEL, + "subagent_api_base": LOCAL_LLM_URL, + "subagent_api_key": LOCAL_LLM_KEY, + "description": "【最佳推荐】主控用云端 Gemini 3.1 Pro 百万上下文做复杂漏洞挖掘;海量并发子智能体全部走本地私有化模型,零成本无外网限流!", + }, + { + "id": "local-pure-cluster", + "name": "🛡️ 本地全离线集群 (主子全跑本地私有化模型)", + "root_model": LOCAL_LLM_MODEL, + "root_api_base": LOCAL_LLM_URL, + "root_api_key": LOCAL_LLM_KEY, + "subagent_model": LOCAL_LLM_MODEL, + "subagent_api_base": LOCAL_LLM_URL, + "subagent_api_key": LOCAL_LLM_KEY, + "description": "完全在企业局域网内运行,数据绝不出网,适合离线环境与内网安全合规审计。", + }, + { + "id": "gemini-optimal", + "name": "⚡ Gemini 纯云端组合 (3.1 Pro + 3.5 Flash)", + "root_model": "openai/gemini-3.1-pro-preview", + "root_api_base": "", + "root_api_key": "", + "subagent_model": "openai/gemini-3.5-flash", + "subagent_api_base": "", + "subagent_api_key": "", + "description": "主控用 3.1 Pro 推理,子任务用 3.5 Flash 极速响应,全云端中转组合。", + }, + { + "id": "claude-hybrid", + "name": "💎 Claude 3.7 安全审计 + 本地模型混合调度", + "root_model": "openai/claude-3-7-sonnet", + "root_api_base": "", + "root_api_key": "", + "subagent_model": LOCAL_LLM_MODEL, + "subagent_api_base": LOCAL_LLM_URL, + "subagent_api_key": LOCAL_LLM_KEY, + "description": "主控使用顶级安全审计模型 Claude 3.7,子任务由本地私有化集群并发执行。", + }, + { + "id": "custom", + "name": "⚙️ 自定义独立双渠道 (Custom Dual Channels)", + "root_model": "", + "root_api_base": "", + "root_api_key": "", + "subagent_model": "", + "subagent_api_base": "", + "subagent_api_key": "", + "description": "自由为两个模型分别配置不同的 Base URL 与 API Key 渠道。", + }, +] + + +def make_hub_handler() -> type[BaseHTTPRequestHandler]: + class HubHandler(BaseHTTPRequestHandler): + server_version = "StrixHub/1.1" + + def log_message(self, format: str, *args: Any) -> None: + logger.debug("StrixHub %s - %s", self.address_string(), format % args) + + def _send_json(self, status: HTTPStatus, payload: Any, headers: dict[str, str] | None = None) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Credentials", "true") + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + if headers: + for k, v in headers.items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(body) + + def _read_json_body(self) -> dict[str, Any]: + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"" + try: + data = json.loads(raw.decode("utf-8")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + def _get_current_user(self) -> dict[str, Any] | None: + auth_header = self.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:].strip() + user = db.validate_session(token) + if user: + return user + + cookie_str = self.headers.get("Cookie", "") + for c in cookie_str.split(";"): + parts = c.strip().split("=", 1) + if len(parts) == 2 and parts[0] == "strix_hub_session": + user = db.validate_session(parts[1]) + if user: + return user + + return None + + def do_OPTIONS(self) -> None: + self.send_response(HTTPStatus.NO_CONTENT) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, Cookie") + self.send_header("Access-Control-Allow-Credentials", "true") + self.end_headers() + + def do_GET(self) -> None: + parts = urlsplit(self.path) + path = parts.path + query = parse_qs(parts.query) + + if path.startswith("/api/"): + self._handle_api_get(path, query) + else: + self._handle_static(path) + + def do_POST(self) -> None: + path = urlsplit(self.path).path + if path.startswith("/api/"): + self._handle_api_post(path) + else: + self._send_json(HTTPStatus.NOT_FOUND, {"error": "Not Found"}) + + def do_PUT(self) -> None: + path = urlsplit(self.path).path + if path.startswith("/api/"): + self._handle_api_put(path) + else: + self._send_json(HTTPStatus.NOT_FOUND, {"error": "Not Found"}) + + def do_DELETE(self) -> None: + path = urlsplit(self.path).path + if path.startswith("/api/"): + self._handle_api_delete(path) + else: + self._send_json(HTTPStatus.NOT_FOUND, {"error": "Not Found"}) + + # --- API Route Handlers --- + + def _handle_api_get(self, path: str, query: dict[str, list[str]]) -> None: + if path == "/api/models/presets": + self._send_json(HTTPStatus.OK, { + "presets": MODEL_PRESETS, + "local_defaults": { + "model": LOCAL_QWEN38_MODEL, + "url": LOCAL_QWEN38_URL, + "key": LOCAL_QWEN38_KEY, + } + }) + return + + if path == "/api/auth/me": + user = self._get_current_user() + if not user: + self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"}) + return + self._send_json(HTTPStatus.OK, {"user": user}) + return + + user = self._get_current_user() + if not user: + self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized, please login"}) + return + + if path == "/api/tasks": + is_admin = user.get("role") == "admin" + tasks = db.list_tasks(user_id=user["id"], is_admin=is_admin) + self._send_json(HTTPStatus.OK, {"tasks": tasks, "count": len(tasks), "is_admin": is_admin}) + return + + if path.startswith("/api/tasks/") and not path.endswith("/logs"): + task_id = path.split("/")[3] + task = db.get_task_by_id(task_id) + if not task: + self._send_json(HTTPStatus.NOT_FOUND, {"error": "Task not found"}) + return + if user.get("role") != "admin" and task.get("owner_id") != user["id"]: + self._send_json(HTTPStatus.FORBIDDEN, {"error": "Forbidden"}) + return + self._send_json(HTTPStatus.OK, {"task": task}) + return + + if path.startswith("/api/tasks/") and path.endswith("/logs"): + task_id = path.split("/")[3] + task = db.get_task_by_id(task_id) + if not task: + self._send_json(HTTPStatus.NOT_FOUND, {"error": "Task not found"}) + return + if user.get("role") != "admin" and task.get("owner_id") != user["id"]: + self._send_json(HTTPStatus.FORBIDDEN, {"error": "Forbidden"}) + return + logs = task_manager.get_task_logs(task_id) + self._send_json(HTTPStatus.OK, {"logs": logs}) + return + + if path == "/api/admin/users": + if user.get("role") != "admin": + self._send_json(HTTPStatus.FORBIDDEN, {"error": "Admin permission required"}) + return + users = db.list_users() + self._send_json(HTTPStatus.OK, {"users": users}) + return + + self._send_json(HTTPStatus.NOT_FOUND, {"error": "API route not found"}) + + def _handle_api_post(self, path: str) -> None: + body = self._read_json_body() + + if path == "/api/auth/login": + username = str(body.get("username", "")).strip() + password = str(body.get("password", "")).strip() + user = db.get_user_by_username(username) + if not user or not db.verify_password(password, user["password_hash"], user["salt"]): + self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "用户名或密码错误"}) + return + + token = db.create_session(user["id"]) + user_data = {"id": user["id"], "username": user["username"], "role": user["role"]} + cookie_header = { + "Set-Cookie": f"strix_hub_session={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800" + } + self._send_json(HTTPStatus.OK, {"token": token, "user": user_data}, headers=cookie_header) + return + + if path == "/api/auth/logout": + token = "" + auth_header = self.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:].strip() + if not token: + cookie_str = self.headers.get("Cookie", "") + for c in cookie_str.split(";"): + parts = c.strip().split("=", 1) + if len(parts) == 2 and parts[0] == "strix_hub_session": + token = parts[1] + if token: + db.delete_session(token) + cookie_header = {"Set-Cookie": "strix_hub_session=; Path=/; HttpOnly; Max-Age=0"} + self._send_json(HTTPStatus.OK, {"ok": True}, headers=cookie_header) + return + + user = self._get_current_user() + if not user: + self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"}) + return + + # Create & Launch Task with Independent Dual Channel + if path == "/api/tasks": + target = str(body.get("target", "")).strip() + if not target: + self._send_json(HTTPStatus.BAD_REQUEST, {"error": "Target is required"}) + return + + scan_mode = str(body.get("scan_mode", "deep")).strip() + instruction = str(body.get("instruction", "")).strip() + + root_model = str(body.get("root_model", "openai/gemini-3.1-pro-preview")).strip() + root_api_base = str(body.get("root_api_base", "")).strip() + root_api_key = str(body.get("root_api_key", "")).strip() + + subagent_model = str(body.get("subagent_model", "openai/gemini-3.5-flash")).strip() + subagent_api_base = str(body.get("subagent_api_base", "")).strip() + subagent_api_key = str(body.get("subagent_api_key", "")).strip() + + task = db.create_task( + owner_id=user["id"], + owner_username=user["username"], + target=target, + scan_mode=scan_mode, + instruction=instruction, + root_model=root_model, + root_api_base=root_api_base, + root_api_key=root_api_key, + subagent_model=subagent_model, + subagent_api_base=subagent_api_base, + subagent_api_key=subagent_api_key, + ) + + started_task = task_manager.start_task(task["id"]) + self._send_json(HTTPStatus.CREATED, {"task": started_task}) + return + + if path.startswith("/api/tasks/") and path.endswith("/start"): + task_id = path.split("/")[3] + task = db.get_task_by_id(task_id) + if not task or (user.get("role") != "admin" and task.get("owner_id") != user["id"]): + self._send_json(HTTPStatus.FORBIDDEN, {"error": "Forbidden"}) + return + res = task_manager.start_task(task_id) + self._send_json(HTTPStatus.OK, {"task": res, "status": "running"}) + return + + if path.startswith("/api/tasks/") and path.endswith("/pause"): + task_id = path.split("/")[3] + task = db.get_task_by_id(task_id) + if not task or (user.get("role") != "admin" and task.get("owner_id") != user["id"]): + self._send_json(HTTPStatus.FORBIDDEN, {"error": "Forbidden"}) + return + ok = task_manager.pause_task(task_id) + self._send_json(HTTPStatus.OK, {"ok": ok, "status": "paused" if ok else task.get("status")}) + return + + if path.startswith("/api/tasks/") and path.endswith("/resume"): + task_id = path.split("/")[3] + task = db.get_task_by_id(task_id) + if not task or (user.get("role") != "admin" and task.get("owner_id") != user["id"]): + self._send_json(HTTPStatus.FORBIDDEN, {"error": "Forbidden"}) + return + ok = task_manager.resume_task(task_id) + self._send_json(HTTPStatus.OK, {"ok": ok, "status": "running" if ok else task.get("status")}) + return + + if path.startswith("/api/tasks/") and path.endswith("/stop"): + task_id = path.split("/")[3] + task = db.get_task_by_id(task_id) + if not task or (user.get("role") != "admin" and task.get("owner_id") != user["id"]): + self._send_json(HTTPStatus.FORBIDDEN, {"error": "Forbidden"}) + return + ok = task_manager.stop_task(task_id) + self._send_json(HTTPStatus.OK, {"ok": ok, "status": "stopped"}) + return + + if path == "/api/admin/users": + if user.get("role") != "admin": + self._send_json(HTTPStatus.FORBIDDEN, {"error": "Admin permission required"}) + return + username = str(body.get("username", "")).strip() + password = str(body.get("password", "")).strip() + role = str(body.get("role", "user")).strip() + if not username or not password: + self._send_json(HTTPStatus.BAD_REQUEST, {"error": "Username and password required"}) + return + new_user = db.create_user(username, password, role) + if not new_user: + self._send_json(HTTPStatus.CONFLICT, {"error": "Username already exists"}) + return + self._send_json(HTTPStatus.CREATED, {"user": new_user}) + return + + self._send_json(HTTPStatus.NOT_FOUND, {"error": "Endpoint not found"}) + + def _handle_api_put(self, path: str) -> None: + """Hot Update task configuration.""" + user = self._get_current_user() + if not user: + self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"}) + return + + if path.startswith("/api/tasks/") and path.endswith("/config"): + task_id = path.split("/")[3] + task = db.get_task_by_id(task_id) + if not task or (user.get("role") != "admin" and task.get("owner_id") != user["id"]): + self._send_json(HTTPStatus.FORBIDDEN, {"error": "Forbidden"}) + return + + body = self._read_json_body() + task_manager.hot_update_task_config( + task_id=task_id, + root_model=body.get("root_model"), + root_api_base=body.get("root_api_base"), + root_api_key=body.get("root_api_key"), + subagent_model=body.get("subagent_model"), + subagent_api_base=body.get("subagent_api_base"), + subagent_api_key=body.get("subagent_api_key"), + ) + updated = db.get_task_by_id(task_id) + self._send_json(HTTPStatus.OK, {"task": updated, "message": "配置热更新生效成功!"}) + return + + self._send_json(HTTPStatus.NOT_FOUND, {"error": "Endpoint not found"}) + + def _handle_api_delete(self, path: str) -> None: + user = self._get_current_user() + if not user: + self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"}) + return + + if path.startswith("/api/tasks/"): + task_id = path.split("/")[3] + task = db.get_task_by_id(task_id) + if not task or (user.get("role") != "admin" and task.get("owner_id") != user["id"]): + self._send_json(HTTPStatus.FORBIDDEN, {"error": "Forbidden"}) + return + task_manager.stop_task(task_id) + db.delete_task(task_id) + self._send_json(HTTPStatus.OK, {"ok": True}) + return + + if path.startswith("/api/admin/users/"): + if user.get("role") != "admin": + self._send_json(HTTPStatus.FORBIDDEN, {"error": "Admin permission required"}) + return + del_id = path.split("/")[4] + ok = db.delete_user(del_id) + self._send_json(HTTPStatus.OK, {"ok": ok}) + return + + self._send_json(HTTPStatus.NOT_FOUND, {"error": "Endpoint not found"}) + + def _handle_static(self, path: str) -> None: + self._serve_embedded_ui() + + def _serve_embedded_ui(self) -> None: + html = EMBEDDED_SPA_HTML + data = html.encode("utf-8") + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + return HubHandler + + +def serve(host: str = "0.0.0.0", port: int = 8888) -> None: + db.init_db() + handler = make_hub_handler() + server = ThreadingHTTPServer((host, port), handler) + logger.info("Strix Hub is running on http://%s:%d", host, port) + print(f"\n=======================================================") + print(f"🚀 Strix Hub Dual-Channel Platform running at:") + print(f"👉 http://{host}:{port}") + print(f" Default Admin: admin / admin123") + print(f" Local Qwen 3.6 Cluster: {LOCAL_QWEN_URL}") + print(f"=======================================================\n") + try: + server.serve_forever() + except KeyboardInterrupt: + logger.info("Shutting down Strix Hub...") + server.server_close() + + +# Self-contained modern Web UI template (Tailwind + React bundle with Dual-Channel & Hot Reload) +EMBEDDED_SPA_HTML = """ + + + + + Strix Hub — 独立双渠道多模型自动化渗透控制台 + + + + + + + + + +
+ + + + +""" diff --git a/strix_hub/task_manager.py b/strix_hub/task_manager.py new file mode 100644 index 00000000..02407ecb --- /dev/null +++ b/strix_hub/task_manager.py @@ -0,0 +1,342 @@ +"""Task Lifecycle & Process Supervisor for Strix Hub. + +Controls Strix scans via subprocesses and OS signals (SIGSTOP, SIGCONT, SIGTERM). +Supports Dual-Channel Model Routing and Hot-Reloading. +""" + +from __future__ import annotations + +import json +import logging +import os +import signal +import subprocess +import threading +import time +from pathlib import Path +from typing import Any + +from strix_hub import db +from strix_hub.model_router import ModelRouterServer + +logger = logging.getLogger("strix_hub.task_manager") + +STRIX_RUNS_DIR = Path(os.environ.get("STRIX_RUNS_DIR", "/opt/strix/strix_runs")) +if not STRIX_RUNS_DIR.exists(): + try: + STRIX_RUNS_DIR.mkdir(parents=True, exist_ok=True) + except Exception: + STRIX_RUNS_DIR = Path.cwd() / "strix_runs" + STRIX_RUNS_DIR.mkdir(parents=True, exist_ok=True) + +# Active runners in memory: {task_id: {"process": Popen, "router": ModelRouterServer, "start_time": float}} +_ACTIVE_TASKS: dict[str, dict[str, Any]] = {} +_LOCK = threading.Lock() + + +def get_strix_bin_path() -> str: + """Find the strix executable binary path.""" + venv_strix = Path("/opt/strix/.venv/bin/strix") + if venv_strix.is_file(): + return str(venv_strix) + uv_bin = Path("/usr/local/bin/uv") + if uv_bin.is_file(): + return "/usr/local/bin/uv run strix" + return "strix" + + +def start_task(task_id: str) -> dict[str, Any]: + """Launch a pentest task as a background subprocess with dual-channel routing.""" + task = db.get_task_full(task_id) + if not task: + raise ValueError("Task not found") + + with _LOCK: + if task_id in _ACTIVE_TASKS: + existing = _ACTIVE_TASKS[task_id].get("process") + if existing and existing.poll() is None: + return db.get_task_by_id(task_id) or {} + + # 1. Start dedicated Dual-Channel ModelRouter on a free port for this task + port = 18800 + (abs(hash(task_id)) % 1000) + + # Fallback to server env if task channel is left blank + default_base = os.environ.get("OPENAI_BASE_URL") or os.environ.get("LLM_API_BASE", "https://api.openai.com/v1") + default_key = os.environ.get("OPENAI_API_KEY") or os.environ.get("LLM_API_KEY", "") + + root_base = task.get("root_api_base") or task.get("api_base") or default_base + root_key = task.get("root_api_key_raw") or task.get("api_key_raw") or default_key + + sub_base = task.get("subagent_api_base") or task.get("api_base") or default_base + sub_key = task.get("subagent_api_key_raw") or task.get("api_key_raw") or default_key + + router = ModelRouterServer( + host="127.0.0.1", + port=port, + root_model=task["root_model"], + root_api_base=root_base, + root_api_key=root_key, + subagent_model=task["subagent_model"], + subagent_api_base=sub_base, + subagent_api_key=sub_key, + ) + router.start() + + # 2. Build subprocess environment (directs LiteLLM / OpenAI to local ModelRouter) + env = dict(os.environ) + env["OPENAI_BASE_URL"] = f"http://127.0.0.1:{port}/v1" + env["LLM_API_BASE"] = f"http://127.0.0.1:{port}/v1" + root_m = task["root_model"] + env["STRIX_LLM"] = root_m if root_m.startswith("openai/") else f"openai/{root_m}" + # A dummy key to satisfy litellm validation if empty + env["OPENAI_API_KEY"] = root_key or "strix-hub-key" + env["LLM_API_KEY"] = root_key or "strix-hub-key" + # Disable streaming so ModelRouter can accurately parse & auto-recover Qwen text tool-calls + env["LLM_DISABLE_STREAMING"] = "true" + + # 3. Assemble command line arguments + target = task["target"] + scan_mode = task["scan_mode"] + instruction = task["instruction"] + + strix_bin = get_strix_bin_path() + cmd = f"{strix_bin} -n --target {target} --scan-mode {scan_mode}" + if instruction: + clean_inst = instruction.replace('"', '\\"') + cmd += f' --instruction "{clean_inst}"' + + # Work directory + work_dir = "/opt/strix" if Path("/opt/strix").is_dir() else str(Path.cwd()) + log_file_path = STRIX_RUNS_DIR / f"{task_id}.log" + log_f = open(log_file_path, "w", encoding="utf-8") + + # Spawn in a new process group so SIGSTOP/SIGCONT pauses all children (Docker/tools) + proc = subprocess.Popen( + cmd, + shell=True, + cwd=work_dir, + env=env, + stdout=log_f, + stderr=subprocess.STDOUT, + preexec_fn=os.setsid if hasattr(os, "setsid") else None, + ) + + start_time = time.time() + _ACTIVE_TASKS[task_id] = { + "process": proc, + "router": router, + "start_time": start_time, + "log_file": log_f, + } + + db.update_task_status(task_id, status="running", pid=proc.pid) + + # Spawn background monitor thread for this task + threading.Thread(target=_monitor_task_process, args=(task_id,), daemon=True).start() + return db.get_task_by_id(task_id) or {} + + +def hot_update_task_config( + task_id: str, + root_model: str | None = None, + root_api_base: str | None = None, + root_api_key: str | None = None, + subagent_model: str | None = None, + subagent_api_base: str | None = None, + subagent_api_key: str | None = None, +) -> bool: + """Hot-reload model configuration in DB and live ModelRouter on the fly.""" + # 1. Update in SQLite DB + db.update_task_model_config( + task_id=task_id, + root_model=root_model, + root_api_base=root_api_base, + root_api_key=root_api_key, + subagent_model=subagent_model, + subagent_api_base=subagent_api_base, + subagent_api_key=subagent_api_key, + ) + + # 2. Hot-reload active ModelRouter instance if task is currently running / paused + with _LOCK: + info = _ACTIVE_TASKS.get(task_id) + if info and info.get("router"): + router: ModelRouterServer = info["router"] + update_payload: dict[str, Any] = {} + if root_model is not None: + update_payload["root_model"] = root_model + if root_api_base is not None: + update_payload["root_api_base"] = root_api_base + if root_api_key is not None: + update_payload["root_api_key"] = root_api_key + if subagent_model is not None: + update_payload["subagent_model"] = subagent_model + if subagent_api_base is not None: + update_payload["subagent_api_base"] = subagent_api_base + if subagent_api_key is not None: + update_payload["subagent_api_key"] = subagent_api_key + router.update_config(**update_payload) + logger.info("Hot-updated running ModelRouter for task %s", task_id) + + return True + + +def pause_task(task_id: str) -> bool: + """Pause task execution using SIGSTOP.""" + with _LOCK: + info = _ACTIVE_TASKS.get(task_id) + if not info or not info.get("process"): + return False + proc = info["process"] + if proc.poll() is not None: + return False + + try: + pgid = os.getpgid(proc.pid) + os.killpg(pgid, signal.SIGSTOP) + db.update_task_status(task_id, status="paused") + logger.info("Paused task %s (PID %d, PGID %d)", task_id, proc.pid, pgid) + return True + except Exception: + logger.exception("Failed to pause task %s", task_id) + return False + + +def resume_task(task_id: str) -> bool: + """Resume task execution using SIGCONT.""" + with _LOCK: + info = _ACTIVE_TASKS.get(task_id) + if not info or not info.get("process"): + return False + proc = info["process"] + if proc.poll() is not None: + return False + + try: + pgid = os.getpgid(proc.pid) + os.killpg(pgid, signal.SIGCONT) + db.update_task_status(task_id, status="running") + logger.info("Resumed task %s (PID %d, PGID %d)", task_id, proc.pid, pgid) + return True + except Exception: + logger.exception("Failed to resume task %s", task_id) + return False + + +def stop_task(task_id: str) -> bool: + """Stop/Kill task execution using SIGTERM / SIGKILL.""" + with _LOCK: + info = _ACTIVE_TASKS.get(task_id) + if not info or not info.get("process"): + db.update_task_status(task_id, status="stopped") + return True + proc = info["process"] + + try: + pgid = os.getpgid(proc.pid) + os.killpg(pgid, signal.SIGTERM) + time.sleep(0.5) + if proc.poll() is None: + os.killpg(pgid, signal.SIGKILL) + except Exception: + pass + + if info.get("router"): + info["router"].stop() + if info.get("log_file") and not info["log_file"].closed: + info["log_file"].close() + + _ACTIVE_TASKS.pop(task_id, None) + db.update_task_status(task_id, status="stopped") + logger.info("Stopped task %s", task_id) + return True + + +def _monitor_task_process(task_id: str) -> None: + """Monitor task process completion, update status and parse findings.""" + with _LOCK: + info = _ACTIVE_TASKS.get(task_id) + if not info: + return + + proc: subprocess.Popen = info["process"] + router: ModelRouterServer = info["router"] + start_time: float = info["start_time"] + + while proc.poll() is None: + time.sleep(1.0) + elapsed = int(time.time() - start_time) + run_dir_name, vulns_cnt = _inspect_strix_runs_dir(task_id) + current_task = db.get_task_by_id(task_id) + current_status = current_task.get("status") if current_task else "running" + new_status = "paused" if current_status == "paused" else "running" + db.update_task_status( + task_id, + status=new_status, + duration_seconds=elapsed, + run_dir_name=run_dir_name, + vulns_count=vulns_cnt, + ) + + exit_code = proc.returncode + elapsed = int(time.time() - start_time) + run_dir_name, vulns_cnt = _inspect_strix_runs_dir(task_id) + + final_status = "completed" if exit_code in [0, 2] else "failed" + db.update_task_status( + task_id, + status=final_status, + duration_seconds=elapsed, + run_dir_name=run_dir_name, + vulns_count=vulns_cnt, + ) + + router.stop() + if info.get("log_file") and not info["log_file"].closed: + info["log_file"].close() + + with _LOCK: + _ACTIVE_TASKS.pop(task_id, None) + logger.info("Task %s completed with status [%s] (code %d)", task_id, final_status, exit_code) + + +def _inspect_strix_runs_dir(task_id: str) -> tuple[str | None, int]: + """Inspect newest run artifacts to find linked run_dir and vulnerability counts.""" + if not STRIX_RUNS_DIR.is_dir(): + return None, 0 + + runs = sorted( + [d for d in STRIX_RUNS_DIR.iterdir() if d.is_dir() and not d.name.startswith(".")], + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + + if not runs: + return None, 0 + + latest = runs[0] + vuln_count = 0 + vulns_file = latest / "vulnerabilities.json" + if vulns_file.is_file(): + try: + with open(vulns_file, "r", encoding="utf-8") as f: + vulns = json.load(f) + if isinstance(vulns, list): + vuln_count = len(vulns) + except Exception: + pass + + return latest.name, vuln_count + + +def get_task_logs(task_id: str, max_lines: int = 100) -> str: + """Read the latest log output for a task.""" + log_path = STRIX_RUNS_DIR / f"{task_id}.log" + if not log_path.is_file(): + return "No log output available yet." + try: + with open(log_path, "r", encoding="utf-8", errors="replace") as f: + lines = f.readlines() + return "".join(lines[-max_lines:]) + except Exception as e: + return f"Error reading logs: {e}"