diff --git a/.gitignore b/.gitignore index a95d6a3..077c4a2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ bin/ *.dll *.so *.dylib +inp2pc +inp2ps +web/vendor/ # Test binary *.test diff --git a/cmd/inp2pc/main.go b/cmd/inp2pc/main.go index 3174c15..00012a3 100644 --- a/cmd/inp2pc/main.go +++ b/cmd/inp2pc/main.go @@ -25,8 +25,9 @@ func main() { user := flag.String("user", "", "Username for token generation") pass := flag.String("password", "", "Password for token generation") flag.BoolVar(&cfg.Insecure, "insecure", false, "Skip TLS verification") - flag.BoolVar(&cfg.RelayEnabled, "relay", false, "Enable relay capability") - flag.BoolVar(&cfg.SuperRelay, "super", false, "Register as super relay node (implies -relay)") + flag.BoolVar(&cfg.RelayEnabled, "relay", cfg.RelayEnabled, "Enable relay capability") + flag.BoolVar(&cfg.SuperRelay, "super", cfg.SuperRelay, "Register as super relay node (implies -relay)") + flag.BoolVar(&cfg.RelayOfficial, "official-relay", cfg.RelayOfficial, "Register as official relay node") flag.IntVar(&cfg.RelayPort, "relay-port", cfg.RelayPort, "Relay listen port") flag.IntVar(&cfg.MaxRelayLoad, "relay-max", cfg.MaxRelayLoad, "Max concurrent relay sessions") flag.IntVar(&cfg.ShareBandwidth, "bw", cfg.ShareBandwidth, "Share bandwidth (Mbps)") @@ -49,9 +50,7 @@ func main() { // Load config file first (unless -newconfig) if !*newConfig { if data, err := os.ReadFile(*configFile); err == nil { - var fileCfg config.ClientConfig - if err := json.Unmarshal(data, &fileCfg); err == nil { - cfg = fileCfg + if err := json.Unmarshal(data, &cfg); err == nil { // fill defaults for missing fields if cfg.ServerPort == 0 { cfg.ServerPort = config.DefaultWSPort @@ -101,6 +100,9 @@ func main() { case "super": cfg.SuperRelay = true cfg.RelayEnabled = true // super implies relay + case "official-relay": + cfg.RelayOfficial = true + cfg.RelayEnabled = true case "bw": fmt.Sscanf(f.Value.String(), "%d", &cfg.ShareBandwidth) } diff --git a/cmd/inp2ps/main.go b/cmd/inp2ps/main.go index 7c7542a..673126f 100644 --- a/cmd/inp2ps/main.go +++ b/cmd/inp2ps/main.go @@ -12,16 +12,58 @@ import ( "net/http" "os" "os/signal" + "sync" "syscall" "time" "github.com/openp2p-cn/inp2p/internal/server" + "github.com/openp2p-cn/inp2p/internal/store" "github.com/openp2p-cn/inp2p/pkg/auth" "github.com/openp2p-cn/inp2p/pkg/config" "github.com/openp2p-cn/inp2p/pkg/nat" "github.com/openp2p-cn/inp2p/pkg/protocol" ) +type rateLimiter struct { + mu sync.Mutex + m map[string]*rateEntry + max int + window time.Duration +} + +type rateEntry struct { + count int + reset time.Time +} + +func newRateLimiter(max int, window time.Duration) *rateLimiter { + return &rateLimiter{m: make(map[string]*rateEntry), max: max, window: window} +} + +func (r *rateLimiter) Allow(key string) bool { + r.mu.Lock() + defer r.mu.Unlock() + e, ok := r.m[key] + now := time.Now() + if !ok || now.After(e.reset) { + r.m[key] = &rateEntry{count: 1, reset: now.Add(r.window)} + return true + } + if e.count >= r.max { + return false + } + e.count++ + return true +} + +func clientIP(addr string) string { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return addr + } + return host +} + func main() { cfg := config.DefaultServerConfig() @@ -38,6 +80,8 @@ func main() { token := flag.Uint64("token", 0, "Master authentication token (uint64)") user := flag.String("user", "", "Username for token generation (requires -password)") pass := flag.String("password", "", "Password for token generation") + bootstrapAdmin := flag.String("bootstrap-admin", "", "Bootstrap system admin username (letters only, >=6)") + bootstrapPass := flag.String("bootstrap-password", "", "Bootstrap system admin password") version := flag.Bool("version", false, "Print version and exit") flag.Parse() @@ -47,6 +91,46 @@ func main() { os.Exit(0) } + // Bootstrap system admin (optional) + if *bootstrapAdmin != "" { + if !server.IsValidGlobalUsername(*bootstrapAdmin) { + log.Fatalf("[main] invalid bootstrap-admin username (letters only, >=6)") + } + if *bootstrapPass == "" { + log.Fatalf("[main] bootstrap-password required") + } + st, err := store.Open(cfg.DBPath) + if err != nil { + log.Fatalf("[main] open store failed: %v", err) + } + _ = st.SetSetting("bootstrapped_admin", "1") + // ensure default tenant exists + if _, gErr := st.GetTenantByID(1); gErr != nil { + _, _, _, _ = st.CreateTenantWithUsers("default", "admin", "admin") + } + // update/create admin user in tenant 1 + users, _ := st.ListUsers(1) + var adminID int64 + for _, u := range users { + if u.Role == "admin" { + adminID = u.ID + break + } + } + if adminID > 0 { + _ = st.UpdateUserEmail(adminID, *bootstrapAdmin) + _ = st.UpdateUserPassword(adminID, *bootstrapPass) + log.Printf("[main] bootstrapped admin updated: %s", *bootstrapAdmin) + } else { + _, err = st.CreateUser(1, "admin", *bootstrapAdmin, *bootstrapPass, 1) + if err != nil { + log.Fatalf("[main] bootstrap admin create failed: %v", err) + } + log.Printf("[main] bootstrapped admin created: %s", *bootstrapAdmin) + } + os.Exit(0) + } + // Token: either direct value or generated from user+password if *token > 0 { cfg.Token = *token @@ -91,7 +175,7 @@ func main() { srv := server.New(cfg) srv.StartCleanup() - // Admin-only Middleware (master token only) + // Admin-only Middleware (System Admin session only) adminMiddleware := func(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/v1/auth/login" { @@ -99,17 +183,18 @@ func main() { return } ac, ok := srv.ResolveAccess(r, cfg.Token) - if !ok || ac.Kind != "master" { + if !ok || ac.Kind != "session" || ac.Role != "admin" || ac.TenantID != 1 { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`) return } + r = r.WithContext(context.WithValue(r.Context(), server.ServerCtxKeyAccess{}, ac)) next(w, r) } } - // Tenant or Admin Middleware (session/apikey/master) + // Tenant Middleware (session/apikey only, no operator role) tenantMiddleware := func(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/v1/auth/login" { @@ -123,15 +208,14 @@ func main() { fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`) return } - if ac.Kind == "session" && ac.Role == "operator" { - path := r.URL.Path - if path != "/api/v1/nodes" && path != "/api/v1/sdwans" && path != "/api/v1/sdwan/edit" && path != "/api/v1/connect" && path != "/api/v1/nodes/apps" && path != "/api/v1/nodes/kick" && path != "/api/v1/nodes/alias" && path != "/api/v1/nodes/ip" && path != "/api/v1/stats" && path != "/api/v1/health" { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`) - return - } + // reject master token for tenant APIs + if ac.Kind == "master" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`) + return } + r = r.WithContext(context.WithValue(r.Context(), server.ServerCtxKeyAccess{}, ac)) next(w, r) } } @@ -192,9 +276,31 @@ func main() { mux.HandleFunc("/api/v1/admin/tenants/", adminMiddleware(srv.HandleAdminCreateAPIKey)) mux.HandleFunc("/api/v1/admin/users", adminMiddleware(srv.HandleAdminUsers)) mux.HandleFunc("/api/v1/admin/users/", adminMiddleware(srv.HandleAdminUsers)) + mux.HandleFunc("/api/v1/admin/settings", adminMiddleware(srv.HandleAdminSettings)) + mux.HandleFunc("/api/v1/admin/audit", adminMiddleware(srv.HandleAdminAudit)) mux.HandleFunc("/api/v1/tenants/enroll", srv.HandleTenantEnroll) - mux.HandleFunc("/api/v1/enroll/consume", srv.HandleEnrollConsume) - mux.HandleFunc("/api/v1/enroll/consume/", srv.HandleEnrollConsume) + // enroll consume with rate-limit + rl := newRateLimiter(10, time.Minute) + mux.HandleFunc("/api/v1/enroll/consume", func(w http.ResponseWriter, r *http.Request) { + ip := clientIP(r.RemoteAddr) + if !rl.Allow(ip) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + fmt.Fprintf(w, `{"error":1,"message":"too many requests"}`) + return + } + srv.HandleEnrollConsume(w, r) + }) + mux.HandleFunc("/api/v1/enroll/consume/", func(w http.ResponseWriter, r *http.Request) { + ip := clientIP(r.RemoteAddr) + if !rl.Allow(ip) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + fmt.Fprintf(w, `{"error":1,"message":"too many requests"}`) + return + } + srv.HandleEnrollConsume(w, r) + }) mux.HandleFunc("/api/v1/nodes/alias", tenantMiddleware(srv.HandleNodeMeta)) mux.HandleFunc("/api/v1/nodes/ip", tenantMiddleware(srv.HandleNodeMeta)) @@ -203,97 +309,63 @@ func main() { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - // Support two modes: - // 1) token login: {"token":"xxxx"} (admin/master only, backward compatible) - // 2) user login: {"tenant":1,"username":"admin","password":"pass"} - var reqTok struct { - Token string `json:"token"` - } + // single mode: username/password login var reqUser struct { - TenantID int64 `json:"tenant"` Username string `json:"username"` Password string `json:"password"` } body, _ := io.ReadAll(r.Body) - _ = json.Unmarshal(body, &reqTok) _ = json.Unmarshal(body, &reqUser) - - // --- user login (session token) --- - if reqUser.TenantID > 0 && reqUser.Username != "" && reqUser.Password != "" { - if srv.Store() == nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, `{"error":1,"message":"store not ready"}`) - return - } - u, err := srv.Store().VerifyUserPassword(reqUser.TenantID, reqUser.Username, reqUser.Password) - if err != nil || u == nil || u.Status != 1 { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnauthorized) - fmt.Fprintf(w, `{"error":1,"message":"invalid credentials"}`) - return - } - sessionToken, exp, err := srv.Store().CreateSessionToken(u.ID, reqUser.TenantID, u.Role, 24*time.Hour) - if err != nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - fmt.Fprintf(w, `{"error":1,"message":"create session failed"}`) - return - } - ten, _ := srv.Store().GetTenantByID(reqUser.TenantID) - resp := struct { - Error int `json:"error"` - Message string `json:"message"` - Token string `json:"token"` - TokenType string `json:"token_type"` - ExpiresAt int64 `json:"expires_at"` - Role string `json:"role"` - Status int `json:"status"` - Subnet string `json:"subnet"` - }{0, "ok", sessionToken, "session", exp, u.Role, u.Status, ""} - if ten != nil { - resp.Subnet = ten.Subnet - } - b, _ := json.Marshal(resp) + if reqUser.Username == "" || reqUser.Password == "" { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write(b) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{"error":1,"message":"username and password required"}`) return } - - // --- token login (legacy/admin) --- - valid := false - role := "admin" - status := 1 - if reqTok.Token != "" { - // support numeric token as string - if reqTok.Token == fmt.Sprintf("%d", cfg.Token) { - valid = true - } else { - for _, t := range cfg.Tokens { - if reqTok.Token == fmt.Sprintf("%d", t) { - valid = true - break - } - } - } + if !server.IsValidGlobalUsername(reqUser.Username) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, `{"error":1,"message":"username must be letters only and >=6"}`) + return } - if !valid { + if srv.Store() == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, `{"error":1,"message":"store not ready"}`) + return + } + u, err := srv.Store().VerifyUserPasswordGlobal(reqUser.Username, reqUser.Password) + if err != nil || u == nil || u.Status != 1 { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) - fmt.Fprintf(w, `{"error":1,"message":"invalid token"}`) + fmt.Fprintf(w, `{"error":1,"message":"invalid credentials"}`) return } - if srv.Store() != nil { - if u, err := srv.Store().GetUserByTenant(0); err == nil && u != nil { - if u.Role != "" { - role = u.Role - } - status = u.Status - } + sessionToken, exp, err := srv.Store().CreateSessionToken(u.ID, u.TenantID, u.Role, 24*time.Hour) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, `{"error":1,"message":"create session failed"}`) + return } + ten, _ := srv.Store().GetTenantByID(u.TenantID) + resp := struct { + Error int `json:"error"` + Message string `json:"message"` + Token string `json:"token"` + TokenType string `json:"token_type"` + ExpiresAt int64 `json:"expires_at"` + Role string `json:"role"` + Status int `json:"status"` + Subnet string `json:"subnet"` + }{0, "ok", sessionToken, "session", exp, u.Role, u.Status, ""} + if ten != nil { + resp.Subnet = ten.Subnet + } + b, _ := json.Marshal(resp) w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"error":0,"token":"%d","role":"%s","status":%d}`, cfg.Token, role, status) + w.WriteHeader(http.StatusOK) + w.Write(b) }) mux.HandleFunc("/api/v1/health", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) { @@ -347,10 +419,35 @@ func main() { http.Error(w, "hub mode requires hubNode", http.StatusBadRequest) return } + // subnet proxy validation (basic) + for _, sp := range req.SubnetProxies { + if sp.Node == "" || sp.LocalCIDR == "" || sp.VirtualCIDR == "" { + http.Error(w, "subnet proxy requires node/localCIDR/virtualCIDR", http.StatusBadRequest) + return + } + _, lnet, lerr := net.ParseCIDR(sp.LocalCIDR) + _, vnet, verr := net.ParseCIDR(sp.VirtualCIDR) + if lerr != nil || verr != nil || lnet == nil || vnet == nil { + http.Error(w, "subnet proxy CIDR invalid", http.StatusBadRequest) + return + } + lOnes, _ := lnet.Mask.Size() + vOnes, _ := vnet.Mask.Size() + if lOnes != vOnes { + http.Error(w, "subnet proxy CIDR mask mismatch", http.StatusBadRequest) + return + } + } // tenant filter by session/apikey tenantID := getTenantID(r) if tenantID > 0 { - if err := srv.SetSDWANTenant(tenantID, req); err != nil { + ac := server.GetAccessContext(r) + actorType, actorID := "", "" + if ac != nil { + actorType = ac.Kind + actorID = fmt.Sprintf("%d", ac.UserID) + } + if err := srv.SetSDWANTenant(tenantID, req, actorType, actorID, r.RemoteAddr); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/go.mod b/go.mod index 09ffcaa..2d091f4 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,10 @@ toolchain go1.24.4 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 diff --git a/go.sum b/go.sum index 55cdff9..9f4d618 100644 --- a/go.sum +++ b/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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 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/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/inp2pc b/inp2pc deleted file mode 100755 index 7c01d31..0000000 Binary files a/inp2pc and /dev/null differ diff --git a/inp2ps b/inp2ps deleted file mode 100755 index 801dfc0..0000000 Binary files a/inp2ps and /dev/null differ diff --git a/internal/client/client.go b/internal/client/client.go index 3138394..7a7fdd5 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -3,6 +3,7 @@ package client import ( "crypto/tls" + "encoding/json" "fmt" "log" "net" @@ -45,6 +46,7 @@ type Client struct { sdwanStop chan struct{} tunMu sync.Mutex tunFile *os.File + sdwanPath string quit chan struct{} wg sync.WaitGroup } @@ -53,6 +55,7 @@ type Client struct { func New(cfg config.ClientConfig) *Client { c := &Client{ cfg: cfg, + sdwanPath: "/etc/inp2p/sdwan.json", natType: protocol.NATUnknown, tunnels: make(map[string]*tunnel.Tunnel), sdwanStop: make(chan struct{}), @@ -62,7 +65,7 @@ func New(cfg config.ClientConfig) *Client { } 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 @@ -95,7 +98,7 @@ func (c *Client) connectAndRun() error { c.publicIP = natResult.PublicIP c.publicPort = natResult.Port1 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 scheme := "ws" @@ -130,12 +133,14 @@ func (c *Client) connectAndRun() error { loginReq := protocol.LoginReq{ Node: c.cfg.Node, Token: c.cfg.Token, + NodeSecret: c.cfg.NodeSecret, User: c.cfg.User, Version: config.Version, NATType: c.natType, ShareBandwidth: c.cfg.ShareBandwidth, RelayEnabled: c.cfg.RelayEnabled, SuperRelay: c.cfg.SuperRelay, + RelayOfficial: c.cfg.RelayOfficial, PublicIP: c.publicIP, PublicPort: c.publicPort, } @@ -236,7 +241,6 @@ func (c *Client) registerHandlers() { return nil } 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 if err := c.applySDWAN(cfg); err != nil { @@ -396,7 +400,7 @@ func (c *Client) connectApp(app config.AppConfig) { ) if err != nil { log.Printf("[client] connect coordination failed for %s: %v", app.PeerNode, err) - c.tryRelay(app) + c.tryRelay(app, "tenant") return } @@ -404,7 +408,7 @@ func (c *Client) connectApp(app config.AppConfig) { protocol.DecodePayload(rspData, &rsp) if rsp.Error != 0 { log.Printf("[client] connect denied: %s", rsp.Detail) - c.tryRelay(app) + c.tryRelay(app, "tenant") return } @@ -420,7 +424,7 @@ func (c *Client) connectApp(app config.AppConfig) { if result.Error != nil { log.Printf("[client] punch failed for %s: %v", app.PeerNode, result.Error) - c.tryRelay(app) + c.tryRelay(app, "tenant") c.reportConnect(app, protocol.ReportConnect{ PeerNode: app.PeerNode, Error: result.Error.Error(), 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. -func (c *Client) tryRelay(app config.AppConfig) { - log.Printf("[client] trying relay for %s", app.PeerNode) +func (c *Client) tryRelay(app config.AppConfig, mode string) { + log.Printf("[client] trying relay(%s) for %s", mode, app.PeerNode) rspData, err := c.conn.Request( protocol.MsgRelay, protocol.SubRelayNodeReq, - protocol.RelayNodeReq{PeerNode: app.PeerNode}, + protocol.RelayNodeReq{PeerNode: app.PeerNode, Mode: mode}, protocol.MsgRelay, protocol.SubRelayNodeRsp, 10*time.Second, ) @@ -465,6 +469,11 @@ func (c *Client) tryRelay(app config.AppConfig) { var rsp protocol.RelayNodeRsp protocol.DecodePayload(rspData, &rsp) 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) return } @@ -545,6 +554,19 @@ func (c *Client) reportConnect(app config.AppConfig, rc protocol.ReportConnect) 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 { selfIP := "" for _, n := range cfg.Nodes { @@ -578,11 +600,24 @@ func (c *Client) applySDWAN(cfg protocol.SDWANConfig) error { // fallback broad route for hub mode / compatibility _ = 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.sdwan = cfg c.sdwanIP = selfIP 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 if err := c.ensureTUNReader(); err != nil { log.Printf("[client] ensureTUNReader failed (non-fatal): %v", err) @@ -591,6 +626,39 @@ func (c *Client) applySDWAN(cfg protocol.SDWANConfig) error { 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 { c.tunMu.Lock() defer c.tunMu.Unlock() @@ -637,13 +705,13 @@ func (c *Client) tunReadLoop() { if f == nil { return } - n, err := f.Read(buf) + n, err := unix.Read(int(f.Fd()), buf) if err != nil { if c.IsStopping() { return } - // Log only real errors, not EOF or timeout - if err.Error() != "EOF" && err.Error() != "resource temporarily unavailable" { + // Ignore transient errors + if err != unix.EINTR && err != unix.EAGAIN { log.Printf("[client] tun read error: %v", err) } time.Sleep(100 * time.Millisecond) diff --git a/internal/server/admin_settings.go b/internal/server/admin_settings.go new file mode 100644 index 0000000..c69e0ed --- /dev/null +++ b/internal/server/admin_settings.go @@ -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"}`) +} diff --git a/internal/server/audit_api.go b/internal/server/audit_api.go new file mode 100644 index 0000000..7c92cf6 --- /dev/null +++ b/internal/server/audit_api.go @@ -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}) +} diff --git a/internal/server/authz.go b/internal/server/authz.go index 267b4de..6d0145d 100644 --- a/internal/server/authz.go +++ b/internal/server/authz.go @@ -26,6 +26,17 @@ func (s *Server) ResolveAccess(r *http.Request, masterToken uint64) (*AccessCont return s.ResolveTenantAccessToken(tok) } +func GetAccessContext(r *http.Request) *AccessContext { + v := r.Context().Value(ServerCtxKeyAccess{}) + if v == nil { + return nil + } + if ac, ok := v.(*AccessContext); ok { + return ac + } + return nil +} + func (s *Server) ResolveTenantAccessToken(tok string) (*AccessContext, bool) { if tok == "" || s.store == nil { return nil, false diff --git a/internal/server/coordinator.go b/internal/server/coordinator.go index 2a3f0ca..7a1cc10 100644 --- a/internal/server/coordinator.go +++ b/internal/server/coordinator.go @@ -3,6 +3,7 @@ package server import ( "fmt" "log" + "os" "time" "github.com/openp2p-cn/inp2p/pkg/auth" @@ -68,6 +69,19 @@ func (s *Server) HandleConnectReq(from *NodeInfo, req protocol.ConnectReq) error return nil } + // Debug: force relay path if explicit env set + if os.Getenv("INP2P_FORCE_RELAY") == "1" { + log.Printf("[coord] %s → %s: force relay requested", from.Name, to.Name) + from.Conn.Write(protocol.MsgPush, protocol.SubPushConnectRsp, protocol.ConnectRsp{ + Error: 0, + From: to.Name, + To: from.Name, + Peer: toParams, + Detail: "punch-failed", + }) + return nil + } + // Push PunchStart to BOTH sides simultaneously punchID := fmt.Sprintf("%s-%s-%d", from.Name, to.Name, time.Now().UnixMilli()) diff --git a/internal/server/ctx.go b/internal/server/ctx.go new file mode 100644 index 0000000..011277b --- /dev/null +++ b/internal/server/ctx.go @@ -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{} diff --git a/internal/server/inp2ps.db-shm b/internal/server/inp2ps.db-shm deleted file mode 100644 index 444c871..0000000 Binary files a/internal/server/inp2ps.db-shm and /dev/null differ diff --git a/internal/server/inp2ps.db-wal b/internal/server/inp2ps.db-wal deleted file mode 100644 index 6fb447c..0000000 Binary files a/internal/server/inp2ps.db-wal and /dev/null differ diff --git a/internal/server/sdwan.go b/internal/server/sdwan.go index de2661b..4ff1f3f 100644 --- a/internal/server/sdwan.go +++ b/internal/server/sdwan.go @@ -121,5 +121,21 @@ func normalizeSDWAN(c protocol.SDWANConfig) protocol.SDWANConfig { c.Nodes = append(c.Nodes, protocol.SDWANNode{Node: node, IP: ip}) } sort.Slice(c.Nodes, func(i, j int) bool { return c.Nodes[i].Node < c.Nodes[j].Node }) + + // de-dup subnet proxies by node+cidr + if len(c.SubnetProxies) > 0 { + m2 := make(map[string]protocol.SubnetProxy) + for _, sp := range c.SubnetProxies { + if sp.Node == "" || sp.VirtualCIDR == "" || sp.LocalCIDR == "" { + continue + } + key := sp.Node + "|" + sp.VirtualCIDR + "|" + sp.LocalCIDR + m2[key] = sp + } + c.SubnetProxies = c.SubnetProxies[:0] + for _, sp := range m2 { + c.SubnetProxies = append(c.SubnetProxies, sp) + } + } return c } diff --git a/internal/server/sdwan_api.go b/internal/server/sdwan_api.go index f47970d..51b0edd 100644 --- a/internal/server/sdwan_api.go +++ b/internal/server/sdwan_api.go @@ -2,6 +2,7 @@ package server import ( "errors" + "fmt" "log" "net/netip" @@ -24,7 +25,7 @@ func (s *Server) SetSDWAN(cfg protocol.SDWANConfig) error { return nil } -func (s *Server) SetSDWANTenant(tenantID int64, cfg protocol.SDWANConfig) error { +func (s *Server) SetSDWANTenant(tenantID int64, cfg protocol.SDWANConfig, actorType, actorID, ip string) error { if cfg.Mode == "hub" { if cfg.HubNode == "" { return errors.New("hub mode requires hubNode") @@ -37,6 +38,10 @@ func (s *Server) SetSDWANTenant(tenantID int64, cfg protocol.SDWANConfig) error if err := s.sdwan.saveTenant(tenantID, cfg); err != nil { return err } + if actorType != "" && s.store != nil { + detail := fmt.Sprintf("mode=%s hub=%s nodes=%d subnetProxies=%d", cfg.Mode, cfg.HubNode, len(cfg.Nodes), len(cfg.SubnetProxies)) + _ = s.store.AddAuditLog(actorType, actorID, "sdwan_update", "tenant", fmt.Sprintf("%d", tenantID), detail, ip) + } s.broadcastSDWANTenant(tenantID, s.sdwan.getTenant(tenantID)) return nil } diff --git a/internal/server/server.go b/internal/server/server.go index f57ce03..085fc09 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -33,6 +33,7 @@ type NodeInfo struct { ShareBandwidth int `json:"shareBandwidth"` RelayEnabled bool `json:"relayEnabled"` SuperRelay bool `json:"superRelay"` + RelayOfficial bool `json:"relayOfficial"` HasIPv4 int `json:"hasIPv4"` IPv6 string `json:"ipv6"` LoginTime time.Time `json:"loginTime"` @@ -78,12 +79,12 @@ func New(cfg config.ServerConfig) *Server { if err != nil { log.Printf("[server] open store failed: %v", err) } else { - // bootstrap default admin/admin in tenant 1 + // bootstrap default tenant if missing if _, gErr := st.GetTenantByID(1); gErr != nil { if _, _, _, cErr := st.CreateTenantWithUsers("default", "admin", "admin"); cErr != nil { log.Printf("[server] bootstrap default tenant failed: %v", cErr) } else { - log.Printf("[server] bootstrap default tenant created (tenant=1, admin/admin)") + log.Printf("[server] bootstrap default tenant created (tenant=1)") } } } @@ -160,7 +161,7 @@ func (s *Server) GetOnlineNodesByTenant(tenantID int64) []*NodeInfo { } // GetRelayNodes returns nodes that can serve as relay. -// Priority: same-user private relay → super relay +// Priority: same-user private relay → super relay (exclude official relays) func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeInfo { excludeSet := make(map[string]bool) for _, n := range excludeNodes { @@ -172,7 +173,7 @@ func (s *Server) GetRelayNodes(forUser string, excludeNodes ...string) []*NodeIn var privateRelays, superRelays []*NodeInfo for _, n := range s.nodes { - if !n.IsOnline() || excludeSet[n.Name] || !n.RelayEnabled { + if !n.IsOnline() || excludeSet[n.Name] || !n.RelayEnabled || n.RelayOfficial { continue } if n.User == forUser { @@ -200,13 +201,33 @@ func (s *Server) GetRelayNodesByTenant(tenantID int64, excludeNodes ...string) [ if !n.IsOnline() || excludeSet[n.Name] { continue } - if n.TenantID == tenantID && (n.RelayEnabled || n.SuperRelay) { + if n.TenantID == tenantID && (n.RelayEnabled || n.SuperRelay) && !n.RelayOfficial { relays = append(relays, n) } } return relays } +// GetOfficialRelays returns official relay nodes (global pool) +func (s *Server) GetOfficialRelays(excludeNodes ...string) []*NodeInfo { + excludeSet := make(map[string]bool) + for _, n := range excludeNodes { + excludeSet[n] = true + } + + s.mu.RLock() + defer s.mu.RUnlock() + + var relays []*NodeInfo + for _, n := range s.nodes { + if !n.IsOnline() || excludeSet[n.Name] || !n.RelayEnabled || !n.RelayOfficial { + continue + } + relays = append(relays, n) + } + return relays +} + // HandleWS is the WebSocket handler for client connections. func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) { ws, err := s.upgrader.Upgrade(w, r, nil) @@ -287,6 +308,7 @@ func (s *Server) HandleWS(w http.ResponseWriter, r *http.Request) { ShareBandwidth: loginReq.ShareBandwidth, RelayEnabled: loginReq.RelayEnabled, SuperRelay: loginReq.SuperRelay, + RelayOfficial: loginReq.RelayOfficial, PublicIP: loginReq.PublicIP, PublicPort: loginReq.PublicPort, LoginTime: time.Now(), @@ -464,23 +486,68 @@ func (s *Server) registerHandlers(conn *signal.Conn, node *NodeInfo) { // handleRelayNodeReq finds and returns the best relay node. func (s *Server) handleRelayNodeReq(conn *signal.Conn, requester *NodeInfo, req protocol.RelayNodeReq) error { - relays := s.GetRelayNodes(requester.User, requester.Name, req.PeerNode) - - if len(relays) == 0 { + mode := "tenant" + if req.Mode == "official" { + mode = "official" + official := s.GetOfficialRelays(requester.Name, req.PeerNode) + if len(official) == 0 { + return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{Error: 1}) + } + relay := official[0] + totp := auth.GenTOTP(relay.Token, time.Now().Unix()) + log.Printf("[server] relay selected: %s (%s) for %s → %s", relay.Name, mode, requester.Name, req.PeerNode) return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{ - Error: 1, + RelayName: relay.Name, + RelayIP: relay.PublicIP, + RelayPort: config.DefaultRelayPort, + RelayToken: totp, + Mode: mode, + Error: 0, }) } + // prefer hub relay if sdwan mode=hub + if requester.TenantID > 0 && s.sdwan != nil { + cfg := s.sdwan.getTenant(requester.TenantID) + if cfg.Mode == "hub" && cfg.HubNode != "" && cfg.HubNode != requester.Name && cfg.HubNode != req.PeerNode { + hub := s.GetNode(cfg.HubNode) + if hub != nil && hub.IsOnline() && hub.TenantID == requester.TenantID && hub.RelayEnabled { + log.Printf("[server] relay selected: %s (hub) for %s → %s", hub.Name, requester.Name, req.PeerNode) + totp := auth.GenTOTP(hub.Token, time.Now().Unix()) + return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{ + RelayName: hub.Name, + RelayIP: hub.PublicIP, + RelayPort: config.DefaultRelayPort, + RelayToken: totp, + Mode: "private", + Error: 0, + }) + } + } + } + // prefer same-tenant relays, exclude requester and peer + relays := s.GetRelayNodesByTenant(requester.TenantID, requester.Name, req.PeerNode) + if len(relays) == 0 { + // fallback to same-user (private) then super + relays = s.GetRelayNodes(requester.User, requester.Name, req.PeerNode) + if len(relays) == 0 { + // final fallback: official relays + official := s.GetOfficialRelays(requester.Name, req.PeerNode) + if len(official) == 0 { + return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{Error: 1}) + } + relays = official + mode = "official" + } else if relays[0].User != requester.User { + mode = "super" + } else { + mode = "private" + } + } // Pick the first (best) relay relay := relays[0] totp := auth.GenTOTP(relay.Token, time.Now().Unix()) - mode := "private" - if relay.User != requester.User { - mode = "super" - } - log.Printf("[server] relay selected: %s (%s) for %s → %s", relay.Name, mode, requester.Name, req.PeerNode) return conn.Write(protocol.MsgRelay, protocol.SubRelayNodeRsp, protocol.RelayNodeRsp{ @@ -510,6 +577,7 @@ func (s *Server) PushConnect(fromNode *NodeInfo, toNodeName string, app protocol FromIP: fromNode.PublicIP, Peer: protocol.PunchParams{ IP: fromNode.PublicIP, + Port: fromNode.PublicPort, NATType: fromNode.NATType, HasIPv4: fromNode.HasIPv4, Token: auth.GenTOTP(fromNode.Token, time.Now().Unix()), @@ -598,6 +666,9 @@ func (s *Server) StartCleanup() { cfg.Mode = "mesh" cfg.HubNode = "" _ = s.sdwan.saveTenant(tid, cfg) + if s.store != nil { + _ = s.store.AddAuditLog("system", "0", "sdwan_update", "tenant", fmt.Sprintf("%d", tid), "hub->mesh (hub offline)", "") + } s.broadcastSDWANTenant(tid, cfg) log.Printf("[sdwan] hub offline, auto fallback to mesh (tenant=%d)", tid) } diff --git a/internal/server/tenant_api.go b/internal/server/tenant_api.go index facb30d..449bf72 100644 --- a/internal/server/tenant_api.go +++ b/internal/server/tenant_api.go @@ -62,6 +62,9 @@ func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request) status = 1 } _ = s.store.UpdateTenantStatus(id, status) + if ac := GetAccessContext(r); ac != nil { + _ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "tenant_status", "tenant", fmt.Sprintf("%d", id), fmt.Sprintf("status=%d", status), r.RemoteAddr) + } writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`) return } @@ -165,6 +168,9 @@ func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request) status = 1 } _ = s.store.UpdateAPIKeyStatus(keyID, status) + if ac := GetAccessContext(r); ac != nil { + _ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "apikey_status", "apikey", fmt.Sprintf("%d", keyID), fmt.Sprintf("status=%d", status), r.RemoteAddr) + } writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`) return } @@ -191,6 +197,9 @@ func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request) }{0, "ok", key, tenantID} b, _ := json.Marshal(resp) writeJSON(w, http.StatusOK, string(b)) + if ac := GetAccessContext(r); ac != nil { + _ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "apikey_create", "tenant", fmt.Sprintf("%d", tenantID), req.Scope, r.RemoteAddr) + } } func (s *Server) HandleTenantEnroll(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/user_api.go b/internal/server/user_api.go new file mode 100644 index 0000000..3f81bf5 --- /dev/null +++ b/internal/server/user_api.go @@ -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"}`) +} diff --git a/internal/store/store.go b/internal/store/store.go index 71b020e..10fb42d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -27,6 +27,18 @@ type Tenant struct { 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 { ID int64 TenantID int64 @@ -102,6 +114,12 @@ func Open(dbPath string) (*Store, error) { if err := s.ensureSubnetPool(); err != nil { return nil, err } + if err := s.ensureSettings(); err != nil { + return nil, err + } + if err := s.backfillNodeIdentity(); err != nil { + return nil, err + } return s, nil } @@ -182,6 +200,11 @@ func (s *Store) migrate() error { ip TEXT, 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 ( subnet TEXT PRIMARY KEY, status INTEGER NOT NULL DEFAULT 0, @@ -282,11 +305,11 @@ func (s *Store) CreateTenantWithUsers(name, adminPassword, operatorPassword stri if err != nil { 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 { 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 { 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) } +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) func (s *Store) ListTenants() ([]Tenant, error) { 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 } +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) { u, err := s.GetUserByEmail(tenantID, email) if err != nil { @@ -682,6 +796,21 @@ func (s *Store) VerifyUserPassword(tenantID int64, email, password string) (*Use 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) { tok := randToken() h := hashTokenString(tok) @@ -742,6 +871,11 @@ func (s *Store) UpdateUserPassword(id int64, password string) error { 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 { h := sha256.Sum256(b) 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) var _ = net.IPv4len diff --git a/pkg/config/config.go b/pkg/config/config.go index c73b188..68bc8d4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -36,17 +36,17 @@ const ( // ServerConfig holds inp2ps configuration. type ServerConfig struct { - WSPort int `json:"wsPort"` - STUNUDP1 int `json:"stunUDP1"` - STUNUDP2 int `json:"stunUDP2"` - STUNTCP1 int `json:"stunTCP1"` - STUNTCP2 int `json:"stunTCP2"` - WebPort int `json:"webPort"` - APIPort int `json:"apiPort"` - DBPath string `json:"dbPath"` - CertFile string `json:"certFile"` - KeyFile string `json:"keyFile"` - LogLevel int `json:"logLevel"` // 0=debug, 1=info, 2=warn, 3=error + WSPort int `json:"wsPort"` + STUNUDP1 int `json:"stunUDP1"` + STUNUDP2 int `json:"stunUDP2"` + STUNTCP1 int `json:"stunTCP1"` + STUNTCP2 int `json:"stunTCP2"` + WebPort int `json:"webPort"` + APIPort int `json:"apiPort"` + DBPath string `json:"dbPath"` + CertFile string `json:"certFile"` + KeyFile string `json:"keyFile"` + LogLevel int `json:"logLevel"` // 0=debug, 1=info, 2=warn, 3=error Token uint64 `json:"token"` // master token for auth Tokens []uint64 `json:"tokens"` // additional tenant tokens JWTKey string `json:"jwtKey"` // auto-generated if empty @@ -132,10 +132,11 @@ type ClientConfig struct { STUNTCP1 int `json:"stunTCP1,omitempty"` STUNTCP2 int `json:"stunTCP2,omitempty"` - RelayEnabled bool `json:"relayEnabled"` // --relay - SuperRelay bool `json:"superRelay"` // --super - RelayPort int `json:"relayPort"` - MaxRelayLoad int `json:"maxRelayLoad"` + RelayEnabled bool `json:"relayEnabled"` // --relay + SuperRelay bool `json:"superRelay"` // --super + RelayOfficial bool `json:"relayOfficial"` // official relay tag + RelayPort int `json:"relayPort"` + MaxRelayLoad int `json:"maxRelayLoad"` ShareBandwidth int `json:"shareBandwidth"` // Mbps LogLevel int `json:"logLevel"` @@ -163,6 +164,8 @@ func DefaultClientConfig() ClientConfig { ShareBandwidth: 10, RelayPort: DefaultRelayPort, MaxRelayLoad: DefaultMaxRelayLoad, + RelayEnabled: true, + RelayOfficial: false, LogLevel: 1, } } diff --git a/pkg/protocol/protocol.go b/pkg/protocol/protocol.go index 41e3651..99de3e8 100644 --- a/pkg/protocol/protocol.go +++ b/pkg/protocol/protocol.go @@ -197,8 +197,9 @@ type LoginReq struct { Version string `json:"version"` NATType NATType `json:"natType"` ShareBandwidth int `json:"shareBandwidth"` - RelayEnabled bool `json:"relayEnabled"` // --relay flag - SuperRelay bool `json:"superRelay"` // --super flag + RelayEnabled bool `json:"relayEnabled"` // --relay flag + SuperRelay bool `json:"superRelay"` // --super flag + RelayOfficial bool `json:"relayOfficial"` // official relay tag PublicIP string `json:"publicIP,omitempty"` PublicPort int `json:"publicPort,omitempty"` } @@ -264,6 +265,7 @@ type ConnectRsp struct { // RelayNodeReq asks the server for a relay node. type RelayNodeReq struct { PeerNode string `json:"peerNode"` + Mode string `json:"mode,omitempty"` // "tenant" | "official" } type RelayNodeRsp struct { @@ -292,17 +294,24 @@ type SDWANNode struct { IP string `json:"ip"` } +type SubnetProxy struct { + Node string `json:"node"` + LocalCIDR string `json:"localCIDR"` + VirtualCIDR string `json:"virtualCIDR"` +} + type SDWANConfig struct { - Enabled bool `json:"enabled,omitempty"` - Name string `json:"name,omitempty"` - GatewayCIDR string `json:"gatewayCIDR"` - Mode string `json:"mode,omitempty"` // hub | mesh | fullmesh - HubNode string `json:"hubNode,omitempty"` - IP string `json:"ip,omitempty"` // node self IP if pushed per-node - MTU int `json:"mtu,omitempty"` - Routes []string `json:"routes,omitempty"` - Nodes []SDWANNode `json:"nodes"` - UpdatedAt int64 `json:"updatedAt,omitempty"` + Enabled bool `json:"enabled,omitempty"` + Name string `json:"name,omitempty"` + GatewayCIDR string `json:"gatewayCIDR"` + Mode string `json:"mode,omitempty"` // hub | mesh | fullmesh + HubNode string `json:"hubNode,omitempty"` + IP string `json:"ip,omitempty"` // node self IP if pushed per-node + MTU int `json:"mtu,omitempty"` + Routes []string `json:"routes,omitempty"` + Nodes []SDWANNode `json:"nodes"` + SubnetProxies []SubnetProxy `json:"subnetProxies,omitempty"` + UpdatedAt int64 `json:"updatedAt,omitempty"` } type SDWANPeer struct { diff --git a/pkg/relay/relay.go b/pkg/relay/relay.go index 312e593..af72aca 100644 --- a/pkg/relay/relay.go +++ b/pkg/relay/relay.go @@ -1,13 +1,13 @@ // Package relay implements relay/super-relay node capabilities. // // Relay flow: -// 1. Client A asks server for relay (RelayNodeReq) -// 2. Server finds relay R, generates TOTP/token, responds to A (RelayNodeRsp) -// 3. Server pushes RelayOffer to R with session info -// 4. A connects to R:relayPort, sends RelayHandshake{SessionID, Role="from", Token} -// 5. B connects to R:relayPort, sends RelayHandshake{SessionID, Role="to", Token} -// (B gets the session info via server push) -// 6. R verifies both tokens, bridges A↔B +// 1. Client A asks server for relay (RelayNodeReq) +// 2. Server finds relay R, generates TOTP/token, responds to A (RelayNodeRsp) +// 3. Server pushes RelayOffer to R with session info +// 4. A connects to R:relayPort, sends RelayHandshake{SessionID, Role="from", Token} +// 5. B connects to R:relayPort, sends RelayHandshake{SessionID, Role="to", Token} +// (B gets the session info via server push) +// 6. R verifies both tokens, bridges A↔B package relay import ( @@ -68,6 +68,7 @@ type Manager struct { enabled bool superRelay bool maxLoad int + maxMbps int token uint64 // this node's auth token port int listener net.Listener @@ -92,11 +93,12 @@ type Session struct { } // 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{ enabled: enabled, superRelay: superRelay, maxLoad: maxLoad, + maxMbps: maxMbps, token: token, port: port, pending: make(map[string]*pendingSession), @@ -296,14 +298,47 @@ func (m *Manager) bridge(ps *pendingSession) { var wg sync.WaitGroup 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() { defer wg.Done() - n, _ := io.Copy(sess.ConnB, sess.ConnA) + n := copyWithLimit(sess.ConnB, sess.ConnA) atomic.AddInt64(&sess.BytesFwd, n) }() go func() { defer wg.Done() - n, _ := io.Copy(sess.ConnA, sess.ConnB) + n := copyWithLimit(sess.ConnA, sess.ConnB) atomic.AddInt64(&sess.BytesFwd, n) }() diff --git a/pkg/relay/relay_test.go b/pkg/relay/relay_test.go index 279e63c..74e1100 100644 --- a/pkg/relay/relay_test.go +++ b/pkg/relay/relay_test.go @@ -12,7 +12,7 @@ import ( func TestRelayBridge(t *testing.T) { 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 { t.Fatal(err) } @@ -94,7 +94,7 @@ func TestRelayBridge(t *testing.T) { func TestRelayLargeData(t *testing.T) { 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 { t.Fatal(err) } @@ -173,7 +173,7 @@ func TestRelayLargeData(t *testing.T) { func TestRelayAuthDenied(t *testing.T) { 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 { t.Fatal(err) } diff --git a/web/index.html b/web/index.html index 402e7b9..de11235 100644 --- a/web/index.html +++ b/web/index.html @@ -31,11 +31,9 @@

INP2P 控制台

登录后可管理节点、SDWAN、连接与租户

- - + -
或使用主 Token 登录(管理员)
- +
用户名要求:仅字母、长度≥6、全局唯一
Build: {{ buildVersion }}
{{ loginErr }}
@@ -151,6 +149,23 @@
+ +
+
子网代理(Subnet Proxy)
+
示例:local 192.168.0.0/24 → virtual 10.0.100.0/24(掩码需一致)
+
+
+ + + + +
+
+ +
@@ -287,11 +302,11 @@ createApp({ const loggedIn = ref(false), busy = ref(false), msg = ref(''), msgType = ref('ok'); 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 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 tenants = ref([]), activeTenant = ref(1), keys = ref([]), users = ref([]), enrolls = ref([]); @@ -300,7 +315,7 @@ createApp({ const userForm = ref({ role:'operator', email:'', password:'' }); 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 filteredNodes = computed(() => { const k = (nodeKeyword.value || '').trim().toLowerCase(); @@ -341,19 +356,14 @@ createApp({ loginErr.value = ''; busy.value = true; try { - let d; - if ((loginToken.value || '').trim()) { - 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()); - if (d.error) throw new Error(d.message || 'token 登录失败'); - 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 || '用户名密码登录失败'); - } + const uname = (loginUser.value || '').trim(); + if (!/^[A-Za-z]{6,}$/.test(uname)) throw new Error('用户名需仅字母且≥6位'); + 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 || '用户名密码登录失败'); localStorage.setItem('t', d.token || ''); role.value = d.role || ''; 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('账号已停用'); loggedIn.value = true; await refreshAll(); @@ -398,6 +408,8 @@ createApp({ }; const addSDWANNode = () => sd.value.nodes = [...(sd.value.nodes || []), { node:'', ip:'' }]; 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 used = new Set(); (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 { 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, tenants, activeTenant, keys, users, enrolls, tenantForm, keyForm, userForm, 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, createTenant, loadTenants, setTenantStatus, createKey, loadKeys, setKeyStatus,