package system import ( "context" "encoding/json" "fmt" "os" "os/exec" "strings" "time" "github.com/atlasos/calypso/internal/common/logger" ) // NTPSettings represents NTP configuration type NTPSettings struct { Timezone string `json:"timezone"` NTPServers []string `json:"ntp_servers"` } // Service handles system management operations type Service struct { logger *logger.Logger rrdService *RRDService } // detectPrimaryInterface detects the primary network interface (first non-loopback with IP) func detectPrimaryInterface(ctx context.Context) string { // Try to get default route interface cmd := exec.CommandContext(ctx, "ip", "route", "show", "default") output, err := cmd.Output() if err == nil { lines := strings.Split(string(output), "\n") for _, line := range lines { if strings.Contains(line, "dev ") { parts := strings.Fields(line) for i, part := range parts { if part == "dev" && i+1 < len(parts) { iface := parts[i+1] if iface != "lo" { return iface } } } } } } // Fallback: get first non-loopback interface with IP cmd = exec.CommandContext(ctx, "ip", "-4", "addr", "show") output, err = cmd.Output() if err == nil { lines := strings.Split(string(output), "\n") for _, line := range lines { line = strings.TrimSpace(line) // Look for interface name line (e.g., "2: ens18: 0 && line[0] >= '0' && line[0] <= '9' && strings.Contains(line, ":") { parts := strings.Fields(line) if len(parts) >= 2 { iface := strings.TrimSuffix(parts[1], ":") if iface != "" && iface != "lo" { // Check if this interface has an IP (next lines will have "inet") // For simplicity, return first non-loopback interface return iface } } } } } // Final fallback return "eth0" } // NewService creates a new system service func NewService(log *logger.Logger) *Service { // Initialize RRD service for network monitoring rrdDir := "/var/lib/calypso/rrd" // Auto-detect primary interface ctx := context.Background() interfaceName := detectPrimaryInterface(ctx) log.Info("Detected primary network interface", "interface", interfaceName) rrdService := NewRRDService(log, rrdDir, interfaceName) return &Service{ 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"` ActiveState string `json:"active_state"` SubState string `json:"sub_state"` LoadState string `json:"load_state"` Description string `json:"description"` Since time.Time `json:"since,omitempty"` } // GetServiceStatus retrieves the status of a systemd service func (s *Service) GetServiceStatus(ctx context.Context, serviceName string) (*ServiceStatus, error) { status := &ServiceStatus{ Name: serviceName, } // 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 } } } return status, nil } // 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", "postgresql", } var statuses []ServiceStatus for _, serviceName := range services { status, err := s.GetServiceStatus(ctx, serviceName) if err != nil { s.logger.Warn("Failed to get service status", "service", serviceName, "error", err) continue } statuses = append(statuses, *status) } return statuses, nil } // RestartService restarts a systemd service func (s *Service) RestartService(ctx context.Context, serviceName string) error { cmd := exec.CommandContext(ctx, "systemctl", "restart", serviceName) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to restart service: %s: %w", string(output), err) } s.logger.Info("Service restarted", "service", serviceName) return nil } // GetJournalLogs retrieves journald logs for a service func (s *Service) GetJournalLogs(ctx context.Context, serviceName string, lines int) ([]map[string]interface{}, error) { cmd := exec.CommandContext(ctx, "journalctl", "-u", serviceName, "-n", fmt.Sprintf("%d", lines), "-o", "json", "--no-pager") output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to get logs: %w", err) } var logs []map[string]interface{} 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 { logs = append(logs, logEntry) } } 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 cmd := exec.CommandContext(ctx, "mkdir", "-p", outputPath) if err := cmd.Run(); err != nil { return fmt.Errorf("failed to create bundle directory: %w", err) } // Collect system information commands := map[string][]string{ "system_info": {"uname", "-a"}, "disk_usage": {"df", "-h"}, "memory": {"free", "-h"}, "scst_status": {"scstadmin", "-list_target"}, "services": {"systemctl", "list-units", "--type=service", "--state=running"}, } for name, cmdArgs := range commands { cmd := exec.CommandContext(ctx, cmdArgs[0], cmdArgs[1:]...) output, err := cmd.CombinedOutput() if err != nil { s.logger.Warn("Failed to collect info", "command", name, "error", err) continue } // Write to file filePath := fmt.Sprintf("%s/%s.txt", outputPath, name) if err := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("cat > %s", filePath)).Run(); err == nil { exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("echo '%s' > %s", string(output), filePath)).Run() } } // Collect journal logs services := []string{"calypso-api", "scst", "iscsi-scst"} for _, service := range services { cmd := exec.CommandContext(ctx, "journalctl", "-u", service, "-n", "1000", "--no-pager") output, err := cmd.CombinedOutput() if err == nil { filePath := fmt.Sprintf("%s/journal_%s.log", outputPath, service) exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("echo '%s' > %s", string(output), filePath)).Run() } } s.logger.Info("Support bundle generated", "path", outputPath) return nil } // NetworkInterface represents a network interface type NetworkInterface struct { Name string `json:"name"` IPAddress string `json:"ip_address"` Subnet string `json:"subnet"` Status string `json:"status"` // "Connected" or "Down" Speed string `json:"speed"` // e.g., "10 Gbps", "1 Gbps" Role string `json:"role"` // "Management", "ISCSI", or empty Gateway string `json:"gateway,omitempty"` DNS1 string `json:"dns1,omitempty"` DNS2 string `json:"dns2,omitempty"` } // ListNetworkInterfaces lists all network interfaces func (s *Service) ListNetworkInterfaces(ctx context.Context) ([]NetworkInterface, error) { // First, get all interface names and their states cmd := exec.CommandContext(ctx, "ip", "link", "show") output, err := cmd.Output() if err != nil { s.logger.Error("Failed to list interfaces", "error", err) return nil, fmt.Errorf("failed to list interfaces: %w", err) } interfaceMap := make(map[string]*NetworkInterface) lines := strings.Split(string(output), "\n") s.logger.Debug("Parsing network interfaces", "output_lines", len(lines)) for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } // Parse interface name and state // Format: "2: ens18: mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000" // Look for lines that start with a number followed by ":" (interface definition line) // Simple check: line starts with digit, contains ":", and contains "state" if len(line) > 0 && line[0] >= '0' && line[0] <= '9' && strings.Contains(line, ":") && strings.Contains(line, "state") { parts := strings.Fields(line) if len(parts) < 2 { continue } // Extract interface name (e.g., "ens18:" or "lo:") ifaceName := strings.TrimSuffix(parts[1], ":") if ifaceName == "" || ifaceName == "lo" { continue // Skip loopback } // Extract state - look for "state UP" or "state DOWN" in the line state := "Down" if strings.Contains(line, "state UP") { state = "Connected" } else if strings.Contains(line, "state DOWN") { state = "Down" } s.logger.Info("Found interface", "name", ifaceName, "state", state) interfaceMap[ifaceName] = &NetworkInterface{ Name: ifaceName, Status: state, Speed: "Unknown", } } } s.logger.Debug("Found interfaces from ip link", "count", len(interfaceMap)) // Get IP addresses for each interface cmd = exec.CommandContext(ctx, "ip", "-4", "addr", "show") output, err = cmd.Output() if err != nil { s.logger.Warn("Failed to get IP addresses", "error", err) } else { lines = strings.Split(string(output), "\n") var currentIfaceName string for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } // Parse interface name (e.g., "2: ens18: ") if strings.Contains(line, ":") && !strings.Contains(line, "inet") && !strings.HasPrefix(line, "valid_lft") && !strings.HasPrefix(line, "altname") { parts := strings.Fields(line) if len(parts) >= 2 { currentIfaceName = strings.TrimSuffix(parts[1], ":") s.logger.Debug("Processing interface for IP", "name", currentIfaceName) } continue } // Parse IP address (e.g., "inet 10.10.14.16/24 brd 10.10.14.255 scope global ens18") if strings.HasPrefix(line, "inet ") && currentIfaceName != "" && currentIfaceName != "lo" { parts := strings.Fields(line) if len(parts) >= 2 { ipWithSubnet := parts[1] // e.g., "10.10.14.16/24" ipParts := strings.Split(ipWithSubnet, "/") if len(ipParts) == 2 { ip := ipParts[0] subnet := ipParts[1] // Find or create interface iface, exists := interfaceMap[currentIfaceName] if !exists { s.logger.Debug("Creating new interface entry", "name", currentIfaceName) iface = &NetworkInterface{ Name: currentIfaceName, Status: "Down", Speed: "Unknown", } interfaceMap[currentIfaceName] = iface } iface.IPAddress = ip iface.Subnet = subnet s.logger.Debug("Set IP for interface", "name", currentIfaceName, "ip", ip, "subnet", subnet) } } } } } // Get default gateway for each interface cmd = exec.CommandContext(ctx, "ip", "route", "show") output, err = cmd.Output() if err == nil { 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 ") { parts := strings.Fields(line) // 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.Info("Set default gateway for interface", "name", ifaceName, "gateway", gateway) } } } else if strings.Contains(line, " via ") && strings.Contains(line, " dev ") { // 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) { 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.Info("Set gateway from network route for interface", "name", ifaceName, "gateway", gateway) } } } } } } else { s.logger.Warn("Failed to get routes", "error", err) } // Get DNS servers from systemd-resolved or /etc/resolv.conf // Try systemd-resolved first cmd = exec.CommandContext(ctx, "systemd-resolve", "--status") output, err = cmd.Output() dnsServers := []string{} if err == nil { // Parse DNS from systemd-resolve output lines = strings.Split(string(output), "\n") for _, line := range lines { line = strings.TrimSpace(line) if strings.HasPrefix(line, "DNS Servers:") { // Format: "DNS Servers: 8.8.8.8 8.8.4.4" parts := strings.Fields(line) if len(parts) >= 3 { dnsServers = parts[2:] } break } } } else { // Fallback to /etc/resolv.conf data, err := os.ReadFile("/etc/resolv.conf") if err == nil { lines = strings.Split(string(data), "\n") for _, line := range lines { line = strings.TrimSpace(line) if strings.HasPrefix(line, "nameserver ") { dns := strings.TrimPrefix(line, "nameserver ") dns = strings.TrimSpace(dns) if dns != "" { dnsServers = append(dnsServers, dns) } } } } } // Convert map to slice var interfaces []NetworkInterface s.logger.Debug("Converting interface map to slice", "map_size", len(interfaceMap)) for _, iface := range interfaceMap { // Get speed for each interface using ethtool if iface.Name != "" && iface.Name != "lo" { cmd := exec.CommandContext(ctx, "ethtool", iface.Name) output, err := cmd.Output() if err == nil { // Parse speed from ethtool output ethtoolLines := strings.Split(string(output), "\n") for _, ethtoolLine := range ethtoolLines { if strings.Contains(ethtoolLine, "Speed:") { parts := strings.Fields(ethtoolLine) if len(parts) >= 2 { iface.Speed = parts[1] } break } } } // Set DNS servers (use first two if available) if len(dnsServers) > 0 { iface.DNS1 = dnsServers[0] } if len(dnsServers) > 1 { iface.DNS2 = dnsServers[1] } // Determine role based on interface name or IP (simple heuristic) // You can enhance this with configuration file or database lookup if strings.Contains(iface.Name, "eth") || strings.Contains(iface.Name, "ens") { // Default to Management for first interface, ISCSI for others if iface.Name == "eth0" || iface.Name == "ens18" { iface.Role = "Management" } else { // Check if IP is in typical iSCSI range (10.x.x.x) if strings.HasPrefix(iface.IPAddress, "10.") && iface.IPAddress != "" { iface.Role = "ISCSI" } } } } interfaces = append(interfaces, *iface) } // If no interfaces found, return empty slice if len(interfaces) == 0 { s.logger.Warn("No network interfaces found") return []NetworkInterface{}, nil } s.logger.Info("Listed network interfaces", "count", len(interfaces)) return interfaces, nil } // GetManagementIPAddress returns the IP address of the management interface func (s *Service) GetManagementIPAddress(ctx context.Context) (string, error) { interfaces, err := s.ListNetworkInterfaces(ctx) if err != nil { return "", fmt.Errorf("failed to list network interfaces: %w", err) } // First, try to find interface with Role "Management" for _, iface := range interfaces { if iface.Role == "Management" && iface.IPAddress != "" && iface.Status == "Connected" { s.logger.Info("Found management interface", "interface", iface.Name, "ip", iface.IPAddress) return iface.IPAddress, nil } } // Fallback: use interface with default route (primary interface) for _, iface := range interfaces { if iface.Gateway != "" && iface.IPAddress != "" && iface.Status == "Connected" { s.logger.Info("Using primary interface as management", "interface", iface.Name, "ip", iface.IPAddress) return iface.IPAddress, nil } } // Final fallback: use first connected interface with IP for _, iface := range interfaces { if iface.IPAddress != "" && iface.Status == "Connected" && iface.Name != "lo" { s.logger.Info("Using first connected interface as management", "interface", iface.Name, "ip", iface.IPAddress) return iface.IPAddress, nil } } return "", fmt.Errorf("no management interface found") } // 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 if settings.Timezone != "" { cmd := exec.CommandContext(ctx, "timedatectl", "set-timezone", settings.Timezone) output, err := cmd.CombinedOutput() if err != nil { s.logger.Error("Failed to set timezone", "timezone", settings.Timezone, "error", err, "output", string(output)) return fmt.Errorf("failed to set timezone: %w", err) } s.logger.Info("Timezone set", "timezone", settings.Timezone) } // Configure NTP servers in systemd-timesyncd if len(settings.NTPServers) > 0 { configPath := "/etc/systemd/timesyncd.conf" // Build config content configContent := "[Time]\n" configContent += "NTP=" for i, server := range settings.NTPServers { if i > 0 { configContent += " " } configContent += server } configContent += "\n" // Write to temporary file first, then move to final location (requires root) tmpPath := "/tmp/timesyncd.conf." + fmt.Sprintf("%d", time.Now().Unix()) if err := os.WriteFile(tmpPath, []byte(configContent), 0644); err != nil { s.logger.Error("Failed to write temporary NTP config", "error", err) return fmt.Errorf("failed to write temporary NTP configuration: %w", err) } // Move to final location using sudo (requires root privileges) cmd := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("mv %s %s", tmpPath, configPath)) output, err := cmd.CombinedOutput() if err != nil { s.logger.Error("Failed to move NTP config", "error", err, "output", string(output)) os.Remove(tmpPath) // Clean up temp file return fmt.Errorf("failed to move NTP configuration: %w", err) } // Restart systemd-timesyncd to apply changes cmd = exec.CommandContext(ctx, "systemctl", "restart", "systemd-timesyncd") output, err = cmd.CombinedOutput() if err != nil { s.logger.Error("Failed to restart systemd-timesyncd", "error", err, "output", string(output)) return fmt.Errorf("failed to restart systemd-timesyncd: %w", err) } s.logger.Info("NTP servers configured", "servers", settings.NTPServers) } return nil } // GetNTPSettings retrieves current NTP configuration from the OS func (s *Service) GetNTPSettings(ctx context.Context) (*NTPSettings, error) { settings := &NTPSettings{ NTPServers: []string{}, } // Get current timezone using timedatectl cmd := exec.CommandContext(ctx, "timedatectl", "show", "--property=Timezone", "--value") output, err := cmd.Output() if err != nil { s.logger.Warn("Failed to get timezone", "error", err) settings.Timezone = "Etc/UTC" // Default fallback } else { settings.Timezone = strings.TrimSpace(string(output)) if settings.Timezone == "" { settings.Timezone = "Etc/UTC" } } // Read NTP servers from systemd-timesyncd config configPath := "/etc/systemd/timesyncd.conf" data, err := os.ReadFile(configPath) if err != nil { s.logger.Warn("Failed to read NTP config", "error", err) // Default NTP servers if config file doesn't exist settings.NTPServers = []string{"pool.ntp.org", "time.google.com"} } else { // Parse NTP servers from config file lines := strings.Split(string(data), "\n") for _, line := range lines { line = strings.TrimSpace(line) if strings.HasPrefix(line, "NTP=") { ntpLine := strings.TrimPrefix(line, "NTP=") if ntpLine != "" { servers := strings.Fields(ntpLine) settings.NTPServers = servers break } } } // If no NTP servers found in config, use defaults if len(settings.NTPServers) == 0 { settings.NTPServers = []string{"pool.ntp.org", "time.google.com"} } } return settings, nil } // ExecuteCommand executes a shell command and returns the output // service parameter is optional and can be: system, scst, storage, backup, tape func (s *Service) ExecuteCommand(ctx context.Context, command string, service string) (string, error) { // Sanitize command - basic security check command = strings.TrimSpace(command) if command == "" { return "", fmt.Errorf("command cannot be empty") } // Block dangerous commands that could harm the system dangerousCommands := []string{ "rm -rf /", "dd if=", ":(){ :|:& };:", "mkfs", "fdisk", "parted", "format", "> /dev/sd", "mkfs.ext", "mkfs.xfs", "mkfs.btrfs", "wipefs", } commandLower := strings.ToLower(command) for _, dangerous := range dangerousCommands { if strings.Contains(commandLower, dangerous) { return "", fmt.Errorf("command blocked for security reasons") } } // Service-specific command handling switch service { case "scst": // Allow SCST admin commands if strings.HasPrefix(command, "scstadmin") { // SCST commands are safe break } case "backup": // Allow bconsole commands if strings.HasPrefix(command, "bconsole") { // Backup console commands are safe break } case "storage": // Allow ZFS and storage commands if strings.HasPrefix(command, "zfs") || strings.HasPrefix(command, "zpool") || strings.HasPrefix(command, "lsblk") { // Storage commands are safe break } case "tape": // Allow tape library commands if strings.HasPrefix(command, "mtx") || strings.HasPrefix(command, "lsscsi") || strings.HasPrefix(command, "sg_") { // Tape commands are safe break } } // Execute command with timeout (30 seconds) ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() // Check if command already has sudo (reuse commandLower from above) hasSudo := strings.HasPrefix(commandLower, "sudo ") // Determine if command needs sudo based on service and command type needsSudo := false if !hasSudo { // Commands that typically need sudo sudoCommands := []string{ "scstadmin", "systemctl", "zfs", "zpool", "mount", "umount", "ip link", "ip addr", "iptables", "journalctl", } for _, sudoCmd := range sudoCommands { if strings.HasPrefix(commandLower, sudoCmd) { needsSudo = true break } } // Service-specific sudo requirements switch service { case "scst": // All SCST admin commands need sudo if strings.HasPrefix(commandLower, "scstadmin") { needsSudo = true } case "storage": // ZFS commands typically need sudo if strings.HasPrefix(commandLower, "zfs") || strings.HasPrefix(commandLower, "zpool") { needsSudo = true } case "system": // System commands like systemctl need sudo if strings.HasPrefix(commandLower, "systemctl") || strings.HasPrefix(commandLower, "journalctl") { needsSudo = true } } } // Build command with or without sudo var cmd *exec.Cmd if needsSudo && !hasSudo { // Use sudo for privileged commands (if not already present) cmd = exec.CommandContext(ctx, "sudo", "sh", "-c", command) } else { // Regular command (or already has sudo) cmd = exec.CommandContext(ctx, "sh", "-c", command) } cmd.Env = append(os.Environ(), "TERM=xterm-256color") cmd.Env = append(os.Environ(), "TERM=xterm-256color") output, err := cmd.CombinedOutput() if err != nil { // Return output even if there's an error (some commands return non-zero exit codes) outputStr := string(output) if len(outputStr) > 0 { return outputStr, nil } return "", fmt.Errorf("command execution failed: %w", err) } return string(output), nil }