From d79dbbdca11a5042e47552c2d4d4f7bb0f0db4b2 Mon Sep 17 00:00:00 2001 From: nfebe Date: Wed, 18 Mar 2026 14:11:18 +0100 Subject: [PATCH] feat(api): Add server info, network health, and resource management Server info endpoint returns hostname, public IPs, and network interfaces. Network health checks DNS resolution against multiple resolvers (8.8.8.8, 1.1.1.1, 9.9.9.9) with latency and tests external connectivity. Resource endpoints allow reading and updating container memory/CPU limits via docker update. Closes #77, closes #79 Signed-off-by: nfebe --- internal/api/resource_handlers.go | 82 +++++++++ internal/api/resource_handlers_test.go | 169 +++++++++++++++++ internal/api/server.go | 7 + internal/api/server_info_handlers.go | 36 ++++ internal/api/server_info_handlers_test.go | 166 +++++++++++++++++ internal/docker/resources.go | 117 ++++++++++++ internal/docker/resources_test.go | 47 +++++ internal/system/network.go | 212 ++++++++++++++++++++++ internal/system/network_test.go | 87 +++++++++ 9 files changed, 923 insertions(+) create mode 100644 internal/api/resource_handlers.go create mode 100644 internal/api/resource_handlers_test.go create mode 100644 internal/api/server_info_handlers.go create mode 100644 internal/api/server_info_handlers_test.go create mode 100644 internal/docker/resources.go create mode 100644 internal/docker/resources_test.go create mode 100644 internal/system/network.go create mode 100644 internal/system/network_test.go diff --git a/internal/api/resource_handlers.go b/internal/api/resource_handlers.go new file mode 100644 index 0000000..c40aee4 --- /dev/null +++ b/internal/api/resource_handlers.go @@ -0,0 +1,82 @@ +package api + +import ( + "net/http" + + "github.com/flatrun/agent/internal/docker" + "github.com/gin-gonic/gin" +) + +func (s *Server) getContainerResources(c *gin.Context) { + id := c.Param("id") + + resources, err := docker.GetContainerResources(id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "resources": resources, + }) +} + +func (s *Server) updateContainerResources(c *gin.Context) { + id := c.Param("id") + + var update docker.ResourceUpdate + if err := c.ShouldBindJSON(&update); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request body: " + err.Error(), + }) + return + } + + if update.MemoryLimit == nil && update.MemorySwap == nil && + update.CPUs == nil && update.CPUShares == nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "At least one resource limit must be specified", + }) + return + } + + if err := docker.UpdateContainerResources(id, &update); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + resources, _ := docker.GetContainerResources(id) + + c.JSON(http.StatusOK, gin.H{ + "message": "Resources updated", + "resources": resources, + }) +} + +func (s *Server) getDeploymentResources(c *gin.Context) { + name := c.Param("name") + + if _, err := s.manager.GetDeployment(name); err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "error": "deployment not found", + }) + return + } + + resources, err := docker.GetDeploymentResources(name) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "deployment": name, + "resources": resources, + }) +} diff --git a/internal/api/resource_handlers_test.go b/internal/api/resource_handlers_test.go new file mode 100644 index 0000000..cb79759 --- /dev/null +++ b/internal/api/resource_handlers_test.go @@ -0,0 +1,169 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/flatrun/agent/internal/auth" + "github.com/flatrun/agent/internal/docker" + "github.com/flatrun/agent/pkg/config" + "github.com/gin-gonic/gin" +) + +func setupResourceTestServer(t *testing.T) (*gin.Engine, string, func()) { + gin.SetMode(gin.TestMode) + + tmpDir, err := os.MkdirTemp("", "resource_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + cfg := &config.Config{ + DeploymentsPath: tmpDir, + Auth: config.AuthConfig{ + Enabled: true, + JWTSecret: "test-jwt-secret-for-resources", + APIKeys: []string{"test-api-key"}, + }, + } + + os.Setenv("FLATRUN_ADMIN_PASSWORD", "testadminpass") + + authManager, err := auth.NewManager(tmpDir, &cfg.Auth) + if err != nil { + os.RemoveAll(tmpDir) + t.Fatalf("Failed to create auth manager: %v", err) + } + + manager := docker.NewManager(tmpDir) + + server := &Server{ + config: cfg, + authManager: authManager, + manager: manager, + } + + router := gin.New() + authMiddleware := auth.NewMiddlewareWithManager(&cfg.Auth, authManager) + + api := router.Group("/api") + api.POST("/auth/login", authMiddleware.Login) + + protected := api.Group("") + protected.Use(authMiddleware.RequireAuth()) + { + protected.GET("/containers/:id/resources", authMiddleware.RequirePermission(auth.PermContainersRead), server.getContainerResources) + protected.PUT("/containers/:id/resources", authMiddleware.RequirePermission(auth.PermContainersWrite), server.updateContainerResources) + protected.GET("/deployments/:name/resources", authMiddleware.RequirePermission(auth.PermDeploymentsRead), server.getDeploymentResources) + } + + cleanup := func() { + authManager.Close() + os.RemoveAll(tmpDir) + os.Unsetenv("FLATRUN_ADMIN_PASSWORD") + } + + token := loginAndGetToken(t, router, "admin", "testadminpass") + return router, token, cleanup +} + +func TestGetContainerResourcesRequiresAuth(t *testing.T) { + router, _, cleanup := setupResourceTestServer(t) + defer cleanup() + + req := httptest.NewRequest(http.MethodGet, "/api/containers/abc123/resources", nil) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code == http.StatusOK { + t.Error("Expected auth error, got 200") + } +} + +func TestUpdateContainerResourcesEmptyBody(t *testing.T) { + router, token, cleanup := setupResourceTestServer(t) + defer cleanup() + + body := map[string]interface{}{} + jsonBody, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPut, "/api/containers/abc123/resources", bytes.NewBuffer(jsonBody)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for empty update, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestUpdateContainerResourcesBadJSON(t *testing.T) { + router, token, cleanup := setupResourceTestServer(t) + defer cleanup() + + req := httptest.NewRequest(http.MethodPut, "/api/containers/abc123/resources", bytes.NewBufferString("{invalid")) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for bad JSON, got %d", w.Code) + } +} + +func TestGetDeploymentResourcesNotFound(t *testing.T) { + router, token, cleanup := setupResourceTestServer(t) + defer cleanup() + + req := httptest.NewRequest(http.MethodGet, "/api/deployments/nonexistent/resources", nil) + req.Header.Set("Authorization", "Bearer "+token) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected 404 for nonexistent deployment, got %d", w.Code) + } +} + +func TestResourceUpdateStructSerialization(t *testing.T) { + mem := int64(256 * 1024 * 1024) + cpus := 0.5 + + update := docker.ResourceUpdate{ + MemoryLimit: &mem, + CPUs: &cpus, + } + + data, err := json.Marshal(update) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + var parsed docker.ResourceUpdate + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + if parsed.MemoryLimit == nil || *parsed.MemoryLimit != mem { + t.Errorf("MemoryLimit = %v, want %d", parsed.MemoryLimit, mem) + } + if parsed.CPUs == nil || *parsed.CPUs != cpus { + t.Errorf("CPUs = %v, want %f", parsed.CPUs, cpus) + } + if parsed.MemorySwap != nil { + t.Error("MemorySwap should be nil") + } + if parsed.CPUShares != nil { + t.Error("CPUShares should be nil") + } +} diff --git a/internal/api/server.go b/internal/api/server.go index e55ffe3..45eb4aa 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -306,6 +306,10 @@ func (s *Server) setupRoutes() { protected.POST("/compose/update", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.updateCompose) protected.GET("/stats", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.getSystemStats) + // Server info and network health endpoints + protected.GET("/server/info", s.authMiddleware.RequirePermission(auth.PermSystemRead), s.getServerInfo) + protected.GET("/server/network-health", s.authMiddleware.RequirePermission(auth.PermSystemRead), s.getNetworkHealth) + // Template and plugin endpoints protected.GET("/plugins", s.authMiddleware.RequirePermission(auth.PermTemplatesRead), s.listPlugins) protected.GET("/plugins/:name", s.authMiddleware.RequirePermission(auth.PermTemplatesRead), s.getPlugin) @@ -326,7 +330,10 @@ func (s *Server) setupRoutes() { protected.GET("/containers/:id/stats", s.authMiddleware.RequirePermission(auth.PermContainersRead), s.getContainerStats) protected.GET("/containers/stats", s.authMiddleware.RequirePermission(auth.PermContainersRead), s.getAllContainerStats) protected.POST("/containers/:id/exec", s.authMiddleware.RequirePermission(auth.PermContainersWrite), s.containerExecHTTP) + protected.GET("/containers/:id/resources", s.authMiddleware.RequirePermission(auth.PermContainersRead), s.getContainerResources) + protected.PUT("/containers/:id/resources", s.authMiddleware.RequirePermission(auth.PermContainersWrite), s.updateContainerResources) protected.GET("/deployments/:name/stats", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.getDeploymentContainerStats) + protected.GET("/deployments/:name/resources", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.getDeploymentResources) // Image endpoints protected.GET("/images", s.authMiddleware.RequirePermission(auth.PermImagesRead), s.listImages) diff --git a/internal/api/server_info_handlers.go b/internal/api/server_info_handlers.go new file mode 100644 index 0000000..9663b05 --- /dev/null +++ b/internal/api/server_info_handlers.go @@ -0,0 +1,36 @@ +package api + +import ( + "net/http" + + "github.com/flatrun/agent/internal/system" + "github.com/gin-gonic/gin" +) + +func (s *Server) getServerInfo(c *gin.Context) { + info, err := system.GetServerInfo() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "server": info, + }) +} + +func (s *Server) getNetworkHealth(c *gin.Context) { + health, err := system.CheckNetworkHealth(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "network_health": health, + }) +} diff --git a/internal/api/server_info_handlers_test.go b/internal/api/server_info_handlers_test.go new file mode 100644 index 0000000..2eaa4c2 --- /dev/null +++ b/internal/api/server_info_handlers_test.go @@ -0,0 +1,166 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/flatrun/agent/internal/auth" + "github.com/flatrun/agent/internal/system" + "github.com/flatrun/agent/pkg/config" + "github.com/gin-gonic/gin" +) + +func setupServerInfoTestServer(t *testing.T) (*gin.Engine, string, func()) { + gin.SetMode(gin.TestMode) + + tmpDir, err := os.MkdirTemp("", "server_info_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + cfg := &config.Config{ + DeploymentsPath: tmpDir, + Auth: config.AuthConfig{ + Enabled: true, + JWTSecret: "test-jwt-secret-for-server-info", + APIKeys: []string{"test-api-key"}, + }, + } + + os.Setenv("FLATRUN_ADMIN_PASSWORD", "testadminpass") + + authManager, err := auth.NewManager(tmpDir, &cfg.Auth) + if err != nil { + os.RemoveAll(tmpDir) + t.Fatalf("Failed to create auth manager: %v", err) + } + + server := &Server{ + config: cfg, + authManager: authManager, + } + + router := gin.New() + authMiddleware := auth.NewMiddlewareWithManager(&cfg.Auth, authManager) + + api := router.Group("/api") + api.POST("/auth/login", authMiddleware.Login) + + protected := api.Group("") + protected.Use(authMiddleware.RequireAuth()) + { + protected.GET("/server/info", authMiddleware.RequirePermission(auth.PermSystemRead), server.getServerInfo) + protected.GET("/server/network-health", authMiddleware.RequirePermission(auth.PermSystemRead), server.getNetworkHealth) + } + + cleanup := func() { + authManager.Close() + os.RemoveAll(tmpDir) + os.Unsetenv("FLATRUN_ADMIN_PASSWORD") + } + + token := loginAndGetToken(t, router, "admin", "testadminpass") + return router, token, cleanup +} + +func TestGetServerInfo(t *testing.T) { + router, token, cleanup := setupServerInfoTestServer(t) + defer cleanup() + + req := httptest.NewRequest(http.MethodGet, "/api/server/info", nil) + req.Header.Set("Authorization", "Bearer "+token) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + serverData, ok := resp["server"] + if !ok { + t.Fatal("Response missing 'server' key") + } + + serverMap, ok := serverData.(map[string]interface{}) + if !ok { + t.Fatal("server should be an object") + } + + if _, ok := serverMap["hostname"]; !ok { + t.Error("server should have hostname") + } + if _, ok := serverMap["interfaces"]; !ok { + t.Error("server should have interfaces") + } +} + +func TestGetServerInfoRequiresAuth(t *testing.T) { + router, _, cleanup := setupServerInfoTestServer(t) + defer cleanup() + + req := httptest.NewRequest(http.MethodGet, "/api/server/info", nil) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code == http.StatusOK { + t.Error("Expected auth error, got 200") + } +} + +func TestGetNetworkHealth(t *testing.T) { + router, token, cleanup := setupServerInfoTestServer(t) + defer cleanup() + + req := httptest.NewRequest(http.MethodGet, "/api/server/network-health", nil) + req.Header.Set("Authorization", "Bearer "+token) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + healthData, ok := resp["network_health"] + if !ok { + t.Fatal("Response missing 'network_health' key") + } + + healthMap, ok := healthData.(map[string]interface{}) + if !ok { + t.Fatal("network_health should be an object") + } + + if _, ok := healthMap["dns"]; !ok { + t.Error("network_health should have dns") + } + if _, ok := healthMap["checked_at"]; !ok { + t.Error("network_health should have checked_at") + } +} + +func TestServerInfoDirectFunction(t *testing.T) { + info, err := system.GetServerInfo() + if err != nil { + t.Fatalf("GetServerInfo failed: %v", err) + } + + if info.Hostname == "" { + t.Error("Hostname should not be empty") + } +} diff --git a/internal/docker/resources.go b/internal/docker/resources.go new file mode 100644 index 0000000..fb5b890 --- /dev/null +++ b/internal/docker/resources.go @@ -0,0 +1,117 @@ +package docker + +import ( + "encoding/json" + "fmt" + "os/exec" + "strconv" + "strings" +) + +type ResourceLimits struct { + MemoryLimit int64 `json:"memory_limit"` + MemorySwap int64 `json:"memory_swap"` + CPUs float64 `json:"cpus"` + CPUShares int64 `json:"cpu_shares"` + RestartPolicy string `json:"restart_policy"` +} + +type ResourceUpdate struct { + MemoryLimit *int64 `json:"memory_limit,omitempty"` + MemorySwap *int64 `json:"memory_swap,omitempty"` + CPUs *float64 `json:"cpus,omitempty"` + CPUShares *int64 `json:"cpu_shares,omitempty"` +} + +type hostConfig struct { + Memory int64 `json:"Memory"` + MemorySwap int64 `json:"MemorySwap"` + NanoCpus int64 `json:"NanoCpus"` + CpuShares int64 `json:"CpuShares"` + RestartPolicy restartPolicyInspect `json:"RestartPolicy"` +} + +type restartPolicyInspect struct { + Name string `json:"Name"` +} + +func GetContainerResources(containerID string) (*ResourceLimits, error) { + cmd := exec.Command("docker", "inspect", "--format", "{{json .HostConfig}}", containerID) + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err) + } + + var hc hostConfig + if err := json.Unmarshal(output, &hc); err != nil { + return nil, fmt.Errorf("failed to parse container config: %w", err) + } + + return &ResourceLimits{ + MemoryLimit: hc.Memory, + MemorySwap: hc.MemorySwap, + CPUs: float64(hc.NanoCpus) / 1e9, + CPUShares: hc.CpuShares, + RestartPolicy: hc.RestartPolicy.Name, + }, nil +} + +func UpdateContainerResources(containerID string, update *ResourceUpdate) error { + args := []string{"update"} + + if update.MemoryLimit != nil { + args = append(args, "--memory", strconv.FormatInt(*update.MemoryLimit, 10)) + } + if update.MemorySwap != nil { + args = append(args, "--memory-swap", strconv.FormatInt(*update.MemorySwap, 10)) + } + if update.CPUs != nil { + args = append(args, "--cpus", strconv.FormatFloat(*update.CPUs, 'f', -1, 64)) + } + if update.CPUShares != nil { + args = append(args, "--cpu-shares", strconv.FormatInt(*update.CPUShares, 10)) + } + + args = append(args, containerID) + + cmd := exec.Command("docker", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("docker update failed: %s", strings.TrimSpace(string(output))) + } + + return nil +} + +func GetDeploymentResources(projectName string) (map[string]*ResourceLimits, error) { + if projectName == "" { + return nil, fmt.Errorf("project name is required") + } + + psCmd := exec.Command("docker", "ps", "-q", + "--filter", "label=com.docker.compose.project="+projectName) + containerIDs, err := psCmd.Output() + if err != nil || len(strings.TrimSpace(string(containerIDs))) == 0 { + return map[string]*ResourceLimits{}, nil + } + + ids := strings.Fields(strings.TrimSpace(string(containerIDs))) + result := make(map[string]*ResourceLimits, len(ids)) + + for _, id := range ids { + nameCmd := exec.Command("docker", "inspect", "--format", "{{.Name}}", id) + nameOutput, err := nameCmd.Output() + name := strings.TrimPrefix(strings.TrimSpace(string(nameOutput)), "/") + if err != nil || name == "" { + name = id[:12] + } + + limits, err := GetContainerResources(id) + if err != nil { + continue + } + result[name] = limits + } + + return result, nil +} diff --git a/internal/docker/resources_test.go b/internal/docker/resources_test.go new file mode 100644 index 0000000..ba315cd --- /dev/null +++ b/internal/docker/resources_test.go @@ -0,0 +1,47 @@ +package docker + +import ( + "testing" +) + +func TestResourceUpdateArgs(t *testing.T) { + mem := int64(512 * 1024 * 1024) // 512MB + cpus := 1.5 + shares := int64(1024) + + update := &ResourceUpdate{ + MemoryLimit: &mem, + CPUs: &cpus, + CPUShares: &shares, + } + + if update.MemoryLimit == nil || *update.MemoryLimit != mem { + t.Errorf("MemoryLimit = %v, want %d", update.MemoryLimit, mem) + } + if update.CPUs == nil || *update.CPUs != cpus { + t.Errorf("CPUs = %v, want %f", update.CPUs, cpus) + } + if update.CPUShares == nil || *update.CPUShares != shares { + t.Errorf("CPUShares = %v, want %d", update.CPUShares, shares) + } + if update.MemorySwap != nil { + t.Error("MemorySwap should be nil when not set") + } +} + +func TestResourceLimitsStruct(t *testing.T) { + limits := &ResourceLimits{ + MemoryLimit: 536870912, + MemorySwap: -1, + CPUs: 2.0, + CPUShares: 1024, + RestartPolicy: "always", + } + + if limits.MemoryLimit != 536870912 { + t.Errorf("MemoryLimit = %d, want 536870912", limits.MemoryLimit) + } + if limits.CPUs != 2.0 { + t.Errorf("CPUs = %f, want 2.0", limits.CPUs) + } +} diff --git a/internal/system/network.go b/internal/system/network.go new file mode 100644 index 0000000..dcdc029 --- /dev/null +++ b/internal/system/network.go @@ -0,0 +1,212 @@ +package system + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "os" + "strings" + "time" +) + +type NetworkHealth struct { + ExternalAccess bool `json:"external_access"` + DNS DNSHealth `json:"dns"` + Interfaces []NetworkInterface `json:"interfaces"` + CheckedAt time.Time `json:"checked_at"` +} + +type DNSHealth struct { + Healthy bool `json:"healthy"` + Resolvers []ResolverCheck `json:"resolvers"` +} + +type ResolverCheck struct { + Server string `json:"server"` + Healthy bool `json:"healthy"` + Latency int64 `json:"latency_ms"` + Error string `json:"error,omitempty"` +} + +type NetworkInterface struct { + Name string `json:"name"` + Addresses []string `json:"addresses"` + Flags string `json:"flags"` +} + +type ServerInfo struct { + Hostname string `json:"hostname"` + PublicIPv4 string `json:"public_ipv4"` + PublicIPv6 string `json:"public_ipv6"` + Interfaces []NetworkInterface `json:"interfaces"` +} + +var defaultDNSServers = []string{ + "8.8.8.8", + "1.1.1.1", + "9.9.9.9", +} + +var dnsTestDomains = []string{ + "www.google.com", + "www.cloudflare.com", +} + +func GetServerInfo() (*ServerInfo, error) { + info := &ServerInfo{} + + if hostname, err := getHostname(); err == nil { + info.Hostname = hostname + } + + info.Interfaces = getNetworkInterfaces() + + if ip, err := getPublicIP("4"); err == nil { + info.PublicIPv4 = ip + } + if ip, err := getPublicIP("6"); err == nil { + info.PublicIPv6 = ip + } + + return info, nil +} + +func CheckNetworkHealth(ctx context.Context) (*NetworkHealth, error) { + health := &NetworkHealth{ + CheckedAt: time.Now(), + } + + health.DNS = checkDNSHealth(ctx) + health.ExternalAccess = checkExternalAccess(ctx) + health.Interfaces = getNetworkInterfaces() + + return health, nil +} + +func checkDNSHealth(ctx context.Context) DNSHealth { + health := DNSHealth{Healthy: true} + + for _, server := range defaultDNSServers { + check := checkResolver(ctx, server) + health.Resolvers = append(health.Resolvers, check) + if !check.Healthy { + health.Healthy = false + } + } + + return health +} + +func checkResolver(ctx context.Context, server string) ResolverCheck { + check := ResolverCheck{Server: server} + + resolver := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{Timeout: 5 * time.Second} + return d.DialContext(ctx, "udp", server+":53") + }, + } + + resolveCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + start := time.Now() + _, err := resolver.LookupHost(resolveCtx, dnsTestDomains[0]) + check.Latency = time.Since(start).Milliseconds() + + if err != nil { + check.Healthy = false + check.Error = err.Error() + } else { + check.Healthy = true + } + + return check +} + +func checkExternalAccess(ctx context.Context) bool { + dialer := net.Dialer{Timeout: 5 * time.Second} + conn, err := dialer.DialContext(ctx, "tcp", "1.1.1.1:443") + if err != nil { + return false + } + conn.Close() + return true +} + +func getNetworkInterfaces() []NetworkInterface { + var result []NetworkInterface + + ifaces, err := net.Interfaces() + if err != nil { + return result + } + + for _, iface := range ifaces { + if iface.Flags&net.FlagLoopback != 0 { + continue + } + + ni := NetworkInterface{ + Name: iface.Name, + Flags: iface.Flags.String(), + } + + addrs, err := iface.Addrs() + if err != nil { + continue + } + + for _, addr := range addrs { + ni.Addresses = append(ni.Addresses, addr.String()) + } + + if len(ni.Addresses) > 0 { + result = append(result, ni) + } + } + + return result +} + +func getHostname() (string, error) { + return os.Hostname() +} + +func getPublicIP(version string) (string, error) { + var endpoints []string + if version == "6" { + endpoints = []string{ + "https://api64.ipify.org", + "https://ipv6.icanhazip.com", + } + } else { + endpoints = []string{ + "https://api.ipify.org", + "https://ipv4.icanhazip.com", + } + } + + client := &http.Client{Timeout: 5 * time.Second} + + for _, endpoint := range endpoints { + resp, err := client.Get(endpoint) + if err != nil { + continue + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + continue + } + ip := strings.TrimSpace(string(body)) + if ip != "" && net.ParseIP(ip) != nil { + return ip, nil + } + } + + return "", fmt.Errorf("failed to determine public IPv%s address", version) +} diff --git a/internal/system/network_test.go b/internal/system/network_test.go new file mode 100644 index 0000000..34c70c4 --- /dev/null +++ b/internal/system/network_test.go @@ -0,0 +1,87 @@ +package system + +import ( + "context" + "testing" + "time" +) + +func TestGetNetworkInterfaces(t *testing.T) { + ifaces := getNetworkInterfaces() + + // Should return at least something (even in CI there are usually non-loopback interfaces) + // but we won't fail if empty since some environments are very minimal + for _, iface := range ifaces { + if iface.Name == "" { + t.Error("Interface name should not be empty") + } + if len(iface.Addresses) == 0 { + t.Errorf("Interface %s has no addresses", iface.Name) + } + } +} + +func TestCheckResolverWithInvalidServer(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + check := checkResolver(ctx, "192.0.2.1") // RFC 5737 TEST-NET, won't respond + if check.Server != "192.0.2.1" { + t.Errorf("Server = %s, want 192.0.2.1", check.Server) + } + if check.Healthy { + t.Error("Expected unhealthy for unreachable DNS server") + } + if check.Error == "" { + t.Error("Expected error message for unreachable DNS server") + } +} + +func TestCheckDNSHealthStructure(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + health := checkDNSHealth(ctx) + + if len(health.Resolvers) != len(defaultDNSServers) { + t.Errorf("Expected %d resolver checks, got %d", len(defaultDNSServers), len(health.Resolvers)) + } + + for _, r := range health.Resolvers { + if r.Server == "" { + t.Error("Resolver server should not be empty") + } + if r.Latency < 0 { + t.Errorf("Latency should be non-negative, got %d", r.Latency) + } + } +} + +func TestNetworkHealthStructure(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + health, err := CheckNetworkHealth(ctx) + if err != nil { + t.Fatalf("CheckNetworkHealth failed: %v", err) + } + + if health.CheckedAt.IsZero() { + t.Error("CheckedAt should not be zero") + } + + if len(health.DNS.Resolvers) == 0 { + t.Error("Expected at least one DNS resolver check") + } +} + +func TestServerInfoStructure(t *testing.T) { + info, err := GetServerInfo() + if err != nil { + t.Fatalf("GetServerInfo failed: %v", err) + } + + if info.Hostname == "" { + t.Error("Hostname should not be empty") + } +}