Compare commits
4
Commits
84c709bbcc
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f56d4479c6 | ||
|
|
1404aa3e4e | ||
|
|
ca24ee5cc4 | ||
|
|
edf76db56b |
@@ -41,3 +41,10 @@ sdwan.json
|
||||
|
||||
# Temp
|
||||
/tmp/
|
||||
|
||||
# WAL sidecars anywhere
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Trash
|
||||
.trash/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
## 防火墙
|
||||
|
||||
服务器需要开放以下端口:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
0.1.0
|
||||
+218
-14
@@ -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")
|
||||
|
||||
@@ -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=<key>`
|
||||
- `detail=<value>`
|
||||
|
||||
## 越权使用审计
|
||||
|
||||
- `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=<system-admin-session-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`
|
||||
@@ -0,0 +1,80 @@
|
||||
# INP2P Android API 契约(MVP)
|
||||
|
||||
> Base URL 示例:`http://127.0.0.1:27183`
|
||||
> 认证方式:`Authorization: Bearer <token>`
|
||||
|
||||
## 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 可能出现)
|
||||
@@ -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`
|
||||
@@ -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)
|
||||
@@ -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:视为发布未完成
|
||||
- 必须先清理残留再进入可用状态
|
||||
- 建议在变更记录中附上本次校验报告路径
|
||||
|
||||
@@ -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`
|
||||
@@ -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` 一致
|
||||
@@ -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.
|
||||
@@ -0,0 +1,88 @@
|
||||
# INP2P RBAC Regression Checklist
|
||||
|
||||
> Replace placeholders:
|
||||
> - `BASE=http://127.0.0.1:27183`
|
||||
> - `ADMIN_TOKEN=<tenant admin session token>`
|
||||
> - `OP_TOKEN=<tenant operator session token>`
|
||||
> - `APIKEY_TOKEN=<tenant api key>`
|
||||
|
||||
## 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`
|
||||
|
||||
## 6) 2026-09-13 回归实测结果(build.43)
|
||||
|
||||
| 用例 | 结果 |
|
||||
|---|---|
|
||||
| master token 禁入 admin/tenant API | PASS(401) |
|
||||
| tenant admin/operator 禁入系统管理 API | PASS(401) |
|
||||
| apikey 基线拒绝 mutation(sdwan/kick/connect) | PASS(403) |
|
||||
| apikey + allow_apikey_mutate_sdwan 越权放行 sdwan/edit | PASS(200,audit rbac_override_used + sdwan_update) |
|
||||
| operator 同租户 connect/kick 基线放行 | PASS(200,audit connect_request/node_kick) |
|
||||
| 跨租户 connect source 不匹配拒绝 | PASS(404,audit connect_request_denied tenant_mismatch_source) |
|
||||
| enroll revoke operator/apikey 拒绝 | PASS(401) |
|
||||
| enroll revoke 跨租户 admin 拒绝 | PASS(404,**本轮修复**:曾为 200 越权,已加 token 归属校验) |
|
||||
| audit API action/actor/target/from/to 筛选 | PASS(6 种筛选全通过) |
|
||||
| 前端 operator:禁用 tab 隐藏、常用 tab 可见 | PASS |
|
||||
| 前端 tenant-2 admin:系统管理 tab 不可见 | PASS |
|
||||
| 前端 sysadmin:全 tab + 审计页 + 高级设置页 | PASS |
|
||||
|
||||
## 已知问题(本轮发现)
|
||||
|
||||
- **enroll revoke 缺租户归属校验**:已修复(84c709b),revoke 前校验 enroll token 归属租户。
|
||||
- /api/v1/nodes/kick 的 `allow_operator_kick_cross_node` 子开关**未接入 override 路径**(仍走 TenantCan 基线+租户匹配 404),开关当前无实际效果。跨租户隔离靠租户匹配兜底,无安全风险,但开关语义与文档不符,待后续接线。
|
||||
- 老种子账号(admin@local/operator@local 等)因含 @/数字不符合 `IsValidGlobalUsername`(仅纯字母≥6),无法用于登录,只作为历史数据存在。
|
||||
@@ -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`
|
||||
- 值班人:`________________`
|
||||
- 时间:`________________`
|
||||
@@ -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`
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 := ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}`)
|
||||
}
|
||||
|
||||
Executable
+40
@@ -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"
|
||||
Executable
+117
@@ -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
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
+211
@@ -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');
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
+628
-481
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user