Integrate with supabase backend
This commit is contained in:
410
internal/auth/auth.go
Normal file
410
internal/auth/auth.go
Normal file
@@ -0,0 +1,410 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nannyagentv2/internal/config"
|
||||
"nannyagentv2/internal/types"
|
||||
)
|
||||
|
||||
const (
|
||||
// Token storage location (secure directory)
|
||||
TokenStorageDir = "/var/lib/nannyagent"
|
||||
TokenStorageFile = ".agent_token.json"
|
||||
RefreshTokenFile = ".refresh_token"
|
||||
|
||||
// Polling configuration
|
||||
MaxPollAttempts = 60 // 5 minutes (60 * 5 seconds)
|
||||
PollInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
// AuthManager handles all authentication-related operations
|
||||
type AuthManager struct {
|
||||
config *config.Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewAuthManager creates a new authentication manager
|
||||
func NewAuthManager(cfg *config.Config) *AuthManager {
|
||||
return &AuthManager{
|
||||
config: cfg,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureTokenStorageDir creates the token storage directory if it doesn't exist
|
||||
func (am *AuthManager) EnsureTokenStorageDir() error {
|
||||
// Check if running as root
|
||||
if os.Geteuid() != 0 {
|
||||
return fmt.Errorf("must run as root to create secure token storage directory")
|
||||
}
|
||||
|
||||
// Create directory with restricted permissions (0700 - only root can access)
|
||||
if err := os.MkdirAll(TokenStorageDir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create token storage directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartDeviceAuthorization initiates the OAuth device authorization flow
|
||||
func (am *AuthManager) StartDeviceAuthorization() (*types.DeviceAuthResponse, error) {
|
||||
payload := map[string]interface{}{
|
||||
"client_id": "nannyagent-cli",
|
||||
"scope": []string{"agent:register"},
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/device/authorize", am.config.DeviceAuthURL)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := am.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start device authorization: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("device authorization failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var deviceResp types.DeviceAuthResponse
|
||||
if err := json.Unmarshal(body, &deviceResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
return &deviceResp, nil
|
||||
}
|
||||
|
||||
// PollForToken polls the token endpoint until authorization is complete
|
||||
func (am *AuthManager) PollForToken(deviceCode string) (*types.TokenResponse, error) {
|
||||
fmt.Println("⏳ Waiting for user authorization...")
|
||||
|
||||
for attempts := 0; attempts < MaxPollAttempts; attempts++ {
|
||||
tokenReq := types.TokenRequest{
|
||||
GrantType: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
DeviceCode: deviceCode,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(tokenReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal token request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/token", am.config.DeviceAuthURL)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := am.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to poll for token: %w", err)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read token response: %w", err)
|
||||
}
|
||||
|
||||
var tokenResp types.TokenResponse
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.Error != "" {
|
||||
if tokenResp.Error == "authorization_pending" {
|
||||
fmt.Print(".")
|
||||
time.Sleep(PollInterval)
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("authorization failed: %s", tokenResp.ErrorDescription)
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken != "" {
|
||||
fmt.Println("\n✅ Authorization successful!")
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
time.Sleep(PollInterval)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("authorization timed out after %d attempts", MaxPollAttempts)
|
||||
}
|
||||
|
||||
// RefreshAccessToken refreshes an expired access token using the refresh token
|
||||
func (am *AuthManager) RefreshAccessToken(refreshToken string) (*types.TokenResponse, error) {
|
||||
tokenReq := types.TokenRequest{
|
||||
GrantType: "refresh_token",
|
||||
RefreshToken: refreshToken,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(tokenReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal refresh request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/token", am.config.DeviceAuthURL)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create refresh request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := am.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read refresh response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp types.TokenResponse
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse refresh response: %w", err)
|
||||
}
|
||||
|
||||
if tokenResp.Error != "" {
|
||||
return nil, fmt.Errorf("token refresh failed: %s", tokenResp.ErrorDescription)
|
||||
}
|
||||
|
||||
return &tokenResp, nil
|
||||
}
|
||||
|
||||
// SaveToken saves the authentication token to secure local storage
|
||||
func (am *AuthManager) SaveToken(token *types.AuthToken) error {
|
||||
if err := am.EnsureTokenStorageDir(); err != nil {
|
||||
return fmt.Errorf("failed to ensure token storage directory: %w", err)
|
||||
}
|
||||
|
||||
// Save main token file
|
||||
tokenPath := am.getTokenPath()
|
||||
jsonData, err := json.MarshalIndent(token, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal token: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(tokenPath, jsonData, 0600); err != nil {
|
||||
return fmt.Errorf("failed to save token: %w", err)
|
||||
}
|
||||
|
||||
// Also save refresh token separately for backup recovery
|
||||
if token.RefreshToken != "" {
|
||||
refreshTokenPath := filepath.Join(TokenStorageDir, RefreshTokenFile)
|
||||
if err := os.WriteFile(refreshTokenPath, []byte(token.RefreshToken), 0600); err != nil {
|
||||
// Don't fail if refresh token backup fails, just log
|
||||
fmt.Printf("Warning: Failed to save backup refresh token: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
} // LoadToken loads the authentication token from secure local storage
|
||||
func (am *AuthManager) LoadToken() (*types.AuthToken, error) {
|
||||
tokenPath := am.getTokenPath()
|
||||
|
||||
data, err := os.ReadFile(tokenPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read token file: %w", err)
|
||||
}
|
||||
|
||||
var token types.AuthToken
|
||||
if err := json.Unmarshal(data, &token); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token: %w", err)
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if time.Now().After(token.ExpiresAt.Add(-5 * time.Minute)) {
|
||||
return nil, fmt.Errorf("token is expired or expiring soon")
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// IsTokenExpired checks if a token needs refresh
|
||||
func (am *AuthManager) IsTokenExpired(token *types.AuthToken) bool {
|
||||
// Consider token expired if it expires within the next 5 minutes
|
||||
return time.Now().After(token.ExpiresAt.Add(-5 * time.Minute))
|
||||
}
|
||||
|
||||
// RegisterDevice performs the complete device registration flow
|
||||
func (am *AuthManager) RegisterDevice() (*types.AuthToken, error) {
|
||||
// Step 1: Start device authorization
|
||||
deviceAuth, err := am.StartDeviceAuthorization()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start device authorization: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Please visit: %s\n", deviceAuth.VerificationURI)
|
||||
fmt.Printf("And enter code: %s\n", deviceAuth.UserCode)
|
||||
|
||||
// Step 2: Poll for token
|
||||
tokenResp, err := am.PollForToken(deviceAuth.DeviceCode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get token: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Create token storage
|
||||
token := &types.AuthToken{
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
TokenType: tokenResp.TokenType,
|
||||
ExpiresAt: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second),
|
||||
AgentID: tokenResp.AgentID,
|
||||
}
|
||||
|
||||
// Step 4: Save token
|
||||
if err := am.SaveToken(token); err != nil {
|
||||
return nil, fmt.Errorf("failed to save token: %w", err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// EnsureAuthenticated ensures the agent has a valid token, refreshing if necessary
|
||||
func (am *AuthManager) EnsureAuthenticated() (*types.AuthToken, error) {
|
||||
// Try to load existing token
|
||||
token, err := am.LoadToken()
|
||||
if err == nil && !am.IsTokenExpired(token) {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Try to refresh with existing refresh token (even if access token is missing/expired)
|
||||
var refreshToken string
|
||||
if err == nil && token.RefreshToken != "" {
|
||||
// Use refresh token from loaded token
|
||||
refreshToken = token.RefreshToken
|
||||
} else {
|
||||
// Try to load refresh token from main token file even if load failed
|
||||
if existingToken, loadErr := am.loadTokenIgnoringExpiry(); loadErr == nil && existingToken.RefreshToken != "" {
|
||||
refreshToken = existingToken.RefreshToken
|
||||
} else {
|
||||
// Try to load refresh token from backup file
|
||||
if backupRefreshToken, backupErr := am.loadRefreshTokenFromBackup(); backupErr == nil {
|
||||
refreshToken = backupRefreshToken
|
||||
fmt.Println("🔄 Found backup refresh token, attempting to use it...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if refreshToken != "" {
|
||||
fmt.Println("🔄 Attempting to refresh access token...")
|
||||
|
||||
refreshResp, refreshErr := am.RefreshAccessToken(refreshToken)
|
||||
if refreshErr == nil {
|
||||
// Get existing agent_id from current token or backup
|
||||
var agentID string
|
||||
if err == nil && token.AgentID != "" {
|
||||
agentID = token.AgentID
|
||||
} else if existingToken, loadErr := am.loadTokenIgnoringExpiry(); loadErr == nil {
|
||||
agentID = existingToken.AgentID
|
||||
}
|
||||
|
||||
// Create new token with refreshed values
|
||||
newToken := &types.AuthToken{
|
||||
AccessToken: refreshResp.AccessToken,
|
||||
RefreshToken: refreshToken, // Keep existing refresh token
|
||||
TokenType: refreshResp.TokenType,
|
||||
ExpiresAt: time.Now().Add(time.Duration(refreshResp.ExpiresIn) * time.Second),
|
||||
AgentID: agentID, // Preserve agent_id
|
||||
}
|
||||
|
||||
// Update refresh token if a new one was provided
|
||||
if refreshResp.RefreshToken != "" {
|
||||
newToken.RefreshToken = refreshResp.RefreshToken
|
||||
}
|
||||
|
||||
if saveErr := am.SaveToken(newToken); saveErr == nil {
|
||||
return newToken, nil
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("⚠️ Token refresh failed: %v\n", refreshErr)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("📝 Initiating new device registration...")
|
||||
return am.RegisterDevice()
|
||||
}
|
||||
|
||||
// loadTokenIgnoringExpiry loads token file without checking expiry
|
||||
func (am *AuthManager) loadTokenIgnoringExpiry() (*types.AuthToken, error) {
|
||||
tokenPath := am.getTokenPath()
|
||||
|
||||
data, err := os.ReadFile(tokenPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read token file: %w", err)
|
||||
}
|
||||
|
||||
var token types.AuthToken
|
||||
if err := json.Unmarshal(data, &token); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token: %w", err)
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// loadRefreshTokenFromBackup tries to load refresh token from backup file
|
||||
func (am *AuthManager) loadRefreshTokenFromBackup() (string, error) {
|
||||
refreshTokenPath := filepath.Join(TokenStorageDir, RefreshTokenFile)
|
||||
|
||||
data, err := os.ReadFile(refreshTokenPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read refresh token backup: %w", err)
|
||||
}
|
||||
|
||||
refreshToken := strings.TrimSpace(string(data))
|
||||
if refreshToken == "" {
|
||||
return "", fmt.Errorf("refresh token backup is empty")
|
||||
}
|
||||
|
||||
return refreshToken, nil
|
||||
}
|
||||
|
||||
func (am *AuthManager) getTokenPath() string {
|
||||
if am.config.TokenPath != "" {
|
||||
return am.config.TokenPath
|
||||
}
|
||||
return filepath.Join(TokenStorageDir, TokenStorageFile)
|
||||
}
|
||||
|
||||
func getHostname() string {
|
||||
if hostname, err := os.Hostname(); err == nil {
|
||||
return hostname
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
131
internal/config/config.go
Normal file
131
internal/config/config.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// Supabase Configuration
|
||||
SupabaseProjectURL string
|
||||
|
||||
// Edge Function Endpoints (auto-generated from SupabaseProjectURL)
|
||||
DeviceAuthURL string
|
||||
AgentAuthURL string
|
||||
|
||||
// Agent Configuration
|
||||
TokenPath string
|
||||
MetricsInterval int
|
||||
|
||||
// Debug/Development
|
||||
Debug bool
|
||||
}
|
||||
|
||||
var DefaultConfig = Config{
|
||||
TokenPath: "./token.json",
|
||||
MetricsInterval: 30,
|
||||
Debug: false,
|
||||
}
|
||||
|
||||
// LoadConfig loads configuration from environment variables and .env file
|
||||
func LoadConfig() (*Config, error) {
|
||||
config := DefaultConfig
|
||||
|
||||
// Try to load .env file from current directory or parent directories
|
||||
envFile := findEnvFile()
|
||||
if envFile != "" {
|
||||
if err := godotenv.Load(envFile); err != nil {
|
||||
fmt.Printf("Warning: Could not load .env file from %s: %v\n", envFile, err)
|
||||
} else {
|
||||
fmt.Printf("Loaded configuration from %s\n", envFile)
|
||||
}
|
||||
}
|
||||
|
||||
// Load from environment variables
|
||||
if url := os.Getenv("SUPABASE_PROJECT_URL"); url != "" {
|
||||
config.SupabaseProjectURL = url
|
||||
}
|
||||
|
||||
if tokenPath := os.Getenv("TOKEN_PATH"); tokenPath != "" {
|
||||
config.TokenPath = tokenPath
|
||||
}
|
||||
|
||||
if debug := os.Getenv("DEBUG"); debug == "true" || debug == "1" {
|
||||
config.Debug = true
|
||||
}
|
||||
|
||||
// Auto-generate edge function URLs from project URL
|
||||
if config.SupabaseProjectURL != "" {
|
||||
config.DeviceAuthURL = fmt.Sprintf("%s/functions/v1/device-auth", config.SupabaseProjectURL)
|
||||
config.AgentAuthURL = fmt.Sprintf("%s/functions/v1/agent-auth-api", config.SupabaseProjectURL)
|
||||
}
|
||||
|
||||
// Validate required configuration
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("configuration validation failed: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// Validate checks if all required configuration is present
|
||||
func (c *Config) Validate() error {
|
||||
var missing []string
|
||||
|
||||
if c.SupabaseProjectURL == "" {
|
||||
missing = append(missing, "SUPABASE_PROJECT_URL")
|
||||
}
|
||||
|
||||
if c.DeviceAuthURL == "" {
|
||||
missing = append(missing, "DEVICE_AUTH_URL (or SUPABASE_PROJECT_URL)")
|
||||
}
|
||||
|
||||
if c.AgentAuthURL == "" {
|
||||
missing = append(missing, "AGENT_AUTH_URL (or SUPABASE_PROJECT_URL)")
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("missing required environment variables: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findEnvFile looks for .env file in current directory and parent directories
|
||||
func findEnvFile() string {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for {
|
||||
envPath := filepath.Join(dir, ".env")
|
||||
if _, err := os.Stat(envPath); err == nil {
|
||||
return envPath
|
||||
}
|
||||
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// PrintConfig prints the current configuration (masking sensitive values)
|
||||
func (c *Config) PrintConfig() {
|
||||
if !c.Debug {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Configuration:")
|
||||
fmt.Printf(" Supabase Project URL: %s\n", c.SupabaseProjectURL)
|
||||
fmt.Printf(" Metrics Interval: %d seconds\n", c.MetricsInterval)
|
||||
fmt.Printf(" Debug: %v\n", c.Debug)
|
||||
}
|
||||
315
internal/metrics/collector.go
Normal file
315
internal/metrics/collector.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
"github.com/shirou/gopsutil/v3/load"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
psnet "github.com/shirou/gopsutil/v3/net"
|
||||
|
||||
"nannyagentv2/internal/types"
|
||||
)
|
||||
|
||||
// Collector handles system metrics collection
|
||||
type Collector struct {
|
||||
agentVersion string
|
||||
}
|
||||
|
||||
// NewCollector creates a new metrics collector
|
||||
func NewCollector(agentVersion string) *Collector {
|
||||
return &Collector{
|
||||
agentVersion: agentVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// GatherSystemMetrics collects comprehensive system metrics
|
||||
func (c *Collector) GatherSystemMetrics() (*types.SystemMetrics, error) {
|
||||
metrics := &types.SystemMetrics{
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
// System Information
|
||||
if hostInfo, err := host.Info(); err == nil {
|
||||
metrics.Hostname = hostInfo.Hostname
|
||||
metrics.Platform = hostInfo.Platform
|
||||
metrics.PlatformFamily = hostInfo.PlatformFamily
|
||||
metrics.PlatformVersion = hostInfo.PlatformVersion
|
||||
metrics.KernelVersion = hostInfo.KernelVersion
|
||||
metrics.KernelArch = hostInfo.KernelArch
|
||||
}
|
||||
|
||||
// CPU Metrics
|
||||
if percentages, err := cpu.Percent(time.Second, false); err == nil && len(percentages) > 0 {
|
||||
metrics.CPUUsage = math.Round(percentages[0]*100) / 100
|
||||
}
|
||||
|
||||
if cpuInfo, err := cpu.Info(); err == nil && len(cpuInfo) > 0 {
|
||||
metrics.CPUCores = len(cpuInfo)
|
||||
metrics.CPUModel = cpuInfo[0].ModelName
|
||||
}
|
||||
|
||||
// Memory Metrics
|
||||
if memInfo, err := mem.VirtualMemory(); err == nil {
|
||||
metrics.MemoryUsage = math.Round(float64(memInfo.Used)/(1024*1024)*100) / 100 // MB
|
||||
metrics.MemoryTotal = memInfo.Total
|
||||
metrics.MemoryUsed = memInfo.Used
|
||||
metrics.MemoryFree = memInfo.Free
|
||||
metrics.MemoryAvailable = memInfo.Available
|
||||
}
|
||||
|
||||
if swapInfo, err := mem.SwapMemory(); err == nil {
|
||||
metrics.SwapTotal = swapInfo.Total
|
||||
metrics.SwapUsed = swapInfo.Used
|
||||
metrics.SwapFree = swapInfo.Free
|
||||
}
|
||||
|
||||
// Disk Metrics
|
||||
if diskInfo, err := disk.Usage("/"); err == nil {
|
||||
metrics.DiskUsage = math.Round(diskInfo.UsedPercent*100) / 100
|
||||
metrics.DiskTotal = diskInfo.Total
|
||||
metrics.DiskUsed = diskInfo.Used
|
||||
metrics.DiskFree = diskInfo.Free
|
||||
}
|
||||
|
||||
// Load Averages
|
||||
if loadAvg, err := load.Avg(); err == nil {
|
||||
metrics.LoadAvg1 = math.Round(loadAvg.Load1*100) / 100
|
||||
metrics.LoadAvg5 = math.Round(loadAvg.Load5*100) / 100
|
||||
metrics.LoadAvg15 = math.Round(loadAvg.Load15*100) / 100
|
||||
}
|
||||
|
||||
// Process Count (simplified - using a constant for now)
|
||||
// Note: gopsutil doesn't have host.Processes(), would need process.Processes()
|
||||
metrics.ProcessCount = 0 // Placeholder
|
||||
|
||||
// Network Metrics
|
||||
netIn, netOut := c.getNetworkStats()
|
||||
metrics.NetworkInKbps = netIn
|
||||
metrics.NetworkOutKbps = netOut
|
||||
|
||||
if netIOCounters, err := psnet.IOCounters(false); err == nil && len(netIOCounters) > 0 {
|
||||
netIO := netIOCounters[0]
|
||||
metrics.NetworkInBytes = netIO.BytesRecv
|
||||
metrics.NetworkOutBytes = netIO.BytesSent
|
||||
}
|
||||
|
||||
// IP Address and Location
|
||||
metrics.IPAddress = c.getIPAddress()
|
||||
metrics.Location = c.getLocation() // Placeholder
|
||||
|
||||
// Filesystem Information
|
||||
metrics.FilesystemInfo = c.getFilesystemInfo()
|
||||
|
||||
// Block Devices
|
||||
metrics.BlockDevices = c.getBlockDevices()
|
||||
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
// getNetworkStats returns network input/output rates in Kbps
|
||||
func (c *Collector) getNetworkStats() (float64, float64) {
|
||||
netIOCounters, err := psnet.IOCounters(false)
|
||||
if err != nil || len(netIOCounters) == 0 {
|
||||
return 0.0, 0.0
|
||||
}
|
||||
|
||||
// Use the first interface for aggregate stats
|
||||
netIO := netIOCounters[0]
|
||||
|
||||
// Convert bytes to kilobits per second (simplified - cumulative bytes to kilobits)
|
||||
netInKbps := float64(netIO.BytesRecv) * 8 / 1024
|
||||
netOutKbps := float64(netIO.BytesSent) * 8 / 1024
|
||||
|
||||
return netInKbps, netOutKbps
|
||||
}
|
||||
|
||||
// getIPAddress returns the primary IP address of the system
|
||||
func (c *Collector) getIPAddress() string {
|
||||
interfaces, err := psnet.Interfaces()
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
for _, iface := range interfaces {
|
||||
if len(iface.Addrs) > 0 && !strings.Contains(iface.Addrs[0].Addr, "127.0.0.1") {
|
||||
return strings.Split(iface.Addrs[0].Addr, "/")[0] // Remove CIDR if present
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// getLocation returns basic location information (placeholder)
|
||||
func (c *Collector) getLocation() string {
|
||||
return "unknown" // Would integrate with GeoIP service
|
||||
}
|
||||
|
||||
// getFilesystemInfo returns information about mounted filesystems
|
||||
func (c *Collector) getFilesystemInfo() []types.FilesystemInfo {
|
||||
partitions, err := disk.Partitions(false)
|
||||
if err != nil {
|
||||
return []types.FilesystemInfo{}
|
||||
}
|
||||
|
||||
var filesystems []types.FilesystemInfo
|
||||
for _, partition := range partitions {
|
||||
usage, err := disk.Usage(partition.Mountpoint)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fs := types.FilesystemInfo{
|
||||
Mountpoint: partition.Mountpoint,
|
||||
Fstype: partition.Fstype,
|
||||
Total: usage.Total,
|
||||
Used: usage.Used,
|
||||
Free: usage.Free,
|
||||
UsagePercent: math.Round(usage.UsedPercent*100) / 100,
|
||||
}
|
||||
filesystems = append(filesystems, fs)
|
||||
}
|
||||
|
||||
return filesystems
|
||||
}
|
||||
|
||||
// getBlockDevices returns information about block devices
|
||||
func (c *Collector) getBlockDevices() []types.BlockDevice {
|
||||
partitions, err := disk.Partitions(true)
|
||||
if err != nil {
|
||||
return []types.BlockDevice{}
|
||||
}
|
||||
|
||||
var devices []types.BlockDevice
|
||||
deviceMap := make(map[string]bool)
|
||||
|
||||
for _, partition := range partitions {
|
||||
// Only include actual block devices
|
||||
if strings.HasPrefix(partition.Device, "/dev/") {
|
||||
deviceName := partition.Device
|
||||
if !deviceMap[deviceName] {
|
||||
deviceMap[deviceName] = true
|
||||
|
||||
device := types.BlockDevice{
|
||||
Name: deviceName,
|
||||
Model: "unknown",
|
||||
Size: 0,
|
||||
SerialNumber: "unknown",
|
||||
}
|
||||
devices = append(devices, device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
// SendMetrics sends system metrics to the agent-auth-api endpoint
|
||||
func (c *Collector) SendMetrics(agentAuthURL, accessToken, agentID string, metrics *types.SystemMetrics) error {
|
||||
// Create flattened metrics request for agent-auth-api
|
||||
metricsReq := c.CreateMetricsRequest(agentID, metrics)
|
||||
|
||||
return c.sendMetricsRequest(agentAuthURL, accessToken, metricsReq)
|
||||
}
|
||||
|
||||
// CreateMetricsRequest converts SystemMetrics to the flattened format expected by agent-auth-api
|
||||
func (c *Collector) CreateMetricsRequest(agentID string, systemMetrics *types.SystemMetrics) *types.MetricsRequest {
|
||||
return &types.MetricsRequest{
|
||||
AgentID: agentID,
|
||||
CPUUsage: systemMetrics.CPUUsage,
|
||||
MemoryUsage: systemMetrics.MemoryUsage,
|
||||
DiskUsage: systemMetrics.DiskUsage,
|
||||
NetworkInKbps: systemMetrics.NetworkInKbps,
|
||||
NetworkOutKbps: systemMetrics.NetworkOutKbps,
|
||||
IPAddress: systemMetrics.IPAddress,
|
||||
Location: systemMetrics.Location,
|
||||
AgentVersion: c.agentVersion,
|
||||
KernelVersion: systemMetrics.KernelVersion,
|
||||
DeviceFingerprint: c.generateDeviceFingerprint(systemMetrics),
|
||||
LoadAverages: map[string]float64{
|
||||
"load1": systemMetrics.LoadAvg1,
|
||||
"load5": systemMetrics.LoadAvg5,
|
||||
"load15": systemMetrics.LoadAvg15,
|
||||
},
|
||||
OSInfo: map[string]string{
|
||||
"platform": systemMetrics.Platform,
|
||||
"platform_family": systemMetrics.PlatformFamily,
|
||||
"platform_version": systemMetrics.PlatformVersion,
|
||||
"kernel_version": systemMetrics.KernelVersion,
|
||||
"kernel_arch": systemMetrics.KernelArch,
|
||||
},
|
||||
FilesystemInfo: systemMetrics.FilesystemInfo,
|
||||
BlockDevices: systemMetrics.BlockDevices,
|
||||
NetworkStats: map[string]uint64{
|
||||
"bytes_sent": systemMetrics.NetworkOutBytes,
|
||||
"bytes_recv": systemMetrics.NetworkInBytes,
|
||||
"total_bytes": systemMetrics.NetworkInBytes + systemMetrics.NetworkOutBytes,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// sendMetricsRequest sends the metrics request to the agent-auth-api
|
||||
func (c *Collector) sendMetricsRequest(agentAuthURL, accessToken string, metricsReq *types.MetricsRequest) error {
|
||||
// Wrap metrics in the expected payload structure
|
||||
payload := map[string]interface{}{
|
||||
"metrics": metricsReq,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal metrics: %w", err)
|
||||
}
|
||||
|
||||
// Send to /metrics endpoint
|
||||
metricsURL := fmt.Sprintf("%s/metrics", agentAuthURL)
|
||||
req, err := http.NewRequest("POST", metricsURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send metrics: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Check response status
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return fmt.Errorf("unauthorized")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("metrics request failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateDeviceFingerprint creates a unique device identifier
|
||||
func (c *Collector) generateDeviceFingerprint(metrics *types.SystemMetrics) string {
|
||||
fingerprint := fmt.Sprintf("%s-%s-%s", metrics.Hostname, metrics.Platform, metrics.KernelVersion)
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte(fingerprint))
|
||||
return fmt.Sprintf("%x", hasher.Sum(nil))[:16]
|
||||
}
|
||||
170
internal/types/types.go
Normal file
170
internal/types/types.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// SystemMetrics represents comprehensive system performance metrics
|
||||
type SystemMetrics struct {
|
||||
// System Information
|
||||
Hostname string `json:"hostname"`
|
||||
Platform string `json:"platform"`
|
||||
PlatformFamily string `json:"platform_family"`
|
||||
PlatformVersion string `json:"platform_version"`
|
||||
KernelVersion string `json:"kernel_version"`
|
||||
KernelArch string `json:"kernel_arch"`
|
||||
|
||||
// CPU Metrics
|
||||
CPUUsage float64 `json:"cpu_usage"`
|
||||
CPUCores int `json:"cpu_cores"`
|
||||
CPUModel string `json:"cpu_model"`
|
||||
|
||||
// Memory Metrics
|
||||
MemoryUsage float64 `json:"memory_usage"`
|
||||
MemoryTotal uint64 `json:"memory_total"`
|
||||
MemoryUsed uint64 `json:"memory_used"`
|
||||
MemoryFree uint64 `json:"memory_free"`
|
||||
MemoryAvailable uint64 `json:"memory_available"`
|
||||
SwapTotal uint64 `json:"swap_total"`
|
||||
SwapUsed uint64 `json:"swap_used"`
|
||||
SwapFree uint64 `json:"swap_free"`
|
||||
|
||||
// Disk Metrics
|
||||
DiskUsage float64 `json:"disk_usage"`
|
||||
DiskTotal uint64 `json:"disk_total"`
|
||||
DiskUsed uint64 `json:"disk_used"`
|
||||
DiskFree uint64 `json:"disk_free"`
|
||||
|
||||
// Network Metrics
|
||||
NetworkInKbps float64 `json:"network_in_kbps"`
|
||||
NetworkOutKbps float64 `json:"network_out_kbps"`
|
||||
NetworkInBytes uint64 `json:"network_in_bytes"`
|
||||
NetworkOutBytes uint64 `json:"network_out_bytes"`
|
||||
|
||||
// System Load
|
||||
LoadAvg1 float64 `json:"load_avg_1"`
|
||||
LoadAvg5 float64 `json:"load_avg_5"`
|
||||
LoadAvg15 float64 `json:"load_avg_15"`
|
||||
|
||||
// Process Information
|
||||
ProcessCount int `json:"process_count"`
|
||||
|
||||
// Network Information
|
||||
IPAddress string `json:"ip_address"`
|
||||
Location string `json:"location"`
|
||||
|
||||
// Filesystem Information
|
||||
FilesystemInfo []FilesystemInfo `json:"filesystem_info"`
|
||||
BlockDevices []BlockDevice `json:"block_devices"`
|
||||
|
||||
// Timestamp
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// FilesystemInfo represents individual filesystem statistics
|
||||
type FilesystemInfo struct {
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
Fstype string `json:"fstype"`
|
||||
Total uint64 `json:"total"`
|
||||
Used uint64 `json:"used"`
|
||||
Free uint64 `json:"free"`
|
||||
UsagePercent float64 `json:"usage_percent"`
|
||||
}
|
||||
|
||||
// BlockDevice represents block device information
|
||||
type BlockDevice struct {
|
||||
Name string `json:"name"`
|
||||
Size uint64 `json:"size"`
|
||||
Model string `json:"model"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
}
|
||||
|
||||
// NetworkStats represents detailed network interface statistics
|
||||
type NetworkStats struct {
|
||||
InterfaceName string `json:"interface_name"`
|
||||
BytesSent uint64 `json:"bytes_sent"`
|
||||
BytesRecv uint64 `json:"bytes_recv"`
|
||||
PacketsSent uint64 `json:"packets_sent"`
|
||||
PacketsRecv uint64 `json:"packets_recv"`
|
||||
ErrorsIn uint64 `json:"errors_in"`
|
||||
ErrorsOut uint64 `json:"errors_out"`
|
||||
DropsIn uint64 `json:"drops_in"`
|
||||
DropsOut uint64 `json:"drops_out"`
|
||||
}
|
||||
|
||||
// AuthToken represents the authentication token structure
|
||||
type AuthToken struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
TokenType string `json:"token_type"`
|
||||
AgentID string `json:"agent_id"`
|
||||
}
|
||||
|
||||
// DeviceAuthRequest represents the device authorization request
|
||||
type DeviceAuthRequest struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// DeviceAuthResponse represents the device authorization response
|
||||
type DeviceAuthResponse struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
|
||||
// TokenRequest represents the token request for device flow
|
||||
type TokenRequest struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
DeviceCode string `json:"device_code,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
}
|
||||
|
||||
// TokenResponse represents the token response
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
// HeartbeatRequest represents the agent heartbeat request
|
||||
type HeartbeatRequest struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Status string `json:"status"`
|
||||
Metrics SystemMetrics `json:"metrics"`
|
||||
}
|
||||
|
||||
// MetricsRequest represents the flattened metrics payload expected by agent-auth-api
|
||||
type MetricsRequest struct {
|
||||
// Agent identification
|
||||
AgentID string `json:"agent_id"`
|
||||
|
||||
// Basic metrics
|
||||
CPUUsage float64 `json:"cpu_usage"`
|
||||
MemoryUsage float64 `json:"memory_usage"`
|
||||
DiskUsage float64 `json:"disk_usage"`
|
||||
|
||||
// Network metrics
|
||||
NetworkInKbps float64 `json:"network_in_kbps"`
|
||||
NetworkOutKbps float64 `json:"network_out_kbps"`
|
||||
|
||||
// System information
|
||||
IPAddress string `json:"ip_address"`
|
||||
Location string `json:"location"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
KernelVersion string `json:"kernel_version"`
|
||||
DeviceFingerprint string `json:"device_fingerprint"`
|
||||
|
||||
// Structured data (JSON fields in database)
|
||||
LoadAverages map[string]float64 `json:"load_averages"`
|
||||
OSInfo map[string]string `json:"os_info"`
|
||||
FilesystemInfo []FilesystemInfo `json:"filesystem_info"`
|
||||
BlockDevices []BlockDevice `json:"block_devices"`
|
||||
NetworkStats map[string]uint64 `json:"network_stats"`
|
||||
}
|
||||
Reference in New Issue
Block a user