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
338 lines
10 KiB
Go
338 lines
10 KiB
Go
// inp2ps — INP2P Signaling Server
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
|
|
"github.com/openp2p-cn/inp2p/internal/server"
|
|
"github.com/openp2p-cn/inp2p/pkg/auth"
|
|
"github.com/openp2p-cn/inp2p/pkg/config"
|
|
"github.com/openp2p-cn/inp2p/pkg/nat"
|
|
"github.com/openp2p-cn/inp2p/pkg/protocol"
|
|
)
|
|
|
|
func main() {
|
|
cfg := config.DefaultServerConfig()
|
|
|
|
flag.IntVar(&cfg.WSPort, "ws-port", cfg.WSPort, "WebSocket signaling port")
|
|
flag.IntVar(&cfg.WebPort, "web-port", cfg.WebPort, "Web console port")
|
|
flag.IntVar(&cfg.STUNUDP1, "stun-udp1", cfg.STUNUDP1, "UDP STUN port 1")
|
|
flag.IntVar(&cfg.STUNUDP2, "stun-udp2", cfg.STUNUDP2, "UDP STUN port 2")
|
|
flag.IntVar(&cfg.STUNTCP1, "stun-tcp1", cfg.STUNTCP1, "TCP STUN port 1")
|
|
flag.IntVar(&cfg.STUNTCP2, "stun-tcp2", cfg.STUNTCP2, "TCP STUN port 2")
|
|
flag.StringVar(&cfg.DBPath, "db", cfg.DBPath, "SQLite database path")
|
|
flag.StringVar(&cfg.CertFile, "cert", "", "TLS certificate file")
|
|
flag.StringVar(&cfg.KeyFile, "key", "", "TLS key file")
|
|
flag.IntVar(&cfg.LogLevel, "log-level", cfg.LogLevel, "Log level (0=debug 1=info 2=warn 3=error)")
|
|
token := flag.Uint64("token", 0, "Master authentication token (uint64)")
|
|
user := flag.String("user", "", "Username for token generation (requires -password)")
|
|
pass := flag.String("password", "", "Password for token generation")
|
|
version := flag.Bool("version", false, "Print version and exit")
|
|
flag.Parse()
|
|
|
|
if *version {
|
|
fmt.Printf("inp2ps version %s\ncommit: %s\nbuild: %s\ngo: %s\n",
|
|
config.Version, config.GitCommit, config.BuildTime, config.GoVersion)
|
|
os.Exit(0)
|
|
}
|
|
|
|
// Token: either direct value or generated from user+password
|
|
if *token > 0 {
|
|
cfg.Token = *token
|
|
} else if *user != "" && *pass != "" {
|
|
cfg.Token = auth.MakeToken(*user, *pass)
|
|
log.Printf("[main] token generated from credentials: %d", cfg.Token)
|
|
}
|
|
|
|
cfg.FillFromEnv()
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
log.Fatalf("[main] config error: %v", err)
|
|
}
|
|
|
|
log.Printf("[main] inp2ps v%s starting", config.Version)
|
|
log.Printf("[main] WSS :%d | STUN UDP :%d,%d | STUN TCP :%d,%d",
|
|
cfg.WSPort, cfg.STUNUDP1, cfg.STUNUDP2, cfg.STUNTCP1, cfg.STUNTCP2)
|
|
|
|
// ─── STUN Servers ───
|
|
stunQuit := make(chan struct{})
|
|
|
|
startSTUN := func(proto string, port int, fn func(int, <-chan struct{}) error) {
|
|
go func() {
|
|
log.Printf("[main] %s STUN listening on :%d", proto, port)
|
|
if err := fn(port, stunQuit); err != nil {
|
|
log.Printf("[main] %s STUN :%d error: %v", proto, port, err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
startSTUN("UDP", cfg.STUNUDP1, nat.ServeUDPSTUN)
|
|
if cfg.STUNUDP2 != cfg.STUNUDP1 {
|
|
startSTUN("UDP", cfg.STUNUDP2, nat.ServeUDPSTUN)
|
|
}
|
|
startSTUN("TCP", cfg.STUNTCP1, nat.ServeTCPSTUN)
|
|
if cfg.STUNTCP2 != cfg.STUNTCP1 {
|
|
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)
|
|
|
|
// 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/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", authMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req protocol.SDWANConfig
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if err := srv.SetSDWAN(req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
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))
|
|
if err != nil {
|
|
log.Fatalf("[main] listen :%d: %v", cfg.WSPort, err)
|
|
}
|
|
log.Printf("[main] signaling server on :%d (no TLS — use reverse proxy for production)", cfg.WSPort)
|
|
|
|
httpSrv := &http.Server{Handler: mux}
|
|
go func() {
|
|
if err := httpSrv.Serve(ln); err != http.ErrServerClosed {
|
|
log.Fatalf("[main] serve: %v", err)
|
|
}
|
|
}()
|
|
|
|
// ─── Graceful Shutdown ───
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
<-sigCh
|
|
|
|
log.Println("[main] shutting down...")
|
|
close(stunQuit)
|
|
srv.Stop()
|
|
httpSrv.Shutdown(context.Background())
|
|
log.Println("[main] goodbye")
|
|
}
|