fix mostly bugs on system management, and user roles and group assignment
This commit is contained in:
@@ -260,7 +260,18 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
||||
}
|
||||
|
||||
// System Management
|
||||
systemService := system.NewService(log)
|
||||
systemHandler := system.NewHandler(log, tasks.NewEngine(db, log))
|
||||
// Set service in handler (if handler needs direct access)
|
||||
// Note: Handler already has service via NewHandler, but we need to ensure it's the same instance
|
||||
|
||||
// Start network monitoring with RRD
|
||||
if err := systemService.StartNetworkMonitoring(context.Background()); err != nil {
|
||||
log.Warn("Failed to start network monitoring", "error", err)
|
||||
} else {
|
||||
log.Info("Network monitoring started with RRD")
|
||||
}
|
||||
|
||||
systemGroup := protected.Group("/system")
|
||||
systemGroup.Use(requirePermission("system", "read"))
|
||||
{
|
||||
@@ -268,8 +279,11 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
||||
systemGroup.GET("/services/:name", systemHandler.GetServiceStatus)
|
||||
systemGroup.POST("/services/:name/restart", systemHandler.RestartService)
|
||||
systemGroup.GET("/services/:name/logs", systemHandler.GetServiceLogs)
|
||||
systemGroup.GET("/logs", systemHandler.GetSystemLogs)
|
||||
systemGroup.GET("/network/throughput", systemHandler.GetNetworkThroughput)
|
||||
systemGroup.POST("/support-bundle", systemHandler.GenerateSupportBundle)
|
||||
systemGroup.GET("/interfaces", systemHandler.ListNetworkInterfaces)
|
||||
systemGroup.PUT("/interfaces/:name", systemHandler.UpdateNetworkInterface)
|
||||
systemGroup.GET("/ntp", systemHandler.GetNTPSettings)
|
||||
systemGroup.POST("/ntp", systemHandler.SaveNTPSettings)
|
||||
}
|
||||
|
||||
@@ -88,11 +88,14 @@ func GetUserGroups(db *database.DB, userID string) ([]string, error) {
|
||||
for rows.Next() {
|
||||
var groupName string
|
||||
if err := rows.Scan(&groupName); err != nil {
|
||||
return nil, err
|
||||
return []string{}, err
|
||||
}
|
||||
groups = append(groups, groupName)
|
||||
}
|
||||
|
||||
if groups == nil {
|
||||
groups = []string{}
|
||||
}
|
||||
return groups, rows.Err()
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,17 @@ func (h *Handler) ListUsers(c *gin.Context) {
|
||||
permissions, _ := GetUserPermissions(h.db, u.ID)
|
||||
groups, _ := GetUserGroups(h.db, u.ID)
|
||||
|
||||
// Ensure arrays are never nil (use empty slice instead)
|
||||
if roles == nil {
|
||||
roles = []string{}
|
||||
}
|
||||
if permissions == nil {
|
||||
permissions = []string{}
|
||||
}
|
||||
if groups == nil {
|
||||
groups = []string{}
|
||||
}
|
||||
|
||||
users = append(users, map[string]interface{}{
|
||||
"id": u.ID,
|
||||
"username": u.Username,
|
||||
@@ -138,6 +149,17 @@ func (h *Handler) GetUser(c *gin.Context) {
|
||||
permissions, _ := GetUserPermissions(h.db, userID)
|
||||
groups, _ := GetUserGroups(h.db, userID)
|
||||
|
||||
// Ensure arrays are never nil (use empty slice instead)
|
||||
if roles == nil {
|
||||
roles = []string{}
|
||||
}
|
||||
if permissions == nil {
|
||||
permissions = []string{}
|
||||
}
|
||||
if groups == nil {
|
||||
groups = []string{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
@@ -236,6 +258,8 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Allow update if roles or groups are provided, even if no other fields are updated
|
||||
// Note: req.Roles and req.Groups can be empty arrays ([]), which is different from nil
|
||||
// Empty array means "remove all roles/groups", nil means "don't change roles/groups"
|
||||
if len(updates) == 1 && req.Roles == nil && req.Groups == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
return
|
||||
@@ -259,13 +283,14 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
||||
|
||||
// Update roles if provided
|
||||
if req.Roles != nil {
|
||||
h.logger.Info("Updating user roles", "user_id", userID, "roles", *req.Roles)
|
||||
h.logger.Info("Updating user roles", "user_id", userID, "requested_roles", *req.Roles)
|
||||
currentRoles, err := GetUserRoles(h.db, userID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get current roles for user", "user_id", userID, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process user roles"})
|
||||
return
|
||||
}
|
||||
h.logger.Info("Current user roles", "user_id", userID, "current_roles", currentRoles)
|
||||
|
||||
rolesToAdd := []string{}
|
||||
rolesToRemove := []string{}
|
||||
@@ -298,8 +323,15 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Info("Roles to add", "user_id", userID, "roles_to_add", rolesToAdd, "count", len(rolesToAdd))
|
||||
h.logger.Info("Roles to remove", "user_id", userID, "roles_to_remove", rolesToRemove, "count", len(rolesToRemove))
|
||||
|
||||
// Add new roles
|
||||
if len(rolesToAdd) == 0 {
|
||||
h.logger.Info("No roles to add", "user_id", userID)
|
||||
}
|
||||
for _, roleName := range rolesToAdd {
|
||||
h.logger.Info("Processing role to add", "user_id", userID, "role_name", roleName)
|
||||
roleID, err := GetRoleIDByName(h.db, roleName)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -311,12 +343,13 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process roles"})
|
||||
return
|
||||
}
|
||||
h.logger.Info("Attempting to add role", "user_id", userID, "role_id", roleID, "role_name", roleName, "assigned_by", currentUser.ID)
|
||||
if err := AddUserRole(h.db, userID, roleID, currentUser.ID); err != nil {
|
||||
h.logger.Error("Failed to add role to user", "user_id", userID, "role_id", roleID, "error", err)
|
||||
// Don't return early, continue with other roles
|
||||
continue
|
||||
h.logger.Error("Failed to add role to user", "user_id", userID, "role_id", roleID, "role_name", roleName, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to add role '%s': %v", roleName, err)})
|
||||
return
|
||||
}
|
||||
h.logger.Info("Role added to user", "user_id", userID, "role_name", roleName)
|
||||
h.logger.Info("Role successfully added to user", "user_id", userID, "role_id", roleID, "role_name", roleName)
|
||||
}
|
||||
|
||||
// Remove old roles
|
||||
@@ -415,8 +448,48 @@ func (h *Handler) UpdateUser(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Info("User updated", "user_id", userID)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "user updated successfully"})
|
||||
// Fetch updated user data to return
|
||||
updatedUser, err := GetUserByID(h.db, userID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to fetch updated user", "user_id", userID, "error", err)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "user updated successfully"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get updated roles, permissions, and groups
|
||||
updatedRoles, _ := GetUserRoles(h.db, userID)
|
||||
updatedPermissions, _ := GetUserPermissions(h.db, userID)
|
||||
updatedGroups, _ := GetUserGroups(h.db, userID)
|
||||
|
||||
// Ensure arrays are never nil
|
||||
if updatedRoles == nil {
|
||||
updatedRoles = []string{}
|
||||
}
|
||||
if updatedPermissions == nil {
|
||||
updatedPermissions = []string{}
|
||||
}
|
||||
if updatedGroups == nil {
|
||||
updatedGroups = []string{}
|
||||
}
|
||||
|
||||
h.logger.Info("User updated", "user_id", userID, "roles", updatedRoles, "groups", updatedGroups)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "user updated successfully",
|
||||
"user": gin.H{
|
||||
"id": updatedUser.ID,
|
||||
"username": updatedUser.Username,
|
||||
"email": updatedUser.Email,
|
||||
"full_name": updatedUser.FullName,
|
||||
"is_active": updatedUser.IsActive,
|
||||
"is_system": updatedUser.IsSystem,
|
||||
"roles": updatedRoles,
|
||||
"permissions": updatedPermissions,
|
||||
"groups": updatedGroups,
|
||||
"created_at": updatedUser.CreatedAt,
|
||||
"updated_at": updatedUser.UpdatedAt,
|
||||
"last_login_at": updatedUser.LastLoginAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user
|
||||
|
||||
@@ -2,6 +2,7 @@ package iam
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/database"
|
||||
@@ -90,11 +91,14 @@ func GetUserRoles(db *database.DB, userID string) ([]string, error) {
|
||||
for rows.Next() {
|
||||
var role string
|
||||
if err := rows.Scan(&role); err != nil {
|
||||
return nil, err
|
||||
return []string{}, err
|
||||
}
|
||||
roles = append(roles, role)
|
||||
}
|
||||
|
||||
if roles == nil {
|
||||
roles = []string{}
|
||||
}
|
||||
return roles, rows.Err()
|
||||
}
|
||||
|
||||
@@ -118,11 +122,14 @@ func GetUserPermissions(db *database.DB, userID string) ([]string, error) {
|
||||
for rows.Next() {
|
||||
var perm string
|
||||
if err := rows.Scan(&perm); err != nil {
|
||||
return nil, err
|
||||
return []string{}, err
|
||||
}
|
||||
permissions = append(permissions, perm)
|
||||
}
|
||||
|
||||
if permissions == nil {
|
||||
permissions = []string{}
|
||||
}
|
||||
return permissions, rows.Err()
|
||||
}
|
||||
|
||||
@@ -133,8 +140,23 @@ func AddUserRole(db *database.DB, userID, roleID, assignedBy string) error {
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, role_id) DO NOTHING
|
||||
`
|
||||
_, err := db.Exec(query, userID, roleID, assignedBy)
|
||||
return err
|
||||
result, err := db.Exec(query, userID, roleID, assignedBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert user role: %w", err)
|
||||
}
|
||||
|
||||
// Check if row was actually inserted (not just skipped due to conflict)
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
// Row already exists, this is not an error but we should know about it
|
||||
return nil // ON CONFLICT DO NOTHING means this is expected
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveUserRole removes a role from a user
|
||||
|
||||
@@ -173,3 +173,63 @@ func (h *Handler) GetNTPSettings(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"settings": settings})
|
||||
}
|
||||
|
||||
// UpdateNetworkInterface updates a network interface configuration
|
||||
func (h *Handler) UpdateNetworkInterface(c *gin.Context) {
|
||||
ifaceName := c.Param("name")
|
||||
if ifaceName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "interface name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IPAddress string `json:"ip_address" binding:"required"`
|
||||
Subnet string `json:"subnet" binding:"required"`
|
||||
Gateway string `json:"gateway,omitempty"`
|
||||
DNS1 string `json:"dns1,omitempty"`
|
||||
DNS2 string `json:"dns2,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Invalid request body", "error", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to service request
|
||||
serviceReq := UpdateNetworkInterfaceRequest{
|
||||
IPAddress: req.IPAddress,
|
||||
Subnet: req.Subnet,
|
||||
Gateway: req.Gateway,
|
||||
DNS1: req.DNS1,
|
||||
DNS2: req.DNS2,
|
||||
Role: req.Role,
|
||||
}
|
||||
|
||||
updatedIface, err := h.service.UpdateNetworkInterface(c.Request.Context(), ifaceName, serviceReq)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to update network interface", "interface", ifaceName, "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"interface": updatedIface})
|
||||
}
|
||||
|
||||
// GetSystemLogs retrieves recent system logs
|
||||
func (h *Handler) GetSystemLogs(c *gin.Context) {
|
||||
limitStr := c.DefaultQuery("limit", "30")
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit <= 0 || limit > 100 {
|
||||
limit = 30
|
||||
}
|
||||
|
||||
logs, err := h.service.GetSystemLogs(c.Request.Context(), limit)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get system logs", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get system logs"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"logs": logs})
|
||||
}
|
||||
|
||||
292
backend/internal/system/rrd.go
Normal file
292
backend/internal/system/rrd.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
)
|
||||
|
||||
// RRDService handles RRD database operations for network monitoring
|
||||
type RRDService struct {
|
||||
logger *logger.Logger
|
||||
rrdDir string
|
||||
interfaceName string
|
||||
}
|
||||
|
||||
// NewRRDService creates a new RRD service
|
||||
func NewRRDService(log *logger.Logger, rrdDir string, interfaceName string) *RRDService {
|
||||
return &RRDService{
|
||||
logger: log,
|
||||
rrdDir: rrdDir,
|
||||
interfaceName: interfaceName,
|
||||
}
|
||||
}
|
||||
|
||||
// NetworkStats represents network interface statistics
|
||||
type NetworkStats struct {
|
||||
Interface string `json:"interface"`
|
||||
RxBytes uint64 `json:"rx_bytes"`
|
||||
TxBytes uint64 `json:"tx_bytes"`
|
||||
RxPackets uint64 `json:"rx_packets"`
|
||||
TxPackets uint64 `json:"tx_packets"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// GetNetworkStats reads network statistics from /proc/net/dev
|
||||
func (r *RRDService) GetNetworkStats(ctx context.Context, interfaceName string) (*NetworkStats, error) {
|
||||
data, err := os.ReadFile("/proc/net/dev")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read /proc/net/dev: %w", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, interfaceName+":") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse line: interface: rx_bytes rx_packets ... tx_bytes tx_packets ...
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 17 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract statistics
|
||||
// Format: interface: rx_bytes rx_packets rx_errs rx_drop ... tx_bytes tx_packets ...
|
||||
rxBytes, err := strconv.ParseUint(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rxPackets, err := strconv.ParseUint(parts[2], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
txBytes, err := strconv.ParseUint(parts[9], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
txPackets, err := strconv.ParseUint(parts[10], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
return &NetworkStats{
|
||||
Interface: interfaceName,
|
||||
RxBytes: rxBytes,
|
||||
TxBytes: txBytes,
|
||||
RxPackets: rxPackets,
|
||||
TxPackets: txPackets,
|
||||
Timestamp: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("interface %s not found in /proc/net/dev", interfaceName)
|
||||
}
|
||||
|
||||
// InitializeRRD creates RRD database if it doesn't exist
|
||||
func (r *RRDService) InitializeRRD(ctx context.Context) error {
|
||||
// Ensure RRD directory exists
|
||||
if err := os.MkdirAll(r.rrdDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create RRD directory: %w", err)
|
||||
}
|
||||
|
||||
rrdFile := filepath.Join(r.rrdDir, fmt.Sprintf("network-%s.rrd", r.interfaceName))
|
||||
|
||||
// Check if RRD file already exists
|
||||
if _, err := os.Stat(rrdFile); err == nil {
|
||||
r.logger.Info("RRD file already exists", "file", rrdFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create RRD database
|
||||
// Use COUNTER type to track cumulative bytes, RRD will calculate rate automatically
|
||||
// DS:inbound:COUNTER:20:0:U - inbound cumulative bytes, 20s heartbeat
|
||||
// DS:outbound:COUNTER:20:0:U - outbound cumulative bytes, 20s heartbeat
|
||||
// RRA:AVERAGE:0.5:1:600 - 1 sample per step, 600 steps (100 minutes at 10s interval)
|
||||
// RRA:AVERAGE:0.5:6:700 - 6 samples per step, 700 steps (11.6 hours at 1min interval)
|
||||
// RRA:AVERAGE:0.5:60:730 - 60 samples per step, 730 steps (5 days at 1hour interval)
|
||||
// RRA:MAX:0.5:1:600 - Max values for same intervals
|
||||
// RRA:MAX:0.5:6:700
|
||||
// RRA:MAX:0.5:60:730
|
||||
cmd := exec.CommandContext(ctx, "rrdtool", "create", rrdFile,
|
||||
"--step", "10", // 10 second step
|
||||
"DS:inbound:COUNTER:20:0:U", // Inbound cumulative bytes, 20s heartbeat
|
||||
"DS:outbound:COUNTER:20:0:U", // Outbound cumulative bytes, 20s heartbeat
|
||||
"RRA:AVERAGE:0.5:1:600", // 10s resolution, 100 minutes
|
||||
"RRA:AVERAGE:0.5:6:700", // 1min resolution, 11.6 hours
|
||||
"RRA:AVERAGE:0.5:60:730", // 1hour resolution, 5 days
|
||||
"RRA:MAX:0.5:1:600", // Max values
|
||||
"RRA:MAX:0.5:6:700",
|
||||
"RRA:MAX:0.5:60:730",
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create RRD: %s: %w", string(output), err)
|
||||
}
|
||||
|
||||
r.logger.Info("RRD database created", "file", rrdFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateRRD updates RRD database with new network statistics
|
||||
func (r *RRDService) UpdateRRD(ctx context.Context, stats *NetworkStats) error {
|
||||
rrdFile := filepath.Join(r.rrdDir, fmt.Sprintf("network-%s.rrd", stats.Interface))
|
||||
|
||||
// Update with cumulative byte counts (COUNTER type)
|
||||
// RRD will automatically calculate the rate (bytes per second)
|
||||
cmd := exec.CommandContext(ctx, "rrdtool", "update", rrdFile,
|
||||
fmt.Sprintf("%d:%d:%d", stats.Timestamp.Unix(), stats.RxBytes, stats.TxBytes),
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update RRD: %s: %w", string(output), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchRRDData fetches data from RRD database for graphing
|
||||
func (r *RRDService) FetchRRDData(ctx context.Context, startTime time.Time, endTime time.Time, resolution string) ([]NetworkDataPoint, error) {
|
||||
rrdFile := filepath.Join(r.rrdDir, fmt.Sprintf("network-%s.rrd", r.interfaceName))
|
||||
|
||||
// Check if RRD file exists
|
||||
if _, err := os.Stat(rrdFile); os.IsNotExist(err) {
|
||||
return []NetworkDataPoint{}, nil
|
||||
}
|
||||
|
||||
// Fetch data using rrdtool fetch
|
||||
// Use AVERAGE consolidation with appropriate resolution
|
||||
cmd := exec.CommandContext(ctx, "rrdtool", "fetch", rrdFile,
|
||||
"AVERAGE",
|
||||
"--start", fmt.Sprintf("%d", startTime.Unix()),
|
||||
"--end", fmt.Sprintf("%d", endTime.Unix()),
|
||||
"--resolution", resolution,
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch RRD data: %s: %w", string(output), err)
|
||||
}
|
||||
|
||||
// Parse rrdtool fetch output
|
||||
// Format:
|
||||
// inbound outbound
|
||||
// 1234567890: 1.2345678901e+06 2.3456789012e+06
|
||||
points := []NetworkDataPoint{}
|
||||
lines := strings.Split(string(output), "\n")
|
||||
|
||||
// Skip header lines
|
||||
dataStart := false
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is the data section
|
||||
if strings.Contains(line, "inbound") && strings.Contains(line, "outbound") {
|
||||
dataStart = true
|
||||
continue
|
||||
}
|
||||
|
||||
if !dataStart {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse data line: timestamp: inbound_value outbound_value
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse timestamp
|
||||
timestampStr := strings.TrimSuffix(parts[0], ":")
|
||||
timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse inbound (bytes per second from COUNTER, convert to Mbps)
|
||||
inboundStr := parts[1]
|
||||
inbound, err := strconv.ParseFloat(inboundStr, 64)
|
||||
if err != nil || inbound < 0 {
|
||||
// Skip NaN or negative values
|
||||
continue
|
||||
}
|
||||
// Convert bytes per second to Mbps (bytes/s * 8 / 1000000)
|
||||
inboundMbps := inbound * 8 / 1000000
|
||||
|
||||
// Parse outbound
|
||||
outboundStr := parts[2]
|
||||
outbound, err := strconv.ParseFloat(outboundStr, 64)
|
||||
if err != nil || outbound < 0 {
|
||||
// Skip NaN or negative values
|
||||
continue
|
||||
}
|
||||
outboundMbps := outbound * 8 / 1000000
|
||||
|
||||
// Format time as MM:SS
|
||||
t := time.Unix(timestamp, 0)
|
||||
timeStr := fmt.Sprintf("%02d:%02d", t.Minute(), t.Second())
|
||||
|
||||
points = append(points, NetworkDataPoint{
|
||||
Time: timeStr,
|
||||
Inbound: inboundMbps,
|
||||
Outbound: outboundMbps,
|
||||
})
|
||||
}
|
||||
|
||||
return points, nil
|
||||
}
|
||||
|
||||
// NetworkDataPoint represents a single data point for graphing
|
||||
type NetworkDataPoint struct {
|
||||
Time string `json:"time"`
|
||||
Inbound float64 `json:"inbound"` // Mbps
|
||||
Outbound float64 `json:"outbound"` // Mbps
|
||||
}
|
||||
|
||||
// StartCollector starts a background goroutine to periodically collect and update RRD
|
||||
func (r *RRDService) StartCollector(ctx context.Context, interval time.Duration) error {
|
||||
// Initialize RRD if needed
|
||||
if err := r.InitializeRRD(ctx); err != nil {
|
||||
return fmt.Errorf("failed to initialize RRD: %w", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Get current stats
|
||||
stats, err := r.GetNetworkStats(ctx, r.interfaceName)
|
||||
if err != nil {
|
||||
r.logger.Warn("Failed to get network stats", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update RRD with cumulative byte counts
|
||||
// RRD COUNTER type will automatically calculate rate
|
||||
if err := r.UpdateRRD(ctx, stats); err != nil {
|
||||
r.logger.Warn("Failed to update RRD", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -20,16 +20,37 @@ type NTPSettings struct {
|
||||
|
||||
// Service handles system management operations
|
||||
type Service struct {
|
||||
logger *logger.Logger
|
||||
logger *logger.Logger
|
||||
rrdService *RRDService
|
||||
}
|
||||
|
||||
// NewService creates a new system service
|
||||
func NewService(log *logger.Logger) *Service {
|
||||
// Initialize RRD service for network monitoring (default to eth0, can be configured)
|
||||
rrdDir := "/var/lib/calypso/rrd"
|
||||
interfaceName := "eth0" // Default interface, can be made configurable
|
||||
rrdService := NewRRDService(log, rrdDir, interfaceName)
|
||||
|
||||
return &Service{
|
||||
logger: log,
|
||||
logger: log,
|
||||
rrdService: rrdService,
|
||||
}
|
||||
}
|
||||
|
||||
// StartNetworkMonitoring starts the RRD collector for network monitoring
|
||||
func (s *Service) StartNetworkMonitoring(ctx context.Context) error {
|
||||
return s.rrdService.StartCollector(ctx, 10*time.Second)
|
||||
}
|
||||
|
||||
// GetNetworkThroughput fetches network throughput data from RRD
|
||||
func (s *Service) GetNetworkThroughput(ctx context.Context, duration time.Duration) ([]NetworkDataPoint, error) {
|
||||
endTime := time.Now()
|
||||
startTime := endTime.Add(-duration)
|
||||
|
||||
// Use 10 second resolution for recent data
|
||||
return s.rrdService.FetchRRDData(ctx, startTime, endTime, "10")
|
||||
}
|
||||
|
||||
// ServiceStatus represents a systemd service status
|
||||
type ServiceStatus struct {
|
||||
Name string `json:"name"`
|
||||
@@ -42,31 +63,37 @@ type ServiceStatus struct {
|
||||
|
||||
// GetServiceStatus retrieves the status of a systemd service
|
||||
func (s *Service) GetServiceStatus(ctx context.Context, serviceName string) (*ServiceStatus, error) {
|
||||
cmd := exec.CommandContext(ctx, "systemctl", "show", serviceName,
|
||||
"--property=ActiveState,SubState,LoadState,Description,ActiveEnterTimestamp",
|
||||
"--value", "--no-pager")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get service status: %w", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(lines) < 4 {
|
||||
return nil, fmt.Errorf("invalid service status output")
|
||||
}
|
||||
|
||||
status := &ServiceStatus{
|
||||
Name: serviceName,
|
||||
ActiveState: strings.TrimSpace(lines[0]),
|
||||
SubState: strings.TrimSpace(lines[1]),
|
||||
LoadState: strings.TrimSpace(lines[2]),
|
||||
Description: strings.TrimSpace(lines[3]),
|
||||
Name: serviceName,
|
||||
}
|
||||
|
||||
// Parse timestamp if available
|
||||
if len(lines) > 4 && lines[4] != "" {
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", strings.TrimSpace(lines[4])); err == nil {
|
||||
status.Since = t
|
||||
// Get each property individually to ensure correct parsing
|
||||
properties := map[string]*string{
|
||||
"ActiveState": &status.ActiveState,
|
||||
"SubState": &status.SubState,
|
||||
"LoadState": &status.LoadState,
|
||||
"Description": &status.Description,
|
||||
}
|
||||
|
||||
for prop, target := range properties {
|
||||
cmd := exec.CommandContext(ctx, "systemctl", "show", serviceName, "--property", prop, "--value", "--no-pager")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to get property", "service", serviceName, "property", prop, "error", err)
|
||||
continue
|
||||
}
|
||||
*target = strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
// Get timestamp if available
|
||||
cmd := exec.CommandContext(ctx, "systemctl", "show", serviceName, "--property", "ActiveEnterTimestamp", "--value", "--no-pager")
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
timestamp := strings.TrimSpace(string(output))
|
||||
if timestamp != "" {
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", timestamp); err == nil {
|
||||
status.Since = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,10 +103,15 @@ func (s *Service) GetServiceStatus(ctx context.Context, serviceName string) (*Se
|
||||
// ListServices lists all Calypso-related services
|
||||
func (s *Service) ListServices(ctx context.Context) ([]ServiceStatus, error) {
|
||||
services := []string{
|
||||
"ssh",
|
||||
"sshd",
|
||||
"smbd",
|
||||
"iscsi-scst",
|
||||
"nfs-server",
|
||||
"nfs",
|
||||
"mhvtl",
|
||||
"calypso-api",
|
||||
"scst",
|
||||
"iscsi-scst",
|
||||
"mhvtl",
|
||||
"postgresql",
|
||||
}
|
||||
|
||||
@@ -135,6 +167,108 @@ func (s *Service) GetJournalLogs(ctx context.Context, serviceName string, lines
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// SystemLogEntry represents a parsed system log entry
|
||||
type SystemLogEntry struct {
|
||||
Time string `json:"time"`
|
||||
Level string `json:"level"`
|
||||
Source string `json:"source"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// GetSystemLogs retrieves recent system logs from journalctl
|
||||
func (s *Service) GetSystemLogs(ctx context.Context, limit int) ([]SystemLogEntry, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 30 // Default to 30 logs
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "journalctl",
|
||||
"-n", fmt.Sprintf("%d", limit),
|
||||
"-o", "json",
|
||||
"--no-pager",
|
||||
"--since", "1 hour ago") // Only get logs from last hour
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get system logs: %w", err)
|
||||
}
|
||||
|
||||
var logs []SystemLogEntry
|
||||
linesOutput := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
for _, line := range linesOutput {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var logEntry map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(line), &logEntry); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse timestamp (__REALTIME_TIMESTAMP is in microseconds)
|
||||
var timeStr string
|
||||
if timestamp, ok := logEntry["__REALTIME_TIMESTAMP"].(float64); ok {
|
||||
// Convert microseconds to nanoseconds for time.Unix (1 microsecond = 1000 nanoseconds)
|
||||
t := time.Unix(0, int64(timestamp)*1000)
|
||||
timeStr = t.Format("15:04:05")
|
||||
} else if timestamp, ok := logEntry["_SOURCE_REALTIME_TIMESTAMP"].(float64); ok {
|
||||
t := time.Unix(0, int64(timestamp)*1000)
|
||||
timeStr = t.Format("15:04:05")
|
||||
} else {
|
||||
timeStr = time.Now().Format("15:04:05")
|
||||
}
|
||||
|
||||
// Parse log level (priority)
|
||||
level := "INFO"
|
||||
if priority, ok := logEntry["PRIORITY"].(float64); ok {
|
||||
switch int(priority) {
|
||||
case 0: // emerg
|
||||
level = "EMERG"
|
||||
case 1, 2, 3: // alert, crit, err
|
||||
level = "ERROR"
|
||||
case 4: // warning
|
||||
level = "WARN"
|
||||
case 5: // notice
|
||||
level = "NOTICE"
|
||||
case 6: // info
|
||||
level = "INFO"
|
||||
case 7: // debug
|
||||
level = "DEBUG"
|
||||
}
|
||||
}
|
||||
|
||||
// Parse source (systemd unit or syslog identifier)
|
||||
source := "system"
|
||||
if unit, ok := logEntry["_SYSTEMD_UNIT"].(string); ok && unit != "" {
|
||||
// Remove .service suffix if present
|
||||
source = strings.TrimSuffix(unit, ".service")
|
||||
} else if ident, ok := logEntry["SYSLOG_IDENTIFIER"].(string); ok && ident != "" {
|
||||
source = ident
|
||||
} else if comm, ok := logEntry["_COMM"].(string); ok && comm != "" {
|
||||
source = comm
|
||||
}
|
||||
|
||||
// Parse message
|
||||
message := ""
|
||||
if msg, ok := logEntry["MESSAGE"].(string); ok {
|
||||
message = msg
|
||||
}
|
||||
|
||||
if message != "" {
|
||||
logs = append(logs, SystemLogEntry{
|
||||
Time: timeStr,
|
||||
Level: level,
|
||||
Source: source,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse to get newest first
|
||||
for i, j := 0, len(logs)-1; i < j; i, j = i+1, j-1 {
|
||||
logs[i], logs[j] = logs[j], logs[i]
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// GenerateSupportBundle generates a diagnostic support bundle
|
||||
func (s *Service) GenerateSupportBundle(ctx context.Context, outputPath string) error {
|
||||
// Create bundle directory
|
||||
@@ -314,33 +448,57 @@ func (s *Service) ListNetworkInterfaces(ctx context.Context) ([]NetworkInterface
|
||||
lines = strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse default route: "default via 10.10.14.1 dev ens18"
|
||||
if strings.HasPrefix(line, "default via ") {
|
||||
// Format: "default via 192.168.1.1 dev ens18"
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 4 && parts[2] == "dev" {
|
||||
gateway := parts[1]
|
||||
ifaceName := parts[3]
|
||||
// Find "via" and "dev" in the parts
|
||||
var gateway string
|
||||
var ifaceName string
|
||||
for i, part := range parts {
|
||||
if part == "via" && i+1 < len(parts) {
|
||||
gateway = parts[i+1]
|
||||
}
|
||||
if part == "dev" && i+1 < len(parts) {
|
||||
ifaceName = parts[i+1]
|
||||
}
|
||||
}
|
||||
if gateway != "" && ifaceName != "" {
|
||||
if iface, exists := interfaceMap[ifaceName]; exists {
|
||||
iface.Gateway = gateway
|
||||
s.logger.Debug("Set gateway for interface", "name", ifaceName, "gateway", gateway)
|
||||
s.logger.Info("Set default gateway for interface", "name", ifaceName, "gateway", gateway)
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(line, " via ") && strings.Contains(line, " dev ") {
|
||||
// Format: "10.10.14.0/24 via 10.10.14.1 dev ens18"
|
||||
// Parse network route: "10.10.14.0/24 via 10.10.14.1 dev ens18"
|
||||
// Or: "192.168.1.0/24 via 192.168.1.1 dev eth0"
|
||||
parts := strings.Fields(line)
|
||||
var gateway string
|
||||
var ifaceName string
|
||||
for i, part := range parts {
|
||||
if part == "via" && i+1 < len(parts) && i+2 < len(parts) && parts[i+2] == "dev" {
|
||||
gateway := parts[i+1]
|
||||
ifaceName := parts[i+3]
|
||||
if iface, exists := interfaceMap[ifaceName]; exists {
|
||||
if part == "via" && i+1 < len(parts) {
|
||||
gateway = parts[i+1]
|
||||
}
|
||||
if part == "dev" && i+1 < len(parts) {
|
||||
ifaceName = parts[i+1]
|
||||
}
|
||||
}
|
||||
// Only set gateway if it's not already set (prefer default route)
|
||||
if gateway != "" && ifaceName != "" {
|
||||
if iface, exists := interfaceMap[ifaceName]; exists {
|
||||
if iface.Gateway == "" {
|
||||
iface.Gateway = gateway
|
||||
s.logger.Debug("Set gateway for interface", "name", ifaceName, "gateway", gateway)
|
||||
s.logger.Info("Set gateway from network route for interface", "name", ifaceName, "gateway", gateway)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s.logger.Warn("Failed to get routes", "error", err)
|
||||
}
|
||||
|
||||
// Get DNS servers from systemd-resolved or /etc/resolv.conf
|
||||
@@ -437,6 +595,123 @@ func (s *Service) ListNetworkInterfaces(ctx context.Context) ([]NetworkInterface
|
||||
return interfaces, nil
|
||||
}
|
||||
|
||||
// UpdateNetworkInterfaceRequest represents the request to update a network interface
|
||||
type UpdateNetworkInterfaceRequest struct {
|
||||
IPAddress string `json:"ip_address"`
|
||||
Subnet string `json:"subnet"`
|
||||
Gateway string `json:"gateway,omitempty"`
|
||||
DNS1 string `json:"dns1,omitempty"`
|
||||
DNS2 string `json:"dns2,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateNetworkInterface updates network interface configuration
|
||||
func (s *Service) UpdateNetworkInterface(ctx context.Context, ifaceName string, req UpdateNetworkInterfaceRequest) (*NetworkInterface, error) {
|
||||
// Validate interface exists
|
||||
cmd := exec.CommandContext(ctx, "ip", "link", "show", ifaceName)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("interface %s not found: %w", ifaceName, err)
|
||||
}
|
||||
|
||||
// Remove existing IP address if any
|
||||
cmd = exec.CommandContext(ctx, "ip", "addr", "flush", "dev", ifaceName)
|
||||
cmd.Run() // Ignore error, interface might not have IP
|
||||
|
||||
// Set new IP address and subnet
|
||||
ipWithSubnet := fmt.Sprintf("%s/%s", req.IPAddress, req.Subnet)
|
||||
cmd = exec.CommandContext(ctx, "ip", "addr", "add", ipWithSubnet, "dev", ifaceName)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to set IP address", "interface", ifaceName, "error", err, "output", string(output))
|
||||
return nil, fmt.Errorf("failed to set IP address: %w", err)
|
||||
}
|
||||
|
||||
// Remove existing default route if any
|
||||
cmd = exec.CommandContext(ctx, "ip", "route", "del", "default")
|
||||
cmd.Run() // Ignore error, might not exist
|
||||
|
||||
// Set gateway if provided
|
||||
if req.Gateway != "" {
|
||||
cmd = exec.CommandContext(ctx, "ip", "route", "add", "default", "via", req.Gateway, "dev", ifaceName)
|
||||
output, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to set gateway", "interface", ifaceName, "error", err, "output", string(output))
|
||||
return nil, fmt.Errorf("failed to set gateway: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update DNS in systemd-resolved or /etc/resolv.conf
|
||||
if req.DNS1 != "" || req.DNS2 != "" {
|
||||
// Try using systemd-resolve first
|
||||
cmd = exec.CommandContext(ctx, "systemd-resolve", "--status")
|
||||
if cmd.Run() == nil {
|
||||
// systemd-resolve is available, use it
|
||||
dnsServers := []string{}
|
||||
if req.DNS1 != "" {
|
||||
dnsServers = append(dnsServers, req.DNS1)
|
||||
}
|
||||
if req.DNS2 != "" {
|
||||
dnsServers = append(dnsServers, req.DNS2)
|
||||
}
|
||||
if len(dnsServers) > 0 {
|
||||
// Use resolvectl to set DNS (newer systemd)
|
||||
cmd = exec.CommandContext(ctx, "resolvectl", "dns", ifaceName, strings.Join(dnsServers, " "))
|
||||
if cmd.Run() != nil {
|
||||
// Fallback to systemd-resolve
|
||||
cmd = exec.CommandContext(ctx, "systemd-resolve", "--interface", ifaceName, "--set-dns", strings.Join(dnsServers, " "))
|
||||
output, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to set DNS via systemd-resolve", "error", err, "output", string(output))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: update /etc/resolv.conf
|
||||
resolvContent := "# Generated by Calypso\n"
|
||||
if req.DNS1 != "" {
|
||||
resolvContent += fmt.Sprintf("nameserver %s\n", req.DNS1)
|
||||
}
|
||||
if req.DNS2 != "" {
|
||||
resolvContent += fmt.Sprintf("nameserver %s\n", req.DNS2)
|
||||
}
|
||||
|
||||
tmpPath := "/tmp/resolv.conf." + fmt.Sprintf("%d", time.Now().Unix())
|
||||
if err := os.WriteFile(tmpPath, []byte(resolvContent), 0644); err != nil {
|
||||
s.logger.Warn("Failed to write temporary resolv.conf", "error", err)
|
||||
} else {
|
||||
cmd = exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("mv %s /etc/resolv.conf", tmpPath))
|
||||
output, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to update /etc/resolv.conf", "error", err, "output", string(output))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bring interface up
|
||||
cmd = exec.CommandContext(ctx, "ip", "link", "set", ifaceName, "up")
|
||||
output, err = cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to bring interface up", "interface", ifaceName, "error", err, "output", string(output))
|
||||
}
|
||||
|
||||
// Return updated interface
|
||||
updatedIface := &NetworkInterface{
|
||||
Name: ifaceName,
|
||||
IPAddress: req.IPAddress,
|
||||
Subnet: req.Subnet,
|
||||
Gateway: req.Gateway,
|
||||
DNS1: req.DNS1,
|
||||
DNS2: req.DNS2,
|
||||
Role: req.Role,
|
||||
Status: "Connected",
|
||||
Speed: "Unknown", // Will be updated on next list
|
||||
}
|
||||
|
||||
s.logger.Info("Updated network interface", "interface", ifaceName, "ip", req.IPAddress, "subnet", req.Subnet)
|
||||
return updatedIface, nil
|
||||
}
|
||||
|
||||
// SaveNTPSettings saves NTP configuration to the OS
|
||||
func (s *Service) SaveNTPSettings(ctx context.Context, settings NTPSettings) error {
|
||||
// Set timezone using timedatectl
|
||||
|
||||
Reference in New Issue
Block a user