diff --git a/backend/bin/calypso-api b/backend/bin/calypso-api index 41bd1bc..4a4737a 100755 Binary files a/backend/bin/calypso-api and b/backend/bin/calypso-api differ diff --git a/backend/internal/common/router/router.go b/backend/internal/common/router/router.go index d963935..fd31ec7 100644 --- a/backend/internal/common/router/router.go +++ b/backend/internal/common/router/router.go @@ -270,6 +270,8 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng systemGroup.GET("/services/:name/logs", systemHandler.GetServiceLogs) systemGroup.POST("/support-bundle", systemHandler.GenerateSupportBundle) systemGroup.GET("/interfaces", systemHandler.ListNetworkInterfaces) + systemGroup.GET("/ntp", systemHandler.GetNTPSettings) + systemGroup.POST("/ntp", systemHandler.SaveNTPSettings) } // IAM routes - GetUser can be accessed by user viewing own profile or admin diff --git a/backend/internal/system/handler.go b/backend/internal/system/handler.go index 04fd9bd..edb84f4 100644 --- a/backend/internal/system/handler.go +++ b/backend/internal/system/handler.go @@ -131,3 +131,45 @@ func (h *Handler) ListNetworkInterfaces(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"interfaces": interfaces}) } + +// SaveNTPSettings saves NTP configuration to the OS +func (h *Handler) SaveNTPSettings(c *gin.Context) { + var settings NTPSettings + if err := c.ShouldBindJSON(&settings); err != nil { + h.logger.Error("Invalid request body", "error", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + // Validate timezone + if settings.Timezone == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "timezone is required"}) + return + } + + // Validate NTP servers + if len(settings.NTPServers) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "at least one NTP server is required"}) + return + } + + if err := h.service.SaveNTPSettings(c.Request.Context(), settings); err != nil { + h.logger.Error("Failed to save NTP settings", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "NTP settings saved successfully"}) +} + +// GetNTPSettings retrieves current NTP configuration +func (h *Handler) GetNTPSettings(c *gin.Context) { + settings, err := h.service.GetNTPSettings(c.Request.Context()) + if err != nil { + h.logger.Error("Failed to get NTP settings", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get NTP settings"}) + return + } + + c.JSON(http.StatusOK, gin.H{"settings": settings}) +} diff --git a/backend/internal/system/service.go b/backend/internal/system/service.go index 8a55345..95b2757 100644 --- a/backend/internal/system/service.go +++ b/backend/internal/system/service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "os" "os/exec" "strings" "time" @@ -11,6 +12,12 @@ import ( "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 @@ -183,6 +190,9 @@ type NetworkInterface struct { 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 @@ -297,6 +307,79 @@ func (s *Service) ListNetworkInterfaces(ctx context.Context) ([]NetworkInterface } } + // 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)) @@ -319,6 +402,14 @@ func (s *Service) ListNetworkInterfaces(ctx context.Context) ([]NetworkInterface } } + // 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") { @@ -345,3 +436,110 @@ func (s *Service) ListNetworkInterfaces(ctx context.Context) ([]NetworkInterface 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 +} diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts index a640144..2abc97d 100644 --- a/frontend/src/api/system.ts +++ b/frontend/src/api/system.ts @@ -7,6 +7,28 @@ export interface NetworkInterface { status: string // "Connected" or "Down" speed: string // e.g., "10 Gbps", "1 Gbps" role: string // "Management", "ISCSI", or empty + gateway?: string + dns1?: string + dns2?: string +} + +export interface UpdateNetworkInterfaceRequest { + ip_address: string + subnet: string + gateway?: string + dns1?: string + dns2?: string + role?: string +} + +export interface SaveNTPSettingsRequest { + timezone: string + ntp_servers: string[] +} + +export interface NTPSettings { + timezone: string + ntp_servers: string[] } export const systemAPI = { @@ -14,5 +36,16 @@ export const systemAPI = { const response = await apiClient.get<{ interfaces: NetworkInterface[] | null }>('/system/interfaces') return response.data.interfaces || [] }, + updateNetworkInterface: async (name: string, data: UpdateNetworkInterfaceRequest): Promise => { + const response = await apiClient.put<{ interface: NetworkInterface }>(`/system/interfaces/${name}`, data) + return response.data.interface + }, + getNTPSettings: async (): Promise => { + const response = await apiClient.get<{ settings: NTPSettings }>('/system/ntp') + return response.data.settings + }, + saveNTPSettings: async (data: SaveNTPSettingsRequest): Promise => { + await apiClient.post('/system/ntp', data) + }, } diff --git a/frontend/src/pages/System.tsx b/frontend/src/pages/System.tsx index bd3af61..aaddd4d 100644 --- a/frontend/src/pages/System.tsx +++ b/frontend/src/pages/System.tsx @@ -1,10 +1,31 @@ -import { useState } from 'react' +import { useState, useRef, useEffect } from 'react' import { Link } from 'react-router-dom' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { systemAPI, NetworkInterface } from '@/api/system' export default function System() { const [snmpEnabled, setSnmpEnabled] = useState(false) + const [openMenu, setOpenMenu] = useState(null) + const [editingInterface, setEditingInterface] = useState(null) + const [timezone, setTimezone] = useState('Etc/UTC') + const [ntpServers, setNtpServers] = useState(['pool.ntp.org', 'time.google.com']) + const menuRef = useRef(null) + + const queryClient = useQueryClient() + + // Save NTP settings mutation + const saveNTPSettingsMutation = useMutation({ + mutationFn: (data: { timezone: string; ntp_servers: string[] }) => systemAPI.saveNTPSettings(data), + onSuccess: () => { + // Refetch NTP settings to get the updated values + queryClient.invalidateQueries({ queryKey: ['system', 'ntp'] }) + // Show success message (you can add a toast notification here) + alert('NTP settings saved successfully!') + }, + onError: (error: any) => { + alert(`Failed to save NTP settings: ${error.message || 'Unknown error'}`) + }, + }) // Fetch network interfaces const { data: interfaces = [], isLoading: interfacesLoading } = useQuery({ @@ -13,6 +34,31 @@ export default function System() { refetchInterval: 5000, // Refresh every 5 seconds }) + // Fetch NTP settings on mount + const { data: ntpSettings } = useQuery({ + queryKey: ['system', 'ntp'], + queryFn: () => systemAPI.getNTPSettings(), + }) + + // Update state when NTP settings are loaded + useEffect(() => { + if (ntpSettings) { + setTimezone(ntpSettings.timezone) + setNtpServers(ntpSettings.ntp_servers) + } + }, [ntpSettings]) + + // Close menu when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setOpenMenu(null) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + return (
{/* Top Navigation */} @@ -133,9 +179,51 @@ export default function System() {
)} - +
+ + {openMenu === iface.name && ( +
+ + +
+ +
+ )} +
) @@ -306,18 +394,42 @@ export default function System() { schedule

Date & Time

- UTC +
- setTimezone(e.target.value)} + className="block w-full rounded-lg border-border-dark bg-[#111a22] py-2.5 pl-3 pr-10 text-sm text-white focus:border-primary focus:ring-1 focus:ring-primary appearance-none" + > - - + + + + + + + + + +
expand_more @@ -333,20 +445,25 @@ export default function System() {
-
-
-
- pool.ntp.org + {ntpServers.map((server, index) => ( +
+
+
+ {server} +
+
+ Stratum 2 • 12ms + +
- Stratum 2 • 12ms -
-
-
-
- time.google.com -
- Stratum 1 • 45ms -
+ ))}
@@ -425,6 +542,187 @@ export default function System() {
+ + {/* Edit Connection Modal */} + {editingInterface && ( + setEditingInterface(null)} + /> + )} +
+ ) +} + +// Edit Connection Modal Component +interface EditConnectionModalProps { + interface: NetworkInterface + onClose: () => void +} + +function EditConnectionModal({ interface: iface, onClose }: EditConnectionModalProps) { + const queryClient = useQueryClient() + const [formData, setFormData] = useState({ + ip_address: iface.ip_address || '', + subnet: iface.subnet || '24', + gateway: iface.gateway || '', + dns1: iface.dns1 || '', + dns2: iface.dns2 || '', + role: iface.role || '', + }) + + const updateMutation = useMutation({ + mutationFn: (data: { ip_address: string; subnet: string; gateway?: string; dns1?: string; dns2?: string; role?: string }) => + systemAPI.updateNetworkInterface(iface.name, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['system', 'interfaces'] }) + onClose() + }, + onError: (error: any) => { + alert(`Failed to update interface: ${error.response?.data?.error || error.message}`) + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + updateMutation.mutate({ + ip_address: formData.ip_address, + subnet: formData.subnet, + gateway: formData.gateway || undefined, + dns1: formData.dns1 || undefined, + dns2: formData.dns2 || undefined, + role: formData.role || undefined, + }) + } + + return ( +
+
+
+
+ settings_ethernet +

Edit Connection - {iface.name}

+
+ +
+ +
+
+ {/* IP Address */} +
+ + setFormData({ ...formData, ip_address: e.target.value })} + className="block w-full rounded-lg border-border-dark bg-[#111a22] p-2.5 text-sm text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary" + placeholder="192.168.1.100" + required + /> +
+ + {/* Subnet Mask */} +
+ + setFormData({ ...formData, subnet: e.target.value })} + className="block w-full rounded-lg border-border-dark bg-[#111a22] p-2.5 text-sm text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary" + placeholder="24" + required + /> +

Enter CIDR notation (e.g., 24 for 255.255.255.0)

+
+ + {/* Gateway */} +
+ + setFormData({ ...formData, gateway: e.target.value })} + className="block w-full rounded-lg border-border-dark bg-[#111a22] p-2.5 text-sm text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary" + placeholder="192.168.1.1" + /> +
+ + {/* DNS Servers */} +
+
+ + setFormData({ ...formData, dns1: e.target.value })} + className="block w-full rounded-lg border-border-dark bg-[#111a22] p-2.5 text-sm text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary" + placeholder="8.8.8.8" + /> +
+
+ + setFormData({ ...formData, dns2: e.target.value })} + className="block w-full rounded-lg border-border-dark bg-[#111a22] p-2.5 text-sm text-white placeholder-gray-500 focus:border-primary focus:ring-1 focus:ring-primary" + placeholder="8.8.4.4" + /> +
+
+ + {/* Role */} +
+ + +
+
+ +
+ + +
+
+
) }