feat: INP2P v0.1.0 — complete P2P tunneling system

Core modules (M1-M6):
- pkg/protocol: message format, encoding, NAT type enums
- pkg/config: server/client config structs, env vars, validation
- pkg/auth: CRC64 token, TOTP gen/verify, one-time relay tokens
- pkg/nat: UDP/TCP STUN client and server
- pkg/signal: WSS message dispatch, sync request/response
- pkg/punch: UDP/TCP hole punching + priority chain
- pkg/mux: stream multiplexer (7B frame: StreamID+Flags+Len)
- pkg/tunnel: mux-based port forwarding with stats
- pkg/relay: relay manager with TOTP auth + session bridging
- internal/server: signaling server (login/heartbeat/report/coordinator)
- internal/client: client (NAT detect/login/punch/relay/reconnect)
- cmd/inp2ps + cmd/inp2pc: main entrypoints with graceful shutdown

All tests pass: 16 tests across 5 packages
Code: 3559 lines core + 861 lines tests = 19 source files
This commit is contained in:
2026-03-02 15:13:22 +08:00
commit 91e3d4da2a
23 changed files with 4681 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
package server
import (
"fmt"
"log"
"time"
"github.com/openp2p-cn/inp2p/pkg/protocol"
)
// ConnectCoordinator handles the complete punch coordination flow:
// 1. Client A sends ConnectReq to server
// 2. Server looks up Client B
// 3. Server pushes PunchStart to BOTH A and B simultaneously
// 4. Both sides call punch.Connect() at the same time
// 5. Success/failure reported back via PunchResult
// HandleConnectReq processes a connection request from node A to node B.
func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error {
to := s.GetNode(req.To)
if to == nil || !to.IsOnline() {
// Peer offline — respond with error
from.Conn.Write(protocol.MsgPush, protocol.SubPushConnectRsp, protocol.ConnectRsp{
Error: 1,
Detail: fmt.Sprintf("node %s offline", req.To),
From: req.To,
To: req.From,
})
return &NodeOfflineError{Node: req.To}
}
log.Printf("[coord] %s → %s: coordinating punch", from.Name, to.Name)
// Build punch parameters for both sides
from.mu.RLock()
fromParams := protocol.PunchParams{
IP: from.PublicIP,
NATType: from.NATType,
HasIPv4: from.HasIPv4,
}
from.mu.RUnlock()
to.mu.RLock()
toParams := protocol.PunchParams{
IP: to.PublicIP,
NATType: to.NATType,
HasIPv4: to.HasIPv4,
}
to.mu.RUnlock()
// Check if punch is possible
if !protocol.CanPunch(fromParams.NATType, toParams.NATType) {
log.Printf("[coord] %s(%s) ↔ %s(%s): punch impossible, suggesting relay",
from.Name, fromParams.NATType, to.Name, toParams.NATType)
// Respond to A with B's info but mark that punch is unlikely
from.Conn.Write(protocol.MsgPush, protocol.SubPushConnectRsp, protocol.ConnectRsp{
Error: 0,
From: to.Name,
To: from.Name,
Peer: toParams,
Detail: "punch-unlikely",
})
return nil
}
// Push PunchStart to BOTH sides simultaneously
punchID := fmt.Sprintf("%s-%s-%d", from.Name, to.Name, time.Now().UnixMilli())
// Tell B about A (so B starts punching toward A)
punchToB := protocol.ConnectReq{
From: from.Name,
To: to.Name,
FromIP: from.PublicIP,
Peer: fromParams,
AppName: req.AppName,
Protocol: req.Protocol,
SrcPort: req.SrcPort,
DstHost: req.DstHost,
DstPort: req.DstPort,
}
if err := to.Conn.Write(protocol.MsgPush, protocol.SubPushConnectReq, punchToB); err != nil {
log.Printf("[coord] push to %s failed: %v", to.Name, err)
}
// Tell A about B (so A starts punching toward B)
rspToA := protocol.ConnectRsp{
Error: 0,
From: to.Name,
To: from.Name,
Peer: toParams,
}
if err := from.Conn.Write(protocol.MsgPush, protocol.SubPushConnectRsp, rspToA); err != nil {
log.Printf("[coord] rsp to %s failed: %v", from.Name, err)
}
log.Printf("[coord] punch started: %s(%s:%s) ↔ %s(%s:%s) id=%s",
from.Name, fromParams.IP, fromParams.NATType,
to.Name, toParams.IP, toParams.NATType,
punchID)
return nil
}
// HandleEditApp pushes an app configuration to a node, triggering tunnel creation.
func (s *Server) HandleEditApp(nodeName string, app protocol.AppConfig) error {
node := s.GetNode(nodeName)
if node == nil || !node.IsOnline() {
return &NodeOfflineError{Node: nodeName}
}
log.Printf("[coord] push EditApp to %s: %s (:%d → %s:%d)",
nodeName, app.AppName, app.SrcPort, app.PeerNode, app.DstPort)
return node.Conn.Write(protocol.MsgPush, protocol.SubPushEditApp, app)
}
// HandleDeleteApp pushes app deletion to a node.
func (s *Server) HandleDeleteApp(nodeName string, appName string) error {
node := s.GetNode(nodeName)
if node == nil || !node.IsOnline() {
return &NodeOfflineError{Node: nodeName}
}
return node.Conn.Write(protocol.MsgPush, protocol.SubPushDeleteApp, struct {
AppName string `json:"appName"`
}{AppName: appName})
}
// HandleReportApps pushes a report-apps request to a node.
func (s *Server) HandleReportApps(nodeName string) error {
node := s.GetNode(nodeName)
if node == nil || !node.IsOnline() {
return &NodeOfflineError{Node: nodeName}
}
return node.Conn.Write(protocol.MsgPush, protocol.SubPushReportApps, nil)
}

406
internal/server/server.go Normal file
View File

@@ -0,0 +1,406 @@
// Package server implements the inp2ps signaling server.
package server
import (
"log"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/openp2p-cn/inp2p/pkg/auth"
"github.com/openp2p-cn/inp2p/pkg/config"
"github.com/openp2p-cn/inp2p/pkg/protocol"
"github.com/openp2p-cn/inp2p/pkg/signal"
)
// NodeInfo represents a connected client node.
type NodeInfo struct {
Name string
Token uint64
User string
Version string
NATType protocol.NATType
PublicIP string
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
}
// IsOnline checks if node has sent heartbeat recently.
func (n *NodeInfo) IsOnline() bool {
n.mu.RLock()
defer n.mu.RUnlock()
return time.Since(n.LastHeartbeat) < time.Duration(config.HeartbeatTimeout)*time.Second
}
// Server is the INP2P signaling server.
type Server struct {
cfg config.ServerConfig
nodes map[string]*NodeInfo // node name → info
mu sync.RWMutex
upgrader websocket.Upgrader
quit chan struct{}
}
// New creates a new server.
func New(cfg config.ServerConfig) *Server {
return &Server{
cfg: cfg,
nodes: make(map[string]*NodeInfo),
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
},
quit: make(chan struct{}),
}
}
// GetNode returns a connected node by name.
func (s *Server) GetNode(name string) *NodeInfo {
s.mu.RLock()
defer s.mu.RUnlock()
return s.nodes[name]
}
// GetOnlineNodes returns all online nodes.
func (s *Server) GetOnlineNodes() []*NodeInfo {
s.mu.RLock()
defer s.mu.RUnlock()
var out []*NodeInfo
for _, n := range s.nodes {
if n.IsOnline() {
out = append(out, n)
}
}
return out
}
// GetRelayNodes returns nodes that can serve as relay.
// Priority: same-user private relay → super relay
func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeInfo {
excludeSet := make(map[string]bool)
for _, n := range excludeNodes {
excludeSet[n] = true
}
s.mu.RLock()
defer s.mu.RUnlock()
var privateRelays, superRelays []*NodeInfo
for _, n := range s.nodes {
if !n.IsOnline() || excludeSet[n.Name] || !n.RelayEnabled {
continue
}
if n.User == forUser {
privateRelays = append(privateRelays, n)
} else if n.SuperRelay {
superRelays = append(superRelays, n)
}
}
// private first, then super
return append(privateRelays, superRelays...)
}
// HandleWS is the WebSocket handler for client connections.
func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
ws, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("[server] ws upgrade error: %v", err)
return
}
conn := signal.NewConn(ws)
log.Printf("[server] new connection from %s", r.RemoteAddr)
// First message must be login
_, msg, err := ws.ReadMessage()
if err != nil {
log.Printf("[server] read login error: %v", err)
ws.Close()
return
}
hdr, err := protocol.DecodeHeader(msg)
if err != nil || hdr.MainType != protocol.MsgLogin || hdr.SubType != protocol.SubLoginReq {
log.Printf("[server] expected login, got %d:%d", hdr.MainType, hdr.SubType)
ws.Close()
return
}
var loginReq protocol.LoginReq
if err := protocol.DecodePayload(msg, &loginReq); err != nil {
log.Printf("[server] decode login: %v", err)
ws.Close()
return
}
// Verify token
if loginReq.Token != s.cfg.Token {
log.Printf("[server] login denied: %s (token mismatch)", loginReq.Node)
conn.Write(protocol.MsgLogin, protocol.SubLoginRsp, protocol.LoginRsp{
Error: 1,
Detail: "invalid token",
})
ws.Close()
return
}
// Check duplicate node
s.mu.Lock()
if old, exists := s.nodes[loginReq.Node]; exists {
log.Printf("[server] replacing existing node %s", loginReq.Node)
old.Conn.Close()
}
node := &NodeInfo{
Name: loginReq.Node,
Token: loginReq.Token,
User: loginReq.User,
Version: loginReq.Version,
NATType: loginReq.NATType,
ShareBandwidth: loginReq.ShareBandwidth,
RelayEnabled: loginReq.RelayEnabled,
SuperRelay: loginReq.SuperRelay,
PublicIP: r.RemoteAddr, // will be updated by NAT detect
LoginTime: time.Now(),
LastHeartbeat: time.Now(),
Conn: conn,
}
s.nodes[loginReq.Node] = node
s.mu.Unlock()
// Send login response
conn.Write(protocol.MsgLogin, protocol.SubLoginRsp, protocol.LoginRsp{
Error: 0,
Ts: time.Now().Unix(),
Token: loginReq.Token,
User: loginReq.User,
Node: loginReq.Node,
})
log.Printf("[server] login ok: node=%s, natType=%s, relay=%v, super=%v, version=%s",
loginReq.Node, loginReq.NATType, loginReq.RelayEnabled, loginReq.SuperRelay, loginReq.Version)
// Notify other nodes
s.broadcastNodeOnline(loginReq.Node)
// Register message handlers
s.registerHandlers(conn, node)
// Start read loop (blocks until disconnect)
if err := conn.ReadLoop(); err != nil {
log.Printf("[server] %s disconnected: %v", loginReq.Node, err)
}
// Cleanup
s.mu.Lock()
if current, ok := s.nodes[loginReq.Node]; ok && current == node {
delete(s.nodes, loginReq.Node)
}
s.mu.Unlock()
log.Printf("[server] %s offline", loginReq.Node)
}
func (s *Server) registerHandlers(conn *signal.Conn, node *NodeInfo) {
// Heartbeat
conn.OnMessage(protocol.MsgHeartbeat, protocol.SubHeartbeatPing, func(data []byte) error {
node.mu.Lock()
node.LastHeartbeat = time.Now()
node.mu.Unlock()
return conn.Write(protocol.MsgHeartbeat, protocol.SubHeartbeatPong, nil)
})
// ReportBasic
conn.OnMessage(protocol.MsgReport, protocol.SubReportBasic, func(data []byte) error {
var report protocol.ReportBasic
if err := protocol.DecodePayload(data, &report); err != nil {
return err
}
node.mu.Lock()
node.OS = report.OS
node.Mac = report.Mac
node.LanIP = report.LanIP
node.Version = report.Version
node.HasIPv4 = report.HasIPv4
node.IPv6 = report.IPv6
node.mu.Unlock()
log.Printf("[server] ReportBasic from %s: os=%s lanIP=%s", node.Name, report.OS, report.LanIP)
// Always respond (official OpenP2P bug: not responding causes client to disconnect)
return conn.Write(protocol.MsgReport, protocol.SubReportBasic, protocol.ReportBasicRsp{Error: 0})
})
// ReportApps
conn.OnMessage(protocol.MsgReport, protocol.SubReportApps, func(data []byte) error {
var apps []protocol.AppConfig
protocol.DecodePayload(data, &apps)
node.mu.Lock()
node.Apps = apps
node.mu.Unlock()
log.Printf("[server] ReportApps from %s: %d apps", node.Name, len(apps))
return nil
})
// ReportConnect
conn.OnMessage(protocol.MsgReport, protocol.SubReportConnect, func(data []byte) error {
var rc protocol.ReportConnect
protocol.DecodePayload(data, &rc)
if rc.Error != "" {
log.Printf("[server] ConnectReport ERROR from %s: peer=%s mode=%s err=%s", node.Name, rc.PeerNode, rc.LinkMode, rc.Error)
} else {
log.Printf("[server] ConnectReport OK from %s: peer=%s mode=%s rtt=%dms", node.Name, rc.PeerNode, rc.LinkMode, rc.RTT)
}
return nil
})
// ConnectReq — client wants to connect to a peer
conn.OnMessage(protocol.MsgPush, protocol.SubPushConnectReq, func(data []byte) error {
var req protocol.ConnectReq
protocol.DecodePayload(data, &req)
return s.HandleConnectReq(node, req)
})
// RelayNodeReq — client asks for a relay node
conn.OnMessage(protocol.MsgRelay, protocol.SubRelayNodeReq, func(data []byte) error {
var req protocol.RelayNodeReq
protocol.DecodePayload(data, &req)
return s.handleRelayNodeReq(conn, node, req)
})
}
// handleRelayNodeReq finds and returns the best relay node.
func (s *Server) handleRelayNodeReq(conn *signal.Conn, requester *NodeInfo, req protocol.RelayNodeReq) error {
relays := s.GetRelayNodes(requester.User, requester.Name, req.PeerNode)
if len(relays) == 0 {
return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{
Error: 1,
})
}
// Pick the first (best) relay
relay := relays[0]
totp := auth.GenTOTP(relay.Token, time.Now().Unix())
mode := "private"
if relay.User != requester.User {
mode = "super"
}
log.Printf("[server] relay selected: %s (%s) for %s → %s", relay.Name, mode, requester.Name, req.PeerNode)
return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{
RelayName: relay.Name,
RelayIP: relay.PublicIP,
RelayPort: config.DefaultRelayPort,
RelayToken: totp,
Mode: mode,
Error: 0,
})
}
// PushConnect sends a punch coordination message to a peer node.
func (s *Server) PushConnect(fromNode *NodeInfo, toNodeName string, app protocol.AppConfig) error {
toNode := s.GetNode(toNodeName)
if toNode == nil || !toNode.IsOnline() {
return &NodeOfflineError{Node: toNodeName}
}
// Push connect request to the destination
req := protocol.ConnectReq{
From: fromNode.Name,
To: toNodeName,
FromIP: fromNode.PublicIP,
Peer: protocol.PunchParams{
IP: fromNode.PublicIP,
NATType: fromNode.NATType,
HasIPv4: fromNode.HasIPv4,
},
AppName: app.AppName,
Protocol: app.Protocol,
SrcPort: app.SrcPort,
DstHost: app.DstHost,
DstPort: app.DstPort,
}
return toNode.Conn.Write(protocol.MsgPush, protocol.SubPushConnectReq, req)
}
// broadcastNodeOnline notifies interested nodes that a peer came online.
func (s *Server) broadcastNodeOnline(nodeName string) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, n := range s.nodes {
if n.Name == nodeName {
continue
}
// Check if this node has any app targeting the new node
n.mu.RLock()
interested := false
for _, app := range n.Apps {
if app.PeerNode == nodeName {
interested = true
break
}
}
n.mu.RUnlock()
if interested {
n.Conn.Write(protocol.MsgPush, protocol.SubPushNodeOnline, struct {
Node string `json:"node"`
}{Node: nodeName})
}
}
}
// StartCleanup periodically removes stale nodes.
func (s *Server) StartCleanup() {
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.mu.Lock()
for name, n := range s.nodes {
if !n.IsOnline() {
log.Printf("[server] cleanup stale node: %s", name)
n.Conn.Close()
delete(s.nodes, name)
}
}
s.mu.Unlock()
case <-s.quit:
return
}
}
}()
}
// Stop shuts down the server.
func (s *Server) Stop() {
close(s.quit)
s.mu.Lock()
for _, n := range s.nodes {
n.Conn.Close()
}
s.mu.Unlock()
}
type NodeOfflineError struct {
Node string
}
func (e *NodeOfflineError) Error() string {
return "node offline: " + e.Node
}

View File

@@ -0,0 +1,151 @@
package server
import (
"fmt"
"log"
"net/http"
"testing"
"time"
"github.com/openp2p-cn/inp2p/pkg/config"
"github.com/openp2p-cn/inp2p/pkg/nat"
"github.com/openp2p-cn/inp2p/pkg/protocol"
"github.com/openp2p-cn/inp2p/pkg/signal"
"github.com/gorilla/websocket"
)
func TestLoginFlow(t *testing.T) {
// Start server
cfg := config.DefaultServerConfig()
cfg.WSPort = 29300
cfg.Token = 999
srv := New(cfg)
mux := http.NewServeMux()
mux.HandleFunc("/ws", srv.HandleWS)
go http.ListenAndServe(fmt.Sprintf(":%d", cfg.WSPort), mux)
time.Sleep(200 * time.Millisecond)
// Connect as client manually
ws, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://127.0.0.1:%d/ws", cfg.WSPort), nil)
if err != nil {
t.Fatal(err)
}
conn := signal.NewConn(ws)
defer conn.Close()
// Start read loop in background
go conn.ReadLoop()
// Send login
loginReq := protocol.LoginReq{
Node: "testNode",
Token: 999,
Version: "test",
NATType: protocol.NATCone,
}
rspData, err := conn.Request(
protocol.MsgLogin, protocol.SubLoginReq, loginReq,
protocol.MsgLogin, protocol.SubLoginRsp,
5*time.Second,
)
if err != nil {
t.Fatalf("login request failed: %v", err)
}
var rsp protocol.LoginRsp
protocol.DecodePayload(rspData, &rsp)
if rsp.Error != 0 {
t.Fatalf("login error: %d %s", rsp.Error, rsp.Detail)
}
log.Printf("Login OK: node=%s", rsp.Node)
// Verify node is registered
time.Sleep(100 * time.Millisecond)
nodes := srv.GetOnlineNodes()
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
if nodes[0].Name != "testNode" {
t.Fatalf("expected testNode, got %s", nodes[0].Name)
}
srv.Stop()
}
func TestTwoClientsWithSTUN(t *testing.T) {
cfg := config.DefaultServerConfig()
cfg.WSPort = 29301
cfg.STUNUDP1 = 29382
cfg.STUNUDP2 = 29384
cfg.STUNTCP1 = 29380
cfg.STUNTCP2 = 29381
cfg.Token = 888
// STUN
stunQuit := make(chan struct{})
defer close(stunQuit)
go nat.ServeUDPSTUN(cfg.STUNUDP1, stunQuit)
go nat.ServeUDPSTUN(cfg.STUNUDP2, stunQuit)
go nat.ServeTCPSTUN(cfg.STUNTCP1, stunQuit)
go nat.ServeTCPSTUN(cfg.STUNTCP2, stunQuit)
srv := New(cfg)
srv.StartCleanup()
mux := http.NewServeMux()
mux.HandleFunc("/ws", srv.HandleWS)
go http.ListenAndServe(fmt.Sprintf(":%d", cfg.WSPort), mux)
time.Sleep(300 * time.Millisecond)
// NAT detect
natResult := nat.Detect("127.0.0.1", cfg.STUNUDP1, cfg.STUNUDP2, cfg.STUNTCP1, cfg.STUNTCP2)
log.Printf("NAT: type=%s publicIP=%s", natResult.Type, natResult.PublicIP)
// Client A
connectClient := func(name string, relay bool) *signal.Conn {
ws, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://127.0.0.1:%d/ws", cfg.WSPort), nil)
if err != nil {
t.Fatalf("dial %s: %v", name, err)
}
conn := signal.NewConn(ws)
go conn.ReadLoop()
rspData, err := conn.Request(
protocol.MsgLogin, protocol.SubLoginReq,
protocol.LoginReq{Node: name, Token: 888, Version: "test", NATType: natResult.Type, RelayEnabled: relay},
protocol.MsgLogin, protocol.SubLoginRsp,
5*time.Second,
)
if err != nil {
t.Fatalf("login %s: %v", name, err)
}
var rsp protocol.LoginRsp
protocol.DecodePayload(rspData, &rsp)
if rsp.Error != 0 {
t.Fatalf("login %s error: %s", name, rsp.Detail)
}
log.Printf("%s login ok", name)
return conn
}
connA := connectClient("nodeA", true)
defer connA.Close()
connB := connectClient("nodeB", false)
defer connB.Close()
time.Sleep(200 * time.Millisecond)
nodes := srv.GetOnlineNodes()
if len(nodes) != 2 {
t.Fatalf("expected 2 nodes, got %d", len(nodes))
}
// Test relay node discovery
relays := srv.GetRelayNodes("", "nodeB")
if len(relays) != 1 || relays[0].Name != "nodeA" {
t.Fatalf("expected nodeA as relay, got %v", relays)
}
log.Printf("Relay nodes: %v", relays[0].Name)
srv.Stop()
}