GravityAgent(六):多用户 Web 平台的设计与隔离
IPv6 网络测绘 GravityAgent Web 多用户
多用户 Web 平台的设计与隔离
前面五篇讲的都是”单机能力”:算法能跑、扫描能扫、OS 能识别、Agent 能编排。但要把这些能力开放给一个团队使用,还需要一层多用户 Web 平台。这一篇讲 agent/web_app.py。
为什么需要 Web 平台
命令行 Agent 适合研究者调试,但团队协作会遇到几个问题:
- 多人共用:不同用户需要各自的配置、聊天历史、文件;
- 权限控制:谁能提交扫描、谁能看哪些批次;
- 长任务体验:6Sense 训练几十分钟,命令行可以等,Web 页面不能”转圈”;
- 文件管理:生成的结果需要能下载、能归档、能隔离。
web_app.py 用 Flask 实现了这些能力。
用户体系与审批流
users.db 的表结构
Web 平台用 agent/users.db 保存所有平台数据:
| 表 | 用途 |
|---|---|
users | 用户:user_id/name/password_hash/role/status |
pending_registrations | 注册审批队列(status='pending' → approve/reject) |
user_configs | 每用户 LLM/scanner 配置 JSON |
scan_ownership | 批次归属:batch_id → user_id, priority(用户隔离) |
user_files | 用户上传/生成文件登记(file_path UNIQUE) |
chat_history | 聊天记录(role/content/tool_calls) |
dns_discovery_ownership | DNS 发现任务归属 |
os_identify_ownership | OS 识别任务归属 |
注册审批
注册不是即时的,而是进入审批队列:
用户提交注册
│
▼
pending_registrations (status='pending')
│
▼
管理员审核
├── approve → 写入 users 表,status='active'
└── reject → 标记拒绝
内置账号
系统内置两个账号:
admin/admin123:管理员,每次启动强制对齐;realtime/realtime:专供 Agent 自动提交扫描使用。
_ensure_admin_accounts 在每次启动时强制对齐这两个账号,避免因为数据库被改坏导致系统无法登录。
鉴权装饰器
@login_required # 要求已登录且 status='active'
@admin_required # 要求 role='admin'
_current_user() 从 session 查 users 表,非 active 状态一律视为未登录。
每用户 Agent 实例
这是多用户平台最巧妙的设计。每个用户可以有独立的 LLM 配置(API key、base_url、model、代理),系统需要为每个用户构建独立的 Agent 实例。
配置合并
def _get_cfg(uid: str) -> dict:
# user_configs 中的配置合并 DEFAULT_CONFIG
return {**DEFAULT_CONFIG, **json.loads(row["config_json"])}
DEFAULT_CONFIG 包含 LLM API key、base_url、model、scanner API、scanner DB URL、代理等默认值。
构建 Agent
def _build_agent(uid: str):
am = _configure_agent_runtime(uid)
cfg = _get_cfg(uid)
proxy = cfg["proxy_url"] if cfg.get("enable_proxy") else None
llm_timeout = httpx.Timeout(connect=30.0, read=LLM_HTTP_TIMEOUT_SEC, ...)
hc = httpx.Client(proxy=proxy, timeout=llm_timeout) if proxy else httpx.Client(trust_env=False, timeout=llm_timeout)
llm = ChatOpenAI(model=cfg["llm_model"], temperature=0, api_key=cfg["llm_api_key"],
base_url=cfg["llm_base_url"], http_client=hc, http_async_client=ahc)
tools = [...25 个工具...]
return create_react_agent(model=llm, tools=tools, prompt=am.SYSTEM_PROMPT)
注意 Web 版 Agent 绑定的是 25 个工具,比 CLI 版少一个 generate_os_confidence_report(该工具通过 Web 的 OS API 单独暴露)。
全局变量切换的取舍
_configure_agent_runtime 会覆写 agent 模块的全局变量:
am.SCANNER_API = cfg["scanner_api"]
am.SCANNER_USER = cfg.get("scanner_user", ...)
am.LLM_API_KEY = cfg["llm_api_key"]
...
set_current_user(uid) # 设置用户上下文
这是一个有意的工程取舍:agent.py 原本是为单用户 CLI 写的,用了大量模块级全局变量。Web 版通过”请求前切换全局变量”来复用这些工具函数,而不是重写成面向对象的服务。
代价是:同一进程内多个用户不能真正并发执行 Agent,必须依赖锁和队列串行化。对于这个规模的团队使用场景,这个取舍是合理的。
优先级扫描队列
扫描提交可能很慢(上传大文件到 scanner),不能阻塞 Web 请求。系统实现了一个小顶堆优先级队列:
def _enqueue(uid, role, fpath, count, ...):
token = secrets.token_hex(8)
pri = 100 if role == "admin" else 1
with _pq_lock:
heapq.heappush(_pq, (-pri, time.time(), token, uid, fpath, ...))
_pq_meta[token] = {"status": "queued", ...}
_pq_event.set()
return token
堆的排序键是 (-priority, time, token, ...),所以:
- 优先级高的(admin=100)先出队;
- 同优先级按提交时间(FIFO);
token保证时间相同时顺序稳定。
后台 worker 线程消费队列:
def _scan_worker():
while True:
_pq_event.wait(timeout=5)
_pq_event.clear()
while True:
item = heapq.heappop(_pq)
# 状态:queued → submitting → submitted / failed
batch_id, submit_error = _do_submit(uid, fpath, ...)
if batch_id:
# 写入 scan_ownership,实现批次归属隔离
conn.execute("INSERT OR REPLACE INTO scan_ownership(batch_id,user_id,priority) VALUES(?,?,?)",
(batch_id, uid, pri))
前端通过 api_scan_queue(token) 轮询提交进度,通过 api_scan_batches 列出本用户可访问的批次。
批次归属隔离
scan_ownership 表把 batch_id 和 user_id 绑定。普通用户查询批次列表时,只能看到自己提交的批次;管理员可以看到全部。这解决了”多人共用一个 scanner”时的数据隔离问题。
后台聊天任务
Web 不直接同步执行长耗时对话,而是通过后台 job 方式运行:
def _register_chat_job(uid, message, attached_file_path) -> tuple[str, threading.Event]:
# 生成 token,注册 job,返回 (token, event)
def _run_chat_job(uid, chat_msg, raw_msg, attached_file_path) -> dict:
# 后台线程执行 agent.invoke
def _start_chat_job(token, uid, chat_msg, raw_msg, attached_file_path):
# 启动后台线程
前端拿到 token 后轮询 api_chat_job,这样 6Sense 训练、候选生成、在线扫描、OS 识别等长任务不会阻塞 HTTP 请求线程。
细分等待文案
后端根据用户意图推断 pending_label:
def _infer_chat_pending_label(raw_msg, attached_file_path) -> str:
if _is_file_prediction_request(raw_msg, attached_file_path):
return "地址生成预测任务执行中"
if any(k in text for k in ("预测", "候选", "生成地址", ...)):
return "地址生成预测任务执行中"
if any(k in text for k in ("模式挖掘", "挖掘模式", "规律", ...)):
return "模式挖掘任务执行中"
if any(k in text for k in ("提交扫描", "开始扫描", ...)):
return "批次扫描提交任务执行中"
if any(k in text for k in ("扫描进度", "批次状态", ...)):
return "扫描结果查询任务执行中"
return "后台任务执行中"
前端轮询时同时展示”已等待 xx 秒”。这项改造直接解决了长任务对话看起来”像卡住了”的问题。
防幻觉的后端兜底
这是 Web 层最有价值的一段代码。它针对一个具体风险:模型没调工具,却声称成功。
识别附件预测请求
def _is_file_prediction_request(raw_msg, attached_file_path) -> bool:
if not attached_file_path:
return False
text = (raw_msg or "").strip().lower()
if not text:
return True # 只传文件不写文字,默认视为预测请求
keywords = ("预测", "生成", "候选", "predict", "candidate",
"6sense", "6graph", "6forest", "entropy")
return any(k in text for k in keywords)
推断算法
def _infer_file_prediction_algorithm(raw_msg) -> str:
if "6sense" in text: return "6sense"
if "6forest" in text: return "6forest"
if "entropy" in text: return "entropy_ip"
if "6graph" in text: return "6graph"
return "6graph" # 默认 6Graph
直接执行
def _fallback_predict_from_file(uid, raw_msg, attached_file_path):
am = _configure_agent_runtime(uid)
result = am.predict_candidates_from_file.invoke({
"file_path": attached_file_path,
"algorithm": _infer_file_prediction_algorithm(raw_msg),
"budget": 5000,
"seed_limit": 0,
"prefix_filter": _infer_prefix_filter(raw_msg),
"save_file": True,
})
return (
"检测到模型未实际调用预测工具,已由后端直接执行附件预测。\n\n" + result,
["predict_candidates_from_file"],
)
注意返回文案里明确写了”检测到模型未实际调用预测工具”——这是诚实的可观测性,而不是掩盖问题。
文件归档
当 Agent 回复中包含绝对路径时,Web 会尝试归档:
def _organize_reply_file_links(uid, reply) -> str:
paths = _extract_file_paths(reply)
ready = _wait_until_files_ready(paths, timeout_sec)
for p in ready:
dst = ... # download/<uid>/<feature>/
if not _path_under_root(dst, DOWNLOAD_DIR):
continue
# 复制并替换回复中的路径
归档逻辑有三个关键点:
- 等待文件真正落盘(
_wait_until_files_ready),避免竞态; - 路径必须在 DOWNLOAD_DIR 下(
_path_under_root),防止越权写入; - 失败时显示”未归档文件(<文件名>)”,而不是假装成功。
用户隔离存储
所有生成结果按用户隔离:
download/<uid>/
├── os_identify/ # OS 识别报告(JSON/Markdown)
├── candidates/ # 候选地址生成结果
├── patterns/ # 模式挖掘结果
├── alive_ips/ # 存活地址导出
├── batch_results/ # 批次扫描结果
└── service_probe/ # 服务探测结果
上传目录同样隔离:
upload/<uid>/
路径由 runtime_paths.py 统一生成,用户上下文通过 set_current_user(uid) 设置。
路径穿越防护
文件下载接口必须防止目录穿越攻击。核心是 _path_under_root:
def _path_under_root(path: Path, root: Path) -> bool:
try:
p = path.resolve()
r = root.resolve()
except Exception:
return False
return p == r or str(p).startswith(str(r) + os.sep)
先 resolve() 展开 .. 和符号链接,再判断是否真的在根目录下。注意结尾的 os.sep——如果没有它,/download/evil 会被误判为在 /download 下。
会话安全
app.config.update(
SESSION_COOKIE_HTTPONLY=True, # JS 无法读取 cookie
SESSION_COOKIE_SAMESITE="Lax", # 缓解 CSRF
PERMANENT_SESSION_LIFETIME=timedelta(days=7),
)
secret_key 持久化到 agent/.secret_key 文件,这样服务重启后 session 不会失效:
_SK_FILE = AGENT_DIR / ".secret_key"
if _SK_FILE.exists():
app.secret_key = _SK_FILE.read_text().strip()
else:
_sk = secrets.token_hex(32)
_SK_FILE.write_text(_sk)
app.secret_key = _sk
路由分组
Web 平台有 40 多个路由,按功能分组:
| 分组 | 路由 |
|---|---|
| 鉴权 | /api/auth/login logout me change_password、/api/register |
| 管理 | /api/admin/pending approve reject users users/<uid>/status |
| 配置 | /api/config(get/set) |
| 聊天 | /api/chat chat/job chat/reset chat/history、/api/chat/upload |
| 扫描 | /api/scan/upload expand_cidr_csv queue status batches |
| DNS | /api/dns/discovery/start runs run results |
| OS 识别 | /api/os/identify/start from_batch runs run run_status results |
| 文件 | /api/files、/api/download、/api/batch/download |
| 其他 | /api/scanner/health、/api/queue/list |
独立数据可视化 WebUI
除了主平台,还有一个独立的只读数据控制台 dbui/app.py,用于:
- 自动发现仓库内 SQLite 数据库;
- 连接 gravity-scanner PostgreSQL 主库;
- 统一浏览表、字段、样本数据和分布;
- 执行只读 SQL 查询。
它和主平台完全分离,只读、无鉴权复杂度,适合运维排查数据。
小结
多用户 Web 平台的设计要点:
| 设计 | 解决的问题 |
|---|---|
| 注册审批 + 角色 | 控制谁能用 |
| 每用户配置 | 各自的 LLM 和 scanner 配置 |
| 全局变量切换 | 复用 CLI 工具函数(有并发取舍) |
| 优先级堆队列 | admin 优先,提交不阻塞 |
| scan_ownership | 批次归属隔离 |
| 后台 job + 文案 | 长任务可观测 |
| 后端兜底 | 防模型幻觉 |
| 用户隔离目录 | 文件互不可见 |
_path_under_root | 防目录穿越 |
| 持久化 secret_key | 重启不掉登录 |
下一篇是系列的最后一篇,我们来复盘这个项目在工程化过程中踩过的真实坑——模块冲突、除零 bug、WAL 落库、参数错位,以及它们各自是怎么被定位和修复的。
订阅本站
通过 RSS 或邮箱,第一时间收到新文章。