diff --git a/BUILD_SEQ b/BUILD_SEQ new file mode 100644 index 0000000..920a139 --- /dev/null +++ b/BUILD_SEQ @@ -0,0 +1 @@ +43 diff --git a/Makefile b/Makefile index 5df4bc9..30f1107 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ APP=inps -VERSION ?= 0.1.0 +VERSION ?= $(shell cat VERSION 2>/dev/null || echo 0.1.0) GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') GO_VERSION := $(shell go env GOVERSION 2>/dev/null || echo unknown) @@ -11,9 +11,15 @@ LDFLAGS := -s -w \ -X 'github.com/openp2p-cn/inp2p/pkg/config.BuildTime=$(BUILD_TIME)' \ -X 'github.com/openp2p-cn/inp2p/pkg/config.GoVersion=$(GO_VERSION)' -.PHONY: build build-client build-server +.PHONY: build build-client build-server verify-deploy bump-version -build: build-client build-server +build: bump-version + @$(MAKE) build-client + @$(MAKE) build-server + +bump-version: + @./scripts/bump-version.sh >/tmp/inp2p_version.new + @echo "[build] version => $$(cat /tmp/inp2p_version.new)" build-client: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ @@ -22,3 +28,6 @@ build-client: build-server: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ go build -ldflags "$(LDFLAGS)" -o bin/inp2ps ./cmd/inp2ps + +verify-deploy: + ./scripts/deploy-check.sh diff --git a/README.md b/README.md index ec01911..63d499b 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,18 @@ pkg/tunnel PASS 0.16s — 端到端转发 + 5 并发 + 统计 pkg/relay PASS 0.3s — 双向桥接 + 1MB 中继 + 认证拒绝 ``` +## 管理与安全文档 + +- 高级越权开关说明:`docs/advanced-overrides.md` +- 租户 RBAC 权限矩阵:`docs/rbac-matrix.md` +- RBAC 回归测试清单:`docs/rbac-regression-checklist.md` + +## 安卓客户端规划文档 + +- 安卓 MVP 计划:`docs/android-mvp-plan.md` +- 安卓 API 契约:`docs/android-api-contract.md` +- 安卓开工检查清单:`docs/android-kickoff-checklist.md` + ## 防火墙 服务器需要开放以下端口: diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..2ef7bc4 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0+build.43 diff --git a/VERSION_BASE b/VERSION_BASE new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION_BASE @@ -0,0 +1 @@ +0.1.0 diff --git a/cmd/inp2ps/main.go b/cmd/inp2ps/main.go index b532d98..15c02a0 100644 --- a/cmd/inp2ps/main.go +++ b/cmd/inp2ps/main.go @@ -215,6 +215,13 @@ func main() { fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`) return } + // only tenant session/apikey can pass + if ac.TenantID <= 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`) + return + } r = r.WithContext(context.WithValue(r.Context(), server.ServerCtxKeyAccess{}, ac)) next(w, r) } @@ -269,7 +276,14 @@ func main() { // Serve Static Web Console webDir := "/root/.openclaw/workspace/inp2p/web" - mux.Handle("/", http.FileServer(http.Dir(webDir))) + fileServer := http.FileServer(http.Dir(webDir)) + mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 防止前端资源缓存导致“登录后瞬间白屏”(新旧 JS 混用) + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + fileServer.ServeHTTP(w, r) + })) // Tenant APIs (API key auth inside handlers) mux.HandleFunc("/api/v1/admin/tenants", adminMiddleware(srv.HandleAdminCreateTenant)) @@ -336,6 +350,7 @@ func main() { } u, err := srv.Store().VerifyUserPasswordGlobal(reqUser.Username, reqUser.Password) if err != nil || u == nil || u.Status != 1 { + _ = srv.Store().AddAuditLog("auth", reqUser.Username, "auth_login_failed", "user", reqUser.Username, "invalid_credentials", r.RemoteAddr) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) fmt.Fprintf(w, `{"error":1,"message":"invalid credentials"}`) @@ -348,6 +363,7 @@ func main() { fmt.Fprintf(w, `{"error":1,"message":"create session failed"}`) return } + _ = srv.Store().AddAuditLog("session", fmt.Sprintf("%d", u.ID), "auth_login_success", "tenant", fmt.Sprintf("%d", u.TenantID), fmt.Sprintf("role=%s", u.Role), r.RemoteAddr) ten, _ := srv.Store().GetTenantByID(u.TenantID) resp := struct { Error int `json:"error"` @@ -380,15 +396,108 @@ func main() { return } w.Header().Set("Content-Type", "application/json") - // tenant filter by session/apikey tenantID := getTenantID(r) + if tenantID > 0 { - nodes := srv.GetOnlineNodesByTenant(tenantID) - _ = json.NewEncoder(w).Encode(map[string]any{"nodes": listNodesOut(nodes, tenantID)}) + online := srv.GetOnlineNodesByTenant(tenantID) + onlineOut := listNodesOut(online, tenantID) + byName := map[string]map[string]any{} + for _, it := range onlineOut { + name, _ := it["name"].(string) + if name == "" { + continue + } + it["online"] = true + it["status"] = "online" + byName[name] = it + } + if srv.Store() != nil { + if creds, err := srv.Store().ListNodeCredentialsByTenant(tenantID); err == nil { + for _, nc := range creds { + if _, ok := byName[nc.NodeName]; ok { + continue + } + item := map[string]any{ + "name": nc.NodeName, + "displayName": nc.NodeName, + "publicIP": "", + "publicPort": 0, + "natType": "unknown", + "tenantId": nc.TenantID, + "version": "", + "relayEnabled": false, + "superRelay": false, + "loginTime": time.Time{}, + "lastHeartbeat": time.Time{}, + "nodeUUID": nc.NodeUUID, + "alias": nc.Alias, + "virtualIP": nc.VirtualIP, + "online": false, + "status": "offline", + } + if nc.Alias != "" { + item["displayName"] = nc.Alias + } + byName[nc.NodeName] = item + } + } + } + merged := make([]map[string]any, 0, len(byName)) + for _, v := range byName { + merged = append(merged, v) + } + _ = json.NewEncoder(w).Encode(map[string]any{"nodes": merged}) return } - nodes := srv.GetOnlineNodes() - _ = json.NewEncoder(w).Encode(map[string]any{"nodes": listNodesOut(nodes, 0)}) + + online := srv.GetOnlineNodes() + onlineOut := listNodesOut(online, 0) + byName := map[string]map[string]any{} + for _, it := range onlineOut { + name, _ := it["name"].(string) + if name == "" { + continue + } + it["online"] = true + it["status"] = "online" + byName[name] = it + } + if srv.Store() != nil { + if creds, err := srv.Store().ListNodeCredentialsAll(); err == nil { + for _, nc := range creds { + if _, ok := byName[nc.NodeName]; ok { + continue + } + item := map[string]any{ + "name": nc.NodeName, + "displayName": nc.NodeName, + "publicIP": "", + "publicPort": 0, + "natType": "unknown", + "tenantId": nc.TenantID, + "version": "", + "relayEnabled": false, + "superRelay": false, + "loginTime": time.Time{}, + "lastHeartbeat": time.Time{}, + "nodeUUID": nc.NodeUUID, + "alias": nc.Alias, + "virtualIP": nc.VirtualIP, + "online": false, + "status": "offline", + } + if nc.Alias != "" { + item["displayName"] = nc.Alias + } + byName[nc.NodeName] = item + } + } + } + merged := make([]map[string]any, 0, len(byName)) + for _, v := range byName { + merged = append(merged, v) + } + _ = json.NewEncoder(w).Encode(map[string]any{"nodes": merged}) })) mux.HandleFunc("/api/v1/sdwans", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) { @@ -443,11 +552,32 @@ func main() { tenantID := getTenantID(r) if tenantID > 0 { ac := server.GetAccessContext(r) + allowed, usedOverride := srv.TenantCanWithOverride(ac, "mutate_sdwan") + if !allowed { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + if usedOverride && ac != nil && srv.Store() != nil { + detail := fmt.Sprintf("action=mutate_sdwan tenant=%d", tenantID) + _ = srv.Store().AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "rbac_override_used", "tenant", fmt.Sprintf("%d", tenantID), detail, r.RemoteAddr) + } actorType, actorID := "", "" if ac != nil { actorType = ac.Kind actorID = fmt.Sprintf("%d", ac.UserID) } + + // lock tenant gatewayCIDR to assigned subnet (tenant cannot modify) + if srv.Store() != nil { + ten, terr := srv.Store().GetTenantByID(tenantID) + if terr == nil && ten != nil && ten.Subnet != "" { + req.GatewayCIDR = ten.Subnet + if len(req.Routes) == 0 { + req.Routes = []string{ten.Subnet} + } + } + } + if err := srv.SetSDWANTenant(tenantID, req, actorType, actorID, r.RemoteAddr); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -478,19 +608,35 @@ func main() { http.Error(w, err.Error(), http.StatusBadRequest) return } + // tenant filter by session/apikey + tenantID := getTenantID(r) + if tenantID > 0 { + ac := server.GetAccessContext(r) + allowed, usedOverride := srv.TenantCanWithOverride(ac, "mutate_node_control") + if !allowed { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + if usedOverride && ac != nil && srv.Store() != nil { + detail := fmt.Sprintf("action=mutate_node_control tenant=%d", tenantID) + _ = srv.Store().AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "rbac_override_used", "tenant", fmt.Sprintf("%d", tenantID), detail, r.RemoteAddr) + } + } node := srv.GetNode(req.Node) if node == nil { http.Error(w, "node not found", http.StatusNotFound) return } - // tenant filter by session/apikey - tenantID := getTenantID(r) if tenantID > 0 && node.TenantID != tenantID { http.Error(w, "node not found", http.StatusNotFound) return } // Push to client _ = node.Conn.Write(protocol.MsgPush, protocol.SubPushConfig, req.Apps) + if ac := server.GetAccessContext(r); ac != nil && srv.Store() != nil { + detail := fmt.Sprintf("node=%s apps=%d", req.Node, len(req.Apps)) + _ = srv.Store().AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "node_apps_push", "node", req.Node, detail, r.RemoteAddr) + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "config push sent"}) })) @@ -508,18 +654,28 @@ func main() { http.Error(w, err.Error(), http.StatusBadRequest) return } + // tenant filter by session/apikey + tenantID := getTenantID(r) + ac := server.GetAccessContext(r) + if tenantID > 0 { + if !server.TenantCan(ac, "mutate_node_control") { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + } node := srv.GetNode(req.Node) if node == nil { http.Error(w, "node not found or offline", http.StatusNotFound) return } - // tenant filter by session/apikey - tenantID := getTenantID(r) if tenantID > 0 && node.TenantID != tenantID { http.Error(w, "node not found", http.StatusNotFound) return } node.Conn.Close() + if ac != nil && srv.Store() != nil { + _ = srv.Store().AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "node_kick", "node", req.Node, "", r.RemoteAddr) + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "node kicked"}) })) @@ -541,14 +697,34 @@ func main() { http.Error(w, err.Error(), http.StatusBadRequest) return } + // tenant filter by session/apikey + tenantID := getTenantID(r) + ac := server.GetAccessContext(r) + if tenantID > 0 { + allowed, usedOverride := srv.TenantCanWithOverride(ac, "mutate_connect") + if !allowed { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + if usedOverride && ac != nil && srv.Store() != nil { + detail := fmt.Sprintf("action=mutate_connect tenant=%d", tenantID) + _ = srv.Store().AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "rbac_override_used", "tenant", fmt.Sprintf("%d", tenantID), detail, r.RemoteAddr) + } + } fromNode := srv.GetNode(req.From) if fromNode == nil { + if ac != nil && srv.Store() != nil { + detail := fmt.Sprintf("from=%s to=%s app=%s src=%d dst=%d reason=source_offline", req.From, req.To, req.AppName, req.SrcPort, req.DstPort) + _ = srv.Store().AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "connect_request_denied", "tenant", fmt.Sprintf("%d", tenantID), detail, r.RemoteAddr) + } http.Error(w, "source node offline", http.StatusNotFound) return } - // tenant filter by session/apikey - tenantID := getTenantID(r) if tenantID > 0 && fromNode.TenantID != tenantID { + if ac != nil && srv.Store() != nil { + detail := fmt.Sprintf("from=%s to=%s app=%s src=%d dst=%d reason=tenant_mismatch_source", req.From, req.To, req.AppName, req.SrcPort, req.DstPort) + _ = srv.Store().AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "connect_request_denied", "tenant", fmt.Sprintf("%d", tenantID), detail, r.RemoteAddr) + } http.Error(w, "node not found", http.StatusNotFound) return } @@ -561,13 +737,41 @@ func main() { DstPort: req.DstPort, Enabled: 1, } - // enforce same-tenant target + // enforce same-tenant target by default; can be relaxed only with explicit advanced switch if tenantID > 0 { + allowCrossTenant := false + if ac != nil && ac.Kind == "session" && ac.Role == "operator" { + if v, _, err := srv.Store().GetSetting("advanced_override_enabled"); err == nil && (v == "1" || v == "true") { + if v2, _, err2 := srv.Store().GetSetting("allow_connect_cross_tenant"); err2 == nil && (v2 == "1" || v2 == "true") { + allowCrossTenant = true + } + } + } toNode := srv.GetNode(req.To) - if toNode == nil || toNode.TenantID != tenantID { + if toNode == nil { + if ac != nil && srv.Store() != nil { + detail := fmt.Sprintf("from=%s to=%s app=%s src=%d dst=%d reason=target_offline", req.From, req.To, req.AppName, req.SrcPort, req.DstPort) + _ = srv.Store().AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "connect_request_denied", "tenant", fmt.Sprintf("%d", tenantID), detail, r.RemoteAddr) + } http.Error(w, "node not found", http.StatusNotFound) return } + if !allowCrossTenant && toNode.TenantID != tenantID { + if ac != nil && srv.Store() != nil { + detail := fmt.Sprintf("from=%s to=%s app=%s src=%d dst=%d reason=tenant_mismatch_target", req.From, req.To, req.AppName, req.SrcPort, req.DstPort) + _ = srv.Store().AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "connect_request_denied", "tenant", fmt.Sprintf("%d", tenantID), detail, r.RemoteAddr) + } + http.Error(w, "node not found", http.StatusNotFound) + return + } + if allowCrossTenant && ac != nil && srv.Store() != nil { + detail := fmt.Sprintf("action=connect_cross_tenant tenant=%d from=%s to=%s", tenantID, req.From, req.To) + _ = srv.Store().AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "rbac_override_used", "tenant", fmt.Sprintf("%d", tenantID), detail, r.RemoteAddr) + } + } + if ac := server.GetAccessContext(r); ac != nil && srv.Store() != nil { + detail := fmt.Sprintf("from=%s to=%s app=%s src=%d dst=%d", req.From, req.To, req.AppName, req.SrcPort, req.DstPort) + _ = srv.Store().AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "connect_request", "tenant", fmt.Sprintf("%d", tenantID), detail, r.RemoteAddr) } if err := srv.PushConnect(fromNode, req.To, app); err != nil { w.Header().Set("Content-Type", "application/json") diff --git a/docs/advanced-overrides.md b/docs/advanced-overrides.md new file mode 100644 index 0000000..405cd00 --- /dev/null +++ b/docs/advanced-overrides.md @@ -0,0 +1,144 @@ +# Advanced Override 开关说明(INP2P) + +> 目标:在不破坏默认最小权限模型的前提下,提供**临时、可审计、可回滚**的越权能力。 + +## 1. 开关清单 + +## 总开关(必须先开) + +- `advanced_override_enabled` + - `0`:关闭(默认) + - `1`:开启 + +> 总开关关闭时,所有越权子开关均不生效。 + +## 越权子开关 + +- `allow_apikey_mutate_sdwan` + - 允许 `apikey` 执行 `mutate_sdwan`(如 `POST /api/v1/sdwan/edit`) + - 默认 `0` + +- `allow_operator_kick_cross_node` + - 预留:允许 operator 执行更高风险节点控制(当前主要用于后续扩展) + - 默认 `0` + +- `allow_connect_cross_tenant` + - 允许 operator 在 connect 目标检查中放宽跨租户限制 + - 默认 `0` + +## 兼容旧开关(保留) + +- `advanced_impersonate` +- `advanced_force_network` +- `advanced_cross_tenant` + +--- + +## 2. 默认安全策略 + +- 所有高级开关默认 `0`(关闭) +- 仅系统管理员(`tenant_id=1` 且 `role=admin`)可在 `/api/v1/admin/settings` 修改 +- `master token` 仍禁止访问 tenant API +- 默认禁止跨租户写操作 + +--- + +## 3. RBAC 行为 + +系统先走基线权限 `TenantCan`,若基线拒绝,再尝试 `TenantCanWithOverride`: + +1. 若 `advanced_override_enabled != 1` → 拒绝 +2. 若命中对应子开关 → 放行(并记录 override 审计) +3. 否则拒绝 + +当前已接入接口: + +- `POST /api/v1/sdwan/edit` +- `POST /api/v1/nodes/apps` +- `POST /api/v1/nodes/kick` +- `POST /api/v1/connect` + +--- + +## 4. 审计事件 + +## 开关配置审计 + +- `setting_change` + - `target_type=setting` + - `target_id=` + - `detail=` + +## 越权使用审计 + +- `rbac_override_used` + - 示例 detail: + - `action=mutate_sdwan tenant=4` + - `action=mutate_connect tenant=3` + - `action=connect_cross_tenant tenant=3 from=a to=b` + +## connect 失败审计 + +- `connect_request_denied` + - 典型 reason: + - `source_offline` + - `tenant_mismatch_source` + - `target_offline` + - `tenant_mismatch_target` + +--- + +## 5. 启用与回滚(操作手册) + +## 启用(示例) + +```bash +BASE=http://127.0.0.1:27183 +ADMIN_TOKEN= + +curl -s -X POST "$BASE/api/v1/admin/settings" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"key":"advanced_override_enabled","value":"1"}' + +curl -s -X POST "$BASE/api/v1/admin/settings" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"key":"allow_apikey_mutate_sdwan","value":"1"}' +``` + +## 回滚(推荐顺序) + +```bash +# 先关子开关 +curl -s -X POST "$BASE/api/v1/admin/settings" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"key":"allow_apikey_mutate_sdwan","value":"0"}' + +curl -s -X POST "$BASE/api/v1/admin/settings" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"key":"allow_connect_cross_tenant","value":"0"}' + +curl -s -X POST "$BASE/api/v1/admin/settings" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"key":"allow_operator_kick_cross_node","value":"0"}' + +# 最后关总开关 +curl -s -X POST "$BASE/api/v1/admin/settings" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"key":"advanced_override_enabled","value":"0"}' +``` + +--- + +## 6. 风险与建议 + +- 建议仅在临时排障窗口启用,完成后立即回滚。 +- 启用前后均应检查审计日志,确认“谁在何时以何种方式使用了越权能力”。 +- 生产环境优先保持: + - `advanced_override_enabled=0` + - `allow_* = 0` diff --git a/docs/android-api-contract.md b/docs/android-api-contract.md new file mode 100644 index 0000000..abeb3d4 --- /dev/null +++ b/docs/android-api-contract.md @@ -0,0 +1,80 @@ +# INP2P Android API 契约(MVP) + +> Base URL 示例:`http://127.0.0.1:27183` +> 认证方式:`Authorization: Bearer ` + +## 1) 登录 +### POST `/api/v1/auth/login` +请求: +```json +{"username":"kyaarin","password":"***"} +``` +响应(成功): +```json +{ + "error":0, + "message":"ok", + "token":"...", + "token_type":"session", + "expires_at":1770000000, + "role":"admin", + "status":1, + "tenant_id":3, + "subnet":"10.200.2.0/24" +} +``` + +## 2) 节点列表 +### GET `/api/v1/nodes` +响应: +```json +{"nodes":[{"name":"node1","tenantId":3,"natType":1,"publicIP":"1.2.3.4"}]} +``` + +## 3) SDWAN +### GET `/api/v1/sdwans` +读取当前租户 SDWAN 配置。 + +### POST `/api/v1/sdwan/edit` +按权限写入。租户端 `gatewayCIDR` 由后端锁定为租户分配子网。 + +## 4) 节点控制 +### POST `/api/v1/nodes/apps` +请求示例: +```json +{"node":"node1","apps":[{"appName":"demo","protocol":"tcp","srcPort":12080,"dstHost":"127.0.0.1","dstPort":80,"enabled":1}]} +``` + +### POST `/api/v1/nodes/kick` +请求: +```json +{"node":"node1"} +``` + +## 5) 手动连接 +### POST `/api/v1/connect` +请求: +```json +{"from":"nodeA","to":"nodeB","srcPort":10080,"dstPort":80,"appName":"manual"} +``` + +## 6) 管理员接口(admin only) +### GET `/api/v1/admin/audit?tenant=3&limit=100` +读取审计日志。 + +### GET `/api/v1/admin/settings` +读取高级设置。 + +### POST `/api/v1/admin/settings` +请求: +```json +{"key":"advanced_override_enabled","value":"1"} +``` + +## 7) 错误码约定(前端处理) +- `200`:成功 +- `400`:参数错误(直接提示) +- `401`:未授权(跳登录) +- `403`:权限不足(提示“无权限”) +- `404`:资源不存在/离线 +- `502`:后端转发失败(connect 可能出现) diff --git a/docs/android-kickoff-checklist.md b/docs/android-kickoff-checklist.md new file mode 100644 index 0000000..8d70a65 --- /dev/null +++ b/docs/android-kickoff-checklist.md @@ -0,0 +1,41 @@ +# 安卓端开工准备清单(可执行) + +## A. 仓库与目录 +- [ ] 新建仓库:`inp2p-android`(或 `inp2p/mobile/android` 子目录) +- [ ] 分支策略:`main` + `dev` + `feature/*` + +## B. 工程初始化 +- [ ] Android Studio 最新稳定版 +- [ ] minSdk 26 / targetSdk 35 +- [ ] Kotlin DSL + Compose 模板 +- [ ] 引入依赖:Retrofit / OkHttp / Moshi(or kotlinx-serialization) / Room / WorkManager + +## C. 安全与配置 +- [ ] 本地存储 token 使用 EncryptedSharedPreferences +- [ ] Base URL 支持调试/生产切换 +- [ ] 调试日志默认脱敏(禁止打印 Bearer token) + +## D. 页面骨架(MVP) +- [ ] Login +- [ ] Dashboard(nodes/stats) +- [ ] Node 控制(apps/kick/connect) +- [ ] SDWAN +- [ ] Admin(settings/audit) + +## E. 网络层 +- [ ] 统一 ApiResult 封装(Success/Error) +- [ ] 401 统一拦截处理(回登录) +- [ ] 403/404/502 统一文案 + +## F. 权限与可见性 +- [ ] 根据 `role + tenant_id` 做 UI 可见性 +- [ ] 高风险动作二次确认弹窗 + +## G. 测试 +- [ ] Mock API 单元测试 +- [ ] 至少 1 条真实环境冒烟链路(login -> nodes -> connect) + +## H. 文档 +- [ ] 在项目 README 中链接: + - `docs/android-mvp-plan.md` + - `docs/android-api-contract.md` diff --git a/docs/android-mvp-plan.md b/docs/android-mvp-plan.md new file mode 100644 index 0000000..59f4e6b --- /dev/null +++ b/docs/android-mvp-plan.md @@ -0,0 +1,61 @@ +# INP2P Android 客户端开工计划(MVP → V1) + +## 目标 +在不改动核心服务端协议的前提下,先交付“可运维、可观察、可控风险”的安卓客户端。 + +## 分阶段路线 + +### Phase 0:开工准备(1-2 天) +- 明确 API 契约(当前后端已具备) +- 固化角色与权限矩阵(admin/operator/apikey) +- 建立 Android 项目骨架(Kotlin + Compose + Retrofit + Room + WorkManager) +- 建立日志/崩溃/埋点规范 + +### Phase 1:MVP(5-7 天) +- 登录/会话管理(`/api/v1/auth/login`) +- 节点列表与状态(`/api/v1/nodes`) +- 手动 connect(`/api/v1/connect`) +- 远程推配置(`/api/v1/nodes/apps`) +- 节点踢下线(`/api/v1/nodes/kick`,仅 admin/operator) +- SDWAN 基础配置只读 + 编辑(按权限) +- 审计查看(admin only,`/api/v1/admin/audit`) + +### Phase 2:增强(7-10 天) +- 设置页:高级开关联动(`/api/v1/admin/settings`) +- 断线重连与 Token 续期提示 +- 更完整的错误态/空态/离线态 +- 细化操作确认(高风险动作二次确认) + +### Phase 3:V1(后续) +- VpnService + TUN 数据面接入 +- 网络切换稳定性(Wi-Fi/4G) +- 前台服务保活与电量策略优化 + +## MVP 范围边界(本次明确) +- ✅ 做:控制面(鉴权、查询、下发) +- ❌ 不做:首版直接上 TUN 全数据面 + +## 技术选型(建议) +- 语言:Kotlin +- UI:Jetpack Compose +- 网络:Retrofit + OkHttp +- 本地存储:Room + EncryptedSharedPreferences +- 并发:Kotlin Coroutines + Flow +- 任务调度:WorkManager +- 最低版本:Android 8.0+ + +## 核心风险与对策 +1. Token 过期导致操作失败 + - 对策:统一 401 拦截,跳登录;关键动作前校验登录态。 +2. 移动网络切换导致请求失败 + - 对策:重试策略(指数退避)、弱网提示、幂等操作提示。 +3. 高危动作误触(kick/connect/sdwan) + - 对策:二次确认 + 审计可追踪 + 角色按钮可见性控制。 +4. 后端字段变动影响兼容 + - 对策:DTO 宽容解析 + 版本检查 + 契约文档锁定。 + +## 验收标准(MVP) +- 能用 admin/operator 成功登录并执行对应权限动作 +- apikey 仅可读,写操作被正确拒绝 +- 关键操作成功/失败信息明确可见 +- 审计与后端一致(至少覆盖 connect/kick/apps/sdwan) diff --git a/docs/deploy-version-check.md b/docs/deploy-version-check.md new file mode 100644 index 0000000..cdc410d --- /dev/null +++ b/docs/deploy-version-check.md @@ -0,0 +1,49 @@ +# INP2P 部署校验规范(强制) + +> 目标:每次新部署后,必须自动校验“服务端/客户端版本一致 + 无残留旧进程/旧二进制”。 + +## 1. 版本控制 + +- 项目版本源:`VERSION` +- 构建时自动注入:`Version / GitCommit / BuildTime / GoVersion` +- 约束:`bin/inp2ps` 与 `bin/inp2pc` 的 `-version` 输出必须等于 `VERSION`。 + +## 2. 校验脚本 + +- 脚本:`scripts/deploy-check.sh` +- Make 入口:`make verify-deploy` + +脚本会检查: + +1) 二进制版本是否匹配 `VERSION` +2) `inp2ps.service` 是否 active +3) `inp2ps.service` 的主进程是否运行自 `bin/inp2ps` +4) 是否存在残留 inp2p 进程(除当前服务主 PID 外) +5) 是否存在残留旧二进制(`*.old*/*.new*`、根目录历史二进制) + +输出: +- 报告目录:`run/deploy-check/` +- 报告文件:`deploy-check-YYYYmmdd-HHMMSS.txt` +- 失败时脚本返回非 0(可用于 CI/发布门禁) + +## 3. 标准发布流程(建议) + +```bash +cd /root/.openclaw/workspace/inp2p + +# 1) 编译(自动读取 VERSION) +make build + +# 2) 部署(替换后重启服务) +systemctl restart inp2ps.service + +# 3) 强制校验(必须通过) +make verify-deploy +``` + +## 4. 失败处理原则 + +- 任一校验 FAIL:视为发布未完成 +- 必须先清理残留再进入可用状态 +- 建议在变更记录中附上本次校验报告路径 + diff --git a/docs/frontend-copy-dictionary.md b/docs/frontend-copy-dictionary.md new file mode 100644 index 0000000..dfb7a2f --- /dev/null +++ b/docs/frontend-copy-dictionary.md @@ -0,0 +1,46 @@ +# INP2P 前端文案词典(P4 封板) + +更新时间:2026-03-08 +适用范围:`web/index.html` 管理控制台 + +## 一、动作文案统一 + +- 刷新类:统一使用 **“重新加载”**(避免与浏览器“刷新页面”歧义) + - 重新加载 Key + - 重新加载 enroll +- 主动作:使用动词 + 对象 + - 创建租户 / 创建用户 / 创建 API Key + - 保存 SDWAN / 保存节点映射 / 保存子网代理 + - 发送配置 / 发送连接请求 +- 危险动作:统一显式风险语义 + - 删除 / 踢下线 / 批量停用 / 批量作废 + +## 二、状态与空态文案 + +- 空态统一使用“暂无 xxx 记录”并给出下一步指引 + - 暂无 API Key 记录 + - 暂无 enroll 记录。点击上方“生成 enroll_code” +- 处理中统一使用“xx 处理中...” + - SDWAN 处理中... + - P2P 操作处理中... + - 用户数据处理中... + +## 三、反馈文案规范 + +- 成功反馈:动作 + 成功 + - 审计 CSV 已导出 + - 高级设置已保存 +- 失败反馈:优先人性化解释(state.humanizeError) +- 批量反馈:统一“成功/失败计数” + - Key 批量停用:成功 X / 失败 Y + +## 四、按钮层级规范 + +- `btn`:主路径提交(会改变状态/写入) +- `btn2`:次级操作(筛选、切换、导出、重试) +- `btn-danger`:高风险操作(不可逆或影响在线连接) + +示例: +- 保存节点映射 / 保存子网代理 / 发送配置 => `btn` +- 重新加载 Key / 清空筛选 / 导出 CSV => `btn2` +- 删除用户 / 踢下线 => `btn-danger` diff --git a/docs/frontend-regression-final-checklist.md b/docs/frontend-regression-final-checklist.md new file mode 100644 index 0000000..9befa55 --- /dev/null +++ b/docs/frontend-regression-final-checklist.md @@ -0,0 +1,69 @@ +# INP2P 最终回归清单(P4) + +更新时间:2026-03-08 +适用版本:`0.1.0+build.18` 起 + +## 1. 发布门禁(必须) + +1) `make build` +2) `systemctl restart inp2ps.service` +3) `make verify-deploy` + +通过标准:`verify-deploy` 全绿。 + +--- + +## 2. 功能回归 + +### 2.1 登录/会话 +- 用户名密码登录成功 +- 停用账号登录被拒绝 +- 登出后 token 清理 + +### 2.2 节点页 +- 节点搜索可用 +- 踢下线按钮为危险色并有确认 +- 改昵称/改IP/发起连接可用 + +### 2.3 SDWAN +- 保存 SDWAN 生效 +- 节点映射保存生效 +- 子网代理保存生效 + +### 2.4 租户/Key/用户/enroll +- 新建流程可用 +- 单条启停可用 +- 批量启停/作废可用 +- 批量失败计数正确 +- 失败池保留/清空行为符合开关 + +### 2.5 审计 +- 预设筛选(全部/登录失败/拒绝/连接拒绝)可用 +- 清空筛选可恢复默认 +- 导出 CSV 可用 +- 导出失败事件 CSV 可用 + +### 2.6 快捷键 +- `/` 聚焦搜索 +- `r` 刷新 +- `g` 跳节点页 +- `?` 显示/隐藏帮助 +- 输入法组合态、Ctrl/Alt/Meta 下不误触 + +--- + +## 3. 交互一致性 + +- 主操作按钮使用 `btn` +- 次操作按钮使用 `btn2` +- 危险操作按钮使用 `btn-danger` +- 空态文案包含下一步引导 +- 最近操作摘要(opSummary)正确回显 + +--- + +## 4. 运维核验 + +- `inp2ps.service` 指向 `/root/.openclaw/workspace/inp2p/bin/inp2ps` +- 运行中仅保留目标主进程,无残留旧版本进程 +- 版本号与 `VERSION` 一致 diff --git a/docs/rbac-matrix.md b/docs/rbac-matrix.md new file mode 100644 index 0000000..c007b4d --- /dev/null +++ b/docs/rbac-matrix.md @@ -0,0 +1,35 @@ +# INP2P Tenant RBAC Matrix + +## Access Context + +- `session/admin`:租户管理员 +- `session/operator`:租户操作员 +- `apikey`:租户 API key(机器访问) +- `master`:系统级 token(禁止用于 tenant API) + +## Action Mapping + +| action | session/admin | session/operator | apikey | +|---|---:|---:|---:| +| `view` | ✅ | ✅ | ✅ | +| `mutate_node_meta` (alias/ip) | ✅ | ✅ | ❌ | +| `mutate_sdwan` | ✅ | ✅ | ❌ | +| `mutate_connect` | ✅ | ✅ | ❌ | +| `mutate_node_control` (apps push / kick) | ✅ | ✅ | ❌ | +| `enroll_manage` (revoke enroll token) | ✅ | ❌ | ❌ | + +## Route Coverage (implemented) + +- `POST /api/v1/nodes/alias` -> `mutate_node_meta` +- `POST /api/v1/nodes/ip` -> `mutate_node_meta` +- `POST /api/v1/sdwan/edit` -> `mutate_sdwan` +- `POST /api/v1/connect` -> `mutate_connect` +- `POST /api/v1/nodes/apps` -> `mutate_node_control` +- `POST /api/v1/nodes/kick` -> `mutate_node_control` +- `POST /api/v1/enroll/consume/{id}?status=0|1` -> `enroll_manage` + +## Security Notes + +- `master` token is blocked by tenant middleware. +- Enroll revoke path now requires tenant auth and `session/admin`. +- WS login logs no longer print raw token values. diff --git a/docs/rbac-regression-checklist.md b/docs/rbac-regression-checklist.md new file mode 100644 index 0000000..559e7c1 --- /dev/null +++ b/docs/rbac-regression-checklist.md @@ -0,0 +1,65 @@ +# INP2P RBAC Regression Checklist + +> Replace placeholders: +> - `BASE=http://127.0.0.1:27183` +> - `ADMIN_TOKEN=` +> - `OP_TOKEN=` +> - `APIKEY_TOKEN=` + +## 1) enroll revoke must be admin-only + +```bash +curl -i -X POST "$BASE/api/v1/enroll/consume/1?status=0" \ + -H "Authorization: Bearer $OP_TOKEN" +# expect: 401/403 + +curl -i -X POST "$BASE/api/v1/enroll/consume/1?status=0" \ + -H "Authorization: Bearer $APIKEY_TOKEN" +# expect: 401/403 +``` + +## 2) apikey cannot mutate + +```bash +curl -i -X POST "$BASE/api/v1/sdwan/edit" \ + -H "Authorization: Bearer $APIKEY_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"enabled":true,"gatewayCIDR":"10.200.10.0/24"}' +# expect: 403 + +curl -i -X POST "$BASE/api/v1/nodes/kick" \ + -H "Authorization: Bearer $APIKEY_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"node":"demo-node"}' +# expect: 403 +``` + +## 3) operator can mutate common ops + +```bash +curl -i -X POST "$BASE/api/v1/connect" \ + -H "Authorization: Bearer $OP_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"from":"n1","to":"n2","srcPort":10080,"dstPort":80,"appName":"test"}' +# expect: 200 (if nodes exist) +``` + +## 4) tenant isolation + +```bash +# operator/admin from tenant A cannot operate tenant B node +curl -i -X POST "$BASE/api/v1/nodes/kick" \ + -H "Authorization: Bearer $OP_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"node":"tenantB-node"}' +# expect: 404 +``` + +## 5) verify audit logs created + +- actions expected in audit: + - `node_kick` + - `node_apps_push` + - `connect_request` + - `tenant_subnet_reassign` + - `sdwan_update` diff --git a/docs/release-onepager-oncall.md b/docs/release-onepager-oncall.md new file mode 100644 index 0000000..fc432c3 --- /dev/null +++ b/docs/release-onepager-oncall.md @@ -0,0 +1,34 @@ +# INP2P 发布值班版(30 秒) + +> 目标:最短路径完成发布并验收。 + +## 1) 一键流程(必须按顺序) + +```bash +cd /root/.openclaw/workspace/inp2p +make build +systemctl restart inp2ps.service +make verify-deploy +``` + +## 2) 成功判定(唯一标准) + +- `make verify-deploy` **全绿 PASS** +- 报告文件已生成: + - `run/deploy-check/deploy-check-YYYYmmdd-HHMMSS.txt` + +## 3) 快速回归(最小 6 项) + +- [ ] 能登录,停用账号会被拒绝 +- [ ] 节点页搜索可用,踢下线有确认 +- [ ] SDWAN 保存可用 +- [ ] Key/用户/enroll 单条操作可用 +- [ ] 批量操作 + 重试失败可用 +- [ ] 审计预设 + CSV 导出可用 + +## 4) 签字留档 + +- 版本:`________________` +- 报告:`run/deploy-check/deploy-check-________________.txt` +- 值班人:`________________` +- 时间:`________________` diff --git a/docs/release-onepager.md b/docs/release-onepager.md new file mode 100644 index 0000000..39109c8 --- /dev/null +++ b/docs/release-onepager.md @@ -0,0 +1,109 @@ +# INP2P 一页发布手册(P4.5 收官) + +更新时间:2026-03-08 +当前基线版本:`0.1.0+build.18` + +--- + +## 0) 适用范围 + +- 项目:`/root/.openclaw/workspace/inp2p` +- 服务:`inp2ps.service` +- 目标:每次发布都可复用本手册完成构建、部署、验收与留档 + +--- + +## 1) 标准发布流程(强制) + +```bash +cd /root/.openclaw/workspace/inp2p +make build +systemctl restart inp2ps.service +make verify-deploy +``` + +**判定规则:** +- 仅当 `make verify-deploy` 全绿,才算发布成功。 +- 仅重启成功不算发布成功。 + +**验收证据:** +- 报告路径:`run/deploy-check/deploy-check-YYYYmmdd-HHMMSS.txt` + +--- + +## 2) UI 交互规范(封板基线) + +### 2.1 按钮层级 +- `btn`:主路径提交(状态变更/写入) +- `btn2`:次级动作(筛选、导出、切换、重试) +- `btn-danger`:高风险动作(删除、踢下线、不可逆) + +### 2.2 文案规范 +- 刷新类统一“重新加载” +- 空态统一“暂无 xxx 记录 + 下一步引导” +- 批量反馈统一“成功/失败计数” + +### 2.3 快捷键规范 +- `/` 聚焦筛选 +- `r` 刷新 +- `g` 打开节点页 +- `?` 打开/关闭帮助 +- 输入法组合态、Ctrl/Alt/Meta 组合键不触发全局热键 + +--- + +## 3) 功能验收清单(最小集) + +1. 登录/登出正常,停用账号被拒绝 +2. 节点页:搜索、踢下线确认、改昵称/改IP +3. SDWAN:保存 SDWAN/节点映射/子网代理 +4. 租户/Key/用户/enroll:单条与批量操作可用 +5. 失败池:保留开关、重试、清空行为正确 +6. 审计:预设筛选、清空筛选、CSV 导出、失败事件导出 +7. 最近操作摘要(opSummary)正确回显 + +--- + +## 4) 本轮阶段成果(摘要) + +### P0(人性化收尾) +- 危险操作显式标红 +- 输入示例占位补齐 +- 复制快捷操作(Key/enroll) + +### P1(信息与审计可读性) +- 常用操作区 +- 审计 action 标签化 + 高风险高亮 +- 审计快速筛选与清空 +- 空态引导、最近操作摘要 + +### P2(效率提升) +- 审计 CSV 导出 + 失败事件导出 +- 快捷键(/ r g ?) +- 批量操作 + 二次确认 + 统计回执 + +### P3(稳定性增强) +- 批量并发执行 + 失败重试 +- 失败池保留/清空策略 +- 审计预设持久化 +- 热键冲突规避(输入法/组合键) + +### P4(封板) +- 按钮层级统一 +- 文案词典落地 +- 最终回归清单沉淀 + +--- + +## 5) 发布签字项(可复制) + +- [ ] 已执行 `make build` +- [ ] 已执行 `systemctl restart inp2ps.service` +- [ ] 已执行 `make verify-deploy` 且全绿 +- [ ] 已记录 deploy-check 报告路径 +- [ ] 已完成最小功能回归 +- [ ] 已确认线上版本号与 `VERSION` 一致 + +签字人:__________ +时间:__________ +报告:`run/deploy-check/deploy-check-________________.txt` diff --git a/internal/server/admin_settings.go b/internal/server/admin_settings.go index c69e0ed..a1f242d 100644 --- a/internal/server/admin_settings.go +++ b/internal/server/admin_settings.go @@ -37,7 +37,8 @@ func (s *Server) HandleAdminSettings(w http.ResponseWriter, r *http.Request) { } // allowlist switch req.Key { - case "advanced_impersonate", "advanced_force_network", "advanced_cross_tenant": + case "advanced_impersonate", "advanced_force_network", "advanced_cross_tenant", + "advanced_override_enabled", "allow_apikey_mutate_sdwan", "allow_operator_kick_cross_node", "allow_connect_cross_tenant": default: writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"invalid key"}`) return diff --git a/internal/server/audit_api.go b/internal/server/audit_api.go index 7c92cf6..7bfcf1f 100644 --- a/internal/server/audit_api.go +++ b/internal/server/audit_api.go @@ -6,7 +6,7 @@ import ( "strconv" ) -// GET /api/v1/admin/audit?tenant=3&limit=50&offset=0 +// GET /api/v1/admin/audit?tenant=3&limit=50&offset=0&action=node_kick&actor_type=session&actor_id=12&target_type=node&target_id=nodeA&from=1700000000&to=1700003600 func (s *Server) HandleAdminAudit(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -30,7 +30,27 @@ func (s *Server) HandleAdminAudit(w http.ResponseWriter, r *http.Request) { tenantID = i } } - logs, err := s.store.ListAuditLogs(tenantID, limit, offset) + + action := r.URL.Query().Get("action") + actorType := r.URL.Query().Get("actor_type") + actorID := r.URL.Query().Get("actor_id") + targetType := r.URL.Query().Get("target_type") + targetID := r.URL.Query().Get("target_id") + + fromTs := int64(0) + toTs := int64(0) + if v := r.URL.Query().Get("from"); v != "" { + if i, err := strconv.ParseInt(v, 10, 64); err == nil && i > 0 { + fromTs = i + } + } + if v := r.URL.Query().Get("to"); v != "" { + if i, err := strconv.ParseInt(v, 10, 64); err == nil && i > 0 { + toTs = i + } + } + + logs, err := s.store.ListAuditLogs(tenantID, limit, offset, action, actorType, actorID, targetType, targetID, fromTs, toTs) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/internal/server/authz.go b/internal/server/authz.go index 6d0145d..61973d6 100644 --- a/internal/server/authz.go +++ b/internal/server/authz.go @@ -3,6 +3,7 @@ package server import ( "net/http" "strconv" + "strings" ) type AccessContext struct { @@ -63,3 +64,66 @@ func (s *Server) ResolveTenantAccessToken(tok string) (*AccessContext, bool) { return nil, false } + +// TenantCan implements action-level RBAC for tenant-scoped APIs. +func TenantCan(ac *AccessContext, action string) bool { + if ac == nil { + return false + } + switch action { + case "view": + return (ac.Kind == "session" && (ac.Role == "admin" || ac.Role == "operator")) || ac.Kind == "apikey" + case "mutate_node_meta", "mutate_sdwan", "mutate_connect", "mutate_node_control": + return ac.Kind == "session" && (ac.Role == "admin" || ac.Role == "operator") + case "enroll_manage": + return ac.Kind == "session" && ac.Role == "admin" + default: + return false + } +} + +func settingOn(v string) bool { + v = strings.TrimSpace(strings.ToLower(v)) + return v == "1" || v == "true" || v == "on" || v == "yes" +} + +// TenantCanWithOverride evaluates baseline RBAC then optional advanced override switches. +// Return values: allowed, usedOverride +func (s *Server) TenantCanWithOverride(ac *AccessContext, action string) (bool, bool) { + if TenantCan(ac, action) { + return true, false + } + if ac == nil || s == nil || s.store == nil || ac.TenantID <= 0 { + return false, false + } + if ac.Kind == "master" { + return false, false + } + // global override gate + if v, _, err := s.store.GetSetting("advanced_override_enabled"); err != nil || !settingOn(v) { + return false, false + } + + switch action { + case "mutate_sdwan": + if ac.Kind == "apikey" { + if v, _, err := s.store.GetSetting("allow_apikey_mutate_sdwan"); err == nil && settingOn(v) { + return true, true + } + } + case "mutate_connect": + if ac.Kind == "session" && ac.Role == "operator" { + if v, _, err := s.store.GetSetting("allow_connect_cross_tenant"); err == nil && settingOn(v) { + return true, true + } + } + case "mutate_node_control": + if ac.Kind == "session" && ac.Role == "operator" { + if v, _, err := s.store.GetSetting("allow_operator_kick_cross_node"); err == nil && settingOn(v) { + return true, true + } + } + } + + return false, false +} diff --git a/internal/server/inp2ps.db-shm b/internal/server/inp2ps.db-shm new file mode 100644 index 0000000..d666490 Binary files /dev/null and b/internal/server/inp2ps.db-shm differ diff --git a/internal/server/inp2ps.db-wal b/internal/server/inp2ps.db-wal new file mode 100644 index 0000000..d68fb09 Binary files /dev/null and b/internal/server/inp2ps.db-wal differ diff --git a/internal/server/node_api.go b/internal/server/node_api.go index 73ef6ee..2e1d163 100644 --- a/internal/server/node_api.go +++ b/internal/server/node_api.go @@ -2,6 +2,7 @@ package server import ( "encoding/json" + "fmt" "net/http" "strings" @@ -22,6 +23,10 @@ func (s *Server) HandleNodeMeta(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"unauthorized"}`) return } + if !TenantCan(ac, "mutate_node_meta") { + writeJSON(w, http.StatusForbidden, `{"error":1,"message":"forbidden"}`) + return + } if strings.HasSuffix(r.URL.Path, "/alias") { var req struct { @@ -36,6 +41,12 @@ func (s *Server) HandleNodeMeta(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"`+err.Error()+`"}`) return } + actorID := "0" + if ac != nil { + actorID = fmt.Sprintf("%d", ac.UserID) + } + detail := fmt.Sprintf("tenant=%d node_uuid=%s alias=%s", ac.TenantID, req.NodeUUID, req.Alias) + _ = s.store.AddAuditLog(ac.Kind, actorID, "node_alias_update", "node", req.NodeUUID, detail, r.RemoteAddr) writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`) return } @@ -53,6 +64,12 @@ func (s *Server) HandleNodeMeta(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"`+err.Error()+`"}`) return } + actorID := "0" + if ac != nil { + actorID = fmt.Sprintf("%d", ac.UserID) + } + detail := fmt.Sprintf("tenant=%d node_uuid=%s virtual_ip=%s", ac.TenantID, req.NodeUUID, req.VirtualIP) + _ = s.store.AddAuditLog(ac.Kind, actorID, "node_ip_update", "node", req.NodeUUID, detail, r.RemoteAddr) nodes := s.GetOnlineNodesByTenant(ac.TenantID) affectedNode := "" diff --git a/internal/server/sdwan_api.go b/internal/server/sdwan_api.go index 51b0edd..7a85eb1 100644 --- a/internal/server/sdwan_api.go +++ b/internal/server/sdwan_api.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "net/netip" + "strings" "github.com/openp2p-cn/inp2p/pkg/protocol" ) @@ -35,17 +36,50 @@ func (s *Server) SetSDWANTenant(tenantID int64, cfg protocol.SDWANConfig, actorT return errors.New("hub node must be online and relay-enabled") } } + + cfg = s.rewriteTenantSDWAN(tenantID, cfg) + if err := s.sdwan.saveTenant(tenantID, cfg); err != nil { return err } if actorType != "" && s.store != nil { - detail := fmt.Sprintf("mode=%s hub=%s nodes=%d subnetProxies=%d", cfg.Mode, cfg.HubNode, len(cfg.Nodes), len(cfg.SubnetProxies)) + detail := fmt.Sprintf("mode=%s hub=%s nodes=%d subnetProxies=%d gateway=%s", cfg.Mode, cfg.HubNode, len(cfg.Nodes), len(cfg.SubnetProxies), cfg.GatewayCIDR) _ = s.store.AddAuditLog(actorType, actorID, "sdwan_update", "tenant", fmt.Sprintf("%d", tenantID), detail, ip) } s.broadcastSDWANTenant(tenantID, s.sdwan.getTenant(tenantID)) return nil } +func (s *Server) rewriteTenantSDWAN(tenantID int64, cfg protocol.SDWANConfig) protocol.SDWANConfig { + if s.store != nil { + if ten, err := s.store.GetTenantByID(tenantID); err == nil && ten != nil && ten.Subnet != "" { + cfg.GatewayCIDR = ten.Subnet + // keep routes aligned with subnet + found := false + for _, r := range cfg.Routes { + if strings.TrimSpace(r) == ten.Subnet { + found = true + break + } + } + if !found { + cfg.Routes = []string{ten.Subnet} + } + } + } + return cfg +} + +func (s *Server) ReconcileTenantSDWAN(tenantID int64) { + cfg := s.sdwan.getTenant(tenantID) + cfg = s.rewriteTenantSDWAN(tenantID, cfg) + if err := s.sdwan.saveTenant(tenantID, cfg); err != nil { + log.Printf("[sdwan] reconcile tenant=%d failed: %v", tenantID, err) + return + } + s.broadcastSDWANTenant(tenantID, cfg) +} + func (s *Server) broadcastSDWAN(cfg protocol.SDWANConfig) { if !cfg.Enabled || cfg.GatewayCIDR == "" { return diff --git a/internal/server/server.go b/internal/server/server.go index 085fc09..92360a1 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -262,7 +262,7 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) { // Verify token: master token OR tenant API key (DB) OR node_secret (DB) valid := s.tokens[loginReq.Token] - log.Printf("[server] login check: token=%d, cfg.Token=%d, valid=%v", loginReq.Token, s.cfg.Token, valid) + log.Printf("[server] login check: node=%s valid=%v", loginReq.Node, valid) var tenantID int64 if !valid && s.store != nil { // try api key (string) or node secret diff --git a/internal/server/user_api.go b/internal/server/user_api.go index 3f81bf5..f6c9a32 100644 --- a/internal/server/user_api.go +++ b/internal/server/user_api.go @@ -170,7 +170,8 @@ func (s *Server) HandleAdminUsers(w http.ResponseWriter, r *http.Request) { return } if ac := GetAccessContext(r); ac != nil { - _ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "user_create", "tenant", fmt.Sprintf("%d", req.TenantID), req.Email, r.RemoteAddr) + detail := fmt.Sprintf("username=%s role=%s", req.Email, req.Role) + _ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "user_create", "tenant", fmt.Sprintf("%d", req.TenantID), detail, r.RemoteAddr) } writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`) } diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 0000000..bd5d3e4 --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BASE_FILE="$ROOT_DIR/VERSION_BASE" +SEQ_FILE="$ROOT_DIR/BUILD_SEQ" +VER_FILE="$ROOT_DIR/VERSION" + +# 初始化基础版本 +if [[ ! -f "$BASE_FILE" ]]; then + if [[ -f "$VER_FILE" ]]; then + cur="$(tr -d ' \n\r\t' < "$VER_FILE")" + # 取 +build 之前的基础版本 + base="${cur%%+build.*}" + [[ -z "$base" ]] && base="0.1.0" + echo "$base" > "$BASE_FILE" + else + echo "0.1.0" > "$BASE_FILE" + fi +fi + +# 初始化构建序号 +if [[ ! -f "$SEQ_FILE" ]]; then + echo "0" > "$SEQ_FILE" +fi + +base="$(tr -d ' \n\r\t' < "$BASE_FILE")" +seq="$(tr -d ' \n\r\t' < "$SEQ_FILE")" + +if ! [[ "$seq" =~ ^[0-9]+$ ]]; then + seq=0 +fi + +seq=$((seq + 1)) +new_ver="${base}+build.${seq}" + +echo "$seq" > "$SEQ_FILE" +echo "$new_ver" > "$VER_FILE" + +echo "$new_ver" diff --git a/scripts/deploy-check.sh b/scripts/deploy-check.sh new file mode 100755 index 0000000..099f0d4 --- /dev/null +++ b/scripts/deploy-check.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +EXPECTED_VERSION="$(tr -d ' \n\r\t' < "$ROOT_DIR/VERSION")" +REPORT_DIR="$ROOT_DIR/run/deploy-check" +TS="$(date +%Y%m%d-%H%M%S)" +REPORT="$REPORT_DIR/deploy-check-$TS.txt" + +mkdir -p "$REPORT_DIR" + +pass() { echo "[PASS] $*" | tee -a "$REPORT"; } +fail() { echo "[FAIL] $*" | tee -a "$REPORT"; FAIL_COUNT=$((FAIL_COUNT+1)); } +info() { echo "[INFO] $*" | tee -a "$REPORT"; } + +FAIL_COUNT=0 + +info "root=$ROOT_DIR" +info "expected_version=$EXPECTED_VERSION" + +# 1) 二进制版本校验 +for bin in "$ROOT_DIR/bin/inp2ps" "$ROOT_DIR/bin/inp2pc"; do + if [[ ! -x "$bin" ]]; then + fail "binary missing or not executable: $bin" + continue + fi + ver_out="$($bin -version 2>/dev/null || true)" + if [[ -z "$ver_out" ]]; then + fail "cannot read version: $bin" + continue + fi + got_ver="$(echo "$ver_out" | awk '/^inp2p[sc] version/{print $3; exit}')" + if [[ "$got_ver" == "$EXPECTED_VERSION" ]]; then + pass "$(basename "$bin") version ok: $got_ver" + else + fail "$(basename "$bin") version mismatch: got=$got_ver expected=$EXPECTED_VERSION" + fi + echo "$ver_out" | sed 's/^/[BIN] /' | tee -a "$REPORT" >/dev/null + done + +# 2) systemd 进程与路径校验(仅 inp2ps.service) +if systemctl show inp2ps.service -p Id --value >/dev/null 2>&1; then + pass "inp2ps.service exists" + active_state="$(systemctl is-active inp2ps.service || true)" + if [[ "$active_state" == "active" ]]; then + pass "inp2ps.service active" + else + fail "inp2ps.service not active: $active_state" + fi + + main_pid="$(systemctl show inp2ps.service -p MainPID --value 2>/dev/null || echo 0)" + if [[ -n "$main_pid" && "$main_pid" != "0" ]]; then + pass "inp2ps.service main pid: $main_pid" + exe_path="$(readlink -f "/proc/$main_pid/exe" 2>/dev/null || true)" + if [[ "$exe_path" == "$ROOT_DIR/bin/inp2ps" ]]; then + pass "inp2ps.service uses expected binary: $exe_path" + else + fail "inp2ps.service binary path mismatch: $exe_path" + fi + else + fail "inp2ps.service MainPID is empty/0" + fi +else + fail "inp2ps.service not found" +fi + +# 3) 残留进程检查(inp2ps/inp2pc) +mapfile -t pids < <(pgrep -f '(^|/)inp2p[sc](\s|$)' || true) +if [[ ${#pids[@]} -eq 0 ]]; then + pass "no inp2p processes running" +else + info "found inp2p-related pids: ${pids[*]}" + allow_pid="$(systemctl show inp2ps.service -p MainPID --value 2>/dev/null || echo 0)" + residual=0 + for pid in "${pids[@]}"; do + exe="$(readlink -f "/proc/$pid/exe" 2>/dev/null || true)" + cmd="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || true)" + echo "[PROC] pid=$pid exe=$exe cmd=$cmd" | tee -a "$REPORT" >/dev/null + + if [[ "$pid" == "$allow_pid" ]]; then + continue + fi + # 允许合法 systemd-run 的 inp2pc 客户端由独立服务名管理:后续可加白名单 + residual=$((residual+1)) + done + + if [[ $residual -eq 0 ]]; then + pass "no residual inp2p process (except inp2ps main pid=$allow_pid)" + else + fail "residual inp2p processes detected: $residual" + fi +fi + +# 4) 旧二进制残留(根目录/临时后缀) +residual_bins=0 +while IFS= read -r f; do + [[ -z "$f" ]] && continue + echo "[FILE] residual-bin: $f" | tee -a "$REPORT" >/dev/null + residual_bins=$((residual_bins+1)) +done < <(find "$ROOT_DIR" -maxdepth 2 -type f \( -name 'inp2ps.old*' -o -name 'inp2pc.old*' -o -name 'inp2ps.new*' -o -name 'inp2pc.new*' -o -path "$ROOT_DIR/inp2ps" -o -path "$ROOT_DIR/inp2pc" \) 2>/dev/null) + +if [[ $residual_bins -eq 0 ]]; then + pass "no residual old/new binaries found" +else + fail "residual binary files found: $residual_bins" +fi + +# 5) 输出总结 +if [[ $FAIL_COUNT -eq 0 ]]; then + pass "deploy check passed" + info "report=$REPORT" + exit 0 +else + fail "deploy check failed (count=$FAIL_COUNT)" + info "report=$REPORT" + exit 1 +fi diff --git a/web/app/actions.js b/web/app/actions.js new file mode 100644 index 0000000..749be25 --- /dev/null +++ b/web/app/actions.js @@ -0,0 +1,634 @@ +window.INP2P = window.INP2P || {}; + +window.INP2P.createActions = function createActions(state, deps) { + const { api, loginByPassword, Vue } = deps; + + const refreshAll = async () => { + if (!state.loggedIn.value) return; + state.globalBusy.value = true; + try { + const [h, s] = await Promise.all([api('/api/v1/health'), api('/api/v1/stats')]); + state.health.value = (h && typeof h === 'object') ? h : {}; + state.stats.value = (s && typeof s === 'object') ? s : {}; + + const nd = await api('/api/v1/nodes'); + state.nodes.value = Array.isArray(nd?.nodes) ? nd.nodes : []; + + try { + const sdRes = await api('/api/v1/sdwans'); + state.sd.value = (sdRes && typeof sdRes === 'object') ? sdRes : { nodes: [], routes: [], subnetProxies: [] }; + if (!Array.isArray(state.sd.value.nodes)) state.sd.value.nodes = []; + if (!Array.isArray(state.sd.value.routes)) state.sd.value.routes = []; + if (!Array.isArray(state.sd.value.subnetProxies)) state.sd.value.subnetProxies = []; + + // tenant subnet locked by backend: keep UI aligned + if (!state.isAdmin.value && state.subnet.value) { + state.sd.value.gatewayCIDR = state.subnet.value; + if (!state.sd.value.routes.length) { + state.sd.value.routes = [state.subnet.value]; + } + } + } catch (e) { + state.toast('SDWAN 加载失败:' + (state.humanizeError ? state.humanizeError(e.message || '') : (e.message || '')), 'error'); + } + + if (state.isAdmin.value) { + try { + const settingsRes = await api('/api/v1/admin/settings'); + state.settings.value = (settingsRes && typeof settingsRes === 'object') ? settingsRes : state.settings.value; + } catch(_) {} + try { + const a = await api('/api/v1/admin/audit?limit=50'); + state.audit.value = Array.isArray(a?.logs) ? a.logs : []; + } catch(_) {} + } + } catch (e) { + state.toast(state.humanizeError ? state.humanizeError(e.message || '刷新失败') : (e.message || '刷新失败'), 'error'); + } finally { + state.globalBusy.value = false; + } + }; + + const stopTimer = () => { + if (state.timer.value) { + clearInterval(state.timer.value); + state.timer.value = null; + } + }; + + const startTimer = () => { + stopTimer(); + if (!state.loggedIn.value) return; + const sec = Math.max(5, Number(state.refreshSec.value || 15)); + state.timer.value = setInterval(refreshAll, sec * 1000); + }; + + const login = async () => { + state.loginErr.value = ''; + state.loginBusy.value = true; + try { + const uname = (state.loginUser.value || '').trim(); + const uerr = state.validateUsername ? state.validateUsername(uname) : ''; + if (uerr) throw new Error(uerr); + const d = await loginByPassword(uname, state.loginPass.value); + if (d.error) throw new Error(d.message || '用户名密码登录失败'); + localStorage.setItem('t', d.token || ''); + state.role.value = d.role || ''; + state.status.value = d.status ?? 1; + state.tokenType.value = d.token_type || 'session'; + state.tenantId.value = d.tenant_id || 0; + state.subnet.value = d.subnet || ''; + if (state.status.value !== 1) throw new Error('账号已停用'); + state.loggedIn.value = true; + await refreshAll(); + state.toast('登录成功'); + } catch (e) { + state.loginErr.value = e.message || '登录失败'; + } finally { + state.loginBusy.value = false; + } + }; + + const logout = () => { + localStorage.removeItem('t'); + localStorage.removeItem('master_t'); + state.loggedIn.value = false; + state.role.value = ''; + state.tokenType.value = ''; + state.tenantId.value = 0; + state.subnet.value = ''; + stopTimer(); + }; + + const addSDWANNode = () => state.sd.value.nodes = [...(state.sd.value.nodes || []), { node:'', ip:'' }]; + const removeSDWANNode = i => state.sd.value.nodes.splice(i, 1); + const addSubnetProxy = () => state.sd.value.subnetProxies = [...(state.sd.value.subnetProxies || []), { node:'', localCIDR:'', virtualCIDR:'' }]; + const removeSubnetProxy = i => state.sd.value.subnetProxies.splice(i, 1); + const autoAssignIPs = () => { + const used = new Set(); + (state.sd.value.nodes || []).forEach(n => { const p = (n.ip||'').split('.'); if (p.length===4) used.add(Number(p[3])); }); + let k = 2; + for (const n of state.sd.value.nodes || []) { + if (!n.ip) { + while (used.has(k) && k < 254) k++; + n.ip = `10.10.0.${k}`; + used.add(k); + k++; + } + } + state.toast('已自动分配缺失 IP'); + }; + + const saveSDWAN = async () => { + const verr = state.validateSDWAN ? state.validateSDWAN(state.sd.value) : ''; + if (verr) { + state.toast(verr, 'error'); + state.logEvent && state.logEvent('error', { tab:'sdwan', action:'save', message:verr }); + return; + } + state.beginTab('sdwan'); + try { + await api('/api/v1/sdwan/edit', { method:'POST', body: JSON.stringify(state.sd.value) }); + state.toast('SDWAN 保存成功'); + await refreshAll(); + state.endTabOk('sdwan', !(state.sd.value?.nodes||[]).length); + } catch (e) { state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('sdwan'); state.logEvent && state.logEvent('error', { tab:'sdwan', action:'save', message:e.message||'' }); } + }; + + const kickNode = async (node) => { + if (!confirm(`高风险操作:踢下线节点 ${node}\n这会中断该节点现有连接。确认继续?`)) return; + try { await api('/api/v1/nodes/kick', { method:'POST', body: JSON.stringify({ node }) }); state.toast('已发送踢下线'); refreshAll(); } + catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); } + }; + + const openNodeEdit = (node, mode='alias') => { + if (!node || !node.nodeUUID) return state.toast('该节点尚无UUID,稍后重连后再试', 'error'); + state.nodeEditNode.value = node; + state.nodeEditMode.value = mode; + state.nodeEditTitle.value = mode === 'alias' ? '编辑设备昵称' : '编辑设备虚拟IP'; + state.nodeEditValue.value = mode === 'alias' ? (node.alias || '') : (node.virtualIP || ''); + state.nodeEditVisible.value = true; + }; + + const closeNodeEdit = () => { + state.nodeEditVisible.value = false; + state.nodeEditNode.value = null; + state.nodeEditValue.value = ''; + }; + + const submitNodeEdit = async () => { + const node = state.nodeEditNode.value; + if (!node || !node.nodeUUID) return; + const val = String(state.nodeEditValue.value || '').trim(); + try { + if (state.nodeEditMode.value === 'alias') { + await api('/api/v1/nodes/alias', { method:'POST', body: JSON.stringify({ node_uuid: node.nodeUUID, alias: val }) }); + state.toast('昵称已更新'); + } else { + if (!val) return state.toast('虚拟IP不能为空', 'error'); + await api('/api/v1/nodes/ip', { method:'POST', body: JSON.stringify({ node_uuid: node.nodeUUID, virtual_ip: val }) }); + state.toast('IP已更新,节点将按新IP重连'); + } + closeNodeEdit(); + refreshAll(); + } catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); } + }; + + const renameNode = async (node) => openNodeEdit(node, 'alias'); + const changeNodeIP = async (node) => openNodeEdit(node, 'ip'); + + const appPushNode = Vue.ref(''); + const appPushRaw = Vue.ref('[{"appName":"demo","protocol":"tcp","srcPort":8080,"peerNode":"","dstHost":"127.0.0.1","dstPort":80,"enabled":1}]'); + let switchTabRef = null; + const setSwitchTab = fn => { switchTabRef = fn; }; + const openAppManager = (node) => { appPushNode.value = node; state.toast(`已选中 ${node},请在控制台执行推配置`); if (switchTabRef) switchTabRef('p2p'); }; + const openConnect = (from) => { state.connectForm.value.from = from; if (switchTabRef) switchTabRef('p2p'); }; + + const pushAppConfigs = async () => { + if (!appPushNode.value) return state.toast('请先选择节点', 'error'); + let apps; + try { apps = JSON.parse(appPushRaw.value); } catch(_) { return state.toast('配置 JSON 格式错误', 'error'); } + state.setTabBusy('p2p', true); + try { await api('/api/v1/nodes/apps', { method:'POST', body: JSON.stringify({ node: appPushNode.value, apps }) }); state.toast('配置已推送'); } + catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); } + finally { state.setTabBusy('p2p', false); } + }; + + const doConnect = async () => { + const req = { ...state.connectForm.value }; + if (!req.from || !req.to) return state.toast('请选择 from/to 节点', 'error'); + state.setTabBusy('p2p', true); + try { + await api('/api/v1/connect', { method:'POST', body: JSON.stringify(req) }); + state.toast('连接请求已发送'); + } catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); } + finally { state.setTabBusy('p2p', false); } + }; + + const loadTenants = async () => { + if (!state.isAdmin.value) { state.tenants.value = []; state.tabState.value = { ...state.tabState.value, tenants:'empty' }; return; } + state.beginTab('tenants'); + try { + const d = await api('/api/v1/admin/tenants'); + state.tenants.value = d.tenants || []; + state.endTabOk('tenants', !state.tenants.value.length); + } catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); } + }; + + const createTenant = async () => { + const terr = state.validateRequired ? state.validateRequired(state.tenantForm.value.name, '租户名') : (!state.tenantForm.value.name ? '租户名不能为空' : ''); + if (terr) return state.toast(terr, 'error'); + state.beginTab('tenants'); + try { + const tenantName = state.tenantForm.value.name; + await api('/api/v1/admin/tenants', { method:'POST', body: JSON.stringify(state.tenantForm.value) }); + state.tenantForm.value = { name:'', admin_password:'', operator_password:'' }; + state.toast('租户创建成功'); state.opSummary.value = `已创建租户:${tenantName}`; + await loadTenants(); + } catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); } + }; + + const setTenantStatus = async (id, st) => { + if (!confirm(`确认将租户 ${id} ${st===1?'启用':'停用'}?`)) return; + try { await api(`/api/v1/admin/tenants/${id}?status=${st}`, { method:'POST', body:'{}' }); state.toast('租户状态已更新'); state.opSummary.value = `租户 ${id} 已${st===1?'启用':'停用'}`; loadTenants(); } + catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); } + }; + + const reassignTenantSubnet = async (id, currentSubnet='') => { + if (!state.isAdmin.value) return; + const next = prompt('输入新的租户子网(仅 10.200.x.0/24)', currentSubnet || ''); + if (next === null) return; + const subnet = String(next || '').trim(); + if (!subnet) return state.toast('子网不能为空', 'error'); + try { + await api(`/api/v1/admin/tenants/${id}/subnet`, { method:'POST', body: JSON.stringify({ subnet }) }); + state.toast('租户子网已重分配'); + await loadTenants(); + try { await refreshAll(); } catch(_) {} + } catch(e){ state.toast(e.message || '重分配失败', 'error'); } + }; + + const loadKeys = async () => { + if (!state.activeTenant.value) return; + state.beginTab('tenants'); + try { + const d = await api(`/api/v1/admin/tenants/${state.activeTenant.value}/keys`); + state.keys.value = d.keys || []; + state.selectedKeyIds.value = []; + if (!state.keepFailedPool.value) state.bulkFailedKeyIds.value = []; + state.endTabOk('tenants', !state.keys.value.length); + } catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); } + }; + + const createKey = async () => { + if (!state.activeTenant.value) return state.toast('请先填写 Tenant ID', 'error'); + const scopeErr = state.validateRequired ? state.validateRequired(state.keyForm.value.scope, 'scope') : ''; + if (scopeErr) return state.toast(scopeErr, 'error'); + state.beginTab('tenants'); + try { + const d = await api(`/api/v1/admin/tenants/${state.activeTenant.value}/keys`, { method:'POST', body: JSON.stringify(state.keyForm.value) }); + state.toast(`API Key 创建成功: ${d.api_key || ''}`); + await loadKeys(); + } catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); } + }; + + const setKeyStatus = async (id, st) => { + if (!confirm(`确认将 Key ${id} ${st===1?'启用':'停用'}?`)) return; + try { await api(`/api/v1/admin/tenants/${state.activeTenant.value}/keys/${id}?status=${st}`, { method:'POST', body:'{}' }); state.toast('Key 状态已更新'); state.opSummary.value = `Key ${id} 已${st===1?'启用':'停用'}`; loadKeys(); } + catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); } + }; + + const loadUsers = async () => { + if (!state.activeTenant.value) return; + state.beginTab('tenants'); + try { + const d = await api(`/api/v1/admin/users?tenant=${state.activeTenant.value}`); + state.users.value = d.users || []; + state.selectedUserIds.value = []; + if (!state.keepFailedPool.value) state.bulkFailedUserIds.value = []; + state.endTabOk('tenants', !state.users.value.length); + } catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); } + }; + + const createUser = async () => { + if (!state.activeTenant.value) return state.toast('请先填写 Tenant ID', 'error'); + const e1 = state.validateRequired ? state.validateRequired(state.userForm.value.email, 'email/username') : ''; + const e2 = state.validateRequired ? state.validateRequired(state.userForm.value.password, 'password') : ''; + if (e1 || e2) return state.toast(e1 || e2, 'error'); + state.beginTab('tenants'); + try { + const createdUser = state.userForm.value.email; + await api('/api/v1/admin/users', { method:'POST', body: JSON.stringify({ tenant: Number(state.activeTenant.value), ...state.userForm.value }) }); + state.userForm.value = { role:'operator', email:'', password:'' }; + state.toast('用户创建成功'); state.opSummary.value = `已创建用户:${createdUser}`; + loadUsers(); + } catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); } + }; + + const setUserStatus = async (id, st) => { + if (!confirm(`确认将用户 ${id} ${st===1?'启用':'停用'}?`)) return; + try { await api(`/api/v1/admin/users/${id}?status=${st}`, { method:'POST', body:'{}' }); state.toast('用户状态已更新'); state.opSummary.value = `用户 ${id} 已${st===1?'启用':'停用'}`; loadUsers(); } + catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); } + }; + + const resetUserPassword = async (id) => { + const p = prompt('请输入新密码(至少6位)'); + if (!p) return; + try { await api(`/api/v1/admin/users/${id}/password`, { method:'POST', body: JSON.stringify({ password: p }) }); state.toast('密码已重置'); } + catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); } + }; + + const deleteUser = async (id) => { + if (!confirm('高风险操作:删除用户后不可恢复。\n确认继续?')) return; + try { await api(`/api/v1/admin/users/${id}/delete`, { method:'POST', body:'{}' }); state.toast('用户已删除'); state.opSummary.value = `用户 ${id} 已删除` ; loadUsers(); } + catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); } + }; + + const loadEnrolls = async () => { + state.beginTab('tenants'); + try { + const d = await api('/api/v1/tenants/enroll'); + state.enrolls.value = d.enrolls || []; + state.selectedEnrollIds.value = []; + if (!state.keepFailedPool.value) state.bulkFailedEnrollIds.value = []; + state.endTabOk('tenants', !state.enrolls.value.length); + } catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); } + }; + + const createEnroll = async () => { + state.beginTab('tenants'); + try { + const d = await api('/api/v1/tenants/enroll', { method:'POST', body:'{}' }); + state.toast(`enroll_code: ${d.enroll_code || ''}`); state.opSummary.value = `已生成 enroll code(租户 ${state.activeTenant.value})`; + loadEnrolls(); + } catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('tenants'); } + }; + + const setEnrollStatus = async (id, st) => { + try { await api(`/api/v1/enroll/consume/${id}?status=${st}`, { method:'POST', body:'{}' }); state.toast('enroll 状态已更新'); state.opSummary.value = `enroll ${id} 状态已更新为 ${st}`; loadEnrolls(); } + catch(e){ state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); } + }; + + const consumeEnroll = async () => { + state.toast('consumeEnroll 为客户端配网流程,控制台当前不直接调用'); + }; + + + const toggleAllKeys = () => { + const ids = (state.filteredKeys.value || []).map(k => Number(k.id)).filter(Boolean); + if (!ids.length) return; + if ((state.selectedKeyIds.value || []).length === ids.length) state.selectedKeyIds.value = []; + else state.selectedKeyIds.value = ids; + }; + + const toggleAllUsers = () => { + const ids = (state.filteredUsers.value || []).map(u => Number(u.id)).filter(Boolean); + if (!ids.length) return; + if ((state.selectedUserIds.value || []).length === ids.length) state.selectedUserIds.value = []; + else state.selectedUserIds.value = ids; + }; + + const toggleAllEnrolls = () => { + const ids = (state.filteredEnrolls.value || []).map(e => Number(e.id)).filter(Boolean); + if (!ids.length) return; + if ((state.selectedEnrollIds.value || []).length === ids.length) state.selectedEnrollIds.value = []; + else state.selectedEnrollIds.value = ids; + }; + + const sleep = (ms) => new Promise(r => setTimeout(r, ms)); + const runWithRetry = async (fn, retries = 1) => { + let lastErr = null; + for (let i = 0; i <= retries; i++) { + try { return await fn(); } + catch (e) { lastErr = e; if (i < retries) await sleep(120); } + } + throw lastErr || new Error('unknown error'); + }; + + const runBulk = async ({ ids, confirmText, onEach, afterReload, summaryLabel, setFailedIds }) => { + const list = (ids || []).slice(); + if (!list.length) return false; + if (!confirm(confirmText)) return false; + + state.setTabBusy('tenants', true); + let ok = 0, fail = 0; + const failedIds = []; + let cursor = 0; + const workers = Array.from({ length: Math.min(6, list.length) }, async () => { + while (cursor < list.length) { + const idx = cursor++; + const id = list[idx]; + try { + await runWithRetry(() => onEach(id), 1); + ok++; + } catch (_) { + fail++; + failedIds.push(id); + } + } + }); + + await Promise.all(workers); + state.toast(`${summaryLabel}完成:成功 ${ok},失败 ${fail}${fail ? '(失败ID可重试)' : ''}`); + if (setFailedIds) setFailedIds(failedIds); + const failedShow = failedIds.slice(0, 8).join(','); + state.opSummary.value = `${summaryLabel}:成功 ${ok} / 失败 ${fail}${failedShow ? `|失败ID: ${failedShow}${failedIds.length>8?'...':''}` : ''}`; + try { await afterReload(); } finally { state.setTabBusy('tenants', false); } + return true; + }; + + const bulkSetKeyStatus = async (st) => { + const ids = (state.selectedKeyIds.value || []).slice(); + if (!ids.length) return state.toast('请先选择至少 1 个 Key', 'error'); + if (!state.activeTenant.value) return state.toast('请先填写 Tenant ID', 'error'); + await runBulk({ + ids, + confirmText: `批量操作确认:将 ${ids.length} 个 Key ${st===1?'启用':'停用'},是否继续?`, + onEach: (id) => api(`/api/v1/admin/tenants/${state.activeTenant.value}/keys/${id}?status=${st}`, { method:'POST', body:'{}' }), + afterReload: loadKeys, + summaryLabel: `Key 批量${st===1?'启用':'停用'}`, + setFailedIds: (arr) => { state.bulkFailedKeyIds.value = arr; } + }); + }; + + const bulkSetUserStatus = async (st) => { + const ids = (state.selectedUserIds.value || []).slice(); + if (!ids.length) return state.toast('请先选择至少 1 个用户', 'error'); + await runBulk({ + ids, + confirmText: `批量操作确认:将 ${ids.length} 个用户 ${st===1?'启用':'停用'},是否继续?`, + onEach: (id) => api(`/api/v1/admin/users/${id}?status=${st}`, { method:'POST', body:'{}' }), + afterReload: loadUsers, + summaryLabel: `用户批量${st===1?'启用':'停用'}`, + setFailedIds: (arr) => { state.bulkFailedUserIds.value = arr; } + }); + }; + + const bulkSetEnrollStatus = async (st) => { + const ids = (state.selectedEnrollIds.value || []).slice(); + if (!ids.length) return state.toast('请先选择至少 1 个 enroll', 'error'); + await runBulk({ + ids, + confirmText: `批量操作确认:将 ${ids.length} 个 enroll 置为状态 ${st},是否继续?`, + onEach: (id) => api(`/api/v1/enroll/consume/${id}?status=${st}`, { method:'POST', body:'{}' }), + afterReload: loadEnrolls, + summaryLabel: `enroll 批量状态变更`, + setFailedIds: (arr) => { state.bulkFailedEnrollIds.value = arr; } + }); + }; + + + const retryFailedKeyOps = async (st=0) => { + const ids = (state.bulkFailedKeyIds.value || []).slice(); + if (!ids.length) return state.toast('没有可重试的 Key 失败项', 'error'); + await runBulk({ + ids, + confirmText: `重试确认:重试 ${ids.length} 个失败 Key 操作,是否继续?`, + onEach: (id) => api(`/api/v1/admin/tenants/${state.activeTenant.value}/keys/${id}?status=${st}`, { method:'POST', body:'{}' }), + afterReload: loadKeys, + summaryLabel: 'Key 失败项重试', + setFailedIds: (arr) => { state.bulkFailedKeyIds.value = arr; } + }); + }; + + const retryFailedUserOps = async (st=0) => { + const ids = (state.bulkFailedUserIds.value || []).slice(); + if (!ids.length) return state.toast('没有可重试的用户失败项', 'error'); + await runBulk({ + ids, + confirmText: `重试确认:重试 ${ids.length} 个失败用户操作,是否继续?`, + onEach: (id) => api(`/api/v1/admin/users/${id}?status=${st}`, { method:'POST', body:'{}' }), + afterReload: loadUsers, + summaryLabel: '用户失败项重试', + setFailedIds: (arr) => { state.bulkFailedUserIds.value = arr; } + }); + }; + + const retryFailedEnrollOps = async (st=0) => { + const ids = (state.bulkFailedEnrollIds.value || []).slice(); + if (!ids.length) return state.toast('没有可重试的 enroll 失败项', 'error'); + await runBulk({ + ids, + confirmText: `重试确认:重试 ${ids.length} 个失败 enroll 操作,是否继续?`, + onEach: (id) => api(`/api/v1/enroll/consume/${id}?status=${st}`, { method:'POST', body:'{}' }), + afterReload: loadEnrolls, + summaryLabel: 'enroll 失败项重试', + setFailedIds: (arr) => { state.bulkFailedEnrollIds.value = arr; } + }); + }; + + const clearFailedPool = (scope='all') => { + if (scope === 'all' || scope === 'key') state.bulkFailedKeyIds.value = []; + if (scope === 'all' || scope === 'user') state.bulkFailedUserIds.value = []; + if (scope === 'all' || scope === 'enroll') state.bulkFailedEnrollIds.value = []; + state.toast(scope==='all' ? '全部失败池已清空' : '失败池已清空'); + }; + + const saveSettings = async () => { + + if (!state.isAdmin.value) return; + state.beginTab('settings'); + try { + for (const k of Object.keys(state.settings.value || {})) { + await api('/api/v1/admin/settings', { method:'POST', body: JSON.stringify({ key: k, value: String(state.settings.value[k]) }) }); + } + state.toast('高级设置已保存'); state.opSummary.value = '高级设置已保存'; + state.settings.value = await api('/api/v1/admin/settings'); + state.endTabOk('settings'); + } catch (e) { state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('settings'); } + }; + + const loadAudit = async () => { + if (!state.isAdmin.value) return; + state.beginTab('audit'); + try { + const p = new URLSearchParams(); + const f = state.auditFilters.value || {}; + if (state.auditTenant.value) p.set('tenant', String(state.auditTenant.value)); + if (f.action) p.set('action', String(f.action)); + if (f.actor_type) p.set('actor_type', String(f.actor_type)); + if (f.actor_id) p.set('actor_id', String(f.actor_id)); + if (f.target_type) p.set('target_type', String(f.target_type)); + if (f.target_id) p.set('target_id', String(f.target_id)); + if (f.from) p.set('from', String(f.from)); + if (f.to) p.set('to', String(f.to)); + p.set('limit', String(Number(f.limit || 100))); + p.set('offset', String(Number(f.offset || 0))); + + const d = await api('/api/v1/admin/audit?' + p.toString()); + state.audit.value = d.logs || []; + state.endTabOk('audit', !state.audit.value.length); + state.toast('审计日志已刷新'); + } catch (e) { state.toast(state.humanizeError ? state.humanizeError(e.message) : (e.message||'操作失败'), 'error'); state.endTabErr('audit'); } + }; + + + const exportRowsAsCsv = (rows, tag='audit') => { + const esc = (v) => '"' + String(v ?? '').replace(/"/g, '""') + '"'; + const head = ['id','actor_type','actor_id','action','target_type','target_id','detail','ip','created_at']; + const lines = [head.map(esc).join(',')]; + for (const a of (rows || [])) { + lines.push([ + a.id, a.actor_type, a.actor_id, a.action, a.target_type, a.target_id, a.detail, a.ip, a.created_at + ].map(esc).join(',')); + } + const csv = '' + lines.join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const t = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + const fn = `${tag}-${t.getFullYear()}${pad(t.getMonth()+1)}${pad(t.getDate())}-${pad(t.getHours())}${pad(t.getMinutes())}${pad(t.getSeconds())}.csv`; + const a = document.createElement('a'); + a.href = url; + a.download = fn; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + }; + + const exportAuditCsv = () => { + if (!state.isAdmin.value) return; + const rows = state.audit.value || []; + if (!rows.length) return state.toast('当前无审计数据可导出', 'error'); + exportRowsAsCsv(rows, 'audit'); + state.toast('审计 CSV 已导出'); + state.opSummary.value = `已导出审计 CSV(${rows.length} 条)`; + }; + + const exportAuditFailuresCsv = () => { + if (!state.isAdmin.value) return; + const rows = (state.audit.value || []).filter(a => /denied|failed|error|kick|delete/i.test(String(a.action||''))); + if (!rows.length) return state.toast('当前无失败/高风险审计可导出', 'error'); + exportRowsAsCsv(rows, 'audit-failures'); + state.toast('失败事件 CSV 已导出'); + state.opSummary.value = `已导出失败/高风险审计 CSV(${rows.length} 条)`; + }; + + const applyAuditPreset = async (preset) => { + const f = { action:'', actor_type:'', actor_id:'', target_type:'', target_id:'', from:'', to:'', limit:100, offset:0 }; + state.auditTenant.value = ''; + if (preset === 'login_failed') f.action = 'auth_login_failed'; + else if (preset === 'denied') f.action = 'enroll_consume_denied'; + else if (preset === 'connect_denied') f.action = 'connect_request_denied'; + else if (preset === 'all') { + // keep default + } + state.auditPreset.value = preset || 'all'; + localStorage.setItem('inp2p_audit_preset', state.auditPreset.value); + state.auditFilters.value = f; + await loadAudit(); + }; + + const clearAuditFilters = async () => { + + state.auditFilters.value = { action:'', actor_type:'', actor_id:'', target_type:'', target_id:'', from:'', to:'', limit:100, offset:0 }; + state.auditTenant.value = ''; + await loadAudit(); + }; + + const updateCharts = () => {}; + + const actions = { + refreshAll, startTimer, stopTimer, + login, logout, + saveSDWAN, addSDWANNode, removeSDWANNode, addSubnetProxy, removeSubnetProxy, autoAssignIPs, + kickNode, renameNode, changeNodeIP, openNodeEdit, closeNodeEdit, submitNodeEdit, + openAppManager, pushAppConfigs, openConnect, doConnect, + createTenant, loadTenants, setTenantStatus, reassignTenantSubnet, + createKey, loadKeys, setKeyStatus, + createUser, loadUsers, setUserStatus, resetUserPassword, deleteUser, + createEnroll, loadEnrolls, setEnrollStatus, consumeEnroll, + saveSettings, loadAudit, clearAuditFilters, applyAuditPreset, exportAuditCsv, exportAuditFailuresCsv, + toggleAllKeys, toggleAllUsers, toggleAllEnrolls, + bulkSetKeyStatus, bulkSetUserStatus, bulkSetEnrollStatus, + retryFailedKeyOps, retryFailedUserOps, retryFailedEnrollOps, clearFailedPool, + updateCharts + }; + + return { + actions, + refs: { appPushNode, appPushRaw }, + setSwitchTab + }; +}; diff --git a/web/app/api.js b/web/app/api.js new file mode 100644 index 0000000..6ed7d7c --- /dev/null +++ b/web/app/api.js @@ -0,0 +1,87 @@ +window.INP2P = window.INP2P || {}; + +window.INP2P.createApi = function createApi(state) { + const bearer = () => ({ Authorization: 'Bearer ' + (localStorage.getItem('t') || '') }); + const inFlight = new Map(); + + const withTimeout = async (fetcher, timeoutMs=8000) => { + let timer = null; + try { + return await Promise.race([ + fetcher(), + new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('请求超时')), timeoutMs); }) + ]); + } finally { + if (timer) clearTimeout(timer); + } + }; + + const shouldRetry = (method, err) => { + const m = String(method || 'GET').toUpperCase(); + if (m !== 'GET') return false; + const msg = (err && err.message) ? String(err.message) : ''; + return msg.includes('请求超时') || msg.includes('Failed to fetch') || msg.includes('NetworkError'); + }; + + const doRequest = async (path, opt={}) => { + const headers = { 'Content-Type': 'application/json', ...(opt.headers||{}), ...bearer() }; + const method = String(opt.method || 'GET').toUpperCase(); + const timeoutMs = Number(opt.timeoutMs || (method === 'GET' ? 8000 : 12000)); + + const runOnce = async () => { + const r = await withTimeout(() => fetch(path, { ...opt, method, headers }), timeoutMs); + let d = {}; + try { d = await r.json(); } catch(_) {} + if (!r.ok) { + if (r.status === 401) { + state.loggedIn.value = false; + state.logEvent && state.logEvent('api_error', { path, status:r.status, message:'401 登录已过期' }); + throw new Error('401 登录已过期'); + } + const em = d.message || ('HTTP ' + r.status); + state.logEvent && state.logEvent('api_error', { path, status:r.status, message: em }); + throw new Error(em); + } + state.logEvent && state.logEvent('api_ok', { path, status:r.status, method }); + return d; + }; + + try { + return await runOnce(); + } catch (e) { + if (shouldRetry(method, e)) { + state.logEvent && state.logEvent('api_retry', { path, method, reason: e.message || 'retry' }); + return await runOnce(); + } + throw e; + } + }; + + const api = async (path, opt={}) => { + const method = String(opt.method || 'GET').toUpperCase(); + const dedupe = opt.dedupe !== false && method === 'GET'; + const key = dedupe ? `${method}:${path}` : ''; + + if (dedupe && inFlight.has(key)) { + state.logEvent && state.logEvent('api_dedupe_hit', { path, method }); + return inFlight.get(key); + } + + const p = doRequest(path, opt).finally(() => { + if (dedupe) inFlight.delete(key); + }); + + if (dedupe) inFlight.set(key, p); + return p; + }; + + const loginByPassword = async (username, password) => { + return withTimeout(() => fetch('/api/v1/auth/login', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify({ username, password }) + }), 10000).then(r=>r.json()); + }; + + return { api, loginByPassword }; +}; diff --git a/web/app/main.js b/web/app/main.js new file mode 100644 index 0000000..e433267 --- /dev/null +++ b/web/app/main.js @@ -0,0 +1,211 @@ +console.log("[INP2P] boot", typeof Vue, Vue && Vue.version); + +const { createApp, onMounted, onBeforeUnmount, watch } = Vue; + +createApp({ + setup(){ + const state = window.INP2P.createState(Vue); + const { api, loginByPassword } = window.INP2P.createApi(state); + const { actions, refs, setSwitchTab } = window.INP2P.createActions(state, { api, loginByPassword, Vue }); + + const switchTab = async (next) => { + state.tab.value = next; + if (!state.loggedIn.value) return; + try { + if (next === 'tenants') await actions.loadTenants(); + else if (next === 'apikeys') await actions.loadKeys(); + else if (next === 'users') await actions.loadUsers(); + else if (next === 'enroll') await actions.loadEnrolls(); + else if (next === 'settings' && state.isAdmin.value) { + state.beginTab('settings'); + try { state.settings.value = await api('/api/v1/admin/settings'); state.endTabOk('settings'); } + catch(_) { state.endTabErr('settings'); } + } else if (next === 'audit' && state.isAdmin.value) { + await actions.loadAudit(); + } else if (next === 'sdwan') { + state.beginTab('sdwan'); + try { + state.sd.value = await api('/api/v1/sdwans'); + state.endTabOk('sdwan', !(state.sd.value?.nodes||[]).length); + } catch (e) { state.toast('SDWAN 加载失败: ' + (e.message || ''), 'error'); state.endTabErr('sdwan'); } + } + } catch (e) { + state.toast(e.message || '切换页面失败', 'error'); + } + }; + + setSwitchTab(switchTab); + + watch(state.refreshSec, actions.startTimer); + watch(state.loggedIn, (v) => { + if (v) actions.startTimer(); + else actions.stopTimer(); + }); + + watch(state.tenantKeyword, (v) => localStorage.setItem('inp2p_kw_tenant', String(v || ''))); + watch(state.keyKeyword, (v) => localStorage.setItem('inp2p_kw_key', String(v || ''))); + watch(state.userKeyword, (v) => localStorage.setItem('inp2p_kw_user', String(v || ''))); + watch(state.enrollKeyword, (v) => localStorage.setItem('inp2p_kw_enroll', String(v || ''))); + watch(state.keepFailedPool, (v) => localStorage.setItem('inp2p_keep_failed_pool', v ? '1' : '0')); + + onMounted(() => { + state.applyTheme && state.applyTheme(); + // 全局兜底:前端异常时保留可见提示,避免“瞬间空白” + window.addEventListener('error', (ev) => { + const m = (ev && ev.message) ? ev.message : 'unknown'; + state.toast('前端异常:' + m, 'error'); + state.logEvent && state.logEvent('error', { tab:'global', action:'window_error', message:m }); + }); + window.addEventListener('unhandledrejection', (ev) => { + const m = ev && ev.reason && ev.reason.message ? ev.reason.message : String(ev && ev.reason ? ev.reason : 'unknown'); + state.toast('请求异常:' + m, 'error'); + state.logEvent && state.logEvent('error', { tab:'global', action:'promise_rejection', message:m }); + }); + + if (localStorage.getItem('t')) { + state.loggedIn.value = true; + actions.refreshAll(); + switchTab(state.tab.value); + } + }); + + onBeforeUnmount(() => { + }); + + return { + buildVersion: state.buildVersion, + tab: state.tab, + filteredTabs: state.filteredTabs, + loggedIn: state.loggedIn, + loginBusy: state.loginBusy, + globalBusy: state.globalBusy, + tabBusy: state.tabBusy, + tabState: state.tabState, + debugMode: state.debugMode, + debugEvents: state.debugEvents, + clearDebugEvents: state.clearDebugEvents, + role: state.role, + roleLabel: state.roleLabel, + status: state.status, + tokenType: state.tokenType, + tenantId: state.tenantId, + loginUser: state.loginUser, + loginPass: state.loginPass, + loginErr: state.loginErr, + opSummary: state.opSummary, + refreshSec: state.refreshSec, + theme: state.theme, + health: state.health, + stats: state.stats, + nodes: state.nodes, + nodeKeyword: state.nodeKeyword, + filteredNodes: state.filteredNodes, + sd: state.sd, + connectForm: state.connectForm, + nodeEditVisible: state.nodeEditVisible, + nodeEditMode: state.nodeEditMode, + nodeEditTitle: state.nodeEditTitle, + nodeEditNode: state.nodeEditNode, + nodeEditValue: state.nodeEditValue, + tenants: state.tenants, + activeTenant: state.activeTenant, + keys: state.keys, + users: state.users, + enrolls: state.enrolls, + selectedKeyIds: state.selectedKeyIds, + selectedUserIds: state.selectedUserIds, + selectedEnrollIds: state.selectedEnrollIds, + bulkFailedKeyIds: state.bulkFailedKeyIds, + bulkFailedUserIds: state.bulkFailedUserIds, + bulkFailedEnrollIds: state.bulkFailedEnrollIds, + keepFailedPool: state.keepFailedPool, + auditPreset: state.auditPreset, + tenantKeyword: state.tenantKeyword, + keyKeyword: state.keyKeyword, + userKeyword: state.userKeyword, + enrollKeyword: state.enrollKeyword, + filteredTenants: state.filteredTenants, + filteredKeys: state.filteredKeys, + filteredUsers: state.filteredUsers, + filteredEnrolls: state.filteredEnrolls, + tenantForm: state.tenantForm, + keyForm: state.keyForm, + userForm: state.userForm, + settings: state.settings, + tenantFormErr: state.tenantFormErr, + keyFormErr: state.keyFormErr, + userFormErr: state.userFormErr, + sdwanFormErr: state.sdwanFormErr, + audit: state.audit, + auditTenant: state.auditTenant, + auditFilters: state.auditFilters, + auditActionOptions: state.auditActionOptions, + onlineMap: state.onlineMap, + nodeStatus: state.nodeStatus, + isOnline: state.isOnline, + nodeOnline: state.nodeOnline, + nodeStatusTextOf: state.nodeStatusTextOf, + nodeStatusClassOf: state.nodeStatusClassOf, + natText: state.natText, + uptime: state.uptime, + fmtTime: state.fmtTime, + + appPushNode: refs.appPushNode, + appPushRaw: refs.appPushRaw, + + login: actions.login, + logout: actions.logout, + refreshAll: actions.refreshAll, + switchTab, + toggleTheme: state.toggleTheme, + saveSDWAN: actions.saveSDWAN, + addSDWANNode: actions.addSDWANNode, + removeSDWANNode: actions.removeSDWANNode, + addSubnetProxy: actions.addSubnetProxy, + removeSubnetProxy: actions.removeSubnetProxy, + autoAssignIPs: actions.autoAssignIPs, + kickNode: actions.kickNode, + renameNode: actions.renameNode, + changeNodeIP: actions.changeNodeIP, + closeNodeEdit: actions.closeNodeEdit, + submitNodeEdit: actions.submitNodeEdit, + openAppManager: actions.openAppManager, + pushAppConfigs: actions.pushAppConfigs, + openConnect: actions.openConnect, + doConnect: actions.doConnect, + createTenant: actions.createTenant, + loadTenants: actions.loadTenants, + setTenantStatus: actions.setTenantStatus, + reassignTenantSubnet: actions.reassignTenantSubnet, + createKey: actions.createKey, + loadKeys: actions.loadKeys, + setKeyStatus: actions.setKeyStatus, + createUser: actions.createUser, + loadUsers: actions.loadUsers, + setUserStatus: actions.setUserStatus, + resetUserPassword: actions.resetUserPassword, + deleteUser: actions.deleteUser, + createEnroll: actions.createEnroll, + loadEnrolls: actions.loadEnrolls, + setEnrollStatus: actions.setEnrollStatus, + consumeEnroll: actions.consumeEnroll, + saveSettings: actions.saveSettings, + loadAudit: actions.loadAudit, + clearAuditFilters: actions.clearAuditFilters, + applyAuditPreset: actions.applyAuditPreset, + exportAuditCsv: actions.exportAuditCsv, + exportAuditFailuresCsv: actions.exportAuditFailuresCsv, + toggleAllKeys: actions.toggleAllKeys, + toggleAllUsers: actions.toggleAllUsers, + toggleAllEnrolls: actions.toggleAllEnrolls, + bulkSetKeyStatus: actions.bulkSetKeyStatus, + bulkSetUserStatus: actions.bulkSetUserStatus, + bulkSetEnrollStatus: actions.bulkSetEnrollStatus, + retryFailedKeyOps: actions.retryFailedKeyOps, + retryFailedUserOps: actions.retryFailedUserOps, + retryFailedEnrollOps: actions.retryFailedEnrollOps, + clearFailedPool: actions.clearFailedPool, + updateCharts: actions.updateCharts + }; + } +}).mount('#app'); diff --git a/web/app/state.js b/web/app/state.js new file mode 100644 index 0000000..af4e8d9 --- /dev/null +++ b/web/app/state.js @@ -0,0 +1,244 @@ +window.INP2P = window.INP2P || {}; + +window.INP2P.createState = function createState(Vue) { + const { ref, computed } = Vue; + + const buildVersion = ref('20260307-2246'); + const tab = ref('dashboard'); + const tabs = [ + {id:'dashboard',name:'仪表盘'},{id:'nodes',name:'设备管理'},{id:'sdwan',name:'组网'},{id:'p2p',name:'连接调试'}, + {id:'apikeys',name:'API 密钥'},{id:'users',name:'用户'},{id:'enroll',name:'注册码'}, + {id:'settings',name:'高级设置'},{id:'audit',name:'审计日志'} + ]; + + const loggedIn = ref(false), msg = ref(''), msgType = ref('ok'); + const opSummary = ref(''); + const debugMode = ref(new URLSearchParams(location.search).get('debug') === '1'); + const debugEvents = ref([]); + const logEvent = (type, payload={}) => { + const item = { id: Date.now() + Math.random(), ts: new Date().toISOString(), type, ...payload }; + debugEvents.value = [item, ...debugEvents.value].slice(0, 20); + if (debugMode.value) { + if (type === 'error' || type === 'api_error') console.error('[INP2P]', item); + else console.log('[INP2P]', item); + } + }; + const clearDebugEvents = () => { debugEvents.value = []; }; + const loginBusy = ref(false), globalBusy = ref(false); + const tabBusy = ref({ sdwan:false, p2p:false, tenants:false, settings:false, audit:false }); + const tabState = ref({}); + const setTabBusy = (k, v) => { tabBusy.value = { ...tabBusy.value, [k]: !!v }; }; + const beginTab = (k) => { setTabBusy(k, true); tabState.value = { ...tabState.value, [k]: 'loading' }; }; + const endTabOk = (k, isEmpty=false) => { setTabBusy(k, false); tabState.value = { ...tabState.value, [k]: (isEmpty ? 'empty' : 'success') }; }; + const endTabErr = (k) => { setTabBusy(k, false); tabState.value = { ...tabState.value, [k]: 'error' }; }; + + const role = ref(''), status = ref(1); + const loginUser = ref(''), loginPass = ref(''), loginErr = ref(''); + + const theme = ref(localStorage.getItem('inp2p_theme') || 'b'); + const applyTheme = () => { + document.documentElement.setAttribute('data-theme', theme.value === 'a' ? 'a' : 'b'); + }; + const toggleTheme = () => { + theme.value = theme.value === 'a' ? 'b' : 'a'; + localStorage.setItem('inp2p_theme', theme.value); + applyTheme(); + }; + const refreshSec = ref(15), timer = ref(null); + + const health = ref({}), stats = ref({}), nodes = ref([]), nodeKeyword = ref(''); + const sd = ref({ enabled:false, name:'sdwan-main', gatewayCIDR:'10.10.0.0/24', mode:'mesh', hubNode:'', mtu:1420, nodes:[], routes:['10.10.0.0/24'], subnetProxies:[] }); + const connectForm = ref({ from:'', to:'', srcPort:80, dstPort:80, appName:'manual-connect' }); + + const nodeEditVisible = ref(false); + const nodeEditMode = ref('alias'); + const nodeEditTitle = ref('编辑设备'); + const nodeEditNode = ref(null); + const nodeEditValue = ref(''); + + const tenants = ref([]), activeTenant = ref(1), keys = ref([]), users = ref([]), enrolls = ref([]); + const tenantKeyword = ref(localStorage.getItem('inp2p_kw_tenant') || ''); + const keyKeyword = ref(localStorage.getItem('inp2p_kw_key') || ''); + const userKeyword = ref(localStorage.getItem('inp2p_kw_user') || ''); + const enrollKeyword = ref(localStorage.getItem('inp2p_kw_enroll') || ''); + const tenantForm = ref({ name:'', admin_password:'', operator_password:'' }); + const keyForm = ref({ scope:'all', ttl:0 }); + const userForm = ref({ role:'operator', email:'', password:'' }); + const selectedKeyIds = ref([]); + const selectedUserIds = ref([]); + const selectedEnrollIds = ref([]); + const bulkFailedKeyIds = ref([]); + const bulkFailedUserIds = ref([]); + const bulkFailedEnrollIds = ref([]); + const keepFailedPool = ref(localStorage.getItem('inp2p_keep_failed_pool') !== '0'); + const auditPreset = ref(localStorage.getItem('inp2p_audit_preset') || 'all'); + + const tokenType = ref(''); + const tenantId = ref(0); + const subnet = ref(''); + const settings = ref({ + advanced_impersonate:'0', advanced_force_network:'0', advanced_cross_tenant:'0', + advanced_override_enabled:'0', allow_apikey_mutate_sdwan:'0', allow_operator_kick_cross_node:'0', allow_connect_cross_tenant:'0' + }); + const audit = ref([]); + const auditTenant = ref(''); + const auditFilters = ref({ + action:'', actor_type:'', actor_id:'', target_type:'', target_id:'', from:'', to:'', + limit: 100, offset: 0 + }); + const auditActionOptions = [ + 'auth_login_success','auth_login_failed','tenant_create','apikey_create', + 'user_create','enroll_create','enroll_status','enroll_consume','enroll_consume_denied', + 'node_alias_update','node_ip_update','node_kick','connect_request_denied' + ]; + + const isAdmin = computed(() => role.value === 'admin' && tenantId.value === 1); + const roleLabel = computed(() => isAdmin.value ? '系统管理' : '子网管理'); + const filteredTabs = computed(() => isAdmin.value ? tabs : tabs.filter(t => !['apikeys','users','enroll','settings','audit'].includes(t.id))); + const filteredNodes = computed(() => { + const k = (nodeKeyword.value || '').trim().toLowerCase(); + if (!k) return nodes.value; + return nodes.value.filter(n => (n.name||'').toLowerCase().includes(k) || (n.publicIP||'').toLowerCase().includes(k)); + }); + + + const filteredTenants = computed(() => { + const k = (tenantKeyword.value || '').trim().toLowerCase(); + if (!k) return tenants.value; + return (tenants.value || []).filter(t => + String(t.id||'').includes(k) || String(t.name||'').toLowerCase().includes(k) || String(t.subnet||'').toLowerCase().includes(k) + ); + }); + const filteredKeys = computed(() => { + const k = (keyKeyword.value || '').trim().toLowerCase(); + if (!k) return keys.value; + return (keys.value || []).filter(x => + String(x.id||'').includes(k) || String(x.scope||'').toLowerCase().includes(k) + ); + }); + const filteredUsers = computed(() => { + const k = (userKeyword.value || '').trim().toLowerCase(); + if (!k) return users.value; + return (users.value || []).filter(u => + String(u.id||'').includes(k) || String(u.role||'').toLowerCase().includes(k) || String(u.email||'').toLowerCase().includes(k) + ); + }); + const filteredEnrolls = computed(() => { + const k = (enrollKeyword.value || '').trim().toLowerCase(); + if (!k) return enrolls.value; + return (enrolls.value || []).filter(e => + String(e.id||'').includes(k) || String(e.code||'').toLowerCase().includes(k) + ); + }); + const onlineMap = computed(() => new Set((nodes.value || []).map(n => n.name))); + const nodeStatus = (name) => (name && onlineMap.value.has(name)) ? '在线' : '离线'; + const isOnline = (name) => (name && onlineMap.value.has(name)); + + const nodeOnline = (n) => { + if (!n) return false; + if (typeof n.online === 'boolean') return n.online; + if (n.status === 1 || String(n.status).toLowerCase() === 'online') return true; + if (n.status === 0 || String(n.status).toLowerCase() === 'offline') return false; + if (n.lastSeen && (Date.now() - new Date(n.lastSeen).getTime()) > 120000) return false; + return !!(n.name && onlineMap.value.has(n.name)); + }; + const nodeStatusTextOf = (n) => nodeOnline(n) ? '在线' : '离线'; + const nodeStatusClassOf = (n) => nodeOnline(n) ? 'status-online' : 'status-offline'; + + + const humanizeError = (msg) => { + const t = String(msg || '').trim(); + if (!t) return '请求失败,请稍后重试'; + if (/unauthorized|invalid credentials|token/i.test(t)) return '认证已失效,请重新登录'; + if (/forbidden|permission|denied/i.test(t)) return '权限不足,当前角色不可执行此操作'; + if (/timeout|timed out/i.test(t)) return '请求超时,请检查网络后重试'; + if (/network|failed to fetch|load failed/i.test(t)) return '网络异常,请稍后再试'; + if (/bad request|invalid/i.test(t)) return '参数不正确,请检查输入项'; + return t; + }; + + const toast = (text, t='ok') => { + msg.value = text; + msgType.value = t; + setTimeout(() => { if (msg.value === text) msg.value = ''; }, 2500); + }; + + const validateUsername = (v) => { + const x = (v || '').trim(); + if (!/^[A-Za-z]{6,}$/.test(x)) return '用户名需仅字母且≥6位'; + return ''; + }; + const validateRequired = (v, label='字段') => { + if (v === null || v === undefined || String(v).trim() === '') return `${label}不能为空`; + return ''; + }; + const validateSDWAN = (sdwan) => { + const x = sdwan || {}; + if (!x.gatewayCIDR || !String(x.gatewayCIDR).includes('/')) return '网段格式不正确(如 10.10.0.0/24)'; + const dup = new Set(); + for (const n of (x.nodes || [])) { + if (!n || !n.node) continue; + if (dup.has(n.node)) return `SDWAN 节点重复:${n.node}`; + dup.add(n.node); + if (n.ip && !/^\d+\.\d+\.\d+\.\d+$/.test(String(n.ip))) return `节点IP格式错误:${n.ip}`; + } + return ''; + }; + + const tenantFormErr = computed(() => { + const n = (tenantForm.value.name || '').trim(); + return n ? '' : '租户名不能为空'; + }); + const keyFormErr = computed(() => { + const sc = (keyForm.value.scope || '').trim(); + return sc ? '' : 'scope 不能为空'; + }); + const userFormErr = computed(() => { + if (!(userForm.value.email || '').trim()) return 'email/username 不能为空'; + if (!(userForm.value.password || '').trim()) return 'password 不能为空'; + if (String(userForm.value.password || '').length < 6) return 'password 至少 6 位'; + return ''; + }); + const sdwanFormErr = computed(() => validateSDWAN(sd.value)); + + const natText = (v) => { + const x = String(v || '').toLowerCase(); + if (!x) return 'Unknown'; + if (x.includes('cone')) return 'Cone'; + if (x.includes('symmetric')) return 'Symmetric'; + if (x.includes('easy')) return 'Easy'; + return x; + }; + + const uptime = ts => { + if(!ts) return '-'; + const sec = Math.max(0, Math.floor((Date.now() - new Date(ts).getTime()) / 1000)); + if(sec < 60) return sec + 's'; + if(sec < 3600) return Math.floor(sec/60) + 'm'; + if(sec < 86400) return Math.floor(sec/3600) + 'h'; + return Math.floor(sec/86400) + 'd'; + }; + const fmtTime = t => t ? new Date(t).toLocaleString() : '-'; + + return { + buildVersion, tab, tabs, + loggedIn, msg, msgType, opSummary, loginBusy, globalBusy, tabBusy, tabState, + setTabBusy, beginTab, endTabOk, endTabErr, + role, status, loginUser, loginPass, loginErr, refreshSec, timer, + theme, applyTheme, toggleTheme, + health, stats, nodes, nodeKeyword, sd, connectForm, + nodeEditVisible, nodeEditMode, nodeEditTitle, nodeEditNode, nodeEditValue, + tenants, activeTenant, keys, users, enrolls, tenantForm, keyForm, userForm, + selectedKeyIds, selectedUserIds, selectedEnrollIds, + bulkFailedKeyIds, bulkFailedUserIds, bulkFailedEnrollIds, keepFailedPool, auditPreset, + tenantKeyword, keyKeyword, userKeyword, enrollKeyword, + tokenType, tenantId, subnet, settings, audit, auditTenant, + auditFilters, auditActionOptions, + isAdmin, roleLabel, filteredTabs, filteredNodes, filteredTenants, filteredKeys, filteredUsers, filteredEnrolls, + onlineMap, nodeStatus, isOnline, nodeOnline, nodeStatusTextOf, nodeStatusClassOf, + debugMode, debugEvents, logEvent, clearDebugEvents, + validateUsername, validateRequired, validateSDWAN, + tenantFormErr, keyFormErr, userFormErr, sdwanFormErr, + humanizeError, toast, natText, uptime, fmtTime + }; +}; diff --git a/web/index.html b/web/index.html index 0c9a6ce..fcef72d 100644 --- a/web/index.html +++ b/web/index.html @@ -4,123 +4,396 @@ INP2P Console - + +

INP2P 控制台

-

登录后可管理节点、SDWAN、连接与租户

+

登录后可管理设备、组网与连接

用户名要求:仅字母、长度≥6、全局唯一
- +
Build: {{ buildVersion }}
{{ loginErr }}
-
-
-
-
INP2P Console
-
Role: {{ role || 'unknown' }} · Build: {{ buildVersion }}
+
+
+
+
+
INP2P 管理控制台
+
统一设备、组网与连接管理
+
+
+ 角色:{{ (role==='admin' && Number(tenantId)===1) ? '系统管理' : '子网管理' }} + 版本:{{ buildVersion }} +
-
- +
+ - + +
-
-
{{ t.name }}
-
+
+ -
{{ msg }}
+
+
{{ msg }}
+
最近操作:{{ opSummary }}
+
+
+
调试事件(最近20条)
+ +
+
+
+ {{ fmtTime(Date.parse(e.ts)) }} + [{{ e.type }}] + {{ e.path || e.tab || '-' }} + {{ e.message }} +
+
暂无调试事件
+
+
+ +
+ +
全局数据同步中...(操作按钮可能暂时禁用)
-
-
在线节点
{{ stats.nodes || 0 }}
-
中继
{{ stats.relay || 0 }}
-
Cone
{{ stats.cone || 0 }}
-
Symmetric
{{ stats.symmetric || 0 }}
-
Unknown
{{ stats.unknown || 0 }}
-
SDWAN
{{ stats.sdwan ? 'ON':'OFF' }}
+
+
设备总览
+
以设备为中心展示在线状态、连通能力与组网健康
+ +
+
+
在线设备
+
{{ stats.nodes || nodes.length || 0 }}
+
当前已建立会话
+
+
+
中继设备
+
{{ stats.relay || 0 }}
+
经中继转发通信
+
+
+
直连设备(锥形 NAT)
+
{{ stats.cone || 0 }}
+
P2P 打洞成功率更高
+
+
+
受限设备(对称 NAT)
+
{{ stats.symmetric || 0 }}
+
通常需中继协助
+
+
+
未知网络类型设备
+
{{ stats.unknown || 0 }}
+
等待设备上报 NAT 信息
+
+
+
组网状态
+
{{ stats.sdwan ? '已启用' : '未启用' }}
+
SDWAN 配置生效状态
+
+
+
-
服务版本:{{ stats.version || '-' }}
-
健康状态:{{ health.status || '-' }}
-
健康上报节点:{{ health.nodes || 0 }}
+
设备运行状态
+
+
控制面版本:{{ stats.version || '-' }}
+
健康状态:{{ health.status || '-' }}
+
健康上报设备数:{{ health.nodes || 0 }}
+
+
+ +
+
+
+
设备节点卡片
+
首页直接查看设备详情与快捷操作(与节点页一致)
+
+ +
+ +
+
+
+
+
{{ n.alias || n.name || '未命名设备' }}
+
{{ n.name || '-' }}
+
+
{{ nodeStatusTextOf(n) }}{{ natText(n.natType) }}
+
+
+
虚拟IP:{{ n.virtualIP || '-' }}
+
公网地址:{{ n.publicIP || '-' }}:{{ n.publicPort || '-' }}
+
+
+ + + + +
+
+
+
暂无在线设备,客户端上线后将展示设备卡片。
-
-
- +
+
+
+
+
设备管理
+
卡片化展示每台设备的连接状态与运维动作
+
+
共 {{ filteredNodes.length }} 台
+
+
+ +
-
- - - - - - - - - - - - - - - - - - -
节点唯一ID虚拟IP公网NAT租户版本在线时长动作
-
{{ n.alias || n.name }}
-
hostname: {{ n.name }}
-
{{ n.nodeUUID || '-' }}{{ n.virtualIP || '-' }}{{ n.publicIP }}:{{ n.publicPort }}{{ natText(n.natType) }}{{ n.tenantId || 0 }}{{ n.version || '-' }}{{ uptime(n.loginTime) }} -
- - - - - -
-
暂无节点
+ +
+
+
+
+
{{ n.alias || n.name || '未命名设备' }}
+
设备名:{{ n.name || '-' }}
+
+ {{ natText(n.natType) }} +
+ +
+
唯一ID:{{ n.nodeUUID || '-' }}
+
虚拟IP:{{ n.virtualIP || '-' }}
+
公网地址:{{ n.publicIP || '-' }}:{{ n.publicPort || '-' }}
+
版本:{{ n.version || '-' }}
+
在线时长:{{ uptime(n.loginTime) }}
+
+ +
+ + + + +
+
+ +
暂无设备节点。请先让客户端上线,或点击上方“立即同步”。
-
+
SDWAN 处理中...
+
SDWAN 数据加载失败,请重试
+
+
+
基础组网配置
+
设置组网开关、名称、网段与模式
+
- - - + + +
+
{{ sdwanFormErr }}
- +
Hub 离线将自动回 Mesh
-
+
+
子网管理视图(仅显示本子网可操作项)
+
我的组网
+
系统已自动为当前子网分配虚拟内网。你可以调整本账号设备的虚拟 IP,保存后生效。
+
+
组网状态:{{ sd.enabled ? '已启用' : '未启用' }}
+
组网名称:{{ sd.name || '我的组网' }}
+
子网网段:{{ sd.gatewayCIDR || subnet || '-' }}
+
+
+ + +
+
+ +
节点映射
- +
- +
-
+
子网代理(Subnet Proxy)
- +
示例:local 192.168.0.0/24 → virtual 10.0.100.0/24(掩码需一致)
@@ -168,16 +457,32 @@ - +
+ +
+
+
我的设备虚拟 IP
+ +
+
+
+
{{ n.node || ('设备'+(i+1)) }}
+ +
可修改本网段内 IP
+
+
当前暂无设备上线,设备上线后将自动分配虚拟 IP。
+
+
+
连接调试操作处理中...
-
手动触发连接
+
手动连接调试
用于排障,不是主组网入口
@@ -185,11 +490,11 @@
- +
-
远程推配置(/api/v1/nodes/apps)
+
远程下发配置(/api/v1/nodes/apps)
- +
+
高级设置保存/加载中...
+
高级设置加载/保存失败
高级设置(越权能力)
-
仅系统管理员可见。开启会记录审计日志。
-
- - - + +
+ ⚠ 高风险开关说明(点击展开) +
仅系统管理可见。建议仅在临时排障窗口启用,并在结束后立即关闭。
+
+ +
+
总开关
+ +
关闭时,下方所有越权子开关均不生效。
- + +
+
越权子开关
+
+ + + +
+
+ +
+
兼容开关(旧)
+
+ + + +
+
+ +
+
审计日志加载中...
+
审计日志加载失败,请重试
审计日志
+
+ + +
+
+ + + + + + + + + + +
- - + + + + + +
@@ -225,469 +598,243 @@ - + - - - + + + - +
IDActorActionTargetDetailIPTime
{{ a.id }}{{ a.actor_type }}:{{ a.actor_id }}{{ a.action }}{{ a.target_type }}:{{ a.target_id }}{{ a.actor_type }}:{{ a.actor_id }} + {{ a.action }} + {{ a.action }} + {{ a.action }} + {{ a.target_type }}:{{ a.target_id }} {{ a.detail }} {{ a.ip }} {{ fmtTime(a.created_at*1000) }}
暂无审计记录
暂无审计记录。可放宽筛选条件后重试
-
+
+
租户操作处理中...
+
租户域请求失败,请重试
创建租户
- +
+
{{ tenantFormErr }}
+
+
+
- + +
ID名称子网状态动作
{{ t.id }}{{ t.name }}{{ t.subnet || '-' }}{{ t.status===1?'启用':'停用' }} - - + + +
暂无租户。先在上方输入名称后点击“创建”
+
API Key 数据处理中...
- - - + + + + + + + + +
+
+
- + - + + - + +
IDScope状态过期动作
IDScope状态过期动作
{{ k.id }}{{ k.scope }}{{ k.status===1?'启用':'停用' }}{{ fmtTime(k.expires_at) }}
暂无 API Key 记录
+
用户数据处理中...
- - - - - + + + + + + + + + +
+
{{ userFormErr }}
+
+
- + - - + + + +
IDRoleEmail状态动作
IDRoleEmail状态动作
{{ u.id }}{{ u.role }}{{ u.email }}{{ u.status===1?'启用':'停用' }}
{{ u.id }}{{ u.role==='admin' ? '系统管理' : '子网管理' }}{{ u.email }}{{ u.status===1?'启用':'停用' }} - - - + + +
暂无用户。先填写账号/密码并点击“创建用户”
+
enroll 数据处理中...
- - + + + + + +
+
+
- + - + + - + +
IDCode状态过期动作
IDCode状态过期动作
{{ e.id }}{{ e.code || '-' }}{{ e.status===1?'可用':'停用' }}{{ fmtTime(e.expires_at) }}
暂无 enroll 记录。点击上方“生成 enroll_code”
+ + +
+
+
+
+
+
{{ nodeEditTitle }}
+
修改后立即生效到该设备
+
+ +
+ +
+
+
设备名称
+
{{ (nodeEditNode && (nodeEditNode.alias || nodeEditNode.name)) || '-' }}
+
+
+
设备ID
+
{{ (nodeEditNode && nodeEditNode.nodeUUID) || '-' }}
+
+
+
当前虚拟IP
+
{{ (nodeEditNode && nodeEditNode.virtualIP) || '-' }}
+
+ +
+ + +
+
+ +
+ + +
+
+
+ +
+ + + +