feat: audit api, sdwan persist, relay fallback updates
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,6 +4,9 @@ bin/
|
|||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
|
inp2pc
|
||||||
|
inp2ps
|
||||||
|
web/vendor/
|
||||||
|
|
||||||
# Test binary
|
# Test binary
|
||||||
*.test
|
*.test
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ func main() {
|
|||||||
user := flag.String("user", "", "Username for token generation")
|
user := flag.String("user", "", "Username for token generation")
|
||||||
pass := flag.String("password", "", "Password for token generation")
|
pass := flag.String("password", "", "Password for token generation")
|
||||||
flag.BoolVar(&cfg.Insecure, "insecure", false, "Skip TLS verification")
|
flag.BoolVar(&cfg.Insecure, "insecure", false, "Skip TLS verification")
|
||||||
flag.BoolVar(&cfg.RelayEnabled, "relay", false, "Enable relay capability")
|
flag.BoolVar(&cfg.RelayEnabled, "relay", cfg.RelayEnabled, "Enable relay capability")
|
||||||
flag.BoolVar(&cfg.SuperRelay, "super", false, "Register as super relay node (implies -relay)")
|
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.RelayPort, "relay-port", cfg.RelayPort, "Relay listen port")
|
||||||
flag.IntVar(&cfg.MaxRelayLoad, "relay-max", cfg.MaxRelayLoad, "Max concurrent relay sessions")
|
flag.IntVar(&cfg.MaxRelayLoad, "relay-max", cfg.MaxRelayLoad, "Max concurrent relay sessions")
|
||||||
flag.IntVar(&cfg.ShareBandwidth, "bw", cfg.ShareBandwidth, "Share bandwidth (Mbps)")
|
flag.IntVar(&cfg.ShareBandwidth, "bw", cfg.ShareBandwidth, "Share bandwidth (Mbps)")
|
||||||
@@ -49,9 +50,7 @@ func main() {
|
|||||||
// Load config file first (unless -newconfig)
|
// Load config file first (unless -newconfig)
|
||||||
if !*newConfig {
|
if !*newConfig {
|
||||||
if data, err := os.ReadFile(*configFile); err == nil {
|
if data, err := os.ReadFile(*configFile); err == nil {
|
||||||
var fileCfg config.ClientConfig
|
if err := json.Unmarshal(data, &cfg); err == nil {
|
||||||
if err := json.Unmarshal(data, &fileCfg); err == nil {
|
|
||||||
cfg = fileCfg
|
|
||||||
// fill defaults for missing fields
|
// fill defaults for missing fields
|
||||||
if cfg.ServerPort == 0 {
|
if cfg.ServerPort == 0 {
|
||||||
cfg.ServerPort = config.DefaultWSPort
|
cfg.ServerPort = config.DefaultWSPort
|
||||||
@@ -101,6 +100,9 @@ func main() {
|
|||||||
case "super":
|
case "super":
|
||||||
cfg.SuperRelay = true
|
cfg.SuperRelay = true
|
||||||
cfg.RelayEnabled = true // super implies relay
|
cfg.RelayEnabled = true // super implies relay
|
||||||
|
case "official-relay":
|
||||||
|
cfg.RelayOfficial = true
|
||||||
|
cfg.RelayEnabled = true
|
||||||
case "bw":
|
case "bw":
|
||||||
fmt.Sscanf(f.Value.String(), "%d", &cfg.ShareBandwidth)
|
fmt.Sscanf(f.Value.String(), "%d", &cfg.ShareBandwidth)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,16 +12,58 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/openp2p-cn/inp2p/internal/server"
|
"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/auth"
|
||||||
"github.com/openp2p-cn/inp2p/pkg/config"
|
"github.com/openp2p-cn/inp2p/pkg/config"
|
||||||
"github.com/openp2p-cn/inp2p/pkg/nat"
|
"github.com/openp2p-cn/inp2p/pkg/nat"
|
||||||
"github.com/openp2p-cn/inp2p/pkg/protocol"
|
"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() {
|
func main() {
|
||||||
cfg := config.DefaultServerConfig()
|
cfg := config.DefaultServerConfig()
|
||||||
|
|
||||||
@@ -38,6 +80,8 @@ func main() {
|
|||||||
token := flag.Uint64("token", 0, "Master authentication token (uint64)")
|
token := flag.Uint64("token", 0, "Master authentication token (uint64)")
|
||||||
user := flag.String("user", "", "Username for token generation (requires -password)")
|
user := flag.String("user", "", "Username for token generation (requires -password)")
|
||||||
pass := flag.String("password", "", "Password for token generation")
|
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")
|
version := flag.Bool("version", false, "Print version and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -47,6 +91,46 @@ func main() {
|
|||||||
os.Exit(0)
|
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
|
// Token: either direct value or generated from user+password
|
||||||
if *token > 0 {
|
if *token > 0 {
|
||||||
cfg.Token = *token
|
cfg.Token = *token
|
||||||
@@ -91,7 +175,7 @@ func main() {
|
|||||||
srv := server.New(cfg)
|
srv := server.New(cfg)
|
||||||
srv.StartCleanup()
|
srv.StartCleanup()
|
||||||
|
|
||||||
// Admin-only Middleware (master token only)
|
// Admin-only Middleware (System Admin session only)
|
||||||
adminMiddleware := func(next http.HandlerFunc) http.HandlerFunc {
|
adminMiddleware := func(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path == "/api/v1/auth/login" {
|
if r.URL.Path == "/api/v1/auth/login" {
|
||||||
@@ -99,17 +183,18 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ac, ok := srv.ResolveAccess(r, cfg.Token)
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
|
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), server.ServerCtxKeyAccess{}, ac))
|
||||||
next(w, r)
|
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 {
|
tenantMiddleware := func(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path == "/api/v1/auth/login" {
|
if r.URL.Path == "/api/v1/auth/login" {
|
||||||
@@ -123,15 +208,14 @@ func main() {
|
|||||||
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
|
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ac.Kind == "session" && ac.Role == "operator" {
|
// reject master token for tenant APIs
|
||||||
path := r.URL.Path
|
if ac.Kind == "master" {
|
||||||
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.Header().Set("Content-Type", "application/json")
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
w.WriteHeader(http.StatusForbidden)
|
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
|
||||||
fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`)
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), server.ServerCtxKeyAccess{}, ac))
|
||||||
next(w, r)
|
next(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,9 +276,31 @@ func main() {
|
|||||||
mux.HandleFunc("/api/v1/admin/tenants/", adminMiddleware(srv.HandleAdminCreateAPIKey))
|
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/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/tenants/enroll", srv.HandleTenantEnroll)
|
||||||
mux.HandleFunc("/api/v1/enroll/consume", srv.HandleEnrollConsume)
|
// enroll consume with rate-limit
|
||||||
mux.HandleFunc("/api/v1/enroll/consume/", srv.HandleEnrollConsume)
|
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/alias", tenantMiddleware(srv.HandleNodeMeta))
|
||||||
mux.HandleFunc("/api/v1/nodes/ip", 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)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Support two modes:
|
// single mode: username/password login
|
||||||
// 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"`
|
|
||||||
}
|
|
||||||
var reqUser struct {
|
var reqUser struct {
|
||||||
TenantID int64 `json:"tenant"`
|
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
_ = json.Unmarshal(body, &reqTok)
|
|
||||||
_ = json.Unmarshal(body, &reqUser)
|
_ = json.Unmarshal(body, &reqUser)
|
||||||
|
if reqUser.Username == "" || reqUser.Password == "" {
|
||||||
// --- 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)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
w.Write(b)
|
fmt.Fprintf(w, `{"error":1,"message":"username and password required"}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !server.IsValidGlobalUsername(reqUser.Username) {
|
||||||
// --- token login (legacy/admin) ---
|
w.Header().Set("Content-Type", "application/json")
|
||||||
valid := false
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
role := "admin"
|
fmt.Fprintf(w, `{"error":1,"message":"username must be letters only and >=6"}`)
|
||||||
status := 1
|
return
|
||||||
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 !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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
fmt.Fprintf(w, `{"error":1,"message":"invalid token"}`)
|
fmt.Fprintf(w, `{"error":1,"message":"invalid credentials"}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if srv.Store() != nil {
|
sessionToken, exp, err := srv.Store().CreateSessionToken(u.ID, u.TenantID, u.Role, 24*time.Hour)
|
||||||
if u, err := srv.Store().GetUserByTenant(0); err == nil && u != nil {
|
if err != nil {
|
||||||
if u.Role != "" {
|
w.Header().Set("Content-Type", "application/json")
|
||||||
role = u.Role
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
}
|
fmt.Fprintf(w, `{"error":1,"message":"create session failed"}`)
|
||||||
status = u.Status
|
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")
|
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) {
|
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)
|
http.Error(w, "hub mode requires hubNode", http.StatusBadRequest)
|
||||||
return
|
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
|
// tenant filter by session/apikey
|
||||||
tenantID := getTenantID(r)
|
tenantID := getTenantID(r)
|
||||||
if tenantID > 0 {
|
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)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
5
go.mod
5
go.mod
@@ -6,7 +6,10 @@ toolchain go1.24.4
|
|||||||
|
|
||||||
require github.com/gorilla/websocket v1.5.3
|
require github.com/gorilla/websocket v1.5.3
|
||||||
|
|
||||||
require golang.org/x/sys v0.41.0
|
require (
|
||||||
|
golang.org/x/crypto v0.23.0
|
||||||
|
golang.org/x/sys v0.41.0
|
||||||
|
)
|
||||||
|
|
||||||
require modernc.org/sqlite v1.29.0
|
require modernc.org/sqlite v1.29.0
|
||||||
|
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -18,6 +18,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
|
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
|
||||||
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@@ -45,6 +46,7 @@ type Client struct {
|
|||||||
sdwanStop chan struct{}
|
sdwanStop chan struct{}
|
||||||
tunMu sync.Mutex
|
tunMu sync.Mutex
|
||||||
tunFile *os.File
|
tunFile *os.File
|
||||||
|
sdwanPath string
|
||||||
quit chan struct{}
|
quit chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
@@ -53,6 +55,7 @@ type Client struct {
|
|||||||
func New(cfg config.ClientConfig) *Client {
|
func New(cfg config.ClientConfig) *Client {
|
||||||
c := &Client{
|
c := &Client{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
sdwanPath: "/etc/inp2p/sdwan.json",
|
||||||
natType: protocol.NATUnknown,
|
natType: protocol.NATUnknown,
|
||||||
tunnels: make(map[string]*tunnel.Tunnel),
|
tunnels: make(map[string]*tunnel.Tunnel),
|
||||||
sdwanStop: make(chan struct{}),
|
sdwanStop: make(chan struct{}),
|
||||||
@@ -62,7 +65,7 @@ func New(cfg config.ClientConfig) *Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfg.RelayEnabled {
|
if cfg.RelayEnabled {
|
||||||
c.relayMgr = relay.NewManager(cfg.RelayPort, true, cfg.SuperRelay, cfg.MaxRelayLoad, cfg.Token)
|
c.relayMgr = relay.NewManager(cfg.RelayPort, true, cfg.SuperRelay, cfg.MaxRelayLoad, cfg.Token, cfg.ShareBandwidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c
|
return c
|
||||||
@@ -95,7 +98,7 @@ func (c *Client) connectAndRun() error {
|
|||||||
c.publicIP = natResult.PublicIP
|
c.publicIP = natResult.PublicIP
|
||||||
c.publicPort = natResult.Port1
|
c.publicPort = natResult.Port1
|
||||||
c.localPort = natResult.LocalPort
|
c.localPort = natResult.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)
|
log.Printf("[client] SENDING_LOGIN_TOKEN=%d NAT type=%s, publicIP=%s, publicPort=%d, localPort=%d", c.cfg.Token, c.natType, c.publicIP, c.publicPort, c.localPort)
|
||||||
|
|
||||||
// 2. WSS Connect
|
// 2. WSS Connect
|
||||||
scheme := "ws"
|
scheme := "ws"
|
||||||
@@ -130,12 +133,14 @@ func (c *Client) connectAndRun() error {
|
|||||||
loginReq := protocol.LoginReq{
|
loginReq := protocol.LoginReq{
|
||||||
Node: c.cfg.Node,
|
Node: c.cfg.Node,
|
||||||
Token: c.cfg.Token,
|
Token: c.cfg.Token,
|
||||||
|
NodeSecret: c.cfg.NodeSecret,
|
||||||
User: c.cfg.User,
|
User: c.cfg.User,
|
||||||
Version: config.Version,
|
Version: config.Version,
|
||||||
NATType: c.natType,
|
NATType: c.natType,
|
||||||
ShareBandwidth: c.cfg.ShareBandwidth,
|
ShareBandwidth: c.cfg.ShareBandwidth,
|
||||||
RelayEnabled: c.cfg.RelayEnabled,
|
RelayEnabled: c.cfg.RelayEnabled,
|
||||||
SuperRelay: c.cfg.SuperRelay,
|
SuperRelay: c.cfg.SuperRelay,
|
||||||
|
RelayOfficial: c.cfg.RelayOfficial,
|
||||||
PublicIP: c.publicIP,
|
PublicIP: c.publicIP,
|
||||||
PublicPort: c.publicPort,
|
PublicPort: c.publicPort,
|
||||||
}
|
}
|
||||||
@@ -236,7 +241,6 @@ func (c *Client) registerHandlers() {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
log.Printf("[client] sdwan config received: gateway=%s nodes=%d mode=%s", cfg.GatewayCIDR, len(cfg.Nodes), cfg.Mode)
|
log.Printf("[client] sdwan config received: gateway=%s nodes=%d mode=%s", cfg.GatewayCIDR, len(cfg.Nodes), cfg.Mode)
|
||||||
_ = os.WriteFile("sdwan.json", data[protocol.HeaderSize:], 0644)
|
|
||||||
|
|
||||||
// apply control+data plane
|
// apply control+data plane
|
||||||
if err := c.applySDWAN(cfg); err != nil {
|
if err := c.applySDWAN(cfg); err != nil {
|
||||||
@@ -396,7 +400,7 @@ func (c *Client) connectApp(app config.AppConfig) {
|
|||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[client] connect coordination failed for %s: %v", app.PeerNode, err)
|
log.Printf("[client] connect coordination failed for %s: %v", app.PeerNode, err)
|
||||||
c.tryRelay(app)
|
c.tryRelay(app, "tenant")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +408,7 @@ func (c *Client) connectApp(app config.AppConfig) {
|
|||||||
protocol.DecodePayload(rspData, &rsp)
|
protocol.DecodePayload(rspData, &rsp)
|
||||||
if rsp.Error != 0 {
|
if rsp.Error != 0 {
|
||||||
log.Printf("[client] connect denied: %s", rsp.Detail)
|
log.Printf("[client] connect denied: %s", rsp.Detail)
|
||||||
c.tryRelay(app)
|
c.tryRelay(app, "tenant")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,7 +424,7 @@ func (c *Client) connectApp(app config.AppConfig) {
|
|||||||
|
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
log.Printf("[client] punch failed for %s: %v", app.PeerNode, result.Error)
|
log.Printf("[client] punch failed for %s: %v", app.PeerNode, result.Error)
|
||||||
c.tryRelay(app)
|
c.tryRelay(app, "tenant")
|
||||||
c.reportConnect(app, protocol.ReportConnect{
|
c.reportConnect(app, protocol.ReportConnect{
|
||||||
PeerNode: app.PeerNode, Error: result.Error.Error(),
|
PeerNode: app.PeerNode, Error: result.Error.Error(),
|
||||||
NATType: c.natType, PeerNATType: rsp.Peer.NATType,
|
NATType: c.natType, PeerNATType: rsp.Peer.NATType,
|
||||||
@@ -448,12 +452,12 @@ func (c *Client) connectApp(app config.AppConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// tryRelay attempts to use a relay node.
|
// tryRelay attempts to use a relay node.
|
||||||
func (c *Client) tryRelay(app config.AppConfig) {
|
func (c *Client) tryRelay(app config.AppConfig, mode string) {
|
||||||
log.Printf("[client] trying relay for %s", app.PeerNode)
|
log.Printf("[client] trying relay(%s) for %s", mode, app.PeerNode)
|
||||||
|
|
||||||
rspData, err := c.conn.Request(
|
rspData, err := c.conn.Request(
|
||||||
protocol.MsgRelay, protocol.SubRelayNodeReq,
|
protocol.MsgRelay, protocol.SubRelayNodeReq,
|
||||||
protocol.RelayNodeReq{PeerNode: app.PeerNode},
|
protocol.RelayNodeReq{PeerNode: app.PeerNode, Mode: mode},
|
||||||
protocol.MsgRelay, protocol.SubRelayNodeRsp,
|
protocol.MsgRelay, protocol.SubRelayNodeRsp,
|
||||||
10*time.Second,
|
10*time.Second,
|
||||||
)
|
)
|
||||||
@@ -465,6 +469,11 @@ func (c *Client) tryRelay(app config.AppConfig) {
|
|||||||
var rsp protocol.RelayNodeRsp
|
var rsp protocol.RelayNodeRsp
|
||||||
protocol.DecodePayload(rspData, &rsp)
|
protocol.DecodePayload(rspData, &rsp)
|
||||||
if rsp.Error != 0 {
|
if rsp.Error != 0 {
|
||||||
|
if mode != "official" {
|
||||||
|
log.Printf("[client] no relay available for %s, fallback official", app.PeerNode)
|
||||||
|
go c.tryRelay(app, "official")
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Printf("[client] no relay available for %s", app.PeerNode)
|
log.Printf("[client] no relay available for %s", app.PeerNode)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -545,6 +554,19 @@ func (c *Client) reportConnect(app config.AppConfig, rc protocol.ReportConnect)
|
|||||||
c.conn.Write(protocol.MsgReport, protocol.SubReportConnect, rc)
|
c.conn.Write(protocol.MsgReport, protocol.SubReportConnect, rc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) writeSDWANConfig(cfg protocol.SDWANConfig) error {
|
||||||
|
path := c.sdwanPath
|
||||||
|
if path == "" {
|
||||||
|
path = "/etc/inp2p/sdwan.json"
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = os.MkdirAll("/etc/inp2p", 0755)
|
||||||
|
return os.WriteFile(path, b, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) applySDWAN(cfg protocol.SDWANConfig) error {
|
func (c *Client) applySDWAN(cfg protocol.SDWANConfig) error {
|
||||||
selfIP := ""
|
selfIP := ""
|
||||||
for _, n := range cfg.Nodes {
|
for _, n := range cfg.Nodes {
|
||||||
@@ -578,11 +600,24 @@ func (c *Client) applySDWAN(cfg protocol.SDWANConfig) error {
|
|||||||
// fallback broad route for hub mode / compatibility
|
// fallback broad route for hub mode / compatibility
|
||||||
_ = runCmd("ip", "route", "replace", pfx.String(), "dev", "optun")
|
_ = runCmd("ip", "route", "replace", pfx.String(), "dev", "optun")
|
||||||
|
|
||||||
|
// refresh rule/table 100 for sdwan
|
||||||
|
_ = runCmd("ip", "rule", "add", "pref", "100", "from", selfIP, "table", "100")
|
||||||
|
_ = runCmd("ip", "route", "replace", pfx.String(), "dev", "optun", "table", "100")
|
||||||
|
|
||||||
c.sdwanMu.Lock()
|
c.sdwanMu.Lock()
|
||||||
c.sdwan = cfg
|
c.sdwan = cfg
|
||||||
c.sdwanIP = selfIP
|
c.sdwanIP = selfIP
|
||||||
c.sdwanMu.Unlock()
|
c.sdwanMu.Unlock()
|
||||||
|
|
||||||
|
// persist sdwan config for local use/diagnostics
|
||||||
|
if err := c.writeSDWANConfig(cfg); err != nil {
|
||||||
|
log.Printf("[client] write sdwan.json failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply subnet proxy (if configured)
|
||||||
|
if err := c.applySubnetProxy(cfg); err != nil {
|
||||||
|
log.Printf("[client] applySubnetProxy failed: %v", err)
|
||||||
|
}
|
||||||
// Try to start TUN reader, but don't fail SDWAN apply if it errors
|
// Try to start TUN reader, but don't fail SDWAN apply if it errors
|
||||||
if err := c.ensureTUNReader(); err != nil {
|
if err := c.ensureTUNReader(); err != nil {
|
||||||
log.Printf("[client] ensureTUNReader failed (non-fatal): %v", err)
|
log.Printf("[client] ensureTUNReader failed (non-fatal): %v", err)
|
||||||
@@ -591,6 +626,39 @@ func (c *Client) applySDWAN(cfg protocol.SDWANConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applySubnetProxy configures local subnet proxying based on SDWAN config.
|
||||||
|
func (c *Client) applySubnetProxy(cfg protocol.SDWANConfig) error {
|
||||||
|
if len(cfg.SubnetProxies) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self := c.cfg.Node
|
||||||
|
for _, sp := range cfg.SubnetProxies {
|
||||||
|
if sp.Node != self {
|
||||||
|
// for non-proxy nodes, add route to virtualCIDR via proxy node IP
|
||||||
|
proxyIP := ""
|
||||||
|
for _, n := range cfg.Nodes {
|
||||||
|
if n.Node == sp.Node {
|
||||||
|
proxyIP = strings.TrimSpace(n.IP)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if proxyIP == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = runCmd("ip", "route", "replace", sp.VirtualCIDR, "via", proxyIP, "dev", "optun")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// This node is the proxy
|
||||||
|
_ = runCmd("sysctl", "-w", "net.ipv4.ip_forward=1")
|
||||||
|
// map virtualCIDR -> localCIDR (NETMAP)
|
||||||
|
if sp.VirtualCIDR != "" && sp.LocalCIDR != "" {
|
||||||
|
_ = runCmd("iptables", "-t", "nat", "-A", "PREROUTING", "-d", sp.VirtualCIDR, "-j", "NETMAP", "--to", sp.LocalCIDR)
|
||||||
|
_ = runCmd("iptables", "-t", "nat", "-A", "POSTROUTING", "-s", sp.LocalCIDR, "-j", "MASQUERADE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) ensureTUNReader() error {
|
func (c *Client) ensureTUNReader() error {
|
||||||
c.tunMu.Lock()
|
c.tunMu.Lock()
|
||||||
defer c.tunMu.Unlock()
|
defer c.tunMu.Unlock()
|
||||||
@@ -637,13 +705,13 @@ func (c *Client) tunReadLoop() {
|
|||||||
if f == nil {
|
if f == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
n, err := f.Read(buf)
|
n, err := unix.Read(int(f.Fd()), buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if c.IsStopping() {
|
if c.IsStopping() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Log only real errors, not EOF or timeout
|
// Ignore transient errors
|
||||||
if err.Error() != "EOF" && err.Error() != "resource temporarily unavailable" {
|
if err != unix.EINTR && err != unix.EAGAIN {
|
||||||
log.Printf("[client] tun read error: %v", err)
|
log.Printf("[client] tun read error: %v", err)
|
||||||
}
|
}
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|||||||
56
internal/server/admin_settings.go
Normal file
56
internal/server/admin_settings.go
Normal 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"}`)
|
||||||
|
}
|
||||||
40
internal/server/audit_api.go
Normal file
40
internal/server/audit_api.go
Normal 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})
|
||||||
|
}
|
||||||
@@ -26,6 +26,17 @@ func (s *Server) ResolveAccess(r *http.Request, masterToken uint64) (*AccessCont
|
|||||||
return s.ResolveTenantAccessToken(tok)
|
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) {
|
func (s *Server) ResolveTenantAccessToken(tok string) (*AccessContext, bool) {
|
||||||
if tok == "" || s.store == nil {
|
if tok == "" || s.store == nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/openp2p-cn/inp2p/pkg/auth"
|
"github.com/openp2p-cn/inp2p/pkg/auth"
|
||||||
@@ -68,6 +69,19 @@ func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error
|
|||||||
return nil
|
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
|
// Push PunchStart to BOTH sides simultaneously
|
||||||
punchID := fmt.Sprintf("%s-%s-%d", from.Name, to.Name, time.Now().UnixMilli())
|
punchID := fmt.Sprintf("%s-%s-%d", from.Name, to.Name, time.Now().UnixMilli())
|
||||||
|
|
||||||
|
|||||||
7
internal/server/ctx.go
Normal file
7
internal/server/ctx.go
Normal 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.
@@ -121,5 +121,21 @@ func normalizeSDWAN(c protocol.SDWANConfig) protocol.SDWANConfig {
|
|||||||
c.Nodes = append(c.Nodes, protocol.SDWANNode{Node: node, IP: ip})
|
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 })
|
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
|
return c
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ func (s *Server) SetSDWAN(cfg protocol.SDWANConfig) error {
|
|||||||
return nil
|
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.Mode == "hub" {
|
||||||
if cfg.HubNode == "" {
|
if cfg.HubNode == "" {
|
||||||
return errors.New("hub mode requires 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 {
|
if err := s.sdwan.saveTenant(tenantID, cfg); err != nil {
|
||||||
return err
|
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))
|
s.broadcastSDWANTenant(tenantID, s.sdwan.getTenant(tenantID))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type NodeInfo struct {
|
|||||||
ShareBandwidth int `json:"shareBandwidth"`
|
ShareBandwidth int `json:"shareBandwidth"`
|
||||||
RelayEnabled bool `json:"relayEnabled"`
|
RelayEnabled bool `json:"relayEnabled"`
|
||||||
SuperRelay bool `json:"superRelay"`
|
SuperRelay bool `json:"superRelay"`
|
||||||
|
RelayOfficial bool `json:"relayOfficial"`
|
||||||
HasIPv4 int `json:"hasIPv4"`
|
HasIPv4 int `json:"hasIPv4"`
|
||||||
IPv6 string `json:"ipv6"`
|
IPv6 string `json:"ipv6"`
|
||||||
LoginTime time.Time `json:"loginTime"`
|
LoginTime time.Time `json:"loginTime"`
|
||||||
@@ -78,12 +79,12 @@ func New(cfg config.ServerConfig) *Server {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[server] open store failed: %v", err)
|
log.Printf("[server] open store failed: %v", err)
|
||||||
} else {
|
} else {
|
||||||
// bootstrap default admin/admin in tenant 1
|
// bootstrap default tenant if missing
|
||||||
if _, gErr := st.GetTenantByID(1); gErr != nil {
|
if _, gErr := st.GetTenantByID(1); gErr != nil {
|
||||||
if _, _, _, cErr := st.CreateTenantWithUsers("default", "admin", "admin"); cErr != nil {
|
if _, _, _, cErr := st.CreateTenantWithUsers("default", "admin", "admin"); cErr != nil {
|
||||||
log.Printf("[server] bootstrap default tenant failed: %v", cErr)
|
log.Printf("[server] bootstrap default tenant failed: %v", cErr)
|
||||||
} else {
|
} 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.
|
// 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 {
|
func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeInfo {
|
||||||
excludeSet := make(map[string]bool)
|
excludeSet := make(map[string]bool)
|
||||||
for _, n := range excludeNodes {
|
for _, n := range excludeNodes {
|
||||||
@@ -172,7 +173,7 @@ func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeIn
|
|||||||
|
|
||||||
var privateRelays, superRelays []*NodeInfo
|
var privateRelays, superRelays []*NodeInfo
|
||||||
for _, n := range s.nodes {
|
for _, n := range s.nodes {
|
||||||
if !n.IsOnline() || excludeSet[n.Name] || !n.RelayEnabled {
|
if !n.IsOnline() || excludeSet[n.Name] || !n.RelayEnabled || n.RelayOfficial {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if n.User == forUser {
|
if n.User == forUser {
|
||||||
@@ -200,13 +201,33 @@ func (s *Server) GetRelayNodesByTenant(tenantID int64, excludeNodes ...string) [
|
|||||||
if !n.IsOnline() || excludeSet[n.Name] {
|
if !n.IsOnline() || excludeSet[n.Name] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if n.TenantID == tenantID && (n.RelayEnabled || n.SuperRelay) {
|
if n.TenantID == tenantID && (n.RelayEnabled || n.SuperRelay) && !n.RelayOfficial {
|
||||||
relays = append(relays, n)
|
relays = append(relays, n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return relays
|
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.
|
// HandleWS is the WebSocket handler for client connections.
|
||||||
func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) {
|
||||||
ws, err := s.upgrader.Upgrade(w, r, nil)
|
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,
|
ShareBandwidth: loginReq.ShareBandwidth,
|
||||||
RelayEnabled: loginReq.RelayEnabled,
|
RelayEnabled: loginReq.RelayEnabled,
|
||||||
SuperRelay: loginReq.SuperRelay,
|
SuperRelay: loginReq.SuperRelay,
|
||||||
|
RelayOfficial: loginReq.RelayOfficial,
|
||||||
PublicIP: loginReq.PublicIP,
|
PublicIP: loginReq.PublicIP,
|
||||||
PublicPort: loginReq.PublicPort,
|
PublicPort: loginReq.PublicPort,
|
||||||
LoginTime: time.Now(),
|
LoginTime: time.Now(),
|
||||||
@@ -464,23 +486,68 @@ func (s *Server) registerHandlers(conn *signal.Conn, node *NodeInfo) {
|
|||||||
|
|
||||||
// handleRelayNodeReq finds and returns the best relay node.
|
// handleRelayNodeReq finds and returns the best relay node.
|
||||||
func (s *Server) handleRelayNodeReq(conn *signal.Conn, requester *NodeInfo, req protocol.RelayNodeReq) error {
|
func (s *Server) handleRelayNodeReq(conn *signal.Conn, requester *NodeInfo, req protocol.RelayNodeReq) error {
|
||||||
relays := s.GetRelayNodes(requester.User, requester.Name, req.PeerNode)
|
mode := "tenant"
|
||||||
|
if req.Mode == "official" {
|
||||||
if len(relays) == 0 {
|
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{
|
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
|
// Pick the first (best) relay
|
||||||
relay := relays[0]
|
relay := relays[0]
|
||||||
totp := auth.GenTOTP(relay.Token, time.Now().Unix())
|
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)
|
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{
|
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,
|
FromIP: fromNode.PublicIP,
|
||||||
Peer: protocol.PunchParams{
|
Peer: protocol.PunchParams{
|
||||||
IP: fromNode.PublicIP,
|
IP: fromNode.PublicIP,
|
||||||
|
Port: fromNode.PublicPort,
|
||||||
NATType: fromNode.NATType,
|
NATType: fromNode.NATType,
|
||||||
HasIPv4: fromNode.HasIPv4,
|
HasIPv4: fromNode.HasIPv4,
|
||||||
Token: auth.GenTOTP(fromNode.Token, time.Now().Unix()),
|
Token: auth.GenTOTP(fromNode.Token, time.Now().Unix()),
|
||||||
@@ -598,6 +666,9 @@ func (s *Server) StartCleanup() {
|
|||||||
cfg.Mode = "mesh"
|
cfg.Mode = "mesh"
|
||||||
cfg.HubNode = ""
|
cfg.HubNode = ""
|
||||||
_ = s.sdwan.saveTenant(tid, cfg)
|
_ = 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)
|
s.broadcastSDWANTenant(tid, cfg)
|
||||||
log.Printf("[sdwan] hub offline, auto fallback to mesh (tenant=%d)", tid)
|
log.Printf("[sdwan] hub offline, auto fallback to mesh (tenant=%d)", tid)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request)
|
|||||||
status = 1
|
status = 1
|
||||||
}
|
}
|
||||||
_ = s.store.UpdateTenantStatus(id, status)
|
_ = 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"}`)
|
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -165,6 +168,9 @@ func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request)
|
|||||||
status = 1
|
status = 1
|
||||||
}
|
}
|
||||||
_ = s.store.UpdateAPIKeyStatus(keyID, status)
|
_ = 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"}`)
|
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -191,6 +197,9 @@ func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request)
|
|||||||
}{0, "ok", key, tenantID}
|
}{0, "ok", key, tenantID}
|
||||||
b, _ := json.Marshal(resp)
|
b, _ := json.Marshal(resp)
|
||||||
writeJSON(w, http.StatusOK, string(b))
|
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) {
|
func (s *Server) HandleTenantEnroll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
176
internal/server/user_api.go
Normal file
176
internal/server/user_api.go
Normal 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"}`)
|
||||||
|
}
|
||||||
@@ -27,6 +27,18 @@ type Tenant struct {
|
|||||||
CreatedAt int64
|
CreatedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuditLog struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ActorType string `json:"actor_type"`
|
||||||
|
ActorID string `json:"actor_id"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
TargetType string `json:"target_type"`
|
||||||
|
TargetID string `json:"target_id"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64
|
ID int64
|
||||||
TenantID int64
|
TenantID int64
|
||||||
@@ -102,6 +114,12 @@ func Open(dbPath string) (*Store, error) {
|
|||||||
if err := s.ensureSubnetPool(); err != nil {
|
if err := s.ensureSubnetPool(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := s.ensureSettings(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.backfillNodeIdentity(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +200,11 @@ func (s *Store) migrate() error {
|
|||||||
ip TEXT,
|
ip TEXT,
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);`,
|
);`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS system_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);`,
|
||||||
`CREATE TABLE IF NOT EXISTS subnet_pool (
|
`CREATE TABLE IF NOT EXISTS subnet_pool (
|
||||||
subnet TEXT PRIMARY KEY,
|
subnet TEXT PRIMARY KEY,
|
||||||
status INTEGER NOT NULL DEFAULT 0,
|
status INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -282,11 +305,11 @@ func (s *Store) CreateTenantWithUsers(name, adminPassword, operatorPassword stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
admin, err := s.CreateUser(ten.ID, "admin", "admin@local", adminPassword, 1)
|
admin, err := s.CreateUser(ten.ID, "admin", "admin@"+name, adminPassword, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
op, err := s.CreateUser(ten.ID, "operator", "operator@local", operatorPassword, 1)
|
op, err := s.CreateUser(ten.ID, "operator", "operator@"+name, operatorPassword, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -494,6 +517,88 @@ func (s *Store) IncEnrollAttempt(code string) {
|
|||||||
_, _ = s.DB.Exec(`UPDATE enroll_tokens SET attempts=attempts+1 WHERE token_hash=?`, h)
|
_, _ = s.DB.Exec(`UPDATE enroll_tokens SET attempts=attempts+1 WHERE token_hash=?`, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ensureSettings() error {
|
||||||
|
defaults := map[string]string{
|
||||||
|
"advanced_impersonate": "0",
|
||||||
|
"advanced_force_network": "0",
|
||||||
|
"advanced_cross_tenant": "0",
|
||||||
|
}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for k, v := range defaults {
|
||||||
|
_, _ = s.DB.Exec(`INSERT OR IGNORE INTO system_settings(key,value,updated_at) VALUES(?,?,?)`, k, v, now)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetSetting(key string) (string, bool, error) {
|
||||||
|
row := s.DB.QueryRow(`SELECT value FROM system_settings WHERE key=?`, key)
|
||||||
|
var v string
|
||||||
|
if err := row.Scan(&v); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return v, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListSettings() (map[string]string, error) {
|
||||||
|
rows, err := s.DB.Query(`SELECT key,value FROM system_settings`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var k, v string
|
||||||
|
if err := rows.Scan(&k, &v); err == nil {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetSetting(key, value string) error {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
_, err := s.DB.Exec(`INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, key, value, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AddAuditLog(actorType, actorID, action, targetType, targetID, detail, ip string) error {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
_, err := s.DB.Exec(`INSERT INTO audit_logs(actor_type,actor_id,action,target_type,target_id,detail,ip,created_at) VALUES(?,?,?,?,?,?,?,?)`, actorType, actorID, action, targetType, targetID, detail, ip, now)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListAuditLogs(tenantID int64, limit, offset int) ([]AuditLog, error) {
|
||||||
|
q := `SELECT id,actor_type,actor_id,action,target_type,target_id,detail,ip,created_at FROM audit_logs`
|
||||||
|
args := []any{}
|
||||||
|
if tenantID > 0 {
|
||||||
|
// limit to logs related to this tenant
|
||||||
|
q += ` WHERE (target_type='tenant' AND target_id=?)`
|
||||||
|
args = append(args, fmt.Sprintf("%d", tenantID))
|
||||||
|
}
|
||||||
|
q += ` ORDER BY id DESC`
|
||||||
|
if limit > 0 {
|
||||||
|
q += ` LIMIT ?`
|
||||||
|
args = append(args, limit)
|
||||||
|
}
|
||||||
|
if offset > 0 {
|
||||||
|
q += ` OFFSET ?`
|
||||||
|
args = append(args, offset)
|
||||||
|
}
|
||||||
|
rows, err := s.DB.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []AuditLog{}
|
||||||
|
for rows.Next() {
|
||||||
|
var a AuditLog
|
||||||
|
if err := rows.Scan(&a.ID, &a.ActorType, &a.ActorID, &a.Action, &a.TargetType, &a.TargetID, &a.Detail, &a.IP, &a.CreatedAt); err == nil {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListTenants returns all tenants (admin)
|
// ListTenants returns all tenants (admin)
|
||||||
func (s *Store) ListTenants() ([]Tenant, error) {
|
func (s *Store) ListTenants() ([]Tenant, error) {
|
||||||
rows, err := s.DB.Query(`SELECT id,name,status,subnet,created_at FROM tenants ORDER BY id DESC`)
|
rows, err := s.DB.Query(`SELECT id,name,status,subnet,created_at FROM tenants ORDER BY id DESC`)
|
||||||
@@ -662,6 +767,15 @@ func (s *Store) UserEmailExists(tenantID int64, email string) (bool, error) {
|
|||||||
return c > 0, nil
|
return c > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) UserEmailExistsGlobal(email string) (bool, error) {
|
||||||
|
row := s.DB.QueryRow(`SELECT COUNT(1) FROM users WHERE email=?`, email)
|
||||||
|
var c int
|
||||||
|
if err := row.Scan(&c); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return c > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) VerifyUserPassword(tenantID int64, email, password string) (*User, error) {
|
func (s *Store) VerifyUserPassword(tenantID int64, email, password string) (*User, error) {
|
||||||
u, err := s.GetUserByEmail(tenantID, email)
|
u, err := s.GetUserByEmail(tenantID, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -682,6 +796,21 @@ func (s *Store) VerifyUserPassword(tenantID int64, email, password string) (*Use
|
|||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) VerifyUserPasswordGlobal(email, password string) (*User, error) {
|
||||||
|
row := s.DB.QueryRow(`SELECT id,tenant_id,role,email,password_hash,status,created_at FROM users WHERE email=? ORDER BY id LIMIT 1`, email)
|
||||||
|
var u User
|
||||||
|
if err := row.Scan(&u.ID, &u.TenantID, &u.Role, &u.Email, &u.PasswordHash, &u.Status, &u.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if u.PasswordHash == "" {
|
||||||
|
return nil, errors.New("password not set")
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
||||||
|
return nil, errors.New("invalid password")
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) CreateSessionToken(userID, tenantID int64, role string, ttl time.Duration) (string, int64, error) {
|
func (s *Store) CreateSessionToken(userID, tenantID int64, role string, ttl time.Duration) (string, int64, error) {
|
||||||
tok := randToken()
|
tok := randToken()
|
||||||
h := hashTokenString(tok)
|
h := hashTokenString(tok)
|
||||||
@@ -742,6 +871,11 @@ func (s *Store) UpdateUserPassword(id int64, password string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateUserEmail(id int64, email string) error {
|
||||||
|
_, err := s.DB.Exec(`UPDATE users SET email=? WHERE id=?`, email, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func hashTokenBytes(b []byte) string {
|
func hashTokenBytes(b []byte) string {
|
||||||
h := sha256.Sum256(b)
|
h := sha256.Sum256(b)
|
||||||
return hex.EncodeToString(h[:])
|
return hex.EncodeToString(h[:])
|
||||||
@@ -767,5 +901,40 @@ func randUUID() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) backfillNodeIdentity() error {
|
||||||
|
rows, err := s.DB.Query(`SELECT id,tenant_id,node_uuid,virtual_ip FROM nodes ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
type rowNode struct {
|
||||||
|
id int64
|
||||||
|
tenantID int64
|
||||||
|
uuid string
|
||||||
|
vip string
|
||||||
|
}
|
||||||
|
var list []rowNode
|
||||||
|
for rows.Next() {
|
||||||
|
var r rowNode
|
||||||
|
if err := rows.Scan(&r.id, &r.tenantID, &r.uuid, &r.vip); err == nil {
|
||||||
|
list = append(list, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range list {
|
||||||
|
if n.uuid == "" {
|
||||||
|
if _, err := s.DB.Exec(`UPDATE nodes SET node_uuid=? WHERE id=?`, randUUID(), n.id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(n.vip) == "" {
|
||||||
|
vip, err := s.AllocateNodeIP(n.tenantID)
|
||||||
|
if err == nil && vip != "" {
|
||||||
|
_, _ = s.DB.Exec(`UPDATE nodes SET virtual_ip=? WHERE id=?`, vip, n.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// helper to avoid unused import (net)
|
// helper to avoid unused import (net)
|
||||||
var _ = net.IPv4len
|
var _ = net.IPv4len
|
||||||
|
|||||||
@@ -36,17 +36,17 @@ const (
|
|||||||
|
|
||||||
// ServerConfig holds inp2ps configuration.
|
// ServerConfig holds inp2ps configuration.
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
WSPort int `json:"wsPort"`
|
WSPort int `json:"wsPort"`
|
||||||
STUNUDP1 int `json:"stunUDP1"`
|
STUNUDP1 int `json:"stunUDP1"`
|
||||||
STUNUDP2 int `json:"stunUDP2"`
|
STUNUDP2 int `json:"stunUDP2"`
|
||||||
STUNTCP1 int `json:"stunTCP1"`
|
STUNTCP1 int `json:"stunTCP1"`
|
||||||
STUNTCP2 int `json:"stunTCP2"`
|
STUNTCP2 int `json:"stunTCP2"`
|
||||||
WebPort int `json:"webPort"`
|
WebPort int `json:"webPort"`
|
||||||
APIPort int `json:"apiPort"`
|
APIPort int `json:"apiPort"`
|
||||||
DBPath string `json:"dbPath"`
|
DBPath string `json:"dbPath"`
|
||||||
CertFile string `json:"certFile"`
|
CertFile string `json:"certFile"`
|
||||||
KeyFile string `json:"keyFile"`
|
KeyFile string `json:"keyFile"`
|
||||||
LogLevel int `json:"logLevel"` // 0=debug, 1=info, 2=warn, 3=error
|
LogLevel int `json:"logLevel"` // 0=debug, 1=info, 2=warn, 3=error
|
||||||
Token uint64 `json:"token"` // master token for auth
|
Token uint64 `json:"token"` // master token for auth
|
||||||
Tokens []uint64 `json:"tokens"` // additional tenant tokens
|
Tokens []uint64 `json:"tokens"` // additional tenant tokens
|
||||||
JWTKey string `json:"jwtKey"` // auto-generated if empty
|
JWTKey string `json:"jwtKey"` // auto-generated if empty
|
||||||
@@ -132,10 +132,11 @@ type ClientConfig struct {
|
|||||||
STUNTCP1 int `json:"stunTCP1,omitempty"`
|
STUNTCP1 int `json:"stunTCP1,omitempty"`
|
||||||
STUNTCP2 int `json:"stunTCP2,omitempty"`
|
STUNTCP2 int `json:"stunTCP2,omitempty"`
|
||||||
|
|
||||||
RelayEnabled bool `json:"relayEnabled"` // --relay
|
RelayEnabled bool `json:"relayEnabled"` // --relay
|
||||||
SuperRelay bool `json:"superRelay"` // --super
|
SuperRelay bool `json:"superRelay"` // --super
|
||||||
RelayPort int `json:"relayPort"`
|
RelayOfficial bool `json:"relayOfficial"` // official relay tag
|
||||||
MaxRelayLoad int `json:"maxRelayLoad"`
|
RelayPort int `json:"relayPort"`
|
||||||
|
MaxRelayLoad int `json:"maxRelayLoad"`
|
||||||
|
|
||||||
ShareBandwidth int `json:"shareBandwidth"` // Mbps
|
ShareBandwidth int `json:"shareBandwidth"` // Mbps
|
||||||
LogLevel int `json:"logLevel"`
|
LogLevel int `json:"logLevel"`
|
||||||
@@ -163,6 +164,8 @@ func DefaultClientConfig() ClientConfig {
|
|||||||
ShareBandwidth: 10,
|
ShareBandwidth: 10,
|
||||||
RelayPort: DefaultRelayPort,
|
RelayPort: DefaultRelayPort,
|
||||||
MaxRelayLoad: DefaultMaxRelayLoad,
|
MaxRelayLoad: DefaultMaxRelayLoad,
|
||||||
|
RelayEnabled: true,
|
||||||
|
RelayOfficial: false,
|
||||||
LogLevel: 1,
|
LogLevel: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,8 +197,9 @@ type LoginReq struct {
|
|||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
NATType NATType `json:"natType"`
|
NATType NATType `json:"natType"`
|
||||||
ShareBandwidth int `json:"shareBandwidth"`
|
ShareBandwidth int `json:"shareBandwidth"`
|
||||||
RelayEnabled bool `json:"relayEnabled"` // --relay flag
|
RelayEnabled bool `json:"relayEnabled"` // --relay flag
|
||||||
SuperRelay bool `json:"superRelay"` // --super flag
|
SuperRelay bool `json:"superRelay"` // --super flag
|
||||||
|
RelayOfficial bool `json:"relayOfficial"` // official relay tag
|
||||||
PublicIP string `json:"publicIP,omitempty"`
|
PublicIP string `json:"publicIP,omitempty"`
|
||||||
PublicPort int `json:"publicPort,omitempty"`
|
PublicPort int `json:"publicPort,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -264,6 +265,7 @@ type ConnectRsp struct {
|
|||||||
// RelayNodeReq asks the server for a relay node.
|
// RelayNodeReq asks the server for a relay node.
|
||||||
type RelayNodeReq struct {
|
type RelayNodeReq struct {
|
||||||
PeerNode string `json:"peerNode"`
|
PeerNode string `json:"peerNode"`
|
||||||
|
Mode string `json:"mode,omitempty"` // "tenant" | "official"
|
||||||
}
|
}
|
||||||
|
|
||||||
type RelayNodeRsp struct {
|
type RelayNodeRsp struct {
|
||||||
@@ -292,17 +294,24 @@ type SDWANNode struct {
|
|||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SubnetProxy struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
LocalCIDR string `json:"localCIDR"`
|
||||||
|
VirtualCIDR string `json:"virtualCIDR"`
|
||||||
|
}
|
||||||
|
|
||||||
type SDWANConfig struct {
|
type SDWANConfig struct {
|
||||||
Enabled bool `json:"enabled,omitempty"`
|
Enabled bool `json:"enabled,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
GatewayCIDR string `json:"gatewayCIDR"`
|
GatewayCIDR string `json:"gatewayCIDR"`
|
||||||
Mode string `json:"mode,omitempty"` // hub | mesh | fullmesh
|
Mode string `json:"mode,omitempty"` // hub | mesh | fullmesh
|
||||||
HubNode string `json:"hubNode,omitempty"`
|
HubNode string `json:"hubNode,omitempty"`
|
||||||
IP string `json:"ip,omitempty"` // node self IP if pushed per-node
|
IP string `json:"ip,omitempty"` // node self IP if pushed per-node
|
||||||
MTU int `json:"mtu,omitempty"`
|
MTU int `json:"mtu,omitempty"`
|
||||||
Routes []string `json:"routes,omitempty"`
|
Routes []string `json:"routes,omitempty"`
|
||||||
Nodes []SDWANNode `json:"nodes"`
|
Nodes []SDWANNode `json:"nodes"`
|
||||||
UpdatedAt int64 `json:"updatedAt,omitempty"`
|
SubnetProxies []SubnetProxy `json:"subnetProxies,omitempty"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SDWANPeer struct {
|
type SDWANPeer struct {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// Package relay implements relay/super-relay node capabilities.
|
// Package relay implements relay/super-relay node capabilities.
|
||||||
//
|
//
|
||||||
// Relay flow:
|
// Relay flow:
|
||||||
// 1. Client A asks server for relay (RelayNodeReq)
|
// 1. Client A asks server for relay (RelayNodeReq)
|
||||||
// 2. Server finds relay R, generates TOTP/token, responds to A (RelayNodeRsp)
|
// 2. Server finds relay R, generates TOTP/token, responds to A (RelayNodeRsp)
|
||||||
// 3. Server pushes RelayOffer to R with session info
|
// 3. Server pushes RelayOffer to R with session info
|
||||||
// 4. A connects to R:relayPort, sends RelayHandshake{SessionID, Role="from", Token}
|
// 4. A connects to R:relayPort, sends RelayHandshake{SessionID, Role="from", Token}
|
||||||
// 5. B connects to R:relayPort, sends RelayHandshake{SessionID, Role="to", Token}
|
// 5. B connects to R:relayPort, sends RelayHandshake{SessionID, Role="to", Token}
|
||||||
// (B gets the session info via server push)
|
// (B gets the session info via server push)
|
||||||
// 6. R verifies both tokens, bridges A↔B
|
// 6. R verifies both tokens, bridges A↔B
|
||||||
package relay
|
package relay
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -68,6 +68,7 @@ type Manager struct {
|
|||||||
enabled bool
|
enabled bool
|
||||||
superRelay bool
|
superRelay bool
|
||||||
maxLoad int
|
maxLoad int
|
||||||
|
maxMbps int
|
||||||
token uint64 // this node's auth token
|
token uint64 // this node's auth token
|
||||||
port int
|
port int
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
@@ -92,11 +93,12 @@ type Session struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a relay manager.
|
// NewManager creates a relay manager.
|
||||||
func NewManager(port int, enabled, superRelay bool, maxLoad int, token uint64) *Manager {
|
func NewManager(port int, enabled, superRelay bool, maxLoad int, token uint64, maxMbps int) *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
superRelay: superRelay,
|
superRelay: superRelay,
|
||||||
maxLoad: maxLoad,
|
maxLoad: maxLoad,
|
||||||
|
maxMbps: maxMbps,
|
||||||
token: token,
|
token: token,
|
||||||
port: port,
|
port: port,
|
||||||
pending: make(map[string]*pendingSession),
|
pending: make(map[string]*pendingSession),
|
||||||
@@ -296,14 +298,47 @@ func (m *Manager) bridge(ps *pendingSession) {
|
|||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
|
|
||||||
|
copyWithLimit := func(dst, src net.Conn) int64 {
|
||||||
|
if m.maxMbps <= 0 {
|
||||||
|
n, _ := io.Copy(dst, src)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
bytesPerSec := int64(m.maxMbps) * 1024 * 1024 / 8
|
||||||
|
if bytesPerSec < 1 {
|
||||||
|
bytesPerSec = 1
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
buf := make([]byte, 32*1024)
|
||||||
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
var allowance = bytesPerSec / 10
|
||||||
|
for {
|
||||||
|
n, err := src.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
// simple token bucket
|
||||||
|
if allowance < int64(n) {
|
||||||
|
<-ticker.C
|
||||||
|
allowance = bytesPerSec / 10
|
||||||
|
}
|
||||||
|
allowance -= int64(n)
|
||||||
|
w, _ := dst.Write(buf[:n])
|
||||||
|
total += int64(w)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
n, _ := io.Copy(sess.ConnB, sess.ConnA)
|
n := copyWithLimit(sess.ConnB, sess.ConnA)
|
||||||
atomic.AddInt64(&sess.BytesFwd, n)
|
atomic.AddInt64(&sess.BytesFwd, n)
|
||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
n, _ := io.Copy(sess.ConnA, sess.ConnB)
|
n := copyWithLimit(sess.ConnA, sess.ConnB)
|
||||||
atomic.AddInt64(&sess.BytesFwd, n)
|
atomic.AddInt64(&sess.BytesFwd, n)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
func TestRelayBridge(t *testing.T) {
|
func TestRelayBridge(t *testing.T) {
|
||||||
token := auth.MakeToken("test", "pass")
|
token := auth.MakeToken("test", "pass")
|
||||||
mgr := NewManager(29700, true, false, 10, token)
|
mgr := NewManager(29700, true, false, 10, token, 10)
|
||||||
if err := mgr.Start(); err != nil {
|
if err := mgr.Start(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ func TestRelayBridge(t *testing.T) {
|
|||||||
|
|
||||||
func TestRelayLargeData(t *testing.T) {
|
func TestRelayLargeData(t *testing.T) {
|
||||||
token := auth.MakeToken("test", "pass")
|
token := auth.MakeToken("test", "pass")
|
||||||
mgr := NewManager(29701, true, false, 10, token)
|
mgr := NewManager(29701, true, false, 10, token, 10)
|
||||||
if err := mgr.Start(); err != nil {
|
if err := mgr.Start(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,7 @@ func TestRelayLargeData(t *testing.T) {
|
|||||||
|
|
||||||
func TestRelayAuthDenied(t *testing.T) {
|
func TestRelayAuthDenied(t *testing.T) {
|
||||||
token := auth.MakeToken("real", "token")
|
token := auth.MakeToken("real", "token")
|
||||||
mgr := NewManager(29702, true, false, 10, token)
|
mgr := NewManager(29702, true, false, 10, token, 10)
|
||||||
if err := mgr.Start(); err != nil {
|
if err := mgr.Start(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,11 +31,9 @@
|
|||||||
<h1 class="text-2xl font-black text-white mb-2">INP2P 控制台</h1>
|
<h1 class="text-2xl font-black text-white mb-2">INP2P 控制台</h1>
|
||||||
<p class="text-slate-500 text-sm mb-6">登录后可管理节点、SDWAN、连接与租户</p>
|
<p class="text-slate-500 text-sm mb-6">登录后可管理节点、SDWAN、连接与租户</p>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<input v-model="loginTenant" class="ipt" placeholder="Tenant ID(用户登录)" @keyup.enter="login">
|
<input v-model="loginUser" class="ipt" placeholder="用户名(全局唯一,字母≥6位)" @keyup.enter="login">
|
||||||
<input v-model="loginUser" class="ipt" placeholder="用户名(如 admin)" @keyup.enter="login">
|
|
||||||
<input v-model="loginPass" class="ipt" type="password" placeholder="密码" @keyup.enter="login">
|
<input v-model="loginPass" class="ipt" type="password" placeholder="密码" @keyup.enter="login">
|
||||||
<div class="text-xs text-slate-500 text-center">或使用主 Token 登录(管理员)</div>
|
<div class="text-xs text-slate-500 text-center">用户名要求:仅字母、长度≥6、全局唯一</div>
|
||||||
<input v-model="loginToken" class="ipt" type="password" placeholder="Master Token" @keyup.enter="login">
|
|
||||||
<button class="btn w-full" :disabled="busy" @click="login">{{ busy ? '登录中...' : '登录' }}</button>
|
<button class="btn w-full" :disabled="busy" @click="login">{{ busy ? '登录中...' : '登录' }}</button>
|
||||||
<div class="text-[11px] text-slate-500 text-center">Build: {{ buildVersion }}</div>
|
<div class="text-[11px] text-slate-500 text-center">Build: {{ buildVersion }}</div>
|
||||||
<div v-if="loginErr" class="text-red-400 text-sm">{{ loginErr }}</div>
|
<div v-if="loginErr" class="text-red-400 text-sm">{{ loginErr }}</div>
|
||||||
@@ -151,6 +149,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<button class="btn2 mt-3" @click="addSDWANNode">+ 添加节点</button>
|
<button class="btn2 mt-3" @click="addSDWANNode">+ 添加节点</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="glass rounded-xl p-4">
|
||||||
|
<div class="font-bold mb-3">子网代理(Subnet Proxy)</div>
|
||||||
|
<div class="text-xs text-slate-400 mb-2">示例:local 192.168.0.0/24 → virtual 10.0.100.0/24(掩码需一致)</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div v-for="(s,i) in sd.subnetProxies" :key="i" class="grid grid-cols-1 md:grid-cols-6 gap-2">
|
||||||
|
<select class="ipt" v-model="s.node">
|
||||||
|
<option value="">选择节点</option>
|
||||||
|
<option v-for="x in nodes" :key="'sp'+x.name" :value="x.name">{{ x.name }}</option>
|
||||||
|
</select>
|
||||||
|
<input class="ipt md:col-span-2" v-model="s.localCIDR" placeholder="192.168.0.0/24">
|
||||||
|
<input class="ipt md:col-span-2" v-model="s.virtualCIDR" placeholder="10.0.100.0/24">
|
||||||
|
<button class="btn2" @click="removeSubnetProxy(i)">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn2 mt-3" @click="addSubnetProxy">+ 添加代理</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="tab==='p2p'" class="space-y-4">
|
<div v-if="tab==='p2p'" class="space-y-4">
|
||||||
@@ -287,11 +302,11 @@ createApp({
|
|||||||
|
|
||||||
const loggedIn = ref(false), busy = ref(false), msg = ref(''), msgType = ref('ok');
|
const loggedIn = ref(false), busy = ref(false), msg = ref(''), msgType = ref('ok');
|
||||||
const role = ref(''), status = ref(1);
|
const role = ref(''), status = ref(1);
|
||||||
const loginTenant = ref('1'), loginUser = ref('admin'), loginPass = ref('admin'), loginToken = ref(''), loginErr = ref('');
|
const loginUser = ref(''), loginPass = ref(''), loginErr = ref('');
|
||||||
const refreshSec = ref(15), timer = ref(null);
|
const refreshSec = ref(15), timer = ref(null);
|
||||||
|
|
||||||
const health = ref({}), stats = ref({}), nodes = ref([]), nodeKeyword = ref('');
|
const health = ref({}), stats = ref({}), nodes = ref([]), nodeKeyword = ref('');
|
||||||
const sd = ref({ enabled:false, name:'sdwan-main', gatewayCIDR:'10.10.0.0/24', mode:'mesh', hubNode:'', mtu:1420, nodes:[], routes:['10.10.0.0/24'] });
|
const sd = ref({ enabled:false, name:'sdwan-main', gatewayCIDR:'10.10.0.0/24', mode:'mesh', hubNode:'', mtu:1420, nodes:[], routes:['10.10.0.0/24'], subnetProxies:[] });
|
||||||
const connectForm = ref({ from:'', to:'', srcPort:80, dstPort:80, appName:'manual-connect' });
|
const connectForm = ref({ from:'', to:'', srcPort:80, dstPort:80, appName:'manual-connect' });
|
||||||
|
|
||||||
const tenants = ref([]), activeTenant = ref(1), keys = ref([]), users = ref([]), enrolls = ref([]);
|
const tenants = ref([]), activeTenant = ref(1), keys = ref([]), users = ref([]), enrolls = ref([]);
|
||||||
@@ -300,7 +315,7 @@ createApp({
|
|||||||
const userForm = ref({ role:'operator', email:'', password:'' });
|
const userForm = ref({ role:'operator', email:'', password:'' });
|
||||||
|
|
||||||
const tokenType = ref('');
|
const tokenType = ref('');
|
||||||
const isAdmin = computed(() => role.value === 'admin' && tokenType.value !== 'session');
|
const isAdmin = computed(() => role.value === 'admin');
|
||||||
const filteredTabs = computed(() => isAdmin.value ? tabs : tabs.filter(t => !['tenants','apikeys','users','enroll'].includes(t.id)));
|
const filteredTabs = computed(() => isAdmin.value ? tabs : tabs.filter(t => !['tenants','apikeys','users','enroll'].includes(t.id)));
|
||||||
const filteredNodes = computed(() => {
|
const filteredNodes = computed(() => {
|
||||||
const k = (nodeKeyword.value || '').trim().toLowerCase();
|
const k = (nodeKeyword.value || '').trim().toLowerCase();
|
||||||
@@ -341,19 +356,14 @@ createApp({
|
|||||||
loginErr.value = '';
|
loginErr.value = '';
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
try {
|
try {
|
||||||
let d;
|
const uname = (loginUser.value || '').trim();
|
||||||
if ((loginToken.value || '').trim()) {
|
if (!/^[A-Za-z]{6,}$/.test(uname)) throw new Error('用户名需仅字母且≥6位');
|
||||||
d = await fetch('/api/v1/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ token: loginToken.value.trim() }) }).then(r=>r.json());
|
const d = await fetch('/api/v1/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ username: uname, password: loginPass.value }) }).then(r=>r.json());
|
||||||
if (d.error) throw new Error(d.message || 'token 登录失败');
|
if (d.error) throw new Error(d.message || '用户名密码登录失败');
|
||||||
localStorage.setItem('master_t', d.token || '');
|
|
||||||
} else {
|
|
||||||
d = await fetch('/api/v1/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ tenant: Number(loginTenant.value || 1), username: loginUser.value, password: loginPass.value }) }).then(r=>r.json());
|
|
||||||
if (d.error) throw new Error(d.message || '用户名密码登录失败');
|
|
||||||
}
|
|
||||||
localStorage.setItem('t', d.token || '');
|
localStorage.setItem('t', d.token || '');
|
||||||
role.value = d.role || '';
|
role.value = d.role || '';
|
||||||
status.value = d.status ?? 1;
|
status.value = d.status ?? 1;
|
||||||
tokenType.value = d.token_type || (localStorage.getItem('t') === localStorage.getItem('master_t') ? 'master' : 'apikey');
|
tokenType.value = d.token_type || 'session';
|
||||||
if (status.value !== 1) throw new Error('账号已停用');
|
if (status.value !== 1) throw new Error('账号已停用');
|
||||||
loggedIn.value = true;
|
loggedIn.value = true;
|
||||||
await refreshAll();
|
await refreshAll();
|
||||||
@@ -398,6 +408,8 @@ createApp({
|
|||||||
};
|
};
|
||||||
const addSDWANNode = () => sd.value.nodes = [...(sd.value.nodes || []), { node:'', ip:'' }];
|
const addSDWANNode = () => sd.value.nodes = [...(sd.value.nodes || []), { node:'', ip:'' }];
|
||||||
const removeSDWANNode = i => sd.value.nodes.splice(i, 1);
|
const removeSDWANNode = i => sd.value.nodes.splice(i, 1);
|
||||||
|
const addSubnetProxy = () => sd.value.subnetProxies = [...(sd.value.subnetProxies || []), { node:'', localCIDR:'', virtualCIDR:'' }];
|
||||||
|
const removeSubnetProxy = i => sd.value.subnetProxies.splice(i, 1);
|
||||||
const autoAssignIPs = () => {
|
const autoAssignIPs = () => {
|
||||||
const used = new Set();
|
const used = new Set();
|
||||||
(sd.value.nodes || []).forEach(n => { const p = (n.ip||'').split('.'); if (p.length===4) used.add(Number(p[3])); });
|
(sd.value.nodes || []).forEach(n => { const p = (n.ip||'').split('.'); if (p.length===4) used.add(Number(p[3])); });
|
||||||
@@ -575,11 +587,11 @@ createApp({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
buildVersion, tab, filteredTabs, loggedIn, busy, msg, msgType, role, status, tokenType,
|
buildVersion, tab, filteredTabs, loggedIn, busy, msg, msgType, role, status, tokenType,
|
||||||
loginTenant, loginUser, loginPass, loginToken, loginErr, refreshSec,
|
loginUser, loginPass, loginErr, refreshSec,
|
||||||
health, stats, nodes, nodeKeyword, filteredNodes, sd, connectForm,
|
health, stats, nodes, nodeKeyword, filteredNodes, sd, connectForm,
|
||||||
tenants, activeTenant, keys, users, enrolls, tenantForm, keyForm, userForm,
|
tenants, activeTenant, keys, users, enrolls, tenantForm, keyForm, userForm,
|
||||||
natText, uptime, fmtTime,
|
natText, uptime, fmtTime,
|
||||||
login, logout, refreshAll, saveSDWAN, addSDWANNode, removeSDWANNode, autoAssignIPs,
|
login, logout, refreshAll, saveSDWAN, addSDWANNode, removeSDWANNode, addSubnetProxy, removeSubnetProxy, autoAssignIPs,
|
||||||
kickNode, renameNode, changeNodeIP, openAppManager, pushAppConfigs, openConnect, doConnect,
|
kickNode, renameNode, changeNodeIP, openAppManager, pushAppConfigs, openConnect, doConnect,
|
||||||
createTenant, loadTenants, setTenantStatus,
|
createTenant, loadTenants, setTenantStatus,
|
||||||
createKey, loadKeys, setKeyStatus,
|
createKey, loadKeys, setKeyStatus,
|
||||||
|
|||||||
Reference in New Issue
Block a user