Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84c709bbcc | ||
|
|
90ada35d86 | ||
|
|
d392a4c8c7 |
+19
-2
@@ -357,8 +357,9 @@ func main() {
|
|||||||
ExpiresAt int64 `json:"expires_at"`
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
|
TenantID int64 `json:"tenant_id"`
|
||||||
Subnet string `json:"subnet"`
|
Subnet string `json:"subnet"`
|
||||||
}{0, "ok", sessionToken, "session", exp, u.Role, u.Status, ""}
|
}{0, "ok", sessionToken, "session", exp, u.Role, u.Status, u.TenantID, ""}
|
||||||
if ten != nil {
|
if ten != nil {
|
||||||
resp.Subnet = ten.Subnet
|
resp.Subnet = ten.Subnet
|
||||||
}
|
}
|
||||||
@@ -581,6 +582,17 @@ func main() {
|
|||||||
// Server uptime + detailed stats
|
// Server uptime + detailed stats
|
||||||
mux.HandleFunc("/api/v1/stats", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/v1/stats", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||||
nodes := srv.GetOnlineNodes()
|
nodes := srv.GetOnlineNodes()
|
||||||
|
// tenant-scoped stats
|
||||||
|
tenantID := getTenantID(r)
|
||||||
|
if tenantID > 0 {
|
||||||
|
filtered := make([]*server.NodeInfo, 0, len(nodes))
|
||||||
|
for _, n := range nodes {
|
||||||
|
if n.TenantID == tenantID {
|
||||||
|
filtered = append(filtered, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodes = filtered
|
||||||
|
}
|
||||||
coneCount, symmCount, unknCount := 0, 0, 0
|
coneCount, symmCount, unknCount := 0, 0, 0
|
||||||
relayCount := 0
|
relayCount := 0
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
@@ -596,7 +608,12 @@ func main() {
|
|||||||
relayCount++
|
relayCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sdwan := srv.GetSDWAN()
|
var sdwan protocol.SDWANConfig
|
||||||
|
if tenantID > 0 {
|
||||||
|
sdwan = srv.GetSDWANTenant(tenantID)
|
||||||
|
} else {
|
||||||
|
sdwan = srv.GetSDWAN()
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"nodes": len(nodes),
|
"nodes": len(nodes),
|
||||||
|
|||||||
@@ -50,23 +50,50 @@ func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// update tenant status via /api/v1/admin/tenants/{id}?status=0|1
|
// update tenant status via /api/v1/admin/tenants/{id}?status=0|1
|
||||||
|
// reassign subnet via /api/v1/admin/tenants/{id}/subnet {"subnet":"10.200.x.0/24"}
|
||||||
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/admin/tenants/") {
|
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/admin/tenants/") {
|
||||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||||
if len(parts) >= 4 {
|
if len(parts) >= 5 {
|
||||||
var id int64
|
var id int64
|
||||||
_, _ = fmt.Sscanf(parts[len(parts)-1], "%d", &id)
|
_, _ = fmt.Sscanf(parts[4], "%d", &id)
|
||||||
st := r.URL.Query().Get("status")
|
if id > 0 {
|
||||||
if id > 0 && st != "" {
|
// /api/v1/admin/tenants/{id}/subnet
|
||||||
status := 0
|
if len(parts) >= 6 && parts[5] == "subnet" {
|
||||||
if st == "1" {
|
var req struct {
|
||||||
status = 1
|
Subnet string `json:"subnet"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Subnet) == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
oldSubnet, err := s.store.ReassignTenantSubnet(id, req.Subnet)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"`+err.Error()+`"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ac := GetAccessContext(r); ac != nil {
|
||||||
|
detail := fmt.Sprintf("subnet_old=%s subnet_new=%s", oldSubnet, strings.TrimSpace(req.Subnet))
|
||||||
|
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "tenant_subnet_reassign", "tenant", fmt.Sprintf("%d", id), detail, r.RemoteAddr)
|
||||||
|
}
|
||||||
|
// auto rewrite tenant SDWAN routes+gateway and push to online nodes
|
||||||
|
s.ReconcileTenantSDWAN(id)
|
||||||
|
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
_ = s.store.UpdateTenantStatus(id, status)
|
|
||||||
if ac := GetAccessContext(r); ac != nil {
|
st := r.URL.Query().Get("status")
|
||||||
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "tenant_status", "tenant", fmt.Sprintf("%d", id), fmt.Sprintf("status=%d", status), r.RemoteAddr)
|
if st != "" {
|
||||||
|
status := 0
|
||||||
|
if st == "1" {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, `{"error":0,"message":"ok"}`)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,6 +143,10 @@ func (s *Server) HandleAdminCreateTenant(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
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 {
|
||||||
|
detail := fmt.Sprintf("name=%s subnet=%s", req.Name, ten.Subnet)
|
||||||
|
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "tenant_create", "tenant", fmt.Sprintf("%d", ten.ID), detail, r.RemoteAddr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -198,7 +229,8 @@ func (s *Server) HandleAdminCreateAPIKey(w http.ResponseWriter, r *http.Request)
|
|||||||
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 {
|
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)
|
detail := fmt.Sprintf("tenant=%d ttl=%d scope=%s", tenantID, req.TTL, req.Scope)
|
||||||
|
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "apikey_create", "tenant", fmt.Sprintf("%d", tenantID), detail, r.RemoteAddr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +283,10 @@ func (s *Server) HandleTenantEnroll(w http.ResponseWriter, r *http.Request) {
|
|||||||
}{0, "ok", code, ten.ID}
|
}{0, "ok", code, ten.ID}
|
||||||
b, _ := json.Marshal(resp)
|
b, _ := json.Marshal(resp)
|
||||||
writeJSON(w, http.StatusOK, string(b))
|
writeJSON(w, http.StatusOK, string(b))
|
||||||
|
if ac != nil {
|
||||||
|
detail := fmt.Sprintf("tenant=%d ttl=600 max_attempt=5", ten.ID)
|
||||||
|
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "enroll_create", "tenant", fmt.Sprintf("%d", ten.ID), detail, r.RemoteAddr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -260,6 +296,11 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
// revoke support: /api/v1/enroll/consume/{id}?status=0
|
// revoke support: /api/v1/enroll/consume/{id}?status=0
|
||||||
if strings.Contains(r.URL.Path, "/enroll/consume/") {
|
if strings.Contains(r.URL.Path, "/enroll/consume/") {
|
||||||
|
ac, ok := s.ResolveTenantAccessToken(BearerToken(r))
|
||||||
|
if !ok || ac.TenantID <= 0 || !TenantCan(ac, "enroll_manage") {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"unauthorized"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||||
if len(parts) >= 4 {
|
if len(parts) >= 4 {
|
||||||
var id int64
|
var id int64
|
||||||
@@ -270,7 +311,14 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) {
|
|||||||
if st == "1" {
|
if st == "1" {
|
||||||
status = 1
|
status = 1
|
||||||
}
|
}
|
||||||
|
// tenant isolation: enroll token must belong to the caller's tenant
|
||||||
|
et, terr := s.store.GetEnrollTokenByID(id)
|
||||||
|
if terr != nil || et == nil || et.TenantID != ac.TenantID {
|
||||||
|
writeJSON(w, http.StatusNotFound, `{"error":1,"message":"not found"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
_ = s.store.UpdateEnrollStatus(id, status)
|
_ = s.store.UpdateEnrollStatus(id, status)
|
||||||
|
_ = s.store.AddAuditLog(ac.Kind, fmt.Sprintf("%d", ac.UserID), "enroll_status", "enroll", 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
|
||||||
}
|
}
|
||||||
@@ -284,6 +332,11 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
|
writeJSON(w, http.StatusBadRequest, `{"error":1,"message":"bad request"}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
actorType, actorID := "enroll", "0"
|
||||||
|
if ac, ok := s.ResolveTenantAccessToken(BearerToken(r)); ok && ac != nil {
|
||||||
|
actorType = ac.Kind
|
||||||
|
actorID = fmt.Sprintf("%d", ac.UserID)
|
||||||
|
}
|
||||||
if s.store == nil {
|
if s.store == nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
|
writeJSON(w, http.StatusInternalServerError, `{"error":1,"message":"store not ready"}`)
|
||||||
return
|
return
|
||||||
@@ -291,6 +344,7 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) {
|
|||||||
et, err := s.store.ConsumeEnrollToken(req.Code)
|
et, err := s.store.ConsumeEnrollToken(req.Code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.store.IncEnrollAttempt(req.Code)
|
s.store.IncEnrollAttempt(req.Code)
|
||||||
|
_ = s.store.AddAuditLog(actorType, actorID, "enroll_consume_denied", "enroll", req.Code, "invalid_or_expired", r.RemoteAddr)
|
||||||
writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"invalid enroll"}`)
|
writeJSON(w, http.StatusUnauthorized, `{"error":1,"message":"invalid enroll"}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -313,6 +367,7 @@ func (s *Server) HandleEnrollConsume(w http.ResponseWriter, r *http.Request) {
|
|||||||
}{0, "ok", cred.NodeID, cred.NodeUUID, cred.NodeName, cred.Alias, cred.VirtualIP, cred.Secret, cred.TenantID, cred.CreatedAt}
|
}{0, "ok", cred.NodeID, cred.NodeUUID, cred.NodeName, cred.Alias, cred.VirtualIP, cred.Secret, cred.TenantID, cred.CreatedAt}
|
||||||
b, _ := json.Marshal(resp)
|
b, _ := json.Marshal(resp)
|
||||||
writeJSON(w, http.StatusOK, string(b))
|
writeJSON(w, http.StatusOK, string(b))
|
||||||
|
_ = s.store.AddAuditLog(actorType, actorID, "enroll_consume", "node", cred.NodeUUID, fmt.Sprintf("tenant=%d node=%s", cred.TenantID, cred.NodeName), r.RemoteAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// placeholder to avoid unused import
|
// placeholder to avoid unused import
|
||||||
|
|||||||
+184
-29
@@ -243,36 +243,29 @@ func (s *Store) migrate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ensureSubnetPool() error {
|
func (s *Store) ensureSubnetPool() error {
|
||||||
// pool: 10.10.1.0/24 .. 10.10.254.0/24
|
// scheme-1 pool: 10.200.0.0/24 .. 10.200.255.0/24 (256 tenants)
|
||||||
// reserve: 10.10.0.0/24 and 10.10.255.0/24
|
// keep legacy rows if they already exist; new allocations prefer 10.200.x.0/24
|
||||||
rows, err := s.DB.Query(`SELECT COUNT(1) FROM subnet_pool;`)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var count int
|
|
||||||
if rows.Next() {
|
|
||||||
_ = rows.Scan(&count)
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
insert := `INSERT INTO subnet_pool(subnet,status,reserved,tenant_id,updated_at) VALUES(?,?,?,?,?)`
|
insert := `INSERT OR IGNORE INTO subnet_pool(subnet,status,reserved,tenant_id,updated_at) VALUES(?,?,?,?,?)`
|
||||||
// reserved
|
for i := 0; i <= 255; i++ {
|
||||||
_, _ = s.DB.Exec(insert, "10.10.0.0/24", 0, 1, nil, now)
|
sn := fmt.Sprintf("10.200.%d.0/24", i)
|
||||||
_, _ = s.DB.Exec(insert, "10.10.255.0/24", 0, 1, nil, now)
|
if _, err := s.DB.Exec(insert, sn, 0, 0, nil, now); err != nil {
|
||||||
for i := 1; i <= 254; i++ {
|
return err
|
||||||
sn := fmt.Sprintf("10.10.%d.0/24", i)
|
}
|
||||||
_, _ = s.DB.Exec(insert, sn, 0, 0, nil, now)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) AllocateSubnet() (string, error) {
|
func (s *Store) AllocateSubnet() (string, error) {
|
||||||
// find first available subnet
|
// prefer scheme-1 range
|
||||||
row := s.DB.QueryRow(`SELECT subnet FROM subnet_pool WHERE status=0 AND reserved=0 ORDER BY subnet LIMIT 1`)
|
row := s.DB.QueryRow(`SELECT subnet FROM subnet_pool WHERE status=0 AND reserved=0 AND subnet LIKE '10.200.%/24' ORDER BY subnet LIMIT 1`)
|
||||||
var subnet string
|
var subnet string
|
||||||
|
if err := row.Scan(&subnet); err == nil && subnet != "" {
|
||||||
|
return subnet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback: any available subnet (legacy compatibility)
|
||||||
|
row = s.DB.QueryRow(`SELECT subnet FROM subnet_pool WHERE status=0 AND reserved=0 ORDER BY subnet LIMIT 1`)
|
||||||
if err := row.Scan(&subnet); err != nil {
|
if err := row.Scan(&subnet); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -395,6 +388,50 @@ func (s *Store) GetNodeCredentialByName(tenantID int64, nodeName string) (*NodeC
|
|||||||
return &n, nil
|
return &n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListNodeCredentialsByTenant(tenantID int64) ([]NodeCredential, error) {
|
||||||
|
rows, err := s.DB.Query(`SELECT id,node_uuid,node_name,alias,virtual_ip,tenant_id,status,last_seen,created_at FROM nodes WHERE tenant_id=? ORDER BY id DESC`, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]NodeCredential, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var n NodeCredential
|
||||||
|
var seen sql.NullInt64
|
||||||
|
if err := rows.Scan(&n.NodeID, &n.NodeUUID, &n.NodeName, &n.Alias, &n.VirtualIP, &n.TenantID, &n.Status, &seen, &n.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if seen.Valid {
|
||||||
|
v := seen.Int64
|
||||||
|
n.LastSeen = &v
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListNodeCredentialsAll() ([]NodeCredential, error) {
|
||||||
|
rows, err := s.DB.Query(`SELECT id,node_uuid,node_name,alias,virtual_ip,tenant_id,status,last_seen,created_at FROM nodes ORDER BY id DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]NodeCredential, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var n NodeCredential
|
||||||
|
var seen sql.NullInt64
|
||||||
|
if err := rows.Scan(&n.NodeID, &n.NodeUUID, &n.NodeName, &n.Alias, &n.VirtualIP, &n.TenantID, &n.Status, &seen, &n.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if seen.Valid {
|
||||||
|
v := seen.Int64
|
||||||
|
n.LastSeen = &v
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) SetNodeAlias(tenantID int64, nodeUUID, alias string) error {
|
func (s *Store) SetNodeAlias(tenantID int64, nodeUUID, alias string) error {
|
||||||
alias = strings.TrimSpace(alias)
|
alias = strings.TrimSpace(alias)
|
||||||
res, err := s.DB.Exec(`UPDATE nodes SET alias=? WHERE tenant_id=? AND node_uuid=?`, alias, tenantID, nodeUUID)
|
res, err := s.DB.Exec(`UPDATE nodes SET alias=? WHERE tenant_id=? AND node_uuid=?`, alias, tenantID, nodeUUID)
|
||||||
@@ -519,9 +556,13 @@ func (s *Store) IncEnrollAttempt(code string) {
|
|||||||
|
|
||||||
func (s *Store) ensureSettings() error {
|
func (s *Store) ensureSettings() error {
|
||||||
defaults := map[string]string{
|
defaults := map[string]string{
|
||||||
"advanced_impersonate": "0",
|
"advanced_impersonate": "0",
|
||||||
"advanced_force_network": "0",
|
"advanced_force_network": "0",
|
||||||
"advanced_cross_tenant": "0",
|
"advanced_cross_tenant": "0",
|
||||||
|
"advanced_override_enabled": "0",
|
||||||
|
"allow_apikey_mutate_sdwan": "0",
|
||||||
|
"allow_operator_kick_cross_node": "0",
|
||||||
|
"allow_connect_cross_tenant": "0",
|
||||||
}
|
}
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
for k, v := range defaults {
|
for k, v := range defaults {
|
||||||
@@ -567,14 +608,44 @@ func (s *Store) AddAuditLog(actorType, actorID, action, targetType, targetID, de
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListAuditLogs(tenantID int64, limit, offset int) ([]AuditLog, error) {
|
func (s *Store) ListAuditLogs(tenantID int64, limit, offset int, action, actorType, actorID, targetType, targetID string, fromTs, toTs int64) ([]AuditLog, error) {
|
||||||
q := `SELECT id,actor_type,actor_id,action,target_type,target_id,detail,ip,created_at FROM audit_logs`
|
q := `SELECT id,actor_type,actor_id,action,target_type,target_id,detail,ip,created_at FROM audit_logs WHERE 1=1`
|
||||||
args := []any{}
|
args := []any{}
|
||||||
|
|
||||||
if tenantID > 0 {
|
if tenantID > 0 {
|
||||||
// limit to logs related to this tenant
|
// limit to logs related to this tenant
|
||||||
q += ` WHERE (target_type='tenant' AND target_id=?)`
|
q += ` AND (target_type='tenant' AND target_id=?)`
|
||||||
args = append(args, fmt.Sprintf("%d", tenantID))
|
args = append(args, fmt.Sprintf("%d", tenantID))
|
||||||
}
|
}
|
||||||
|
if action != "" {
|
||||||
|
q += ` AND action=?`
|
||||||
|
args = append(args, action)
|
||||||
|
}
|
||||||
|
if actorType != "" {
|
||||||
|
q += ` AND actor_type=?`
|
||||||
|
args = append(args, actorType)
|
||||||
|
}
|
||||||
|
if actorID != "" {
|
||||||
|
q += ` AND actor_id=?`
|
||||||
|
args = append(args, actorID)
|
||||||
|
}
|
||||||
|
if targetType != "" {
|
||||||
|
q += ` AND target_type=?`
|
||||||
|
args = append(args, targetType)
|
||||||
|
}
|
||||||
|
if targetID != "" {
|
||||||
|
q += ` AND target_id=?`
|
||||||
|
args = append(args, targetID)
|
||||||
|
}
|
||||||
|
if fromTs > 0 {
|
||||||
|
q += ` AND created_at>=?`
|
||||||
|
args = append(args, fromTs)
|
||||||
|
}
|
||||||
|
if toTs > 0 {
|
||||||
|
q += ` AND created_at<=?`
|
||||||
|
args = append(args, toTs)
|
||||||
|
}
|
||||||
|
|
||||||
q += ` ORDER BY id DESC`
|
q += ` ORDER BY id DESC`
|
||||||
if limit > 0 {
|
if limit > 0 {
|
||||||
q += ` LIMIT ?`
|
q += ` LIMIT ?`
|
||||||
@@ -621,6 +692,75 @@ func (s *Store) UpdateTenantStatus(id int64, status int) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReassignTenantSubnet reassigns tenant subnet with conflict checks.
|
||||||
|
// Allowed range (scheme-1): 10.200.0.0/16, /24 only.
|
||||||
|
func (s *Store) ReassignTenantSubnet(tenantID int64, newSubnet string) (string, error) {
|
||||||
|
newSubnet = strings.TrimSpace(newSubnet)
|
||||||
|
if newSubnet == "" {
|
||||||
|
return "", errors.New("subnet required")
|
||||||
|
}
|
||||||
|
ip, ipnet, err := net.ParseCIDR(newSubnet)
|
||||||
|
if err != nil || ipnet == nil {
|
||||||
|
return "", errors.New("invalid subnet")
|
||||||
|
}
|
||||||
|
ones, bits := ipnet.Mask.Size()
|
||||||
|
if bits != 32 || ones != 24 {
|
||||||
|
return "", errors.New("only /24 ipv4 subnet supported")
|
||||||
|
}
|
||||||
|
if ip4 := ip.To4(); ip4 == nil || ip4[0] != 10 || ip4[1] != 200 {
|
||||||
|
return "", errors.New("subnet must be in 10.200.0.0/16")
|
||||||
|
}
|
||||||
|
if ip.String() != ipnet.IP.String() {
|
||||||
|
return "", errors.New("subnet must be network address, e.g. 10.200.x.0/24")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
var oldSubnet string
|
||||||
|
row := tx.QueryRow(`SELECT subnet FROM tenants WHERE id=?`, tenantID)
|
||||||
|
if err := row.Scan(&oldSubnet); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if oldSubnet == newSubnet {
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return oldSubnet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// conflict check: target subnet must not belong to another tenant
|
||||||
|
var occupiedBy sql.NullInt64
|
||||||
|
if err := tx.QueryRow(`SELECT id FROM tenants WHERE subnet=?`, newSubnet).Scan(&occupiedBy); err == nil {
|
||||||
|
if occupiedBy.Valid && occupiedBy.Int64 != tenantID {
|
||||||
|
return "", errors.New("subnet already occupied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
// release old pool mark
|
||||||
|
_, _ = tx.Exec(`UPDATE subnet_pool SET status=0, tenant_id=NULL, updated_at=? WHERE subnet=? AND tenant_id=?`, now, oldSubnet, tenantID)
|
||||||
|
// assign tenant
|
||||||
|
if _, err := tx.Exec(`UPDATE tenants SET subnet=? WHERE id=?`, newSubnet, tenantID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// ensure pool row exists and mark occupied
|
||||||
|
if _, err := tx.Exec(`INSERT OR IGNORE INTO subnet_pool(subnet,status,reserved,tenant_id,updated_at) VALUES(?,?,?,?,?)`, newSubnet, 1, 0, tenantID, now); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`UPDATE subnet_pool SET status=1, tenant_id=?, updated_at=? WHERE subnet=?`, tenantID, now, newSubnet); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return oldSubnet, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteTenant(id int64) error {
|
func (s *Store) DeleteTenant(id int64) error {
|
||||||
_, err := s.DB.Exec(`DELETE FROM tenants WHERE id=?`, id)
|
_, err := s.DB.Exec(`DELETE FROM tenants WHERE id=?`, id)
|
||||||
return err
|
return err
|
||||||
@@ -680,6 +820,21 @@ func (s *Store) ListEnrollTokens(tenantID int64) ([]EnrollToken, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetEnrollTokenByID returns a single enroll token (used for ownership checks)
|
||||||
|
func (s *Store) GetEnrollTokenByID(id int64) (*EnrollToken, error) {
|
||||||
|
row := s.DB.QueryRow(`SELECT id,tenant_id,token_hash,expires_at,used_at,max_attempt,attempts,status,created_at FROM enroll_tokens WHERE id=?`, id)
|
||||||
|
var et EnrollToken
|
||||||
|
var used sql.NullInt64
|
||||||
|
if err := row.Scan(&et.ID, &et.TenantID, &et.Hash, &et.ExpiresAt, &used, &et.MaxAttempt, &et.Attempts, &et.Status, &et.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if used.Valid {
|
||||||
|
v := used.Int64
|
||||||
|
et.UsedAt = &v
|
||||||
|
}
|
||||||
|
return &et, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateEnrollStatus(id int64, status int) error {
|
func (s *Store) UpdateEnrollStatus(id int64, status int) error {
|
||||||
_, err := s.DB.Exec(`UPDATE enroll_tokens SET status=? WHERE id=?`, status, id)
|
_, err := s.DB.Exec(`UPDATE enroll_tokens SET status=? WHERE id=?`, status, id)
|
||||||
return err
|
return err
|
||||||
|
|||||||
+7
-4
@@ -141,7 +141,7 @@
|
|||||||
<button class="btn2" :disabled="busy" @click="saveSDWAN">保存节点映射</button>
|
<button class="btn2" :disabled="busy" @click="saveSDWAN">保存节点映射</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div v-for="(n,i) in sd.nodes" :key="i" class="grid grid-cols-1 md:grid-cols-5 gap-2">
|
<div v-for="(n,i) in (sd.nodes||[])" :key="i" class="grid grid-cols-1 md:grid-cols-5 gap-2">
|
||||||
<select class="ipt" v-model="n.node">
|
<select class="ipt" v-model="n.node">
|
||||||
<option value="">选择节点</option>
|
<option value="">选择节点</option>
|
||||||
<option v-for="x in nodes" :key="x.name" :value="x.name">{{ (x.alias || x.name) }}(在线)</option>
|
<option v-for="x in nodes" :key="x.name" :value="x.name">{{ (x.alias || x.name) }}(在线)</option>
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
</div>
|
</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="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 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">
|
<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">
|
<select class="ipt" v-model="s.node">
|
||||||
<option value="">选择节点</option>
|
<option value="">选择节点</option>
|
||||||
<option v-for="x in nodes" :key="'sp'+x.name" :value="x.name">{{ x.name }}</option>
|
<option v-for="x in nodes" :key="'sp'+x.name" :value="x.name">{{ x.name }}</option>
|
||||||
@@ -361,10 +361,11 @@ createApp({
|
|||||||
const userForm = ref({ role:'operator', email:'', password:'' });
|
const userForm = ref({ role:'operator', email:'', password:'' });
|
||||||
|
|
||||||
const tokenType = ref('');
|
const tokenType = ref('');
|
||||||
|
const tenantId = ref(0);
|
||||||
const settings = ref({ advanced_impersonate:'0', advanced_force_network:'0', advanced_cross_tenant:'0' });
|
const settings = ref({ advanced_impersonate:'0', advanced_force_network:'0', advanced_cross_tenant:'0' });
|
||||||
const audit = ref([]);
|
const audit = ref([]);
|
||||||
const auditTenant = ref('');
|
const auditTenant = ref('');
|
||||||
const isAdmin = computed(() => role.value === 'admin');
|
const isAdmin = computed(() => role.value === 'admin' && tenantId.value === 1);
|
||||||
const filteredTabs = computed(() => isAdmin.value ? tabs : tabs.filter(t => !['tenants','apikeys','users','enroll','settings','audit'].includes(t.id)));
|
const filteredTabs = computed(() => isAdmin.value ? tabs : tabs.filter(t => !['tenants','apikeys','users','enroll','settings','audit'].includes(t.id)));
|
||||||
const filteredNodes = computed(() => {
|
const filteredNodes = computed(() => {
|
||||||
const k = (nodeKeyword.value || '').trim().toLowerCase();
|
const k = (nodeKeyword.value || '').trim().toLowerCase();
|
||||||
@@ -416,6 +417,7 @@ createApp({
|
|||||||
role.value = d.role || '';
|
role.value = d.role || '';
|
||||||
status.value = d.status ?? 1;
|
status.value = d.status ?? 1;
|
||||||
tokenType.value = d.token_type || 'session';
|
tokenType.value = d.token_type || 'session';
|
||||||
|
tenantId.value = d.tenant_id || 0;
|
||||||
if (status.value !== 1) throw new Error('账号已停用');
|
if (status.value !== 1) throw new Error('账号已停用');
|
||||||
loggedIn.value = true;
|
loggedIn.value = true;
|
||||||
await refreshAll();
|
await refreshAll();
|
||||||
@@ -433,6 +435,7 @@ createApp({
|
|||||||
loggedIn.value = false;
|
loggedIn.value = false;
|
||||||
role.value = '';
|
role.value = '';
|
||||||
tokenType.value = '';
|
tokenType.value = '';
|
||||||
|
tenantId.value = 0;
|
||||||
stopTimer();
|
stopTimer();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -667,7 +670,7 @@ createApp({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buildVersion, tab, filteredTabs, loggedIn, busy, msg, msgType, role, status, tokenType,
|
buildVersion, tab, filteredTabs, loggedIn, busy, msg, msgType, role, status, tokenType, tenantId,
|
||||||
loginUser, loginPass, 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user