feat: audit api, sdwan persist, relay fallback updates

This commit is contained in:
2026-03-06 14:47:03 +08:00
parent e96a2e5dd9
commit 57b4dadd42
26 changed files with 991 additions and 183 deletions

View File

@@ -0,0 +1,56 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
)
// GET /api/v1/admin/settings
// POST /api/v1/admin/settings {key,value}
func (s *Server) HandleAdminSettings(w http.ResponseWriter, r *http.Request) {
if s.store == nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return
}
if r.Method == http.MethodGet {
settings, err := s.store.ListSettings()
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"list settings failed"}`)
return
}
b, _ := json.Marshal(map[string]any{"error": 0, "settings": settings})
writeJSON(w, http.StatusOK, string(b))
return
}
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
var req struct {
Key string `json:"key"`
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Key == "" {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
// allowlist
switch req.Key {
case "advanced_impersonate", "advanced_force_network", "advanced_cross_tenant":
default:
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"invalid key"}`)
return
}
if req.Value == "" {
req.Value = "0"
}
if err := s.store.SetSetting(req.Key, req.Value); err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"set failed"}`)
return
}
if ac := GetAccessContext(r); ac != nil {
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "setting_change", "setting", req.Key, req.Value, r.RemoteAddr)
}
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
}

View File

@@ -0,0 +1,40 @@
package server
import (
"encoding/json"
"net/http"
"strconv"
)
// GET /api/v1/admin/audit?tenant=3&limit=50&offset=0
func (s *Server) HandleAdminAudit(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
limit := 50
offset := 0
if v := r.URL.Query().Get("limit"); v != "" {
if i, err := strconv.Atoi(v); err == nil && i > 0 && i <= 500 {
limit = i
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if i, err := strconv.Atoi(v); err == nil && i >= 0 {
offset = i
}
}
tenantID := int64(0)
if v := r.URL.Query().Get("tenant"); v != "" {
if i, err := strconv.ParseInt(v, 10, 64); err == nil {
tenantID = i
}
}
logs, err := s.store.ListAuditLogs(tenantID, limit, offset)
if 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, "logs": logs})
}

View File

@@ -26,6 +26,17 @@ func (s *Server) ResolveAccess(r *http.Request, masterToken uint64) (*AccessCont
return s.ResolveTenantAccessToken(tok)
}
func GetAccessContext(r *http.Request) *AccessContext {
v := r.Context().Value(ServerCtxKeyAccess{})
if v == nil {
return nil
}
if ac, ok := v.(*AccessContext); ok {
return ac
}
return nil
}
func (s *Server) ResolveTenantAccessToken(tok string) (*AccessContext, bool) {
if tok == "" || s.store == nil {
return nil, false

View File

@@ -3,6 +3,7 @@ package server
import (
"fmt"
"log"
"os"
"time"
"github.com/openp2p-cn/inp2p/pkg/auth"
@@ -68,6 +69,19 @@ func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error
return nil
}
// Debug: force relay path if explicit env set
if os.Getenv("INP2P_FORCE_RELAY") == "1" {
log.Printf("[coord] %s → %s: force relay requested", from.Name, to.Name)
from.Conn.Write(protocol.MsgPush, protocol.SubPushConnectRsp, protocol.ConnectRsp{
Error: 0,
From: to.Name,
To: from.Name,
Peer: toParams,
Detail: "punch-failed",
})
return nil
}
// Push PunchStart to BOTH sides simultaneously
punchID := fmt.Sprintf("%s-%s-%d", from.Name, to.Name, time.Now().UnixMilli())

7
internal/server/ctx.go Normal file
View File

@@ -0,0 +1,7 @@
package server
// ctx key alias for main
// NOTE: main sets this type to avoid import cycles
// use GetAccessContext to retrieve
type ServerCtxKeyAccess struct{}

Binary file not shown.

Binary file not shown.

View File

@@ -121,5 +121,21 @@ func normalizeSDWAN(c protocol.SDWANConfig) protocol.SDWANConfig {
c.Nodes = append(c.Nodes, protocol.SDWANNode{Node: node, IP: ip})
}
sort.Slice(c.Nodes, func(i, j int) bool { return c.Nodes[i].Node < c.Nodes[j].Node })
// de-dup subnet proxies by node+cidr
if len(c.SubnetProxies) > 0 {
m2 := make(map[string]protocol.SubnetProxy)
for _, sp := range c.SubnetProxies {
if sp.Node == "" || sp.VirtualCIDR == "" || sp.LocalCIDR == "" {
continue
}
key := sp.Node + "|" + sp.VirtualCIDR + "|" + sp.LocalCIDR
m2[key] = sp
}
c.SubnetProxies = c.SubnetProxies[:0]
for _, sp := range m2 {
c.SubnetProxies = append(c.SubnetProxies, sp)
}
}
return c
}

View File

@@ -2,6 +2,7 @@ package server
import (
"errors"
"fmt"
"log"
"net/netip"
@@ -24,7 +25,7 @@ func (s *Server) SetSDWAN(cfg protocol.SDWANConfig) error {
return nil
}
func (s *Server) SetSDWANTenant(tenantID int64, cfg protocol.SDWANConfig) error {
func (s *Server) SetSDWANTenant(tenantID int64, cfg protocol.SDWANConfig, actorType, actorID, ip string) error {
if cfg.Mode == "hub" {
if cfg.HubNode == "" {
return errors.New("hub mode requires hubNode")
@@ -37,6 +38,10 @@ func (s *Server) SetSDWANTenant(tenantID int64, cfg protocol.SDWANConfig) error
if err := s.sdwan.saveTenant(tenantID, cfg); err != nil {
return err
}
if actorType != "" && s.store != nil {
detail := fmt.Sprintf("mode=%s hub=%s nodes=%d subnetProxies=%d", cfg.Mode, cfg.HubNode, len(cfg.Nodes), len(cfg.SubnetProxies))
_ = s.store.AddAuditLog(actorType, actorID, "sdwan_update", "tenant", fmt.Sprintf("%d", tenantID), detail, ip)
}
s.broadcastSDWANTenant(tenantID, s.sdwan.getTenant(tenantID))
return nil
}

View File

@@ -33,6 +33,7 @@ type NodeInfo struct {
ShareBandwidth int `json:"shareBandwidth"`
RelayEnabled bool `json:"relayEnabled"`
SuperRelay bool `json:"superRelay"`
RelayOfficial bool `json:"relayOfficial"`
HasIPv4 int `json:"hasIPv4"`
IPv6 string `json:"ipv6"`
LoginTime time.Time `json:"loginTime"`
@@ -78,12 +79,12 @@ func New(cfg config.ServerConfig) *Server {
if err != nil {
log.Printf("[server] open store failed: %v", err)
} else {
// bootstrap default admin/admin in tenant 1
// bootstrap default tenant if missing
if _, gErr := st.GetTenantByID(1); gErr != nil {
if _, _, _, cErr := st.CreateTenantWithUsers("default", "admin", "admin"); cErr != nil {
log.Printf("[server] bootstrap default tenant failed: %v", cErr)
} else {
log.Printf("[server] bootstrap default tenant created (tenant=1, admin/admin)")
log.Printf("[server] bootstrap default tenant created (tenant=1)")
}
}
}
@@ -160,7 +161,7 @@ func (s *Server) GetOnlineNodesByTenant(tenantID int64) []*NodeInfo {
}
// GetRelayNodes returns nodes that can serve as relay.
// Priority: same-user private relay → super relay
// Priority: same-user private relay → super relay (exclude official relays)
func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeInfo {
excludeSet := make(map[string]bool)
for _, n := range excludeNodes {
@@ -172,7 +173,7 @@ func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeIn
var privateRelays, superRelays []*NodeInfo
for _, n := range s.nodes {
if !n.IsOnline() || excludeSet[n.Name] || !n.RelayEnabled {
if !n.IsOnline() || excludeSet[n.Name] || !n.RelayEnabled || n.RelayOfficial {
continue
}
if n.User == forUser {
@@ -200,13 +201,33 @@ func (s *Server) GetRelayNodesByTenant(tenantID int64, excludeNodes ...string) [
if !n.IsOnline() || excludeSet[n.Name] {
continue
}
if n.TenantID == tenantID && (n.RelayEnabled || n.SuperRelay) {
if n.TenantID == tenantID && (n.RelayEnabled || n.SuperRelay) && !n.RelayOfficial {
relays = append(relays, n)
}
}
return relays
}
// GetOfficialRelays returns official relay nodes (global pool)
func (s *Server) GetOfficialRelays(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] || !n.RelayEnabled || !n.RelayOfficial {
continue
}
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)
@@ -287,6 +308,7 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
ShareBandwidth: loginReq.ShareBandwidth,
RelayEnabled: loginReq.RelayEnabled,
SuperRelay: loginReq.SuperRelay,
RelayOfficial: loginReq.RelayOfficial,
PublicIP: loginReq.PublicIP,
PublicPort: loginReq.PublicPort,
LoginTime: time.Now(),
@@ -464,23 +486,68 @@ func (s *Server) registerHandlers(conn *signal.Conn, node *NodeInfo) {
// 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 {
mode := "tenant"
if req.Mode == "official" {
mode = "official"
official := s.GetOfficialRelays(requester.Name, req.PeerNode)
if len(official) == 0 {
return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{Error: 1})
}
relay := official[0]
totp := auth.GenTOTP(relay.Token, time.Now().Unix())
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{
Error: 1,
RelayName: relay.Name,
RelayIP: relay.PublicIP,
RelayPort: config.DefaultRelayPort,
RelayToken: totp,
Mode: mode,
Error: 0,
})
}
// prefer hub relay if sdwan mode=hub
if requester.TenantID > 0 && s.sdwan != nil {
cfg := s.sdwan.getTenant(requester.TenantID)
if cfg.Mode == "hub" && cfg.HubNode != "" && cfg.HubNode != requester.Name && cfg.HubNode != req.PeerNode {
hub := s.GetNode(cfg.HubNode)
if hub != nil && hub.IsOnline() && hub.TenantID == requester.TenantID && hub.RelayEnabled {
log.Printf("[server] relay selected: %s (hub) for %s → %s", hub.Name, requester.Name, req.PeerNode)
totp := auth.GenTOTP(hub.Token, time.Now().Unix())
return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{
RelayName: hub.Name,
RelayIP: hub.PublicIP,
RelayPort: config.DefaultRelayPort,
RelayToken: totp,
Mode: "private",
Error: 0,
})
}
}
}
// prefer same-tenant relays, exclude requester and peer
relays := s.GetRelayNodesByTenant(requester.TenantID, requester.Name, req.PeerNode)
if len(relays) == 0 {
// fallback to same-user (private) then super
relays = s.GetRelayNodes(requester.User, requester.Name, req.PeerNode)
if len(relays) == 0 {
// final fallback: official relays
official := s.GetOfficialRelays(requester.Name, req.PeerNode)
if len(official) == 0 {
return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{Error: 1})
}
relays = official
mode = "official"
} else if relays[0].User != requester.User {
mode = "super"
} else {
mode = "private"
}
}
// 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{
@@ -510,6 +577,7 @@ func (s *Server) PushConnect(fromNode *NodeInfo, toNodeName string, app protocol
FromIP: fromNode.PublicIP,
Peer: protocol.PunchParams{
IP: fromNode.PublicIP,
Port: fromNode.PublicPort,
NATType: fromNode.NATType,
HasIPv4: fromNode.HasIPv4,
Token: auth.GenTOTP(fromNode.Token, time.Now().Unix()),
@@ -598,6 +666,9 @@ func (s *Server) StartCleanup() {
cfg.Mode = "mesh"
cfg.HubNode = ""
_ = s.sdwan.saveTenant(tid, cfg)
if s.store != nil {
_ = s.store.AddAuditLog("system", "0", "sdwan_update", "tenant", fmt.Sprintf("%d", tid), "hub->mesh (hub offline)", "")
}
s.broadcastSDWANTenant(tid, cfg)
log.Printf("[sdwan] hub offline, auto fallback to mesh (tenant=%d)", tid)
}

View File

@@ -62,6 +62,9 @@ func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request)
status = 1
}
_ = s.store.UpdateTenantStatus(id, status)
if ac := GetAccessContext(r); ac != nil {
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "tenant_status", "tenant", fmt.Sprintf("%d", id), fmt.Sprintf("status=%d", status), r.RemoteAddr)
}
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
return
}
@@ -165,6 +168,9 @@ func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request)
status = 1
}
_ = s.store.UpdateAPIKeyStatus(keyID, status)
if ac := GetAccessContext(r); ac != nil {
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "apikey_status", "apikey", fmt.Sprintf("%d", keyID), fmt.Sprintf("status=%d", status), r.RemoteAddr)
}
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
return
}
@@ -191,6 +197,9 @@ func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request)
}{0, "ok", key, tenantID}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
if ac := GetAccessContext(r); ac != nil {
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "apikey_create", "tenant", fmt.Sprintf("%d", tenantID), req.Scope, r.RemoteAddr)
}
}
func (s *Server) HandleTenantEnroll(w http.ResponseWriter, r *http.Request) {

176
internal/server/user_api.go Normal file
View File

@@ -0,0 +1,176 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"unicode"
)
// Admin user management
// GET /api/v1/admin/users?tenant=1
// POST /api/v1/admin/users {tenant, role, email, password}
// POST /api/v1/admin/users/{id}?status=0|1
// POST /api/v1/admin/users/{id}/password {password}
func IsValidGlobalUsername(v string) bool {
if len(v) < 6 {
return false
}
for _, r := range v {
if r > unicode.MaxASCII || !unicode.IsLetter(r) {
return false
}
}
return true
}
func (s *Server) HandleAdminUsers(w http.ResponseWriter, r *http.Request) {
if s.store == nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
return
}
// list
if r.Method == http.MethodGet {
tenantID := int64(0)
_ = r.ParseForm()
fmt.Sscanf(r.Form.Get("tenant"), "%d", &tenantID)
if tenantID <= 0 {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"tenant required"}`)
return
}
users, err := s.store.ListUsers(tenantID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"list users failed"}`)
return
}
// strip password hash
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, map[string]any{
"id": u.ID,
"tenant_id": u.TenantID,
"role": u.Role,
"email": u.Email,
"status": u.Status,
"created_at": u.CreatedAt,
})
}
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Users interface{} `json:"users"`
}{0, "ok", out}
b, _ := json.Marshal(resp)
writeJSON(w, http.StatusOK, string(b))
return
}
// update status or password
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/admin/users/") {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
var id int64
// /api/v1/admin/users/{id}/password
if strings.HasSuffix(r.URL.Path, "/password") && len(parts) >= 5 {
_, _ = fmt.Sscanf(parts[len(parts)-2], "%d", &id)
} else if strings.HasSuffix(r.URL.Path, "/delete") && len(parts) >= 5 {
_, _ = fmt.Sscanf(parts[len(parts)-2], "%d", &id)
} else {
_, _ = fmt.Sscanf(parts[len(parts)-1], "%d", &id)
}
if id <= 0 {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
// /password
if strings.HasSuffix(r.URL.Path, "/password") {
var req struct {
Password string `json:"password"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if req.Password == "" || len(req.Password) < 6 {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"password too short"}`)
return
}
if err := s.store.UpdateUserPassword(id, req.Password); err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"update password failed"}`)
return
}
if ac := GetAccessContext(r); ac != nil {
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "user_password", "user", fmt.Sprintf("%d", id), "", r.RemoteAddr)
}
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
return
}
// delete
if strings.HasSuffix(r.URL.Path, "/delete") {
if err := s.store.UpdateUserStatus(id, 0); err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"delete failed"}`)
return
}
if ac := GetAccessContext(r); ac != nil {
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "user_delete", "user", fmt.Sprintf("%d", id), "", r.RemoteAddr)
}
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
return
}
// status
st := r.URL.Query().Get("status")
if st == "" {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"status required"}`)
return
}
status := 0
if st == "1" {
status = 1
}
if err := s.store.UpdateUserStatus(id, status); err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"update status failed"}`)
return
}
if ac := GetAccessContext(r); ac != nil {
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "user_status", "user", fmt.Sprintf("%d", id), fmt.Sprintf("status=%d", status), r.RemoteAddr)
}
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
return
}
// create
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, `{"error":1,"message":"method not allowed"}`)
return
}
var req struct {
TenantID int64 `json:"tenant"`
Role string `json:"role"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TenantID <= 0 || req.Role == "" || req.Email == "" || req.Password == "" {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
return
}
if len(req.Password) < 6 {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"password too short"}`)
return
}
if !IsValidGlobalUsername(req.Email) {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"username must be letters only and >=6"}`)
return
}
if exists, err := s.store.UserEmailExistsGlobal(req.Email); err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"check user failed"}`)
return
} else if exists {
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"username exists"}`)
return
}
if _, err := s.store.CreateUser(req.TenantID, req.Role, req.Email, req.Password, 1); err != nil {
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"create user failed"}`)
return
}
if ac := GetAccessContext(r); ac != nil {
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "user_create", "tenant", fmt.Sprintf("%d", req.TenantID), req.Email, r.RemoteAddr)
}
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
}