From edf76db56bf9f63e0cfa33faf151bd920decdf4a Mon Sep 17 00:00:00 2001 From: openclaw Date: Sun, 13 Sep 2026 08:41:35 +0800 Subject: [PATCH] chore: track version files, docs, web app split, scripts (missed commits from March work) --- BUILD_SEQ | 1 + Makefile | 15 +- README.md | 12 + VERSION | 1 + VERSION_BASE | 1 + cmd/inp2ps/main.go | 232 +++- docs/advanced-overrides.md | 144 +++ docs/android-api-contract.md | 80 ++ docs/android-kickoff-checklist.md | 41 + docs/android-mvp-plan.md | 61 + docs/deploy-version-check.md | 49 + docs/frontend-copy-dictionary.md | 46 + docs/frontend-regression-final-checklist.md | 69 ++ docs/rbac-matrix.md | 35 + docs/rbac-regression-checklist.md | 65 ++ docs/release-onepager-oncall.md | 34 + docs/release-onepager.md | 109 ++ internal/server/admin_settings.go | 3 +- internal/server/audit_api.go | 24 +- internal/server/authz.go | 64 ++ internal/server/inp2ps.db-shm | Bin 0 -> 32768 bytes internal/server/inp2ps.db-wal | Bin 0 -> 2340192 bytes internal/server/node_api.go | 17 + internal/server/sdwan_api.go | 36 +- internal/server/server.go | 2 +- internal/server/user_api.go | 3 +- scripts/bump-version.sh | 40 + scripts/deploy-check.sh | 117 ++ web/app/actions.js | 634 +++++++++++ web/app/api.js | 87 ++ web/app/main.js | 211 ++++ web/app/state.js | 244 ++++ web/index.html | 1109 +++++++++++-------- 33 files changed, 3082 insertions(+), 504 deletions(-) create mode 100644 BUILD_SEQ create mode 100644 VERSION create mode 100644 VERSION_BASE create mode 100644 docs/advanced-overrides.md create mode 100644 docs/android-api-contract.md create mode 100644 docs/android-kickoff-checklist.md create mode 100644 docs/android-mvp-plan.md create mode 100644 docs/deploy-version-check.md create mode 100644 docs/frontend-copy-dictionary.md create mode 100644 docs/frontend-regression-final-checklist.md create mode 100644 docs/rbac-matrix.md create mode 100644 docs/rbac-regression-checklist.md create mode 100644 docs/release-onepager-oncall.md create mode 100644 docs/release-onepager.md create mode 100644 internal/server/inp2ps.db-shm create mode 100644 internal/server/inp2ps.db-wal create mode 100755 scripts/bump-version.sh create mode 100755 scripts/deploy-check.sh create mode 100644 web/app/actions.js create mode 100644 web/app/api.js create mode 100644 web/app/main.js create mode 100644 web/app/state.js 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 0000000000000000000000000000000000000000..d666490010cd6aceec01b0336998d27f9fadad0f GIT binary patch literal 32768 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⪼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

*#F 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 E12K-gq5uE@ literal 0 HcmV?d00001 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) || '-' }}
+
+ +
+ + +
+
+ +
+ + +
+
+
+ +
+ + + +