diff --git a/ARCH_V1_USER_NETWORK.md b/ARCH_V1_USER_NETWORK.md new file mode 100644 index 0000000..22d4c54 --- /dev/null +++ b/ARCH_V1_USER_NETWORK.md @@ -0,0 +1,220 @@ +# INP2P 最终方案 v1(用户网络模型) + +> 状态:已冻结(按 2026-03-03 讨论结论) +> 目标:稳定优先,先完成认证/网络模型收敛,再做增量体验优化。 + +--- + +## 0. 冻结决策(已确认) + +1. 一账号=一网络(1:1) +2. Hub 离线自动回 Mesh +3. 改 IP 后:目标节点重连 + 同网广播更新 +4. API Key:保留后台能力,前台不突出 +5. 暂不上 Refresh Token +6. Node 唯一标识采用 UUID(稳定优先) + +--- + +## 1. 核心对象模型 + +### 1.1 Account(账号) +- 控制台登录主体(人) +- 字段: + - `account_id` (int64 PK) + - `username` (unique) + - `password_hash` + - `status` (1/0) + - `created_at` + +### 1.2 Network(用户网络) +- 每个账号唯一绑定一个网络 +- 字段: + - `network_id` (int64 PK) + - `account_id` (unique FK -> accounts) + - `cidr` (e.g. `10.0.1.0/24`) + - `mode` (`mesh`|`hub`) + - `hub_node_id` (nullable, FK -> nodes.node_id) + - `fallback_to_mesh` (bool, default true) + - `status` (1/0) + - `created_at`, `updated_at` + +### 1.3 Node(设备) +- 设备唯一标识不变;展示名可变 +- 字段: + - `node_id` (UUID, PK) ✅ 后端唯一键 + - `network_id` (FK) + - `hostname` (设备上报) + - `alias` (用户自定义昵称,可空) + - `virtual_ip` (network cidr 内) + - `node_secret_hash` + - `relay_enabled` (bool) + - `online` (bool) + - `last_seen` + - `created_at`, `updated_at` + +### 1.4 凭证对象 +- `session_tokens`(控制台会话,短期) +- `enroll_tokens`(一次性/短期入网码) +- `api_keys`(后台自动化,非控制台主要路径) + +--- + +## 2. 凭证与认证边界(彻底拆分) + +### 2.1 会话 token(控制台) +- 登录成功后返回 `session_token` +- 内含:`account_id`, `network_id`, `role=owner`, `exp` +- 用于所有控制台 API + +### 2.2 enroll token(设备入网) +- 控制台生成,一次性,短时有效(默认 10 分钟) +- 设备携带 `hostname` + enroll token 兑换 `node_id + node_secret + virtual_ip` + +### 2.3 node_secret(设备长期) +- 设备 WS 登录凭证 +- 仅设备连接信令使用,不用于控制台 + +### 2.4 API Key(后台能力) +- 用于自动化脚本/平台对接 +- 不作为控制台默认登录路径 +- 前台不突出,仅高级设置页可见 + +--- + +## 3. 网络与 IPAM 规则 + +### 3.1 子网分配 +- 池:`10.0.1.0/24` ~ `10.0.254.0/24` +- 保留:`10.0.0.0/24`、`10.0.255.0/24` +- 账号注册时分配第一个可用网段 + +### 3.2 地址分配 +- 每网络内自动分配:从 `.2` 开始 +- 保留 `.0/.255/.1` +- 禁止重复占用 + +### 3.3 手动改 IP +- 必须在网络 CIDR 内 +- 不可与现有节点冲突 +- 成功后触发: + 1) 目标节点收到 `ip_changed` 推送并重连 + 2) 服务端广播 `peer_ip_changed` 给同网在线节点 + +--- + +## 4. 拓扑模式状态机 + +### 4.1 Mesh(默认) +- 同网节点按策略尝试直连(UDP/TCP) +- 失败后按中继策略兜底 + +### 4.2 Hub +- 条件:`hub_node_id` 必填,且必须“在线 + 同网络 + relay_enabled=true” +- 数据面按 hub 转发 + +### 4.3 Hub 离线回退 +- 监测阈值:15~30 秒无心跳 +- 自动回 `mesh`(记录审计日志) +- Hub 恢复后暂不自动切回(v1 简化) + +--- + +## 5. API 草图(v1) + +## 5.1 认证 +- `POST /api/v1/auth/register` + - req: `{username,password}` + - rsp: `{account_id, network:{cidr,mode}}` +- `POST /api/v1/auth/login` + - req: `{username,password}` + - rsp: `{session_token, expires_in, network}` +- `POST /api/v1/auth/logout` + +## 5.2 网络配置 +- `GET /api/v1/network` +- `POST /api/v1/network/mode` + - req: `{mode:"mesh"|"hub", hub_node_id?}` +- `POST /api/v1/network/hub` + - req: `{hub_node_id}`(含在线校验) + +## 5.3 节点管理 +- `GET /api/v1/nodes` +- `POST /api/v1/nodes/{node_id}/alias` + - req: `{alias}` +- `POST /api/v1/nodes/{node_id}/ip` + - req: `{virtual_ip}` +- `POST /api/v1/nodes/{node_id}/kick` + +## 5.4 入网与设备凭证 +- `POST /api/v1/enroll/create` +- `POST /api/v1/enroll/revoke/{id}` +- `POST /api/v1/enroll/consume` + - req: `{code, hostname}` + - rsp: `{node_id, node_secret, virtual_ip, network_cidr}` + +## 5.5 自动化(低显著) +- `GET/POST /api/v1/settings/api-keys` + +--- + +## 6. 前端展示规范 + +- 节点主显示名:`alias || hostname` +- 次级显示:`node_id`(短) +- 明确区分: + - "控制台会话"(session) + - "设备凭证"(node_secret) + - "自动化凭证"(api key) +- API Key 页面放到“高级设置”折叠区,不作为主流程入口 + +--- + +## 7. 迁移计划(分阶段) + +### Phase A(认证收敛,必做) +1. `auth/login` 改为返回 session token,不再返回 API key +2. 中间件基于 session 解出 `account_id/network_id` +3. 控制台 API 全量改用 session 鉴权 + +### Phase B(节点模型升级) +1. 引入 `node_id(UUID)` +2. 增加 `alias` 字段 +3. 节点唯一性改以 `node_id` 为准(非 hostname) + +### Phase C(IPAM + 拓扑闭环) +1. 改 IP 重连与广播机制 +2. Hub 在线校验 + 自动回 mesh +3. 观测与审计日志完善 + +--- + +## 8. 验收标准(v1) + +1. 用户登录后拿到 session token(不是 API key) +2. 同一网络外的数据不可见 +3. 改 IP 后目标节点重连,其他节点收到变更通知 +4. hub 下线后自动回 mesh +5. node_id 在重命名 hostname/alias 后仍不变 +6. API Key 不影响控制台主流程 + +--- + +## 9. 风险与规避 + +- 风险:旧 token 体系与新 session 并存期可能混淆 + - 规避:版本开关 + 明确响应字段(`token_type=session`) +- 风险:节点重连窗口导致短时抖动 + - 规避:变更前提示 + 逐节点串行生效 +- 风险:hub 恢复/掉线频繁导致模式抖动 + - 规避:加入最小驻留时间(如 60s) + +--- + +## 10. 下一步实施顺序(立即执行) + +1. 后端:新增 session token 生成/校验(无 refresh) +2. 后端:中间件切换到 session 识别 network +3. 前端:登录流程改读 `session_token` +4. 后端:保留 API key 能力但移出主登录流程 +5. 回归联调:登录、节点、sdwan、connect、enroll、hub 回退 diff --git a/cmd/inp2ps/main.go b/cmd/inp2ps/main.go index 2676d22..da20514 100644 --- a/cmd/inp2ps/main.go +++ b/cmd/inp2ps/main.go @@ -91,80 +91,62 @@ func main() { srv := server.New(cfg) srv.StartCleanup() - // Admin-only Middleware + // Admin-only Middleware (master token only) adminMiddleware := func(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/v1/auth/login" { next(w, r) return } - authHeader := r.Header.Get("Authorization") - valid := authHeader == fmt.Sprintf("Bearer %d", cfg.Token) - if !valid { + ac, ok := srv.ResolveAccess(r, cfg.Token) + if !ok || ac.Kind != "master" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`) return } - // RBAC: admin only - if srv.Store() != nil { - if u, err := srv.Store().GetUserByToken(server.BearerToken(r)); err == nil && u != nil { - if u.Status != 1 || u.Role != "admin" { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`) - return - } - } - } next(w, r) } } - // Tenant or Admin Middleware + // Tenant or Admin Middleware (session/apikey/master) tenantMiddleware := func(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/v1/auth/login" { next(w, r) return } - authHeader := r.Header.Get("Authorization") - if authHeader == fmt.Sprintf("Bearer %d", cfg.Token) { - next(w, r) + ac, ok := srv.ResolveAccess(r, cfg.Token) + if !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`) return } - // check API key + RBAC - if srv.Store() != nil { - if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil { - // role check if user exists - if u, err := srv.Store().GetUserByToken(server.BearerToken(r)); err == nil && u != nil { - if u.Status != 1 { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`) - return - } - if u.Role == "operator" { - path := r.URL.Path - if path != "/api/v1/nodes" && path != "/api/v1/sdwans" && path != "/api/v1/sdwan/edit" && path != "/api/v1/connect" && path != "/api/v1/nodes/apps" && path != "/api/v1/nodes/kick" && path != "/api/v1/stats" && path != "/api/v1/health" { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`) - return - } - } - } - next(w, r) + if ac.Kind == "session" && ac.Role == "operator" { + path := r.URL.Path + if path != "/api/v1/nodes" && path != "/api/v1/sdwans" && path != "/api/v1/sdwan/edit" && path != "/api/v1/connect" && path != "/api/v1/nodes/apps" && path != "/api/v1/nodes/kick" && path != "/api/v1/stats" && path != "/api/v1/health" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`) return } } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnauthorized) - fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`) - return + next(w, r) } } + getTenantID := func(r *http.Request) int64 { + tok := server.BearerToken(r) + if tok == "" { + return 0 + } + if ac, ok := srv.ResolveTenantAccessToken(tok); ok && ac.Kind != "master" { + return ac.TenantID + } + return 0 + } + mux := http.NewServeMux() mux.HandleFunc("/ws", srv.HandleWS) @@ -201,7 +183,7 @@ func main() { _ = json.Unmarshal(body, &reqTok) _ = json.Unmarshal(body, &reqUser) - // --- user login --- + // --- user login (session token) --- if reqUser.TenantID > 0 && reqUser.Username != "" && reqUser.Password != "" { if srv.Store() == nil { w.Header().Set("Content-Type", "application/json") @@ -216,23 +198,24 @@ func main() { fmt.Fprintf(w, `{"error":1,"message":"invalid credentials"}`) return } - // issue API key for this tenant and return subnet - key, err := srv.Store().CreateAPIKey(reqUser.TenantID, "all", 0) + sessionToken, exp, err := srv.Store().CreateSessionToken(u.ID, reqUser.TenantID, u.Role, 24*time.Hour) if err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, `{"error":1,"message":"create token failed"}`) + fmt.Fprintf(w, `{"error":1,"message":"create session failed"}`) return } ten, _ := srv.Store().GetTenantByID(reqUser.TenantID) resp := struct { - Error int `json:"error"` - Message string `json:"message"` - Token string `json:"token"` - Role string `json:"role"` - Status int `json:"status"` - Subnet string `json:"subnet"` - }{0, "ok", key, u.Role, u.Status, ""} + Error int `json:"error"` + Message string `json:"message"` + Token string `json:"token"` + TokenType string `json:"token_type"` + ExpiresAt int64 `json:"expires_at"` + Role string `json:"role"` + Status int `json:"status"` + Subnet string `json:"subnet"` + }{0, "ok", sessionToken, "session", exp, u.Role, u.Status, ""} if ten != nil { resp.Subnet = ten.Subnet } @@ -289,13 +272,8 @@ func main() { return } w.Header().Set("Content-Type", "application/json") - // tenant filter by API key - tenantID := int64(0) - if srv.Store() != nil { - if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil { - tenantID = ten.ID - } - } + // tenant filter by session/apikey + tenantID := getTenantID(r) if tenantID > 0 { nodes := srv.GetOnlineNodesByTenant(tenantID) _ = json.NewEncoder(w).Encode(map[string]any{"nodes": nodes}) @@ -311,13 +289,8 @@ func main() { return } w.Header().Set("Content-Type", "application/json") - // tenant filter by API key - tenantID := int64(0) - if srv.Store() != nil { - if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil { - tenantID = ten.ID - } - } + // tenant filter by session/apikey + tenantID := getTenantID(r) if tenantID > 0 { _ = json.NewEncoder(w).Encode(srv.GetSDWANTenant(tenantID)) return @@ -335,13 +308,8 @@ func main() { http.Error(w, err.Error(), http.StatusBadRequest) return } - // tenant filter by API key - tenantID := int64(0) - if srv.Store() != nil { - if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil { - tenantID = ten.ID - } - } + // tenant filter by session/apikey + tenantID := getTenantID(r) if tenantID > 0 { if err := srv.SetSDWANTenant(tenantID, req); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -378,13 +346,8 @@ func main() { http.Error(w, "node not found", http.StatusNotFound) return } - // tenant filter by API key - tenantID := int64(0) - if srv.Store() != nil { - if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil { - tenantID = ten.ID - } - } + // tenant filter by session/apikey + tenantID := getTenantID(r) if tenantID > 0 && node.TenantID != tenantID { http.Error(w, "node not found", http.StatusNotFound) return @@ -413,13 +376,8 @@ func main() { http.Error(w, "node not found or offline", http.StatusNotFound) return } - // tenant filter by API key - tenantID := int64(0) - if srv.Store() != nil { - if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil { - tenantID = ten.ID - } - } + // tenant filter by session/apikey + tenantID := getTenantID(r) if tenantID > 0 && node.TenantID != tenantID { http.Error(w, "node not found", http.StatusNotFound) return @@ -451,13 +409,8 @@ func main() { http.Error(w, "source node offline", http.StatusNotFound) return } - // tenant filter by API key - tenantID := int64(0) - if srv.Store() != nil { - if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil { - tenantID = ten.ID - } - } + // tenant filter by session/apikey + tenantID := getTenantID(r) if tenantID > 0 && fromNode.TenantID != tenantID { http.Error(w, "node not found", http.StatusNotFound) return @@ -529,10 +482,10 @@ func main() { // Enable TCP keepalive at server level httpSrv := &http.Server{ - Handler: mux, + Handler: mux, ReadHeaderTimeout: 10 * time.Second, - WriteTimeout: 30 * time.Second, - IdleTimeout: 120 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, } go func() { if err := httpSrv.Serve(ln); err != http.ErrServerClosed { diff --git a/internal/server/authz.go b/internal/server/authz.go new file mode 100644 index 0000000..267b4de --- /dev/null +++ b/internal/server/authz.go @@ -0,0 +1,54 @@ +package server + +import ( + "net/http" + "strconv" +) + +type AccessContext struct { + Kind string + TenantID int64 + UserID int64 + Role string + Token string +} + +func (s *Server) ResolveAccess(r *http.Request, masterToken uint64) (*AccessContext, bool) { + tok := BearerToken(r) + if tok == "" { + return nil, false + } + + if tok == strconv.FormatUint(masterToken, 10) { + return &AccessContext{Kind: "master", Role: "admin", Token: tok}, true + } + + return s.ResolveTenantAccessToken(tok) +} + +func (s *Server) ResolveTenantAccessToken(tok string) (*AccessContext, bool) { + if tok == "" || s.store == nil { + return nil, false + } + + if ss, err := s.store.VerifySessionToken(tok); err == nil && ss != nil { + return &AccessContext{ + Kind: "session", + TenantID: ss.TenantID, + UserID: ss.UserID, + Role: ss.Role, + Token: tok, + }, true + } + + if ten, err := s.store.VerifyAPIKey(tok); err == nil && ten != nil { + return &AccessContext{ + Kind: "apikey", + TenantID: ten.ID, + Role: "apikey", + Token: tok, + }, true + } + + return nil, false +} diff --git a/internal/server/tenant_api.go b/internal/server/tenant_api.go index 31939bc..a610542 100644 --- a/internal/server/tenant_api.go +++ b/internal/server/tenant_api.go @@ -34,12 +34,47 @@ func writeJSON(w http.ResponseWriter, status int, body string) { } func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + tenants, err := s.store.ListTenants() + if err != nil { + writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"list tenants failed"}`) + return + } + resp := struct { + Error int `json:"error"` + Message string `json:"message"` + Tenants []store.Tenant `json:"tenants"` + }{0, "ok", tenants} + b, _ := json.Marshal(resp) + writeJSON(w, http.StatusOK, string(b)) + return + } + // update tenant status via /api/v1/admin/tenants/{id}?status=0|1 + if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/admin/tenants/") { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) >= 4 { + var id int64 + _, _ = fmt.Sscanf(parts[len(parts)-1], "%d", &id) + st := r.URL.Query().Get("status") + if id > 0 && st != "" { + status := 0 + if st == "1" { + status = 1 + } + _ = s.store.UpdateTenantStatus(id, status) + writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`) + return + } + } + } if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`) return } var req struct { - Name string `json:"name"` + Name string `json:"name"` + AdminPassword string `json:"admin_password"` + OperatorPassword string `json:"operator_password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" { writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`) @@ -49,23 +84,39 @@ func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`) return } - ten, err := s.store.CreateTenant(req.Name) + var ten *store.Tenant + var admin *store.User + var op *store.User + var err error + if req.AdminPassword != "" && req.OperatorPassword != "" { + ten, admin, op, err = s.store.CreateTenantWithUsers(req.Name, req.AdminPassword, req.OperatorPassword) + } else { + ten, err = s.store.CreateTenant(req.Name) + } if err != nil { writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create tenant failed"}`) return } resp := struct { - Error int `json:"error"` - Message string `json:"message"` - Tenant int64 `json:"tenant_id"` - Subnet string `json:"subnet"` - }{0, "ok", ten.ID, ten.Subnet} + Error int `json:"error"` + Message string `json:"message"` + Tenant int64 `json:"tenant_id"` + Subnet string `json:"subnet"` + AdminUser string `json:"admin_user"` + OperatorUser string `json:"operator_user"` + }{0, "ok", ten.ID, ten.Subnet, "", ""} + if admin != nil { + resp.AdminUser = admin.Email + } + if op != nil { + resp.OperatorUser = op.Email + } b, _ := json.Marshal(resp) writeJSON(w, http.StatusOK, string(b)) } func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { + if r.Method != http.MethodPost && r.Method != http.MethodGet { writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`) return } @@ -87,6 +138,37 @@ func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`) return } + if r.Method == http.MethodGet { + keys, err := s.store.ListAPIKeys(tenantID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"list keys failed"}`) + return + } + resp := struct { + Error int `json:"error"` + Message string `json:"message"` + Keys []store.APIKey `json:"keys"` + }{0, "ok", keys} + b, _ := json.Marshal(resp) + writeJSON(w, http.StatusOK, string(b)) + return + } + // update key status via /api/v1/admin/tenants/{id}/keys/{keyId}?status=0|1 + if strings.Contains(r.URL.Path, "/keys/") { + parts2 := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + var keyID int64 + _, _ = fmt.Sscanf(parts2[len(parts2)-1], "%d", &keyID) + st := r.URL.Query().Get("status") + if keyID > 0 && st != "" { + status := 0 + if st == "1" { + status = 1 + } + _ = s.store.UpdateAPIKeyStatus(keyID, status) + writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`) + return + } + } var req struct { Scope string `json:"scope"` TTL int64 `json:"ttl"` // seconds @@ -112,21 +194,41 @@ func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request) } func (s *Server) HandleTenantEnroll(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { + if r.Method != http.MethodPost && r.Method != http.MethodGet { writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`) return } - // tenant auth by API key + // tenant auth by session/apikey if s.store == nil { writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`) return } tok := BearerToken(r) - ten, err := s.store.VerifyAPIKey(tok) - if err != nil || ten == nil { + ac, ok := s.ResolveTenantAccessToken(tok) + if !ok || ac.TenantID <= 0 { writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"unauthorized"}`) return } + ten, err := s.store.GetTenantByID(ac.TenantID) + if err != nil || ten == nil || ten.Status != 1 { + writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"unauthorized"}`) + return + } + if r.Method == http.MethodGet { + tokens, err := s.store.ListEnrollTokens(ten.ID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"list enroll failed"}`) + return + } + resp := struct { + Error int `json:"error"` + Message string `json:"message"` + Enrolls []store.EnrollToken `json:"enrolls"` + }{0, "ok", tokens} + b, _ := json.Marshal(resp) + writeJSON(w, http.StatusOK, string(b)) + return + } code, err := s.store.CreateEnrollToken(ten.ID, 10*time.Minute, 5) if err != nil { writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create enroll failed"}`) @@ -147,6 +249,24 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`) return } + // revoke support: /api/v1/enroll/consume/{id}?status=0 + if strings.Contains(r.URL.Path, "/enroll/consume/") { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) >= 4 { + var id int64 + _, _ = fmt.Sscanf(parts[len(parts)-1], "%d", &id) + st := r.URL.Query().Get("status") + if id > 0 && st != "" { + status := 0 + if st == "1" { + status = 1 + } + _ = s.store.UpdateEnrollStatus(id, status) + writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`) + return + } + } + } var req struct { Code string `json:"code"` NodeName string `json:"node"` @@ -171,12 +291,13 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) { return } resp := struct { - Error int `json:"error"` - Message string `json:"message"` - NodeID int64 `json:"node_id"` - Secret string `json:"node_secret"` - Tenant int64 `json:"tenant_id"` - }{0, "ok", cred.NodeID, cred.Secret, cred.TenantID} + Error int `json:"error"` + Message string `json:"message"` + NodeID int64 `json:"node_id"` + Secret string `json:"node_secret"` + Tenant int64 `json:"tenant_id"` + CreatedAt int64 `json:"created_at"` + }{0, "ok", cred.NodeID, cred.Secret, cred.TenantID, cred.CreatedAt} b, _ := json.Marshal(resp) writeJSON(w, http.StatusOK, string(b)) } diff --git a/internal/store/store.go b/internal/store/store.go index 0358fd5..e05d84c 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -10,6 +10,7 @@ import ( "net" "time" + "golang.org/x/crypto/bcrypt" _ "modernc.org/sqlite" ) @@ -18,27 +19,43 @@ type Store struct { } type Tenant struct { - ID int64 - Name string - Status int - Subnet string + ID int64 + Name string + Status int + Subnet string + CreatedAt int64 +} + +type User struct { + ID int64 + TenantID int64 + Role string + Email string + PasswordHash string + Status int + CreatedAt int64 } type APIKey struct { - ID int64 - TenantID int64 - Hash string - Scope string - Expires *time.Time - Status int + ID int64 + TenantID int64 + Hash string + Scope string + ExpiresAt *int64 + Status int + CreatedAt int64 + Plain string } type NodeCredential struct { - NodeID int64 - NodeName string - Secret string - VirtualIP string - TenantID int64 + NodeID int64 + NodeName string + Secret string + VirtualIP string + TenantID int64 + Status int + CreatedAt int64 + LastSeen *int64 } type EnrollToken struct { @@ -50,6 +67,18 @@ type EnrollToken struct { MaxAttempt int Attempts int Status int + CreatedAt int64 +} + +type SessionToken struct { + ID int64 + UserID int64 + TenantID int64 + Role string + TokenHash string + ExpiresAt int64 + Status int + CreatedAt int64 } func Open(dbPath string) (*Store, error) { @@ -111,6 +140,7 @@ func (s *Store) migrate() error { virtual_ip TEXT, status INTEGER NOT NULL DEFAULT 1, last_seen INTEGER, + created_at INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(tenant_id) REFERENCES tenants(id) );`, `CREATE TABLE IF NOT EXISTS enroll_tokens ( @@ -154,6 +184,18 @@ func (s *Store) migrate() error { tenant_id INTEGER, updated_at INTEGER NOT NULL );`, + `CREATE TABLE IF NOT EXISTS session_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + role TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at INTEGER NOT NULL, + status INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id), + FOREIGN KEY(tenant_id) REFERENCES tenants(id) + );`, } for _, stmt := range stmts { if _, err := s.DB.Exec(stmt); err != nil { @@ -215,25 +257,45 @@ func (s *Store) CreateTenant(name string) (*Tenant, error) { } id, _ := res.LastInsertId() _, _ = s.DB.Exec(`UPDATE subnet_pool SET status=1, tenant_id=?, updated_at=? WHERE subnet=?`, id, now, sn) - return &Tenant{ID: id, Name: name, Status: 1, Subnet: sn}, nil + return &Tenant{ID: id, Name: name, Status: 1, Subnet: sn, CreatedAt: now}, nil +} + +func (s *Store) CreateTenantWithUsers(name, adminPassword, operatorPassword string) (*Tenant, *User, *User, error) { + if adminPassword == "" || operatorPassword == "" { + return nil, nil, nil, errors.New("password required") + } + ten, err := s.CreateTenant(name) + if err != nil { + return nil, nil, nil, err + } + admin, err := s.CreateUser(ten.ID, "admin", "admin@local", adminPassword, 1) + if err != nil { + return nil, nil, nil, err + } + op, err := s.CreateUser(ten.ID, "operator", "operator@local", operatorPassword, 1) + if err != nil { + return nil, nil, nil, err + } + return ten, admin, op, nil } func (s *Store) CreateNodeCredential(tenantID int64, nodeName string) (*NodeCredential, error) { secret := randToken() h := hashTokenString(secret) - res, err := s.DB.Exec(`INSERT INTO nodes(tenant_id,node_name,node_secret_hash,status) VALUES(?,?,?,1)`, tenantID, nodeName, h) + now := time.Now().Unix() + res, err := s.DB.Exec(`INSERT INTO nodes(tenant_id,node_name,node_secret_hash,status,last_seen,created_at) VALUES(?,?,?,1,?,?)`, tenantID, nodeName, h, now, now) if err != nil { return nil, err } id, _ := res.LastInsertId() - return &NodeCredential{NodeID: id, NodeName: nodeName, Secret: secret, TenantID: tenantID}, nil + return &NodeCredential{NodeID: id, NodeName: nodeName, Secret: secret, TenantID: tenantID, Status: 1, CreatedAt: now, LastSeen: &now}, nil } func (s *Store) VerifyNodeSecret(nodeName, secret string) (*Tenant, error) { h := hashTokenString(secret) - row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet FROM nodes n JOIN tenants t ON n.tenant_id=t.id WHERE n.node_name=? AND n.node_secret_hash=? AND n.status=1`, nodeName, h) + row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet,t.created_at FROM nodes n JOIN tenants t ON n.tenant_id=t.id WHERE n.node_name=? AND n.node_secret_hash=? AND n.status=1 AND t.status=1`, nodeName, h) var t Tenant - if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet); err != nil { + if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil { return nil, err } return &t, nil @@ -241,9 +303,18 @@ func (s *Store) VerifyNodeSecret(nodeName, secret string) (*Tenant, error) { func (s *Store) GetTenantByToken(token uint64) (*Tenant, error) { h := hashToken(token) - row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1`, h) + row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet,t.created_at FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1 AND t.status=1`, h) var t Tenant - if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet); err != nil { + if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil { + return nil, err + } + return &t, nil +} + +func (s *Store) GetTenantByID(id int64) (*Tenant, error) { + row := s.DB.QueryRow(`SELECT id,name,status,subnet,created_at FROM tenants WHERE id=?`, id) + var t Tenant + if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil { return nil, err } return &t, nil @@ -274,10 +345,10 @@ func (s *Store) CreateEnrollToken(tenantID int64, ttl time.Duration, maxAttempt func (s *Store) ConsumeEnrollToken(code string) (*EnrollToken, error) { h := hashTokenString(code) now := time.Now().Unix() - row := s.DB.QueryRow(`SELECT id,tenant_id,expires_at,used_at,max_attempt,attempts,status FROM enroll_tokens WHERE token_hash=?`, h) + row := s.DB.QueryRow(`SELECT id,tenant_id,expires_at,used_at,max_attempt,attempts,status,created_at FROM enroll_tokens WHERE token_hash=?`, h) var et EnrollToken var used sql.NullInt64 - if err := row.Scan(&et.ID, &et.TenantID, &et.ExpiresAt, &used, &et.MaxAttempt, &et.Attempts, &et.Status); err != nil { + if err := row.Scan(&et.ID, &et.TenantID, &et.ExpiresAt, &used, &et.MaxAttempt, &et.Attempts, &et.Status, &et.CreatedAt); err != nil { return nil, err } if used.Valid { @@ -306,6 +377,97 @@ func (s *Store) IncEnrollAttempt(code string) { _, _ = s.DB.Exec(`UPDATE enroll_tokens SET attempts=attempts+1 WHERE token_hash=?`, h) } +// ListTenants returns all tenants (admin) +func (s *Store) ListTenants() ([]Tenant, error) { + rows, err := s.DB.Query(`SELECT id,name,status,subnet,created_at FROM tenants ORDER BY id DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Tenant + for rows.Next() { + var t Tenant + if err := rows.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err == nil { + out = append(out, t) + } + } + return out, nil +} + +func (s *Store) UpdateTenantStatus(id int64, status int) error { + _, err := s.DB.Exec(`UPDATE tenants SET status=? WHERE id=?`, status, id) + return err +} + +func (s *Store) DeleteTenant(id int64) error { + _, err := s.DB.Exec(`DELETE FROM tenants WHERE id=?`, id) + return err +} + +// ListAPIKeys returns api keys of a tenant (admin) +func (s *Store) ListAPIKeys(tenantID int64) ([]APIKey, error) { + rows, err := s.DB.Query(`SELECT id,tenant_id,key_hash,scope,expires_at,status,created_at FROM api_keys WHERE tenant_id=? ORDER BY id DESC`, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []APIKey + for rows.Next() { + var k APIKey + var exp sql.NullInt64 + if err := rows.Scan(&k.ID, &k.TenantID, &k.Hash, &k.Scope, &exp, &k.Status, &k.CreatedAt); err == nil { + if exp.Valid { + v := exp.Int64 + k.ExpiresAt = &v + } + out = append(out, k) + } + } + return out, nil +} + +func (s *Store) UpdateAPIKeyStatus(id int64, status int) error { + _, err := s.DB.Exec(`UPDATE api_keys SET status=? WHERE id=?`, status, id) + return err +} + +func (s *Store) DeleteAPIKey(id int64) error { + _, err := s.DB.Exec(`DELETE FROM api_keys WHERE id=?`, id) + return err +} + +// ListEnrollTokens returns enroll tokens for a tenant (admin) +func (s *Store) ListEnrollTokens(tenantID int64) ([]EnrollToken, error) { + rows, err := s.DB.Query(`SELECT id,tenant_id,token_hash,expires_at,used_at,max_attempt,attempts,status,created_at FROM enroll_tokens WHERE tenant_id=? ORDER BY id DESC`, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []EnrollToken + for rows.Next() { + var et EnrollToken + var used sql.NullInt64 + if err := rows.Scan(&et.ID, &et.TenantID, &et.Hash, &et.ExpiresAt, &used, &et.MaxAttempt, &et.Attempts, &et.Status, &et.CreatedAt); err == nil { + if used.Valid { + v := used.Int64 + et.UsedAt = &v + } + out = append(out, et) + } + } + return out, nil +} + +func (s *Store) UpdateEnrollStatus(id int64, status int) error { + _, err := s.DB.Exec(`UPDATE enroll_tokens SET status=? WHERE id=?`, status, id) + return err +} + +func (s *Store) DeleteEnrollToken(id int64) error { + _, err := s.DB.Exec(`DELETE FROM enroll_tokens WHERE id=?`, id) + return err +} + func hashToken(token uint64) string { b := make([]byte, 8) for i := uint(0); i < 8; i++ { @@ -320,14 +482,149 @@ func hashTokenString(token string) string { func (s *Store) VerifyAPIKey(token string) (*Tenant, error) { h := hashTokenString(token) - row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1`, h) + row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet,t.created_at FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1 AND t.status=1`, h) var t Tenant - if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet); err != nil { + if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet, &t.CreatedAt); err != nil { return nil, err } return &t, nil } +func (s *Store) GetUserByTenant(tenantID int64) (*User, error) { + row := s.DB.QueryRow(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? ORDER BY id LIMIT 1`, tenantID) + var u User + if err := row.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil { + return nil, err + } + return &u, nil +} + +func (s *Store) GetUserByToken(token string) (*User, error) { + h := hashTokenString(token) + row := s.DB.QueryRow(`SELECT u.id,u.tenant_id,u.role,u.email,u.password_hash,u.status,u.created_at FROM api_keys k JOIN users u ON k.tenant_id=u.tenant_id WHERE k.key_hash=? AND k.status=1`, h) + var u User + if err := row.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil { + return nil, err + } + return &u, nil +} + +func (s *Store) GetUserByEmail(tenantID int64, email string) (*User, error) { + row := s.DB.QueryRow(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? AND email=? ORDER BY id LIMIT 1`, tenantID, email) + var u User + if err := row.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil { + return nil, err + } + return &u, nil +} + +func (s *Store) CreateUser(tenantID int64, role, email, password string, status int) (*User, error) { + now := time.Now().Unix() + var hash string + if password != "" { + b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + hash = string(b) + } + res, err := s.DB.Exec(`INSERT INTO users(tenant_id,role,email,password_hash,status,created_at) VALUES(?,?,?,?,?,?)`, tenantID, role, email, hash, status, now) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return &User{ID: id, TenantID: tenantID, Role: role, Email: email, PasswordHash: hash, Status: status, CreatedAt: now}, nil +} + +func (s *Store) UserEmailExists(tenantID int64, email string) (bool, error) { + row := s.DB.QueryRow(`SELECT COUNT(1) FROM users WHERE tenant_id=? AND email=?`, tenantID, email) + var c int + if err := row.Scan(&c); err != nil { + return false, err + } + return c > 0, nil +} + +func (s *Store) VerifyUserPassword(tenantID int64, email, password string) (*User, error) { + u, err := s.GetUserByEmail(tenantID, email) + if err != nil { + // compatibility: allow login by role name (admin/operator) + row := s.DB.QueryRow(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? AND role=? ORDER BY id LIMIT 1`, tenantID, email) + var byRole User + if scanErr := row.Scan(&byRole.ID, &byRole.TenantID, &byRole.Role, &byRole.Email, &byRole.PasswordHash, &byRole.Status, &byRole.CreatedAt); scanErr != nil { + return nil, err + } + u = &byRole + } + if u.PasswordHash == "" { + return nil, errors.New("password not set") + } + if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil { + return nil, errors.New("invalid password") + } + return u, nil +} + +func (s *Store) CreateSessionToken(userID, tenantID int64, role string, ttl time.Duration) (string, int64, error) { + tok := randToken() + h := hashTokenString(tok) + now := time.Now().Unix() + exp := time.Now().Add(ttl).Unix() + _, err := s.DB.Exec(`INSERT INTO session_tokens(user_id,tenant_id,role,token_hash,expires_at,status,created_at) VALUES(?,?,?,?,?,1,?)`, userID, tenantID, role, h, exp, now) + if err != nil { + return "", 0, err + } + return tok, exp, nil +} + +func (s *Store) VerifySessionToken(token string) (*SessionToken, error) { + h := hashTokenString(token) + now := time.Now().Unix() + row := s.DB.QueryRow(`SELECT id,user_id,tenant_id,role,token_hash,expires_at,status,created_at FROM session_tokens WHERE token_hash=? AND status=1 AND expires_at>?`, h, now) + var st SessionToken + if err := row.Scan(&st.ID, &st.UserID, &st.TenantID, &st.Role, &st.TokenHash, &st.ExpiresAt, &st.Status, &st.CreatedAt); err != nil { + return nil, err + } + return &st, nil +} + +func (s *Store) RevokeSessionToken(token string) error { + h := hashTokenString(token) + _, err := s.DB.Exec(`UPDATE session_tokens SET status=0 WHERE token_hash=?`, h) + return err +} + +func (s *Store) ListUsers(tenantID int64) ([]User, error) { + rows, err := s.DB.Query(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE tenant_id=? ORDER BY id`, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []User{} + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil { + return nil, err + } + out = append(out, u) + } + return out, nil +} + +func (s *Store) UpdateUserStatus(id int64, status int) error { + _, err := s.DB.Exec(`UPDATE users SET status=? WHERE id=?`, status, id) + return err +} + +func (s *Store) UpdateUserPassword(id int64, password string) error { + b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + _, err = s.DB.Exec(`UPDATE users SET password_hash=? WHERE id=?`, string(b), id) + return err +} + func hashTokenBytes(b []byte) string { h := sha256.Sum256(b) return hex.EncodeToString(h[:]) diff --git a/web/index.html b/web/index.html index 6984f14..2fc1ece 100644 --- a/web/index.html +++ b/web/index.html @@ -287,7 +287,8 @@ createApp({ const keyForm = ref({ scope:'all', ttl:0 }); const userForm = ref({ role:'operator', email:'', password:'' }); - const isAdmin = computed(() => role.value === 'admin' && localStorage.getItem('t') === localStorage.getItem('master_t')); + const tokenType = ref(''); + const isAdmin = computed(() => role.value === 'admin' && tokenType.value !== 'session'); const filteredTabs = computed(() => isAdmin.value ? tabs : tabs.filter(t => !['tenants','apikeys','users','enroll'].includes(t.id))); const filteredNodes = computed(() => { const k = (nodeKeyword.value || '').trim().toLowerCase(); @@ -340,6 +341,7 @@ createApp({ localStorage.setItem('t', d.token || ''); role.value = d.role || ''; status.value = d.status ?? 1; + tokenType.value = d.token_type || (localStorage.getItem('t') === localStorage.getItem('master_t') ? 'master' : 'apikey'); if (status.value !== 1) throw new Error('账号已停用'); loggedIn.value = true; await refreshAll(); @@ -356,6 +358,7 @@ createApp({ localStorage.removeItem('master_t'); loggedIn.value = false; role.value = ''; + tokenType.value = ''; stopTimer(); }; @@ -537,7 +540,7 @@ createApp({ }); return { - buildVersion, tab, filteredTabs, loggedIn, busy, msg, msgType, role, status, + buildVersion, tab, filteredTabs, loggedIn, busy, msg, msgType, role, status, tokenType, loginTenant, loginUser, loginPass, loginToken, loginErr, refreshSec, health, stats, nodes, nodeKeyword, filteredNodes, sd, connectForm, tenants, activeTenant, keys, users, enrolls, tenantForm, keyForm, userForm,