远程搭建客户端直连 Hermes(API Server + Caddy + 证书固定)
场景:云端服务器上跑着 Hermes AI 助理,微信通道 token 每天过期、Web 登录会话易失效。本文记录一条持久直连通道的完整搭建方案:本地桌面/Web 客户端通过 HTTPS 直连服务器,聊天(SSE 流式)+ 文档下载,端到端 10 项测试全通过。
一、总体架构
本地电脑 云服务器
┌──────────────────────────┐ ┌──────────────────────────────────┐
│ 客户端(Tkinter桌面版 / │ HTTPS+证书固定│ Caddy :443(TLS 终结) │
│ Web浏览器版) │ ────────────▶ │ ├─ /api/* /v1/* → 127.0.0.1:8642│
│ ├─ 登录(地址+密钥+指纹) │ │ │ = Hermes API Server 平台 │
│ ├─ 聊天(SSE 流式) │ │ └─ /files/* → 127.0.0.1:8643 │
│ └─ 文件(浏览/下载) │ │ = files_server.py(文档服务)│
└──────────────────────────┘ └──────────────────────────────────┘
关键设计决策:
- 服务端零胶水代码:直接用 Hermes 内置的 API Server 平台(OpenAI 兼容接口 + SSE 流式),不自研 agent 接入层。
- 客户端零依赖:纯 Python 标准库(tkinter/ssl/urllib),本地机器不用装任何 pip 包。
二、服务端配置
1. 启用 API Server 平台
它在 gateway 启动时读 .env,改完必须重启 gateway:
# ~/.hermes/.env
API_SERVER_ENABLED=true
API_SERVER_KEY=<openssl rand -hex 24> # 生成随机密钥
API_SERVER_HOST=127.0.0.1 # 只监听回环,公网入口交给 Caddy
API_SERVER_PORT=8642
可用端点(全部 Bearer 认证):
- GET /v1/models
- POST /v1/chat/completions(OpenAI 兼容,stream:true 即 SSE)
- POST /v1/runs + GET /v1/runs/{id}/events(结构化事件流,见下文)
- 会话 API /api/sessions/...、定时任务 API /api/jobs/...
2. 文件服务(files_server.py)
~200 行标准库 http.server 实现:列目录 + 流式下载,Bearer 认证 + Range 断点续传 + 路径穿越防护,systemd 托管(restart=on-failure)。
中文文件名三个必踩坑(都实测踩过):
1. BaseHTTPRequestHandler.self.path 是 latin-1 解码的,要用 unquote_to_bytes(path).decode("utf-8") 还原,否则中文名乱码;
2. Content-Disposition 含非 latin1 字符时必须用 RFC 5987 格式 filename*=UTF-8''<percent-encoded>,否则响应中途抛 UnicodeEncodeError(症状:HTTP 200 但 0 字节);
3. Content-Length + Accept-Ranges + Content-Range 三件套支撑 206 断点续传。
路由要同时接受 /files 和 /files/(Caddy 原样透传客户端的斜杠)。
3. Caddy 路由(复用现有证书,不影响博客)
:443 {
tls /etc/caddy/certs/blog.crt /etc/caddy/certs/blog.key
handle /api/* { reverse_proxy 127.0.0.1:8642 }
handle /v1/* { reverse_proxy 127.0.0.1:8642 }
handle /files/* { reverse_proxy 127.0.0.1:8643 }
handle { reverse_proxy 127.0.0.1:8090 } # 博客等其它流量
}
4. 重启 gateway 的旁路技巧
gateway 是 user 级 systemd 单元(系统级 systemctl status 会显示假 inactive/not-found)。在 gateway 会话内直接重启会被拦截,用 systemd-run 绕出进程树:
# 重启脚本内写:
XDG_RUNTIME_DIR=/run/user/1000 systemctl --user restart hermes-gateway.service
# 执行:
sudo systemd-run --on-active=2 --uid=1000 /path/to/restart.sh
三、客户端安全设计(自签证书环境)
- 证书固定(cert pinning):自签证书没有 CA 链,客户端首次连接时取服务器证书的 SHA-256 指纹(
sslsocket 的getpeercert(binary_form)→ base64),用户在界面确认后存入配置;之后每次请求都校验指纹,指纹不符 → 抛 SecurityError 拒绝连接。这是无 CA 环境下的 MITM 防护正解。 - Bearer 令牌:所有请求带
API_SERVER_KEY,错 key 一律 401 并在登录界面给出指引。 - 本地配置:
~/.hermes_client/config.json,os.chmod(0o600),先写临时文件再 rename 防半写。 - SSL 上下文(客户端):
ssl.create_default_context()后check_hostname=False; verify_mode=CERT_NONE; set_ciphers("DEFAULT:@SECLEVEL=1")——用 pin 代替 CA 校验。
四、SSE 结构化流式(显示思考/工具/token 用量)
只用 /v1/chat/completions 只能拿到最终答案,思考过程和工具执行全被丢弃。正确姿势是走 /v1/runs 事件流:
POST /v1/runs {model, input, conversation_history, session_id}
→ 202 {run_id, status}
GET /v1/runs/{run_id}/events (SSE)
→ reasoning.available {text} 思考过程
→ tool.started {tool, preview} / tool.completed {tool, duration}
→ message.delta {delta} 正文增量
→ run.completed {output, usage:{input_tokens, output_tokens}}
注意点:
- 事件 JSON 是 ensure_ascii 转义的(\ud83d\ude80 形态),客户端必须 json.loads 还原;
- tool 事件字段名是 tool_name 不是 tool;
- chat/completions 路径会过滤下划线开头的工具,拿不到 thinking——必须走 runs;
- 旧服务器无 /v1/runs 时回退 chat/completions SSE + event: hermes.tool.progress 解析;
- SSE 解析按 \n\n 分割、event:/data: 分行处理,要兼容 \r\n\r\n、分块切断、注释行和 [DONE]。
Web 版前端用 fetch + ReadableStream 消费,渲染为:可折叠思考块(<details>)、工具卡片(🛠 名称+preview+耗时)、正文气泡、token 用量脚注。emoji 端到端透传没问题,缺的是 CSS 字体回退——字体栈要加 "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji"。
五、响应式与深色 UI 要点
- 根字号
clamp(13px, 0.95vw + 11px, 16px)随视口缩放;气泡max-width: min(78%, 900px)。 - 断点:<760px 侧栏变固定抽屉(position:fixed + z-index),☰ 按钮切换,选完会话自动收起。
- 面板拖拽改宽:两个 6px
.resizerdiv,mousedown 记起始宽度、mousemove 绑在 document(绑在把手上一拖过边缘就丢事件)、mouseup 后宽度存 localStorage。方向易搞反:侧栏把手在右侧w=startW+dx,文档面板把手在左侧w=startW-dx。 - 长文件名不省略截断:
word-break: break-all; overflow-wrap: anywhere+title属性悬停看全名。 - 大坑:
.view的居中属性(align-items:center)泄漏到主界面会让整页收缩成窄条(新会话不满页、聊几轮出现宽表格后"突然变满页")。主界面必须显式覆盖align-items: stretch; justify-content: flex-start。
六、401 排查流程(按序执行)
实践中服务器几乎总是好的——401 就是客户端发的 key ≠ API_SERVER_KEY:
grep "^API_SERVER_KEY=" ~/.hermes/.env拿权威密钥;ss -tlnp | grep 8642确认监听;- 本机
curl -s -H "Authorization: Bearer $KEY" http://127.0.0.1:8642/v1/models→ 应 200; - 公网
curl -sk ... https://<host>/v1/models→ 应 200; - 负向对照:故意错 key → 应 401(确认 401 语义);
- 结论:服务器正常 → 客户端输入问题。常见原因:key 前后粘了空白/换行、输入法打了全角字符、用了旧轮换的 key。
七、验收清单
- [ ]
curl -sk -H "Authorization: Bearer $KEY" https://<host>/v1/models→ 200 JSON - [ ]
stream:true→ SSEdata:分块流式 - [ ] 错 key → 401;错指纹 → 客户端 SecurityError 拒连
- [ ] 文件:认证列目录、中文名 PDF 下载(
%PDF魔数)、Range → 206 且长度精确 - [ ] 穿越攻击:
/files/..%2Fetc%2Fpasswd→ 400 - [ ] systemd 服务开机自启;gateway 用
systemctl --user管理
八、踩坑补充
- Tkinter 所有网络 IO 必须放 daemon 线程,UI 更新只走队列轮询(
root.after(100, ...))——worker 线程直接碰控件必崩。 - 自定义 SSL 上下文必须挂到
build_opener(HTTPSHandler(context=ctx)),裸urllib.request.urlopen用默认上下文会报自签证书校验失败。 global声明必须在函数内任何读取之前,否则 SyntaxError "used prior to global declaration"。- 登录顺序坑:先构建主界面再开/开会话——会话树控件在主 UI 建立前不存在。