fix: multi issues - TUN read loop, SDWAN routing for TenantID=0, WS keepalive 10s

This commit is contained in:
2026-03-03 11:24:00 +08:00
parent 9f6e065f3a
commit 10473020d2
22 changed files with 1122 additions and 76 deletions

View File

@@ -95,7 +95,7 @@ func (c *Client) connectAndRun() error {
c.publicIP = natResult.PublicIP
c.publicPort = natResult.Port1
c.localPort = natResult.LocalPort
log.Printf("[client] NAT type=%s, publicIP=%s, publicPort=%d, localPort=%d", c.natType, c.publicIP, c.publicPort, c.localPort)
log.Printf("[client] SENDING_LOGIN_TOKEN=%d NAT type=%s, publicIP=%s, publicPort=%d, localPort=%d", c.natType, c.publicIP, c.publicPort, c.localPort)
// 2. WSS Connect
scheme := "ws"
@@ -642,28 +642,34 @@ func (c *Client) tunReadLoop() {
if c.IsStopping() {
return
}
// Log only real errors, not EOF or timeout
if err.Error() != "EOF" && err.Error() != "resource temporarily unavailable" {
log.Printf("[client] tun read error: %v", err)
}
time.Sleep(100 * time.Millisecond)
log.Printf("[client] tun read error: %v", err)
continue
}
// Skip empty packets or non-IPv4
if n == 0 || n < 20 {
log.Printf("[client] tun read error: %v", err)
continue
}
pkt := buf[:n]
version := pkt[0] >> 4
if version != 4 {
log.Printf("[client] tun read error: %v", err)
continue // skip non-IPv4
}
dstIP := net.IP(pkt[16:20]).String()
c.sdwanMu.RLock()
self := c.sdwanIP
c.sdwanMu.RUnlock()
if dstIP == self {
log.Printf("[client] tun read error: %v", err)
continue // skip packets to self
}
// send raw binary to avoid JSON base64 overhead
log.Printf("[client] tun: read pkt len=%d dst=%s", n, dstIP)
frame := protocol.EncodeRaw(protocol.MsgTunnel, protocol.SubTunnelSDWANRaw, pkt)
_ = c.conn.WriteRaw(frame)
if err := c.conn.WriteRaw(frame); err != nil {
log.Printf("[client] tun write failed: %v", err)
}
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -5,6 +5,7 @@ import (
"log"
"time"
"github.com/openp2p-cn/inp2p/pkg/auth"
"github.com/openp2p-cn/inp2p/pkg/protocol"
)
@@ -17,12 +18,12 @@ import (
// 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)
to := s.GetNodeForUser(req.To, from.Token)
if to == nil || !to.IsOnline() {
// Peer offline — respond with error
// Peer offline or not visible — respond with generic not found
from.Conn.Write(protocol.MsgPush, protocol.SubPushConnectRsp, protocol.ConnectRsp{
Error: 1,
Detail: fmt.Sprintf("node %s offline", req.To),
Detail: "node not found",
From: req.To,
To: req.From,
})
@@ -38,6 +39,7 @@ func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error
Port: from.PublicPort,
NATType: from.NATType,
HasIPv4: from.HasIPv4,
Token: auth.GenTOTP(from.Token, time.Now().Unix()),
}
from.mu.RUnlock()
@@ -47,6 +49,7 @@ func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error
Port: to.PublicPort,
NATType: to.NATType,
HasIPv4: to.HasIPv4,
Token: auth.GenTOTP(to.Token, time.Now().Unix()),
}
to.mu.RUnlock()

Binary file not shown.

Binary file not shown.

View File

@@ -12,13 +12,14 @@ import (
)
type sdwanStore struct {
mu sync.RWMutex
path string
cfg protocol.SDWANConfig
mu sync.RWMutex
path string
cfg protocol.SDWANConfig
multi map[int64]protocol.SDWANConfig
}
func newSDWANStore(path string) *sdwanStore {
s := &sdwanStore{path: path}
s := &sdwanStore{path: path, multi: make(map[int64]protocol.SDWANConfig)}
_ = s.load()
return s
}
@@ -33,6 +34,15 @@ func (s *sdwanStore) load() error {
}
return err
}
// try multi-tenant first
var m map[int64]protocol.SDWANConfig
if err := json.Unmarshal(b, &m); err == nil && len(m) > 0 {
for k, v := range m {
m[k] = normalizeSDWAN(v)
}
s.multi = m
return nil
}
var c protocol.SDWANConfig
if err := json.Unmarshal(b, &c); err != nil {
return err
@@ -57,12 +67,40 @@ func (s *sdwanStore) save(cfg protocol.SDWANConfig) error {
return nil
}
func (s *sdwanStore) saveTenant(tenantID int64, cfg protocol.SDWANConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
cfg = normalizeSDWAN(cfg)
cfg.UpdatedAt = time.Now().Unix()
if s.multi == nil {
s.multi = make(map[int64]protocol.SDWANConfig)
}
s.multi[tenantID] = cfg
b, err := json.MarshalIndent(s.multi, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(s.path, b, 0644); err != nil {
return err
}
return nil
}
func (s *sdwanStore) get() protocol.SDWANConfig {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg
}
func (s *sdwanStore) getTenant(tenantID int64) protocol.SDWANConfig {
s.mu.RLock()
defer s.mu.RUnlock()
if s.multi == nil {
return protocol.SDWANConfig{}
}
return s.multi[tenantID]
}
func normalizeSDWAN(c protocol.SDWANConfig) protocol.SDWANConfig {
if c.Mode == "" {
c.Mode = "hub"

View File

@@ -11,6 +11,10 @@ func (s *Server) GetSDWAN() protocol.SDWANConfig {
return s.sdwan.get()
}
func (s *Server) GetSDWANTenant(tenantID int64) protocol.SDWANConfig {
return s.sdwan.getTenant(tenantID)
}
func (s *Server) SetSDWAN(cfg protocol.SDWANConfig) error {
if err := s.sdwan.save(cfg); err != nil {
return err
@@ -19,6 +23,14 @@ func (s *Server) SetSDWAN(cfg protocol.SDWANConfig) error {
return nil
}
func (s *Server) SetSDWANTenant(tenantID int64, cfg protocol.SDWANConfig) error {
if err := s.sdwan.saveTenant(tenantID, cfg); err != nil {
return err
}
s.broadcastSDWANTenant(tenantID, s.sdwan.getTenant(tenantID))
return nil
}
func (s *Server) broadcastSDWAN(cfg protocol.SDWANConfig) {
if !cfg.Enabled || cfg.GatewayCIDR == "" {
return
@@ -33,6 +45,20 @@ func (s *Server) broadcastSDWAN(cfg protocol.SDWANConfig) {
}
}
func (s *Server) broadcastSDWANTenant(tenantID int64, cfg protocol.SDWANConfig) {
if !cfg.Enabled || cfg.GatewayCIDR == "" {
return
}
s.mu.RLock()
defer s.mu.RUnlock()
for _, n := range s.nodes {
if !n.IsOnline() || n.TenantID != tenantID {
continue
}
_ = n.Conn.Write(protocol.MsgPush, protocol.SubPushSDWANConfig, cfg)
}
}
func (s *Server) pushSDWANPeer(to *NodeInfo, peer protocol.SDWANPeer) {
if to == nil || !to.IsOnline() {
return
@@ -48,7 +74,14 @@ func (s *Server) pushSDWANDel(to *NodeInfo, peer protocol.SDWANPeer) {
}
func (s *Server) announceSDWANNodeOnline(nodeName string) {
cfg := s.sdwan.get()
// pick tenant config by node
s.mu.RLock()
newNode := s.nodes[nodeName]
s.mu.RUnlock()
if newNode == nil {
return
}
cfg := s.sdwan.getTenant(newNode.TenantID)
if cfg.GatewayCIDR == "" {
return
}
@@ -64,7 +97,7 @@ func (s *Server) announceSDWANNodeOnline(nodeName string) {
}
s.mu.RLock()
newNode := s.nodes[nodeName]
newNode = s.nodes[nodeName]
if newNode == nil || !newNode.IsOnline() {
s.mu.RUnlock()
return
@@ -74,7 +107,7 @@ func (s *Server) announceSDWANNodeOnline(nodeName string) {
continue
}
other := s.nodes[n.Node]
if other == nil || !other.IsOnline() {
if other == nil || !other.IsOnline() || other.TenantID != newNode.TenantID {
continue
}
// existing -> new
@@ -86,7 +119,13 @@ func (s *Server) announceSDWANNodeOnline(nodeName string) {
}
func (s *Server) announceSDWANNodeOffline(nodeName string) {
cfg := s.sdwan.get()
s.mu.RLock()
old := s.nodes[nodeName]
s.mu.RUnlock()
if old == nil {
return
}
cfg := s.sdwan.getTenant(old.TenantID)
if cfg.GatewayCIDR == "" {
return
}
@@ -100,7 +139,7 @@ func (s *Server) announceSDWANNodeOffline(nodeName string) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, n := range s.nodes {
if n.Name == nodeName || !n.IsOnline() {
if n.Name == nodeName || !n.IsOnline() || n.TenantID != old.TenantID {
continue
}
s.pushSDWANDel(n, protocol.SDWANPeer{Node: nodeName, IP: selfIP, Online: false})
@@ -112,7 +151,13 @@ func (s *Server) RouteSDWANPacket(from *NodeInfo, pkt protocol.SDWANPacket) {
if from == nil {
return
}
cfg := s.sdwan.get()
// Use global config for untrusted nodes (TenantID=0), otherwise use tenant config
var cfg protocol.SDWANConfig
if from.TenantID == 0 {
cfg = s.sdwan.get()
} else {
cfg = s.sdwan.getTenant(from.TenantID)
}
if cfg.GatewayCIDR == "" || pkt.DstIP == "" || len(pkt.Payload) == 0 {
return
}
@@ -124,12 +169,18 @@ func (s *Server) RouteSDWANPacket(from *NodeInfo, pkt protocol.SDWANPacket) {
toNode := ""
for _, n := range cfg.Nodes {
if n.IP == pkt.DstIP {
toNode = n.Node
break
candidate := s.GetNodeForUser(n.Node, from.Token)
if candidate != nil && candidate.TenantID == from.TenantID {
toNode = n.Node
break
}
}
if p, err := netip.ParseAddr(n.IP); err == nil && p == dst {
toNode = n.Node
break
candidate := s.GetNodeForUser(n.Node, from.Token)
if candidate != nil && candidate.TenantID == from.TenantID {
toNode = n.Node
break
}
}
}
if toNode == "" || toNode == from.Name {
@@ -138,6 +189,9 @@ func (s *Server) RouteSDWANPacket(from *NodeInfo, pkt protocol.SDWANPacket) {
s.mu.RLock()
to := s.nodes[toNode]
if to != nil && to.TenantID != from.TenantID {
to = nil
}
s.mu.RUnlock()
if to == nil || !to.IsOnline() {
return

View File

@@ -2,6 +2,7 @@
package server
import (
"fmt"
"log"
"net"
"net/http"
@@ -10,6 +11,7 @@ import (
"github.com/gorilla/websocket"
"github.com/openp2p-cn/inp2p/pkg/auth"
"github.com/openp2p-cn/inp2p/internal/store"
"github.com/openp2p-cn/inp2p/pkg/config"
"github.com/openp2p-cn/inp2p/pkg/protocol"
"github.com/openp2p-cn/inp2p/pkg/signal"
@@ -17,26 +19,27 @@ import (
// NodeInfo represents a connected client node.
type NodeInfo struct {
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:"-"`
Name string `json:"name"`
Token uint64 `json:"-"`
TenantID int64 `json:"tenantId"`
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:"-"`
mu sync.RWMutex `json:"-"`
}
// IsOnline checks if node has sent heartbeat recently.
@@ -49,25 +52,43 @@ func (n *NodeInfo) IsOnline() bool {
// Server is the INP2P signaling server.
type Server struct {
cfg config.ServerConfig
nodes map[string]*NodeInfo // node name → info
nodes map[string]*NodeInfo
mu sync.RWMutex
upgrader websocket.Upgrader
quit chan struct{}
sdwanPath string
sdwan *sdwanStore
store *store.Store
tokens map[uint64]bool
}
func (s *Server) Store() *store.Store { return s.store }
// New creates a new server.
func New(cfg config.ServerConfig) *Server {
// Use absolute path for sdwan config to avoid working directory issues
sdwanPath := "/root/.openclaw/workspace/inp2p/sdwan.json"
tokens := make(map[uint64]bool)
if cfg.Token != 0 {
tokens[cfg.Token] = true
}
for _, t := range cfg.Tokens {
tokens[t] = true
}
st, err := store.Open(cfg.DBPath)
if err != nil {
log.Printf("[server] open store failed: %v", err)
}
return &Server{
cfg: cfg,
nodes: make(map[string]*NodeInfo),
sdwanPath: sdwanPath,
sdwan: newSDWANStore(sdwanPath),
store: st,
tokens: tokens,
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
ReadBufferSize: 4096,
WriteBufferSize: 4096,
},
quit: make(chan struct{}),
}
@@ -93,6 +114,42 @@ func (s *Server) GetOnlineNodes() []*NodeInfo {
return out
}
// GetNodeForUser returns node if token matches (legacy) or tenant matches.
func (s *Server) GetNodeForUser(name string, token uint64) *NodeInfo {
s.mu.RLock()
defer s.mu.RUnlock()
n := s.nodes[name]
if n == nil {
return nil
}
if n.Token != token && n.TenantID == 0 {
return nil
}
return n
}
func (s *Server) GetNodeForTenant(name string, tenantID int64) *NodeInfo {
s.mu.RLock()
defer s.mu.RUnlock()
n := s.nodes[name]
if n == nil || n.TenantID != tenantID {
return nil
}
return n
}
func (s *Server) GetOnlineNodesByTenant(tenantID int64) []*NodeInfo {
s.mu.RLock()
defer s.mu.RUnlock()
var out []*NodeInfo
for _, n := range s.nodes {
if n.IsOnline() && n.TenantID == tenantID {
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 {
@@ -119,6 +176,28 @@ func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeIn
return append(privateRelays, superRelays...)
}
// GetRelayNodesByTenant returns relay nodes within tenant.
func (s *Server) GetRelayNodesByTenant(tenantID int64, excludeNodes ...string) []*NodeInfo {
excludeSet := make(map[string]bool)
for _, n := range excludeNodes {
excludeSet[n] = true
}
s.mu.RLock()
defer s.mu.RUnlock()
var relays []*NodeInfo
for _, n := range s.nodes {
if !n.IsOnline() || excludeSet[n.Name] {
continue
}
if n.TenantID == tenantID && (n.RelayEnabled || n.SuperRelay) {
relays = append(relays, n)
}
}
return relays
}
// 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)
@@ -151,8 +230,26 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
return
}
// Verify token
if loginReq.Token != s.cfg.Token {
// Verify token: master token OR tenant API key (DB) OR node_secret (DB)
valid := s.tokens[loginReq.Token]
log.Printf("[server] login check: token=%d, cfg.Token=%d, valid=%v", loginReq.Token, s.cfg.Token, valid)
var tenantID int64
if !valid && s.store != nil {
// try api key (string) or node secret
if loginReq.NodeSecret != "" {
if ten, err := s.store.VerifyNodeSecret(loginReq.Node, loginReq.NodeSecret); err == nil && ten != nil {
valid = true
tenantID = ten.ID
}
}
if !valid {
if ten, err := s.store.VerifyAPIKey(fmt.Sprintf("%d", loginReq.Token)); err == nil && ten != nil {
valid = true
tenantID = ten.ID
}
}
}
if !valid {
log.Printf("[server] login denied: %s (token mismatch)", loginReq.Node)
conn.Write(protocol.MsgLogin, protocol.SubLoginRsp, protocol.LoginRsp{
Error: 1,
@@ -174,6 +271,7 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
node := &NodeInfo{
Name: loginReq.Node,
Token: loginReq.Token,
TenantID: tenantID,
User: loginReq.User,
Version: loginReq.Version,
NATType: loginReq.NATType,
@@ -211,11 +309,21 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
s.broadcastNodeOnline(loginReq.Node)
// Push current SDWAN config right after login (if exists and enabled)
if cfg := s.sdwan.get(); cfg.Enabled && cfg.GatewayCIDR != "" {
if err := conn.Write(protocol.MsgPush, protocol.SubPushSDWANConfig, cfg); err != nil {
log.Printf("[server] sdwan config push failed: %v", err)
} else {
log.Printf("[server] sdwan config pushed to %s", loginReq.Node)
if node.TenantID > 0 {
if cfg := s.sdwan.getTenant(node.TenantID); cfg.Enabled && cfg.GatewayCIDR != "" {
if err := conn.Write(protocol.MsgPush, protocol.SubPushSDWANConfig, cfg); err != nil {
log.Printf("[server] sdwan config push failed: %v", err)
} else {
log.Printf("[server] sdwan config pushed to %s", loginReq.Node)
}
}
} else {
if cfg := s.sdwan.get(); cfg.Enabled && cfg.GatewayCIDR != "" {
if err := conn.Write(protocol.MsgPush, protocol.SubPushSDWANConfig, cfg); err != nil {
log.Printf("[server] sdwan config push failed: %v", err)
} else {
log.Printf("[server] sdwan config pushed to %s", loginReq.Node)
}
}
}
// Event-driven SDWAN peer notification
@@ -378,10 +486,13 @@ func (s *Server) handleRelayNodeReq(conn *signal.Conn, requester *NodeInfo, req
// 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)
toNode := s.GetNodeForUser(toNodeName, fromNode.Token)
if toNode == nil || !toNode.IsOnline() {
return &NodeOfflineError{Node: toNodeName}
}
if fromNode.TenantID != 0 && toNode.TenantID != fromNode.TenantID {
return &NodeOfflineError{Node: toNodeName}
}
// Push connect request to the destination
req := protocol.ConnectReq{
@@ -392,6 +503,7 @@ func (s *Server) PushConnect(fromNode *NodeInfo, toNodeName string, app protocol
IP: fromNode.PublicIP,
NATType: fromNode.NATType,
HasIPv4: fromNode.HasIPv4,
Token: auth.GenTOTP(fromNode.Token, time.Now().Unix()),
},
AppName: app.AppName,
Protocol: app.Protocol,
@@ -406,12 +518,19 @@ func (s *Server) PushConnect(fromNode *NodeInfo, toNodeName string, app protocol
// broadcastNodeOnline notifies interested nodes that a peer came online.
func (s *Server) broadcastNodeOnline(nodeName string) {
s.mu.RLock()
newNode := s.nodes[nodeName]
defer s.mu.RUnlock()
if newNode == nil {
return
}
for _, n := range s.nodes {
if n.Name == nodeName {
continue
}
if n.Token != newNode.Token && (newNode.TenantID == 0 || n.TenantID != newNode.TenantID) {
continue
}
// Check if this node has any app targeting the new node
n.mu.RLock()
interested := false

View File

@@ -0,0 +1,185 @@
package server
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/openp2p-cn/inp2p/internal/store"
)
// helpers
func BearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if h == "" {
return ""
}
parts := strings.SplitN(h, " ", 2)
if len(parts) != 2 {
return ""
}
if strings.ToLower(parts[0]) != "bearer" {
return ""
}
return strings.TrimSpace(parts[1])
}
func writeJSON(w http.ResponseWriter, status int, body string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
io.WriteString(w, body)
}
func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
if s.store == nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return
}
ten, err := s.store.CreateTenant(req.Name)
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create tenant failed"}`)
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Tenant int64 `json:"tenant_id"`
Subnet string `json:"subnet"`
}{0, "ok", ten.ID, ten.Subnet}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
}
func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
if s.store == nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return
}
// /api/v1/admin/tenants/{id}/keys
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 6 || parts[5] != "keys" {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
// parts: api v1 admin tenants {id} keys
idPart := parts[4]
var tenantID int64
_, _ = fmt.Sscanf(idPart, "%d", &tenantID)
if tenantID == 0 {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
var req struct {
Scope string `json:"scope"`
TTL int64 `json:"ttl"` // seconds
}
_ = json.NewDecoder(r.Body).Decode(&req)
var ttl time.Duration
if req.TTL > 0 {
ttl = time.Duration(req.TTL) * time.Second
}
key, err := s.store.CreateAPIKey(tenantID, req.Scope, ttl)
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create key failed"}`)
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
APIKey string `json:"api_key"`
Tenant int64 `json:"tenant_id"`
}{0, "ok", key, tenantID}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
}
func (s *Server) HandleTenantEnroll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
// tenant auth by API key
if s.store == nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return
}
tok := BearerToken(r)
ten, err := s.store.VerifyAPIKey(tok)
if err != nil || ten == nil {
writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"unauthorized"}`)
return
}
code, err := s.store.CreateEnrollToken(ten.ID, 10*time.Minute, 5)
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create enroll failed"}`)
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Code string `json:"enroll_code"`
Tenant int64 `json:"tenant_id"`
}{0, "ok", code, ten.ID}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
}
func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
var req struct {
Code string `json:"code"`
NodeName string `json:"node"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Code == "" || req.NodeName == "" {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
if s.store == nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return
}
et, err := s.store.ConsumeEnrollToken(req.Code)
if err != nil {
s.store.IncEnrollAttempt(req.Code)
writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"invalid enroll"}`)
return
}
cred, err := s.store.CreateNodeCredential(et.TenantID, req.NodeName)
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create node failed"}`)
return
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
NodeID int64 `json:"node_id"`
Secret string `json:"node_secret"`
Tenant int64 `json:"tenant_id"`
}{0, "ok", cred.NodeID, cred.Secret, cred.TenantID}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
}
// placeholder to avoid unused import
var _ = store.Tenant{}

343
internal/store/store.go Normal file
View File

@@ -0,0 +1,343 @@
package store
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"net"
"time"
_ "modernc.org/sqlite"
)
type Store struct {
DB *sql.DB
}
type Tenant struct {
ID int64
Name string
Status int
Subnet string
}
type APIKey struct {
ID int64
TenantID int64
Hash string
Scope string
Expires *time.Time
Status int
}
type NodeCredential struct {
NodeID int64
NodeName string
Secret string
VirtualIP string
TenantID int64
}
type EnrollToken struct {
ID int64
TenantID int64
Hash string
ExpiresAt int64
UsedAt *int64
MaxAttempt int
Attempts int
Status int
}
func Open(dbPath string) (*Store, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, err
}
if _, err := db.Exec(`PRAGMA journal_mode=WAL;`); err != nil {
return nil, err
}
if _, err := db.Exec(`PRAGMA foreign_keys=ON;`); err != nil {
return nil, err
}
s := &Store{DB: db}
if err := s.migrate(); err != nil {
return nil, err
}
if err := s.ensureSubnetPool(); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) migrate() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS tenants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
status INTEGER NOT NULL DEFAULT 1,
subnet TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
);`,
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
role TEXT NOT NULL,
email TEXT,
password_hash TEXT,
status INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
);`,
`CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
scope TEXT,
expires_at INTEGER,
status INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
);`,
`CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
node_name TEXT NOT NULL,
node_pubkey TEXT,
node_secret_hash TEXT,
virtual_ip TEXT,
status INTEGER NOT NULL DEFAULT 1,
last_seen INTEGER,
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
);`,
`CREATE TABLE IF NOT EXISTS enroll_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
used_at INTEGER,
max_attempt INTEGER NOT NULL DEFAULT 5,
attempts INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY(tenant_id) REFERENCES tenants(id)
);`,
`CREATE TABLE IF NOT EXISTS peering_policies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
src_tenant_id INTEGER NOT NULL,
dst_tenant_id INTEGER NOT NULL,
rules TEXT,
expires_at INTEGER,
status INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY(src_tenant_id) REFERENCES tenants(id),
FOREIGN KEY(dst_tenant_id) REFERENCES tenants(id)
);`,
`CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_type TEXT,
actor_id TEXT,
action TEXT,
target_type TEXT,
target_id TEXT,
detail TEXT,
ip TEXT,
created_at INTEGER NOT NULL
);`,
`CREATE TABLE IF NOT EXISTS subnet_pool (
subnet TEXT PRIMARY KEY,
status INTEGER NOT NULL DEFAULT 0,
reserved INTEGER NOT NULL DEFAULT 0,
tenant_id INTEGER,
updated_at INTEGER NOT NULL
);`,
}
for _, stmt := range stmts {
if _, err := s.DB.Exec(stmt); err != nil {
return err
}
}
return nil
}
func (s *Store) ensureSubnetPool() error {
// pool: 10.10.1.0/24 .. 10.10.254.0/24
// reserve: 10.10.0.0/24 and 10.10.255.0/24
rows, err := s.DB.Query(`SELECT COUNT(1) FROM subnet_pool;`)
if err != nil {
return err
}
defer rows.Close()
var count int
if rows.Next() {
_ = rows.Scan(&count)
}
if count > 0 {
return nil
}
now := time.Now().Unix()
insert := `INSERT INTO subnet_pool(subnet,status,reserved,tenant_id,updated_at) VALUES(?,?,?,?,?)`
// reserved
_, _ = s.DB.Exec(insert, "10.10.0.0/24", 0, 1, nil, now)
_, _ = s.DB.Exec(insert, "10.10.255.0/24", 0, 1, nil, now)
for i := 1; i <= 254; i++ {
sn := fmt.Sprintf("10.10.%d.0/24", i)
_, _ = s.DB.Exec(insert, sn, 0, 0, nil, now)
}
return nil
}
func (s *Store) AllocateSubnet() (string, error) {
// find first available subnet
row := s.DB.QueryRow(`SELECT subnet FROM subnet_pool WHERE status=0 AND reserved=0 ORDER BY subnet LIMIT 1`)
var subnet string
if err := row.Scan(&subnet); err != nil {
return "", err
}
if subnet == "" {
return "", errors.New("no subnet available")
}
return subnet, nil
}
func (s *Store) CreateTenant(name string) (*Tenant, error) {
sn, err := s.AllocateSubnet()
if err != nil {
return nil, err
}
now := time.Now().Unix()
res, err := s.DB.Exec(`INSERT INTO tenants(name,status,subnet,created_at) VALUES(?,?,?,?)`, name, 1, sn, now)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
_, _ = s.DB.Exec(`UPDATE subnet_pool SET status=1, tenant_id=?, updated_at=? WHERE subnet=?`, id, now, sn)
return &Tenant{ID: id, Name: name, Status: 1, Subnet: sn}, nil
}
func (s *Store) CreateNodeCredential(tenantID int64, nodeName string) (*NodeCredential, error) {
secret := randToken()
h := hashTokenString(secret)
res, err := s.DB.Exec(`INSERT INTO nodes(tenant_id,node_name,node_secret_hash,status) VALUES(?,?,?,1)`, tenantID, nodeName, h)
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
return &NodeCredential{NodeID: id, NodeName: nodeName, Secret: secret, TenantID: tenantID}, nil
}
func (s *Store) VerifyNodeSecret(nodeName, secret string) (*Tenant, error) {
h := hashTokenString(secret)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet FROM nodes n JOIN tenants t ON n.tenant_id=t.id WHERE n.node_name=? AND n.node_secret_hash=? AND n.status=1`, nodeName, h)
var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet); err != nil {
return nil, err
}
return &t, nil
}
func (s *Store) GetTenantByToken(token uint64) (*Tenant, error) {
h := hashToken(token)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1`, h)
var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet); err != nil {
return nil, err
}
return &t, nil
}
func (s *Store) CreateAPIKey(tenantID int64, scope string, ttl time.Duration) (string, error) {
token := randToken()
h := hashTokenString(token)
now := time.Now().Unix()
if ttl > 0 {
e := time.Now().Add(ttl)
_, err := s.DB.Exec(`INSERT INTO api_keys(tenant_id,key_hash,scope,expires_at,status,created_at) VALUES(?,?,?,?,1,?)`, tenantID, h, scope, e.Unix(), now)
return token, err
}
_, err := s.DB.Exec(`INSERT INTO api_keys(tenant_id,key_hash,scope,expires_at,status,created_at) VALUES(?,?,?,?,1,?)`, tenantID, h, scope, nil, now)
return token, err
}
func (s *Store) CreateEnrollToken(tenantID int64, ttl time.Duration, maxAttempt int) (string, error) {
code := randToken()
h := hashTokenString(code)
exp := time.Now().Add(ttl).Unix()
now := time.Now().Unix()
_, err := s.DB.Exec(`INSERT INTO enroll_tokens(tenant_id,token_hash,expires_at,max_attempt,attempts,status,created_at) VALUES(?,?,?,?,0,1,?)`, tenantID, h, exp, maxAttempt, now)
return code, err
}
func (s *Store) ConsumeEnrollToken(code string) (*EnrollToken, error) {
h := hashTokenString(code)
now := time.Now().Unix()
row := s.DB.QueryRow(`SELECT id,tenant_id,expires_at,used_at,max_attempt,attempts,status FROM enroll_tokens WHERE token_hash=?`, h)
var et EnrollToken
var used sql.NullInt64
if err := row.Scan(&et.ID, &et.TenantID, &et.ExpiresAt, &used, &et.MaxAttempt, &et.Attempts, &et.Status); err != nil {
return nil, err
}
if used.Valid {
return nil, errors.New("token already used")
}
if et.Status != 1 {
return nil, errors.New("token disabled")
}
if et.Attempts >= et.MaxAttempt {
return nil, errors.New("token attempts exceeded")
}
if now > et.ExpiresAt {
return nil, errors.New("token expired")
}
// mark used
_, err := s.DB.Exec(`UPDATE enroll_tokens SET used_at=?, attempts=attempts+1 WHERE id=?`, now, et.ID)
if err != nil {
return nil, err
}
et.UsedAt = &now
return &et, nil
}
func (s *Store) IncEnrollAttempt(code string) {
h := hashTokenString(code)
_, _ = s.DB.Exec(`UPDATE enroll_tokens SET attempts=attempts+1 WHERE token_hash=?`, h)
}
func hashToken(token uint64) string {
b := make([]byte, 8)
for i := uint(0); i < 8; i++ {
b[7-i] = byte(token >> (i * 8))
}
return hashTokenBytes(b)
}
func hashTokenString(token string) string {
return hashTokenBytes([]byte(token))
}
func (s *Store) VerifyAPIKey(token string) (*Tenant, error) {
h := hashTokenString(token)
row := s.DB.QueryRow(`SELECT t.id,t.name,t.status,t.subnet FROM api_keys k JOIN tenants t ON k.tenant_id=t.id WHERE k.key_hash=? AND k.status=1`, h)
var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Status, &t.Subnet); err != nil {
return nil, err
}
return &t, nil
}
func hashTokenBytes(b []byte) string {
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func randToken() string {
b := make([]byte, 24)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// helper to avoid unused import (net)
var _ = net.IPv4len