From 67bc6ecae658f3a9de73305b1383aafec81f245f Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 3 Mar 2026 18:46:47 +0800 Subject: [PATCH] web: restore full single-file console and fix enroll revoke route --- cmd/inp2ps/main.go | 125 ++++++- web/index.html | 892 +++++++++++++++++++++++++-------------------- 2 files changed, 603 insertions(+), 414 deletions(-) diff --git a/cmd/inp2ps/main.go b/cmd/inp2ps/main.go index 2523f0b..2676d22 100644 --- a/cmd/inp2ps/main.go +++ b/cmd/inp2ps/main.go @@ -106,6 +106,17 @@ func main() { fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`) return } + // RBAC: admin only + if srv.Store() != nil { + if u, err := srv.Store().GetUserByToken(server.BearerToken(r)); err == nil && u != nil { + if u.Status != 1 || u.Role != "admin" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`) + return + } + } + } next(w, r) } } @@ -122,9 +133,27 @@ func main() { next(w, r) return } - // check API key + // check API key + RBAC if srv.Store() != nil { if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil { + // role check if user exists + if u, err := srv.Store().GetUserByToken(server.BearerToken(r)); err == nil && u != nil { + if u.Status != 1 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`) + return + } + if u.Role == "operator" { + path := r.URL.Path + if path != "/api/v1/nodes" && path != "/api/v1/sdwans" && path != "/api/v1/sdwan/edit" && path != "/api/v1/connect" && path != "/api/v1/nodes/apps" && path != "/api/v1/nodes/kick" && path != "/api/v1/stats" && path != "/api/v1/health" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`) + return + } + } + } next(w, r) return } @@ -146,36 +175,88 @@ func main() { // Tenant APIs (API key auth inside handlers) mux.HandleFunc("/api/v1/admin/tenants", adminMiddleware(srv.HandleAdminCreateTenant)) mux.HandleFunc("/api/v1/admin/tenants/", adminMiddleware(srv.HandleAdminCreateAPIKey)) + mux.HandleFunc("/api/v1/admin/users", adminMiddleware(srv.HandleAdminUsers)) + mux.HandleFunc("/api/v1/admin/users/", adminMiddleware(srv.HandleAdminUsers)) mux.HandleFunc("/api/v1/tenants/enroll", srv.HandleTenantEnroll) mux.HandleFunc("/api/v1/enroll/consume", srv.HandleEnrollConsume) + mux.HandleFunc("/api/v1/enroll/consume/", srv.HandleEnrollConsume) mux.HandleFunc("/api/v1/auth/login", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - var req struct { - Token uint64 `json:"token,string"` // support string from frontend + // Support two modes: + // 1) token login: {"token":"xxxx"} (admin/master only, backward compatible) + // 2) user login: {"tenant":1,"username":"admin","password":"pass"} + var reqTok struct { + Token string `json:"token"` + } + var reqUser struct { + TenantID int64 `json:"tenant"` + Username string `json:"username"` + Password string `json:"password"` } - // Try string first then uint64 body, _ := io.ReadAll(r.Body) - if err := json.Unmarshal(body, &req); err != nil { - var req2 struct { - Token uint64 `json:"token"` - } - if err := json.Unmarshal(body, &req2); err != nil { - http.Error(w, "bad request", http.StatusBadRequest) + _ = json.Unmarshal(body, &reqTok) + _ = json.Unmarshal(body, &reqUser) + + // --- user login --- + if reqUser.TenantID > 0 && reqUser.Username != "" && reqUser.Password != "" { + if srv.Store() == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, `{"error":1,"message":"store not ready"}`) return } - req.Token = req2.Token + u, err := srv.Store().VerifyUserPassword(reqUser.TenantID, reqUser.Username, reqUser.Password) + if err != nil || u == nil || u.Status != 1 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, `{"error":1,"message":"invalid credentials"}`) + return + } + // issue API key for this tenant and return subnet + key, err := srv.Store().CreateAPIKey(reqUser.TenantID, "all", 0) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, `{"error":1,"message":"create token failed"}`) + return + } + ten, _ := srv.Store().GetTenantByID(reqUser.TenantID) + resp := struct { + Error int `json:"error"` + Message string `json:"message"` + Token string `json:"token"` + Role string `json:"role"` + Status int `json:"status"` + Subnet string `json:"subnet"` + }{0, "ok", key, u.Role, u.Status, ""} + if ten != nil { + resp.Subnet = ten.Subnet + } + b, _ := json.Marshal(resp) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(b) + return } - valid := req.Token == cfg.Token - if !valid { - for _, t := range cfg.Tokens { - if req.Token == t { - valid = true - break + // --- token login (legacy/admin) --- + valid := false + role := "admin" + status := 1 + if reqTok.Token != "" { + // support numeric token as string + if reqTok.Token == fmt.Sprintf("%d", cfg.Token) { + valid = true + } else { + for _, t := range cfg.Tokens { + if reqTok.Token == fmt.Sprintf("%d", t) { + valid = true + break + } } } } @@ -185,8 +266,16 @@ func main() { fmt.Fprintf(w, `{"error":1,"message":"invalid token"}`) return } + if srv.Store() != nil { + if u, err := srv.Store().GetUserByTenant(0); err == nil && u != nil { + if u.Role != "" { + role = u.Role + } + status = u.Status + } + } w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"error":0,"token":"%d"}`, cfg.Token) + fmt.Fprintf(w, `{"error":0,"token":"%d","role":"%s","status":%d}`, cfg.Token, role, status) }) mux.HandleFunc("/api/v1/health", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) { diff --git a/web/index.html b/web/index.html index 05bd3e7..6984f14 100644 --- a/web/index.html +++ b/web/index.html @@ -1,457 +1,557 @@ - - + + INP2P Console - + -
- -
-
-
-
-

INP2P

-

输入 Master Token

+
+
+
+

INP2P 控制台

+

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

+
+ + + +
或使用主 Token 登录(管理员)
+ + +
Build: {{ buildVersion }}
+
{{ loginErr }}
- - -
{{ loginErr }}
- - - - -
-
-

{{ tabs.find(t=>t.id===tab)?.label }}

- -
-
- - -
-
-
-
节点
-
{{ st.nodes||0 }}
-
-
-
中继
-
{{ st.relay||0 }}
-
-
-
Cone / Symm
-
{{ st.cone||0 }} / {{ st.symmetric||0 }}
-
-
-
SDWAN
-
{{ st.sdwan?'ON':'OFF' }}
-
-
-
-

事件日志

-
-
- [{{ l.t }}] - {{ l.m }} -
-
暂无事件
-
-
+
+ + + +
+
- -
-
- - -
- - - +
+
{{ t.name }}
+
+ +
{{ msg }}
+ +
+
+
在线节点
{{ stats.nodes || 0 }}
+
中继
{{ stats.relay || 0 }}
+
Cone
{{ stats.cone || 0 }}
+
Symmetric
{{ stats.symmetric || 0 }}
+
Unknown
{{ stats.unknown || 0 }}
+
SDWAN
{{ stats.sdwan ? 'ON':'OFF' }}
+
+
+
服务版本:{{ stats.version || '-' }}
+
健康状态:{{ health.status || '-' }}
+
健康上报节点:{{ health.nodes || 0 }}
+
+
+ +
+
+ +
+
+
节点公网 IPNAT中继在线操作
+ + - - - - - - - + + + + + + + + + + +
节点公网NAT租户版本在线时长动作
{{ n.name }}{{ n.publicIP }}:{{ n.publicPort }}{{ ['Cone','Symm'][n.natType-1]||'Unk' }}{{ n.relayEnabled?'是':'否' }}{{ uptime(n.loginTime) }} - - - +
{{ n.name }}{{ n.publicIP }}:{{ n.publicPort }}{{ natText(n.natType) }}{{ n.tenantId || 0 }}{{ n.version || '-' }}{{ uptime(n.loginTime) }} +
+ + + +
+
暂无节点
+
+
+ +
+
+
+ + + + + +
+
+ + +
+
+ +
+
节点映射
+
+
+ + + +
+
+ +
+
+ +
+
+
手动触发连接
+
+ + + + +
+ + +
+ +
+
远程推配置(/api/v1/nodes/apps)
+
+ +
示例:[{"appName":"demo","protocol":"tcp","srcPort":8080,"peerNode":"","dstHost":"127.0.0.1","dstPort":80,"enabled":1}]
+
+ + +
+
+ +
+
+
创建租户
+
+ + + + +
+
+
+ + + + + +
ID名称子网状态动作
{{ t.id }}{{ t.name }}{{ t.subnet || '-' }}{{ t.status===1?'启用':'停用' }} + + +
-
无节点
-
- - -
-
-
-
-

网络配置

- -
-
- - -
-
- - -
- -
-
-
-

IP 分配表

- -
- - - - - - - - - - - - -
节点虚拟 IP状态操作
{{ sn.node }}{{ nodeOnline(sn.node)?'在线':'离线' }}
-
- - - -
-
-
-
- - -
-
-

创建 P2P 隧道

-

选择两个在线节点,创建端口转发隧道。从 A 的本地端口转发到 B 的目标端口。

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - - {{ p2p.from }} :{{ p2p.srcPort }} → {{ p2p.to }} :{{ p2p.dstPort }} - -
-
-
- - -
-
-
创建租户
-
- - -
-
- -
-
生成租户 API Key
- -
Key:
- -
- -
-
生成 Enroll Code
-
- - -
-
- -
-
兑换 Node Secret
-
- - -
-
Node Secret:
- -
-
- -
-
-
-

隧道配置: {{ tunNode.name }}

- +
+
+ + + + +
-
-
-
-
{{ a.appName || '未命名' }}
-
:{{ a.srcPort }} → {{ a.peerNode }}:{{ a.dstPort }}
-
- -
-
暂无隧道配置
-
-
添加隧道
-
- - - - -
- -
-
-
- - +
+ + + + + + + + +
IDScope状态过期动作
{{ k.id }}{{ k.scope }}{{ k.status===1?'启用':'停用' }}{{ fmtTime(k.expires_at) }}
-
- -
- {{ toast }} +
+
+ + + + + + +
+
+ + + + + + + + +
IDRoleEmail状态动作
{{ u.id }}{{ u.role }}{{ u.email }}{{ u.status===1?'启用':'停用' }} + + + +
+
+
+ +
+
+ + +
+
+ + + + + + + + +
IDCode状态过期动作
{{ e.id }}{{ e.code || '-' }}{{ e.status===1?'可用':'停用' }}{{ fmtTime(e.expires_at) }}
+
+