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

@@ -25,8 +25,9 @@ func main() {
user := flag.String("user", "", "Username for token generation")
pass := flag.String("password", "", "Password for token generation")
flag.BoolVar(&cfg.Insecure, "insecure", false, "Skip TLS verification")
flag.BoolVar(&cfg.RelayEnabled, "relay", false, "Enable relay capability")
flag.BoolVar(&cfg.SuperRelay, "super", false, "Register as super relay node (implies -relay)")
flag.BoolVar(&cfg.RelayEnabled, "relay", cfg.RelayEnabled, "Enable relay capability")
flag.BoolVar(&cfg.SuperRelay, "super", cfg.SuperRelay, "Register as super relay node (implies -relay)")
flag.BoolVar(&cfg.RelayOfficial, "official-relay", cfg.RelayOfficial, "Register as official relay node")
flag.IntVar(&cfg.RelayPort, "relay-port", cfg.RelayPort, "Relay listen port")
flag.IntVar(&cfg.MaxRelayLoad, "relay-max", cfg.MaxRelayLoad, "Max concurrent relay sessions")
flag.IntVar(&cfg.ShareBandwidth, "bw", cfg.ShareBandwidth, "Share bandwidth (Mbps)")
@@ -49,9 +50,7 @@ func main() {
// Load config file first (unless -newconfig)
if !*newConfig {
if data, err := os.ReadFile(*configFile); err == nil {
var fileCfg config.ClientConfig
if err := json.Unmarshal(data, &fileCfg); err == nil {
cfg = fileCfg
if err := json.Unmarshal(data, &cfg); err == nil {
// fill defaults for missing fields
if cfg.ServerPort == 0 {
cfg.ServerPort = config.DefaultWSPort
@@ -101,6 +100,9 @@ func main() {
case "super":
cfg.SuperRelay = true
cfg.RelayEnabled = true // super implies relay
case "official-relay":
cfg.RelayOfficial = true
cfg.RelayEnabled = true
case "bw":
fmt.Sscanf(f.Value.String(), "%d", &cfg.ShareBandwidth)
}

View File

@@ -12,16 +12,58 @@ import (
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/openp2p-cn/inp2p/internal/server"
"github.com/openp2p-cn/inp2p/internal/store"
"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"
)
type rateLimiter struct {
mu sync.Mutex
m map[string]*rateEntry
max int
window time.Duration
}
type rateEntry struct {
count int
reset time.Time
}
func newRateLimiter(max int, window time.Duration) *rateLimiter {
return &rateLimiter{m: make(map[string]*rateEntry), max: max, window: window}
}
func (r *rateLimiter) Allow(key string) bool {
r.mu.Lock()
defer r.mu.Unlock()
e, ok := r.m[key]
now := time.Now()
if !ok || now.After(e.reset) {
r.m[key] = &rateEntry{count: 1, reset: now.Add(r.window)}
return true
}
if e.count >= r.max {
return false
}
e.count++
return true
}
func clientIP(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
return host
}
func main() {
cfg := config.DefaultServerConfig()
@@ -38,6 +80,8 @@ func main() {
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")
bootstrapAdmin := flag.String("bootstrap-admin", "", "Bootstrap system admin username (letters only, >=6)")
bootstrapPass := flag.String("bootstrap-password", "", "Bootstrap system admin password")
version := flag.Bool("version", false, "Print version and exit")
flag.Parse()
@@ -47,6 +91,46 @@ func main() {
os.Exit(0)
}
// Bootstrap system admin (optional)
if *bootstrapAdmin != "" {
if !server.IsValidGlobalUsername(*bootstrapAdmin) {
log.Fatalf("[main] invalid bootstrap-admin username (letters only, >=6)")
}
if *bootstrapPass == "" {
log.Fatalf("[main] bootstrap-password required")
}
st, err := store.Open(cfg.DBPath)
if err != nil {
log.Fatalf("[main] open store failed: %v", err)
}
_ = st.SetSetting("bootstrapped_admin", "1")
// ensure default tenant exists
if _, gErr := st.GetTenantByID(1); gErr != nil {
_, _, _, _ = st.CreateTenantWithUsers("default", "admin", "admin")
}
// update/create admin user in tenant 1
users, _ := st.ListUsers(1)
var adminID int64
for _, u := range users {
if u.Role == "admin" {
adminID = u.ID
break
}
}
if adminID > 0 {
_ = st.UpdateUserEmail(adminID, *bootstrapAdmin)
_ = st.UpdateUserPassword(adminID, *bootstrapPass)
log.Printf("[main] bootstrapped admin updated: %s", *bootstrapAdmin)
} else {
_, err = st.CreateUser(1, "admin", *bootstrapAdmin, *bootstrapPass, 1)
if err != nil {
log.Fatalf("[main] bootstrap admin create failed: %v", err)
}
log.Printf("[main] bootstrapped admin created: %s", *bootstrapAdmin)
}
os.Exit(0)
}
// Token: either direct value or generated from user+password
if *token > 0 {
cfg.Token = *token
@@ -91,7 +175,7 @@ func main() {
srv := server.New(cfg)
srv.StartCleanup()
// Admin-only Middleware (master token only)
// Admin-only Middleware (System Admin session only)
adminMiddleware := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/auth/login" {
@@ -99,17 +183,18 @@ func main() {
return
}
ac, ok := srv.ResolveAccess(r, cfg.Token)
if !ok || ac.Kind != "master" {
if !ok || ac.Kind != "session" || ac.Role != "admin" || ac.TenantID != 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
return
}
r = r.WithContext(context.WithValue(r.Context(), server.ServerCtxKeyAccess{}, ac))
next(w, r)
}
}
// Tenant or Admin Middleware (session/apikey/master)
// Tenant Middleware (session/apikey only, no operator role)
tenantMiddleware := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/auth/login" {
@@ -123,15 +208,14 @@ func main() {
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
return
}
if ac.Kind == "session" && ac.Role == "operator" {
path := r.URL.Path
if path != "/api/v1/nodes" && path != "/api/v1/sdwans" && path != "/api/v1/sdwan/edit" && path != "/api/v1/connect" && path != "/api/v1/nodes/apps" && path != "/api/v1/nodes/kick" && path != "/api/v1/nodes/alias" && path != "/api/v1/nodes/ip" && path != "/api/v1/stats" && path != "/api/v1/health" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`)
return
}
// reject master token for tenant APIs
if ac.Kind == "master" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
return
}
r = r.WithContext(context.WithValue(r.Context(), server.ServerCtxKeyAccess{}, ac))
next(w, r)
}
}
@@ -192,9 +276,31 @@ func main() {
mux.HandleFunc("/api/v1/admin/tenants/", adminMiddleware(srv.HandleAdminCreateAPIKey))
mux.HandleFunc("/api/v1/admin/users", adminMiddleware(srv.HandleAdminUsers))
mux.HandleFunc("/api/v1/admin/users/", adminMiddleware(srv.HandleAdminUsers))
mux.HandleFunc("/api/v1/admin/settings", adminMiddleware(srv.HandleAdminSettings))
mux.HandleFunc("/api/v1/admin/audit", adminMiddleware(srv.HandleAdminAudit))
mux.HandleFunc("/api/v1/tenants/enroll", srv.HandleTenantEnroll)
mux.HandleFunc("/api/v1/enroll/consume", srv.HandleEnrollConsume)
mux.HandleFunc("/api/v1/enroll/consume/", srv.HandleEnrollConsume)
// enroll consume with rate-limit
rl := newRateLimiter(10, time.Minute)
mux.HandleFunc("/api/v1/enroll/consume", func(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r.RemoteAddr)
if !rl.Allow(ip) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
fmt.Fprintf(w, `{"error":1,"message":"too many requests"}`)
return
}
srv.HandleEnrollConsume(w, r)
})
mux.HandleFunc("/api/v1/enroll/consume/", func(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r.RemoteAddr)
if !rl.Allow(ip) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
fmt.Fprintf(w, `{"error":1,"message":"too many requests"}`)
return
}
srv.HandleEnrollConsume(w, r)
})
mux.HandleFunc("/api/v1/nodes/alias", tenantMiddleware(srv.HandleNodeMeta))
mux.HandleFunc("/api/v1/nodes/ip", tenantMiddleware(srv.HandleNodeMeta))
@@ -203,97 +309,63 @@ func main() {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Support two modes:
// 1) token login: {"token":"xxxx"} (admin/master only, backward compatible)
// 2) user login: {"tenant":1,"username":"admin","password":"pass"}
var reqTok struct {
Token string `json:"token"`
}
// single mode: username/password login
var reqUser struct {
TenantID int64 `json:"tenant"`
Username string `json:"username"`
Password string `json:"password"`
}
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &reqTok)
_ = json.Unmarshal(body, &reqUser)
// --- user login (session token) ---
if reqUser.TenantID > 0 && reqUser.Username != "" && reqUser.Password != "" {
if srv.Store() == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":1,"message":"store not ready"}`)
return
}
u, err := srv.Store().VerifyUserPassword(reqUser.TenantID, reqUser.Username, reqUser.Password)
if err != nil || u == nil || u.Status != 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":1,"message":"invalid credentials"}`)
return
}
sessionToken, exp, err := srv.Store().CreateSessionToken(u.ID, reqUser.TenantID, u.Role, 24*time.Hour)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":1,"message":"create session failed"}`)
return
}
ten, _ := srv.Store().GetTenantByID(reqUser.TenantID)
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Token string `json:"token"`
TokenType string `json:"token_type"`
ExpiresAt int64 `json:"expires_at"`
Role string `json:"role"`
Status int `json:"status"`
Subnet string `json:"subnet"`
}{0, "ok", sessionToken, "session", exp, u.Role, u.Status, ""}
if ten != nil {
resp.Subnet = ten.Subnet
}
b, _ := json.Marshal(resp)
if reqUser.Username == "" || reqUser.Password == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(b)
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":1,"message":"username and password required"}`)
return
}
// --- token login (legacy/admin) ---
valid := false
role := "admin"
status := 1
if reqTok.Token != "" {
// support numeric token as string
if reqTok.Token == fmt.Sprintf("%d", cfg.Token) {
valid = true
} else {
for _, t := range cfg.Tokens {
if reqTok.Token == fmt.Sprintf("%d", t) {
valid = true
break
}
}
}
if !server.IsValidGlobalUsername(reqUser.Username) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":1,"message":"username must be letters only and >=6"}`)
return
}
if !valid {
if srv.Store() == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":1,"message":"store not ready"}`)
return
}
u, err := srv.Store().VerifyUserPasswordGlobal(reqUser.Username, reqUser.Password)
if err != nil || u == nil || u.Status != 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":1,"message":"invalid token"}`)
fmt.Fprintf(w, `{"error":1,"message":"invalid credentials"}`)
return
}
if srv.Store() != nil {
if u, err := srv.Store().GetUserByTenant(0); err == nil && u != nil {
if u.Role != "" {
role = u.Role
}
status = u.Status
}
sessionToken, exp, err := srv.Store().CreateSessionToken(u.ID, u.TenantID, u.Role, 24*time.Hour)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":1,"message":"create session failed"}`)
return
}
ten, _ := srv.Store().GetTenantByID(u.TenantID)
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Token string `json:"token"`
TokenType string `json:"token_type"`
ExpiresAt int64 `json:"expires_at"`
Role string `json:"role"`
Status int `json:"status"`
Subnet string `json:"subnet"`
}{0, "ok", sessionToken, "session", exp, u.Role, u.Status, ""}
if ten != nil {
resp.Subnet = ten.Subnet
}
b, _ := json.Marshal(resp)
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"error":0,"token":"%d","role":"%s","status":%d}`, cfg.Token, role, status)
w.WriteHeader(http.StatusOK)
w.Write(b)
})
mux.HandleFunc("/api/v1/health", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
@@ -347,10 +419,35 @@ func main() {
http.Error(w, "hub mode requires hubNode", http.StatusBadRequest)
return
}
// subnet proxy validation (basic)
for _, sp := range req.SubnetProxies {
if sp.Node == "" || sp.LocalCIDR == "" || sp.VirtualCIDR == "" {
http.Error(w, "subnet proxy requires node/localCIDR/virtualCIDR", http.StatusBadRequest)
return
}
_, lnet, lerr := net.ParseCIDR(sp.LocalCIDR)
_, vnet, verr := net.ParseCIDR(sp.VirtualCIDR)
if lerr != nil || verr != nil || lnet == nil || vnet == nil {
http.Error(w, "subnet proxy CIDR invalid", http.StatusBadRequest)
return
}
lOnes, _ := lnet.Mask.Size()
vOnes, _ := vnet.Mask.Size()
if lOnes != vOnes {
http.Error(w, "subnet proxy CIDR mask mismatch", http.StatusBadRequest)
return
}
}
// tenant filter by session/apikey
tenantID := getTenantID(r)
if tenantID > 0 {
if err := srv.SetSDWANTenant(tenantID, req); err != nil {
ac := server.GetAccessContext(r)
actorType, actorID := "", ""
if ac != nil {
actorType = ac.Kind
actorID = fmt.Sprintf("%d", ac.UserID)
}
if err := srv.SetSDWANTenant(tenantID, req, actorType, actorID, r.RemoteAddr); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}