Files
calypso/backend/internal/system/service.go
2025-12-29 20:43:34 +07:00

546 lines
17 KiB
Go

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: <BROADCAST,MULTICAST,UP,LOWER_UP> 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: <BROADCAST,MULTICAST,UP,LOWER_UP>")
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
}