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 } // NewService creates a new system service func NewService(log *logger.Logger) *Service { return &Service{ logger: log, } } // 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) { 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]), } // 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 } } return status, nil } // ListServices lists all Calypso-related services func (s *Service) ListServices(ctx context.Context) ([]ServiceStatus, error) { services := []string{ "calypso-api", "scst", "iscsi-scst", "mhvtl", "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 } // 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 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] if iface, exists := interfaceMap[ifaceName]; exists { iface.Gateway = gateway s.logger.Debug("Set 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" parts := strings.Fields(line) 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 { iface.Gateway = gateway s.logger.Debug("Set gateway for interface", "name", ifaceName, "gateway", gateway) } break } } } } } // 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 } // 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 }