@gavinc-cn/touchstone-dsh 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +335 -2
- package/README.zh.md +317 -0
- package/agents.py +247 -0
- package/auth.py +86 -0
- package/board.py +4229 -0
- package/builtin_assets.py +808 -0
- package/builtin_prompts/free_style/flow.md +301 -0
- package/builtin_prompts/free_style/load_test.md +114 -0
- package/builtin_prompts/free_style/templates/bug_report_template.md +41 -0
- package/builtin_prompts/free_style/templates/case_template.md +23 -0
- package/builtin_prompts/free_style/templates/index_template.md +41 -0
- package/builtin_prompts/free_style/templates/load_charts_template.md +104 -0
- package/builtin_prompts/free_style/templates/load_index_template.md +5 -0
- package/builtin_prompts/free_style/templates/load_plan_template.md +76 -0
- package/builtin_prompts/free_style/templates/load_scenario_template.md +49 -0
- package/builtin_prompts/free_style/templates/status_template.md +18 -0
- package/chat.py +616 -0
- package/db.py +1407 -0
- package/dsh-plugin/cordis.patch.yml +21 -0
- package/dsh-plugin/install.sh +79 -0
- package/dsh-plugin/lib/agent-driver.js +1888 -0
- package/dsh-plugin/lib/client.bundle.js +397 -0
- package/dsh-plugin/lib/index.js +284 -0
- package/dsh-plugin/scripts/build.mjs +37 -0
- package/dsh-plugin/scripts/prepack-check.mjs +61 -0
- package/dsh-plugin/scripts/select-bundle.mjs +51 -0
- package/dsh-plugin/src/client.js +384 -0
- package/dshdriver.py +702 -0
- package/dshevents.py +438 -0
- package/export_cases.py +160 -0
- package/extensions/dsh-autocommit/asset.json +18 -0
- package/extensions/dsh-autocommit/pkg/lib/index.mjs +361 -0
- package/extensions/dsh-autocommit/pkg/package.json +10 -0
- package/feishu.py +2139 -0
- package/lib.py +245 -0
- package/lifecycle.py +417 -0
- package/loadcase.py +558 -0
- package/loadgen.py +678 -0
- package/localbus.py +91 -0
- package/migrate_p7b.py +191 -0
- package/package.json +74 -4
- package/platcompat.py +272 -0
- package/prompts.py +666 -0
- package/rag.py +553 -0
- package/requirements.txt +9 -0
- package/runner.py +2471 -0
- package/server.py +5337 -0
- package/sessparse.py +638 -0
- package/touchstone.py +512 -0
- package/waitq.py +1319 -0
- package/webui/dist-plugin/assets/index-B0WoP1Qy.css +1 -0
- package/webui/dist-plugin/assets/index-eHpxBfP6.js +473 -0
- package/webui/dist-plugin/index.html +18 -0
- package/worktree.py +293 -0
package/agents.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Touchstone 本机 AI CLI 扫描:供项目配置"智能体路径"下拉选择。
|
|
3
|
+
|
|
4
|
+
扫描顺序:PATH(shutil.which)→ 常见安装目录,找到即止;版本探测 --version 2s 超时。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import glob
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
|
|
14
|
+
# 支持的 agent 清单: (展示名, 可能的可执行名元组)。
|
|
15
|
+
# 2026-10-03(P7b 退场删除,B1):产品入口收敛为 **dsh 插件形态**,kimi / opencode /
|
|
16
|
+
# claude / hermes 四个 CLI 条目不再扫描(对应族已删除);dsh 可执行仍扫——它是
|
|
17
|
+
# 「插件·进程内」条目的 path/version 来源。
|
|
18
|
+
# 2026-10-04(B7):扫描结果只剩「插件·进程内」一条(见 scan_agents),本表的展示名
|
|
19
|
+
# 自此仅作文档用,实际用于定位 dsh 可执行(PATH → 常见安装目录)。
|
|
20
|
+
AGENT_DEFS = (
|
|
21
|
+
("dsh(DeepSeek Harness)", ("dsh",)),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# 退场族的旧虚拟条目前缀(kimi web / opencode serve 驱动,P7b 删除):扫描不再产出,
|
|
25
|
+
# 仅「存量 agent_path 的归族」仍要认出来 —— 归 RETIRED_FAMILY 由起跑处给明确报错。
|
|
26
|
+
LEGACY_WEB_PREFIXES = ("kimi-web:", "opencode-web:")
|
|
27
|
+
|
|
28
|
+
# 退场族标记(P7b,2026-10-03):agent_path 仍是旧族可执行名或旧虚拟前缀时归此族。
|
|
29
|
+
# 存量库里的绑定由 migrate_p7b.py 改写(`dsh-plugin:<dsh 路径>` 或空串);未改写前
|
|
30
|
+
# 起跑处据此返回 RETIRED_MSG,而不是静默按 dsh 跑(dsh 宿主里没有旧会话)或
|
|
31
|
+
# AttributeError(驱动模块已删)。
|
|
32
|
+
RETIRED_FAMILY = "retired"
|
|
33
|
+
RETIRED_MSG = "该智能体族已下线,请在项目设置里改绑 dsh 插件"
|
|
34
|
+
|
|
35
|
+
# dsh 插件驱动前缀(路线 A):项目 agent_path 存 "dsh-plugin:" 表示走 **dsh 宿主进程内**
|
|
36
|
+
# 的 agent 运行时(dsh-plugin/lib/agent-driver.js 持有的会话池),不再 spawn
|
|
37
|
+
# `dsh --profile headless` 子进程。前缀后可跟任意提示串(如 dsh 可执行路径)但平台不使用:
|
|
38
|
+
# 驱动地址经环境变量 TS_AGENT_DRIVER_URL 下发(见 dshdriver.py),不依赖本机路径。
|
|
39
|
+
DSH_PLUGIN_PREFIX = "dsh-plugin:"
|
|
40
|
+
|
|
41
|
+
# 「插件·进程内」条目展示名(前端「智能体」下拉的唯一选项)。P7b B7(2026-10-04)
|
|
42
|
+
# 起由 scan_agents 直接产出为**唯一一条**,裸 dsh CLI 行不再返回(见 scan_agents docstring)。
|
|
43
|
+
PLUGIN_ENTRY_NAME = "dsh(插件·进程内)"
|
|
44
|
+
|
|
45
|
+
# PATH 之外常出现的安装目录(按已知部署习惯)
|
|
46
|
+
EXTRA_DIRS = [
|
|
47
|
+
"/usr/local/bin",
|
|
48
|
+
os.path.expanduser("~/.local/bin"),
|
|
49
|
+
"/bin/versions/node",
|
|
50
|
+
os.path.expanduser("~/.npm-global/bin"),
|
|
51
|
+
]
|
|
52
|
+
# Windows:npm 全局 shim(kimi.cmd / claude.cmd 等)所在目录
|
|
53
|
+
# (shutil.which 走 PATH 也能命中,此目录兜底 PATH 被裁剪的安装方式)
|
|
54
|
+
if os.name == "nt":
|
|
55
|
+
_appdata = os.environ.get("APPDATA")
|
|
56
|
+
if _appdata:
|
|
57
|
+
EXTRA_DIRS.append(os.path.join(_appdata, "npm"))
|
|
58
|
+
# 递归进 versions 下各 node 版本的 bin/
|
|
59
|
+
VERSIONS_BIN_GLOB = "/bin/versions/node"
|
|
60
|
+
# conda/mamba 各虚拟环境的 bin 目录(glob 模式,覆盖常见安装位置;
|
|
61
|
+
# 如 hermes 常被装进某个 conda 环境的 bin/)
|
|
62
|
+
CONDA_ENV_BIN_GLOBS = [
|
|
63
|
+
"/opt/miniconda3/envs/*/bin",
|
|
64
|
+
"/opt/anaconda3/envs/*/bin",
|
|
65
|
+
os.path.expanduser("~/miniconda3/envs/*/bin"),
|
|
66
|
+
os.path.expanduser("~/anaconda3/envs/*/bin"),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _search_extra(name):
|
|
71
|
+
"""在扩展目录中查找 name 可执行文件,返回路径或 None。"""
|
|
72
|
+
candidates = []
|
|
73
|
+
for d in EXTRA_DIRS:
|
|
74
|
+
if not os.path.isdir(d):
|
|
75
|
+
continue
|
|
76
|
+
if d == VERSIONS_BIN_GLOB:
|
|
77
|
+
for ver in os.listdir(d):
|
|
78
|
+
candidates.append(os.path.join(d, ver, "bin", name))
|
|
79
|
+
else:
|
|
80
|
+
candidates.append(os.path.join(d, name))
|
|
81
|
+
for p in candidates:
|
|
82
|
+
if os.path.isfile(p) and os.access(p, os.X_OK):
|
|
83
|
+
return p
|
|
84
|
+
# conda/mamba 虚拟环境 bin:glob 展开各环境目录后逐个匹配
|
|
85
|
+
for pat in CONDA_ENV_BIN_GLOBS:
|
|
86
|
+
for bindir in sorted(glob.glob(pat)):
|
|
87
|
+
p = os.path.join(bindir, name)
|
|
88
|
+
if os.path.isfile(p) and os.access(p, os.X_OK):
|
|
89
|
+
return p
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _probe_version(path):
|
|
94
|
+
"""运行 --version(2s 超时),失败返回空串。
|
|
95
|
+
cwd 用系统临时目录(跨平台;原写死 /tmp 在 Windows 不存在会静默失败)。"""
|
|
96
|
+
try:
|
|
97
|
+
out = subprocess.run([path, "--version"], capture_output=True, text=True,
|
|
98
|
+
encoding="utf-8", errors="replace",
|
|
99
|
+
timeout=2, cwd=tempfile.gettempdir())
|
|
100
|
+
text = (out.stdout or out.stderr).strip().splitlines()
|
|
101
|
+
return text[0][:120] if text else ""
|
|
102
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
103
|
+
return ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def scan_agents():
|
|
107
|
+
"""扫描本机 dsh 可执行,返回**产品入口单条目** [{name, path, version, found}]。
|
|
108
|
+
|
|
109
|
+
P7b B1 起扫描只认 dsh;**B7(2026-10-04)再收敛为只回一条**「dsh(插件·进程内)」
|
|
110
|
+
(`path = dsh-plugin:<dsh 路径>`,进程内会话池:多轮续接/插话/实时事件流)。
|
|
111
|
+
|
|
112
|
+
为什么仍要定位 dsh 可执行:该条目的 path(前缀后的提示串)与 version 都取自它
|
|
113
|
+
(`--version` 探测);但**裸 dsh CLI 行不再返回给前端**——它属退场族
|
|
114
|
+
(`agent_family` 对 basename == "dsh" 判 `retired`,起跑处必报 `RETIRED_MSG`),
|
|
115
|
+
列进「智能体」下拉就是纯误选陷阱(用户 2026-10-04 报告「同一个 dsh 出现两条」)。
|
|
116
|
+
|
|
117
|
+
dsh 未找到时返回同名的 `found=False` 条目(前端显示「dsh(插件·进程内)未找到」),
|
|
118
|
+
保持「下拉里有且只有这一条」的读侧不变量。
|
|
119
|
+
"""
|
|
120
|
+
path = ""
|
|
121
|
+
for _name, bins in AGENT_DEFS:
|
|
122
|
+
for b in bins:
|
|
123
|
+
path = shutil.which(b) or _search_extra(b)
|
|
124
|
+
if path:
|
|
125
|
+
break
|
|
126
|
+
if path:
|
|
127
|
+
break
|
|
128
|
+
return [{"name": PLUGIN_ENTRY_NAME,
|
|
129
|
+
"path": DSH_PLUGIN_PREFIX + path if path else "",
|
|
130
|
+
"version": _probe_version(path) if path else "",
|
|
131
|
+
"found": bool(path)}]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def agent_family(agent_path):
|
|
135
|
+
"""归族:只可能返回 `"dsh_plugin"`(可跑)或 `RETIRED_FAMILY`(退场族,起跑处报错)。
|
|
136
|
+
|
|
137
|
+
- `dsh-plugin:` 前缀 → `dsh_plugin`:产品唯一入口(dsh 宿主进程内 agent 运行时,
|
|
138
|
+
见 dshdriver.py)。前缀判定必须先于 basename——该串不是有效可执行路径;
|
|
139
|
+
- 空 `agent_path`(db 默认 `''`)与未知可执行名 → `dsh_plugin`:2026-10-03 起
|
|
140
|
+
默认族由 kimi 改为 dsh 插件(产品入口收敛);
|
|
141
|
+
- kimi / opencode / claude / hermes / deepseek(dsh CLI) 旧可执行名,以及
|
|
142
|
+
`kimi-web:` / `opencode-web:` 两个旧虚拟前缀 → `RETIRED_FAMILY`(P7b 删除的
|
|
143
|
+
五族,2026-10-03)。存量项目由 `migrate_p7b.py` 改写 agent_path;未改写的
|
|
144
|
+
项目在起跑处得到 `RETIRED_MSG` 明确提示。
|
|
145
|
+
"""
|
|
146
|
+
ap = (agent_path or "").strip()
|
|
147
|
+
if ap.startswith(DSH_PLUGIN_PREFIX):
|
|
148
|
+
return "dsh_plugin"
|
|
149
|
+
if ap.startswith(LEGACY_WEB_PREFIXES):
|
|
150
|
+
return RETIRED_FAMILY # kimi-web: / opencode-web:(旧虚拟条目)
|
|
151
|
+
name = os.path.basename(ap).lower()
|
|
152
|
+
if any(h in name for h in ("kimi", "opencode", "claude", "hermes", "deepseek")):
|
|
153
|
+
return RETIRED_FAMILY # 旧 CLI 族可执行名
|
|
154
|
+
if name == "dsh":
|
|
155
|
+
# dsh CLI(--profile headless)已退场:只保留插件形态(每轮独立会话、无续接)
|
|
156
|
+
return RETIRED_FAMILY
|
|
157
|
+
# 空 agent_path(db 默认 '')与未知可执行名的默认族:本项目已收敛为「产品入口
|
|
158
|
+
# 只有 dsh 插件形态」(2026-10-03 方案 §七 D1/D2),故默认走 dsh_plugin;
|
|
159
|
+
# 要跑其他族须在项目里显式选。此前默认 "kimi",会让未配置项目静默按 kimi 跑。
|
|
160
|
+
return "dsh_plugin"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# 各 agent 族的 skill 目录约定(2026-08-31 从各 CLI 安装产物与本机磁盘实况验证,勿凭记忆改):
|
|
164
|
+
# 族 -> (用户级目录元组, 项目级相对目录元组),SKILL.md 位于各目录的子目录下。
|
|
165
|
+
# 产品只剩 dsh 插件一族:`dsh-skill-filesystem` 实测扫描 `<project>/.agents/skills`
|
|
166
|
+
# (project-agents)与 `~/.agents/skills`(user-agents),另有 dsh 私有根
|
|
167
|
+
# `<project>/.dsh/skills` 与 `~/.dsh/skills`;四者一并扫,与 dsh 侧技能面板看到的一致。
|
|
168
|
+
# `builtin_assets` 的清单校验与本表判 `family`,落点取各元组首个目录。
|
|
169
|
+
SKILL_DIRS_BY_FAMILY = {
|
|
170
|
+
"dsh_plugin": (("~/.dsh/skills", "~/.agents/skills"),
|
|
171
|
+
(".dsh/skills", ".agents/skills")),
|
|
172
|
+
}
|
|
173
|
+
# SKILL.md 相对扫描根的最大目录深度:2 层(分类/技能名,如 <类>/<名>/SKILL.md)
|
|
174
|
+
SKILL_WALK_MAX_DEPTH = 2
|
|
175
|
+
# SKILL.md frontmatter 的 name/description 行解析(简单 key: value,足够通用格式)
|
|
176
|
+
_SKILL_FRONT_RE = re.compile(r"\A---\s*\n(.*?)\n---", re.S)
|
|
177
|
+
_SKILL_NAME_RE = re.compile(r"^name:[ \t]*(.+)$", re.M)
|
|
178
|
+
_SKILL_DESC_RE = re.compile(r"^description:[ \t]*(.+)$", re.M)
|
|
179
|
+
# description 截断长度(避免超长描述撑爆下拉)
|
|
180
|
+
_SKILL_DESC_LIMIT = 200
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _parse_skill_md(path, fallback_name):
|
|
184
|
+
"""解析一个 SKILL.md 的 frontmatter,返回 (name, description)。
|
|
185
|
+
|
|
186
|
+
name 缺省用所在目录名;description 缺省空串(截断到 _SKILL_DESC_LIMIT);
|
|
187
|
+
文件读失败返回 None,调用方跳过该条。
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
191
|
+
head = f.read(4096)
|
|
192
|
+
except OSError:
|
|
193
|
+
return None
|
|
194
|
+
m = _SKILL_FRONT_RE.match(head)
|
|
195
|
+
body = m.group(1) if m else head # 无 frontmatter 时退化为头部全文(容错)
|
|
196
|
+
nm = _SKILL_NAME_RE.search(body)
|
|
197
|
+
dm = _SKILL_DESC_RE.search(body)
|
|
198
|
+
return (nm.group(1).strip() if nm else fallback_name,
|
|
199
|
+
dm.group(1).strip()[:_SKILL_DESC_LIMIT] if dm else "")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _scan_skill_dir(root, source):
|
|
203
|
+
"""扫描一个 skill 根目录(深度受限),返回 [{name, description, source}]。
|
|
204
|
+
|
|
205
|
+
目录不存在/不可读返回空列表;深度按相对根的目录层数限制
|
|
206
|
+
(SKILL.md 最深位于第 SKILL_WALK_MAX_DEPTH 层)。
|
|
207
|
+
"""
|
|
208
|
+
skills = []
|
|
209
|
+
if not root or not os.path.isdir(root):
|
|
210
|
+
return skills
|
|
211
|
+
base_depth = root.rstrip(os.sep).count(os.sep)
|
|
212
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
213
|
+
rel = dirpath.rstrip(os.sep).count(os.sep) - base_depth
|
|
214
|
+
# 只在深度未达上限前继续下钻(SKILL.md 允许出现在 rel <= MAX_DEPTH 层)
|
|
215
|
+
dirnames[:] = [d for d in dirnames if rel < SKILL_WALK_MAX_DEPTH]
|
|
216
|
+
if "SKILL.md" not in filenames:
|
|
217
|
+
continue
|
|
218
|
+
parsed = _parse_skill_md(os.path.join(dirpath, "SKILL.md"),
|
|
219
|
+
os.path.basename(dirpath.rstrip(os.sep)))
|
|
220
|
+
if parsed:
|
|
221
|
+
skills.append({"name": parsed[0], "description": parsed[1],
|
|
222
|
+
"source": source})
|
|
223
|
+
return skills
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def scan_skills(agent_path, project_dir=""):
|
|
227
|
+
"""列出指定 agent CLI 可用的 skill(用户级 + 项目级),供项目能力下拉。
|
|
228
|
+
|
|
229
|
+
目录约定见 SKILL_DIRS_BY_FAMILY(dsh 插件族四目录);同名 skill 项目级覆盖
|
|
230
|
+
用户级;agent_path 为空(未配置)返回空列表,无目录约定的族返回空。
|
|
231
|
+
返回 [{name, description, source}],按 name 排序;纯文件扫描不做缓存。
|
|
232
|
+
"""
|
|
233
|
+
ap = (agent_path or "").strip()
|
|
234
|
+
if not ap:
|
|
235
|
+
return []
|
|
236
|
+
family = agent_family(ap)
|
|
237
|
+
user_dirs, proj_dirs = SKILL_DIRS_BY_FAMILY.get(family, ((), ()))
|
|
238
|
+
found = {}
|
|
239
|
+
for d in user_dirs:
|
|
240
|
+
for s in _scan_skill_dir(os.path.expanduser(d), "user"):
|
|
241
|
+
found.setdefault(s["name"], s)
|
|
242
|
+
pd = (project_dir or "").strip()
|
|
243
|
+
if pd:
|
|
244
|
+
for d in proj_dirs:
|
|
245
|
+
for s in _scan_skill_dir(os.path.join(pd, d), "project"):
|
|
246
|
+
found[s["name"]] = s # 项目级覆盖用户级
|
|
247
|
+
return sorted(found.values(), key=lambda s: s["name"])
|
package/auth.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Touchstone 认证:PBKDF2 密码哈希、session 签发与校验、登录限流。
|
|
3
|
+
|
|
4
|
+
纯标准库。db 层会话表由 server.py 组装调用(本模块不依赖 db.py)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
PBKDF2_ITER = 100_000
|
|
15
|
+
SESSION_TTL = 7 * 24 * 3600 # 7 天
|
|
16
|
+
COOKIE_NAME = "ts_session"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def hash_password(password):
|
|
20
|
+
"""返回 (哈希 hex, 盐 hex)。PBKDF2-HMAC-SHA256,10 万轮,16 字节随机盐。
|
|
21
|
+
|
|
22
|
+
仅用于种子 admin 等存量账号;新写入的密码统一用 md5_password(见下)。
|
|
23
|
+
"""
|
|
24
|
+
salt = os.urandom(16)
|
|
25
|
+
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, PBKDF2_ITER)
|
|
26
|
+
return digest.hex(), salt.hex()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def md5_password(password):
|
|
30
|
+
"""返回 (MD5 hex, 盐空串)。
|
|
31
|
+
|
|
32
|
+
按需求约定:注册用户/后台重置/修改密码统一用 MD5 存储(无盐)。仅 32 位 hex。
|
|
33
|
+
"""
|
|
34
|
+
return hashlib.md5(password.encode("utf-8")).hexdigest(), ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def verify_password(password, pass_hash, salt_hex):
|
|
38
|
+
"""常量时间比对,防时序侧信道。
|
|
39
|
+
|
|
40
|
+
兼容两种存量格式:长度为 32 的 hex 按 MD5 验证(新注册/重置的密码),
|
|
41
|
+
否则按 PBKDF2(种子 admin 等旧账号)。
|
|
42
|
+
密码非字符串(如 JSON null)直接判不匹配,不抛异常。
|
|
43
|
+
"""
|
|
44
|
+
if not isinstance(password, str):
|
|
45
|
+
return False
|
|
46
|
+
if len(pass_hash) == 32:
|
|
47
|
+
return hmac.compare_digest(md5_password(password)[0], pass_hash)
|
|
48
|
+
try:
|
|
49
|
+
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"),
|
|
50
|
+
bytes.fromhex(salt_hex), PBKDF2_ITER)
|
|
51
|
+
except (ValueError, TypeError):
|
|
52
|
+
return False
|
|
53
|
+
return hmac.compare_digest(digest.hex(), pass_hash)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def new_token():
|
|
57
|
+
"""新会话 token(32 字节随机 hex)。"""
|
|
58
|
+
return secrets.token_hex(32)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def session_expiry():
|
|
62
|
+
"""会话过期时间字符串(now + 7 天)。"""
|
|
63
|
+
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() + SESSION_TTL))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LoginLimiter:
|
|
67
|
+
"""每 IP 每分钟最多 10 次登录尝试的滑动窗口限流(内存态即可)。"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, max_attempts=10, window=60):
|
|
70
|
+
self.max_attempts = max_attempts
|
|
71
|
+
self.window = window
|
|
72
|
+
self._lock = threading.Lock()
|
|
73
|
+
self._records = {} # ip -> [尝试时间戳]
|
|
74
|
+
|
|
75
|
+
def allow(self, ip):
|
|
76
|
+
"""返回 True 表示本次尝试放行(尝试计数在 check 后须调用 record)。"""
|
|
77
|
+
now = time.time()
|
|
78
|
+
with self._lock:
|
|
79
|
+
records = [t for t in self._records.get(ip, []) if now - t < self.window]
|
|
80
|
+
self._records[ip] = records
|
|
81
|
+
return len(records) < self.max_attempts
|
|
82
|
+
|
|
83
|
+
def record(self, ip):
|
|
84
|
+
with self._lock:
|
|
85
|
+
self._records.setdefault(ip, []).append(time.time())
|
|
86
|
+
|