feat: web console with real management operations

Backend APIs added:
- POST /api/v1/nodes/kick - disconnect a node
- POST /api/v1/connect - trigger P2P tunnel between nodes
- GET /api/v1/stats - detailed server statistics

Frontend features:
- Dashboard: real stats from /api/v1/stats (cone/symm/relay counts)
- Node management: table view, kick node, configure tunnels
- SDWAN: enable/disable, CIDR config, IP allocation, online status
- P2P Connect: create tunnel between two nodes from UI
- Event log: tracks all operations
This commit is contained in:
2026-03-03 00:42:01 +08:00
parent 6d5b1f50ab
commit 9f6e065f3a
4 changed files with 569 additions and 440 deletions

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
@@ -84,25 +85,94 @@ func main() {
startSTUN("TCP", cfg.STUNTCP2, nat.ServeTCPSTUN)
}
// ─── Signaling Server ───
// ─── Signaling Server ───
srv := server.New(cfg)
srv.StartCleanup()
// Auth Middleware
authMiddleware := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/auth/login" {
next(w, r)
return
}
// Check Authorization header
authHeader := r.Header.Get("Authorization")
expected := fmt.Sprintf("Bearer %d", cfg.Token)
if authHeader != expected {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
return
}
next(w, r)
}
}
mux := http.NewServeMux()
mux.HandleFunc("/ws", srv.HandleWS)
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
// Serve Static Web Console
webDir := "/root/.openclaw/workspace/inp2p/web"
mux.Handle("/", http.FileServer(http.Dir(webDir)))
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
}
// 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)
return
}
req.Token = req2.Token
}
if req.Token != cfg.Token {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":1,"message":"invalid token"}`)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"error":0,"token":"%d"}`, cfg.Token)
})
mux.HandleFunc("/api/v1/health", authMiddleware(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","version":"%s","nodes":%d}`, config.Version, len(srv.GetOnlineNodes()))
})
mux.HandleFunc("/api/v1/sdwans", func(w http.ResponseWriter, r *http.Request) {
}))
mux.HandleFunc("/api/v1/nodes", authMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
nodes := srv.GetOnlineNodes()
_ = json.NewEncoder(w).Encode(map[string]any{"nodes": nodes})
}))
mux.HandleFunc("/api/v1/sdwans", authMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(srv.GetSDWAN())
})
mux.HandleFunc("/api/v1/sdwan/edit", func(w http.ResponseWriter, r *http.Request) {
}))
mux.HandleFunc("/api/v1/sdwan/edit", authMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
@@ -118,7 +188,127 @@ func main() {
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "ok"})
})
}))
// Remote Config Push API
mux.HandleFunc("/api/v1/nodes/apps", authMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Node string `json:"node"`
Apps []protocol.AppConfig `json:"apps"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
node := srv.GetNode(req.Node)
if node == nil {
http.Error(w, "node not found", http.StatusNotFound)
return
}
// Push to client
_ = node.Conn.Write(protocol.MsgPush, protocol.SubPushConfig, req.Apps)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "config push sent"})
}))
// Kick (disconnect) a node
mux.HandleFunc("/api/v1/nodes/kick", authMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Node string `json:"node"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
node := srv.GetNode(req.Node)
if node == nil {
http.Error(w, "node not found or offline", http.StatusNotFound)
return
}
node.Conn.Close()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "node kicked"})
}))
// Trigger P2P connect between two nodes
mux.HandleFunc("/api/v1/connect", authMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
From string `json:"from"`
To string `json:"to"`
SrcPort int `json:"srcPort"`
DstPort int `json:"dstPort"`
AppName string `json:"appName"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fromNode := srv.GetNode(req.From)
if fromNode == nil {
http.Error(w, "source node offline", http.StatusNotFound)
return
}
app := protocol.AppConfig{
AppName: req.AppName,
Protocol: "tcp",
SrcPort: req.SrcPort,
PeerNode: req.To,
DstHost: "127.0.0.1",
DstPort: req.DstPort,
Enabled: 1,
}
if err := srv.PushConnect(fromNode, req.To, app); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(map[string]any{"error": 1, "message": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "connect request sent"})
}))
// Server uptime + detailed stats
mux.HandleFunc("/api/v1/stats", authMiddleware(func(w http.ResponseWriter, r *http.Request) {
nodes := srv.GetOnlineNodes()
coneCount, symmCount, unknCount := 0, 0, 0
relayCount := 0
for _, n := range nodes {
switch n.NATType {
case 1:
coneCount++
case 2:
symmCount++
default:
unknCount++
}
if n.RelayEnabled || n.SuperRelay {
relayCount++
}
}
sdwan := srv.GetSDWAN()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"nodes": len(nodes),
"relay": relayCount,
"cone": coneCount,
"symmetric": symmCount,
"unknown": unknCount,
"sdwan": sdwan.Enabled,
"version": config.Version,
})
}))
// ─── HTTP Listener ───
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.WSPort))