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") + } +}