mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-16 13:19:17 +02:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc1779275d | |||
| 10dff937b1 | |||
| d4e1fe3bbe | |||
| 179976ae57 | |||
| 1c758bb98c | |||
| 17c4f38ee3 | |||
| cd7e57d121 | |||
| 0f2c3f65cc | |||
| 7779666e27 | |||
| c74bd4403b | |||
| 04d23ddb43 | |||
| 0874e84393 | |||
| 57f57f30b1 | |||
| f37d613a0c | |||
| 87d0ff9154 | |||
| b3418f39b8 | |||
| f9e1ca0e2d | |||
| 2c45879669 | |||
| 1cdcfa2c2d | |||
| eab5b73846 | |||
| d961ba1ec7 | |||
| 1ba5e57ec6 | |||
| 1216d25f96 | |||
| fde693408e | |||
| 352a81a869 | |||
| b2562b1010 | |||
| 0d8ba51087 | |||
| 0b847fcea3 | |||
| bf2f49fe62 | |||
| 75e64b1a86 | |||
| 2167735022 | |||
| 4ee292cc1f | |||
| 961205940f | |||
| ffe797bd06 | |||
| b6c864547e | |||
| da369c2edc | |||
| 54dc31a616 | |||
| 9e0b985221 | |||
| eb47077082 | |||
| f9a482857d | |||
| 679a68b12f | |||
| 840a26c7ef | |||
| 030e69c02d | |||
| d9683cdb44 | |||
| 60a063dd7d | |||
| 5f0c1805a7 |
@@ -174,9 +174,11 @@ The `run.sh` script will automatically:
|
||||
- ✅ Build the project
|
||||
- ✅ Start the server
|
||||
|
||||
**Networking defaults:** `run.sh` starts the server with **`--https`** and the repo **`config.yaml`** (local self-signed TLS; better for many concurrent streams). Use **`./run.sh --http`** for plain HTTP. In production, set **`server.tls_cert_path`** / **`server.tls_key_path`** in **`config.yaml`** (see comments there). For manual runs, add **`--https`** or **`CYBERSTRIKE_HTTPS=1`**; if **`-config`** is wrong, the binary prints a short usage hint on stderr.
|
||||
|
||||
**First-Time Configuration:**
|
||||
1. **Configure OpenAI-compatible API** (required before first use)
|
||||
- Open http://localhost:8080 after launch
|
||||
- After launch, open **`https://127.0.0.1:8080/`** (or **`https://localhost:8080/`**; replace **8080** with `server.port` in `config.yaml`) and accept the self-signed certificate warning once. If you used `./run.sh --http`, use **`http://`** instead.
|
||||
- Go to `Settings` → Fill in your API credentials:
|
||||
```yaml
|
||||
openai:
|
||||
@@ -197,14 +199,16 @@ The `run.sh` script will automatically:
|
||||
|
||||
**Alternative Launch Methods:**
|
||||
```bash
|
||||
# Direct Go run (requires manual setup)
|
||||
go run cmd/server/main.go
|
||||
# Direct Go run (set up env yourself); add --https to match run.sh defaults
|
||||
go run cmd/server/main.go --https
|
||||
|
||||
# Manual build
|
||||
go build -o cyberstrike-ai cmd/server/main.go
|
||||
./cyberstrike-ai
|
||||
./cyberstrike-ai --https
|
||||
```
|
||||
|
||||
If server logs show `client sent an HTTP request to an HTTPS server`, a client is still using **`http://`** on a TLS-only port—switch the URL to **`https://`**.
|
||||
|
||||
**Note:** The Python virtual environment (`venv/`) is automatically created and managed by `run.sh`. Tools that require Python (like `api-fuzzer`, `http-framework-test`, etc.) will automatically use this environment.
|
||||
|
||||
### Version Update (No Breaking Changes)
|
||||
|
||||
+8
-4
@@ -173,9 +173,11 @@ chmod +x run.sh && ./run.sh
|
||||
- ✅ 编译构建项目
|
||||
- ✅ 启动服务器
|
||||
|
||||
**网络默认:** `run.sh` 会以 **`--https`** 并传入项目根 **`config.yaml`** 启动(本机自签证书,多路流式场景更稳)。只要明文 HTTP 用 **`./run.sh --http`**。生产环境在 **`config.yaml`** 的 **`server.tls_cert_path` / `server.tls_key_path`** 配正式证书(见文件内注释)。手动启动可加 **`--https`** 或环境变量 **`CYBERSTRIKE_HTTPS=1`**;`-config` 写错时程序会在终端提示正确写法。
|
||||
|
||||
**首次配置:**
|
||||
1. **配置 AI 模型 API**(首次使用前必填)
|
||||
- 启动后访问 http://localhost:8080
|
||||
- 启动后在浏览器打开 **`https://127.0.0.1:8080/`**(或 **`https://localhost:8080/`**;端口以 `config.yaml` 中 **`server.port`** 为准,默认 8080),并按提示信任自签证书。若使用 **`./run.sh --http`**,则改用 **`http://`** 访问。
|
||||
- 进入 `设置` → 填写 API 配置信息:
|
||||
```yaml
|
||||
openai:
|
||||
@@ -196,14 +198,16 @@ chmod +x run.sh && ./run.sh
|
||||
|
||||
**其他启动方式:**
|
||||
```bash
|
||||
# 直接运行(需手动配置环境)
|
||||
go run cmd/server/main.go
|
||||
# 直接运行(需自行配环境);与 run.sh 默认一致可加 --https
|
||||
go run cmd/server/main.go --https
|
||||
|
||||
# 手动编译
|
||||
go build -o cyberstrike-ai cmd/server/main.go
|
||||
./cyberstrike-ai
|
||||
./cyberstrike-ai --https
|
||||
```
|
||||
|
||||
若日志出现 `client sent an HTTP request to an HTTPS server`,说明仍有客户端用 **`http://`** 访问只提供 HTTPS 的端口,请改为 **`https://`**。
|
||||
|
||||
**说明:** Python 虚拟环境(`venv/`)由 `run.sh` 自动创建和管理。需要 Python 的工具(如 `api-fuzzer`、`http-framework-test` 等)会自动使用该环境。
|
||||
|
||||
### CyberStrikeAI 版本更新(无兼容性问题)
|
||||
|
||||
+43
-3
@@ -9,22 +9,62 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var configPath = flag.String("config", "config.yaml", "配置文件路径")
|
||||
var httpsBootstrap = flag.Bool("https", false, "启用主站 HTTPS:未配置 tls_cert_path/tls_key_path 时使用内存自签证书(本地测试);与 run.sh 默认行为一致")
|
||||
flag.Parse()
|
||||
|
||||
// 环境变量兼容(便于 systemd/docker 等不传参场景)
|
||||
if !*httpsBootstrap {
|
||||
v := strings.TrimSpace(os.Getenv("CYBERSTRIKE_HTTPS"))
|
||||
if v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") {
|
||||
*httpsBootstrap = true
|
||||
}
|
||||
}
|
||||
|
||||
// 加载配置
|
||||
cfg, err := config.Load(*configPath)
|
||||
cp := strings.TrimSpace(*configPath)
|
||||
if cp == "" {
|
||||
cp = "config.yaml"
|
||||
}
|
||||
if strings.HasPrefix(cp, "-") {
|
||||
fmt.Fprintf(os.Stderr, "无效的 -config 路径 %q。\n若同时需要 HTTPS,请写成: ./cyberstrike-ai --https -config config.yaml(-config 后必须是 yaml 文件路径)。\n", cp)
|
||||
os.Exit(2)
|
||||
}
|
||||
cfg, err := config.Load(cp)
|
||||
if err != nil {
|
||||
fmt.Printf("加载配置失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if *httpsBootstrap {
|
||||
config.ApplyDevHTTPSBootstrap(cfg)
|
||||
}
|
||||
|
||||
port := cfg.Server.Port
|
||||
if port <= 0 {
|
||||
port = 8080
|
||||
}
|
||||
scheme := "http"
|
||||
if config.MainWebUIUsesHTTPS(&cfg.Server) {
|
||||
scheme = "https"
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf("→ Web 界面: %s://127.0.0.1:%d/\n", scheme, port)
|
||||
if scheme == "https" && cfg.Server.TLSAutoSelfSign {
|
||||
fmt.Println(" (内存自签证书:浏览器首次需确认「继续访问」)")
|
||||
}
|
||||
if scheme == "https" && config.ServerHTTPRedirectEnabled(&cfg.Server) {
|
||||
fmt.Printf(" (http://127.0.0.1:%d/ 将自动跳转到 HTTPS)\n", port)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// MCP 启用且 auth_header_value 为空时,自动生成随机密钥并写回配置
|
||||
if err := config.EnsureMCPAuth(*configPath, cfg); err != nil {
|
||||
if err := config.EnsureMCPAuth(cp, cfg); err != nil {
|
||||
fmt.Printf("MCP 鉴权配置失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
@@ -44,7 +84,7 @@ func main() {
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// 创建应用
|
||||
application, err := app.New(cfg, log)
|
||||
application, err := app.New(cfg, log, cp)
|
||||
if err != nil {
|
||||
log.Fatal("应用初始化失败", "error", err)
|
||||
}
|
||||
|
||||
+13
-2
@@ -10,11 +10,22 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.6.11"
|
||||
version: "v1.6.15"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
port: 8080 # HTTP 服务端口,可通过浏览器访问 http://localhost:8080
|
||||
port: 8080 # 服务端口;未启用 TLS 时为 http://localhost:8080
|
||||
# --- 可选:HTTPS + HTTP/2(缓解浏览器对同源 HTTP/1.1 的并发连接数限制,多路 Deep 流式更稳)---
|
||||
# 启用 TLS 的条件(满足其一即可):tls_enabled: true,或 tls_auto_self_sign: true,或同时配置了 tls_cert_path + tls_key_path。
|
||||
# 启用后请用 https://127.0.0.1:<本端口>/ 访问;若仍用 http:// 访问同端口,将自动 308 跳转到 HTTPS(可用 tls_http_redirect: false 关闭)。
|
||||
tls_enabled: true
|
||||
# 启用 HTTPS 时,明文 HTTP 是否自动跳转到 HTTPS(默认 true;同端口嗅探 TLS/HTTP 后分流)
|
||||
# tls_http_redirect: true
|
||||
# 方式 A(推荐生产):PEM 证书与私钥路径
|
||||
# tls_cert_path: /path/to/fullchain.pem
|
||||
# tls_key_path: /path/to/privkey.pem
|
||||
# 方式 B(仅本地/测试):无证书文件时内存自签(浏览器会提示不受信任;SAN 含 localhost / 127.0.0.1)
|
||||
tls_auto_self_sign: true
|
||||
# 认证配置
|
||||
auth:
|
||||
password: # Web 登录密码,请修改为强密码
|
||||
|
||||
@@ -88,7 +88,7 @@ require (
|
||||
golang.org/x/arch v0.15.0 // indirect
|
||||
golang.org/x/crypto v0.39.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/net v0.34.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect
|
||||
|
||||
@@ -247,6 +247,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
||||
+72
-9
@@ -3,8 +3,10 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -30,6 +32,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
// App 应用
|
||||
@@ -60,7 +63,7 @@ type App struct {
|
||||
}
|
||||
|
||||
// New 创建新应用
|
||||
func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error) {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
router := gin.Default()
|
||||
|
||||
@@ -292,10 +295,10 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
}()
|
||||
}
|
||||
|
||||
// 获取配置文件路径
|
||||
configPath := "config.yaml"
|
||||
if len(os.Args) > 1 {
|
||||
configPath = os.Args[1]
|
||||
// 配置文件路径必须由入口传入(与 flag -config 一致)。勿再用 os.Args[1],否则 ./cyberstrike-ai --https 会把 --https 当成路径。
|
||||
configPath = strings.TrimSpace(configPath)
|
||||
if configPath == "" {
|
||||
configPath = "config.yaml"
|
||||
}
|
||||
|
||||
skillsDir := skillpackage.SkillsRootFromConfig(cfg.SkillsDir, configPath)
|
||||
@@ -530,18 +533,49 @@ func (a *App) RunWithContext(ctx context.Context) error {
|
||||
}()
|
||||
}
|
||||
|
||||
// 启动主服务器
|
||||
// 启动主服务器(可选 HTTPS + HTTP/2,见 config server.tls_*)
|
||||
addr := fmt.Sprintf("%s:%d", a.config.Server.Host, a.config.Server.Port)
|
||||
a.logger.Info("启动HTTP服务器", zap.String("address", addr))
|
||||
tlsMode, tlsConf, certFile, keyFile, tlsErr := prepareMainServerTLS(&a.config.Server)
|
||||
if tlsErr != nil {
|
||||
return tlsErr
|
||||
}
|
||||
|
||||
srv := &http.Server{Addr: addr, Handler: a.router}
|
||||
var mainMux *mainServerMux
|
||||
httpRedirect := config.ServerHTTPRedirectEnabled(&a.config.Server)
|
||||
if tlsMode != mainTLSOff {
|
||||
srv.TLSConfig = tlsConf
|
||||
if err := http2.ConfigureServer(srv, &http2.Server{}); err != nil {
|
||||
return fmt.Errorf("主服务 HTTP/2 配置失败: %w", err)
|
||||
}
|
||||
switch tlsMode {
|
||||
case mainTLSFromFiles:
|
||||
a.logger.Info("启动 HTTPS 主服务(已启用 HTTP/2 协商)",
|
||||
zap.String("address", addr),
|
||||
zap.String("cert", certFile),
|
||||
)
|
||||
case mainTLSInMemorySelfSigned:
|
||||
a.logger.Info("启动 HTTPS 主服务(内存自签证书,仅测试;已启用 HTTP/2 协商)",
|
||||
zap.String("address", addr),
|
||||
)
|
||||
}
|
||||
if httpRedirect {
|
||||
a.logger.Info("已启用 HTTP→HTTPS 自动跳转(同端口嗅探分流)", zap.String("address", addr))
|
||||
}
|
||||
} else {
|
||||
a.logger.Info("启动 HTTP 主服务", zap.String("address", addr))
|
||||
}
|
||||
|
||||
// 监听 context 取消,优雅关闭 HTTP 服务器
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
if mainMux != nil {
|
||||
if err := mainMux.Shutdown(shutdownCtx); err != nil {
|
||||
a.logger.Error("HTTP/HTTPS 分流服务器关闭失败", zap.Error(err))
|
||||
}
|
||||
} else if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
a.logger.Error("HTTP服务器关闭失败", zap.Error(err))
|
||||
}
|
||||
if mcpServer != nil {
|
||||
@@ -551,7 +585,36 @@ func (a *App) RunWithContext(ctx context.Context) error {
|
||||
}
|
||||
}()
|
||||
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
var err error
|
||||
switch {
|
||||
case tlsMode != mainTLSOff && httpRedirect:
|
||||
var tlsConfReady *tls.Config
|
||||
tlsConfReady, err = ensureMainTLSConfigCerts(tlsMode, tlsConf, certFile, keyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载 TLS 证书: %w", err)
|
||||
}
|
||||
srv.TLSConfig = tlsConfReady
|
||||
var ln net.Listener
|
||||
ln, err = net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mainMux = newMainServerMux(ln, srv, portFromListenAddr(addr), a.logger.Logger)
|
||||
err = mainMux.Serve()
|
||||
case tlsMode == mainTLSOff:
|
||||
err = srv.ListenAndServe()
|
||||
case tlsMode == mainTLSFromFiles:
|
||||
err = srv.ListenAndServeTLS(certFile, keyFile)
|
||||
case tlsMode == mainTLSInMemorySelfSigned:
|
||||
var ln net.Listener
|
||||
ln, err = tls.Listen("tcp", addr, srv.TLSConfig)
|
||||
if err == nil {
|
||||
err = srv.Serve(ln)
|
||||
}
|
||||
default:
|
||||
err = srv.ListenAndServe()
|
||||
}
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// peekedConn 在已预读首字节后仍将连接交给 net/http 或 crypto/tls。
|
||||
type peekedConn struct {
|
||||
net.Conn
|
||||
r *bufio.Reader
|
||||
}
|
||||
|
||||
func (c *peekedConn) Read(p []byte) (int, error) {
|
||||
return c.r.Read(p)
|
||||
}
|
||||
|
||||
// oneConnListener 供 http.Server.Serve 处理单条 TCP 连接(含 keep-alive)。
|
||||
type oneConnListener struct {
|
||||
conn net.Conn
|
||||
addr net.Addr
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (l *oneConnListener) Accept() (net.Conn, error) {
|
||||
var c net.Conn
|
||||
l.once.Do(func() {
|
||||
c = l.conn
|
||||
l.conn = nil
|
||||
})
|
||||
if c == nil {
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (l *oneConnListener) Close() error { return nil }
|
||||
func (l *oneConnListener) Addr() net.Addr { return l.addr }
|
||||
|
||||
func isTLSHandshakeRecord(b byte) bool {
|
||||
return b == 0x16
|
||||
}
|
||||
|
||||
func newHTTPToHTTPSRedirectHandler(httpsPort int) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
var target string
|
||||
if httpsPort == 443 {
|
||||
target = fmt.Sprintf("https://%s%s", host, r.URL.RequestURI())
|
||||
} else {
|
||||
target = fmt.Sprintf("https://%s:%d%s", host, httpsPort, r.URL.RequestURI())
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusPermanentRedirect)
|
||||
})
|
||||
}
|
||||
|
||||
func portFromListenAddr(addr string) int {
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return 443
|
||||
}
|
||||
p, err := strconv.Atoi(portStr)
|
||||
if err != nil || p <= 0 {
|
||||
return 443
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func ensureMainTLSConfigCerts(mode mainTLSMode, tlsConf *tls.Config, certFile, keyFile string) (*tls.Config, error) {
|
||||
if mode != mainTLSFromFiles {
|
||||
return tlsConf, nil
|
||||
}
|
||||
if tlsConf == nil {
|
||||
tlsConf = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
if len(tlsConf.Certificates) > 0 {
|
||||
return tlsConf, nil
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsConf.Certificates = []tls.Certificate{cert}
|
||||
return tlsConf, nil
|
||||
}
|
||||
|
||||
type mainServerMux struct {
|
||||
ln net.Listener
|
||||
httpsSrv *http.Server
|
||||
redirectSrv *http.Server
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func newMainServerMux(ln net.Listener, httpsSrv *http.Server, httpsPort int, logger *zap.Logger) *mainServerMux {
|
||||
return &mainServerMux{
|
||||
ln: ln,
|
||||
httpsSrv: httpsSrv,
|
||||
redirectSrv: &http.Server{Handler: newHTTPToHTTPSRedirectHandler(httpsPort), ReadHeaderTimeout: 10 * time.Second},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mainServerMux) Serve() error {
|
||||
for {
|
||||
conn, err := m.ln.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return http.ErrServerClosed
|
||||
}
|
||||
return err
|
||||
}
|
||||
go m.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mainServerMux) handleConn(raw net.Conn) {
|
||||
if err := raw.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
||||
_ = raw.Close()
|
||||
return
|
||||
}
|
||||
br := bufio.NewReader(raw)
|
||||
b, err := br.Peek(1)
|
||||
if err != nil {
|
||||
_ = raw.Close()
|
||||
return
|
||||
}
|
||||
_ = raw.SetReadDeadline(time.Time{})
|
||||
|
||||
pc := &peekedConn{Conn: raw, r: br}
|
||||
ocl := &oneConnListener{conn: pc, addr: raw.LocalAddr()}
|
||||
|
||||
if isTLSHandshakeRecord(b[0]) {
|
||||
m.serveHTTPS(pc, raw.LocalAddr())
|
||||
return
|
||||
}
|
||||
if err := m.redirectSrv.Serve(ocl); err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) {
|
||||
m.logger.Debug("HTTP 重定向连接处理结束", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// serveHTTPS 在已嗅探为 TLS 的连接上完成握手,再按 ALPN 走 HTTP/2 或 HTTP/1.1。
|
||||
// 不能对同一 http.Server 并发调用 Serve(TLSConfig!=nil),否则握手/ALPN 会异常(浏览器 ERR_SSL_PROTOCOL_ERROR)。
|
||||
func (m *mainServerMux) serveHTTPS(pc *peekedConn, localAddr net.Addr) {
|
||||
tlsConn := tls.Server(pc, m.httpsSrv.TLSConfig)
|
||||
handCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := tlsConn.HandshakeContext(handCtx); err != nil {
|
||||
m.logger.Debug("TLS 握手失败", zap.Error(err))
|
||||
_ = pc.Close()
|
||||
return
|
||||
}
|
||||
|
||||
srv := m.httpsSrv
|
||||
if srv.TLSNextProto != nil {
|
||||
proto := tlsConn.ConnectionState().NegotiatedProtocol
|
||||
if fn := srv.TLSNextProto[proto]; fn != nil {
|
||||
fn(srv, tlsConn, srv.Handler)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
plain := *srv
|
||||
plain.TLSConfig = nil
|
||||
ocl := &oneConnListener{conn: tlsConn, addr: localAddr}
|
||||
if err := plain.Serve(ocl); err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) {
|
||||
m.logger.Debug("HTTPS 连接处理结束", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mainServerMux) Shutdown(ctx context.Context) error {
|
||||
_ = m.ln.Close()
|
||||
var err1, err2 error
|
||||
if m.httpsSrv != nil {
|
||||
err1 = m.httpsSrv.Shutdown(ctx)
|
||||
}
|
||||
if m.redirectSrv != nil {
|
||||
err2 = m.redirectSrv.Shutdown(ctx)
|
||||
}
|
||||
if err1 != nil {
|
||||
return err1
|
||||
}
|
||||
return err2
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
func TestNewHTTPToHTTPSRedirectHandler(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
httpsPort int
|
||||
host string
|
||||
uri string
|
||||
wantTarget string
|
||||
}{
|
||||
{
|
||||
name: "non standard port",
|
||||
httpsPort: 8080,
|
||||
host: "127.0.0.1:8080",
|
||||
uri: "/login?next=/",
|
||||
wantTarget: "https://127.0.0.1:8080/login?next=/",
|
||||
},
|
||||
{
|
||||
name: "standard port",
|
||||
httpsPort: 443,
|
||||
host: "example.com:80",
|
||||
uri: "/",
|
||||
wantTarget: "https://example.com/",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHTTPToHTTPSRedirectHandler(tt.httpsPort)
|
||||
req := httptest.NewRequest(http.MethodGet, "http://"+tt.host+tt.uri, nil)
|
||||
req.Host = tt.host
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusPermanentRedirect {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusPermanentRedirect)
|
||||
}
|
||||
if got := rec.Header().Get("Location"); got != tt.wantTarget {
|
||||
t.Fatalf("Location = %q, want %q", got, tt.wantTarget)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTLSHandshakeRecord(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !isTLSHandshakeRecord(0x16) {
|
||||
t.Fatal("expected TLS handshake record")
|
||||
}
|
||||
if isTLSHandshakeRecord('G') {
|
||||
t.Fatal("GET should not be TLS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerHTTPRedirectEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
disabled := false
|
||||
enabled := true
|
||||
if config.ServerHTTPRedirectEnabled(nil) {
|
||||
t.Fatal("nil config should disable redirect")
|
||||
}
|
||||
if !config.ServerHTTPRedirectEnabled(&config.ServerConfig{TLSEnabled: true}) {
|
||||
t.Fatal("HTTPS without explicit flag should enable redirect")
|
||||
}
|
||||
if config.ServerHTTPRedirectEnabled(&config.ServerConfig{TLSEnabled: true, TLSHTTPRedirect: &disabled}) {
|
||||
t.Fatal("explicit false should disable redirect")
|
||||
}
|
||||
if !config.ServerHTTPRedirectEnabled(&config.ServerConfig{TLSEnabled: true, TLSHTTPRedirect: &enabled}) {
|
||||
t.Fatal("explicit true should enable redirect")
|
||||
}
|
||||
if config.ServerHTTPRedirectEnabled(&config.ServerConfig{}) {
|
||||
t.Fatal("plain HTTP should not redirect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainServerMuxHTTPRedirectAndHTTPS(t *testing.T) {
|
||||
cert, err := generateMainServerSelfSignedCert()
|
||||
if err != nil {
|
||||
t.Fatalf("generate cert: %v", err)
|
||||
}
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
})
|
||||
srv := &http.Server{Handler: handler, TLSConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}}
|
||||
if err := http2.ConfigureServer(srv, &http2.Server{}); err != nil {
|
||||
t.Fatalf("configure http2: %v", err)
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
mux := newMainServerMux(ln, srv, portFromListenAddr(ln.Addr().String()), nil)
|
||||
go func() { _ = mux.Serve() }()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12},
|
||||
},
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
addr := ln.Addr().String()
|
||||
|
||||
httpResp, err := client.Get("http://" + addr + "/")
|
||||
if err != nil {
|
||||
t.Fatalf("http get: %v", err)
|
||||
}
|
||||
_ = httpResp.Body.Close()
|
||||
if httpResp.StatusCode != http.StatusPermanentRedirect {
|
||||
t.Fatalf("http status = %d, want %d", httpResp.StatusCode, http.StatusPermanentRedirect)
|
||||
}
|
||||
if got := httpResp.Header.Get("Location"); got != "https://127.0.0.1:"+strconv.Itoa(portFromListenAddr(addr))+"/" {
|
||||
t.Fatalf("Location = %q", got)
|
||||
}
|
||||
|
||||
httpsResp, err := client.Get("https://" + addr + "/")
|
||||
if err != nil {
|
||||
t.Fatalf("https get: %v", err)
|
||||
}
|
||||
defer httpsResp.Body.Close()
|
||||
if httpsResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("https status = %d, want %d", httpsResp.StatusCode, http.StatusOK)
|
||||
}
|
||||
body, _ := io.ReadAll(httpsResp.Body)
|
||||
if string(body) != "ok" {
|
||||
t.Fatalf("body = %q, want ok", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
)
|
||||
|
||||
// mainTLSMode 主 Web 服务 TLS 启动方式。
|
||||
type mainTLSMode int
|
||||
|
||||
const (
|
||||
mainTLSOff mainTLSMode = iota
|
||||
mainTLSFromFiles
|
||||
mainTLSInMemorySelfSigned
|
||||
)
|
||||
|
||||
// prepareMainServerTLS 根据 server 配置决定主站是否启用 HTTPS(及 HTTP/2 协商)。
|
||||
// fromFiles:使用 tls_cert_path + tls_key_path,由 http.Server.ListenAndServeTLS 加载 PEM。
|
||||
// inMemory:tls_auto_self_sign 生成的自签证书,仅用于本地/测试。
|
||||
func prepareMainServerTLS(cfg *config.ServerConfig) (mode mainTLSMode, tlsConf *tls.Config, certFile, keyFile string, err error) {
|
||||
if cfg == nil || !config.MainWebUIUsesHTTPS(cfg) {
|
||||
return mainTLSOff, nil, "", "", nil
|
||||
}
|
||||
certFile = strings.TrimSpace(cfg.TLSCertPath)
|
||||
keyFile = strings.TrimSpace(cfg.TLSKeyPath)
|
||||
if certFile != "" && keyFile != "" {
|
||||
// 证书由 ListenAndServeTLS 从文件加载;此处仅提供最小 TLS 配置供 http2.ConfigureServer 合并 ALPN。
|
||||
return mainTLSFromFiles, &tls.Config{MinVersion: tls.VersionTLS12}, certFile, keyFile, nil
|
||||
}
|
||||
if cfg.TLSAutoSelfSign {
|
||||
cert, genErr := generateMainServerSelfSignedCert()
|
||||
if genErr != nil {
|
||||
return mainTLSOff, nil, "", "", fmt.Errorf("生成自签 TLS 证书: %w", genErr)
|
||||
}
|
||||
tlsConf = &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}
|
||||
return mainTLSInMemorySelfSigned, tlsConf, "", "", nil
|
||||
}
|
||||
return mainTLSOff, nil, "", "", fmt.Errorf("server: 已启用 TLS(tls_enabled / tls_auto_self_sign / 证书路径),请设置 tls_cert_path 与 tls_key_path,或将 tls_auto_self_sign 设为 true(仅测试环境)")
|
||||
}
|
||||
|
||||
func generateMainServerSelfSignedCert() (tls.Certificate, error) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, big.NewInt(1<<62))
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "CyberStrikeAI"},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
return tls.X509KeyPair(certPEM, keyPEM)
|
||||
}
|
||||
@@ -391,7 +391,8 @@ type MultiAgentAPIUpdate struct {
|
||||
RobotUseMultiAgent bool `json:"robot_use_multi_agent"`
|
||||
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
|
||||
PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"`
|
||||
ToolSearchAlwaysVisibleTools []string `json:"tool_search_always_visible_tools,omitempty"`
|
||||
// 指针区分「JSON 未传该字段」与「传空数组要清空」;省略时不应覆盖 YAML 中的常驻工具白名单。
|
||||
ToolSearchAlwaysVisibleTools *[]string `json:"tool_search_always_visible_tools,omitempty"`
|
||||
}
|
||||
|
||||
// RobotsConfig 机器人配置(企业微信、钉钉、飞书等)
|
||||
@@ -443,8 +444,17 @@ type RobotLarkConfig struct {
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Host string `yaml:"host" json:"host"`
|
||||
Port int `yaml:"port" json:"port"`
|
||||
// TLSEnabled 为 true 时主 Web UI 使用 HTTPS;现代浏览器在同源下会协商 HTTP/2,缓解 HTTP/1.1 每源并发连接数限制。
|
||||
TLSEnabled bool `yaml:"tls_enabled,omitempty" json:"tls_enabled,omitempty"`
|
||||
// TLSCertPath / TLSKeyPath 非空时从 PEM 文件加载证书(生产环境推荐)。
|
||||
TLSCertPath string `yaml:"tls_cert_path,omitempty" json:"tls_cert_path,omitempty"`
|
||||
TLSKeyPath string `yaml:"tls_key_path,omitempty" json:"tls_key_path,omitempty"`
|
||||
// TLSAutoSelfSign 为 true 且未配置有效证书路径时,启动时生成内存自签证书(仅本地/测试;浏览器会提示不受信任)。
|
||||
TLSAutoSelfSign bool `yaml:"tls_auto_self_sign,omitempty" json:"tls_auto_self_sign,omitempty"`
|
||||
// TLSHTTPRedirect 为 false 时禁用 HTTP→HTTPS 跳转;省略或为 true 且已启用 HTTPS 时,明文 HTTP 访问将 308 跳转到 HTTPS(同端口嗅探分流)。
|
||||
TLSHTTPRedirect *bool `yaml:"tls_http_redirect,omitempty" json:"tls_http_redirect,omitempty"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
// MainWebUIUsesHTTPS 判断主 Web UI 是否以 HTTPS 监听(与 internal/app.prepareMainServerTLS 前置条件一致)。
|
||||
func MainWebUIUsesHTTPS(s *ServerConfig) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
if s.TLSEnabled {
|
||||
return true
|
||||
}
|
||||
if s.TLSAutoSelfSign {
|
||||
return true
|
||||
}
|
||||
cert := strings.TrimSpace(s.TLSCertPath)
|
||||
key := strings.TrimSpace(s.TLSKeyPath)
|
||||
return cert != "" && key != ""
|
||||
}
|
||||
|
||||
// ServerHTTPRedirectEnabled 是否在主站启用 HTTPS 时把明文 HTTP 请求重定向到 HTTPS(默认开启)。
|
||||
func ServerHTTPRedirectEnabled(s *ServerConfig) bool {
|
||||
if s == nil || !MainWebUIUsesHTTPS(s) {
|
||||
return false
|
||||
}
|
||||
if s.TLSHTTPRedirect == nil {
|
||||
return true
|
||||
}
|
||||
return *s.TLSHTTPRedirect
|
||||
}
|
||||
|
||||
// ApplyDevHTTPSBootstrap 供 --https / 一键脚本使用:强制开启主站 TLS。
|
||||
// 若已配置 tls_cert_path 与 tls_key_path 则仅用 PEM,不开启自签;否则启用 tls_auto_self_sign(内存证书,仅本地测试)。
|
||||
func ApplyDevHTTPSBootstrap(cfg *Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
cfg.Server.TLSEnabled = true
|
||||
cert := strings.TrimSpace(cfg.Server.TLSCertPath)
|
||||
key := strings.TrimSpace(cfg.Server.TLSKeyPath)
|
||||
if cert != "" && key != "" {
|
||||
cfg.Server.TLSAutoSelfSign = false
|
||||
return
|
||||
}
|
||||
cfg.Server.TLSAutoSelfSign = true
|
||||
}
|
||||
@@ -26,7 +26,7 @@ type Conversation struct {
|
||||
// Message 消息
|
||||
type Message struct {
|
||||
ID string `json:"id"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoningContent,omitempty"`
|
||||
@@ -117,6 +117,7 @@ func (db *DB) GetConversationByWebshellConnectionID(connectionID string) (*Conve
|
||||
}
|
||||
for i := range conv.Messages {
|
||||
if details, ok := processDetailsMap[conv.Messages[i].ID]; ok {
|
||||
details = DedupeConsecutiveProcessDetails(details)
|
||||
detailsJSON := make([]map[string]interface{}, len(details))
|
||||
for j, detail := range details {
|
||||
var data interface{}
|
||||
@@ -235,6 +236,7 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
|
||||
// 将过程详情附加到对应的消息上
|
||||
for i := range conv.Messages {
|
||||
if details, ok := processDetailsMap[conv.Messages[i].ID]; ok {
|
||||
details = DedupeConsecutiveProcessDetails(details)
|
||||
// 将ProcessDetail转换为JSON格式,以便前端使用
|
||||
detailsJSON := make([]map[string]interface{}, len(details))
|
||||
for j, detail := range details {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DedupeConsecutiveProcessDetails 去掉相邻且语义相同的过程详情(使用 DB 中 data 列原始 JSON 作指纹,避免 map 序列化键序不稳定)。
|
||||
func DedupeConsecutiveProcessDetails(rows []ProcessDetail) []ProcessDetail {
|
||||
if len(rows) < 2 {
|
||||
return rows
|
||||
}
|
||||
out := make([]ProcessDetail, 0, len(rows))
|
||||
var lastKey string
|
||||
for _, d := range rows {
|
||||
key := processDetailRowKey(d)
|
||||
if len(out) > 0 && key != "" && key == lastKey {
|
||||
continue
|
||||
}
|
||||
out = append(out, d)
|
||||
lastKey = key
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func processDetailRowKey(d ProcessDetail) string {
|
||||
return fmt.Sprintf("%s\x00%s\x00%s", d.EventType, strings.TrimSpace(d.Message), d.Data)
|
||||
}
|
||||
@@ -755,7 +755,9 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
if req.MultiAgent.PlanExecuteLoopMaxIterations != nil {
|
||||
h.config.MultiAgent.PlanExecuteLoopMaxIterations = *req.MultiAgent.PlanExecuteLoopMaxIterations
|
||||
}
|
||||
h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools = dedupeToolNameList(req.MultiAgent.ToolSearchAlwaysVisibleTools)
|
||||
if req.MultiAgent.ToolSearchAlwaysVisibleTools != nil {
|
||||
h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools = dedupeToolNameList(*req.MultiAgent.ToolSearchAlwaysVisibleTools)
|
||||
}
|
||||
h.logger.Info("更新多代理配置",
|
||||
zap.Bool("enabled", h.config.MultiAgent.Enabled),
|
||||
zap.Bool("robot_use_multi_agent", h.config.MultiAgent.RobotUseMultiAgent),
|
||||
@@ -1474,6 +1476,11 @@ func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
|
||||
root := doc.Content[0]
|
||||
robotsNode := ensureMap(root, "robots")
|
||||
|
||||
if cfg.Session.StrictUserIdentity != nil {
|
||||
sessionNode := ensureMap(robotsNode, "session")
|
||||
setBoolInMap(sessionNode, "strict_user_identity", *cfg.Session.StrictUserIdentity)
|
||||
}
|
||||
|
||||
wecomNode := ensureMap(robotsNode, "wecom")
|
||||
setBoolInMap(wecomNode, "enabled", cfg.Wecom.Enabled)
|
||||
setStringInMap(wecomNode, "token", cfg.Wecom.Token)
|
||||
@@ -1486,12 +1493,14 @@ func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
|
||||
setBoolInMap(dingtalkNode, "enabled", cfg.Dingtalk.Enabled)
|
||||
setStringInMap(dingtalkNode, "client_id", cfg.Dingtalk.ClientID)
|
||||
setStringInMap(dingtalkNode, "client_secret", cfg.Dingtalk.ClientSecret)
|
||||
setBoolInMap(dingtalkNode, "allow_conversation_id_fallback", cfg.Dingtalk.AllowConversationIDFallback)
|
||||
|
||||
larkNode := ensureMap(robotsNode, "lark")
|
||||
setBoolInMap(larkNode, "enabled", cfg.Lark.Enabled)
|
||||
setStringInMap(larkNode, "app_id", cfg.Lark.AppID)
|
||||
setStringInMap(larkNode, "app_secret", cfg.Lark.AppSecret)
|
||||
setStringInMap(larkNode, "verify_token", cfg.Lark.VerifyToken)
|
||||
setBoolInMap(larkNode, "allow_chat_id_fallback", cfg.Lark.AllowChatIDFallback)
|
||||
}
|
||||
|
||||
func updateMultiAgentConfig(doc *yaml.Node, cfg config.MultiAgentConfig) {
|
||||
|
||||
@@ -117,6 +117,8 @@ func (h *ConversationHandler) GetMessageProcessDetails(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
details = database.DedupeConsecutiveProcessDetails(details)
|
||||
|
||||
// 转换为前端期望的 JSON 结构(与 GetConversation 中 processDetails 结构一致)
|
||||
out := make([]map[string]interface{}, 0, len(details))
|
||||
for _, d := range details {
|
||||
|
||||
@@ -573,6 +573,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
var subAssistantBuf string
|
||||
var subReplyStreamID string
|
||||
var mainAssistantBuf string
|
||||
// 已通过 response_delta 推到前端的正文(与 monitor.js normalizeStreamingDeltaJs 累积一致)
|
||||
var mainAssistWireAccum string
|
||||
var mainAssistDupTarget string // 非空表示本段主助手流需缓冲至 EOF,与 execute 输出比对去重
|
||||
var reasoningBuf string
|
||||
var prevReasoningDisplay string // UI 用:剥离 Claude 内部 signature 尾缀后的累计展示
|
||||
@@ -681,6 +683,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
"einoAgent": ev.AgentName,
|
||||
"orchestration": orchMode,
|
||||
})
|
||||
mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, contentDelta)
|
||||
}
|
||||
}
|
||||
} else if !streamsMainAssistant(ev.AgentName) {
|
||||
@@ -726,21 +729,29 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
}
|
||||
} else if s != "" {
|
||||
if progress != nil {
|
||||
progress("response_start", "", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": snapshotMCPIDs(),
|
||||
"messageGeneratedBy": "eino:" + ev.AgentName,
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": ev.AgentName,
|
||||
"orchestration": orchMode,
|
||||
})
|
||||
progress("response_delta", s, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": snapshotMCPIDs(),
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": ev.AgentName,
|
||||
"orchestration": orchMode,
|
||||
})
|
||||
// 仅用 TrimSpace 与 execute 比对;推到 UI 的必须是 mainAssistantBuf,
|
||||
// 否则尾部空白/换行与已流式前缀不一致时,前端 normalize 会走拼接路径造成叠字。
|
||||
_, eofTail := normalizeStreamingDelta(mainAssistWireAccum, mainAssistantBuf)
|
||||
if eofTail != "" {
|
||||
if !streamHeaderSent {
|
||||
progress("response_start", "", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": snapshotMCPIDs(),
|
||||
"messageGeneratedBy": "eino:" + ev.AgentName,
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": ev.AgentName,
|
||||
"orchestration": orchMode,
|
||||
})
|
||||
}
|
||||
progress("response_delta", eofTail, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": snapshotMCPIDs(),
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": ev.AgentName,
|
||||
"orchestration": orchMode,
|
||||
})
|
||||
mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, eofTail)
|
||||
}
|
||||
}
|
||||
lastAssistant = s
|
||||
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage(s, nil))
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Eino execute 去重分支 EOF flush 须以 mainAssistantBuf 为基准计算 tail,
|
||||
// 若误用 TrimSpace(mainAssistantBuf),会与已推前缀在空白处失配,normalize 走拼接路径叠字。
|
||||
func TestNormalizeStreamingDelta_eofTailUsesRawBufNotTrim(t *testing.T) {
|
||||
wireAccum := "phrase "
|
||||
rawFull := "phrase \n"
|
||||
_, tail := normalizeStreamingDelta(wireAccum, rawFull)
|
||||
if want := "\n"; tail != want {
|
||||
t.Fatalf("tail=%q want %q", tail, want)
|
||||
}
|
||||
|
||||
nextWrong, badTail := normalizeStreamingDelta(wireAccum, strings.TrimSpace(rawFull))
|
||||
if badTail != "phrase" || nextWrong != "phrase phrase" {
|
||||
t.Fatalf("trimmed full vs wire prefix mismatch should concat-append; got next=%q badTail=%q", nextWrong, badTail)
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ show_progress() {
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " CyberStrikeAI 一键部署启动脚本"
|
||||
echo " (默认 HTTPS 自签证书;纯 HTTP 请用: $0 --http)"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
@@ -353,7 +354,18 @@ need_rebuild() {
|
||||
}
|
||||
|
||||
# 主流程
|
||||
# 默认启动主站 HTTPS(--https 传给二进制);传 --http 则走明文 HTTP。
|
||||
main() {
|
||||
USE_HTTPS=1
|
||||
FORWARD_ARGS=()
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--http" ]; then
|
||||
USE_HTTPS=0
|
||||
continue
|
||||
fi
|
||||
FORWARD_ARGS+=("$arg")
|
||||
done
|
||||
|
||||
# 环境检查
|
||||
info "检查运行环境..."
|
||||
check_python
|
||||
@@ -377,13 +389,30 @@ main() {
|
||||
# 启动服务器
|
||||
success "所有准备工作完成!"
|
||||
echo ""
|
||||
info "启动 CyberStrikeAI 服务器..."
|
||||
if [ "$USE_HTTPS" -eq 1 ]; then
|
||||
info "启动 CyberStrikeAI 服务器(HTTPS + HTTP/2,自签证书)..."
|
||||
note "纯 HTTP 启动请使用: $0 --http"
|
||||
else
|
||||
info "启动 CyberStrikeAI 服务器(HTTP)..."
|
||||
fi
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# 运行服务器
|
||||
exec "./$BINARY_NAME"
|
||||
|
||||
# 始终传入项目根目录下的 config.yaml,避免 cwd 不在项目根时找不到配置;额外参数仍可追加(如再次 -config 覆盖,以 Go flag 后写为准)。
|
||||
if [ "$USE_HTTPS" -eq 1 ]; then
|
||||
if [ "${#FORWARD_ARGS[@]}" -gt 0 ]; then
|
||||
exec "./$BINARY_NAME" -config "$CONFIG_FILE" --https "${FORWARD_ARGS[@]}"
|
||||
else
|
||||
exec "./$BINARY_NAME" -config "$CONFIG_FILE" --https
|
||||
fi
|
||||
else
|
||||
if [ "${#FORWARD_ARGS[@]}" -gt 0 ]; then
|
||||
exec "./$BINARY_NAME" -config "$CONFIG_FILE" "${FORWARD_ARGS[@]}"
|
||||
else
|
||||
exec "./$BINARY_NAME" -config "$CONFIG_FILE"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 执行主流程
|
||||
main
|
||||
# 执行主流程(支持参数,如: ./run.sh --http)
|
||||
main "$@"
|
||||
|
||||
@@ -440,6 +440,230 @@ args:
|
||||
print("Body: <empty>")
|
||||
|
||||
|
||||
def compile_response_filter(pattern: str, ignore_case: bool):
|
||||
flags = 0
|
||||
if ignore_case:
|
||||
flags |= re.IGNORECASE
|
||||
try:
|
||||
return re.compile(pattern, flags)
|
||||
except re.error as exc:
|
||||
print(f"Invalid response_filter regex: {exc}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def truncate_utf8(text: str, max_bytes: int) -> Tuple[str, bool]:
|
||||
if max_bytes <= 0 or not text:
|
||||
return text, False
|
||||
encoded = text.encode("utf-8", errors="replace")
|
||||
if len(encoded) <= max_bytes:
|
||||
return text, False
|
||||
truncated = encoded[:max_bytes].decode("utf-8", errors="ignore")
|
||||
return truncated, True
|
||||
|
||||
|
||||
def cap_line_entries(entries: List[Tuple[int, str]], max_lines: int) -> Tuple[List[Tuple[int, str]], bool]:
|
||||
if max_lines <= 0 or len(entries) <= max_lines:
|
||||
return entries, False
|
||||
return entries[:max_lines], True
|
||||
|
||||
|
||||
def expand_line_context(line_numbers: List[int], total_lines: int, context: int) -> List[int]:
|
||||
if context <= 0:
|
||||
return sorted(set(line_numbers))
|
||||
included = set()
|
||||
for num in line_numbers:
|
||||
start = max(1, num - context)
|
||||
end = min(total_lines, num + context)
|
||||
for i in range(start, end + 1):
|
||||
included.add(i)
|
||||
return sorted(included)
|
||||
|
||||
|
||||
def format_line_entries(lines: List[str], indices: List[int], ellipsis_gaps: bool = True) -> str:
|
||||
if not indices:
|
||||
return ""
|
||||
chunks = []
|
||||
prev = None
|
||||
for num in indices:
|
||||
if ellipsis_gaps and prev is not None and num > prev + 1:
|
||||
chunks.append(" ...")
|
||||
chunks.append(f" L{num}: {lines[num - 1]}")
|
||||
prev = num
|
||||
return "\n".join(chunks)
|
||||
|
||||
|
||||
def filter_body_by_lines(
|
||||
lines: List[str],
|
||||
compiled: "re.Pattern",
|
||||
invert: bool,
|
||||
context_lines: int,
|
||||
max_lines: int,
|
||||
) -> Tuple[str, Dict[str, object]]:
|
||||
matched_nums = []
|
||||
for idx, line in enumerate(lines, start=1):
|
||||
hit = compiled.search(line) is not None
|
||||
if invert:
|
||||
hit = not hit
|
||||
if hit:
|
||||
matched_nums.append(idx)
|
||||
total = len(lines)
|
||||
meta = {
|
||||
"mode": "line",
|
||||
"total_lines": total,
|
||||
"matched_lines": len(matched_nums),
|
||||
"invert": invert,
|
||||
"truncated": False,
|
||||
"byte_truncated": False,
|
||||
}
|
||||
if not matched_nums:
|
||||
return "", meta
|
||||
display_nums = expand_line_context(matched_nums, total, context_lines)
|
||||
entries = [(n, lines[n - 1]) for n in display_nums]
|
||||
entries, line_capped = cap_line_entries(entries, max_lines)
|
||||
meta["truncated"] = line_capped
|
||||
meta["display_lines"] = len(entries)
|
||||
return format_line_entries(lines, [n for n, _ in entries], ellipsis_gaps=context_lines > 0), meta
|
||||
|
||||
|
||||
def filter_body_multiline(
|
||||
text: str,
|
||||
compiled: "re.Pattern",
|
||||
invert: bool,
|
||||
max_lines: int,
|
||||
dotall: bool,
|
||||
) -> Tuple[str, Dict[str, object]]:
|
||||
flags = compiled.flags
|
||||
if dotall:
|
||||
pattern = re.compile(compiled.pattern, flags | re.DOTALL | re.MULTILINE)
|
||||
else:
|
||||
pattern = re.compile(compiled.pattern, flags | re.MULTILINE)
|
||||
matches = list(pattern.finditer(text))
|
||||
if invert:
|
||||
if matches:
|
||||
return "", {"mode": "multiline" if not dotall else "full", "total_lines": text.count("\n") + (1 if text else 0), "matched_lines": 0, "invert": True, "truncated": False, "byte_truncated": False}
|
||||
output = text
|
||||
meta = {"mode": "multiline" if not dotall else "full", "matched_lines": 1, "invert": True, "truncated": False, "byte_truncated": False}
|
||||
lines = text.splitlines()
|
||||
if max_lines > 0 and len(lines) > max_lines:
|
||||
output = "\n".join(lines[:max_lines])
|
||||
meta["truncated"] = True
|
||||
meta["total_lines"] = len(lines)
|
||||
meta["display_lines"] = min(len(lines), max_lines) if max_lines > 0 else len(lines)
|
||||
return output, meta
|
||||
chunks = []
|
||||
for match in matches:
|
||||
snippet = match.group(0)
|
||||
if "\n" in snippet:
|
||||
snippet = snippet.replace("\n", "\\n")
|
||||
start_line = text.count("\n", 0, match.start()) + 1
|
||||
chunks.append((start_line, f" @{start_line}: {snippet}"))
|
||||
entries, line_capped = cap_line_entries(chunks, max_lines if max_lines > 0 else len(chunks))
|
||||
meta = {
|
||||
"mode": "multiline" if not dotall else "full",
|
||||
"total_lines": text.count("\n") + (1 if text else 0),
|
||||
"matched_lines": len(matches),
|
||||
"invert": False,
|
||||
"truncated": line_capped,
|
||||
"byte_truncated": False,
|
||||
"display_lines": len(entries),
|
||||
}
|
||||
return "\n".join(line for _, line in entries), meta
|
||||
|
||||
|
||||
def apply_body_limits_plain(text: str, max_lines: int) -> Tuple[str, Dict[str, object]]:
|
||||
lines = text.splitlines()
|
||||
meta = {
|
||||
"mode": "plain",
|
||||
"total_lines": len(lines),
|
||||
"matched_lines": len(lines),
|
||||
"invert": False,
|
||||
"truncated": False,
|
||||
"byte_truncated": False,
|
||||
"display_lines": len(lines),
|
||||
}
|
||||
output = text
|
||||
if max_lines > 0 and len(lines) > max_lines:
|
||||
output = "\n".join(lines[:max_lines])
|
||||
meta["truncated"] = True
|
||||
meta["display_lines"] = max_lines
|
||||
return output, meta
|
||||
|
||||
|
||||
def format_response_body_output(
|
||||
decoded_body: str,
|
||||
filter_pattern: str,
|
||||
filter_mode: str,
|
||||
filter_invert: bool,
|
||||
filter_ignore_case: bool,
|
||||
max_lines: int,
|
||||
max_bytes: int,
|
||||
preview_lines: int,
|
||||
context_lines: int,
|
||||
compiled_filter=None,
|
||||
) -> Tuple[str, Dict[str, object]]:
|
||||
text = decoded_body.rstrip("\r\n")
|
||||
if not text:
|
||||
return "", {"mode": "empty", "total_lines": 0, "matched_lines": 0, "invert": filter_invert, "truncated": False, "byte_truncated": False, "display_lines": 0}
|
||||
|
||||
lines = text.splitlines()
|
||||
mode = (filter_mode or "line").strip().lower()
|
||||
if mode not in {"line", "multiline", "full"}:
|
||||
mode = "line"
|
||||
|
||||
if filter_pattern:
|
||||
compiled = compiled_filter or compile_response_filter(filter_pattern, filter_ignore_case)
|
||||
if mode == "line":
|
||||
output, meta = filter_body_by_lines(lines, compiled, filter_invert, context_lines, max_lines)
|
||||
else:
|
||||
output, meta = filter_body_multiline(text, compiled, filter_invert, max_lines, dotall=(mode == "full"))
|
||||
meta["filter_pattern"] = filter_pattern
|
||||
if not output and not filter_invert:
|
||||
preview = min(max(preview_lines, 0), len(lines))
|
||||
if preview > 0:
|
||||
preview_text = format_line_entries(lines, list(range(1, preview + 1)), ellipsis_gaps=False)
|
||||
preview_text, byte_truncated = truncate_utf8(preview_text, max_bytes)
|
||||
return preview_text, {
|
||||
**meta,
|
||||
"preview": True,
|
||||
"matched_lines": 0,
|
||||
"display_lines": preview,
|
||||
"byte_truncated": byte_truncated,
|
||||
}
|
||||
return "", {**meta, "preview": False, "matched_lines": 0, "display_lines": 0}
|
||||
else:
|
||||
output, meta = apply_body_limits_plain(text, max_lines)
|
||||
|
||||
output, byte_truncated = truncate_utf8(output, max_bytes)
|
||||
if byte_truncated:
|
||||
meta["byte_truncated"] = True
|
||||
return output, meta
|
||||
|
||||
|
||||
def print_response_body_summary(meta: Dict[str, object]):
|
||||
mode = meta.get("mode")
|
||||
if mode == "empty":
|
||||
return
|
||||
parts = [f"mode={mode}"]
|
||||
if meta.get("filter_pattern"):
|
||||
parts.append(f"pattern={meta['filter_pattern']!r}")
|
||||
if meta.get("invert"):
|
||||
parts.append("invert=true")
|
||||
total = meta.get("total_lines")
|
||||
matched = meta.get("matched_lines")
|
||||
displayed = meta.get("display_lines")
|
||||
if total is not None and matched is not None:
|
||||
parts.append(f"matched {matched}/{total} lines")
|
||||
if displayed is not None:
|
||||
parts.append(f"showing {displayed}")
|
||||
if meta.get("preview"):
|
||||
parts.append("preview on zero match")
|
||||
if meta.get("truncated"):
|
||||
parts.append("line cap applied")
|
||||
if meta.get("byte_truncated"):
|
||||
parts.append("byte cap applied")
|
||||
print(f"[body] {' | '.join(parts)}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Pure Python HTTP testing helper powered by httpx")
|
||||
parser.add_argument("--url", required=True)
|
||||
@@ -466,6 +690,16 @@ args:
|
||||
parser.add_argument("--debug", dest="debug", action="store_true")
|
||||
parser.add_argument("--response-encoding", dest="response_encoding", default="")
|
||||
parser.add_argument("--download", dest="download", default="")
|
||||
parser.add_argument("--response-filter", dest="response_filter", default="")
|
||||
parser.add_argument("--response-filter-mode", dest="response_filter_mode", default="line")
|
||||
parser.add_argument("--response-filter-invert", dest="response_filter_invert", action="store_true")
|
||||
parser.add_argument("--no-response-filter-invert", dest="response_filter_invert", action="store_false")
|
||||
parser.add_argument("--response-filter-ignore-case", dest="response_filter_ignore_case", action="store_true")
|
||||
parser.add_argument("--no-response-filter-ignore-case", dest="response_filter_ignore_case", action="store_false")
|
||||
parser.add_argument("--response-max-lines", dest="response_max_lines", type=int, default=0)
|
||||
parser.add_argument("--response-max-bytes", dest="response_max_bytes", type=int, default=0)
|
||||
parser.add_argument("--response-preview-lines", dest="response_preview_lines", type=int, default=5)
|
||||
parser.add_argument("--response-context-lines", dest="response_context_lines", type=int, default=0)
|
||||
parser.set_defaults(
|
||||
include_headers=False,
|
||||
auto_encode_url=False,
|
||||
@@ -475,9 +709,22 @@ args:
|
||||
show_command=False,
|
||||
show_summary=False,
|
||||
debug=False,
|
||||
response_filter_invert=False,
|
||||
response_filter_ignore_case=False,
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
response_filter = (args.response_filter or "").strip()
|
||||
response_max_lines = max(0, args.response_max_lines or 0)
|
||||
response_max_bytes = max(0, args.response_max_bytes or 0)
|
||||
response_preview_lines = max(0, args.response_preview_lines if args.response_preview_lines is not None else 5)
|
||||
response_context_lines = max(0, args.response_context_lines or 0)
|
||||
compiled_response_filter = None
|
||||
if response_filter:
|
||||
compiled_response_filter = compile_response_filter(
|
||||
response_filter, args.response_filter_ignore_case
|
||||
)
|
||||
|
||||
repeat = max(1, args.repeat)
|
||||
try:
|
||||
delay_between = float(args.delay or "0")
|
||||
@@ -648,9 +895,37 @@ args:
|
||||
for key, value in response.headers.items():
|
||||
print(f"{key}: {value}")
|
||||
print("")
|
||||
output_body = decoded_body.rstrip()
|
||||
output_body, body_output_meta = format_response_body_output(
|
||||
decoded_body,
|
||||
response_filter,
|
||||
args.response_filter_mode,
|
||||
args.response_filter_invert,
|
||||
args.response_filter_ignore_case,
|
||||
response_max_lines,
|
||||
response_max_bytes,
|
||||
response_preview_lines,
|
||||
response_context_lines,
|
||||
compiled_filter=compiled_response_filter,
|
||||
)
|
||||
has_filter_or_cap = bool(
|
||||
response_filter or response_max_lines > 0 or response_max_bytes > 0
|
||||
)
|
||||
if has_filter_or_cap and body_output_meta.get("mode") != "empty":
|
||||
print_response_body_summary(body_output_meta)
|
||||
if body_output_meta.get("preview") and not body_output_meta.get("matched_lines"):
|
||||
print("[body] no regex match; showing preview:")
|
||||
if output_body:
|
||||
print(output_body)
|
||||
if body_output_meta.get("truncated") or body_output_meta.get("byte_truncated"):
|
||||
omitted = (body_output_meta.get("total_lines") or 0) - (
|
||||
body_output_meta.get("display_lines") or 0
|
||||
)
|
||||
if omitted > 0:
|
||||
print(f"[body] ... {omitted} more line(s) omitted (use --download for full body)")
|
||||
elif body_output_meta.get("mode") == "empty":
|
||||
print("[no body]")
|
||||
elif response_filter and not body_output_meta.get("preview"):
|
||||
print("[body] no lines matched filter")
|
||||
else:
|
||||
print("[no body]")
|
||||
|
||||
@@ -729,6 +1004,13 @@ description: |
|
||||
- 连接探针:在无代理场景下额外进行 DNS/TCP/TLS 探测,粗粒度复刻 curl -w 指标
|
||||
- 可重复观测:repeat/delay + TTFB/total/speed_download 统计,便于盲注/时序测试
|
||||
- 扩展开关:additional_args 解析 http2/cert/verify/trust_env/max_redirects 等 httpx 选项
|
||||
- 响应体瘦身:response_filter 按行/块正则提取,配合 max_lines/max_bytes 限制 stdout,降低 Agent token 消耗
|
||||
|
||||
**响应过滤最佳实践:**
|
||||
- 大页面/HTML:用 `response_filter` 抓 error|exception|password|token|uid 等关键字行
|
||||
- 无 filter 时:设 `response_max_lines=80` 或 `response_max_bytes=8192` 防止整页灌入上下文
|
||||
- 0 命中:自动预览前 `response_preview_lines` 行,避免误判「空响应」
|
||||
- 完整留存:大 body 用 `download` 落盘,stdout 只保留摘要行
|
||||
parameters:
|
||||
- name: "url"
|
||||
type: "string"
|
||||
@@ -836,6 +1118,56 @@ parameters:
|
||||
description: "强制响应解码使用的编码(如GBK),覆盖自动探测"
|
||||
required: false
|
||||
flag: "--response-encoding"
|
||||
- name: "response_filter"
|
||||
type: "string"
|
||||
description: |
|
||||
响应体正则过滤(仅影响 stdout,不影响 --download 与指标)。
|
||||
默认 line 模式按行匹配;示例:'(error|exception|SQL|password|token|uid)'。
|
||||
与 response_max_lines/response_max_bytes 配合可显著减少 token 消耗。
|
||||
required: false
|
||||
flag: "--response-filter"
|
||||
- name: "response_filter_mode"
|
||||
type: "string"
|
||||
description: "过滤模式:line(按行,默认)、multiline(跨行块)、full(整段 DOTALL 匹配)"
|
||||
required: false
|
||||
default: "line"
|
||||
flag: "--response-filter-mode"
|
||||
- name: "response_filter_invert"
|
||||
type: "bool"
|
||||
description: "反向过滤:输出不匹配 regex 的行(用于剔除 HTML 噪音)"
|
||||
required: false
|
||||
default: false
|
||||
flag: "--response-filter-invert"
|
||||
- name: "response_filter_ignore_case"
|
||||
type: "bool"
|
||||
description: "正则忽略大小写"
|
||||
required: false
|
||||
default: false
|
||||
flag: "--response-filter-ignore-case"
|
||||
- name: "response_max_lines"
|
||||
type: "int"
|
||||
description: "stdout 最多输出行数(0=不限制);有 filter 时限制命中行数,无 filter 时截断全文"
|
||||
required: false
|
||||
default: 0
|
||||
flag: "--response-max-lines"
|
||||
- name: "response_max_bytes"
|
||||
type: "int"
|
||||
description: "stdout 响应体 UTF-8 字节上限(0=不限制),超出部分截断"
|
||||
required: false
|
||||
default: 0
|
||||
flag: "--response-max-bytes"
|
||||
- name: "response_preview_lines"
|
||||
type: "int"
|
||||
description: "filter 零命中时预览的前 N 行(默认 5,0=不预览)"
|
||||
required: false
|
||||
default: 5
|
||||
flag: "--response-preview-lines"
|
||||
- name: "response_context_lines"
|
||||
type: "int"
|
||||
description: "line 模式下命中行上下各保留 N 行上下文(类似 grep -C)"
|
||||
required: false
|
||||
default: 0
|
||||
flag: "--response-context-lines"
|
||||
- name: "action"
|
||||
type: "string"
|
||||
description: "保留字段:标识调用意图(request, spider等),脚本内部不使用"
|
||||
|
||||
@@ -260,8 +260,14 @@
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
max-width: 420px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.c2-actions > button {
|
||||
flex: 1;
|
||||
min-width: min(100%, 160px);
|
||||
}
|
||||
/* ============================================================================
|
||||
Listener Cards
|
||||
============================================================================ */
|
||||
|
||||
+314
-29
@@ -14749,6 +14749,76 @@ header {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 480 / 260;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* 底部氛围光:轻微呼吸 + 悬停扇区时整体染上该等级色调 */
|
||||
.dashboard-severity-chart::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -14% -12% -10%;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.92;
|
||||
background:
|
||||
radial-gradient(ellipse 82% 64% at 50% 74%, rgba(99, 102, 241, 0.17), transparent 58%),
|
||||
radial-gradient(ellipse 52% 42% at 14% 94%, rgba(56, 189, 248, 0.11), transparent 52%),
|
||||
radial-gradient(ellipse 48% 38% at 88% 90%, rgba(244, 114, 182, 0.08), transparent 50%);
|
||||
animation: dashboard-donut-aura 7s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.dashboard-severity-chart[data-hover-severity="critical"]::before {
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
background: radial-gradient(ellipse 82% 64% at 50% 74%, rgba(239, 68, 68, 0.38), transparent 58%),
|
||||
radial-gradient(ellipse 50% 44% at 22% 92%, rgba(249, 115, 22, 0.18), transparent 54%);
|
||||
}
|
||||
|
||||
.dashboard-severity-chart[data-hover-severity="high"]::before {
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
background: radial-gradient(ellipse 82% 64% at 50% 74%, rgba(249, 115, 22, 0.36), transparent 58%),
|
||||
radial-gradient(ellipse 48% 40% at 78% 88%, rgba(234, 179, 8, 0.14), transparent 52%);
|
||||
}
|
||||
|
||||
.dashboard-severity-chart[data-hover-severity="medium"]::before {
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
background: radial-gradient(ellipse 82% 64% at 50% 74%, rgba(234, 179, 8, 0.34), transparent 58%),
|
||||
radial-gradient(ellipse 46% 38% at 18% 88%, rgba(250, 204, 21, 0.16), transparent 52%);
|
||||
}
|
||||
|
||||
.dashboard-severity-chart[data-hover-severity="low"]::before {
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
background: radial-gradient(ellipse 82% 64% at 50% 74%, rgba(45, 212, 191, 0.34), transparent 58%),
|
||||
radial-gradient(ellipse 46% 38% at 86% 88%, rgba(14, 165, 233, 0.14), transparent 52%);
|
||||
}
|
||||
|
||||
.dashboard-severity-chart[data-hover-severity="info"]::before {
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
background: radial-gradient(ellipse 82% 64% at 50% 74%, rgba(59, 130, 246, 0.34), transparent 58%),
|
||||
radial-gradient(ellipse 46% 38% at 30% 86%, rgba(129, 140, 248, 0.16), transparent 52%);
|
||||
}
|
||||
|
||||
@keyframes dashboard-donut-aura {
|
||||
0% {
|
||||
opacity: 0.78;
|
||||
transform: scale(0.97);
|
||||
filter: saturate(0.92);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1.03);
|
||||
filter: saturate(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-severity-chart > .dashboard-severity-donut {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dashboard-severity-donut {
|
||||
@@ -14758,30 +14828,170 @@ header {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.dashboard-severity-donut .donut-track {
|
||||
fill: #f1f5f9;
|
||||
.dashboard-severity-donut .donut-track-shadow {
|
||||
fill: #c9d4e3;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.dashboard-severity-donut .donut-track-vignette {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dashboard-severity-donut .donut-segment-gloss {
|
||||
mix-blend-mode: soft-light;
|
||||
opacity: 0.48;
|
||||
transition: opacity 0.26s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dashboard-severity-donut .donut-segment-gloss.is-active {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.dashboard-severity-donut .donut-segment {
|
||||
/* 段与段之间用白色描边制造“切割线”效果,与参考图二一致;
|
||||
环回到黄金比例(厚度 50)后,描边也用回 4,切割线感更强 */
|
||||
filter: url(#donut-segment-soften);
|
||||
stroke: #ffffff;
|
||||
stroke-width: 4;
|
||||
stroke-linejoin: round;
|
||||
transition: opacity 0.2s ease;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.22s ease, filter 0.22s ease;
|
||||
}
|
||||
|
||||
.dashboard-severity-donut .donut-segment.is-empty {
|
||||
display: none;
|
||||
/* 透明命中层:几何固定,悬停时只改视觉层,避免 scale/描边导致边缘频闪 */
|
||||
.dashboard-severity-donut .donut-segment-hit {
|
||||
fill: transparent;
|
||||
stroke: transparent;
|
||||
stroke-width: 0;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
pointer-events: visible;
|
||||
}
|
||||
|
||||
.dashboard-severity-donut .donut-segment-hit:focus-visible {
|
||||
outline: 2px solid rgba(0, 102, 255, 0.55);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dashboard-severity-donut.donut-ready .donut-segment {
|
||||
animation: donut-segment-in 0.72s cubic-bezier(0.22, 1.18, 0.36, 1) backwards;
|
||||
}
|
||||
|
||||
.dashboard-severity-donut.donut-ready .donut-segment.seg-critical { animation-delay: 0.03s; }
|
||||
.dashboard-severity-donut.donut-ready .donut-segment.seg-high { animation-delay: 0.07s; }
|
||||
.dashboard-severity-donut.donut-ready .donut-segment.seg-medium { animation-delay: 0.11s; }
|
||||
.dashboard-severity-donut.donut-ready .donut-segment.seg-low { animation-delay: 0.15s; }
|
||||
.dashboard-severity-donut.donut-ready .donut-segment.seg-info { animation-delay: 0.19s; }
|
||||
|
||||
@keyframes donut-segment-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.72) translateY(10px);
|
||||
}
|
||||
72% {
|
||||
opacity: 1;
|
||||
transform: scale(1.06) translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-severity-donut.is-highlighting .donut-segment.is-dimmed,
|
||||
.dashboard-severity-donut.is-highlighting .donut-label-text.is-dimmed,
|
||||
.dashboard-severity-donut.is-highlighting .donut-leader.is-dimmed,
|
||||
.dashboard-severity-donut.is-highlighting .donut-segment-gloss.is-dimmed {
|
||||
opacity: 0.26;
|
||||
}
|
||||
|
||||
.dashboard-severity-donut .donut-segment.is-active {
|
||||
/* 不用 scale / stroke-width,防止命中区抖动 */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dashboard-severity-donut[data-hover-severity="critical"] .donut-segment.is-active {
|
||||
filter: url(#donut-segment-soften) drop-shadow(0 0 28px rgba(239, 68, 68, 0.55)) drop-shadow(0 10px 26px rgba(239, 68, 68, 0.28));
|
||||
}
|
||||
|
||||
.dashboard-severity-donut[data-hover-severity="high"] .donut-segment.is-active {
|
||||
filter: url(#donut-segment-soften) drop-shadow(0 0 26px rgba(249, 115, 22, 0.52)) drop-shadow(0 10px 24px rgba(249, 115, 22, 0.26));
|
||||
}
|
||||
|
||||
.dashboard-severity-donut[data-hover-severity="medium"] .donut-segment.is-active {
|
||||
filter: url(#donut-segment-soften) drop-shadow(0 0 26px rgba(234, 179, 8, 0.48)) drop-shadow(0 10px 22px rgba(202, 138, 4, 0.22));
|
||||
}
|
||||
|
||||
.dashboard-severity-donut[data-hover-severity="low"] .donut-segment.is-active {
|
||||
filter: url(#donut-segment-soften) drop-shadow(0 0 26px rgba(45, 212, 191, 0.48)) drop-shadow(0 10px 22px rgba(13, 148, 136, 0.22));
|
||||
}
|
||||
|
||||
.dashboard-severity-donut[data-hover-severity="info"] .donut-segment.is-active {
|
||||
filter: url(#donut-segment-soften) drop-shadow(0 0 26px rgba(59, 130, 246, 0.48)) drop-shadow(0 10px 22px rgba(37, 99, 235, 0.22));
|
||||
}
|
||||
|
||||
.dashboard-severity-donut .donut-leader {
|
||||
stroke: rgba(148, 163, 184, 0.45);
|
||||
stroke-width: 1.25;
|
||||
pointer-events: none;
|
||||
stroke-linecap: round;
|
||||
transition: opacity 0.22s ease, stroke 0.22s ease;
|
||||
}
|
||||
|
||||
.dashboard-severity-donut.donut-ready .donut-leader {
|
||||
stroke-dasharray: 100;
|
||||
stroke-dashoffset: 100;
|
||||
animation: donut-leader-draw 0.75s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.dashboard-severity-donut.donut-ready .donut-leader.label-critical { animation-delay: 0.12s; }
|
||||
.dashboard-severity-donut.donut-ready .donut-leader.label-high { animation-delay: 0.18s; }
|
||||
.dashboard-severity-donut.donut-ready .donut-leader.label-medium { animation-delay: 0.24s; }
|
||||
.dashboard-severity-donut.donut-ready .donut-leader.label-low { animation-delay: 0.30s; }
|
||||
.dashboard-severity-donut.donut-ready .donut-leader.label-info { animation-delay: 0.36s; }
|
||||
|
||||
@keyframes donut-leader-draw {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
.dashboard-severity-donut .donut-leader.is-active {
|
||||
stroke: rgba(71, 85, 105, 0.95);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.dashboard-severity-donut .donut-label-text {
|
||||
pointer-events: none;
|
||||
transition: opacity 0.22s ease, transform 0.28s cubic-bezier(0.34, 1.35, 0.48, 1);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
.dashboard-severity-donut.donut-ready .donut-label-text {
|
||||
animation: donut-label-pop 0.58s cubic-bezier(0.34, 1.25, 0.48, 1) backwards;
|
||||
}
|
||||
|
||||
.dashboard-severity-donut.donut-ready .donut-label-text.label-critical { animation-delay: 0.2s; }
|
||||
.dashboard-severity-donut.donut-ready .donut-label-text.label-high { animation-delay: 0.26s; }
|
||||
.dashboard-severity-donut.donut-ready .donut-label-text.label-medium { animation-delay: 0.32s; }
|
||||
.dashboard-severity-donut.donut-ready .donut-label-text.label-low { animation-delay: 0.38s; }
|
||||
.dashboard-severity-donut.donut-ready .donut-label-text.label-info { animation-delay: 0.44s; }
|
||||
|
||||
@keyframes donut-label-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-severity-donut .donut-label-text.is-active {
|
||||
font-weight: 800;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.dashboard-severity-donut .donut-label-text .donut-label-pct {
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
@@ -14799,45 +15009,86 @@ header {
|
||||
.dashboard-severity-donut .donut-label-text.label-low { fill: #14b8a6; }
|
||||
.dashboard-severity-donut .donut-label-text.label-info { fill: #3b82f6; }
|
||||
|
||||
/* 半环形配色:保持原有浅色基调(红→橙→黄→青→蓝) */
|
||||
.dashboard-severity-donut .donut-segment.seg-critical { fill: #f87171; }
|
||||
.dashboard-severity-donut .donut-segment.seg-high { fill: #fb923c; }
|
||||
.dashboard-severity-donut .donut-segment.seg-medium { fill: #facc15; }
|
||||
.dashboard-severity-donut .donut-segment.seg-low { fill: #2dd4bf; }
|
||||
.dashboard-severity-donut .donut-segment.seg-info { fill: #60a5fa; }
|
||||
/* 半环形主体配色由 SVG linearGradient(#donut-grad-*)提供 */
|
||||
|
||||
.dashboard-severity-donut .donut-segment.is-empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-severity-chart::before {
|
||||
animation: none;
|
||||
}
|
||||
.dashboard-severity-donut.donut-ready .donut-segment,
|
||||
.dashboard-severity-donut.donut-ready .donut-leader,
|
||||
.dashboard-severity-donut.donut-ready .donut-label-text {
|
||||
animation: none !important;
|
||||
}
|
||||
.dashboard-severity-center.is-hovering {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 中心数字:纯文字,贴在半圆开口下方(直径线附近),不遮挡彩色弧带 */
|
||||
.dashboard-severity-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
/* cy 在 viewBox(0,0,480,260) 中是 215,约 83% 处;
|
||||
这里把中心文字放在内圈靠下、靠近直径线的位置,让数字看起来"坐"在半圆里。 */
|
||||
top: 76%;
|
||||
transform: translate(-50%, -50%);
|
||||
bottom: 6%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
width: 60%;
|
||||
width: auto;
|
||||
max-width: 7rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
transition: transform 0.28s cubic-bezier(0.34, 1.35, 0.48, 1);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.dashboard-severity-center.is-hovering {
|
||||
transform: translateX(-50%) scale(1.06);
|
||||
}
|
||||
|
||||
.dashboard-severity-center-label.is-severity {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.dashboard-severity-center-value {
|
||||
font-size: 2.75rem;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.03em;
|
||||
letter-spacing: -0.04em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-shadow:
|
||||
0 0 20px rgba(255, 255, 255, 0.95),
|
||||
0 1px 2px rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.dashboard-severity-center-label {
|
||||
font-size: 0.8125rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 8px;
|
||||
letter-spacing: 0.04em;
|
||||
margin-top: 4px;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 0 12px rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.dashboard-severity-center-label[data-severity="critical"] { color: #dc2626; }
|
||||
.dashboard-severity-center-label[data-severity="high"] { color: #ea580c; }
|
||||
.dashboard-severity-center-label[data-severity="medium"] { color: #b45309; }
|
||||
.dashboard-severity-center-label[data-severity="low"] { color: #0f766e; }
|
||||
.dashboard-severity-center-label[data-severity="info"] { color: #2563eb; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.dashboard-severity-center-value { font-size: 2.25rem; }
|
||||
.dashboard-severity-center-label { font-size: 0.75rem; }
|
||||
.dashboard-severity-center-value { font-size: 2.1rem; }
|
||||
.dashboard-severity-center-label { font-size: 0.6875rem; }
|
||||
}
|
||||
|
||||
.dashboard-severity-legend {
|
||||
@@ -14856,12 +15107,46 @@ header {
|
||||
padding: 10px 4px;
|
||||
font-size: 0.9375rem;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s, opacity 0.2s;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-severity-legend-item:hover {
|
||||
background: rgba(0, 0, 0, 0.025);
|
||||
.dashboard-severity-legend-item:hover,
|
||||
.dashboard-severity-legend-item.is-active {
|
||||
background: rgba(0, 102, 255, 0.06);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dashboard-severity-legend-item.is-active {
|
||||
box-shadow: inset 3px 0 0 var(--accent-color, #0066ff);
|
||||
}
|
||||
|
||||
.dashboard-severity-legend-item.is-zero {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.dashboard-severity-legend-item:focus-visible {
|
||||
outline: 2px solid rgba(0, 102, 255, 0.45);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dashboard-severity-donut-tooltip {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 10000;
|
||||
max-width: 280px;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
color: #fff;
|
||||
background: rgba(15, 23, 42, 0.94);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.22);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-severity-legend-dot {
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"settings": "System settings",
|
||||
"hitl": "Human-in-the-loop",
|
||||
"c2": "C2",
|
||||
"c2Manage": "C2 management",
|
||||
"c2Listeners": "Listeners",
|
||||
"c2Sessions": "Sessions",
|
||||
"c2Tasks": "Tasks",
|
||||
@@ -146,6 +147,7 @@
|
||||
"active": "Active",
|
||||
"highFreq": "High frequency",
|
||||
"noCallData": "No call data",
|
||||
"severityClickHint": "Click to view",
|
||||
"lastUpdated": "Last updated",
|
||||
"viewAll": "View all →",
|
||||
"recentVulns": "Recent vulnerabilities",
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"settings": "系统设置",
|
||||
"hitl": "人机协同",
|
||||
"c2": "C2",
|
||||
"c2Manage": "C2 管理",
|
||||
"c2Listeners": "监听器",
|
||||
"c2Sessions": "会话",
|
||||
"c2Tasks": "任务",
|
||||
@@ -146,6 +147,7 @@
|
||||
"active": "活跃",
|
||||
"highFreq": "高频",
|
||||
"noCallData": "暂无调用数据",
|
||||
"severityClickHint": "点击查看",
|
||||
"lastUpdated": "上次更新",
|
||||
"viewAll": "查看全部 →",
|
||||
"recentVulns": "最近漏洞",
|
||||
|
||||
@@ -2224,6 +2224,39 @@ function showCopySuccess(button) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 相邻且类型/正文/data 完全一致的过程详情只保留一条(与后端去重一致,避免时间线叠多条相同块) */
|
||||
function dedupeConsecutiveProcessDetailRows(details) {
|
||||
if (!Array.isArray(details) || details.length < 2) {
|
||||
return details;
|
||||
}
|
||||
const out = [details[0]];
|
||||
for (let i = 1; i < details.length; i++) {
|
||||
const cur = details[i];
|
||||
if (processDetailRowFingerprint(out[out.length - 1]) === processDetailRowFingerprint(cur)) {
|
||||
continue;
|
||||
}
|
||||
out.push(cur);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function processDetailRowFingerprint(d) {
|
||||
if (!d || typeof d !== 'object') {
|
||||
return '';
|
||||
}
|
||||
const et = String(d.eventType || '');
|
||||
const msg = String(d.message != null ? d.message : '').trim();
|
||||
let dataKey = '';
|
||||
try {
|
||||
if (d.data != null) {
|
||||
dataKey = JSON.stringify(d.data);
|
||||
}
|
||||
} catch (e) {
|
||||
dataKey = String(d.data);
|
||||
}
|
||||
return et + '\0' + msg + '\0' + dataKey;
|
||||
}
|
||||
|
||||
// 渲染过程详情
|
||||
function renderProcessDetails(messageId, processDetails) {
|
||||
const messageElement = document.getElementById(messageId);
|
||||
@@ -2323,6 +2356,7 @@ function renderProcessDetails(messageId, processDetails) {
|
||||
}
|
||||
detailsContainer.dataset.lazyNotLoaded = '0';
|
||||
detailsContainer.dataset.loaded = '1';
|
||||
processDetails = dedupeConsecutiveProcessDetailRows(processDetails);
|
||||
// 如果没有processDetails或为空,显示空状态
|
||||
if (!processDetails || processDetails.length === 0) {
|
||||
// 显示空状态提示
|
||||
|
||||
+417
-14
@@ -202,7 +202,6 @@ async function refreshDashboard() {
|
||||
openHighCount = pickOpenCount(openHighRes, highCount);
|
||||
openMediumCount = pickOpenCount(openMediumRes, mediumCount);
|
||||
openLowCount = pickOpenCount(openLowRes, lowCount);
|
||||
if (severityTotalEl) severityTotalEl.textContent = String(total);
|
||||
severityIds.forEach(sev => {
|
||||
const count = bySeverity[sev] || 0;
|
||||
const el = document.getElementById('dashboard-severity-' + sev);
|
||||
@@ -1390,6 +1389,17 @@ function dashboardBarTooltipOnOut(ev) {
|
||||
if (dashboardBarTooltipEl) dashboardBarTooltipEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// 仪表盘 → 漏洞管理:带严重程度/状态筛选跳转
|
||||
function navigateToVulnerabilitiesWithFilter(opts) {
|
||||
opts = opts || {};
|
||||
var params = new URLSearchParams();
|
||||
if (opts.severity) params.set('severity', opts.severity);
|
||||
if (opts.status) params.set('status', opts.status);
|
||||
var qs = params.toString();
|
||||
window.location.hash = qs ? 'vulnerabilities?' + qs : 'vulnerabilities';
|
||||
}
|
||||
window.navigateToVulnerabilitiesWithFilter = navigateToVulnerabilitiesWithFilter;
|
||||
|
||||
// 漏洞严重程度分布:半环形(donut)渲染
|
||||
// 几何参数固定,便于配合 viewBox 0 0 560 320 的 SVG 容器
|
||||
// 段间分隔由 CSS 的白色 stroke 完成,不再使用 gapRad
|
||||
@@ -1402,9 +1412,31 @@ var SEVERITY_DONUT_CFG = {
|
||||
rOuter: 165,
|
||||
rInner: 115, // 环厚 = 50(介于原 90 和上一版 35 之间,自然且有质感)
|
||||
labelOffset: 14,
|
||||
gapRad: 0
|
||||
gapRad: 0.012
|
||||
};
|
||||
|
||||
// 三段渐变:[高光浅调, 中段饱和色, 深色边缘] —— 做出类似 3D 釉面的层次
|
||||
var SEVERITY_DONUT_GRADIENTS = {
|
||||
critical: ['#fecaca', '#f87171', '#dc2626'],
|
||||
high: ['#fed7aa', '#fb923c', '#ea580c'],
|
||||
medium: ['#fef08a', '#facc15', '#ca8a04'],
|
||||
low: ['#99f6e4', '#2dd4bf', '#0f766e'],
|
||||
info: ['#bfdbfe', '#60a5fa', '#2563eb']
|
||||
};
|
||||
|
||||
var severityDonutCenterDisplayed = { total: null, hoverCount: null };
|
||||
|
||||
var severityDonutState = {
|
||||
bySeverity: {},
|
||||
total: 0,
|
||||
hoverId: null,
|
||||
bound: false
|
||||
};
|
||||
|
||||
var severityDonutTooltipEl = null;
|
||||
var severityDonutTooltipTimer = null;
|
||||
var severityDonutHoverClearTimer = null;
|
||||
|
||||
var SEVERITY_DEFAULT_LABELS = {
|
||||
critical: '严重',
|
||||
high: '高危',
|
||||
@@ -1422,17 +1454,65 @@ function severityLabel(id) {
|
||||
return SEVERITY_DEFAULT_LABELS[id] || id;
|
||||
}
|
||||
|
||||
function ensureSeverityDonutDefs() {
|
||||
var defsEl = document.getElementById('dashboard-severity-donut-defs');
|
||||
if (!defsEl || defsEl.hasChildNodes()) return;
|
||||
var html = '';
|
||||
html += '<linearGradient id="donut-track-face" x1="0%" y1="0%" x2="0%" y2="100%">';
|
||||
html += '<stop offset="0%" stop-color="#f8fafc"/>';
|
||||
html += '<stop offset="55%" stop-color="#e8eef5"/>';
|
||||
html += '<stop offset="100%" stop-color="#dce5ef"/>';
|
||||
html += '</linearGradient>';
|
||||
html += '<radialGradient id="donut-track-vignette" cx="50%" cy="85%" r="75%" fx="50%" fy="85%">';
|
||||
html += '<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>';
|
||||
html += '<stop offset="70%" stop-color="#ffffff" stop-opacity="0"/>';
|
||||
html += '</radialGradient>';
|
||||
html += '<radialGradient id="donut-inner-gloss" cx="35%" cy="75%" r="55%">';
|
||||
html += '<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>';
|
||||
html += '<stop offset="55%" stop-color="#ffffff" stop-opacity="0.08"/>';
|
||||
html += '<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>';
|
||||
html += '</radialGradient>';
|
||||
html += '<filter id="donut-segment-soften" x="-18%" y="-18%" width="136%" height="136%" color-interpolation-filters="sRGB">';
|
||||
html += '<feGaussianBlur in="SourceAlpha" stdDeviation="0.8" result="blur"/>';
|
||||
html += '<feOffset dx="0" dy="1.5" in="blur" result="off"/>';
|
||||
html += '<feFlood flood-color="#0f172a" flood-opacity="0.13" result="flood"/>';
|
||||
html += '<feComposite in="flood" in2="off" operator="in" result="shadow"/>';
|
||||
html += '<feMerge><feMergeNode in="shadow"/><feMergeNode in="SourceGraphic"/></feMerge>';
|
||||
html += '</filter>';
|
||||
Object.keys(SEVERITY_DONUT_GRADIENTS).forEach(function (id) {
|
||||
var stops = SEVERITY_DONUT_GRADIENTS[id];
|
||||
html += '<linearGradient id="donut-grad-' + id + '" x1="18%" y1="12%" x2="88%" y2="94%">';
|
||||
html += '<stop offset="0%" stop-color="' + stops[0] + '"/>';
|
||||
html += '<stop offset="52%" stop-color="' + stops[1] + '"/>';
|
||||
html += '<stop offset="100%" stop-color="' + stops[2] + '"/>';
|
||||
html += '</linearGradient>';
|
||||
});
|
||||
defsEl.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderSeverityDonut(bySeverity, total) {
|
||||
var svgEl = document.getElementById('dashboard-severity-donut');
|
||||
var trackEl = document.getElementById('dashboard-severity-donut-track');
|
||||
var leadersEl = document.getElementById('dashboard-severity-donut-leaders');
|
||||
var segmentsEl = document.getElementById('dashboard-severity-donut-segments');
|
||||
var hitsEl = document.getElementById('dashboard-severity-donut-hits');
|
||||
var labelsEl = document.getElementById('dashboard-severity-donut-labels');
|
||||
if (!trackEl || !segmentsEl || !labelsEl) return;
|
||||
|
||||
var cfg = SEVERITY_DONUT_CFG;
|
||||
severityDonutState.bySeverity = bySeverity && typeof bySeverity === 'object' ? bySeverity : {};
|
||||
severityDonutState.total = total || 0;
|
||||
severityDonutState.hoverId = null;
|
||||
|
||||
// 背景轨迹(完整半环)只渲染一次
|
||||
var cfg = SEVERITY_DONUT_CFG;
|
||||
ensureSeverityDonutDefs();
|
||||
|
||||
// 背景轨迹(完整半环):双层填充营造凹槽 + 高光
|
||||
if (!trackEl.hasChildNodes()) {
|
||||
trackEl.innerHTML = '<path class="donut-track" d="' + halfRingPath(cfg.cx, cfg.cy, cfg.rOuter, cfg.rInner) + '"/>';
|
||||
var trackPath = halfRingPath(cfg.cx, cfg.cy, cfg.rOuter, cfg.rInner);
|
||||
trackEl.innerHTML =
|
||||
'<path class="donut-track-shadow" d="' + trackPath + '"/>' +
|
||||
'<path class="donut-track" fill="url(#donut-track-face)" d="' + trackPath + '"/>' +
|
||||
'<path class="donut-track-vignette" fill="url(#donut-track-vignette)" d="' + trackPath + '"/>';
|
||||
}
|
||||
|
||||
var ids = ['critical', 'high', 'medium', 'low', 'info'];
|
||||
@@ -1441,12 +1521,24 @@ function renderSeverityDonut(bySeverity, total) {
|
||||
});
|
||||
var visible = severities.filter(function (s) { return s.value > 0; });
|
||||
|
||||
if (svgEl) {
|
||||
svgEl.classList.remove('is-highlighting');
|
||||
svgEl.removeAttribute('data-hover-severity');
|
||||
}
|
||||
if (!total || total <= 0 || visible.length === 0) {
|
||||
segmentsEl.innerHTML = '';
|
||||
if (hitsEl) hitsEl.innerHTML = '';
|
||||
labelsEl.innerHTML = '';
|
||||
if (leadersEl) leadersEl.innerHTML = '';
|
||||
clearSeverityDonutLegendHighlight();
|
||||
resetSeverityDonutCenter(false);
|
||||
_clearSeverityDonutChartWrapHover();
|
||||
if (svgEl) svgEl.classList.remove('donut-ready');
|
||||
return;
|
||||
}
|
||||
|
||||
resetSeverityDonutCenter(true);
|
||||
|
||||
// 弧长按 value/total 计算;若严重度求和 < total(存在未分级),右侧会保留背景轨迹的空白
|
||||
var sumVisible = visible.reduce(function (s, seg) { return s + seg.value; }, 0);
|
||||
var coverage = sumVisible / total; // 半环被实际段覆盖的比例
|
||||
@@ -1456,7 +1548,10 @@ function renderSeverityDonut(bySeverity, total) {
|
||||
var arcsTotalRad = Math.max(0, Math.PI * coverage - totalGapRad);
|
||||
|
||||
var segmentsHtml = '';
|
||||
var hitsHtml = '';
|
||||
var glossHtml = '';
|
||||
var labelsHtml = '';
|
||||
var leadersHtml = '';
|
||||
var cumRad = 0;
|
||||
|
||||
visible.forEach(function (seg, i) {
|
||||
@@ -1466,17 +1561,21 @@ function renderSeverityDonut(bySeverity, total) {
|
||||
var angleEnd = angleStart - segRad;
|
||||
|
||||
var path = arcSegmentPath(cfg.cx, cfg.cy, cfg.rOuter, cfg.rInner, angleStart, angleEnd);
|
||||
segmentsHtml += '<path class="donut-segment seg-' + seg.id + '" d="' + path + '"/>';
|
||||
var pctOfTotal = (seg.value / total) * 100;
|
||||
var pctRounded = Math.round(pctOfTotal);
|
||||
var name = esc(severityLabel(seg.id));
|
||||
var ariaLabel = name + ' ' + seg.value + ' (' + pctRounded + '%)';
|
||||
segmentsHtml += '<path class="donut-segment seg-' + seg.id + '" data-severity="' + seg.id + '" data-count="' + seg.value + '" data-pct="' + pctRounded + '" fill="url(#donut-grad-' + seg.id + ')" d="' + path + '"/>';
|
||||
hitsHtml += '<path class="donut-segment-hit seg-' + seg.id + '" data-severity="' + seg.id + '" fill="transparent" d="' + path + '" tabindex="0" role="button" aria-label="' + ariaLabel + '"/>';
|
||||
glossHtml += '<path class="donut-segment-gloss seg-' + seg.id + '" data-severity="' + seg.id + '" fill="url(#donut-inner-gloss)" d="' + arcSegmentPath(cfg.cx, cfg.cy, cfg.rOuter - 2, cfg.rInner + 6, angleStart, angleEnd) + '" pointer-events="none"/>';
|
||||
|
||||
// 仅当占比 >= 5% 时显示外置标签,避免小段标签互相重叠
|
||||
var pctOfTotal = (seg.value / total) * 100;
|
||||
if (pctOfTotal >= 5) {
|
||||
var midAngle = (angleStart + angleEnd) / 2;
|
||||
var labelR = cfg.rOuter + cfg.labelOffset;
|
||||
var labelR = cfg.rOuter + cfg.labelOffset + 6;
|
||||
var sinMid = Math.sin(midAngle);
|
||||
var cosMid = Math.cos(midAngle);
|
||||
var lx = cfg.cx + labelR * cosMid;
|
||||
// 顶部区域标签整体向上抬一些,避免与外弧贴住;侧边标签则不调整
|
||||
var topLift = sinMid > 0.4 ? Math.round((sinMid - 0.3) * 10) : 0;
|
||||
var ly = cfg.cy - labelR * sinMid - topLift;
|
||||
|
||||
@@ -1484,11 +1583,15 @@ function renderSeverityDonut(bySeverity, total) {
|
||||
if (cosMid < -0.15) anchor = 'end';
|
||||
else if (cosMid > 0.15) anchor = 'start';
|
||||
|
||||
var pctText = Math.round(pctOfTotal) + '%';
|
||||
var name = esc(severityLabel(seg.id));
|
||||
var pctText = pctRounded + '%';
|
||||
var arcR = cfg.rOuter + 4;
|
||||
var lineX1 = cfg.cx + arcR * cosMid;
|
||||
var lineY1 = cfg.cy - arcR * sinMid;
|
||||
var lineX2 = cfg.cx + (cfg.rOuter + cfg.labelOffset - 2) * cosMid;
|
||||
var lineY2 = cfg.cy - (cfg.rOuter + cfg.labelOffset - 2) * sinMid;
|
||||
leadersHtml += '<line class="donut-leader label-' + seg.id + '" data-severity="' + seg.id + '" pathLength="100" x1="' + lineX1.toFixed(1) + '" y1="' + lineY1.toFixed(1) + '" x2="' + lineX2.toFixed(1) + '" y2="' + lineY2.toFixed(1) + '"/>';
|
||||
|
||||
// 两行:第一行 "数量 (百分比)"(弧色),第二行 "严重度名称"(同色但稍小)
|
||||
labelsHtml += '<text class="donut-label-text label-' + seg.id + '" text-anchor="' + anchor + '" x="' + lx.toFixed(1) + '" y="' + ly.toFixed(1) + '">';
|
||||
labelsHtml += '<text class="donut-label-text label-' + seg.id + '" data-severity="' + seg.id + '" text-anchor="' + anchor + '" x="' + lx.toFixed(1) + '" y="' + ly.toFixed(1) + '">';
|
||||
labelsHtml += '<tspan x="' + lx.toFixed(1) + '" dy="0">' + seg.value + ' <tspan class="donut-label-pct">(' + pctText + ')</tspan></tspan>';
|
||||
labelsHtml += '<tspan class="donut-label-name" x="' + lx.toFixed(1) + '" dy="14">' + name + '</tspan>';
|
||||
labelsHtml += '</text>';
|
||||
@@ -1498,8 +1601,308 @@ function renderSeverityDonut(bySeverity, total) {
|
||||
if (i < visibleCount - 1) cumRad += cfg.gapRad;
|
||||
});
|
||||
|
||||
segmentsEl.innerHTML = segmentsHtml;
|
||||
if (leadersEl) leadersEl.innerHTML = leadersHtml;
|
||||
segmentsEl.innerHTML = segmentsHtml + glossHtml;
|
||||
if (hitsEl) hitsEl.innerHTML = hitsHtml;
|
||||
labelsEl.innerHTML = labelsHtml;
|
||||
if (svgEl) {
|
||||
svgEl.classList.remove('donut-ready');
|
||||
void svgEl.offsetWidth;
|
||||
requestAnimationFrame(function () {
|
||||
svgEl.classList.add('donut-ready');
|
||||
});
|
||||
}
|
||||
scheduleSeverityCenterCountUp(total);
|
||||
attachSeverityDonutInteractivity();
|
||||
}
|
||||
|
||||
function scheduleSeverityCenterCountUp(targetTotal) {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
var totalEl = document.getElementById('dashboard-severity-total');
|
||||
if (totalEl) totalEl.textContent = String(targetTotal);
|
||||
severityDonutCenterDisplayed.total = targetTotal;
|
||||
return;
|
||||
}
|
||||
var totalEl = document.getElementById('dashboard-severity-total');
|
||||
if (!totalEl || severityDonutState.hoverId) return;
|
||||
var from = typeof severityDonutCenterDisplayed.total === 'number' ? severityDonutCenterDisplayed.total : 0;
|
||||
var to = targetTotal;
|
||||
if (from === to) {
|
||||
totalEl.textContent = String(to);
|
||||
severityDonutCenterDisplayed.total = to;
|
||||
return;
|
||||
}
|
||||
var start = null;
|
||||
var dur = Math.min(520, 180 + Math.abs(to - from) * 28);
|
||||
function tick(now) {
|
||||
if (!start) start = now;
|
||||
var t = Math.min(1, (now - start) / dur);
|
||||
var eased = 1 - Math.pow(1 - t, 3);
|
||||
var val = Math.round(from + (to - from) * eased);
|
||||
totalEl.textContent = String(val);
|
||||
if (t < 1) {
|
||||
requestAnimationFrame(tick);
|
||||
} else {
|
||||
totalEl.textContent = String(to);
|
||||
severityDonutCenterDisplayed.total = to;
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function resetSeverityDonutCenter(skipTotalSnapshot) {
|
||||
var totalEl = document.getElementById('dashboard-severity-total');
|
||||
var labelEl = document.getElementById('dashboard-severity-center-label');
|
||||
var centerEl = document.getElementById('dashboard-severity-center');
|
||||
var n = severityDonutState.total || 0;
|
||||
if (!skipTotalSnapshot && totalEl) totalEl.textContent = String(n);
|
||||
if (!skipTotalSnapshot) severityDonutCenterDisplayed.total = n;
|
||||
severityDonutCenterDisplayed.hoverCount = null;
|
||||
if (labelEl) {
|
||||
labelEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.totalVulns') : '总漏洞数');
|
||||
labelEl.classList.remove('is-severity');
|
||||
labelEl.removeAttribute('data-severity');
|
||||
}
|
||||
if (centerEl) centerEl.classList.remove('is-hovering');
|
||||
}
|
||||
|
||||
function setSeverityDonutHover(severityId) {
|
||||
var svgEl = document.getElementById('dashboard-severity-donut');
|
||||
var centerEl = document.getElementById('dashboard-severity-center');
|
||||
var totalEl = document.getElementById('dashboard-severity-total');
|
||||
var labelEl = document.getElementById('dashboard-severity-center-label');
|
||||
if (!severityId) {
|
||||
severityDonutState.hoverId = null;
|
||||
if (svgEl) {
|
||||
svgEl.classList.remove('is-highlighting');
|
||||
svgEl.removeAttribute('data-hover-severity');
|
||||
}
|
||||
clearSeverityDonutLegendHighlight();
|
||||
resetSeverityDonutCenter(false);
|
||||
_clearSeverityDonutChartWrapHover();
|
||||
return;
|
||||
}
|
||||
var count = (severityDonutState.bySeverity && severityDonutState.bySeverity[severityId]) || 0;
|
||||
severityDonutState.hoverId = severityId;
|
||||
if (svgEl) {
|
||||
svgEl.classList.add('is-highlighting');
|
||||
svgEl.setAttribute('data-hover-severity', severityId);
|
||||
}
|
||||
highlightSeverityDonutParts(severityId);
|
||||
highlightSeverityLegendItem(severityId);
|
||||
if (totalEl) {
|
||||
totalEl.textContent = String(count);
|
||||
severityDonutCenterDisplayed.hoverCount = count;
|
||||
}
|
||||
if (labelEl) {
|
||||
labelEl.textContent = severityLabel(severityId);
|
||||
labelEl.classList.add('is-severity');
|
||||
labelEl.setAttribute('data-severity', severityId);
|
||||
}
|
||||
if (centerEl) centerEl.classList.add('is-hovering');
|
||||
var chartWrap = document.querySelector('.dashboard-severity-chart');
|
||||
if (chartWrap) chartWrap.setAttribute('data-hover-severity', severityId);
|
||||
}
|
||||
|
||||
function _clearSeverityDonutChartWrapHover() {
|
||||
var chartWrap = document.querySelector('.dashboard-severity-chart');
|
||||
if (chartWrap) chartWrap.removeAttribute('data-hover-severity');
|
||||
}
|
||||
|
||||
function highlightSeverityDonutParts(severityId) {
|
||||
var svgEl = document.getElementById('dashboard-severity-donut');
|
||||
if (!svgEl) return;
|
||||
svgEl.querySelectorAll('.donut-segment[data-severity], .donut-segment-gloss[data-severity], .donut-leader[data-severity], .donut-label-text[data-severity]').forEach(function (el) {
|
||||
var match = el.getAttribute('data-severity') === severityId;
|
||||
el.classList.toggle('is-active', match);
|
||||
el.classList.toggle('is-dimmed', !match);
|
||||
});
|
||||
}
|
||||
|
||||
function highlightSeverityLegendItem(severityId) {
|
||||
var legend = document.getElementById('dashboard-vuln-bars');
|
||||
if (!legend) return;
|
||||
legend.querySelectorAll('.dashboard-severity-legend-item').forEach(function (item) {
|
||||
var match = item.getAttribute('data-severity') === severityId;
|
||||
item.classList.toggle('is-active', match);
|
||||
});
|
||||
}
|
||||
|
||||
function clearSeverityDonutLegendHighlight() {
|
||||
var legend = document.getElementById('dashboard-vuln-bars');
|
||||
if (legend) {
|
||||
legend.querySelectorAll('.dashboard-severity-legend-item.is-active').forEach(function (el) {
|
||||
el.classList.remove('is-active');
|
||||
});
|
||||
}
|
||||
var svgEl = document.getElementById('dashboard-severity-donut');
|
||||
if (svgEl) {
|
||||
svgEl.querySelectorAll('.is-active, .is-dimmed').forEach(function (el) {
|
||||
el.classList.remove('is-active', 'is-dimmed');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function severityDonutTooltipText(severityId) {
|
||||
var count = (severityDonutState.bySeverity && severityDonutState.bySeverity[severityId]) || 0;
|
||||
var pct = severityDonutState.total > 0 ? Math.round((count / severityDonutState.total) * 100) : 0;
|
||||
var hint = (typeof window.t === 'function' ? window.t('dashboard.severityClickHint') : '点击查看');
|
||||
return severityLabel(severityId) + ' · ' + count + ' (' + pct + '%) — ' + hint;
|
||||
}
|
||||
|
||||
function showSeverityDonutTooltip(ev, severityId) {
|
||||
if (!severityDonutTooltipEl) {
|
||||
severityDonutTooltipEl = document.createElement('div');
|
||||
severityDonutTooltipEl.className = 'dashboard-severity-donut-tooltip';
|
||||
severityDonutTooltipEl.setAttribute('role', 'tooltip');
|
||||
document.body.appendChild(severityDonutTooltipEl);
|
||||
}
|
||||
clearTimeout(severityDonutTooltipTimer);
|
||||
severityDonutTooltipTimer = setTimeout(function () {
|
||||
severityDonutTooltipEl.textContent = severityDonutTooltipText(severityId);
|
||||
severityDonutTooltipEl.style.display = 'block';
|
||||
requestAnimationFrame(function () {
|
||||
var x = ev.clientX;
|
||||
var y = ev.clientY;
|
||||
var ttRect = severityDonutTooltipEl.getBoundingClientRect();
|
||||
var left = x - ttRect.width / 2;
|
||||
var top = y - ttRect.height - 12;
|
||||
if (top < 8) top = y + 16;
|
||||
var pad = 8;
|
||||
if (left < pad) left = pad;
|
||||
if (left + ttRect.width > window.innerWidth - pad) left = window.innerWidth - ttRect.width - pad;
|
||||
severityDonutTooltipEl.style.left = left + 'px';
|
||||
severityDonutTooltipEl.style.top = top + 'px';
|
||||
});
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function hideSeverityDonutTooltip() {
|
||||
clearTimeout(severityDonutTooltipTimer);
|
||||
severityDonutTooltipTimer = null;
|
||||
if (severityDonutTooltipEl) severityDonutTooltipEl.style.display = 'none';
|
||||
}
|
||||
|
||||
function attachSeverityDonutInteractivity() {
|
||||
var hitsEl = document.getElementById('dashboard-severity-donut-hits');
|
||||
var legend = document.getElementById('dashboard-vuln-bars');
|
||||
if (!hitsEl) return;
|
||||
|
||||
if (!severityDonutState.bound) {
|
||||
severityDonutState.bound = true;
|
||||
hitsEl.addEventListener('mouseover', severityDonutPointerOver);
|
||||
hitsEl.addEventListener('mouseout', severityDonutPointerOut);
|
||||
hitsEl.addEventListener('click', severityDonutClick);
|
||||
hitsEl.addEventListener('keydown', severityDonutKeydown);
|
||||
if (legend) {
|
||||
legend.addEventListener('mouseover', severityLegendPointerOver);
|
||||
legend.addEventListener('mouseout', severityLegendPointerOut);
|
||||
legend.addEventListener('click', severityLegendClick);
|
||||
legend.addEventListener('keydown', severityLegendKeydown);
|
||||
}
|
||||
}
|
||||
|
||||
legend && legend.querySelectorAll('.dashboard-severity-legend-item').forEach(function (item) {
|
||||
if (!item.getAttribute('data-severity')) return;
|
||||
var sev = item.getAttribute('data-severity');
|
||||
var count = (severityDonutState.bySeverity && severityDonutState.bySeverity[sev]) || 0;
|
||||
item.classList.toggle('is-zero', count === 0);
|
||||
item.setAttribute('aria-label', severityDonutTooltipText(sev));
|
||||
});
|
||||
}
|
||||
|
||||
function severityDonutHitTarget(el) {
|
||||
return el && el.closest && el.closest('.donut-segment-hit');
|
||||
}
|
||||
|
||||
function severityDonutCancelHoverClear() {
|
||||
clearTimeout(severityDonutHoverClearTimer);
|
||||
severityDonutHoverClearTimer = null;
|
||||
}
|
||||
|
||||
function severityDonutScheduleHoverClear() {
|
||||
severityDonutCancelHoverClear();
|
||||
severityDonutHoverClearTimer = setTimeout(function () {
|
||||
severityDonutHoverClearTimer = null;
|
||||
setSeverityDonutHover(null);
|
||||
hideSeverityDonutTooltip();
|
||||
}, 60);
|
||||
}
|
||||
|
||||
function severityDonutPointerOver(ev) {
|
||||
var target = severityDonutHitTarget(ev.target);
|
||||
if (!target) return;
|
||||
var id = target.getAttribute('data-severity');
|
||||
if (!id) return;
|
||||
severityDonutCancelHoverClear();
|
||||
if (severityDonutState.hoverId === id) return;
|
||||
setSeverityDonutHover(id);
|
||||
showSeverityDonutTooltip(ev, id);
|
||||
}
|
||||
|
||||
function severityDonutPointerOut(ev) {
|
||||
var related = ev.relatedTarget;
|
||||
if (related) {
|
||||
if (severityDonutHitTarget(related)) return;
|
||||
var legendItem = related.closest && related.closest('.dashboard-severity-legend-item[data-severity]');
|
||||
if (legendItem) return;
|
||||
var hitsRoot = document.getElementById('dashboard-severity-donut-hits');
|
||||
if (hitsRoot && hitsRoot.contains(related)) return;
|
||||
}
|
||||
severityDonutScheduleHoverClear();
|
||||
}
|
||||
|
||||
function severityDonutClick(ev) {
|
||||
var target = severityDonutHitTarget(ev.target);
|
||||
if (!target) return;
|
||||
var id = target.getAttribute('data-severity');
|
||||
if (!id) return;
|
||||
ev.preventDefault();
|
||||
navigateToVulnerabilitiesWithFilter({ severity: id });
|
||||
}
|
||||
|
||||
function severityDonutKeydown(ev) {
|
||||
if (ev.key !== 'Enter' && ev.key !== ' ') return;
|
||||
var target = severityDonutHitTarget(ev.target);
|
||||
if (!target) return;
|
||||
ev.preventDefault();
|
||||
var id = target.getAttribute('data-severity');
|
||||
if (id) navigateToVulnerabilitiesWithFilter({ severity: id });
|
||||
}
|
||||
|
||||
function severityLegendPointerOver(ev) {
|
||||
var item = ev.target && ev.target.closest && ev.target.closest('.dashboard-severity-legend-item[data-severity]');
|
||||
if (!item) return;
|
||||
var id = item.getAttribute('data-severity');
|
||||
if (!id) return;
|
||||
severityDonutCancelHoverClear();
|
||||
setSeverityDonutHover(id);
|
||||
showSeverityDonutTooltip(ev, id);
|
||||
}
|
||||
|
||||
function severityLegendPointerOut(ev) {
|
||||
var item = ev.target && ev.target.closest && ev.target.closest('.dashboard-severity-legend-item[data-severity]');
|
||||
var related = ev.relatedTarget && ev.relatedTarget.closest && ev.relatedTarget.closest('.dashboard-severity-legend-item[data-severity]');
|
||||
if (item && item === related) return;
|
||||
severityDonutScheduleHoverClear();
|
||||
}
|
||||
|
||||
function severityLegendClick(ev) {
|
||||
var item = ev.target && ev.target.closest && ev.target.closest('.dashboard-severity-legend-item[data-severity]');
|
||||
if (!item) return;
|
||||
var id = item.getAttribute('data-severity');
|
||||
if (!id) return;
|
||||
ev.preventDefault();
|
||||
navigateToVulnerabilitiesWithFilter({ severity: id });
|
||||
}
|
||||
|
||||
function severityLegendKeydown(ev) {
|
||||
if (ev.key !== 'Enter' && ev.key !== ' ') return;
|
||||
var item = ev.target && ev.target.closest && ev.target.closest('.dashboard-severity-legend-item[data-severity]');
|
||||
if (!item) return;
|
||||
ev.preventDefault();
|
||||
var id = item.getAttribute('data-severity');
|
||||
if (id) navigateToVulnerabilitiesWithFilter({ severity: id });
|
||||
}
|
||||
|
||||
// SVG 半环(背景轨迹)路径
|
||||
|
||||
@@ -772,7 +772,7 @@ function toggleProgressDetails(progressId) {
|
||||
}
|
||||
}
|
||||
|
||||
// 编排器开始输出最终回复时隐藏整条进度消息(迭代阶段保持展开可见;此处整行收起而非仅折叠时间线)
|
||||
// 编排器开始输出最终回复时隐藏整条进度消息(过程已迁入助手气泡的「展开详情」,避免与进度卡重复)
|
||||
function hideProgressMessageForFinalReply(progressId) {
|
||||
if (!progressId) return;
|
||||
const el = document.getElementById(progressId);
|
||||
@@ -970,7 +970,7 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut
|
||||
});
|
||||
}
|
||||
|
||||
// 移除原来的进度消息
|
||||
// 移除原来的进度消息(详情已快照到助手消息下的 process-details)
|
||||
removeMessage(progressId);
|
||||
}
|
||||
|
||||
@@ -1885,6 +1885,8 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
}
|
||||
|
||||
// 多代理模式下,迭代过程中的输出只显示在时间线中,不创建助手消息气泡
|
||||
// 同一 progressId 再次 response_start 时先移除旧占位,避免多条「助手输出」卡片且仅最后一条收 delta
|
||||
// 改为保留旧占位,让每一段 response_start 都能在时间线中完整展示。
|
||||
// 创建时间线条目用于显示迭代过程中的输出
|
||||
const title = einoMainStreamPlanningTitle(responseData);
|
||||
const itemId = addTimelineItem(timeline, 'thinking', {
|
||||
|
||||
@@ -1087,6 +1087,7 @@ async function applySettings() {
|
||||
|
||||
const wecomAgentIdVal = document.getElementById('robot-wecom-agent-id')?.value.trim();
|
||||
const prevOpenai = (currentConfig && currentConfig.openai) ? currentConfig.openai : {};
|
||||
const prevRobots = (currentConfig && currentConfig.robots) ? currentConfig.robots : {};
|
||||
const config = {
|
||||
openai: {
|
||||
...prevOpenai,
|
||||
@@ -1118,7 +1119,7 @@ async function applySettings() {
|
||||
return {
|
||||
enabled: document.getElementById('multi-agent-enabled')?.checked === true,
|
||||
robot_use_multi_agent: document.getElementById('multi-agent-robot-use')?.checked === true,
|
||||
batch_use_multi_agent: false,
|
||||
batch_use_multi_agent: currentConfig?.multi_agent?.batch_use_multi_agent === true,
|
||||
plan_execute_loop_max_iterations: peLoop
|
||||
};
|
||||
})(),
|
||||
@@ -1127,6 +1128,7 @@ async function applySettings() {
|
||||
enabled: c2Enabled
|
||||
},
|
||||
robots: {
|
||||
...(prevRobots.session && typeof prevRobots.session === 'object' ? { session: prevRobots.session } : {}),
|
||||
wecom: {
|
||||
enabled: document.getElementById('robot-wecom-enabled')?.checked === true,
|
||||
token: document.getElementById('robot-wecom-token')?.value.trim() || '',
|
||||
@@ -1138,13 +1140,15 @@ async function applySettings() {
|
||||
dingtalk: {
|
||||
enabled: document.getElementById('robot-dingtalk-enabled')?.checked === true,
|
||||
client_id: document.getElementById('robot-dingtalk-client-id')?.value.trim() || '',
|
||||
client_secret: document.getElementById('robot-dingtalk-client-secret')?.value.trim() || ''
|
||||
client_secret: document.getElementById('robot-dingtalk-client-secret')?.value.trim() || '',
|
||||
allow_conversation_id_fallback: !!(prevRobots.dingtalk && prevRobots.dingtalk.allow_conversation_id_fallback)
|
||||
},
|
||||
lark: {
|
||||
enabled: document.getElementById('robot-lark-enabled')?.checked === true,
|
||||
app_id: document.getElementById('robot-lark-app-id')?.value.trim() || '',
|
||||
app_secret: document.getElementById('robot-lark-app-secret')?.value.trim() || '',
|
||||
verify_token: document.getElementById('robot-lark-verify-token')?.value.trim() || ''
|
||||
verify_token: document.getElementById('robot-lark-verify-token')?.value.trim() || '',
|
||||
allow_chat_id_fallback: !!(prevRobots.lark && prevRobots.lark.allow_chat_id_fallback)
|
||||
}
|
||||
},
|
||||
tools: []
|
||||
|
||||
@@ -72,19 +72,27 @@ function syncVulnerabilityFiltersFromLocationHash() {
|
||||
const vid = (params.get('id') || '').trim();
|
||||
const cid = (params.get('conversation_id') || '').trim();
|
||||
const tid = (params.get('task_id') || '').trim();
|
||||
if (!vid && !cid && !tid) {
|
||||
const sev = (params.get('severity') || '').trim();
|
||||
const st = (params.get('status') || '').trim();
|
||||
if (!vid && !cid && !tid && !sev && !st) {
|
||||
return;
|
||||
}
|
||||
|
||||
vulnerabilityFilters.id = '';
|
||||
vulnerabilityFilters.conversation_id = '';
|
||||
vulnerabilityFilters.task_id = '';
|
||||
vulnerabilityFilters.severity = '';
|
||||
vulnerabilityFilters.status = '';
|
||||
const idEl = document.getElementById('vulnerability-id-filter');
|
||||
const convEl = document.getElementById('vulnerability-conversation-filter');
|
||||
const taskEl = document.getElementById('vulnerability-task-filter');
|
||||
const sevEl = document.getElementById('vulnerability-severity-filter');
|
||||
const stEl = document.getElementById('vulnerability-status-filter');
|
||||
if (idEl) idEl.value = '';
|
||||
if (convEl) convEl.value = '';
|
||||
if (taskEl) taskEl.value = '';
|
||||
if (sevEl) sevEl.value = '';
|
||||
if (stEl) stEl.value = '';
|
||||
|
||||
if (vid) {
|
||||
vulnerabilityFilters.id = vid;
|
||||
@@ -98,6 +106,14 @@ function syncVulnerabilityFiltersFromLocationHash() {
|
||||
vulnerabilityFilters.task_id = tid;
|
||||
if (taskEl) taskEl.value = tid;
|
||||
}
|
||||
if (sev) {
|
||||
vulnerabilityFilters.severity = sev;
|
||||
if (sevEl) sevEl.value = sev;
|
||||
}
|
||||
if (st) {
|
||||
vulnerabilityFilters.status = st;
|
||||
if (stEl) stEl.value = st;
|
||||
}
|
||||
vulnerabilityPagination.currentPage = 1;
|
||||
}
|
||||
|
||||
|
||||
+25
-16
@@ -190,10 +190,12 @@
|
||||
<!-- C2 侧栏入口(带子菜单) -->
|
||||
<div class="nav-item nav-item-has-submenu" data-page="c2" id="nav-c2">
|
||||
<div class="nav-item-content" data-title="C2" onclick="window.toggleSubmenu('c2')" data-i18n="nav.c2" data-i18n-attr="data-title" data-i18n-skip-text="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
|
||||
<path d="M2 17l10 5 10-5"></path>
|
||||
<path d="M2 12l10 5 10-5"></path>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
|
||||
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path>
|
||||
<circle cx="12" cy="12" r="2"></circle>
|
||||
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path>
|
||||
<path d="M19.1 4.9C23 8.8 23 15.2 19.1 19"></path>
|
||||
</svg>
|
||||
<span data-i18n="nav.c2">C2</span>
|
||||
<svg class="submenu-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -201,6 +203,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="nav-submenu" id="submenu-c2">
|
||||
<div class="nav-submenu-item" data-page="c2" onclick="switchPage('c2')" data-i18n="nav.c2Manage">C2 管理</div>
|
||||
<div class="nav-submenu-item" data-page="c2-listeners" onclick="switchPage('c2-listeners')" data-i18n="nav.c2Listeners">监听器</div>
|
||||
<div class="nav-submenu-item" data-page="c2-sessions" onclick="switchPage('c2-sessions')" data-i18n="nav.c2Sessions">会话</div>
|
||||
<div class="nav-submenu-item" data-page="c2-tasks" onclick="switchPage('c2-tasks')" data-i18n="nav.c2Tasks">任务</div>
|
||||
@@ -446,42 +449,46 @@
|
||||
</div>
|
||||
</aside>
|
||||
<div class="dashboard-severity-chart">
|
||||
<svg class="dashboard-severity-donut" id="dashboard-severity-donut" viewBox="0 0 480 260" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
|
||||
<svg class="dashboard-severity-donut" id="dashboard-severity-donut" viewBox="0 0 480 260" preserveAspectRatio="xMidYMid meet" role="img" aria-labelledby="dashboard-severity-donut-title">
|
||||
<title id="dashboard-severity-donut-title" data-i18n="dashboard.severityDistribution">漏洞严重程度分布</title>
|
||||
<defs id="dashboard-severity-donut-defs"></defs>
|
||||
<g id="dashboard-severity-donut-track"></g>
|
||||
<g id="dashboard-severity-donut-leaders"></g>
|
||||
<g id="dashboard-severity-donut-segments"></g>
|
||||
<g id="dashboard-severity-donut-hits"></g>
|
||||
<g id="dashboard-severity-donut-labels"></g>
|
||||
</svg>
|
||||
<div class="dashboard-severity-center">
|
||||
<div class="dashboard-severity-center" id="dashboard-severity-center">
|
||||
<div class="dashboard-severity-center-value" id="dashboard-severity-total">0</div>
|
||||
<div class="dashboard-severity-center-label" data-i18n="dashboard.totalVulns">总漏洞数</div>
|
||||
<div class="dashboard-severity-center-label" id="dashboard-severity-center-label" data-i18n="dashboard.totalVulns">总漏洞数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-severity-legend" id="dashboard-vuln-bars">
|
||||
<div class="dashboard-severity-legend-item">
|
||||
<div class="dashboard-severity-legend-item" data-severity="critical" role="button" tabindex="0">
|
||||
<span class="dashboard-severity-legend-dot critical"></span>
|
||||
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityCritical">严重</span>
|
||||
<span class="dashboard-severity-legend-value" id="dashboard-severity-critical">0</span>
|
||||
<span class="dashboard-severity-legend-pct" id="dashboard-severity-critical-pct">0%</span>
|
||||
</div>
|
||||
<div class="dashboard-severity-legend-item">
|
||||
<div class="dashboard-severity-legend-item" data-severity="high" role="button" tabindex="0">
|
||||
<span class="dashboard-severity-legend-dot high"></span>
|
||||
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityHigh">高危</span>
|
||||
<span class="dashboard-severity-legend-value" id="dashboard-severity-high">0</span>
|
||||
<span class="dashboard-severity-legend-pct" id="dashboard-severity-high-pct">0%</span>
|
||||
</div>
|
||||
<div class="dashboard-severity-legend-item">
|
||||
<div class="dashboard-severity-legend-item" data-severity="medium" role="button" tabindex="0">
|
||||
<span class="dashboard-severity-legend-dot medium"></span>
|
||||
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityMedium">中危</span>
|
||||
<span class="dashboard-severity-legend-value" id="dashboard-severity-medium">0</span>
|
||||
<span class="dashboard-severity-legend-pct" id="dashboard-severity-medium-pct">0%</span>
|
||||
</div>
|
||||
<div class="dashboard-severity-legend-item">
|
||||
<div class="dashboard-severity-legend-item" data-severity="low" role="button" tabindex="0">
|
||||
<span class="dashboard-severity-legend-dot low"></span>
|
||||
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityLow">低危</span>
|
||||
<span class="dashboard-severity-legend-value" id="dashboard-severity-low">0</span>
|
||||
<span class="dashboard-severity-legend-pct" id="dashboard-severity-low-pct">0%</span>
|
||||
</div>
|
||||
<div class="dashboard-severity-legend-item">
|
||||
<div class="dashboard-severity-legend-item" data-severity="info" role="button" tabindex="0">
|
||||
<span class="dashboard-severity-legend-dot info"></span>
|
||||
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityInfo">信息</span>
|
||||
<span class="dashboard-severity-legend-value" id="dashboard-severity-info">0</span>
|
||||
@@ -1605,11 +1612,13 @@
|
||||
<div id="c2-main" class="c2-main">
|
||||
<div class="c2-welcome">
|
||||
<div class="c2-welcome-icon">
|
||||
<svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="url(#c2-grad)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="url(#c2-grad)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<defs><linearGradient id="c2-grad" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#00d4ff"/><stop offset="100%" stop-color="#a855f7"/></linearGradient></defs>
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
|
||||
<path d="M2 17l10 5 10-5"></path>
|
||||
<path d="M2 12l10 5 10-5"></path>
|
||||
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
|
||||
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path>
|
||||
<circle cx="12" cy="12" r="2"></circle>
|
||||
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path>
|
||||
<path d="M19.1 4.9C23 8.8 23 15.2 19.1 19"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 data-i18n="c2.welcomeTitle">AI-Native C2 框架</h3>
|
||||
|
||||
Reference in New Issue
Block a user