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 - -
-
- -
- - -
-
-
-
在线节点
-
{{ stats.nodes || 0 }}
-
-
-
中继
-
{{ relayCount }}
-
-
-
打洞率
-
{{ punchRate }}%
-
-
-
SDWAN
-
{{ sdwan.enabled ? 'ON' : 'OFF' }}
-
-
- -
-
-

NAT 分布

-
-
-
-

信令日志

-
-
- [{{ log.time }}] - {{ log.msg }} -
-
等待数据...
-
-
-
-
- - -
-
- - -
-
-
-
-
-

{{ n.name }}

-
{{ n.publicIP }}:{{ n.publicPort }}
-
-
- {{ ['CONE','SYMM','STAT'][n.natType-1] || 'UNK' }} -
-
-
-
OS{{ n.os || 'linux' }}
-
版本{{ n.version }}
-
中继{{ n.relayEnabled ? '是' : '否' }}
-
在线{{ formatUptime(n.loginTime) }}
-
- -
-
-
- - -
-
-
-
-

网络配置

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

IP 分配

- -
- - - - - - - - -
{{ sn.node }}
-
- - - -
-
-
-
- - -
-
-

系统设置

-
-
-
自动刷新
-
当前: {{ refreshInterval }} 秒
-
- -
-
-
-
-
- - -
-
-
-

隧道: {{ appManagerNode.name }}

- -
-
-
-
-
{{ a.appName }}
-
{{ a.srcPort }} → {{ a.peerNode }}:{{ a.dstPort }}
- -
-
-
- - - - - -
-
-
- - -
-
-
- - -
- {{ 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'); +