7.8「跨主机 Agent 发现」原计划(Gateway + Registry 拆分、etcd/Consul 注册)
取消,改为验证现有协议已经够用。验证过程暴露两个真实缺陷,一并修掉。
## 为什么不做注册中心
它要解决「Gateway 怎么找到 Agent」,而这个问题在本架构里不存在:
连接方向是单向的 —— Agent 主动连 Gateway,Gateway 从不外呼。
远端 Agent 只需要一个公网 URL 加一把密钥,被叫方自己会打进来。
注册中心要解决的「被叫方在哪」根本没出现过。
同一个理由此前已经决定了平台会话同步走插件上报而不是 Gateway 拉取。
## 验证方式:一个纯标准库脚本
`deploy/remote-agent-demo.py` 在另一台主机(192.168.2.106)上跑,
不装 AgentMail 的任何代码。注册 / 心跳(带模型目录)/ SSE 长连 /
收件箱 / 标记已读 / 发信全通,Gateway 侧 status=online 且 last_seen 随心跳推进。
完整一轮往返跑通:admin 发给 remotebot@/tmp/remotebot-ws,脚本回信入库。
「协议层面已支持」的含义就是这个:跨主机不需要新组件,只需要三个环境变量。
## 缺陷一:SSE 只推连上之后的事件,没人补拉积压
写那个脚本时第一版只挂了 SSE,启动前发的邮件永远不会被处理。
查了才发现**两个正式插件也有这个洞** —— 原以为它们做了补拉,实际没有。
后果比明确的失败更难排查:邮件躺在收件箱里,而发件人以为 Agent 收到了。
新增共用模块 `lib/catchup.js`,两插件在首个成功心跳后补投一次。五条约束
都对应一种具体的坏行为:
- 只在**首个**心跳后补 —— 每轮都补会把「模型正在处理中、尚未标已读」的
邮件重复投递
- 串行、一次最多 5 封 —— 每封都要起一轮模型,并发放出去等于对上游打 N 个
并发请求,且最后几封要等前面全部跑完
- 与 SSE 共用 deliveredMails 去重 —— 心跳与 SSE 建连之间有个窗口,
那期间到的邮件两条路都会到
- 按时间**正序**投(收件箱倒序返回)—— 倒着塞进去同一会话的上下文是乱的
- permission 类不补投 —— 原来的工具调用早随进程没了,没有可恢复的上下文
端到端两平台各验一次:停插件 → 发信 → 启插件 → 日志「补投 1 封离线期间的
邮件」→ 回信入库;随后在线再发一封确认只回一次。
## 缺陷二:400 只说 "Invalid JSON",不说是哪个字段
脚本把 `workspaces` 传成字符串数组(它要 `[{name, path}]`),
得到的只是一句固定文案,只能靠翻服务端结构体才能发现。
两个官方插件都传 `workspaces: []`,所以这个洞一直没暴露;
第三方客户端没有「翻服务端源码」这个条件。
新增 `handler.DecodeBody`,22 处 `Decode` + 固定文案的调用点全部换过去:
{"error": "字段 \"workspaces\" 类型不对:期望 object,收到 string"}
{"error": "JSON 语法错误(第 8 字节处)"}
{"error": "请求体为空"}
刻意不回显 encoding/json 的原文 —— 它带 Go 类型名(models.Workspace),
那是本侧的实现细节,不该出现在公开 API 的响应里。期望类型用 JSON 的说法。
截断的 JSON 走 io.ErrUnexpectedEOF 而不是 json.SyntaxError,单独一条分支,
否则会落到笼统的兜底文案里(写测试时才发现)。
## 验证
- Go:13 个新测试(decode_test.go 含「不得泄漏 Go 类型名」断言)
- 插件:两侧各 10 个补投测试,共 200 个
- 共用模块同源校验通过(catchup 已纳入 check-shared-libs.sh)
- 生产已部署
409 lines
11 KiB
Go
409 lines
11 KiB
Go
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/agentmail/gateway/internal/middleware"
|
|
"github.com/agentmail/gateway/internal/models"
|
|
"github.com/agentmail/gateway/internal/repo"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// ---------- 登录 / 登出 / 自身信息 ----------
|
|
|
|
type loginRequest struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type userOut struct {
|
|
UserID string `json:"user_id"`
|
|
Username string `json:"username"`
|
|
DisplayName string `json:"display_name"`
|
|
Role string `json:"role"`
|
|
Status string `json:"status"`
|
|
AllowedAgents []string `json:"allowed_agents"`
|
|
AllowedPaths []string `json:"allowed_paths"`
|
|
LastLogin string `json:"last_login,omitempty"`
|
|
CreatedAt string `json:"created_at,omitempty"`
|
|
}
|
|
|
|
func toUserOut(u *models.User) userOut {
|
|
o := userOut{
|
|
UserID: u.ID.String(),
|
|
Username: u.Username,
|
|
DisplayName: u.DisplayName,
|
|
Role: u.Role,
|
|
Status: u.Status,
|
|
AllowedAgents: emptySlice(u.AllowedAgents),
|
|
AllowedPaths: emptySlice(u.AllowedPaths),
|
|
CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
}
|
|
if u.LastLogin != nil {
|
|
o.LastLogin = u.LastLogin.Format("2006-01-02 15:04:05")
|
|
}
|
|
return o
|
|
}
|
|
|
|
// ---------- 首次初始化 ----------
|
|
|
|
// GET /api/v1/setup/status —— 公开:前端据此判断是否展示初始化向导
|
|
func SetupStatus(w http.ResponseWriter, r *http.Request) {
|
|
needs, err := repo.NeedsSetup(r.Context())
|
|
if err != nil {
|
|
Error(w, http.StatusInternalServerError, "Failed to check setup status")
|
|
return
|
|
}
|
|
JSON(w, http.StatusOK, map[string]bool{"needs_setup": needs})
|
|
}
|
|
|
|
type setupRequest struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
DisplayName string `json:"display_name"`
|
|
}
|
|
|
|
// POST /api/v1/setup/admin —— 公开,但仅在系统无任何用户时可用
|
|
func SetupAdmin(w http.ResponseWriter, r *http.Request) {
|
|
var req setupRequest
|
|
if !DecodeBody(w, r, &req) {
|
|
return
|
|
}
|
|
if len(req.Password) < 8 {
|
|
Error(w, http.StatusBadRequest, "密码自少 8 位")
|
|
return
|
|
}
|
|
|
|
u, err := repo.SetupFirstAdmin(r.Context(), req.Username, req.Password, req.DisplayName)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, repo.ErrAlreadySetup):
|
|
Error(w, http.StatusConflict, "系统已初始化,请直接登录")
|
|
case errors.Is(err, repo.ErrInvalidUsername):
|
|
Error(w, http.StatusBadRequest, "用户名只能是 2-64 位的小写字母、数字、点、下划线、连字符,且不能为 human")
|
|
case errors.Is(err, repo.ErrNameTaken):
|
|
Error(w, http.StatusConflict, "该名称已被 Agent 占用")
|
|
default:
|
|
Error(w, http.StatusInternalServerError, "初始化失败")
|
|
}
|
|
return
|
|
}
|
|
|
|
// 初始化后直接登录
|
|
token, expires, err := repo.CreateUserSession(r.Context(), u.ID, r.UserAgent())
|
|
if err == nil {
|
|
maxAge := int(time.Until(expires).Seconds())
|
|
if maxAge < 0 {
|
|
maxAge = 0
|
|
}
|
|
middleware.SetSessionCookie(w, token, maxAge)
|
|
}
|
|
|
|
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
|
|
}
|
|
|
|
// POST /api/v1/auth/login
|
|
func Login(w http.ResponseWriter, r *http.Request) {
|
|
var req loginRequest
|
|
if !DecodeBody(w, r, &req) {
|
|
return
|
|
}
|
|
name := strings.ToLower(strings.TrimSpace(req.Username))
|
|
if name == "" || req.Password == "" {
|
|
Error(w, http.StatusBadRequest, "Missing username or password")
|
|
return
|
|
}
|
|
|
|
if locked, remain := limiter.Locked(r.Context(), name); locked {
|
|
JSON(w, http.StatusTooManyRequests, map[string]interface{}{
|
|
"error": "尝试过于频繁,请稍后再试",
|
|
"retry_after": remain,
|
|
})
|
|
return
|
|
}
|
|
|
|
u, err := repo.Authenticate(r.Context(), name, req.Password)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, repo.ErrBadCredentials):
|
|
limiter.Fail(r.Context(), name)
|
|
Error(w, http.StatusUnauthorized, "用户名或密码错误")
|
|
case errors.Is(err, repo.ErrUserDisabled):
|
|
Error(w, http.StatusForbidden, "账号已被禁用")
|
|
default:
|
|
Error(w, http.StatusInternalServerError, "登录失败")
|
|
}
|
|
return
|
|
}
|
|
limiter.Reset(r.Context(), name)
|
|
|
|
token, expires, err := repo.CreateUserSession(r.Context(), u.ID, r.UserAgent())
|
|
if err != nil {
|
|
Error(w, http.StatusInternalServerError, "无法创建会话")
|
|
return
|
|
}
|
|
maxAge := int(time.Until(expires).Seconds())
|
|
if maxAge < 0 {
|
|
maxAge = 0
|
|
}
|
|
middleware.SetSessionCookie(w, token, maxAge)
|
|
|
|
JSON(w, http.StatusOK, map[string]interface{}{
|
|
"user": toUserOut(u),
|
|
})
|
|
}
|
|
|
|
// POST /api/v1/auth/logout
|
|
func Logout(w http.ResponseWriter, r *http.Request) {
|
|
if token := middleware.SessionToken(r); token != "" {
|
|
_ = repo.DeleteUserSession(r.Context(), token)
|
|
}
|
|
middleware.ClearSessionCookie(w)
|
|
JSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
|
|
}
|
|
|
|
// GET /api/v1/auth/me
|
|
func Me(w http.ResponseWriter, r *http.Request) {
|
|
u := middleware.GetUser(r)
|
|
if u == nil {
|
|
Error(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
|
|
}
|
|
|
|
type changePasswordRequest struct {
|
|
OldPassword string `json:"old_password"`
|
|
NewPassword string `json:"new_password"`
|
|
}
|
|
|
|
// POST /api/v1/auth/password
|
|
func ChangePassword(w http.ResponseWriter, r *http.Request) {
|
|
u := middleware.GetUser(r)
|
|
if u == nil {
|
|
Error(w, http.StatusUnauthorized, "not authenticated")
|
|
return
|
|
}
|
|
|
|
var req changePasswordRequest
|
|
if !DecodeBody(w, r, &req) {
|
|
return
|
|
}
|
|
if len(req.NewPassword) < 8 {
|
|
Error(w, http.StatusBadRequest, "新密码至少 8 位")
|
|
return
|
|
}
|
|
if _, err := repo.Authenticate(r.Context(), u.Username, req.OldPassword); err != nil {
|
|
Error(w, http.StatusUnauthorized, "原密码错误")
|
|
return
|
|
}
|
|
if err := repo.SetPassword(r.Context(), u.ID, req.NewPassword); err != nil {
|
|
Error(w, http.StatusInternalServerError, "修改密码失败")
|
|
return
|
|
}
|
|
middleware.ClearSessionCookie(w)
|
|
JSON(w, http.StatusOK, map[string]string{"status": "password_changed"})
|
|
}
|
|
|
|
// ---------- 管理员:用户管理 ----------
|
|
|
|
// GET /api/v1/admin/users
|
|
func AdminListUsers(w http.ResponseWriter, r *http.Request) {
|
|
users, err := repo.ListUsers(r.Context())
|
|
if err != nil {
|
|
Error(w, http.StatusInternalServerError, "Failed to list users")
|
|
return
|
|
}
|
|
out := make([]userOut, 0, len(users))
|
|
for i := range users {
|
|
out = append(out, toUserOut(&users[i]))
|
|
}
|
|
JSON(w, http.StatusOK, map[string]interface{}{"users": out})
|
|
}
|
|
|
|
type createUserRequest struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
DisplayName string `json:"display_name"`
|
|
Role string `json:"role"`
|
|
AllowedAgents []string `json:"allowed_agents"`
|
|
AllowedPaths []string `json:"allowed_paths"`
|
|
}
|
|
|
|
// POST /api/v1/admin/users
|
|
func AdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
|
var req createUserRequest
|
|
if !DecodeBody(w, r, &req) {
|
|
return
|
|
}
|
|
if len(req.Password) < 8 {
|
|
Error(w, http.StatusBadRequest, "密码至少 8 位")
|
|
return
|
|
}
|
|
|
|
u, err := repo.CreateUser(r.Context(), req.Username, req.Password, req.DisplayName, req.Role,
|
|
req.AllowedAgents, req.AllowedPaths)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, repo.ErrNameTaken):
|
|
Error(w, http.StatusConflict, "该名称已被用户或 Agent 占用")
|
|
case errors.Is(err, repo.ErrInvalidUsername):
|
|
Error(w, http.StatusBadRequest, "用户名只能是 2-64 位的小写字母、数字、点、下划线、连字符,且不能为 human")
|
|
default:
|
|
Error(w, http.StatusInternalServerError, "创建用户失败")
|
|
}
|
|
return
|
|
}
|
|
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
|
|
}
|
|
|
|
type updateUserRequest struct {
|
|
DisplayName *string `json:"display_name"`
|
|
Role *string `json:"role"`
|
|
Status *string `json:"status"`
|
|
AllowedAgents *[]string `json:"allowed_agents"`
|
|
AllowedPaths *[]string `json:"allowed_paths"`
|
|
}
|
|
|
|
// PUT /api/v1/admin/users/{id}
|
|
func AdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := pathUUID(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var req updateUserRequest
|
|
if !DecodeBody(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
// 不允许把最后一个管理员降级或禁用
|
|
if err := guardLastAdmin(r, id, req.Role, req.Status); err != nil {
|
|
Error(w, http.StatusConflict, err.Error())
|
|
return
|
|
}
|
|
|
|
u, err := repo.UpdateUser(r.Context(), id, repo.UserUpdate{
|
|
DisplayName: req.DisplayName,
|
|
Role: req.Role,
|
|
Status: req.Status,
|
|
AllowedAgents: req.AllowedAgents,
|
|
AllowedPaths: req.AllowedPaths,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, repo.ErrUserNotFound) {
|
|
Error(w, http.StatusNotFound, "用户不存在")
|
|
return
|
|
}
|
|
Error(w, http.StatusInternalServerError, "更新用户失败")
|
|
return
|
|
}
|
|
JSON(w, http.StatusOK, map[string]interface{}{"user": toUserOut(u)})
|
|
}
|
|
|
|
// GET /api/v1/admin/scopes —— 可授权的 Agent 与目录候选
|
|
func AdminListScopes(w http.ResponseWriter, r *http.Request) {
|
|
agents, err := repo.ListAgents(r.Context(), "")
|
|
if err != nil {
|
|
Error(w, http.StatusInternalServerError, "Failed to list agents")
|
|
return
|
|
}
|
|
names := make([]string, 0, len(agents))
|
|
for _, a := range agents {
|
|
names = append(names, a.Name)
|
|
}
|
|
paths, _ := repo.AllWorkspaceNames(r.Context())
|
|
|
|
JSON(w, http.StatusOK, map[string]interface{}{
|
|
"agents": emptySlice(names),
|
|
"paths": emptySlice(paths),
|
|
})
|
|
}
|
|
|
|
// DELETE /api/v1/admin/users/{id} —— 禁用而非物理删除,保留邮件历史
|
|
func AdminDisableUser(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := pathUUID(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
disabled := "disabled"
|
|
if err := guardLastAdmin(r, id, nil, &disabled); err != nil {
|
|
Error(w, http.StatusConflict, err.Error())
|
|
return
|
|
}
|
|
if err := repo.DisableUser(r.Context(), id); err != nil {
|
|
if errors.Is(err, repo.ErrUserNotFound) {
|
|
Error(w, http.StatusNotFound, "用户不存在")
|
|
return
|
|
}
|
|
Error(w, http.StatusInternalServerError, "禁用用户失败")
|
|
return
|
|
}
|
|
JSON(w, http.StatusOK, map[string]string{"status": "disabled"})
|
|
}
|
|
|
|
type resetPasswordRequest struct {
|
|
NewPassword string `json:"new_password"`
|
|
}
|
|
|
|
// POST /api/v1/admin/users/{id}/reset
|
|
func AdminResetPassword(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := pathUUID(w, r, "id")
|
|
if !ok {
|
|
return
|
|
}
|
|
var req resetPasswordRequest
|
|
if !DecodeBody(w, r, &req) {
|
|
return
|
|
}
|
|
if len(req.NewPassword) < 8 {
|
|
Error(w, http.StatusBadRequest, "密码至少 8 位")
|
|
return
|
|
}
|
|
if err := repo.SetPassword(r.Context(), id, req.NewPassword); err != nil {
|
|
if errors.Is(err, repo.ErrUserNotFound) {
|
|
Error(w, http.StatusNotFound, "用户不存在")
|
|
return
|
|
}
|
|
Error(w, http.StatusInternalServerError, "重置密码失败")
|
|
return
|
|
}
|
|
JSON(w, http.StatusOK, map[string]string{"status": "password_reset"})
|
|
}
|
|
|
|
// ---------- 辅助 ----------
|
|
|
|
func pathUUID(w http.ResponseWriter, r *http.Request, key string) (uuid.UUID, bool) {
|
|
id, err := uuid.Parse(chi.URLParam(r, key))
|
|
if err != nil {
|
|
Error(w, http.StatusBadRequest, "Invalid "+key)
|
|
return uuid.Nil, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// guardLastAdmin 阻止把系统里最后一个可用管理员降级或禁用
|
|
func guardLastAdmin(r *http.Request, id uuid.UUID, role, status *string) error {
|
|
demoting := role != nil && *role != "admin"
|
|
disabling := status != nil && *status != "active"
|
|
if !demoting && !disabling {
|
|
return nil
|
|
}
|
|
|
|
target, err := repo.GetUserByID(r.Context(), id)
|
|
if err != nil || !target.IsAdmin() || target.Status != "active" {
|
|
return nil
|
|
}
|
|
n, err := repo.CountAdmins(r.Context())
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if n <= 1 {
|
|
return errors.New("系统至少需要保留一个可用管理员")
|
|
}
|
|
return nil
|
|
}
|