821 lines
26 KiB
Go
821 lines
26 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
|
|
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,
|
|
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: <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 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
|
|
}
|
|
|
|
// 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
|
|
}
|