diff --git a/cmd/agent/main.go b/cmd/agent/main.go new file mode 100644 index 0000000..e6b5b9b --- /dev/null +++ b/cmd/agent/main.go @@ -0,0 +1,135 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "runtime" + "time" + + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/disk" + "github.com/shirou/gopsutil/v3/host" + "github.com/shirou/gopsutil/v3/mem" + "github.com/shirou/gopsutil/v3/net" +) + +type Metrics struct { + Hostname string `json:"hostname"` + Timestamp time.Time `json:"timestamp"` + CPU CPUMetric `json:"cpu"` + Memory MemMetric `json:"memory"` + Disk []DiskMetric `json:"disk"` + Network NetMetric `json:"network"` +} + +type CPUMetric struct { + UsagePercent float64 `json:"usage_percent"` + Cores int `json:"cores"` +} + +type MemMetric struct { + Total uint64 `json:"total"` + Used uint64 `json:"used"` + UsedPercent float64 `json:"used_percent"` +} + +type DiskMetric struct { + Path string `json:"path"` + Total uint64 `json:"total"` + Used uint64 `json:"used"` + UsedPercent float64 `json:"used_percent"` +} + +type NetMetric struct { + BytesSent uint64 `json:"bytes_sent"` + BytesRecv uint64 `json:"bytes_recv"` +} + +func main() { + serverURL := os.Getenv("OPHION_SERVER") + if serverURL == "" { + serverURL = "http://localhost:8080" + } + + apiKey := os.Getenv("OPHION_API_KEY") + if apiKey == "" { + log.Fatal("OPHION_API_KEY is required") + } + + interval := 30 * time.Second + log.Printf("🐍 OPHION Agent starting - reporting to %s every %s", serverURL, interval) + + ticker := time.NewTicker(interval) + for range ticker.C { + metrics := collectMetrics() + sendMetrics(serverURL, apiKey, metrics) + } +} + +func collectMetrics() Metrics { + hostname, _ := os.Hostname() + + cpuPercent, _ := cpu.Percent(time.Second, false) + cpuUsage := 0.0 + if len(cpuPercent) > 0 { + cpuUsage = cpuPercent[0] + } + + memInfo, _ := mem.VirtualMemory() + + diskInfo, _ := disk.Usage("/") + disks := []DiskMetric{{ + Path: "/", + Total: diskInfo.Total, + Used: diskInfo.Used, + UsedPercent: diskInfo.UsedPercent, + }} + + netIO, _ := net.IOCounters(false) + netMetric := NetMetric{} + if len(netIO) > 0 { + netMetric.BytesSent = netIO[0].BytesSent + netMetric.BytesRecv = netIO[0].BytesRecv + } + + return Metrics{ + Hostname: hostname, + Timestamp: time.Now(), + CPU: CPUMetric{ + UsagePercent: cpuUsage, + Cores: runtime.NumCPU(), + }, + Memory: MemMetric{ + Total: memInfo.Total, + Used: memInfo.Used, + UsedPercent: memInfo.UsedPercent, + }, + Disk: disks, + Network: netMetric, + } +} + +func sendMetrics(serverURL, apiKey string, metrics Metrics) { + data, _ := json.Marshal(metrics) + + req, _ := http.NewRequest("POST", serverURL+"/api/v1/metrics", bytes.NewBuffer(data)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + log.Printf("Error sending metrics: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + log.Printf("✓ Metrics sent: CPU=%.1f%% MEM=%.1f%%", + metrics.CPU.UsagePercent, metrics.Memory.UsedPercent) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..f660472 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "log" + "os" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/logger" +) + +func main() { + app := fiber.New(fiber.Config{ + AppName: "OPHION Observability Platform", + }) + + // Middleware + app.Use(logger.New()) + app.Use(cors.New()) + + // Health check + app.Get("/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "healthy", + "service": "ophion", + "version": "0.1.0", + }) + }) + + // API routes + api := app.Group("/api/v1") + api.Get("/metrics", getMetrics) + api.Post("/metrics", ingestMetrics) + api.Get("/logs", getLogs) + api.Post("/logs", ingestLogs) + api.Get("/alerts", getAlerts) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + log.Printf("🐍 OPHION starting on port %s", port) + log.Fatal(app.Listen(":" + port)) +} + +func getMetrics(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"metrics": []string{}}) +} + +func ingestMetrics(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"status": "received"}) +} + +func getLogs(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"logs": []string{}}) +} + +func ingestLogs(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"status": "received"}) +} + +func getAlerts(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"alerts": []string{}}) +} diff --git a/deploy/docker/Dockerfile.server b/deploy/docker/Dockerfile.server new file mode 100644 index 0000000..e9c2c4a --- /dev/null +++ b/deploy/docker/Dockerfile.server @@ -0,0 +1,13 @@ +FROM golang:1.22-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o ophion-server ./cmd/server + +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates +WORKDIR /app +COPY --from=builder /app/ophion-server . +EXPOSE 8080 +CMD ["./ophion-server"] diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml new file mode 100644 index 0000000..37a45d8 --- /dev/null +++ b/deploy/docker/docker-compose.yml @@ -0,0 +1,58 @@ +version: '3.8' + +services: + ophion-server: + build: + context: ../.. + dockerfile: deploy/docker/Dockerfile.server + ports: + - "8080:8080" + environment: + - DATABASE_URL=postgres://ophion:ophion@postgres:5432/ophion + - CLICKHOUSE_URL=clickhouse://clickhouse:9000/ophion + - REDIS_URL=redis://redis:6379 + - JWT_SECRET=${JWT_SECRET:-change-me-in-production} + depends_on: + - postgres + - clickhouse + - redis + restart: unless-stopped + + ophion-web: + build: + context: ../.. + dockerfile: deploy/docker/Dockerfile.web + ports: + - "3000:3000" + environment: + - API_URL=http://ophion-server:8080 + depends_on: + - ophion-server + restart: unless-stopped + + postgres: + image: postgres:16-alpine + environment: + - POSTGRES_USER=ophion + - POSTGRES_PASSWORD=ophion + - POSTGRES_DB=ophion + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + + clickhouse: + image: clickhouse/clickhouse-server:24.1 + volumes: + - clickhouse_data:/var/lib/clickhouse + restart: unless-stopped + + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + restart: unless-stopped + +volumes: + postgres_data: + clickhouse_data: + redis_data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e80d2e4 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/bigtux/ophion + +go 1.22 + +require ( + github.com/gofiber/fiber/v2 v2.52.0 + github.com/shirou/gopsutil/v3 v3.24.1 + github.com/golang-jwt/jwt/v5 v5.2.0 +) diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..8c4ae9c --- /dev/null +++ b/install.sh @@ -0,0 +1,49 @@ +#!/bin/bash +set -e + +echo "🐍 OPHION - Observability Platform Installer" +echo "=============================================" + +# Check Docker +if ! command -v docker &> /dev/null; then + echo "❌ Docker not found. Installing..." + curl -fsSL https://get.docker.com | sh +fi + +# Check Docker Compose +if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then + echo "❌ Docker Compose not found. Please install it." + exit 1 +fi + +# Create directory +INSTALL_DIR="${OPHION_DIR:-/opt/ophion}" +mkdir -p "$INSTALL_DIR" +cd "$INSTALL_DIR" + +# Download docker-compose +echo "📥 Downloading OPHION..." +curl -fsSL https://raw.githubusercontent.com/bigtux/ophion/main/deploy/docker/docker-compose.yml -o docker-compose.yml + +# Generate secrets +JWT_SECRET=$(openssl rand -hex 32) +echo "JWT_SECRET=$JWT_SECRET" > .env + +# Start services +echo "🚀 Starting OPHION..." +docker compose up -d + +echo "" +echo "✅ OPHION installed successfully!" +echo "" +echo "📊 Dashboard: http://localhost:3000" +echo "🔌 API: http://localhost:8080" +echo "" +echo "Next steps:" +echo "1. Open http://localhost:3000 in your browser" +echo "2. Create your admin account" +echo "3. Add your first server with the agent" +echo "" +echo "To install the agent on a server:" +echo " curl -fsSL https://get.ophion.io/agent | bash" +echo "" diff --git a/internal/api/ratelimit.go b/internal/api/ratelimit.go new file mode 100644 index 0000000..7a71087 --- /dev/null +++ b/internal/api/ratelimit.go @@ -0,0 +1,27 @@ +package api + +import ( + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/limiter" +) + +func RateLimitMiddleware() fiber.Handler { + return limiter.New(limiter.Config{ + Max: 100, // 100 requests + Expiration: 1 * time.Minute, // per minute + KeyGenerator: func(c *fiber.Ctx) string { + // Use API key or IP for rate limiting + if key := c.Locals("api_key"); key != nil { + return key.(string) + } + return c.IP() + }, + LimitReached: func(c *fiber.Ctx) error { + return c.Status(429).JSON(fiber.Map{ + "error": "Rate limit exceeded", + }) + }, + }) +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..261914a --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,93 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt/v5" +) + +var jwtSecret []byte + +func Init(secret string) { + jwtSecret = []byte(secret) +} + +// GenerateAPIKey creates a new API key for agents +func GenerateAPIKey() string { + bytes := make([]byte, 32) + rand.Read(bytes) + return "ophion_" + hex.EncodeToString(bytes) +} + +// GenerateJWT creates a JWT token for users +func GenerateJWT(userID string, email string) (string, error) { + claims := jwt.MapClaims{ + "sub": userID, + "email": email, + "iat": time.Now().Unix(), + "exp": time.Now().Add(24 * time.Hour).Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) +} + +// ValidateJWT validates a JWT token +func ValidateJWT(tokenString string) (*jwt.MapClaims, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return jwtSecret, nil + }) + + if err != nil || !token.Valid { + return nil, err + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, jwt.ErrInvalidKey + } + + return &claims, nil +} + +// AuthMiddleware protects routes +func AuthMiddleware() fiber.Handler { + return func(c *fiber.Ctx) error { + authHeader := c.Get("Authorization") + + if authHeader == "" { + return c.Status(401).JSON(fiber.Map{ + "error": "Missing authorization header", + }) + } + + // Support both "Bearer " and API keys + token := strings.TrimPrefix(authHeader, "Bearer ") + + // Check if it's an API key + if strings.HasPrefix(token, "ophion_") { + // TODO: Validate API key against database + c.Locals("auth_type", "api_key") + c.Locals("api_key", token) + return c.Next() + } + + // Validate JWT + claims, err := ValidateJWT(token) + if err != nil { + return c.Status(401).JSON(fiber.Map{ + "error": "Invalid token", + }) + } + + c.Locals("auth_type", "jwt") + c.Locals("user_id", (*claims)["sub"]) + c.Locals("email", (*claims)["email"]) + + return c.Next() + } +}