diff --git a/cmd/inp2ps/main.go b/cmd/inp2ps/main.go
index c0cedfd..3061db9 100644
--- a/cmd/inp2ps/main.go
+++ b/cmd/inp2ps/main.go
@@ -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))
diff --git a/internal/server/server.go b/internal/server/server.go
index 3ae094e..4bd1f92 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -17,26 +17,26 @@ import (
// NodeInfo represents a connected client node.
type NodeInfo struct {
- Name string
- Token uint64
- User string
- Version string
- NATType protocol.NATType
- PublicIP string
- PublicPort int
- LanIP string
- OS string
- Mac string
- ShareBandwidth int
- RelayEnabled bool
- SuperRelay bool
- HasIPv4 int
- IPv6 string
- LoginTime time.Time
- LastHeartbeat time.Time
- Conn *signal.Conn
- Apps []protocol.AppConfig
- mu sync.RWMutex
+ Name string `json:"name"`
+ Token uint64 `json:"-"`
+ User string `json:"user"`
+ Version string `json:"version"`
+ NATType protocol.NATType `json:"natType"`
+ PublicIP string `json:"publicIP"`
+ PublicPort int `json:"publicPort"`
+ LanIP string `json:"lanIP"`
+ OS string `json:"os"`
+ Mac string `json:"mac"`
+ ShareBandwidth int `json:"shareBandwidth"`
+ RelayEnabled bool `json:"relayEnabled"`
+ SuperRelay bool `json:"superRelay"`
+ HasIPv4 int `json:"hasIPv4"`
+ IPv6 string `json:"ipv6"`
+ LoginTime time.Time `json:"loginTime"`
+ LastHeartbeat time.Time `json:"lastHeartbeat"`
+ Conn *signal.Conn `json:"-"`
+ Apps []protocol.AppConfig `json:"apps"`
+ mu sync.RWMutex `json:"-"`
}
// IsOnline checks if node has sent heartbeat recently.
diff --git a/pkg/protocol/protocol.go b/pkg/protocol/protocol.go
index 9305612..1ad094f 100644
--- a/pkg/protocol/protocol.go
+++ b/pkg/protocol/protocol.go
@@ -65,6 +65,7 @@ const (
SubPushSDWANConfig // push sdwan config to client
SubPushSDWANPeer // push sdwan peer online/update
SubPushSDWANDel // push sdwan peer offline/delete
+ SubPushConfig // generic remote config push
)
// Sub types: MsgTunnel
diff --git a/web/index.html b/web/index.html
index a4dc7ce..c47c462 100644
--- a/web/index.html
+++ b/web/index.html
@@ -3,443 +3,381 @@
- INP2P Control Plane
+ INP2P Console
-
-
-
-
-
-
-
-
-
-
INP2P
-
输入 Master Token
-
-
-
-
-
{{ loginError }}
-
+
+
+
+
+
+
+
⚡
+
INP2P
+
输入 Master Token
-
-
-
-
-
-
-
-
- {{ tabNames[activeTab] }}
-
-
{{ refreshInterval }}s
-
-
-
-
-
-
-
-
-
-
-
-
隧道: {{ appManagerNode.name }}
-
-
-
-
-
-
-
-
-
-
-
-
- {{ msg }}
+
+
+
{{ loginErr }}
-
+ const kickNode=async(n)=>{
+ if(!confirm('确认踢出节点 '+n.name+'?'))return;
+ const r=await api('/api/v1/nodes/kick',{method:'POST',body:JSON.stringify({node:n.name})});
+ if(r){show(n.name+' 已踢出','ok');log('踢出 '+n.name,'ok');setTimeout(refresh,1000)}
+ };
+
+ const openTunnel=(n)=>{tunNode.value=n;tunApps.value=JSON.parse(JSON.stringify(n.apps||[]))};
+ const pushTun=async()=>{
+ busy.value=true;
+ const r=await api('/api/v1/nodes/apps',{method:'POST',body:JSON.stringify({node:tunNode.value.name,apps:tunApps.value})});
+ busy.value=false;
+ if(r){show('隧道配置已下发','ok');log('下发隧道到 '+tunNode.value.name,'ok');tunNode.value=null;setTimeout(refresh,1000)}
+ };
+
+ const openConnect=(n)=>{tab.value='p2p';p2p.from=n.name};
+
+ const doConnect=async()=>{
+ busy.value=true;
+ const r=await api('/api/v1/connect',{method:'POST',body:JSON.stringify({from:p2p.from,to:p2p.to,appName:p2p.name,srcPort:p2p.srcPort,dstPort:p2p.dstPort})});
+ busy.value=false;
+ if(r&&r.error===0){show('P2P 连接请求已发送','ok');log(`${p2p.from} → ${p2p.to} 连接请求`,'ok')}
+ };
+
+ const addSDNode=()=>{
+ if(!addNode.value||!addIP.value)return;
+ sd.value.nodes.push({node:addNode.value,ip:addIP.value});
+ addNode.value='';addIP.value='';saveSD()
+ };
+
+ const autoIP=()=>{
+ const b=sd.value.gatewayCIDR.replace('.0/24','.');let c=2;
+ nodes.value.filter(n=>!sd.value.nodes.some(s=>s.node===n.name)).forEach(n=>{
+ while(sd.value.nodes.some(s=>s.ip===b+c))c++;
+ sd.value.nodes.push({node:n.name,ip:b+c});c++
+ });saveSD()
+ };
+
+ const nodeOnline=(name)=>nodes.value.some(n=>n.name===name);
+
+ const uptime=(t)=>{
+ if(!t)return'-';try{
+ const s=Math.floor((Date.now()-new Date(t).getTime())/1000);
+ if(s<60)return s+'s';if(s<3600)return Math.floor(s/60)+'m';
+ if(s<86400)return Math.floor(s/3600)+'h';return Math.floor(s/86400)+'d'
+ }catch{return'-'}
+ };
+
+ const fNodes=computed(()=>nodes.value.filter(n=>n.name.toLowerCase().includes(nf.value.toLowerCase())));
+ const uaNodes=computed(()=>nodes.value.filter(n=>!sd.value.nodes.some(s=>s.node===n.name)));
+
+ let timer;
+ onMounted(()=>{if(loggedIn.value){refresh();timer=setInterval(refresh,15000)}});
+
+ return{loggedIn,loginToken,loginErr,busy,tab,tabs,nf,toast,toastType,
+ st,nodes,sd,logs,tunNode,tunApps,na,addNode,addIP,p2p,
+ login,refresh,saveSD,kickNode,openTunnel,pushTun,openConnect,doConnect,
+ addSDNode,autoIP,nodeOnline,uptime,fNodes,uaNodes}
+}}).mount('#app');
+