From cb9949021aa8259612c20aad97242032ed140b2f Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Thu, 21 Aug 2025 22:53:28 -0700 Subject: [PATCH 01/23] - Added MariaDB support for handling pod template personalization - Added file upload functionality to download and serve pod template images - Created helper functions and edited pre-existing functions to use the new pod template format --- database/connect.go | 83 ++++++++ database/query.go | 164 ++++++++++++++++ go.mod | 2 + go.sum | 4 + main.go | 23 +++ proxmox/cloning/pods.go | 2 +- proxmox/cloning/templates.go | 359 ++++++++++++++++++++++++++++++----- proxmox/images/upload.go | 115 +++++++++++ proxmox/vms.go | 37 +++- 9 files changed, 737 insertions(+), 52 deletions(-) create mode 100644 database/connect.go create mode 100644 database/query.go create mode 100644 proxmox/images/upload.go diff --git a/database/connect.go b/database/connect.go new file mode 100644 index 0000000..1c03670 --- /dev/null +++ b/database/connect.go @@ -0,0 +1,83 @@ +package database + +import ( + "database/sql" + "fmt" + "log" + "os" + + _ "github.com/go-sql-driver/mysql" +) + +// DB is the global database connection +var DB *sql.DB + +// Connect to the MariaDB database +func ConnectDB() (*sql.DB, error) { + dbHost := os.Getenv("DB_HOST") + dbPort := os.Getenv("DB_PORT") + dbUser := os.Getenv("DB_USER") + dbPassword := os.Getenv("DB_PASSWORD") + dbName := os.Getenv("DB_NAME") + + // Check if any required environment variables are not set + if dbHost == "" || dbPort == "" || dbUser == "" || dbName == "" || dbPassword == "" { + return nil, fmt.Errorf("one or more database environment variables are not set") + } + + // Build the Data Source Name (DSN) + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", + dbUser, dbPassword, dbHost, dbPort, dbName) + + // Open database connection + db, err := sql.Open("mysql", dsn) + if err != nil { + return nil, fmt.Errorf("failed to open database connection: %w", err) + } + + // Test the connection + err = db.Ping() + if err != nil { + db.Close() + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + log.Printf("Successfully connected to MariaDB database: %s", dbName) + + // Set the global DB variable + DB = db + + return db, nil +} + +// CloseDB closes the database connection +func CloseDB() error { + if DB != nil { + err := DB.Close() + if err != nil { + return fmt.Errorf("failed to close database connection: %w", err) + } + log.Println("Database connection closed") + } + return nil +} + +// GetDB returns the global database connection +func GetDB() *sql.DB { + return DB +} + +// InitializeDB initializes the database connection and ensures it's ready for use +func InitializeDB() error { + _, err := ConnectDB() + if err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + + // Configure connection pool settings + DB.SetMaxOpenConns(25) // Maximum number of open connections + DB.SetMaxIdleConns(25) // Maximum number of idle connections + DB.SetConnMaxLifetime(0) // Maximum connection lifetime (0 = unlimited) + + return nil +} diff --git a/database/query.go b/database/query.go new file mode 100644 index 0000000..9eb52f0 --- /dev/null +++ b/database/query.go @@ -0,0 +1,164 @@ +package database + +import ( + "database/sql" + "fmt" +) + +// Template represents a template record from the database +type Template struct { + Name string `json:"name"` + Description string `json:"description"` + ImagePath string `json:"image_path"` + Visible bool `json:"visible"` + VMCount int `json:"vm_count"` + Deployments int `json:"deployments"` + CreatedAt string `json:"created_at"` +} + +func BuildTemplates(rows *sql.Rows) ([]Template, error) { + templates := []Template{} + + // Iterate through the result set + for rows.Next() { + var template Template + err := rows.Scan( + &template.Name, + &template.Description, + &template.ImagePath, + &template.Visible, + &template.VMCount, + &template.Deployments, + &template.CreatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan row: %w", err) + } + templates = append(templates, template) + } + + return templates, nil +} + +// Returns all visible templates +func SelectVisibleTemplates() ([]Template, error) { + if DB == nil { + return nil, fmt.Errorf("database connection is not initialized") + } + + var query = "SELECT * FROM templates WHERE visible = true" + + rows, err := DB.Query(query) + if err != nil { + return nil, fmt.Errorf("failed to execute query (%s): %w", query, err) + } + defer rows.Close() + + templates, err := BuildTemplates(rows) + if err != nil { + return nil, fmt.Errorf("failed to build templates: %w", err) + } + + return templates, nil +} + +// Returns all templates +func SelectAllTemplates() ([]Template, error) { + if DB == nil { + return nil, fmt.Errorf("database connection is not initialized") + } + + var query = "SELECT * FROM templates" + + rows, err := DB.Query(query) + if err != nil { + return nil, fmt.Errorf("failed to execute query (%s): %w", query, err) + } + defer rows.Close() + + templates, err := BuildTemplates(rows) + if err != nil { + return nil, fmt.Errorf("failed to build templates: %w", err) + } + + return templates, nil +} + +// Insert template into database +func InsertTemplate(template Template) error { + if DB == nil { + return fmt.Errorf("database connection is not initialized") + } + + query := "INSERT INTO templates (name, description, image_path, visible, vm_count) VALUES (?, ?, ?, ?, ?)" + + _, err := DB.Exec(query, template.Name, template.Description, template.ImagePath, template.Visible, template.VMCount) + if err != nil { + return fmt.Errorf("failed to execute query (%s): %w", query, err) + } + + return nil +} + +// Update template data +func UpdateTemplate(template Template) error { + if DB == nil { + return fmt.Errorf("database connection is not initialized") + } + + query := "UPDATE templates SET description = ?, image_path = ?, visible = ?, vm_count = ? WHERE name = ?" + + _, err := DB.Exec(query, template.Description, template.ImagePath, template.Visible, template.VMCount, template.Name) + if err != nil { + return fmt.Errorf("failed to execute query (%s): %w", query, err) + } + + return nil +} + +// Helper function to select all template names +func SelectAllTemplateNames() ([]string, error) { + templates, err := SelectAllTemplates() + if err != nil { + return nil, err + } + + var templateNames []string + for _, template := range templates { + templateNames = append(templateNames, template.Name) + } + + return templateNames, nil +} + +// TODO: Implement +func AddDeployment(templateName string) error { + if DB == nil { + return fmt.Errorf("database connection is not initialized") + } + + query := "UPDATE templates SET deployments = deployments + 1 WHERE name = ?" + + _, err := DB.Exec(query, templateName) + if err != nil { + return fmt.Errorf("failed to execute query (%s): %w", query, err) + } + + return nil +} + +// Toggles the visibility of a template +func ToggleVisibility(templateName string) error { + if DB == nil { + return fmt.Errorf("database connection is not initialized") + } + + query := "UPDATE templates SET visible = NOT visible WHERE name = ?" + + _, err := DB.Exec(query, templateName) + if err != nil { + return fmt.Errorf("failed to execute query (%s): %w", query, err) + } + + return nil +} diff --git a/go.mod b/go.mod index 85491cf..e70ff74 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( ) require ( + filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/bsm/redislock v0.9.4 // indirect github.com/bytedance/sonic v1.13.2 // indirect @@ -23,6 +24,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/context v1.1.2 // indirect diff --git a/go.sum b/go.sum index 2e2eb65..e500483 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= @@ -41,6 +43,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= diff --git a/main.go b/main.go index 03ed0c1..bb92a7c 100644 --- a/main.go +++ b/main.go @@ -5,8 +5,10 @@ import ( "os" "github.com/P-E-D-L/proclone/auth" + "github.com/P-E-D-L/proclone/database" "github.com/P-E-D-L/proclone/proxmox" "github.com/P-E-D-L/proclone/proxmox/cloning" + "github.com/P-E-D-L/proclone/proxmox/images" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" @@ -19,7 +21,19 @@ func init() { } func main() { + // Ensure upload directory exists + if err := os.MkdirAll(images.UploadDir, os.ModePerm); err != nil { + log.Fatalf("failed to create upload dir: %v", err) + } + + // Initialize database connection + if err := database.InitializeDB(); err != nil { + log.Fatalf("Failed to initialize database: %v", err) + } + defer database.CloseDB() + r := gin.Default() + r.MaxMultipartMemory = 10 << 20 // 10 MiB // store session cookie // **IN PROD USE REAL SECURE KEY** @@ -46,6 +60,7 @@ func main() { // Proxmox User Template endpoints user.GET("/proxmox/templates", cloning.GetAvailableTemplates) + user.GET("/proxmox/templates/images/:filename", images.HandleGetFile) user.POST("/proxmox/templates/clone", cloning.CloneTemplateToPod) user.POST("/proxmox/pods/delete", cloning.DeletePod) @@ -67,6 +82,14 @@ func main() { // Proxmox Admin Pod endpoints admin.GET("/proxmox/pods/all", cloning.GetPods) + // Proxmox Admin Template endpoints + admin.POST("/proxmox/templates/publish", cloning.PublishTemplate) + admin.POST("/proxmox/templates/update", cloning.UpdateTemplate) + admin.GET("/proxmox/templates", cloning.GetAllTemplates) + admin.GET("/proxmox/templates/unpublished", cloning.GetUnpublishedTemplates) + admin.POST("/proxmox/templates/toggle", cloning.ToggleTemplateVisibility) + admin.POST("/proxmox/templates/image/upload", images.HandleUpload) + // Active Directory User endpoints admin.GET("/users", auth.GetUsers) diff --git a/proxmox/cloning/pods.go b/proxmox/cloning/pods.go index 441ec65..5be3495 100644 --- a/proxmox/cloning/pods.go +++ b/proxmox/cloning/pods.go @@ -104,7 +104,7 @@ func GetPods(c *gin.Context) { return } - // fetch template reponse + // fetch pod response var podResponse *PodResponse var error error diff --git a/proxmox/cloning/templates.go b/proxmox/cloning/templates.go index cc4fe81..10311bf 100644 --- a/proxmox/cloning/templates.go +++ b/proxmox/cloning/templates.go @@ -1,40 +1,139 @@ package cloning import ( + "encoding/json" "fmt" "log" "net/http" - "regexp" + "strings" + + "slices" "github.com/P-E-D-L/proclone/auth" + "github.com/P-E-D-L/proclone/database" "github.com/P-E-D-L/proclone/proxmox" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) +type ProxmoxPool struct { + PoolID string `json:"poolid"` +} + type TemplateResponse struct { - Templates []TemplateWithVMs `json:"templates"` + Templates []database.Template `json:"templates"` +} + +type ProxmoxPoolResponse struct { + Pools []ProxmoxPool `json:"pools"` } -type TemplateWithVMs struct { - Name string `json:"name"` - Deployments int `json:"deployments"` - VMs []proxmox.VirtualResource `json:"vms"` +type UnpublishedTemplateResponse struct { + Templates []UnpublishedTemplate `json:"templates"` +} + +type UnpublishedTemplate struct { + Name string `json:"name"` } /* - * ===== GET ALL CURRENT POD TEMPLATES ===== + * /api/proxmox/templates + * Returns a list of templates based on their current visibility */ func GetAvailableTemplates(c *gin.Context) { session := sessions.Default(c) username := session.Get("username") - // Make sure user is authenticated (redundant) + // Make sure user is authenticated isAuth, _ := auth.IsAuthenticated(c) if !isAuth { log.Printf("Unauthorized access attempt") c.JSON(http.StatusForbidden, gin.H{ - "error": "Only authenticated users can access template data", + "error": "Only authenticated users can access templates", + }) + return + } + + // fetch template response from database + templates, err := database.SelectVisibleTemplates() + if err != nil { + log.Printf("Database error for user %s: %v", username, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to fetch templates from database: %v", err), + }) + return + } + + // convert templates to response format + templatesResponse, err := BuildTemplatesResponse(templates) + if err != nil { + log.Printf("Failed to get available templates response for user %s: %v", username, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to process templates: %v", err), + }) + return + } + + log.Printf("Successfully fetched %d templates for user %s", len(templates), username) + c.JSON(http.StatusOK, templatesResponse) +} + +/* + * /api/admin/proxmox/templates + * Returns a list of all templates + */ +func GetAllTemplates(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("username") + isAdmin := session.Get("is_admin") + + // Make sure user is admin + if !isAdmin.(bool) { + log.Printf("Forbidden access attempt") + c.JSON(http.StatusForbidden, gin.H{ + "error": "Only Admin users can create a template", + }) + return + } + + // fetch template response from database + templates, err := database.SelectAllTemplates() + if err != nil { + log.Printf("Database error for user %s: %v", username, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to fetch templates from database: %v", err), + }) + return + } + + // convert templates to response format + templatesResponse, err := BuildTemplatesResponse(templates) + if err != nil { + log.Printf("Failed to get available templates response for user %s: %v", username, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to process templates: %v", err), + }) + return + } + + log.Printf("Successfully fetched %d templates for user %s", len(templates), username) + c.JSON(http.StatusOK, templatesResponse) +} + +func BuildTemplatesResponse(templates []database.Template) (TemplateResponse, error) { + return TemplateResponse{Templates: templates}, nil +} + +func GetUnpublishedTemplates(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("username") + isAdmin := session.Get("is_admin") + + // Make sure user is admin (redundant) + if !isAdmin.(bool) { + log.Printf("Forbidden access attempt") + c.JSON(http.StatusForbidden, gin.H{ + "error": "Only Admin users can create a template", }) return } @@ -51,66 +150,230 @@ func GetAvailableTemplates(c *gin.Context) { return } - // If no proxmox host specified, return empty repsonse + // If no proxmox host specified, return empty response if config.Host == "" { log.Printf("No proxmox server configured") - c.JSON(http.StatusOK, proxmox.VirtualMachineResponse{VirtualMachines: []proxmox.VirtualResource{}}) + c.JSON(http.StatusOK, UnpublishedTemplateResponse{Templates: []UnpublishedTemplate{}}) return } - // fetch template response - templateResponse, err := getTemplateResponse(config) + // Get all Kamino templates from Proxmox + allKaminoTemplates, err := getAllKaminoTemplateNames(config) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to fetch template list from proxmox cluster", + "error": "Failed to fetch pool list from proxmox cluster", "details": err, }) return } - log.Printf("Successfully fetched template list for user %s", username) - c.JSON(http.StatusOK, templateResponse) -} + // Get published template names from database + publishedTemplateNames, err := database.SelectAllTemplateNames() + if err != nil { + log.Printf("Database error for user %s: %v", username, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to fetch published templates from database: %v", err), + }) + return + } -func getTemplateResponse(config *proxmox.ProxmoxConfig) (*TemplateResponse, error) { + // Get unpublished templates (templates in Proxmox but not in database) + unpublishedTemplates := getUnpublishedTemplateNames(allKaminoTemplates, publishedTemplateNames) + + log.Printf("Successfully fetched unpublished template list for admin user %s", username) + c.JSON(http.StatusOK, UnpublishedTemplateResponse{Templates: unpublishedTemplates}) +} - // get all virtual resources from proxmox - resources, err := proxmox.GetVirtualResources(config) +func getAllKaminoTemplateNames(config *proxmox.ProxmoxConfig) (*ProxmoxPoolResponse, error) { + // Fetch pools from Proxmox API + statusCode, body, err := proxmox.MakeRequest(config, "api2/json/pools", "GET", nil, nil) if err != nil { - return nil, err - } - - // map template pools to their VMs - templateMap := make(map[string]*TemplateWithVMs) - reg := regexp.MustCompile(`kamino_template_.*`) - - // first pass: find all pools that are templates - for _, r := range *resources { - if r.Type == "pool" && reg.MatchString(r.ResourcePool) { - name := r.ResourcePool[16:] - templateMap[name] = &TemplateWithVMs{ - Name: name, - Deployments: 0, - VMs: []proxmox.VirtualResource{}, - } + return nil, fmt.Errorf("failed to fetch pools from Proxmox: %v", err) + } + + if statusCode != http.StatusOK { + return nil, fmt.Errorf("proxmox API returned status %d", statusCode) + } + + // Parse the response + var apiResp proxmox.ProxmoxAPIResponse + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("failed to parse pools response: %v", err) + } + + // Parse the pools data + var pools []ProxmoxPool + if err := json.Unmarshal(apiResp.Data, &pools); err != nil { + return nil, fmt.Errorf("failed to extract pools from response: %v", err) + } + + // Filter pools that start with "kamino_template_" + var templatePools []ProxmoxPool + for _, pool := range pools { + if strings.HasPrefix(pool.PoolID, "kamino_template_") { + templatePools = append(templatePools, pool) } } - // second pass: map VMs to their template pool - for _, r := range *resources { - if r.Type == "qemu" && reg.MatchString(r.ResourcePool) { - name := r.ResourcePool[16:] - if template, ok := templateMap[name]; ok { - template.VMs = append(template.VMs, r) - } + return &ProxmoxPoolResponse{Pools: templatePools}, nil +} + +func GetUnpublishedTemplatesResponse(allKaminoTemplates *ProxmoxPoolResponse, publishedTemplateNames []string) (*ProxmoxPoolResponse, error) { + var unpublishedPools []ProxmoxPool + + for _, pool := range allKaminoTemplates.Pools { + if !slices.Contains(publishedTemplateNames, pool.PoolID) { + unpublishedPools = append(unpublishedPools, pool) + } + } + + return &ProxmoxPoolResponse{Pools: unpublishedPools}, nil +} + +// getUnpublishedTemplateNames returns a list of template names that are in Proxmox but not published in the database +func getUnpublishedTemplateNames(allKaminoTemplates *ProxmoxPoolResponse, publishedTemplateNames []string) []UnpublishedTemplate { + var unpublishedTemplates []UnpublishedTemplate + + for _, pool := range allKaminoTemplates.Pools { + // Remove the "kamino_template_" prefix to get the actual template name + templateName := strings.TrimPrefix(pool.PoolID, "kamino_template_") + + // Check if this template name is not in the published list + if !slices.Contains(publishedTemplateNames, templateName) { + unpublishedTemplates = append(unpublishedTemplates, UnpublishedTemplate{ + Name: templateName, + }) } } - // build response - var templateResponse TemplateResponse - for _, template := range templateMap { - templateResponse.Templates = append(templateResponse.Templates, *template) + return unpublishedTemplates +} + +/* + * /api/admin/proxmox/templates/publish + * This function publishes a template that is on proxmox + */ +func PublishTemplate(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("username") + isAdmin := session.Get("is_admin") + + // Make sure user is authenticated (redundant) + if !isAdmin.(bool) { + log.Printf("Forbidden access attempt") + c.JSON(http.StatusForbidden, gin.H{ + "error": "Only Admin users can create a template", + }) + return + } + + var template database.Template + if err := c.ShouldBindJSON(&template); err != nil { + log.Printf("Failed to bind JSON for user %s: %v", username, err) + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request payload", + }) + return + } + + // Insert the new template into the database + if err := database.InsertTemplate(template); err != nil { + log.Printf("Database error for user %s: %v", username, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to create template: %v", err), + }) + return + } + + log.Printf("Successfully created template %s for admin user %s", template.Name, username) + c.JSON(http.StatusCreated, gin.H{ + "message": "Template created successfully", + }) +} + +/* + * /api/admin/proxmox/templates/update + * This function updates an existing template + */ +func UpdateTemplate(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("username") + isAdmin := session.Get("is_admin") + + // Make sure user is authenticated and is an admin + if !isAdmin.(bool) { + log.Printf("Forbidden access attempt") + c.JSON(http.StatusForbidden, gin.H{ + "error": "Only Admin users can update a template", + }) + return + } + + var template database.Template + if err := c.ShouldBindJSON(&template); err != nil { + log.Printf("Failed to bind JSON for user %s: %v", username, err) + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request payload", + }) + return + } + + // Update the template in the database + if err := database.UpdateTemplate(template); err != nil { + log.Printf("Database error for user %s: %v", username, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to update template: %v", err), + }) + return + } + + log.Printf("Successfully updated template %s for admin user %s", template.Name, username) + c.JSON(http.StatusOK, gin.H{ + "message": "Template updated successfully", + }) +} + +/* + * /api/admin/proxmox/templates/update + * This function toggles the visibility of a published template + */ +func ToggleTemplateVisibility(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("username") + isAdmin := session.Get("is_admin") + + // Make sure user is authenticated and is an admin + if !isAdmin.(bool) { + log.Printf("Forbidden access attempt") + c.JSON(http.StatusForbidden, gin.H{ + "error": "Only Admin users can update a template", + }) + return + } + + var req struct { + TemplateName string `json:"template_name"` + } + if err := c.ShouldBindJSON(&req); err != nil { + log.Printf("Failed to bind JSON for user %s: %v", username, err) + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request payload", + }) + return + } + templateName := req.TemplateName + + // Update the template in the database + if err := database.ToggleVisibility(templateName); err != nil { + log.Printf("Database error for user %s: %v", username, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to update template: %v", err), + }) + return } - return &templateResponse, nil + log.Printf("Successfully toggled template visibility %s for admin user %s", templateName, username) + c.JSON(http.StatusOK, gin.H{ + "message": "Template visibility toggled successfully", + }) } diff --git a/proxmox/images/upload.go b/proxmox/images/upload.go new file mode 100644 index 0000000..8a5281d --- /dev/null +++ b/proxmox/images/upload.go @@ -0,0 +1,115 @@ +package images + +import ( + "fmt" + "io" + "log" + "mime/multipart" + "net/http" + "path/filepath" + "strings" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +const UploadDir = "./uploads" + +// Use a map to define allowed MIME types for better performance +// and to avoid using a switch statement +var allowedMIMEs = map[string]struct{}{ + "image/jpeg": {}, + "image/png": {}, +} + +func HandleUpload(c *gin.Context) { + session := sessions.Default(c) + isAdmin := session.Get("is_admin") + + // Make sure user is admin (redundant) + if !isAdmin.(bool) { + log.Printf("Forbidden access attempt") + c.JSON(http.StatusForbidden, gin.H{ + "error": "Only Admin users can upload a template image", + }) + return + } + + // Check header for multipart/form-data + if !strings.HasPrefix(c.Request.Header.Get("Content-Type"), "multipart/form-data") { + c.JSON(http.StatusBadRequest, gin.H{"error": "Content-Type must be multipart/form-data"}) + return + } + + // Parse the multipart form + file, header, err := c.Request.FormFile("image") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "image field is required"}) + return + } + defer file.Close() + + // Basic check: Is file size 0? + if header.Size == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "uploaded file is empty"}) + return + } + + // Block unsupported file types + filetype, err := detectMIME(file) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to detect file type"}) + return + } + if _, ok := allowedMIMEs[filetype]; !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported file type", "type": filetype}) + return + } + + // Reset file pointer back to beginning + if _, err := file.Seek(0, io.SeekStart); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset file reader"}) + return + } + // File name sanitization + filename := filepath.Base(header.Filename) // basic sanitization + filename = filepath.Clean(filename) // clean up the filename + filename = strings.ReplaceAll(filename, " ", "_") // replace spaces with underscores + + // Unique file name + // Save with a UUID filename to avoid name collisions + // generate unique filename + newFilename := fmt.Sprintf("%s-%s", uuid.NewString(), filename) + outPath := filepath.Join(UploadDir, newFilename) + + // Save file using Gin utility + if err := c.SaveUploadedFile(header, outPath); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "unable to save file"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "file uploaded successfully", + "filename": newFilename, + "mime_type": filetype, + "path": outPath, + }) +} + +// detectMIME reads a small buffer to determine the file's MIME type +func detectMIME(f multipart.File) (string, error) { + buffer := make([]byte, 512) + if _, err := f.Read(buffer); err != nil && err != io.EOF { + return "", err + } + return http.DetectContentType(buffer), nil +} + +func HandleGetFile(c *gin.Context) { + filename := c.Param("filename") + filePath := filepath.Join(UploadDir, filename) + + // Serve the file + c.File(filePath) +} diff --git a/proxmox/vms.go b/proxmox/vms.go index d9b65dc..84eafb4 100644 --- a/proxmox/vms.go +++ b/proxmox/vms.go @@ -58,13 +58,21 @@ type VMPowerResponse struct { Success int `json:"success"` } +type Pool struct { + Poolid string `json:"poolid"` + Members []VirtualResource `json:"members"` +} + type PoolResponse struct { Data Pool `json:"data"` } -type Pool struct { - Poolid string `json:"poolid"` - Members []VirtualResource `json:"members"` +type PoolName struct { + PoolName string `json:"poolid"` +} + +type PoolNamesResponse struct { + Data []PoolName `json:"data"` } /* @@ -558,3 +566,26 @@ func GetPoolMembers(config *ProxmoxConfig, pool string) (members []VirtualResour // return array of resource pool members return apiResp.Data.Members, nil } + +// Helper function that retrieves all pool names in proxmox +func GetPoolNames(config *ProxmoxConfig) (pools []string, err error) { + // Prepare proxmox pool get URL + poolPath := "api2/json/pools" + + _, body, err := MakeRequest(config, poolPath, "GET", nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to request resource pools: %v", err) + } + + // Parse response into PoolsResponse struct + var apiResp PoolNamesResponse + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("failed to parse status response: %v", err) + } + + // return array of resource pool names + for _, pool := range apiResp.Data { + pools = append(pools, pool.PoolName) + } + return pools, nil +} From 420f7483a55963707c798427ac27184afa99902b Mon Sep 17 00:00:00 2001 From: HGWJ Date: Sat, 23 Aug 2025 15:12:40 -0700 Subject: [PATCH 02/23] add signoz + fix packages --- .env | 3 ++ go.mod | 42 ++++++++++++------ go.sum | 57 +++++++++++++++++++++++++ main.go | 76 ++++++++++++++++++++++++++++++--- proxmox/cloning/cloning.go | 6 +-- proxmox/cloning/deleting.go | 4 +- proxmox/cloning/networking.go | 2 +- proxmox/cloning/optimization.go | 2 +- proxmox/cloning/pods.go | 4 +- proxmox/cloning/templates.go | 6 +-- 10 files changed, 172 insertions(+), 30 deletions(-) diff --git a/.env b/.env index 9befeb2..601ee37 100644 --- a/.env +++ b/.env @@ -10,3 +10,6 @@ PROXMOX_TOKEN_ID=kaminosvc@pve!kamino-token PROXMOX_TOKEN_SECRET=placeholder PROXMOX_VERIFY_SSL="false" PROXMOX_NODES=gonk,commando,gemini +SERVICE_NAME="proclone" +OTEL_EXPORTER_OTLP_ENDPOINT="signoz-otel-collector.signoz.svc.cluster.local:4317" +INSECURE_MODE="true" \ No newline at end of file diff --git a/go.mod b/go.mod index e70ff74..b2b665f 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ -module github.com/P-E-D-L/proclone +module github.com/cpp-cyber/proclone go 1.24.1 require ( github.com/gin-contrib/sessions v1.0.3 - github.com/gin-gonic/gin v1.10.0 + github.com/gin-gonic/gin v1.10.1 github.com/go-ldap/ldap/v3 v3.4.11 github.com/joho/godotenv v1.5.1 ) @@ -13,14 +13,17 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/bsm/redislock v0.9.4 // indirect - github.com/bytedance/sonic v1.13.2 // indirect + github.com/bytedance/sonic v1.13.3 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect + github.com/cenkalti/backoff/v5 v5.0.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.5 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect - github.com/gin-contrib/sse v1.0.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect @@ -30,21 +33,34 @@ require ( github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/redis/go-redis/v9 v9.11.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.12 // indirect - golang.org/x/arch v0.16.0 // indirect - golang.org/x/crypto v0.37.0 // indirect - golang.org/x/net v0.38.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.24.0 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.62.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/sdk v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.0 // indirect + golang.org/x/arch v0.18.0 // indirect + golang.org/x/crypto v0.39.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.26.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e500483..2a2e8fc 100644 --- a/go.sum +++ b/go.sum @@ -8,9 +8,13 @@ github.com/bsm/redislock v0.9.4 h1:X/Wse1DPpiQgHbVYRE9zv6m070UcKoOGekgvpNhiSvw= github.com/bsm/redislock v0.9.4/go.mod h1:Epf7AJLiSFwLCiZcfi6pWFO/8eAYrYpQXFxEDPoDeAk= github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= +github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= +github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= +github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -25,16 +29,27 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gin-contrib/sessions v1.0.3 h1:AZ4j0AalLsGqdrKNbbrKcXx9OJZqViirvNGsJTxcQps= github.com/gin-contrib/sessions v1.0.3/go.mod h1:5i4XMx4KPtQihnzxEqG9u1K446lO3G19jAi2GtbfsAI= github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -60,6 +75,8 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -81,6 +98,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= @@ -93,6 +112,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.0.3 h1:+7mmR26M0IvyLxGZUHxu4GiBkJkVDid0Un+j4ScYu4k= @@ -115,19 +136,55 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.62.0 h1:fZNpsQuTwFFSGC96aJexNOBrCD7PjD9Tm/HyHtXhmnk= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.62.0/go.mod h1:+NFxPSeYg0SoiRUO4k0ceJYMCY9FiRbYFmByUpm7GJY= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= +go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U= golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= +golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= +golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/main.go b/main.go index bb92a7c..f5b0e21 100644 --- a/main.go +++ b/main.go @@ -1,18 +1,36 @@ package main import ( + "context" "log" "os" + "strings" - "github.com/P-E-D-L/proclone/auth" - "github.com/P-E-D-L/proclone/database" - "github.com/P-E-D-L/proclone/proxmox" - "github.com/P-E-D-L/proclone/proxmox/cloning" - "github.com/P-E-D-L/proclone/proxmox/images" + "github.com/cpp-cyber/proclone/auth" + "github.com/cpp-cyber/proclone/database" + "github.com/cpp-cyber/proclone/proxmox" + "github.com/cpp-cyber/proclone/proxmox/cloning" + "github.com/cpp-cyber/proclone/proxmox/images" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "github.com/joho/godotenv" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "google.golang.org/grpc/credentials" + + "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +var ( + serviceName = os.Getenv("SERVICE_NAME") + collectorURL = os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") + insecure = os.Getenv("INSECURE_MODE") ) // init the environment @@ -20,7 +38,53 @@ func init() { _ = godotenv.Load() } +func initTracer() func(context.Context) error { + + var secureOption otlptracegrpc.Option + + if strings.ToLower(insecure) == "false" || insecure == "0" || strings.ToLower(insecure) == "f" { + secureOption = otlptracegrpc.WithTLSCredentials(credentials.NewClientTLSFromCert(nil, "")) + } else { + secureOption = otlptracegrpc.WithInsecure() + } + + exporter, err := otlptrace.New( + context.Background(), + otlptracegrpc.NewClient( + secureOption, + otlptracegrpc.WithEndpoint(collectorURL), + ), + ) + + if err != nil { + log.Fatalf("Failed to create exporter: %v", err) + } + resources, err := resource.New( + context.Background(), + resource.WithAttributes( + attribute.String("service.name", serviceName), + attribute.String("library.language", "go"), + ), + ) + if err != nil { + log.Fatalf("Could not set resources: %v", err) + } + + otel.SetTracerProvider( + sdktrace.NewTracerProvider( + sdktrace.WithSampler(sdktrace.AlwaysSample()), + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(resources), + ), + ) + return exporter.Shutdown +} + func main() { + + cleanup := initTracer() + defer cleanup(context.Background()) + // Ensure upload directory exists if err := os.MkdirAll(images.UploadDir, os.ModePerm); err != nil { log.Fatalf("failed to create upload dir: %v", err) @@ -33,6 +97,8 @@ func main() { defer database.CloseDB() r := gin.Default() + r.Use(otelgin.Middleware(serviceName)) + r.MaxMultipartMemory = 10 << 20 // 10 MiB // store session cookie diff --git a/proxmox/cloning/cloning.go b/proxmox/cloning/cloning.go index 04e3223..3bf1c04 100644 --- a/proxmox/cloning/cloning.go +++ b/proxmox/cloning/cloning.go @@ -13,9 +13,9 @@ import ( "strconv" "time" - "github.com/P-E-D-L/proclone/auth" - "github.com/P-E-D-L/proclone/proxmox" - "github.com/P-E-D-L/proclone/proxmox/cloning/locking" + "github.com/cpp-cyber/proclone/auth" + "github.com/cpp-cyber/proclone/proxmox" + "github.com/cpp-cyber/proclone/proxmox/cloning/locking" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) diff --git a/proxmox/cloning/deleting.go b/proxmox/cloning/deleting.go index f2bb981..caa77ae 100644 --- a/proxmox/cloning/deleting.go +++ b/proxmox/cloning/deleting.go @@ -8,8 +8,8 @@ import ( "strings" "time" - "github.com/P-E-D-L/proclone/auth" - "github.com/P-E-D-L/proclone/proxmox" + "github.com/cpp-cyber/proclone/auth" + "github.com/cpp-cyber/proclone/proxmox" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) diff --git a/proxmox/cloning/networking.go b/proxmox/cloning/networking.go index 7a89f26..19972b6 100644 --- a/proxmox/cloning/networking.go +++ b/proxmox/cloning/networking.go @@ -9,7 +9,7 @@ import ( "regexp" "time" - "github.com/P-E-D-L/proclone/proxmox" + "github.com/cpp-cyber/proclone/proxmox" ) type VNetResponse struct { diff --git a/proxmox/cloning/optimization.go b/proxmox/cloning/optimization.go index bc015f4..4817096 100644 --- a/proxmox/cloning/optimization.go +++ b/proxmox/cloning/optimization.go @@ -4,7 +4,7 @@ import ( "fmt" "math" - "github.com/P-E-D-L/proclone/proxmox" + "github.com/cpp-cyber/proclone/proxmox" ) /* diff --git a/proxmox/cloning/pods.go b/proxmox/cloning/pods.go index 5be3495..6dfbeab 100644 --- a/proxmox/cloning/pods.go +++ b/proxmox/cloning/pods.go @@ -6,8 +6,8 @@ import ( "net/http" "regexp" - "github.com/P-E-D-L/proclone/auth" - "github.com/P-E-D-L/proclone/proxmox" + "github.com/cpp-cyber/proclone/auth" + "github.com/cpp-cyber/proclone/proxmox" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) diff --git a/proxmox/cloning/templates.go b/proxmox/cloning/templates.go index 10311bf..48d1407 100644 --- a/proxmox/cloning/templates.go +++ b/proxmox/cloning/templates.go @@ -9,9 +9,9 @@ import ( "slices" - "github.com/P-E-D-L/proclone/auth" - "github.com/P-E-D-L/proclone/database" - "github.com/P-E-D-L/proclone/proxmox" + "github.com/cpp-cyber/proclone/auth" + "github.com/cpp-cyber/proclone/database" + "github.com/cpp-cyber/proclone/proxmox" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) From fd5dbbbd74d7bb8c2ad6bfe1924ca0556e6cfdaa Mon Sep 17 00:00:00 2001 From: HGWJ Date: Sat, 23 Aug 2025 15:27:39 -0700 Subject: [PATCH 03/23] Revert "add signoz + fix packages" This reverts commit 420f7483a55963707c798427ac27184afa99902b. --- .env | 3 -- go.mod | 42 ++++++------------ go.sum | 57 ------------------------- main.go | 76 +++------------------------------ proxmox/cloning/cloning.go | 6 +-- proxmox/cloning/deleting.go | 4 +- proxmox/cloning/networking.go | 2 +- proxmox/cloning/optimization.go | 2 +- proxmox/cloning/pods.go | 4 +- proxmox/cloning/templates.go | 6 +-- 10 files changed, 30 insertions(+), 172 deletions(-) diff --git a/.env b/.env index 601ee37..9befeb2 100644 --- a/.env +++ b/.env @@ -10,6 +10,3 @@ PROXMOX_TOKEN_ID=kaminosvc@pve!kamino-token PROXMOX_TOKEN_SECRET=placeholder PROXMOX_VERIFY_SSL="false" PROXMOX_NODES=gonk,commando,gemini -SERVICE_NAME="proclone" -OTEL_EXPORTER_OTLP_ENDPOINT="signoz-otel-collector.signoz.svc.cluster.local:4317" -INSECURE_MODE="true" \ No newline at end of file diff --git a/go.mod b/go.mod index b2b665f..e70ff74 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ -module github.com/cpp-cyber/proclone +module github.com/P-E-D-L/proclone go 1.24.1 require ( github.com/gin-contrib/sessions v1.0.3 - github.com/gin-gonic/gin v1.10.1 + github.com/gin-gonic/gin v1.10.0 github.com/go-ldap/ldap/v3 v3.4.11 github.com/joho/godotenv v1.5.1 ) @@ -13,17 +13,14 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/bsm/redislock v0.9.4 // indirect - github.com/bytedance/sonic v1.13.3 // indirect + github.com/bytedance/sonic v1.13.2 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect - github.com/cenkalti/backoff/v5 v5.0.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.5 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/gabriel-vasile/mimetype v1.4.9 // indirect - github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.0.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect @@ -33,34 +30,21 @@ require ( github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/redis/go-redis/v9 v9.11.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.62.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/sdk v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect - go.opentelemetry.io/proto/otlp v1.7.0 // indirect - golang.org/x/arch v0.18.0 // indirect - golang.org/x/crypto v0.39.0 // indirect - golang.org/x/net v0.41.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/grpc v1.73.0 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.16.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2a2e8fc..e500483 100644 --- a/go.sum +++ b/go.sum @@ -8,13 +8,9 @@ github.com/bsm/redislock v0.9.4 h1:X/Wse1DPpiQgHbVYRE9zv6m070UcKoOGekgvpNhiSvw= github.com/bsm/redislock v0.9.4/go.mod h1:Epf7AJLiSFwLCiZcfi6pWFO/8eAYrYpQXFxEDPoDeAk= github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= -github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= -github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= -github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= -github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -29,27 +25,16 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= -github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= -github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gin-contrib/sessions v1.0.3 h1:AZ4j0AalLsGqdrKNbbrKcXx9OJZqViirvNGsJTxcQps= github.com/gin-contrib/sessions v1.0.3/go.mod h1:5i4XMx4KPtQihnzxEqG9u1K446lO3G19jAi2GtbfsAI= github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= -github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= -github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= -github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= -github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -75,8 +60,6 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -98,8 +81,6 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= -github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= @@ -112,8 +93,6 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.0.3 h1:+7mmR26M0IvyLxGZUHxu4GiBkJkVDid0Un+j4ScYu4k= @@ -136,55 +115,19 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= -github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.62.0 h1:fZNpsQuTwFFSGC96aJexNOBrCD7PjD9Tm/HyHtXhmnk= -go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.62.0/go.mod h1:+NFxPSeYg0SoiRUO4k0ceJYMCY9FiRbYFmByUpm7GJY= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= -go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= -go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U= golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= -golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= -golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/main.go b/main.go index f5b0e21..bb92a7c 100644 --- a/main.go +++ b/main.go @@ -1,36 +1,18 @@ package main import ( - "context" "log" "os" - "strings" - "github.com/cpp-cyber/proclone/auth" - "github.com/cpp-cyber/proclone/database" - "github.com/cpp-cyber/proclone/proxmox" - "github.com/cpp-cyber/proclone/proxmox/cloning" - "github.com/cpp-cyber/proclone/proxmox/images" + "github.com/P-E-D-L/proclone/auth" + "github.com/P-E-D-L/proclone/database" + "github.com/P-E-D-L/proclone/proxmox" + "github.com/P-E-D-L/proclone/proxmox/cloning" + "github.com/P-E-D-L/proclone/proxmox/images" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "github.com/joho/godotenv" - - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" - "google.golang.org/grpc/credentials" - - "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" - "go.opentelemetry.io/otel/sdk/resource" - sdktrace "go.opentelemetry.io/otel/sdk/trace" -) - -var ( - serviceName = os.Getenv("SERVICE_NAME") - collectorURL = os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") - insecure = os.Getenv("INSECURE_MODE") ) // init the environment @@ -38,53 +20,7 @@ func init() { _ = godotenv.Load() } -func initTracer() func(context.Context) error { - - var secureOption otlptracegrpc.Option - - if strings.ToLower(insecure) == "false" || insecure == "0" || strings.ToLower(insecure) == "f" { - secureOption = otlptracegrpc.WithTLSCredentials(credentials.NewClientTLSFromCert(nil, "")) - } else { - secureOption = otlptracegrpc.WithInsecure() - } - - exporter, err := otlptrace.New( - context.Background(), - otlptracegrpc.NewClient( - secureOption, - otlptracegrpc.WithEndpoint(collectorURL), - ), - ) - - if err != nil { - log.Fatalf("Failed to create exporter: %v", err) - } - resources, err := resource.New( - context.Background(), - resource.WithAttributes( - attribute.String("service.name", serviceName), - attribute.String("library.language", "go"), - ), - ) - if err != nil { - log.Fatalf("Could not set resources: %v", err) - } - - otel.SetTracerProvider( - sdktrace.NewTracerProvider( - sdktrace.WithSampler(sdktrace.AlwaysSample()), - sdktrace.WithBatcher(exporter), - sdktrace.WithResource(resources), - ), - ) - return exporter.Shutdown -} - func main() { - - cleanup := initTracer() - defer cleanup(context.Background()) - // Ensure upload directory exists if err := os.MkdirAll(images.UploadDir, os.ModePerm); err != nil { log.Fatalf("failed to create upload dir: %v", err) @@ -97,8 +33,6 @@ func main() { defer database.CloseDB() r := gin.Default() - r.Use(otelgin.Middleware(serviceName)) - r.MaxMultipartMemory = 10 << 20 // 10 MiB // store session cookie diff --git a/proxmox/cloning/cloning.go b/proxmox/cloning/cloning.go index 3bf1c04..04e3223 100644 --- a/proxmox/cloning/cloning.go +++ b/proxmox/cloning/cloning.go @@ -13,9 +13,9 @@ import ( "strconv" "time" - "github.com/cpp-cyber/proclone/auth" - "github.com/cpp-cyber/proclone/proxmox" - "github.com/cpp-cyber/proclone/proxmox/cloning/locking" + "github.com/P-E-D-L/proclone/auth" + "github.com/P-E-D-L/proclone/proxmox" + "github.com/P-E-D-L/proclone/proxmox/cloning/locking" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) diff --git a/proxmox/cloning/deleting.go b/proxmox/cloning/deleting.go index caa77ae..f2bb981 100644 --- a/proxmox/cloning/deleting.go +++ b/proxmox/cloning/deleting.go @@ -8,8 +8,8 @@ import ( "strings" "time" - "github.com/cpp-cyber/proclone/auth" - "github.com/cpp-cyber/proclone/proxmox" + "github.com/P-E-D-L/proclone/auth" + "github.com/P-E-D-L/proclone/proxmox" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) diff --git a/proxmox/cloning/networking.go b/proxmox/cloning/networking.go index 19972b6..7a89f26 100644 --- a/proxmox/cloning/networking.go +++ b/proxmox/cloning/networking.go @@ -9,7 +9,7 @@ import ( "regexp" "time" - "github.com/cpp-cyber/proclone/proxmox" + "github.com/P-E-D-L/proclone/proxmox" ) type VNetResponse struct { diff --git a/proxmox/cloning/optimization.go b/proxmox/cloning/optimization.go index 4817096..bc015f4 100644 --- a/proxmox/cloning/optimization.go +++ b/proxmox/cloning/optimization.go @@ -4,7 +4,7 @@ import ( "fmt" "math" - "github.com/cpp-cyber/proclone/proxmox" + "github.com/P-E-D-L/proclone/proxmox" ) /* diff --git a/proxmox/cloning/pods.go b/proxmox/cloning/pods.go index 6dfbeab..5be3495 100644 --- a/proxmox/cloning/pods.go +++ b/proxmox/cloning/pods.go @@ -6,8 +6,8 @@ import ( "net/http" "regexp" - "github.com/cpp-cyber/proclone/auth" - "github.com/cpp-cyber/proclone/proxmox" + "github.com/P-E-D-L/proclone/auth" + "github.com/P-E-D-L/proclone/proxmox" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) diff --git a/proxmox/cloning/templates.go b/proxmox/cloning/templates.go index 48d1407..10311bf 100644 --- a/proxmox/cloning/templates.go +++ b/proxmox/cloning/templates.go @@ -9,9 +9,9 @@ import ( "slices" - "github.com/cpp-cyber/proclone/auth" - "github.com/cpp-cyber/proclone/database" - "github.com/cpp-cyber/proclone/proxmox" + "github.com/P-E-D-L/proclone/auth" + "github.com/P-E-D-L/proclone/database" + "github.com/P-E-D-L/proclone/proxmox" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) From 347dcf2ad7f83854559946b777a8472ac920426c Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Sat, 23 Aug 2025 20:51:42 -0700 Subject: [PATCH 04/23] Pod template personalization --- auth/ldap.go | 2 +- database/query.go | 18 +- go.mod | 7 + go.sum | 15 ++ main.go | 2 + proxmox/cloning/cloning.go | 368 +++++++++++++++++++---------------- proxmox/cloning/deleting.go | 1 - proxmox/cloning/templates.go | 47 ++++- proxmox/images/upload.go | 3 +- uploads/kaminoLogo.svg | 8 + 10 files changed, 293 insertions(+), 178 deletions(-) create mode 100644 uploads/kaminoLogo.svg diff --git a/auth/ldap.go b/auth/ldap.go index ebeaae0..49ef0cd 100644 --- a/auth/ldap.go +++ b/auth/ldap.go @@ -143,7 +143,7 @@ func (lc *LDAPConnection) GetAllUsers() (*UserResponse, error) { // check if user is admin isAdmin := false for _, group := range groups { - if strings.Contains(strings.ToLower(group), "cn=domain admins") || strings.Contains(strings.ToLower(group), "cn=kamino admin") { + if strings.Contains(strings.ToLower(group), "cn=domain admins") || strings.Contains(strings.ToLower(group), "cn=proxmox-admins") { isAdmin = true break } diff --git a/database/query.go b/database/query.go index 9eb52f0..12f2994 100644 --- a/database/query.go +++ b/database/query.go @@ -116,6 +116,22 @@ func UpdateTemplate(template Template) error { return nil } +// Delete template from database +func DeleteTemplate(templateName string) error { + if DB == nil { + return fmt.Errorf("database connection is not initialized") + } + + query := "DELETE FROM templates WHERE name = ?" + + _, err := DB.Exec(query, templateName) + if err != nil { + return fmt.Errorf("failed to execute query (%s): %w", query, err) + } + + return nil +} + // Helper function to select all template names func SelectAllTemplateNames() ([]string, error) { templates, err := SelectAllTemplates() @@ -131,7 +147,7 @@ func SelectAllTemplateNames() ([]string, error) { return templateNames, nil } -// TODO: Implement +// Adds one to the deployment count of a template func AddDeployment(templateName string) error { if DB == nil { return fmt.Errorf("database connection is not initialized") diff --git a/go.mod b/go.mod index e70ff74..bc42e08 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,8 @@ require ( github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.0.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect @@ -40,9 +42,14 @@ require ( github.com/redis/go-redis/v9 v9.11.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect golang.org/x/arch v0.16.0 // indirect golang.org/x/crypto v0.37.0 // indirect golang.org/x/net v0.38.0 // indirect + golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.32.0 // indirect golang.org/x/text v0.24.0 // indirect google.golang.org/protobuf v1.36.6 // indirect diff --git a/go.sum b/go.sum index e500483..bf4544a 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,11 @@ github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -115,12 +120,22 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U= golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/main.go b/main.go index bb92a7c..bdff76a 100644 --- a/main.go +++ b/main.go @@ -85,10 +85,12 @@ func main() { // Proxmox Admin Template endpoints admin.POST("/proxmox/templates/publish", cloning.PublishTemplate) admin.POST("/proxmox/templates/update", cloning.UpdateTemplate) + admin.POST("/proxmox/templates/delete", cloning.DeleteTemplate) admin.GET("/proxmox/templates", cloning.GetAllTemplates) admin.GET("/proxmox/templates/unpublished", cloning.GetUnpublishedTemplates) admin.POST("/proxmox/templates/toggle", cloning.ToggleTemplateVisibility) admin.POST("/proxmox/templates/image/upload", images.HandleUpload) + admin.POST("/proxmox/templates/clone/bulk", cloning.BulkCloneTemplate) // Active Directory User endpoints admin.GET("/users", auth.GetUsers) diff --git a/proxmox/cloning/cloning.go b/proxmox/cloning/cloning.go index 04e3223..c732e96 100644 --- a/proxmox/cloning/cloning.go +++ b/proxmox/cloning/cloning.go @@ -14,10 +14,12 @@ import ( "time" "github.com/P-E-D-L/proclone/auth" + "github.com/P-E-D-L/proclone/database" "github.com/P-E-D-L/proclone/proxmox" "github.com/P-E-D-L/proclone/proxmox/cloning/locking" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" + "golang.org/x/sync/errgroup" ) const KAMINO_TEMP_POOL string = "0100_Kamino_Templates" @@ -35,7 +37,6 @@ type NewPoolResponse struct { type CloneResponse struct { Success int `json:"success"` - PodName string `json:"pod_name"` Errors []string `json:"errors,omitempty"` } @@ -58,7 +59,6 @@ type Disk struct { func CloneTemplateToPod(c *gin.Context) { session := sessions.Default(c) username := session.Get("username") - var errors []string // Make sure user is authenticated isAuth, _ := auth.IsAuthenticated(c) @@ -80,176 +80,19 @@ func CloneTemplateToPod(c *gin.Context) { return } - templatePool := "kamino_template_" + req.TemplateName - - // Load Proxmox configuration - config, err := proxmox.LoadProxmoxConfig() - if err != nil { - log.Printf("Configuration error for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("failed to load Proxmox configuration: %v", err), - }) + usernameStr, ok := username.(string) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid session username"}) return } - // Get all virtual resources - apiResp, err := proxmox.GetVirtualResources(config) + err := ProxmoxCloneTemplate(req.TemplateName, usernameStr) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "failed to fetch virtual resources", - "details": err.Error(), - }) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // Find VMs in template pool - var templateVMs []proxmox.VirtualResource - var routerTemplate proxmox.VirtualResource - for _, r := range *apiResp { - - // if VM is a member of target pool, add it to list - if r.Type == "qemu" && r.ResourcePool == templatePool { - templateVMs = append(templateVMs, r) - } - - // if vm is pod router template, save that to variable - if r.Name == ROUTER_NAME && r.ResourcePool == KAMINO_TEMP_POOL { - routerTemplate = r - } - } - - // handle case where template is empty and should not be cloned - if len(templateVMs) == 0 { - c.JSON(http.StatusNotFound, gin.H{ - "error": fmt.Sprintf("No VMs found in template pool: %s", templatePool), - }) - return - } - - // get next avaialble pod ID - NewPodID, newPodNumber, err := nextPodID(config, c) - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "failed to get a pod ID", - "details": err.Error(), - }) - return - } - - // create new pod resource pool with ID - NewPodPool, err := createNewPodPool(username.(string), NewPodID, req.TemplateName, config) - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "failed to create new pod resource pool", - "details": err.Error(), - }) - return - } - - /* Clone 1:1 NAT router from template - * - */ - newRouter, err := cloneVM(config, routerTemplate, NewPodPool) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to clone router VM: %v", err)) - } - - // Clone each VM to new pool - for _, vm := range templateVMs { - _, err := cloneVM(config, vm, NewPodPool) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to clone VM %s: %v", vm.Name, err)) - } - } - - // Check if vnet exists, if not, create it - vnetExists, err := checkForVnet(config, newPodNumber) - var vnetName string - - if err != nil { - errors = append(errors, fmt.Sprintf("failed to check current vnets: %v", err)) - } - - if !vnetExists { - vnetName, err = addVNetObject(config, newPodNumber) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to create new vnet object: %v", err)) - } - - err = applySDNChanges(config) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to apply new sdn changes: %v", err)) - } - } else { - vnetName = fmt.Sprintf("kamino%d", newPodNumber) - } - - // Configure VNet of all VMs - err = setPodVnet(config, NewPodPool, vnetName) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to update pod vnet: %v", err)) - } - - // Turn on router - err = waitForDiskAvailability(config, newRouter.Node, newRouter.VMID, 120*time.Second) - if err != nil { - errors = append(errors, fmt.Sprintf("router disk unavailable: %v", err)) - } - _, err = proxmox.PowerOnRequest(config, *newRouter) - - if err != nil { - errors = append(errors, fmt.Sprintf("failed to start router VM: %v", err)) - } - - // Wait for router to be running - err = proxmox.WaitForRunning(config, *newRouter) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to start router VM: %v", err)) - } else { - err = configurePodRouter(config, newPodNumber, newRouter.Node, newRouter.VMID) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to configure pod router: %v", err)) - } - } - - // automatically give user who cloned the pod access - err = setPoolPermission(config, NewPodPool, username.(string)) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to update pool permissions for %s: %v", username, err)) - } - - var success int = 0 - if len(errors) == 0 { - success = 1 - } - - response := CloneResponse{ - Success: success, - PodName: NewPodPool, - Errors: errors, - } - - if len(errors) > 0 { - // if an error has occured, count # of successfully cloned VMs - var clonedVMs []proxmox.VirtualResource - for _, r := range *apiResp { - if r.Type == "qemu" && r.ResourcePool == NewPodPool { - clonedVMs = append(templateVMs, r) - } - } - - // if there are no cloned VMs in the resource pool, clean up the resource pool - if len(clonedVMs) == 0 { - cleanupFailedPodPool(config, NewPodPool) - } - - // send response :) - c.JSON(http.StatusPartialContent, response) - } else { - c.JSON(http.StatusOK, response) - } + c.JSON(http.StatusOK, gin.H{"message": "Pod deployed successfully!"}) } // assign a user to be a VM user for a resource pool @@ -465,16 +308,10 @@ func makeCloneRequest(config *proxmox.ProxmoxConfig, vm proxmox.VirtualResource, } // finds lowest available POD ID between 1001 - 1255 -func nextPodID(config *proxmox.ProxmoxConfig, c *gin.Context) (string, int, error) { +func nextPodID(config *proxmox.ProxmoxConfig) (string, int, error) { podResponse, err := getAdminPodResponse(config) - - // if error, return error status if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "failed to fetch pod list from proxmox cluster", - "details": err, - }) - return "", 0, err + return "", 0, fmt.Errorf("failed to fetch pod list from proxmox cluster: %v", err) } pods := podResponse.Pods @@ -513,6 +350,143 @@ func nextPodID(config *proxmox.ProxmoxConfig, c *gin.Context) (string, int, erro return strconv.Itoa(nextId), nextId - 1000, nil } +// ProxmoxCloneTemplate performs the full cloning flow for a given template and user. +func ProxmoxCloneTemplate(templateName, username string) error { + var errors []string + + templatePool := "kamino_template_" + templateName + + // Load Proxmox configuration + config, err := proxmox.LoadProxmoxConfig() + if err != nil { + return fmt.Errorf("failed to load Proxmox configuration: %v", err) + } + + // Get all virtual resources + apiResp, err := proxmox.GetVirtualResources(config) + if err != nil { + return fmt.Errorf("failed to fetch virtual resources: %v", err) + } + + // Find VMs in template pool + var templateVMs []proxmox.VirtualResource + var routerTemplate proxmox.VirtualResource + for _, r := range *apiResp { + if r.Type == "qemu" && r.ResourcePool == templatePool { + templateVMs = append(templateVMs, r) + } + if r.Name == ROUTER_NAME && r.ResourcePool == KAMINO_TEMP_POOL { + routerTemplate = r + } + } + + if len(templateVMs) == 0 { + return fmt.Errorf("no VMs found in template pool: %s", templatePool) + } + + // get next available pod ID + NewPodID, newPodNumber, err := nextPodID(config) + if err != nil { + return fmt.Errorf("failed to get a pod ID: %v", err) + } + + // create new pod resource pool with ID + NewPodPool, err := createNewPodPool(username, NewPodID, templateName, config) + if err != nil { + return fmt.Errorf("failed to create new pod resource pool: %v", err) + } + + // Clone 1:1 NAT router from template + newRouter, err := cloneVM(config, routerTemplate, NewPodPool) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to clone router VM: %v", err)) + } + + // Clone each VM to new pool + for _, vm := range templateVMs { + _, err := cloneVM(config, vm, NewPodPool) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to clone VM %s: %v", vm.Name, err)) + } + } + + // Check if vnet exists, if not, create it + vnetExists, err := checkForVnet(config, newPodNumber) + var vnetName string + if err != nil { + errors = append(errors, fmt.Sprintf("failed to check current vnets: %v", err)) + } + + if !vnetExists { + vnetName, err = addVNetObject(config, newPodNumber) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to create new vnet object: %v", err)) + } + + err = applySDNChanges(config) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to apply new sdn changes: %v", err)) + } + } else { + vnetName = fmt.Sprintf("kamino%d", newPodNumber) + } + + // Configure VNet of all VMs + err = setPodVnet(config, NewPodPool, vnetName) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to update pod vnet: %v", err)) + } + + // Turn on router + if newRouter != nil { + err = waitForDiskAvailability(config, newRouter.Node, newRouter.VMID, 120*time.Second) + if err != nil { + errors = append(errors, fmt.Sprintf("router disk unavailable: %v", err)) + } + _, err = proxmox.PowerOnRequest(config, *newRouter) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to start router VM: %v", err)) + } + + // Wait for router to be running + err = proxmox.WaitForRunning(config, *newRouter) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to start router VM: %v", err)) + } else { + err = configurePodRouter(config, newPodNumber, newRouter.Node, newRouter.VMID) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to configure pod router: %v", err)) + } + } + } + + // automatically give user who cloned the pod access + err = setPoolPermission(config, NewPodPool, username) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to update pool permissions for %s: %v", username, err)) + } + + if len(errors) > 0 { + // if an error has occured, count # of successfully cloned VMs + var clonedVMs []proxmox.VirtualResource + for _, r := range *apiResp { + if r.Type == "qemu" && r.ResourcePool == NewPodPool { + clonedVMs = append(clonedVMs, r) + } + } + + // if there are no cloned VMs in the resource pool, clean up the resource pool + if len(clonedVMs) == 0 { + _ = cleanupFailedPodPool(config, NewPodPool) + } + + return fmt.Errorf("failed to clone one or more VMs: %v", errors) + } + + database.AddDeployment(templateName) + return nil +} + func cleanupFailedPodPool(config *proxmox.ProxmoxConfig, poolName string) error { poolDeletePath := fmt.Sprintf("api2/json/pools/%s", poolName) @@ -551,6 +525,54 @@ func createNewPodPool(username string, newPodID string, templateName string, con return newPoolName, nil } +/* + * /api/admin/proxmox/templates/clone/bulk + * This function bulk clones a single template for a list of users + */ +func BulkCloneTemplate(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("username") + isAdmin := session.Get("is_admin") + + // Make sure user is authenticated and is an admin + if !isAdmin.(bool) { + log.Printf("Forbidden access attempt") + c.JSON(http.StatusForbidden, gin.H{ + "error": "Only Admin users can delete a template", + }) + return + } + + var form struct { + Template string `json:"template"` + Names []string `json:"names"` + } + + err := c.ShouldBindJSON(&form) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + fmt.Printf("User %s is cloning %d pods\n", username, len(form.Names)) + eg := errgroup.Group{} + for i := 0; i < len(form.Names); i++ { + if form.Names[i] == "" { + continue + } + eg.Go(func() error { + return ProxmoxCloneTemplate(form.Template, form.Names[i]) + }) + } + + if err := eg.Wait(); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Pods deployed successfully!"}) +} + func waitForDiskAvailability(config *proxmox.ProxmoxConfig, node string, vmid int, maxWait time.Duration) error { start := time.Now() var status *ConfigResponse diff --git a/proxmox/cloning/deleting.go b/proxmox/cloning/deleting.go index f2bb981..6f4adc7 100644 --- a/proxmox/cloning/deleting.go +++ b/proxmox/cloning/deleting.go @@ -148,7 +148,6 @@ func DeletePod(c *gin.Context) { response := DeleteResponse{ Success: success, - PodName: req.PodName, Errors: errors, } diff --git a/proxmox/cloning/templates.go b/proxmox/cloning/templates.go index 10311bf..cba6df8 100644 --- a/proxmox/cloning/templates.go +++ b/proxmox/cloning/templates.go @@ -334,7 +334,7 @@ func UpdateTemplate(c *gin.Context) { } /* - * /api/admin/proxmox/templates/update + * /api/admin/proxmox/templates/toggle * This function toggles the visibility of a published template */ func ToggleTemplateVisibility(c *gin.Context) { @@ -377,3 +377,48 @@ func ToggleTemplateVisibility(c *gin.Context) { "message": "Template visibility toggled successfully", }) } + +/* + * /api/admin/proxmox/templates/delete + * This function deletes a template + */ +func DeleteTemplate(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("username") + isAdmin := session.Get("is_admin") + + // Make sure user is authenticated and is an admin + if !isAdmin.(bool) { + log.Printf("Forbidden access attempt") + c.JSON(http.StatusForbidden, gin.H{ + "error": "Only Admin users can delete a template", + }) + return + } + + var req struct { + TemplateName string `json:"template_name"` + } + if err := c.ShouldBindJSON(&req); err != nil { + log.Printf("Failed to bind JSON for user %s: %v", username, err) + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request payload", + }) + return + } + templateName := req.TemplateName + + // Delete the template from the database + if err := database.DeleteTemplate(templateName); err != nil { + log.Printf("Database error for user %s: %v", username, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to delete template: %v", err), + }) + return + } + + log.Printf("Successfully deleted template %s for admin user %s", templateName, username) + c.JSON(http.StatusOK, gin.H{ + "message": "Template deleted successfully", + }) +} diff --git a/proxmox/images/upload.go b/proxmox/images/upload.go index 8a5281d..6abe49d 100644 --- a/proxmox/images/upload.go +++ b/proxmox/images/upload.go @@ -6,6 +6,7 @@ import ( "log" "mime/multipart" "net/http" + "os" "path/filepath" "strings" @@ -14,7 +15,7 @@ import ( "github.com/google/uuid" ) -const UploadDir = "./uploads" +var UploadDir = os.Getenv("UPLOADS_DIR") // Use a map to define allowed MIME types for better performance // and to avoid using a switch statement diff --git a/uploads/kaminoLogo.svg b/uploads/kaminoLogo.svg new file mode 100644 index 0000000..1cc6d09 --- /dev/null +++ b/uploads/kaminoLogo.svg @@ -0,0 +1,8 @@ + + + + + + + + From d8fe47bf1ede609681d39fccb9da18f973ecfbd0 Mon Sep 17 00:00:00 2001 From: HGWJ Date: Sun, 24 Aug 2025 17:40:38 -0700 Subject: [PATCH 05/23] fix packages and add signoz again --- .env | 3 ++ go.mod | 36 ++++++++++------ go.sum | 44 +++++++++++++++++++ main.go | 75 ++++++++++++++++++++++++++++++--- proxmox/cloning/cloning.go | 8 ++-- proxmox/cloning/deleting.go | 4 +- proxmox/cloning/networking.go | 2 +- proxmox/cloning/optimization.go | 2 +- proxmox/cloning/pods.go | 4 +- proxmox/cloning/templates.go | 6 +-- 10 files changed, 153 insertions(+), 31 deletions(-) diff --git a/.env b/.env index 9befeb2..601ee37 100644 --- a/.env +++ b/.env @@ -10,3 +10,6 @@ PROXMOX_TOKEN_ID=kaminosvc@pve!kamino-token PROXMOX_TOKEN_SECRET=placeholder PROXMOX_VERIFY_SSL="false" PROXMOX_NODES=gonk,commando,gemini +SERVICE_NAME="proclone" +OTEL_EXPORTER_OTLP_ENDPOINT="signoz-otel-collector.signoz.svc.cluster.local:4317" +INSECURE_MODE="true" \ No newline at end of file diff --git a/go.mod b/go.mod index bc42e08..32b46be 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,10 @@ -module github.com/P-E-D-L/proclone +module github.com/cpp-cyber/proclone go 1.24.1 require ( github.com/gin-contrib/sessions v1.0.3 - github.com/gin-gonic/gin v1.10.0 + github.com/gin-gonic/gin v1.10.1 github.com/go-ldap/ldap/v3 v3.4.11 github.com/joho/godotenv v1.5.1 ) @@ -13,13 +13,14 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/bsm/redislock v0.9.4 // indirect - github.com/bytedance/sonic v1.13.2 // indirect + github.com/bytedance/sonic v1.13.3 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect + github.com/cenkalti/backoff/v5 v5.0.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.5 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect - github.com/gin-contrib/sse v1.0.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -32,26 +33,35 @@ require ( github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/redis/go-redis/v9 v9.11.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.12 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.62.0 // indirect go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/sdk v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect - golang.org/x/arch v0.16.0 // indirect - golang.org/x/crypto v0.37.0 // indirect - golang.org/x/net v0.38.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.0 // indirect + golang.org/x/arch v0.18.0 // indirect + golang.org/x/crypto v0.39.0 // indirect + golang.org/x/net v0.41.0 // indirect golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.26.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index bf4544a..0c46eac 100644 --- a/go.sum +++ b/go.sum @@ -8,9 +8,13 @@ github.com/bsm/redislock v0.9.4 h1:X/Wse1DPpiQgHbVYRE9zv6m070UcKoOGekgvpNhiSvw= github.com/bsm/redislock v0.9.4/go.mod h1:Epf7AJLiSFwLCiZcfi6pWFO/8eAYrYpQXFxEDPoDeAk= github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= +github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= +github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= +github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -25,12 +29,18 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gin-contrib/sessions v1.0.3 h1:AZ4j0AalLsGqdrKNbbrKcXx9OJZqViirvNGsJTxcQps= github.com/gin-contrib/sessions v1.0.3/go.mod h1:5i4XMx4KPtQihnzxEqG9u1K446lO3G19jAi2GtbfsAI= github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= @@ -65,6 +75,8 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -86,6 +98,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= @@ -98,6 +112,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.0.3 h1:+7mmR26M0IvyLxGZUHxu4GiBkJkVDid0Un+j4ScYu4k= @@ -120,29 +136,57 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.62.0 h1:fZNpsQuTwFFSGC96aJexNOBrCD7PjD9Tm/HyHtXhmnk= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.62.0/go.mod h1:+NFxPSeYg0SoiRUO4k0ceJYMCY9FiRbYFmByUpm7GJY= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= +go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U= golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= +golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= +golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/main.go b/main.go index bdff76a..a190ad0 100644 --- a/main.go +++ b/main.go @@ -1,18 +1,36 @@ package main import ( + "context" "log" "os" + "strings" - "github.com/P-E-D-L/proclone/auth" - "github.com/P-E-D-L/proclone/database" - "github.com/P-E-D-L/proclone/proxmox" - "github.com/P-E-D-L/proclone/proxmox/cloning" - "github.com/P-E-D-L/proclone/proxmox/images" + "github.com/cpp-cyber/proclone/auth" + "github.com/cpp-cyber/proclone/database" + "github.com/cpp-cyber/proclone/proxmox" + "github.com/cpp-cyber/proclone/proxmox/cloning" + "github.com/cpp-cyber/proclone/proxmox/images" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "github.com/joho/godotenv" + + "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "google.golang.org/grpc/credentials" + + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +var ( + serviceName = os.Getenv("SERVICE_NAME") + collectorURL = os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") + insecure = os.Getenv("INSECURE_MODE") ) // init the environment @@ -20,7 +38,53 @@ func init() { _ = godotenv.Load() } +func initTracer() func(context.Context) error { + + var secureOption otlptracegrpc.Option + + if strings.ToLower(insecure) == "false" || insecure == "0" || strings.ToLower(insecure) == "f" { + secureOption = otlptracegrpc.WithTLSCredentials(credentials.NewClientTLSFromCert(nil, "")) + } else { + secureOption = otlptracegrpc.WithInsecure() + } + + exporter, err := otlptrace.New( + context.Background(), + otlptracegrpc.NewClient( + secureOption, + otlptracegrpc.WithEndpoint(collectorURL), + ), + ) + + if err != nil { + log.Fatalf("Failed to create exporter: %v", err) + } + resources, err := resource.New( + context.Background(), + resource.WithAttributes( + attribute.String("service.name", serviceName), + attribute.String("library.language", "go"), + ), + ) + if err != nil { + log.Fatalf("Could not set resources: %v", err) + } + + otel.SetTracerProvider( + sdktrace.NewTracerProvider( + sdktrace.WithSampler(sdktrace.AlwaysSample()), + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(resources), + ), + ) + return exporter.Shutdown +} + func main() { + // Handle Signoz tracing + cleanup := initTracer() + defer cleanup(context.Background()) + // Ensure upload directory exists if err := os.MkdirAll(images.UploadDir, os.ModePerm); err != nil { log.Fatalf("failed to create upload dir: %v", err) @@ -33,6 +97,7 @@ func main() { defer database.CloseDB() r := gin.Default() + r.Use(otelgin.Middleware(serviceName)) r.MaxMultipartMemory = 10 << 20 // 10 MiB // store session cookie diff --git a/proxmox/cloning/cloning.go b/proxmox/cloning/cloning.go index c732e96..ee00abf 100644 --- a/proxmox/cloning/cloning.go +++ b/proxmox/cloning/cloning.go @@ -13,10 +13,10 @@ import ( "strconv" "time" - "github.com/P-E-D-L/proclone/auth" - "github.com/P-E-D-L/proclone/database" - "github.com/P-E-D-L/proclone/proxmox" - "github.com/P-E-D-L/proclone/proxmox/cloning/locking" + "github.com/cpp-cyber/proclone/auth" + "github.com/cpp-cyber/proclone/database" + "github.com/cpp-cyber/proclone/proxmox" + "github.com/cpp-cyber/proclone/proxmox/cloning/locking" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "golang.org/x/sync/errgroup" diff --git a/proxmox/cloning/deleting.go b/proxmox/cloning/deleting.go index 6f4adc7..a075761 100644 --- a/proxmox/cloning/deleting.go +++ b/proxmox/cloning/deleting.go @@ -8,8 +8,8 @@ import ( "strings" "time" - "github.com/P-E-D-L/proclone/auth" - "github.com/P-E-D-L/proclone/proxmox" + "github.com/cpp-cyber/proclone/auth" + "github.com/cpp-cyber/proclone/proxmox" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) diff --git a/proxmox/cloning/networking.go b/proxmox/cloning/networking.go index 7a89f26..19972b6 100644 --- a/proxmox/cloning/networking.go +++ b/proxmox/cloning/networking.go @@ -9,7 +9,7 @@ import ( "regexp" "time" - "github.com/P-E-D-L/proclone/proxmox" + "github.com/cpp-cyber/proclone/proxmox" ) type VNetResponse struct { diff --git a/proxmox/cloning/optimization.go b/proxmox/cloning/optimization.go index bc015f4..4817096 100644 --- a/proxmox/cloning/optimization.go +++ b/proxmox/cloning/optimization.go @@ -4,7 +4,7 @@ import ( "fmt" "math" - "github.com/P-E-D-L/proclone/proxmox" + "github.com/cpp-cyber/proclone/proxmox" ) /* diff --git a/proxmox/cloning/pods.go b/proxmox/cloning/pods.go index 5be3495..6dfbeab 100644 --- a/proxmox/cloning/pods.go +++ b/proxmox/cloning/pods.go @@ -6,8 +6,8 @@ import ( "net/http" "regexp" - "github.com/P-E-D-L/proclone/auth" - "github.com/P-E-D-L/proclone/proxmox" + "github.com/cpp-cyber/proclone/auth" + "github.com/cpp-cyber/proclone/proxmox" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) diff --git a/proxmox/cloning/templates.go b/proxmox/cloning/templates.go index cba6df8..83242a7 100644 --- a/proxmox/cloning/templates.go +++ b/proxmox/cloning/templates.go @@ -9,9 +9,9 @@ import ( "slices" - "github.com/P-E-D-L/proclone/auth" - "github.com/P-E-D-L/proclone/database" - "github.com/P-E-D-L/proclone/proxmox" + "github.com/cpp-cyber/proclone/auth" + "github.com/cpp-cyber/proclone/database" + "github.com/cpp-cyber/proclone/proxmox" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) From c9d219068cb86e9e62546d0ada086db0050e76e0 Mon Sep 17 00:00:00 2001 From: HGWJ Date: Sun, 24 Aug 2025 18:08:13 -0700 Subject: [PATCH 06/23] better naming --- .env | 4 ++-- main.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.env b/.env index 601ee37..99c600c 100644 --- a/.env +++ b/.env @@ -10,6 +10,6 @@ PROXMOX_TOKEN_ID=kaminosvc@pve!kamino-token PROXMOX_TOKEN_SECRET=placeholder PROXMOX_VERIFY_SSL="false" PROXMOX_NODES=gonk,commando,gemini -SERVICE_NAME="proclone" +SIGNOZ_SERVICE_NAME="proclone" OTEL_EXPORTER_OTLP_ENDPOINT="signoz-otel-collector.signoz.svc.cluster.local:4317" -INSECURE_MODE="true" \ No newline at end of file +SIGNOZ_INSECURE_MODE="true" \ No newline at end of file diff --git a/main.go b/main.go index a190ad0..3f65ad1 100644 --- a/main.go +++ b/main.go @@ -28,9 +28,9 @@ import ( ) var ( - serviceName = os.Getenv("SERVICE_NAME") + serviceName = os.Getenv("SIGNOZ_SERVICE_NAME") collectorURL = os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") - insecure = os.Getenv("INSECURE_MODE") + insecure = os.Getenv("SIGNOZ_INSECURE_MODE") ) // init the environment From 95d9d93d772c11dd8123c86dc80fb33249e5f376 Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Wed, 3 Sep 2025 22:32:08 -0700 Subject: [PATCH 07/23] small update --- .env | 15 - .gitignore | 35 ++ Dockerfile | 5 +- auth/auth.go | 144 ----- auth/ldap.go | 185 ------ auth/users.go | 73 --- cmd/api/main.go | 60 ++ database/connect.go | 83 --- database/query.go | 180 ------ go.mod | 52 +- go.sum | 77 +-- internal/api/handlers/auth_handler.go | 142 +++++ internal/api/handlers/cloning_handler.go | 405 +++++++++++++ internal/api/handlers/dashboard_handler.go | 90 +++ internal/api/handlers/groups_handler.go | 113 ++++ internal/api/handlers/health_handler.go | 51 ++ internal/api/handlers/proxmox_handler.go | 42 ++ internal/api/handlers/types.go | 68 +++ internal/api/handlers/users_handler.go | 146 +++++ internal/api/handlers/vms_handler.go | 69 +++ internal/api/middleware/authorization.go | 62 ++ internal/api/routes/admin_routes.go | 43 ++ internal/api/routes/private_routes.go | 20 + internal/api/routes/public_routes.go | 13 + internal/api/routes/routes.go | 24 + internal/auth/auth.go | 154 +++++ internal/auth/groups.go | 447 ++++++++++++++ internal/auth/ldap.go | 456 +++++++++++++++ internal/auth/users.go | 645 +++++++++++++++++++++ internal/cloning/cloning.go | 424 ++++++++++++++ internal/cloning/pods.go | 92 +++ internal/cloning/router.go | 80 +++ internal/cloning/templates.go | 323 +++++++++++ internal/cloning/types.go | 95 +++ internal/proxmox/cluster.go | 105 ++++ internal/proxmox/networking.go | 36 ++ internal/proxmox/pools.go | 212 +++++++ internal/proxmox/proxmox.go | 156 +++++ internal/proxmox/resources.go | 103 ++++ internal/proxmox/types.go | 59 ++ internal/proxmox/vms.go | 375 ++++++++++++ internal/tools/database.go | 290 +++++++++ internal/tools/requests.go | 116 ++++ internal/tools/telemetry.go | 3 + main.go | 172 ------ proxmox/cloning/cloning.go | 636 -------------------- proxmox/cloning/deleting.go | 185 ------ proxmox/cloning/locking/mutexLock.go | 45 -- proxmox/cloning/networking.go | 337 ----------- proxmox/cloning/optimization.go | 82 --- proxmox/cloning/pods.go | 191 ------ proxmox/cloning/templates.go | 424 -------------- proxmox/images/upload.go | 116 ---- proxmox/proxmox.go | 93 --- proxmox/requests.go | 59 -- proxmox/resources.go | 173 ------ proxmox/vms.go | 591 ------------------- uploads/kaminoLogo.svg | 8 - 58 files changed, 5578 insertions(+), 3902 deletions(-) delete mode 100644 .env create mode 100644 .gitignore delete mode 100644 auth/auth.go delete mode 100644 auth/ldap.go delete mode 100644 auth/users.go create mode 100644 cmd/api/main.go delete mode 100644 database/connect.go delete mode 100644 database/query.go create mode 100644 internal/api/handlers/auth_handler.go create mode 100644 internal/api/handlers/cloning_handler.go create mode 100644 internal/api/handlers/dashboard_handler.go create mode 100644 internal/api/handlers/groups_handler.go create mode 100644 internal/api/handlers/health_handler.go create mode 100644 internal/api/handlers/proxmox_handler.go create mode 100644 internal/api/handlers/types.go create mode 100644 internal/api/handlers/users_handler.go create mode 100644 internal/api/handlers/vms_handler.go create mode 100644 internal/api/middleware/authorization.go create mode 100644 internal/api/routes/admin_routes.go create mode 100644 internal/api/routes/private_routes.go create mode 100644 internal/api/routes/public_routes.go create mode 100644 internal/api/routes/routes.go create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/groups.go create mode 100644 internal/auth/ldap.go create mode 100644 internal/auth/users.go create mode 100644 internal/cloning/cloning.go create mode 100644 internal/cloning/pods.go create mode 100644 internal/cloning/router.go create mode 100644 internal/cloning/templates.go create mode 100644 internal/cloning/types.go create mode 100644 internal/proxmox/cluster.go create mode 100644 internal/proxmox/networking.go create mode 100644 internal/proxmox/pools.go create mode 100644 internal/proxmox/proxmox.go create mode 100644 internal/proxmox/resources.go create mode 100644 internal/proxmox/types.go create mode 100644 internal/proxmox/vms.go create mode 100644 internal/tools/database.go create mode 100644 internal/tools/requests.go create mode 100644 internal/tools/telemetry.go delete mode 100644 main.go delete mode 100644 proxmox/cloning/cloning.go delete mode 100644 proxmox/cloning/deleting.go delete mode 100644 proxmox/cloning/locking/mutexLock.go delete mode 100644 proxmox/cloning/networking.go delete mode 100644 proxmox/cloning/optimization.go delete mode 100644 proxmox/cloning/pods.go delete mode 100644 proxmox/cloning/templates.go delete mode 100644 proxmox/images/upload.go delete mode 100644 proxmox/proxmox.go delete mode 100644 proxmox/requests.go delete mode 100644 proxmox/resources.go delete mode 100644 proxmox/vms.go delete mode 100644 uploads/kaminoLogo.svg diff --git a/.env b/.env deleted file mode 100644 index 99c600c..0000000 --- a/.env +++ /dev/null @@ -1,15 +0,0 @@ -LDAP_SERVER=placeholder.placeholder -PC_PORT=8080 -SECRET_KEY=placeholder -LDAP_BIND_DN=cn=ldap-reader,cn=users,dc=domain,dc=com -LDAP_BIND_PASSWORD=placeholder -LDAP_BASE_DN=dc=domain,dc=com -PROXMOX_SERVER=your.domain.com -PROXMOX_PORT=8006 -PROXMOX_TOKEN_ID=kaminosvc@pve!kamino-token -PROXMOX_TOKEN_SECRET=placeholder -PROXMOX_VERIFY_SSL="false" -PROXMOX_NODES=gonk,commando,gemini -SIGNOZ_SERVICE_NAME="proclone" -OTEL_EXPORTER_OTLP_ENDPOINT="signoz-otel-collector.signoz.svc.cluster.local:4317" -SIGNOZ_INSECURE_MODE="true" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4cc65c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +*.env + +# Uploaded template images +uploads/ + +# Editor/IDE +# .idea/ +# .vscode/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 3d4fa9a..1e4eb3b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,13 @@ -FROM golang:1.24 as builder +FROM golang:1.24 AS builder WORKDIR /app COPY . . RUN go mod download -RUN go build -o server . +RUN go build -o server ./cmd/api FROM debian:bookworm-slim WORKDIR /app COPY --from=builder /app/server . +COPY cmd/api/.env . EXPOSE 8080 CMD ["./server"] diff --git a/auth/auth.go b/auth/auth.go deleted file mode 100644 index 7fa108c..0000000 --- a/auth/auth.go +++ /dev/null @@ -1,144 +0,0 @@ -package auth - -import ( - "fmt" - "log" - "net/http" - "strings" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" -) - -// struct to hold username and password received from post request -type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` -} - -// called by /api/login post request -func LoginHandler(c *gin.Context) { - var req LoginRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) - return - } - - username := strings.TrimSpace(req.Username) - password := req.Password - - // return error if either username or password are empty - if username == "" || password == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "Username and password are required"}) - return - } - - // Connect to LDAP - ldapConn, err := ConnectToLDAP() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("LDAP connection failed: %v", err)}) - return - } - defer ldapConn.Close() - - // Authenticate user - _, groups, err := ldapConn.AuthenticateUser(username, password) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) - return - } - - // Check if user is admin - isAdmin := CheckIfAdmin(groups) - - log.Println("logging user membership: ", groups) - for _, group := range groups { - log.Println("User is a member of: ", group) - } - - // create session - session := sessions.Default(c) - session.Set("authenticated", true) - session.Set("username", username) - session.Set("is_admin", isAdmin) - session.Save() - - c.JSON(http.StatusOK, gin.H{"message": "Login successful"}) -} - -// handle clearing session cookies -func LogoutHandler(c *gin.Context) { - session := sessions.Default(c) - session.Clear() - session.Save() - c.JSON(http.StatusOK, gin.H{"message": "Logged out"}) -} - -// check logged in profile -func ProfileHandler(c *gin.Context) { - session := sessions.Default(c) - username := session.Get("username") - isAdmin := session.Get("is_admin") - - if username == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "username": username, - "isAdmin": isAdmin, - }) -} - -// check if user is authenticated -func IsAuthenticated(c *gin.Context) (bool, string) { - session := sessions.Default(c) - auth, ok := session.Get("authenticated").(bool) - if !ok || !auth { - return false, "" - } - username, _ := session.Get("username").(string) - return true, username -} - -// check if user is in "Domain Admins" group -func isAdmin(c *gin.Context) bool { - session := sessions.Default(c) - isAdmin, _ := session.Get("is_admin").(bool) - return isAdmin -} - -// api endpoint that returns true if user is already authenticated -func SessionHandler(c *gin.Context) { - if ok, username := IsAuthenticated(c); ok { - is_admin := isAdmin(c) - c.JSON(http.StatusOK, gin.H{ - "authenticated": true, - "username": username, - "isAdmin": is_admin, - }) - } else { - c.JSON(http.StatusUnauthorized, gin.H{"authenticated": false}) - } -} - -// auth protected routes helper function -func AuthRequired(c *gin.Context) { - if ok, _ := IsAuthenticated(c); !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - c.Abort() - return - } - c.Next() -} - -// admin protected routes helper function -func AdminRequired(c *gin.Context) { - if !isAdmin(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden"}) - c.Abort() - return - } - c.Next() -} diff --git a/auth/ldap.go b/auth/ldap.go deleted file mode 100644 index 49ef0cd..0000000 --- a/auth/ldap.go +++ /dev/null @@ -1,185 +0,0 @@ -package auth - -import ( - "fmt" - "os" - "strings" - "time" - - "github.com/go-ldap/ldap/v3" -) - -// LDAPConnection holds the LDAP connection and configuration -type LDAPConnection struct { - conn *ldap.Conn - server string - baseDN string - bindDN string - bindPassword string -} - -// ConnectToLDAP creates a new LDAP connection and returns it -func ConnectToLDAP() (*LDAPConnection, error) { - // LDAP configuration from environment variables - ldapServer := os.Getenv("LDAP_SERVER") - baseDN := os.Getenv("LDAP_BASE_DN") - bindDN := os.Getenv("LDAP_BIND_DN") - bindPassword := os.Getenv("LDAP_BIND_PASSWORD") - - // check LDAP configuration - if ldapServer == "" || baseDN == "" || bindDN == "" || bindPassword == "" { - return nil, fmt.Errorf("LDAP configuration is missing") - } - - // connect to LDAP server - conn, err := ldap.DialURL("ldap://" + ldapServer + ":389") - if err != nil { - return nil, fmt.Errorf("LDAP connection failed: %v", err) - } - - // bind as service account - err = conn.Bind(bindDN, bindPassword) - if err != nil { - conn.Close() - return nil, fmt.Errorf("LDAP service account bind failed: %v", err) - } - - return &LDAPConnection{ - conn: conn, - server: ldapServer, - baseDN: baseDN, - bindDN: bindDN, - bindPassword: bindPassword, - }, nil -} - -// Close closes the LDAP connection -func (lc *LDAPConnection) Close() { - if lc.conn != nil { - lc.conn.Close() - } -} - -// AuthenticateUser authenticates a user against LDAP and returns user info -func (lc *LDAPConnection) AuthenticateUser(username, password string) (string, []string, error) { - // Define search request - searchRequest := ldap.NewSearchRequest( - lc.baseDN, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(sAMAccountName=%s)", username), - []string{"dn", "memberOf"}, - nil, - ) - - // search for user - sr, err := lc.conn.Search(searchRequest) - if err != nil { - return "", nil, fmt.Errorf("user not found in LDAP: %v", err) - } - - // handle user not found - if len(sr.Entries) != 1 { - return "", nil, fmt.Errorf("user not found or multiple users found") - } - - userDN := sr.Entries[0].DN - groups := sr.Entries[0].GetAttributeValues("memberOf") - - // bind as user to verify password - err = lc.conn.Bind(userDN, password) - if err != nil { - return "", nil, fmt.Errorf("invalid credentials") - } - - // rebind as service account for further operations - err = lc.conn.Bind(lc.bindDN, lc.bindPassword) - if err != nil { - return "", nil, fmt.Errorf("failed to rebind as service account") - } - - return userDN, groups, nil -} - -// GetAllUsers fetches all users from Active Directory -func (lc *LDAPConnection) GetAllUsers() (*UserResponse, error) { - // search for all users - searchRequest := ldap.NewSearchRequest( - lc.baseDN, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - "(&(objectClass=user)(objectCategory=person)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))", - []string{"sAMAccountName", "whenCreated", "memberOf"}, - nil, - ) - - // perform search - sr, err := lc.conn.Search(searchRequest) - if err != nil { - return nil, fmt.Errorf("LDAP search failed: %v", err) - } - - var userResponse UserResponse - userResponse.Users = make([]UserWithRoles, 0) - - // process each user entry - for _, entry := range sr.Entries { - username := entry.GetAttributeValue("sAMAccountName") - whenCreated := entry.GetAttributeValue("whenCreated") - groups := entry.GetAttributeValues("memberOf") - - // skip if no username - if username == "" { - continue - } - - // parse and format creation date - createdDate := "Unknown" - if whenCreated != "" { - // AD stores dates in GeneralizedTime format: YYYYMMDDHHMMSS.0Z - if parsedTime, err := time.Parse("20060102150405.0Z", whenCreated); err == nil { - createdDate = parsedTime.Format("2006-01-02 15:04:05") - } - } - - // check if user is admin - isAdmin := false - for _, group := range groups { - if strings.Contains(strings.ToLower(group), "cn=domain admins") || strings.Contains(strings.ToLower(group), "cn=proxmox-admins") { - isAdmin = true - break - } - } - - // clean up group names (extract CN values) - cleanGroups := make([]string, 0) - for _, group := range groups { - // extract CN from DN format - parts := strings.Split(group, ",") - if len(parts) > 0 && strings.HasPrefix(strings.ToLower(parts[0]), "cn=") { - groupName := strings.TrimPrefix(parts[0], "CN=") - groupName = strings.TrimPrefix(groupName, "cn=") - cleanGroups = append(cleanGroups, groupName) - } - } - - user := UserWithRoles{ - Username: username, - CreatedDate: createdDate, - IsAdmin: isAdmin, - Groups: cleanGroups, - } - - userResponse.Users = append(userResponse.Users, user) - } - - return &userResponse, nil -} - -// CheckIfAdmin checks if a user is in the Domain Admins group -func CheckIfAdmin(groups []string) bool { - for _, group := range groups { - if strings.Contains(strings.ToLower(group), "cn=domain admins") { - return true - } - } - return false -} diff --git a/auth/users.go b/auth/users.go deleted file mode 100644 index 734d1ab..0000000 --- a/auth/users.go +++ /dev/null @@ -1,73 +0,0 @@ -package auth - -import ( - "log" - "net/http" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" -) - -type UserResponse struct { - Users []UserWithRoles `json:"users"` -} - -type UserWithRoles struct { - Username string `json:"username"` - CreatedDate string `json:"createdDate"` - IsAdmin bool `json:"isAdmin"` - Groups []string `json:"groups"` -} - -// helper function that fetches all users from Active Directory -func buildUserResponse() (*UserResponse, error) { - // Connect to LDAP - ldapConn, err := ConnectToLDAP() - if err != nil { - return nil, err - } - defer ldapConn.Close() - - // Get all users using the LDAP connection - return ldapConn.GetAllUsers() -} - -/* - * ===== ADMIN ENDPOINT ===== - * This function returns a list of - * all users and their roles in Active Directory - */ -func GetUsers(c *gin.Context) { - session := sessions.Default(c) - username := session.Get("username") - isAdmin := session.Get("is_admin") - - // make sure user is authenticated and is admin - if !isAdmin.(bool) { - log.Printf("Forbidden access attempt by user %s", username) - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only Admin users can see all domain users", - }) - return - } - - // fetch user response - userResponse, err := getAdminUserResponse() - - // if error, return error status - if err != nil { - log.Printf("Failed to fetch user list for admin %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to fetch user list from Active Directory", - "details": err.Error(), - }) - return - } - - log.Printf("Successfully fetched user list for admin %s", username) - c.JSON(http.StatusOK, userResponse) -} - -func getAdminUserResponse() (*UserResponse, error) { - return buildUserResponse() -} diff --git a/cmd/api/main.go b/cmd/api/main.go new file mode 100644 index 0000000..f4e569e --- /dev/null +++ b/cmd/api/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "log" + + "github.com/cpp-cyber/proclone/internal/api/handlers" + "github.com/cpp-cyber/proclone/internal/api/routes" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + _ "github.com/go-sql-driver/mysql" + "github.com/joho/godotenv" + "github.com/kelseyhightower/envconfig" +) + +// Config holds all application configuration +type Config struct { + Port string `envconfig:"PORT" default:":8080"` + SessionSecret string `envconfig:"SESSION_SECRET" default:"default-secret-key"` +} + +// init the environment +func init() { + _ = godotenv.Load() +} + +func main() { + gin.SetMode(gin.ReleaseMode) + + // Load and parse configuration from environment variables + var config Config + if err := envconfig.Process("", &config); err != nil { + log.Fatalf("Failed to process environment configuration: %v", err) + } + + r := gin.Default() + + // Setup session middleware + store := cookie.NewStore([]byte(config.SessionSecret)) + r.Use(sessions.Sessions("session", store)) + + // Initialize handlers + authHandler, err := handlers.NewAuthHandler() + if err != nil { + log.Fatalf("Failed to initialize auth handler: %v", err) + } + + proxmoxHandler, err := handlers.NewProxmoxHandler() + if err != nil { + log.Fatalf("Failed to initialize Proxmox handler: %v", err) + } + + cloningHandler, err := handlers.NewCloningHandler() + if err != nil { + log.Fatalf("Failed to initialize cloning handler: %v", err) + } + + routes.RegisterRoutes(r, authHandler, proxmoxHandler, cloningHandler) + r.Run(config.Port) +} diff --git a/database/connect.go b/database/connect.go deleted file mode 100644 index 1c03670..0000000 --- a/database/connect.go +++ /dev/null @@ -1,83 +0,0 @@ -package database - -import ( - "database/sql" - "fmt" - "log" - "os" - - _ "github.com/go-sql-driver/mysql" -) - -// DB is the global database connection -var DB *sql.DB - -// Connect to the MariaDB database -func ConnectDB() (*sql.DB, error) { - dbHost := os.Getenv("DB_HOST") - dbPort := os.Getenv("DB_PORT") - dbUser := os.Getenv("DB_USER") - dbPassword := os.Getenv("DB_PASSWORD") - dbName := os.Getenv("DB_NAME") - - // Check if any required environment variables are not set - if dbHost == "" || dbPort == "" || dbUser == "" || dbName == "" || dbPassword == "" { - return nil, fmt.Errorf("one or more database environment variables are not set") - } - - // Build the Data Source Name (DSN) - dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", - dbUser, dbPassword, dbHost, dbPort, dbName) - - // Open database connection - db, err := sql.Open("mysql", dsn) - if err != nil { - return nil, fmt.Errorf("failed to open database connection: %w", err) - } - - // Test the connection - err = db.Ping() - if err != nil { - db.Close() - return nil, fmt.Errorf("failed to ping database: %w", err) - } - - log.Printf("Successfully connected to MariaDB database: %s", dbName) - - // Set the global DB variable - DB = db - - return db, nil -} - -// CloseDB closes the database connection -func CloseDB() error { - if DB != nil { - err := DB.Close() - if err != nil { - return fmt.Errorf("failed to close database connection: %w", err) - } - log.Println("Database connection closed") - } - return nil -} - -// GetDB returns the global database connection -func GetDB() *sql.DB { - return DB -} - -// InitializeDB initializes the database connection and ensures it's ready for use -func InitializeDB() error { - _, err := ConnectDB() - if err != nil { - return fmt.Errorf("failed to initialize database: %w", err) - } - - // Configure connection pool settings - DB.SetMaxOpenConns(25) // Maximum number of open connections - DB.SetMaxIdleConns(25) // Maximum number of idle connections - DB.SetConnMaxLifetime(0) // Maximum connection lifetime (0 = unlimited) - - return nil -} diff --git a/database/query.go b/database/query.go deleted file mode 100644 index 12f2994..0000000 --- a/database/query.go +++ /dev/null @@ -1,180 +0,0 @@ -package database - -import ( - "database/sql" - "fmt" -) - -// Template represents a template record from the database -type Template struct { - Name string `json:"name"` - Description string `json:"description"` - ImagePath string `json:"image_path"` - Visible bool `json:"visible"` - VMCount int `json:"vm_count"` - Deployments int `json:"deployments"` - CreatedAt string `json:"created_at"` -} - -func BuildTemplates(rows *sql.Rows) ([]Template, error) { - templates := []Template{} - - // Iterate through the result set - for rows.Next() { - var template Template - err := rows.Scan( - &template.Name, - &template.Description, - &template.ImagePath, - &template.Visible, - &template.VMCount, - &template.Deployments, - &template.CreatedAt, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan row: %w", err) - } - templates = append(templates, template) - } - - return templates, nil -} - -// Returns all visible templates -func SelectVisibleTemplates() ([]Template, error) { - if DB == nil { - return nil, fmt.Errorf("database connection is not initialized") - } - - var query = "SELECT * FROM templates WHERE visible = true" - - rows, err := DB.Query(query) - if err != nil { - return nil, fmt.Errorf("failed to execute query (%s): %w", query, err) - } - defer rows.Close() - - templates, err := BuildTemplates(rows) - if err != nil { - return nil, fmt.Errorf("failed to build templates: %w", err) - } - - return templates, nil -} - -// Returns all templates -func SelectAllTemplates() ([]Template, error) { - if DB == nil { - return nil, fmt.Errorf("database connection is not initialized") - } - - var query = "SELECT * FROM templates" - - rows, err := DB.Query(query) - if err != nil { - return nil, fmt.Errorf("failed to execute query (%s): %w", query, err) - } - defer rows.Close() - - templates, err := BuildTemplates(rows) - if err != nil { - return nil, fmt.Errorf("failed to build templates: %w", err) - } - - return templates, nil -} - -// Insert template into database -func InsertTemplate(template Template) error { - if DB == nil { - return fmt.Errorf("database connection is not initialized") - } - - query := "INSERT INTO templates (name, description, image_path, visible, vm_count) VALUES (?, ?, ?, ?, ?)" - - _, err := DB.Exec(query, template.Name, template.Description, template.ImagePath, template.Visible, template.VMCount) - if err != nil { - return fmt.Errorf("failed to execute query (%s): %w", query, err) - } - - return nil -} - -// Update template data -func UpdateTemplate(template Template) error { - if DB == nil { - return fmt.Errorf("database connection is not initialized") - } - - query := "UPDATE templates SET description = ?, image_path = ?, visible = ?, vm_count = ? WHERE name = ?" - - _, err := DB.Exec(query, template.Description, template.ImagePath, template.Visible, template.VMCount, template.Name) - if err != nil { - return fmt.Errorf("failed to execute query (%s): %w", query, err) - } - - return nil -} - -// Delete template from database -func DeleteTemplate(templateName string) error { - if DB == nil { - return fmt.Errorf("database connection is not initialized") - } - - query := "DELETE FROM templates WHERE name = ?" - - _, err := DB.Exec(query, templateName) - if err != nil { - return fmt.Errorf("failed to execute query (%s): %w", query, err) - } - - return nil -} - -// Helper function to select all template names -func SelectAllTemplateNames() ([]string, error) { - templates, err := SelectAllTemplates() - if err != nil { - return nil, err - } - - var templateNames []string - for _, template := range templates { - templateNames = append(templateNames, template.Name) - } - - return templateNames, nil -} - -// Adds one to the deployment count of a template -func AddDeployment(templateName string) error { - if DB == nil { - return fmt.Errorf("database connection is not initialized") - } - - query := "UPDATE templates SET deployments = deployments + 1 WHERE name = ?" - - _, err := DB.Exec(query, templateName) - if err != nil { - return fmt.Errorf("failed to execute query (%s): %w", query, err) - } - - return nil -} - -// Toggles the visibility of a template -func ToggleVisibility(templateName string) error { - if DB == nil { - return fmt.Errorf("database connection is not initialized") - } - - query := "UPDATE templates SET visible = NOT visible WHERE name = ?" - - _, err := DB.Exec(query, templateName) - if err != nil { - return fmt.Errorf("failed to execute query (%s): %w", query, err) - } - - return nil -} diff --git a/go.mod b/go.mod index 32b46be..432853f 100644 --- a/go.mod +++ b/go.mod @@ -2,66 +2,48 @@ module github.com/cpp-cyber/proclone go 1.24.1 +toolchain go1.24.6 + require ( - github.com/gin-contrib/sessions v1.0.3 + github.com/gin-contrib/sessions v1.0.4 github.com/gin-gonic/gin v1.10.1 github.com/go-ldap/ldap/v3 v3.4.11 + github.com/go-sql-driver/mysql v1.9.3 + github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 + github.com/kelseyhightower/envconfig v1.4.0 ) require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect - github.com/bsm/redislock v0.9.4 // indirect - github.com/bytedance/sonic v1.13.3 // indirect + github.com/bytedance/sonic v1.13.2 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect - github.com/cenkalti/backoff/v5 v5.0.2 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.5 // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/gabriel-vasile/mimetype v1.4.9 // indirect - github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.0.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect - github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/goccy/go-json v0.10.5 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/redis/go-redis/v9 v9.11.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.62.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/sdk v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect - go.opentelemetry.io/proto/otlp v1.7.0 // indirect - golang.org/x/arch v0.18.0 // indirect - golang.org/x/crypto v0.39.0 // indirect - golang.org/x/net v0.41.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect - google.golang.org/grpc v1.73.0 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.16.0 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0c46eac..a8df5d3 100644 --- a/go.sum +++ b/go.sum @@ -4,52 +4,29 @@ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= -github.com/bsm/redislock v0.9.4 h1:X/Wse1DPpiQgHbVYRE9zv6m070UcKoOGekgvpNhiSvw= -github.com/bsm/redislock v0.9.4/go.mod h1:Epf7AJLiSFwLCiZcfi6pWFO/8eAYrYpQXFxEDPoDeAk= github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= -github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= -github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= -github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= -github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= -github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= -github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= -github.com/gin-contrib/sessions v1.0.3 h1:AZ4j0AalLsGqdrKNbbrKcXx9OJZqViirvNGsJTxcQps= -github.com/gin-contrib/sessions v1.0.3/go.mod h1:5i4XMx4KPtQihnzxEqG9u1K446lO3G19jAi2GtbfsAI= +github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U= +github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs= github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= -github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= -github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= -github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -75,8 +52,6 @@ github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kX github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= @@ -95,11 +70,11 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= -github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= @@ -112,14 +87,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/redis/go-redis/v9 v9.0.3 h1:+7mmR26M0IvyLxGZUHxu4GiBkJkVDid0Un+j4ScYu4k= -github.com/redis/go-redis/v9 v9.0.3/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= -github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs= -github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -136,57 +105,19 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= -github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.62.0 h1:fZNpsQuTwFFSGC96aJexNOBrCD7PjD9Tm/HyHtXhmnk= -go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.62.0/go.mod h1:+NFxPSeYg0SoiRUO4k0ceJYMCY9FiRbYFmByUpm7GJY= -go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= -go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= -go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U= golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= -golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= -golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= -google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/api/handlers/auth_handler.go b/internal/api/handlers/auth_handler.go new file mode 100644 index 0000000..b864919 --- /dev/null +++ b/internal/api/handlers/auth_handler.go @@ -0,0 +1,142 @@ +package handlers + +import ( + "fmt" + "log" + "net/http" + + "github.com/cpp-cyber/proclone/internal/auth" + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +// AuthHandler handles HTTP authentication requests +type AuthHandler struct { + authService auth.Service +} + +// NewAuthHandler creates a new authentication handler +func NewAuthHandler() (*AuthHandler, error) { + authService, err := auth.NewLDAPService() + if err != nil { + return nil, fmt.Errorf("failed to create auth service: %w", err) + } + + log.Println("Auth handler initialized") + + return &AuthHandler{ + authService: authService, + }, nil +} + +// LoginHandler handles the login POST request +func (h *AuthHandler) LoginHandler(c *gin.Context) { + var loginReq struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + } + + if err := c.ShouldBindJSON(&loginReq); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + + // Authenticate user + valid, err := h.authService.Authenticate(loginReq.Username, loginReq.Password) + if err != nil { + log.Printf("Authentication failed for user %s: %v", loginReq.Username, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Authentication failed"}) + return + } + + if !valid { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) + return + } + + // Create session + session := sessions.Default(c) + session.Set("id", loginReq.Username) + + // Check if user is admin + isAdmin, err := h.authService.IsAdmin(loginReq.Username) + if err != nil { + log.Printf("Error checking admin status for user %s: %v", loginReq.Username, err) + isAdmin = false + } + session.Set("isAdmin", isAdmin) + + if err := session.Save(); err != nil { + log.Printf("Failed to save session for user %s: %v", loginReq.Username, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save session"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Login successful", + "isAdmin": isAdmin, + }) +} + +// LogoutHandler handles user logout +func (h *AuthHandler) LogoutHandler(c *gin.Context) { + session := sessions.Default(c) + session.Clear() + + if err := session.Save(); err != nil { + log.Printf("Failed to clear session: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save session"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Successfully logged out"}) +} + +// SessionHandler returns current session information for authenticated users +func (h *AuthHandler) SessionHandler(c *gin.Context) { + session := sessions.Default(c) + + // Since this is under private routes, AuthRequired middleware ensures session exists + id := session.Get("id") + isAdmin := session.Get("isAdmin") + + // Convert isAdmin to bool, defaulting to false if not set + adminStatus := false + if isAdmin != nil { + adminStatus = isAdmin.(bool) + } + + c.JSON(http.StatusOK, gin.H{ + "authenticated": true, + "username": id.(string), + "isAdmin": adminStatus, + }) +} + +func (h *AuthHandler) RegisterHandler(c *gin.Context) { + var req CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + + // Check if the username already exists + var userDN = "" + userDN, err := h.authService.GetUserDN(req.Username) + if userDN != "" { + c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"}) + return + } + if err != nil { + // Ignore since this error is (most likely) stating that the user does not exist + } + + // Create user + if err := h.authService.CreateAndRegisterUser(auth.UserRegistrationInfo(req)); err != nil { + log.Printf("Failed to create user %s: %v", req.Username, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "User registered successfully"}) +} diff --git a/internal/api/handlers/cloning_handler.go b/internal/api/handlers/cloning_handler.go new file mode 100644 index 0000000..5413dc9 --- /dev/null +++ b/internal/api/handlers/cloning_handler.go @@ -0,0 +1,405 @@ +package handlers + +import ( + "fmt" + "log" + "net/http" + "path/filepath" + "strings" + + "github.com/cpp-cyber/proclone/internal/auth" + "github.com/cpp-cyber/proclone/internal/cloning" + "github.com/cpp-cyber/proclone/internal/proxmox" + "github.com/cpp-cyber/proclone/internal/tools" + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +// CloningHandler holds the cloning manager +type CloningHandler struct { + Manager *cloning.CloningManager + dbClient *tools.DBClient +} + +// NewCloningHandler creates a new cloning handler, loading dependencies internally +func NewCloningHandler() (*CloningHandler, error) { + // Initialize database connection + dbClient, err := tools.NewDBClient() + if err != nil { + return nil, fmt.Errorf("failed to initialize database client: %w", err) + } + + // Initialize Proxmox service + proxmoxService, err := proxmox.NewService() + if err != nil { + return nil, fmt.Errorf("failed to create Proxmox service: %w", err) + } + + // Initialize LDAP service + ldapService, err := auth.NewLDAPService() + if err != nil { + return nil, fmt.Errorf("failed to create LDAP service: %w", err) + } + + // Initialize Cloning manager + cloningManager, err := cloning.NewCloningManager(proxmoxService, dbClient.DB(), ldapService) + if err != nil { + return nil, fmt.Errorf("failed to initialize cloning manager: %w", err) + } + log.Println("Cloning manager initialized") + + return &CloningHandler{ + Manager: cloningManager, + dbClient: dbClient, + }, nil +} + +// CloneTemplateHandler handles requests to clone a template pool for a user or group +func (ch *CloningHandler) CloneTemplateHandler(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("id").(string) + + var req CloneRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request body", + "details": err.Error(), + }) + return + } + + // Construct the full template pool name + templatePoolName := "kamino_template_" + req.Template + + err := ch.Manager.CloneTemplate(templatePoolName, username, false) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to clone template", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": fmt.Sprintf("Pod template cloned successfully for user %s", username), + "template": req.Template, + }) +} + +// ADMIN: BulkCloneTemplateHandler handles POST requests for cloning multiple templates for a list of users +func (ch *CloningHandler) AdminCloneTemplateHandler(c *gin.Context) { + var req AdminCloneRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request body", + "details": err.Error(), + }) + return + } + + // Verify that template is not blank + if req.Template == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "No template specified", + "details": "A template must be specified", + }) + return + } + + // Verify that users and groups are not empty + if len(req.Usernames) == 0 && len(req.Groups) == 0 { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "No users or groups specified", + "details": "At least one user or group must be specified", + }) + return + } + + // Construct the full template pool name + templatePoolName := "kamino_template_" + req.Template + + // Clone for users + var errors []error + for _, username := range req.Usernames { + err := ch.Manager.CloneTemplate(templatePoolName, username, false) + if err != nil { + errors = append(errors, fmt.Errorf("failed to clone template for user %s: %v", username, err)) + } + } + + // Clone for groups + for _, group := range req.Groups { + err := ch.Manager.CloneTemplate(templatePoolName, group, true) + if err != nil { + errors = append(errors, fmt.Errorf("failed to clone template for group %s: %v", group, err)) + } + } + + // Check for errors + if len(errors) > 0 { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to clone templates", + "details": errors, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Templates cloned successfully", + }) +} + +// DeletePodHandler handles requests to delete a pod +func (ch *CloningHandler) DeletePodHandler(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("id").(string) + + var req DeletePodRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request body", + "details": err.Error(), + }) + return + } + + // Check if the pod belongs to the user + if !strings.Contains(req.Pod, username) { + c.JSON(http.StatusForbidden, gin.H{ + "error": "You do not have permission to delete this pod", + "details": fmt.Sprintf("Pod %s does not belong to user %s", req.Pod, username), + }) + return + } + + err := ch.Manager.DeletePod(req.Pod) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to delete pod", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Pod deleted successfully"}) +} + +func (ch *CloningHandler) AdminDeletePodHandler(c *gin.Context) { + var req AdminDeletePodRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid request body", + "details": err.Error(), + }) + return + } + + var errors []error + for _, pod := range req.Pods { + err := ch.Manager.DeletePod(pod) + if err != nil { + errors = append(errors, fmt.Errorf("failed to delete pod %s: %v", pod, err)) + } + } + + if len(errors) > 0 { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to delete pods", + "details": errors, + }) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Pods deleted successfully"}) +} + +func (ch *CloningHandler) GetUnpublishedTemplatesHandler(c *gin.Context) { + templates, err := ch.Manager.GetUnpublishedTemplates() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to retrieve unpublished templates", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "templates": templates, + "count": len(templates), + }) +} + +// PRIVATE: GetPodsHandler handles GET requests for retrieving a user's pods +func (ch *CloningHandler) GetPodsHandler(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("id").(string) + + pods, err := ch.Manager.GetPods(username) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve pods for user " + username, "details": err.Error()}) + return + } + + // Loop through the user's deployed pods and add template information + for i := range pods { + templateName := strings.Replace(pods[i].Name[5:], fmt.Sprintf("_%s", username), "", 1) + templateInfo, err := ch.Manager.DatabaseService.GetTemplateInfo(templateName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve template info for pod " + pods[i].Name, "details": err.Error()}) + return + } + pods[i].Template = templateInfo + } + + c.JSON(http.StatusOK, gin.H{"pods": pods}) +} + +// ADMIN: GetAllPodsHandler handles GET requests for retrieving all pods +func (ch *CloningHandler) AdminGetPodsHandler(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("id").(string) + + pods, err := ch.Manager.GetAllPods() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve pods for user " + username, "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"pods": pods}) +} + +// Template-related handlers + +// PRIVATE: GetTemplatesHandler handles GET requests for retrieving templates +func (ch *CloningHandler) GetTemplatesHandler(c *gin.Context) { + templates, err := ch.Manager.DatabaseService.GetTemplates() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to retrieve templates", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "templates": templates, + "count": len(templates), + }) +} + +// ADMIN: GetPublishedTemplatesHandler handles GET requests for retrieving all templates +func (ch *CloningHandler) AdminGetTemplatesHandler(c *gin.Context) { + templates, err := ch.Manager.DatabaseService.GetPublishedTemplates() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to retrieve all templates", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "templates": templates, + "count": len(templates), + }) +} + +// PRIVATE: GetTemplateImageHandler handles GET requests for retrieving a template's image +func (ch *CloningHandler) GetTemplateImageHandler(c *gin.Context) { + filename := c.Param("filename") + config := ch.Manager.DatabaseService.GetTemplateConfig() + filePath := filepath.Join(config.UploadDir, filename) + + // Serve the file + c.File(filePath) +} + +// ADMIN: PublishTemplateHandler handles POST requests for publishing a template +func (ch *CloningHandler) PublishTemplateHandler(c *gin.Context) { + var req PublishTemplateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) + return + } + + if err := ch.Manager.PublishTemplate(req.Template); err != nil { + log.Printf("Error publishing template: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to publish template", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Template published successfully", + }) +} + +// ADMIN: DeleteTemplateHandler handles POST requests for deleting a template +func (ch *CloningHandler) DeleteTemplateHandler(c *gin.Context) { + var req TemplateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) + return + } + + if err := ch.Manager.DatabaseService.DeleteTemplate(req.Template); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to delete template", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Template deleted successfully", + }) +} + +// ADMIN: ToggleTemplateVisibilityHandler handles POST requests for toggling a template's visibility +func (ch *CloningHandler) ToggleTemplateVisibilityHandler(c *gin.Context) { + var req TemplateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) + return + } + + if err := ch.Manager.DatabaseService.ToggleTemplateVisibility(req.Template); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to toggle template visibility", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Template visibility toggled successfully", + }) +} + +// ADMIN: UploadTemplateImageHandler handles POST requests for uploading a template's image +func (ch *CloningHandler) UploadTemplateImageHandler(c *gin.Context) { + result, err := ch.Manager.DatabaseService.UploadTemplateImage(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to upload template image", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, result) +} + +// HealthCheck checks the database connection health +func (ch *CloningHandler) HealthCheck() error { + return ch.dbClient.HealthCheck() +} + +// Reconnect attempts to reconnect to the database +func (ch *CloningHandler) Reconnect() error { + return ch.dbClient.Connect() +} diff --git a/internal/api/handlers/dashboard_handler.go b/internal/api/handlers/dashboard_handler.go new file mode 100644 index 0000000..239712d --- /dev/null +++ b/internal/api/handlers/dashboard_handler.go @@ -0,0 +1,90 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// DashboardHandler handles HTTP requests for dashboard operations +type DashboardHandler struct { + authHandler *AuthHandler + proxmoxHandler *ProxmoxHandler + cloningHandler *CloningHandler +} + +// NewDashboardHandler creates a new dashboard handler +func NewDashboardHandler(authHandler *AuthHandler, proxmoxHandler *ProxmoxHandler, cloningHandler *CloningHandler) *DashboardHandler { + return &DashboardHandler{ + authHandler: authHandler, + proxmoxHandler: proxmoxHandler, + cloningHandler: cloningHandler, + } +} + +// DashboardStats represents the structure of dashboard statistics +type DashboardStats struct { + UserCount int `json:"users"` + GroupCount int `json:"groups"` + PublishedTemplateCount int `json:"published_templates"` + DeployedPodCount int `json:"deployed_pods"` + VirtualMachineCount int `json:"vms"` + ClusterResourceUsage any `json:"cluster"` +} + +// ADMIN: GetDashboardStatsHandler retrieves all dashboard statistics in a single request +func (dh *DashboardHandler) GetDashboardStatsHandler(c *gin.Context) { + stats := DashboardStats{} + + // Get user count + users, err := dh.authHandler.authService.GetUsers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve user count", "details": err.Error()}) + return + } + stats.UserCount = len(users) + + // Get group count + groups, err := dh.authHandler.authService.GetGroups() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve group count", "details": err.Error()}) + return + } + stats.GroupCount = len(groups) + + // Get published template count + publishedTemplates, err := dh.cloningHandler.Manager.DatabaseService.GetPublishedTemplates() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve published template count", "details": err.Error()}) + return + } + stats.PublishedTemplateCount = len(publishedTemplates) + + // Get deployed pod count + pods, err := dh.cloningHandler.Manager.GetAllPods() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployed pod count", "details": err.Error()}) + return + } + stats.DeployedPodCount = len(pods) + + // Get virtual machine count + vms, err := dh.proxmoxHandler.service.GetVMs() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve virtual machine count", "details": err.Error()}) + return + } + stats.VirtualMachineCount = len(vms) + + // Get cluster resource usage + clusterUsage, err := dh.proxmoxHandler.service.GetClusterResourceUsage() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve cluster resource usage", "details": err.Error()}) + return + } + stats.ClusterResourceUsage = clusterUsage + + c.JSON(http.StatusOK, gin.H{ + "stats": stats, + }) +} diff --git a/internal/api/handlers/groups_handler.go b/internal/api/handlers/groups_handler.go new file mode 100644 index 0000000..31a122f --- /dev/null +++ b/internal/api/handlers/groups_handler.go @@ -0,0 +1,113 @@ +package handlers + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" +) + +func (h *AuthHandler) GetGroupsHandler(c *gin.Context) { + groups, err := h.authService.GetGroups() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve groups"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "groups": groups, + "count": len(groups), + }) +} + +// ADMIN: CreateGroupsHandler creates new group(s) +func (h *AuthHandler) CreateGroupsHandler(c *gin.Context) { + var req GroupsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group data"}) + return + } + + var errors []error + + for _, group := range req.Groups { + if err := h.authService.CreateGroup(group); err != nil { + errors = append(errors, fmt.Errorf("failed to create group %s: %v", group, err)) + } + } + + if len(errors) > 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create groups", "details": errors}) + return + } + + c.JSON(http.StatusCreated, gin.H{"message": "Groups created successfully"}) +} + +func (h *AuthHandler) RenameGroupHandler(c *gin.Context) { + var req RenameGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group data"}) + return + } + + if err := h.authService.RenameGroup(req.OldName, req.NewName); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to rename group"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Group renamed successfully"}) +} + +func (h *AuthHandler) DeleteGroupsHandler(c *gin.Context) { + var req GroupsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group data"}) + return + } + + var errors []error + + for _, group := range req.Groups { + if err := h.authService.DeleteGroup(group); err != nil { + errors = append(errors, fmt.Errorf("failed to delete group %s: %v", group, err)) + } + } + + if len(errors) > 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete groups", "details": errors}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Groups deleted successfully"}) +} + +func (h *AuthHandler) AddUsersHandler(c *gin.Context) { + var req ModifyGroupMembersRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group data"}) + return + } + + if err := h.authService.AddUsersToGroup(req.Group, req.Usernames); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add users to group"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Users added to group successfully"}) +} + +func (h *AuthHandler) RemoveUsersHandler(c *gin.Context) { + var req ModifyGroupMembersRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group data"}) + return + } + + if err := h.authService.RemoveUsersFromGroup(req.Group, req.Usernames); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove users from group"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Users removed from group successfully"}) +} diff --git a/internal/api/handlers/health_handler.go b/internal/api/handlers/health_handler.go new file mode 100644 index 0000000..0410237 --- /dev/null +++ b/internal/api/handlers/health_handler.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// PUBLIC: HealthCheckHandler handles GET requests for health checks with detailed service status +func HealthCheckHandler(authHandler *AuthHandler, cloningHandler *CloningHandler) gin.HandlerFunc { + return func(c *gin.Context) { + healthStatus := gin.H{ + "status": "healthy", + "services": gin.H{ + "api": "healthy", + }, + } + + statusCode := http.StatusOK + + // Check LDAP connection + if authHandler != nil && authHandler.authService != nil { + if err := authHandler.authService.HealthCheck(); err != nil { + healthStatus["services"].(gin.H)["ldap"] = gin.H{ + "status": "unhealthy", + "error": err.Error(), + } + healthStatus["status"] = "degraded" + statusCode = http.StatusServiceUnavailable + } else { + healthStatus["services"].(gin.H)["ldap"] = "healthy" + } + } + + // Check database connection (via cloning handler) + if cloningHandler != nil { + if err := cloningHandler.HealthCheck(); err != nil { + healthStatus["services"].(gin.H)["database"] = gin.H{ + "status": "unhealthy", + "error": err.Error(), + } + healthStatus["status"] = "degraded" + statusCode = http.StatusServiceUnavailable + } else { + healthStatus["services"].(gin.H)["database"] = "healthy" + } + } + + c.JSON(statusCode, healthStatus) + } +} diff --git a/internal/api/handlers/proxmox_handler.go b/internal/api/handlers/proxmox_handler.go new file mode 100644 index 0000000..d68968b --- /dev/null +++ b/internal/api/handlers/proxmox_handler.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "fmt" + "log" + "net/http" + + "github.com/cpp-cyber/proclone/internal/proxmox" + "github.com/gin-gonic/gin" +) + +// ProxmoxHandler handles HTTP requests for Proxmox operations +type ProxmoxHandler struct { + service proxmox.Service +} + +// NewProxmoxHandler creates a new Proxmox handler, loading configuration internally +func NewProxmoxHandler() (*ProxmoxHandler, error) { + proxmoxService, err := proxmox.NewService() + if err != nil { + return nil, fmt.Errorf("failed to create Proxmox service: %w", err) + } + + log.Println("Proxmox handler initialized") + + return &ProxmoxHandler{ + service: proxmoxService, + }, nil +} + +// ADMIN: GetClusterResourceUsageHandler retrieves and formats the total cluster resource usage in addition to each individual node's usage +func (ph *ProxmoxHandler) GetClusterResourceUsageHandler(c *gin.Context) { + response, err := ph.service.GetClusterResourceUsage() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve cluster resource usage", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "cluster": response, + }) +} diff --git a/internal/api/handlers/types.go b/internal/api/handlers/types.go new file mode 100644 index 0000000..2cf71cb --- /dev/null +++ b/internal/api/handlers/types.go @@ -0,0 +1,68 @@ +package handlers + +import "github.com/cpp-cyber/proclone/internal/cloning" + +// API endpoint request structures + +type VMActionRequest struct { + Node string `json:"node"` + VMID int `json:"vmid"` +} + +type TemplateRequest struct { + Template string `json:"template"` +} + +type PublishTemplateRequest struct { + Template cloning.KaminoTemplate `json:"template"` +} + +type CloneRequest struct { + Template string `json:"template"` +} + +type GroupsRequest struct { + Groups []string `json:"groups"` +} + +type AdminCloneRequest struct { + Template string `json:"template"` + Usernames []string `json:"usernames"` + Groups []string `json:"groups"` +} + +type DeletePodRequest struct { + Pod string `json:"pod"` +} + +type AdminDeletePodRequest struct { + Pods []string `json:"pods"` +} + +type CreateUserRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type AdminCreateUserRequest struct { + Users []CreateUserRequest `json:"users"` +} + +type UsersRequest struct { + Usernames []string `json:"usernames"` +} + +type ModifyGroupMembersRequest struct { + Group string `json:"group"` + Usernames []string `json:"usernames"` +} + +type SetUserGroupsRequest struct { + Username string `json:"username"` + Groups []string `json:"groups"` +} + +type RenameGroupRequest struct { + OldName string `json:"old_name"` + NewName string `json:"new_name"` +} diff --git a/internal/api/handlers/users_handler.go b/internal/api/handlers/users_handler.go new file mode 100644 index 0000000..36747b6 --- /dev/null +++ b/internal/api/handlers/users_handler.go @@ -0,0 +1,146 @@ +package handlers + +import ( + "fmt" + "net/http" + + "github.com/cpp-cyber/proclone/internal/auth" + "github.com/gin-gonic/gin" +) + +// ADMIN: GetUsersHandler returns a list of all users +func (h *AuthHandler) GetUsersHandler(c *gin.Context) { + users, err := h.authService.GetUsers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve users"}) + return + } + + var adminCount = 0 + var disabledCount = 0 + for _, user := range users { + if user.IsAdmin { + adminCount++ + } + if !user.Enabled { + disabledCount++ + } + } + + c.JSON(http.StatusOK, gin.H{ + "users": users, + "count": len(users), + "disabled_count": disabledCount, + "admin_count": adminCount, + }) +} + +// ADMIN: CreateUsersHandler creates new user(s) +func (h *AuthHandler) CreateUsersHandler(c *gin.Context) { + var newUser AdminCreateUserRequest + if err := c.ShouldBindJSON(&newUser); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user data"}) + return + } + + var errors []error + for _, user := range newUser.Users { + if err := h.authService.CreateAndRegisterUser(auth.UserRegistrationInfo(user)); err != nil { + errors = append(errors, fmt.Errorf("failed to create user %s: %v", user.Username, err)) + } + } + + if len(errors) > 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create users", "details": errors}) + return + } + + c.JSON(http.StatusCreated, gin.H{"message": "Users created successfully"}) +} + +// ADMIN: DeleteUsersHandler deletes existing user(s) +func (h *AuthHandler) DeleteUsersHandler(c *gin.Context) { + var req UsersRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) + return + } + + var errors []error + + for _, username := range req.Usernames { + if err := h.authService.DeleteUser(username); err != nil { + errors = append(errors, fmt.Errorf("failed to delete user %s: %v", username, err)) + } + } + + if len(errors) > 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete users", "details": errors}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Users deleted successfully"}) +} + +// ADMIN: EnableUsersHandler enables existing user(s) +func (h *AuthHandler) EnableUsersHandler(c *gin.Context) { + var req UsersRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) + return + } + + var errors []error + for _, username := range req.Usernames { + if err := h.authService.EnableUserAccount(username); err != nil { + errors = append(errors, fmt.Errorf("failed to enable user %s: %v", username, err)) + } + } + + if len(errors) > 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable users", "details": errors}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Users enabled successfully"}) +} + +// ADMIN: DisableUsersHandler disables existing user(s) +func (h *AuthHandler) DisableUsersHandler(c *gin.Context) { + var req UsersRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) + return + } + + var errors []error + + for _, username := range req.Usernames { + if err := h.authService.DisableUserAccount(username); err != nil { + errors = append(errors, fmt.Errorf("failed to disable user %s: %v", username, err)) + } + } + + if len(errors) > 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable users", "details": errors}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Users disabled successfully"}) +} + +// ADMIN: SetUserGroupsHandler sets the groups for an existing user +func (h *AuthHandler) SetUserGroupsHandler(c *gin.Context) { + var req SetUserGroupsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) + return + } + + if err := h.authService.SetUserGroups(req.Username, req.Groups); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to set user groups", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "User groups updated successfully"}) +} diff --git a/internal/api/handlers/vms_handler.go b/internal/api/handlers/vms_handler.go new file mode 100644 index 0000000..273fd39 --- /dev/null +++ b/internal/api/handlers/vms_handler.go @@ -0,0 +1,69 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// ADMIN: GetVMsHandler handles GET requests for retrieving all VMs on Proxmox +func (ph *ProxmoxHandler) GetVMsHandler(c *gin.Context) { + vms, err := ph.service.GetVMs() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve VMs", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"vms": vms}) +} + +// ADMIN: StartVMHandler handles POST requests for starting a VM on Proxmox +func (ph *ProxmoxHandler) StartVMHandler(c *gin.Context) { + var req VMActionRequest + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) + return + } + + if err := ph.service.StartVM(req.Node, req.VMID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start VM", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "VM started"}) +} + +// ADMIN: ShutdownVMHandler handles POST requests for shutting down a VM on Proxmox +func (ph *ProxmoxHandler) ShutdownVMHandler(c *gin.Context) { + var req VMActionRequest + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) + return + } + + if err := ph.service.ShutdownVM(req.Node, req.VMID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to shutdown VM", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "VM shutdown"}) +} + +// ADMIN: RebootVMHandler handles POST requests for rebooting a VM on Proxmox +func (ph *ProxmoxHandler) RebootVMHandler(c *gin.Context) { + var req VMActionRequest + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) + return + } + + if err := ph.service.RebootVM(req.Node, req.VMID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reboot VM", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "VM rebooted"}) +} diff --git a/internal/api/middleware/authorization.go b/internal/api/middleware/authorization.go new file mode 100644 index 0000000..2315095 --- /dev/null +++ b/internal/api/middleware/authorization.go @@ -0,0 +1,62 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +// authRequired provides authentication middleware for ensuring that a user is logged in. +func AuthRequired(c *gin.Context) { + session := sessions.Default(c) + id := session.Get("id") + if id == nil { + c.String(http.StatusUnauthorized, "Unauthorized") + c.Abort() + return + } + c.Next() +} + +func AdminRequired(c *gin.Context) { + session := sessions.Default(c) + id := session.Get("id") + if id == nil { + c.String(http.StatusUnauthorized, "Unauthorized") + c.Abort() + return + } + + isAdmin := session.Get("isAdmin") + if isAdmin == nil || !isAdmin.(bool) { + c.String(http.StatusForbidden, "Admin access required") + c.Abort() + return + } + + c.Next() +} + +func GetUser(c *gin.Context) string { + userID := sessions.Default(c).Get("id") + if userID != nil { + return userID.(string) + } + return "" +} + +func Logout(c *gin.Context) { + session := sessions.Default(c) + id := session.Get("id") + if id == nil { + c.JSON(http.StatusOK, gin.H{"message": "No session."}) + return + } + session.Delete("id") + if err := session.Save(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save session"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Successfully logged out!"}) +} diff --git a/internal/api/routes/admin_routes.go b/internal/api/routes/admin_routes.go new file mode 100644 index 0000000..721327c --- /dev/null +++ b/internal/api/routes/admin_routes.go @@ -0,0 +1,43 @@ +package routes + +import ( + "github.com/cpp-cyber/proclone/internal/api/handlers" + "github.com/gin-gonic/gin" +) + +// registerAdminRoutes defines all routes accessible to admin users +func registerAdminRoutes(g *gin.RouterGroup, authHandler *handlers.AuthHandler, proxmoxHandler *handlers.ProxmoxHandler, cloningHandler *handlers.CloningHandler) { + // Create dashboard handler + dashboardHandler := handlers.NewDashboardHandler(authHandler, proxmoxHandler, cloningHandler) + + // GET Requests + g.GET("/dashboard", dashboardHandler.GetDashboardStatsHandler) + g.GET("/cluster", proxmoxHandler.GetClusterResourceUsageHandler) + g.GET("/users", authHandler.GetUsersHandler) + g.GET("/groups", authHandler.GetGroupsHandler) + g.GET("/vms", proxmoxHandler.GetVMsHandler) + g.GET("/pods", cloningHandler.AdminGetPodsHandler) + g.GET("/templates", cloningHandler.AdminGetTemplatesHandler) + g.GET("/templates/unpublished", cloningHandler.GetUnpublishedTemplatesHandler) + + // POST Requests + g.POST("/users/create", authHandler.CreateUsersHandler) + g.POST("/users/delete", authHandler.DeleteUsersHandler) + g.POST("/users/enable", authHandler.EnableUsersHandler) + g.POST("/users/disable", authHandler.DisableUsersHandler) + g.POST("/user/groups", authHandler.SetUserGroupsHandler) + g.POST("/groups/create", authHandler.CreateGroupsHandler) + g.POST("/group/members/add", authHandler.AddUsersHandler) + g.POST("/group/members/remove", authHandler.RemoveUsersHandler) + g.POST("/group/rename", authHandler.RenameGroupHandler) + g.POST("/groups/delete", authHandler.DeleteGroupsHandler) + g.POST("/vm/start", proxmoxHandler.StartVMHandler) + g.POST("/vm/shutdown", proxmoxHandler.ShutdownVMHandler) + g.POST("/vm/reboot", proxmoxHandler.RebootVMHandler) + g.POST("/pods/delete", cloningHandler.AdminDeletePodHandler) + g.POST("/template/publish", cloningHandler.PublishTemplateHandler) + g.POST("/template/delete", cloningHandler.DeleteTemplateHandler) + g.POST("/template/visibility", cloningHandler.ToggleTemplateVisibilityHandler) + g.POST("/template/image/upload", cloningHandler.UploadTemplateImageHandler) + g.POST("/templates/clone", cloningHandler.AdminCloneTemplateHandler) +} diff --git a/internal/api/routes/private_routes.go b/internal/api/routes/private_routes.go new file mode 100644 index 0000000..d9eac10 --- /dev/null +++ b/internal/api/routes/private_routes.go @@ -0,0 +1,20 @@ +package routes + +import ( + "github.com/cpp-cyber/proclone/internal/api/handlers" + "github.com/gin-gonic/gin" +) + +// registerPrivateRoutes defines all routes accessible to authenticated users +func registerPrivateRoutes(g *gin.RouterGroup, authHandler *handlers.AuthHandler, proxmoxHandler *handlers.ProxmoxHandler, cloningHandler *handlers.CloningHandler) { + // GET Requests + g.GET("/session", authHandler.SessionHandler) + g.GET("/pods", cloningHandler.GetPodsHandler) + g.GET("/templates", cloningHandler.GetTemplatesHandler) + g.GET("/template/image/:filename", cloningHandler.GetTemplateImageHandler) + + // POST Requests + g.POST("/logout", authHandler.LogoutHandler) + g.POST("/pod/delete", cloningHandler.DeletePodHandler) + g.POST("/template/clone", cloningHandler.CloneTemplateHandler) +} diff --git a/internal/api/routes/public_routes.go b/internal/api/routes/public_routes.go new file mode 100644 index 0000000..ea72952 --- /dev/null +++ b/internal/api/routes/public_routes.go @@ -0,0 +1,13 @@ +package routes + +import ( + "github.com/cpp-cyber/proclone/internal/api/handlers" + "github.com/gin-gonic/gin" +) + +// registerPublicRoutes defines all routes accessible without authentication +func registerPublicRoutes(g *gin.RouterGroup, authHandler *handlers.AuthHandler, cloningHandler *handlers.CloningHandler) { + // GET Requests + g.GET("/health", handlers.HealthCheckHandler(authHandler, cloningHandler)) + g.POST("/login", authHandler.LoginHandler) +} diff --git a/internal/api/routes/routes.go b/internal/api/routes/routes.go new file mode 100644 index 0000000..f29a73d --- /dev/null +++ b/internal/api/routes/routes.go @@ -0,0 +1,24 @@ +package routes + +import ( + "github.com/cpp-cyber/proclone/internal/api/handlers" + "github.com/cpp-cyber/proclone/internal/api/middleware" + "github.com/gin-gonic/gin" +) + +// RegisterRoutes sets up all API routes with their respective middleware and handlers +func RegisterRoutes(r *gin.Engine, authHandler *handlers.AuthHandler, proxmoxHandler *handlers.ProxmoxHandler, cloningHandler *handlers.CloningHandler) { + // Public routes (no authentication required) + public := r.Group("/api/v1") + registerPublicRoutes(public, authHandler, cloningHandler) + + // Private routes (authentication required) + private := r.Group("/api/v1") + private.Use(middleware.AuthRequired) + registerPrivateRoutes(private, authHandler, proxmoxHandler, cloningHandler) + + // Admin routes (authentication + admin privileges required) + admin := r.Group("/api/v1/admin") + admin.Use(middleware.AdminRequired) + registerAdminRoutes(admin, authHandler, proxmoxHandler, cloningHandler) +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..a361326 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,154 @@ +package auth + +import ( + "fmt" + + ldapv3 "github.com/go-ldap/ldap/v3" +) + +// Service defines the authentication service interface +type Service interface { + Authenticate(username, password string) (bool, error) + IsAdmin(username string) (bool, error) + GetUsers() ([]User, error) + CreateAndRegisterUser(userInfo UserRegistrationInfo) error + DeleteUser(username string) error + AddUserToGroup(username string, groupName string) error + SetUserGroups(username string, groups []string) error + CreateGroup(groupName string) error + GetGroups() ([]Group, error) + RenameGroup(oldGroupName string, newGroupName string) error + DeleteGroup(groupName string) error + GetGroupMembers(groupName string) ([]User, error) + RemoveUserFromGroup(username string, groupName string) error + EnableUserAccount(username string) error + DisableUserAccount(username string) error + HealthCheck() error + Reconnect() error + AddUsersToGroup(groupName string, usernames []string) error + RemoveUsersFromGroup(groupName string, usernames []string) error + GetUserGroups(userDN string) ([]string, error) + GetUserDN(username string) (string, error) +} + +// LDAPService implements authentication using LDAP +type LDAPService struct { + client *Client +} + +// NewLDAPService creates a new LDAP authentication service +func NewLDAPService() (*LDAPService, error) { + config, err := LoadConfig() + if err != nil { + return nil, fmt.Errorf("failed to load LDAP configuration: %w", err) + } + + client := NewClient(config) + if err := client.Connect(); err != nil { + return nil, fmt.Errorf("failed to connect to LDAP: %w", err) + } + + return &LDAPService{ + client: client, + }, nil +} + +// Authenticate performs user authentication against LDAP +func (s *LDAPService) Authenticate(username, password string) (bool, error) { + userDN, err := s.GetUserDN(username) + if err != nil { + return false, fmt.Errorf("failed to get user DN: %v", err) + } + + // Bind as user to verify password + err = s.client.Bind(userDN, password) + if err != nil { + return false, nil // Invalid credentials, not an error + } + + // Rebind as service account for further operations + config := s.client.Config() + if config.BindUser != "" { + err = s.client.Bind(config.BindUser, config.BindPassword) + if err != nil { + return false, fmt.Errorf("failed to rebind as service account: %v", err) + } + } + + return true, nil +} + +// IsAdmin checks if a user is a member of the admin group +func (s *LDAPService) IsAdmin(username string) (bool, error) { + config := s.client.Config() + + // Search for admin group + adminGroupReq := ldapv3.NewSearchRequest( + config.AdminGroupDN, + ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, + "(objectClass=group)", + []string{"member"}, + nil, + ) + + // Search for user DN + userDNReq := ldapv3.NewSearchRequest( + config.BaseDN, + ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(&(objectClass=user)(sAMAccountName=%s))", username), + []string{"dn"}, + nil, + ) + + adminGroupEntry, err := s.client.SearchEntry(adminGroupReq) + if err != nil { + return false, fmt.Errorf("failed to search admin group: %v", err) + } + + userEntry, err := s.client.SearchEntry(userDNReq) + if err != nil { + return false, fmt.Errorf("failed to search user: %v", err) + } + + if adminGroupEntry == nil { + return false, fmt.Errorf("admin group not found") + } + + if userEntry == nil { + return false, fmt.Errorf("user not found") + } + + // Check if user DN is in admin group members + for _, member := range adminGroupEntry.GetAttributeValues("member") { + if member == userEntry.DN { + return true, nil + } + } + + return false, nil +} + +// Close closes the LDAP connection +func (s *LDAPService) Close() error { + return s.client.Disconnect() +} + +// HealthCheck verifies that the LDAP connection is working +func (s *LDAPService) HealthCheck() error { + return s.client.HealthCheck() +} + +// Reconnect attempts to reconnect to the LDAP server +func (s *LDAPService) Reconnect() error { + return s.client.Connect() +} + +// SetPassword sets the password for a user using User struct +func (s *LDAPService) SetPassword(user User, password string) error { + userDN, err := s.GetUserDN(user.Name) + if err != nil { + return fmt.Errorf("failed to get user DN: %v", err) + } + + return s.SetUserPassword(userDN, password) +} diff --git a/internal/auth/groups.go b/internal/auth/groups.go new file mode 100644 index 0000000..5781034 --- /dev/null +++ b/internal/auth/groups.go @@ -0,0 +1,447 @@ +package auth + +import ( + "fmt" + "regexp" + "strings" + "time" + + ldapv3 "github.com/go-ldap/ldap/v3" +) + +type CreateRequest struct { + Group string `json:"group"` +} + +type Group struct { + Name string `json:"name"` + CanModify bool `json:"can_modify"` + CreatedAt string `json:"created_at,omitempty"` + UserCount int `json:"user_count,omitempty"` +} + +// GetGroups retrieves all groups from the KaminoGroups OU +func (s *LDAPService) GetGroups() ([]Group, error) { + config := s.client.Config() + + // Search for all groups in the KaminoGroups OU + kaminoGroupsOU := "OU=KaminoGroups," + config.BaseDN + req := ldapv3.NewSearchRequest( + kaminoGroupsOU, + ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, + "(objectClass=group)", + []string{"cn", "whenCreated", "member"}, + nil, + ) + + searchResult, err := s.client.Search(req) + if err != nil { + return nil, fmt.Errorf("failed to search for groups: %v", err) + } + + var groups []Group + for _, entry := range searchResult.Entries { + cn := entry.GetAttributeValue("cn") + + // Check if the group is protected + protectedGroup, err := isProtectedGroup(cn) + if err != nil { + return nil, fmt.Errorf("failed to determine if the group %s is protected: %v", cn, err) + } + + group := Group{ + Name: cn, + CanModify: !protectedGroup, + UserCount: len(entry.GetAttributeValues("member")), + } + + // Add creation date if available and convert it + whenCreated := entry.GetAttributeValue("whenCreated") + if whenCreated != "" { + // AD stores dates in GeneralizedTime format: YYYYMMDDHHMMSS.0Z + if parsedTime, err := time.Parse("20060102150405.0Z", whenCreated); err == nil { + group.CreatedAt = parsedTime.Format("2006-01-02 15:04:05") + } + } + + groups = append(groups, group) + } + + return groups, nil +} + +func ValidateGroupName(groupName string) error { + if groupName == "" { + return fmt.Errorf("group name cannot be empty") + } + + if len(groupName) >= 64 { + return fmt.Errorf("group name must be less than 64 characters") + } + + regex := regexp.MustCompile("^[a-zA-Z0-9-_]*$") + if !regex.MatchString(groupName) { + return fmt.Errorf("group name must only contain letters, numbers, hyphens, and underscores") + } + + return nil +} + +// CreateGroup creates a new group in LDAP +func (s *LDAPService) CreateGroup(groupName string) error { + config := s.client.Config() + + // Validate group name + if err := ValidateGroupName(groupName); err != nil { + return fmt.Errorf("invalid group name: %v", err) + } + + // Check if group already exists + _, err := s.GetGroupDN(groupName) + if err == nil { + return fmt.Errorf("group already exists: %s", groupName) + } + + // Construct the DN for the new group + groupDN := fmt.Sprintf("CN=%s,OU=KaminoGroups,%s", groupName, config.BaseDN) + + // Create the add request + addReq := ldapv3.NewAddRequest(groupDN, nil) + addReq.Attribute("objectClass", []string{"top", "group"}) + addReq.Attribute("cn", []string{groupName}) + addReq.Attribute("name", []string{groupName}) + addReq.Attribute("sAMAccountName", []string{groupName}) + // Set group type to Global Security Group (0x80000002) + addReq.Attribute("groupType", []string{"-2147483646"}) + + err = s.client.Add(addReq) + if err != nil { + return fmt.Errorf("failed to create group %s: %v", groupName, err) + } + + return nil +} + +// RenameGroup updates an existing group in LDAP +func (s *LDAPService) RenameGroup(oldGroupName string, newGroupName string) error { + // Check if the group is protected + protectedGroup, err := isProtectedGroup(oldGroupName) + if err != nil { + return fmt.Errorf("failed to determine if the group %s is protected: %v", oldGroupName, err) + } + + if protectedGroup { + return fmt.Errorf("cannot rename protected group: %s", oldGroupName) + } + + // If names are the same, nothing to do + if oldGroupName == newGroupName { + return nil + } + + // Validate new group name + if err := ValidateGroupName(newGroupName); err != nil { + return fmt.Errorf("invalid group name: %v", err) + } + + // Get the DN of the existing group + groupDN, err := s.GetGroupDN(oldGroupName) + if err != nil { + return fmt.Errorf("failed to find group %s: %v", oldGroupName, err) + } + + // Check if the new name already exists + _, err = s.GetGroupDN(newGroupName) + if err == nil { + return fmt.Errorf("group with name %s already exists", newGroupName) + } + + // Get config to construct the new DN + config := s.client.Config() + + // Extract the parent DN (everything after the first comma in the current DN) + // Example: "CN=OldName,OU=KaminoGroups,DC=example,DC=com" -> "OU=KaminoGroups,DC=example,DC=com" + parentDN := "OU=KaminoGroups," + config.BaseDN + + // Create new RDN (Relative Distinguished Name) + newRDN := fmt.Sprintf("CN=%s", newGroupName) + + // Use ModifyDN operation to rename the group + modifyDNReq := ldapv3.NewModifyDNRequest(groupDN, newRDN, true, parentDN) + + err = s.client.ModifyDN(modifyDNReq) + if err != nil { + return fmt.Errorf("failed to rename group %s to %s: %v", oldGroupName, newGroupName, err) + } + + return nil +} + +// DeleteGroup deletes a group from LDAP +func (s *LDAPService) DeleteGroup(groupName string) error { + // Check if the group is protected + protectedGroup, err := isProtectedGroup(groupName) + if err != nil { + return fmt.Errorf("failed to determine if the group %s is protected: %v", groupName, err) + } + + if protectedGroup { + return fmt.Errorf("cannot delete protected group: %s", groupName) + } + + // Get the DN of the group to delete + groupDN, err := s.GetGroupDN(groupName) + if err != nil { + return fmt.Errorf("failed to find group %s: %v", groupName, err) + } + + // Create delete request + delReq := ldapv3.NewDelRequest(groupDN, nil) + + err = s.client.Delete(delReq) + if err != nil { + return fmt.Errorf("failed to delete group %s: %v", groupName, err) + } + + return nil +} + +// GetGroupMembers retrieves all members of a specific group +func (s *LDAPService) GetGroupMembers(groupName string) ([]User, error) { + config := s.client.Config() + + // Get the group DN + groupDN, err := s.GetGroupDN(groupName) + if err != nil { + return nil, fmt.Errorf("failed to find group %s: %v", groupName, err) + } + + // Search for users who are members of this group + req := ldapv3.NewSearchRequest( + config.BaseDN, + ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(&(objectClass=user)(sAMAccountName=*)(memberOf=%s))", groupDN), + []string{"sAMAccountName", "dn", "whenCreated", "userAccountControl"}, + nil, + ) + + searchResult, err := s.client.Search(req) + if err != nil { + return nil, fmt.Errorf("failed to search for group members: %v", err) + } + + var users []User + for _, entry := range searchResult.Entries { + user := User{ + Name: entry.GetAttributeValue("sAMAccountName"), + } + + // Add creation date if available and convert it + whenCreated := entry.GetAttributeValue("whenCreated") + if whenCreated != "" { + // AD stores dates in GeneralizedTime format: YYYYMMDDHHMMSS.0Z + if parsedTime, err := time.Parse("20060102150405.0Z", whenCreated); err == nil { + user.CreatedAt = parsedTime.Format("2006-01-02 15:04:05") + } + } + + // Check if user is enabled by parsing userAccountControl + user.Enabled = isUserEnabled(entry.GetAttributeValue("userAccountControl")) + + users = append(users, user) + } + + return users, nil +} + +func isProtectedGroup(groupName string) (bool, error) { + groupName = strings.ToLower(groupName) + + blocked := []string{"kamino", "domain", "admin"} + for _, b := range blocked { + if strings.Contains(groupName, b) { + return true, nil + } + } + return false, nil +} + +func (s *LDAPService) AddUsersToGroup(groupName string, usernames []string) error { + if len(usernames) == 0 { + return nil // Nothing to do + } + + // Get group DN first + groupDN, err := s.GetGroupDN(groupName) + if err != nil { + return fmt.Errorf("failed to find group %s: %v", groupName, err) + } + + // Get all user DNs, filtering out invalid users + var validUserDNs []string + var invalidUsers []string + + for _, username := range usernames { + if username == "" { + continue // Skip empty usernames + } + + userDN, err := s.GetUserDN(username) + if err != nil { + invalidUsers = append(invalidUsers, username) + continue + } + validUserDNs = append(validUserDNs, userDN) + } + + // If no valid users found, return error + if len(validUserDNs) == 0 { + if len(invalidUsers) > 0 { + return fmt.Errorf("no valid users found. Invalid users: %s", strings.Join(invalidUsers, ", ")) + } + return fmt.Errorf("no users provided") + } + + // Add all users to the group in a single LDAP modify operation + modifyReq := ldapv3.NewModifyRequest(groupDN, nil) + modifyReq.Add("member", validUserDNs) + + err = s.client.Modify(modifyReq) + if err != nil { + // If bulk add fails, try adding users individually to identify which ones are already members + var alreadyMembers []string + var failedUsers []string + var successCount int + + for i, userDN := range validUserDNs { + individualReq := ldapv3.NewModifyRequest(groupDN, nil) + individualReq.Add("member", []string{userDN}) + + individualErr := s.client.Modify(individualReq) + if individualErr != nil { + if strings.Contains(strings.ToLower(individualErr.Error()), "already exists") || + strings.Contains(strings.ToLower(individualErr.Error()), "attribute or value exists") { + alreadyMembers = append(alreadyMembers, usernames[i]) + } else { + failedUsers = append(failedUsers, fmt.Sprintf("%s: %v", usernames[i], individualErr)) + } + } else { + successCount++ + } + } + + // Prepare result message + var messages []string + if successCount > 0 { + messages = append(messages, fmt.Sprintf("%d users added successfully", successCount)) + } + if len(alreadyMembers) > 0 { + messages = append(messages, fmt.Sprintf("%d users already members (skipped): %s", len(alreadyMembers), strings.Join(alreadyMembers, ", "))) + } + if len(invalidUsers) > 0 { + messages = append(messages, fmt.Sprintf("%d invalid users (skipped): %s", len(invalidUsers), strings.Join(invalidUsers, ", "))) + } + if len(failedUsers) > 0 { + messages = append(messages, fmt.Sprintf("%d users failed: %s", len(failedUsers), strings.Join(failedUsers, "; "))) + } + + if len(failedUsers) > 0 && successCount == 0 { + return fmt.Errorf("failed to add users to group %s: %s", groupName, strings.Join(messages, "; ")) + } + + // If some succeeded, just log the status (don't return error for partial success) + fmt.Printf("AddUsersToGroup result: %s\n", strings.Join(messages, "; ")) + } + + return nil +} + +func (s *LDAPService) RemoveUsersFromGroup(groupName string, usernames []string) error { + if len(usernames) == 0 { + return nil // Nothing to do + } + + // Get group DN first + groupDN, err := s.GetGroupDN(groupName) + if err != nil { + return fmt.Errorf("failed to find group %s: %v", groupName, err) + } + + // Get all user DNs, filtering out invalid users + var validUserDNs []string + var invalidUsers []string + + for _, username := range usernames { + if username == "" { + continue // Skip empty usernames + } + + userDN, err := s.GetUserDN(username) + if err != nil { + invalidUsers = append(invalidUsers, username) + continue + } + validUserDNs = append(validUserDNs, userDN) + } + + // If no valid users found, return error + if len(validUserDNs) == 0 { + if len(invalidUsers) > 0 { + return fmt.Errorf("no valid users found. Invalid users: %s", strings.Join(invalidUsers, ", ")) + } + return fmt.Errorf("no users provided") + } + + // Remove all users from the group in a single LDAP modify operation + modifyReq := ldapv3.NewModifyRequest(groupDN, nil) + modifyReq.Delete("member", validUserDNs) + + err = s.client.Modify(modifyReq) + if err != nil { + // If bulk remove fails, try removing users individually to identify which ones are not members + var notMembers []string + var failedUsers []string + var successCount int + + for i, userDN := range validUserDNs { + individualReq := ldapv3.NewModifyRequest(groupDN, nil) + individualReq.Delete("member", []string{userDN}) + + individualErr := s.client.Modify(individualReq) + if individualErr != nil { + if strings.Contains(strings.ToLower(individualErr.Error()), "no such attribute") || + strings.Contains(strings.ToLower(individualErr.Error()), "no such value") { + notMembers = append(notMembers, usernames[i]) + } else { + failedUsers = append(failedUsers, fmt.Sprintf("%s: %v", usernames[i], individualErr)) + } + } else { + successCount++ + } + } + + // Prepare result message + var messages []string + if successCount > 0 { + messages = append(messages, fmt.Sprintf("%d users removed successfully", successCount)) + } + if len(notMembers) > 0 { + messages = append(messages, fmt.Sprintf("%d users not members (skipped): %s", len(notMembers), strings.Join(notMembers, ", "))) + } + if len(invalidUsers) > 0 { + messages = append(messages, fmt.Sprintf("%d invalid users (skipped): %s", len(invalidUsers), strings.Join(invalidUsers, ", "))) + } + if len(failedUsers) > 0 { + messages = append(messages, fmt.Sprintf("%d users failed: %s", len(failedUsers), strings.Join(failedUsers, "; "))) + } + + if len(failedUsers) > 0 && successCount == 0 { + return fmt.Errorf("failed to remove users from group %s: %s", groupName, strings.Join(messages, "; ")) + } + + // If some succeeded, just log the status (don't return error for partial success) + fmt.Printf("RemoveUsersFromGroup result: %s\n", strings.Join(messages, "; ")) + } + + return nil +} diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go new file mode 100644 index 0000000..580dca5 --- /dev/null +++ b/internal/auth/ldap.go @@ -0,0 +1,456 @@ +package auth + +import ( + "crypto/tls" + "fmt" + "strings" + "sync" + "time" + + "github.com/go-ldap/ldap/v3" + "github.com/kelseyhightower/envconfig" +) + +// Config holds LDAP configuration +type Config struct { + URL string `envconfig:"LDAP_URL" default:"ldaps://localhost:636"` + BindUser string `envconfig:"LDAP_BIND_USER"` + BindPassword string `envconfig:"LDAP_BIND_PASSWORD"` + SkipTLSVerify bool `envconfig:"LDAP_SKIP_TLS_VERIFY" default:"false"` + AdminGroupDN string `envconfig:"LDAP_ADMIN_GROUP_DN"` + BaseDN string `envconfig:"LDAP_BASE_DN"` +} + +// Client wraps LDAP connection and provides low-level operations +type Client struct { + conn ldap.Client + config *Config + mutex sync.RWMutex + connected bool +} + +// NewClient creates a new LDAP client +func NewClient(config *Config) *Client { + return &Client{config: config} +} + +// LoadConfig loads and validates LDAP configuration from environment variables +func LoadConfig() (*Config, error) { + var config Config + if err := envconfig.Process("", &config); err != nil { + return nil, fmt.Errorf("failed to process LDAP configuration: %w", err) + } + return &config, nil +} + +// Connectivity + +// Connect establishes connection to LDAP server +func (c *Client) Connect() error { + c.mutex.Lock() + defer c.mutex.Unlock() + + conn, err := c.dial() + if err != nil { + c.connected = false + return fmt.Errorf("failed to connect to LDAP server: %v", err) + } + + if c.config.BindUser != "" { + err = conn.Bind(c.config.BindUser, c.config.BindPassword) + if err != nil { + conn.Close() + c.connected = false + return fmt.Errorf("failed to bind to LDAP server: %v", err) + } + } + + c.conn = conn + c.connected = true + return nil +} + +// dial creates a new LDAP connection +func (c *Client) dial() (ldap.Client, error) { + var dialOpts []ldap.DialOpt + if strings.HasPrefix(c.config.URL, "ldaps://") { + dialOpts = append(dialOpts, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: c.config.SkipTLSVerify, MinVersion: tls.VersionTLS12})) + } else { + return nil, fmt.Errorf("only ldaps:// is supported") + } + return ldap.DialURL(c.config.URL, dialOpts...) +} + +// Disconnect closes the LDAP connection +func (c *Client) Disconnect() error { + c.mutex.Lock() + defer c.mutex.Unlock() + + if c.conn == nil { + c.connected = false + return nil + } + + err := c.conn.Close() + c.connected = false + return err +} + +// isConnectionError checks if an error indicates a connection problem +func (c *Client) isConnectionError(err error) bool { + if err == nil { + return false + } + + errorMsg := strings.ToLower(err.Error()) + return strings.Contains(errorMsg, "connection closed") || + strings.Contains(errorMsg, "network error") || + strings.Contains(errorMsg, "connection reset") || + strings.Contains(errorMsg, "broken pipe") || + strings.Contains(errorMsg, "connection refused") || + strings.Contains(errorMsg, "timeout") || + strings.Contains(errorMsg, "eof") || + strings.Contains(errorMsg, "operations error") || + strings.Contains(errorMsg, "successful bind must be completed") || + strings.Contains(errorMsg, "ldap result code 1") +} + +// reconnect attempts to reconnect to the LDAP server +func (c *Client) reconnect() error { + c.mutex.Lock() + defer c.mutex.Unlock() + + // Close existing connection if any + if c.conn != nil { + c.conn.Close() + } + c.connected = false + + // Wait a moment before retrying + time.Sleep(100 * time.Millisecond) + + // Attempt reconnection + conn, err := c.dial() + if err != nil { + return fmt.Errorf("failed to reconnect to LDAP server: %v", err) + } + + if c.config.BindUser != "" { + err = conn.Bind(c.config.BindUser, c.config.BindPassword) + if err != nil { + conn.Close() + return fmt.Errorf("failed to bind after reconnection: %v", err) + } + } + + c.conn = conn + c.connected = true + return nil +} + +// Bind performs LDAP bind operation +func (c *Client) Bind(userDN, password string) error { + return c.executeWithRetry(func() error { + c.mutex.RLock() + conn := c.conn + c.mutex.RUnlock() + + if conn == nil { + return fmt.Errorf("no LDAP connection available") + } + + return conn.Bind(userDN, password) + }, 2) // Retry up to 2 times +} + +// Config returns the LDAP configuration +func (c *Client) Config() *Config { + return c.config +} + +// IsConnected returns the current connection status +func (c *Client) IsConnected() bool { + c.mutex.RLock() + defer c.mutex.RUnlock() + return c.connected +} + +// validateBind checks if the current bind is still valid by performing a simple operation +func (c *Client) validateBind() error { + c.mutex.RLock() + conn := c.conn + c.mutex.RUnlock() + + if conn == nil { + return fmt.Errorf("no connection available") + } + + // Try a simple search to validate the bind + req := ldap.NewSearchRequest( + c.config.BaseDN, + ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false, + "(objectClass=*)", + []string{"dn"}, + nil, + ) + + _, err := conn.Search(req) + return err +} + +// HealthCheck performs a simple search to verify the connection is working +func (c *Client) HealthCheck() error { + req := ldap.NewSearchRequest( + c.config.BaseDN, + ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false, + "(objectClass=*)", + []string{"dn"}, + nil, + ) + + return c.executeWithRetry(func() error { + c.mutex.RLock() + conn := c.conn + c.mutex.RUnlock() + + if conn == nil { + return fmt.Errorf("no LDAP connection available") + } + + _, err := conn.Search(req) + return err + }, 2) +} + +/* + Operations +*/ + +// executeWithRetry executes an LDAP operation with automatic retry on connection errors +func (c *Client) executeWithRetry(operation func() error, maxRetries int) error { + var lastErr error + + for attempt := 0; attempt <= maxRetries; attempt++ { + c.mutex.RLock() + connected := c.connected + conn := c.conn + c.mutex.RUnlock() + + // Check if we need to reconnect + if !connected || conn == nil { + if reconnectErr := c.reconnect(); reconnectErr != nil { + lastErr = reconnectErr + continue + } + } else { + // Validate that the bind is still active + if bindErr := c.validateBind(); bindErr != nil { + if c.isConnectionError(bindErr) { + if reconnectErr := c.reconnect(); reconnectErr != nil { + lastErr = reconnectErr + continue + } + } + } + } + + err := operation() + if err == nil { + return nil + } + + lastErr = err + + // If it's not a connection error, don't retry + if !c.isConnectionError(err) { + return err + } + + // Mark as disconnected and try to reconnect + c.mutex.Lock() + c.connected = false + c.mutex.Unlock() + + // Don't reconnect on the last attempt + if attempt < maxRetries { + if reconnectErr := c.reconnect(); reconnectErr != nil { + lastErr = reconnectErr + } + } + } + + return fmt.Errorf("operation failed after %d retries, last error: %v", maxRetries+1, lastErr) +} + +// SearchEntry performs an LDAP search and returns the first entry +func (c *Client) SearchEntry(req *ldap.SearchRequest) (*ldap.Entry, error) { + var result *ldap.SearchResult + + err := c.executeWithRetry(func() error { + c.mutex.RLock() + conn := c.conn + c.mutex.RUnlock() + + if conn == nil { + return fmt.Errorf("no LDAP connection available") + } + + res, err := conn.Search(req) + if err != nil { + return fmt.Errorf("failed to search entry: %v", err) + } + result = res + return nil + }, 2) // Retry up to 2 times + + if err != nil { + return nil, err + } + + if len(result.Entries) == 0 { + return nil, nil + } + return result.Entries[0], nil +} + +// Search performs an LDAP search and returns all matching entries +func (c *Client) Search(req *ldap.SearchRequest) (*ldap.SearchResult, error) { + var result *ldap.SearchResult + + err := c.executeWithRetry(func() error { + c.mutex.RLock() + conn := c.conn + c.mutex.RUnlock() + + if conn == nil { + return fmt.Errorf("no LDAP connection available") + } + + res, err := conn.Search(req) + if err != nil { + return fmt.Errorf("failed to search: %v", err) + } + result = res + return nil + }, 2) // Retry up to 2 times + + if err != nil { + return nil, err + } + + return result, nil +} + +// Add performs an LDAP add operation +func (c *Client) Add(req *ldap.AddRequest) error { + return c.executeWithRetry(func() error { + c.mutex.RLock() + conn := c.conn + c.mutex.RUnlock() + + if conn == nil { + return fmt.Errorf("no LDAP connection available") + } + + return conn.Add(req) + }, 2) // Retry up to 2 times +} + +// Modify performs an LDAP modify operation +func (c *Client) Modify(req *ldap.ModifyRequest) error { + return c.executeWithRetry(func() error { + c.mutex.RLock() + conn := c.conn + c.mutex.RUnlock() + + if conn == nil { + return fmt.Errorf("no LDAP connection available") + } + + return conn.Modify(req) + }, 2) // Retry up to 2 times +} + +// Delete performs an LDAP delete operation +func (c *Client) Delete(req *ldap.DelRequest) error { + return c.executeWithRetry(func() error { + c.mutex.RLock() + conn := c.conn + c.mutex.RUnlock() + + if conn == nil { + return fmt.Errorf("no LDAP connection available") + } + + return conn.Del(req) + }, 2) // Retry up to 2 times +} + +// ModifyDN performs an LDAP modify DN operation (rename/move) +func (c *Client) ModifyDN(req *ldap.ModifyDNRequest) error { + return c.executeWithRetry(func() error { + c.mutex.RLock() + conn := c.conn + c.mutex.RUnlock() + + if conn == nil { + return fmt.Errorf("no LDAP connection available") + } + + return conn.ModifyDN(req) + }, 2) // Retry up to 2 times +} + +/* + DNs +*/ + +// GetUserDN retrieves the DN for a given username +func (s *LDAPService) GetUserDN(username string) (string, error) { + config := s.client.Config() + + req := ldap.NewSearchRequest( + config.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(&(objectClass=user)(sAMAccountName=%s))", username), + []string{"dn"}, + nil, + ) + + entry, err := s.client.SearchEntry(req) + if err != nil { + return "", fmt.Errorf("failed to search for user: %v", err) + } + + if entry == nil { + return "", fmt.Errorf("user not found") + } + + return entry.DN, nil +} + +// GetGroupDN retrieves the DN for a given group name from KaminoGroups OU +func (s *LDAPService) GetGroupDN(groupName string) (string, error) { + config := s.client.Config() + + // Search for the group in KaminoGroups OU + kaminoGroupsOU := "OU=KaminoGroups," + config.BaseDN + req := ldap.NewSearchRequest( + kaminoGroupsOU, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(&(objectClass=group)(cn=%s))", groupName), + []string{"dn"}, + nil, + ) + + entry, err := s.client.SearchEntry(req) + if err != nil { + return "", fmt.Errorf("failed to search for group: %v", err) + } + + if entry == nil { + return "", fmt.Errorf("group not found: %s", groupName) + } + + return entry.DN, nil +} diff --git a/internal/auth/users.go b/internal/auth/users.go new file mode 100644 index 0000000..a34840a --- /dev/null +++ b/internal/auth/users.go @@ -0,0 +1,645 @@ +package auth + +import ( + "encoding/binary" + "fmt" + "regexp" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf16" + + ldapv3 "github.com/go-ldap/ldap/v3" +) + +type User struct { + Name string `json:"name"` + CreatedAt string `json:"created_at"` + Enabled bool `json:"enabled"` + IsAdmin bool `json:"is_admin"` + Groups []Group `json:"groups"` +} + +// UserRegistrationInfo contains the information needed to register a new user +type UserRegistrationInfo struct { + Username string `json:"username" validate:"required,min=1,max=20"` + Password string `json:"password" validate:"required,min=8"` +} + +// NewUserRegistrationInfo creates a new UserRegistrationInfo with required fields +func NewUserRegistrationInfo(username, password string) *UserRegistrationInfo { + return &UserRegistrationInfo{ + Username: username, + Password: password, + } +} + +// Validate validates the user registration information +func (u *UserRegistrationInfo) Validate() error { + if err := validateUsername(u.Username); err != nil { + return err + } + + if err := validatePasswordReq(u.Password); err != nil { + return err + } + + return nil +} + +// GetUsers retrieves all users from LDAP +func (s *LDAPService) GetUsers() ([]User, error) { + config := s.client.Config() + + // Create search request to find all user objects who are members of KaminoUsers group + kaminoUsersGroupDN := "CN=KaminoUsers,OU=KaminoGroups," + config.BaseDN + req := ldapv3.NewSearchRequest( + config.BaseDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(&(objectClass=user)(sAMAccountName=*)(memberOf=%s))", kaminoUsersGroupDN), // Filter for users in KaminoUsers group + []string{"sAMAccountName", "dn", "whenCreated", "memberOf", "userAccountControl"}, // Attributes to retrieve + nil, + ) + + // Perform the search + searchResult, err := s.client.Search(req) + if err != nil { + return nil, fmt.Errorf("failed to search for users: %v", err) + } + + var users = make([]User, 0) + for _, entry := range searchResult.Entries { + user := User{ + Name: entry.GetAttributeValue("sAMAccountName"), + } + + // Add creation date if available and convert it + whenCreated := entry.GetAttributeValue("whenCreated") + if whenCreated != "" { + // AD stores dates in GeneralizedTime format: YYYYMMDDHHMMSS.0Z + if parsedTime, err := time.Parse("20060102150405.0Z", whenCreated); err == nil { + user.CreatedAt = parsedTime.Format("2006-01-02 15:04:05") + } + } + + // Check if user is enabled by parsing userAccountControl + user.Enabled = isUserEnabled(entry.GetAttributeValue("userAccountControl")) + + // Check for admin privileges and add group memberships + var groups []Group + var isAdmin = false + for _, groupDN := range entry.GetAttributeValues("memberOf") { + // Check if user is admin based on group membership + groupName := extractCNFromDN(groupDN) + if groupName == "Domain Admins" || groupName == "Proxmox-Admins" { + isAdmin = true + } + + // Only include groups from Kamino-Groups OU in the groups list + if !strings.Contains(strings.ToLower(groupDN), "ou=kaminogroups") { + continue + } + + // Add group to user's groups list + if groupName != "" { + groups = append(groups, Group{ + Name: groupName, + }) + } + } + + user.IsAdmin = isAdmin + user.Groups = groups + + users = append(users, user) + } + + return users, nil +} + +// extractDomainFromDN extracts the domain name from a Distinguished Name +func extractDomainFromDN(dn string) string { + // Convert DN like "DC=example,DC=com" to "example.com" + parts := strings.Split(strings.ToLower(dn), ",") + var domainParts []string + + for _, part := range parts { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "dc=") { + domainParts = append(domainParts, strings.TrimPrefix(part, "dc=")) + } + } + + return strings.Join(domainParts, ".") +} + +// encodePasswordForAD encodes a password for Active Directory unicodePwd attribute +func encodePasswordForAD(password string) string { + // AD requires password to be UTF-16LE encoded and surrounded by quotes + quotedPassword := fmt.Sprintf("\"%s\"", password) + utf16Encoded := utf16.Encode([]rune(quotedPassword)) + + // Convert to bytes in little-endian format + bytes := make([]byte, len(utf16Encoded)*2) + for i, r := range utf16Encoded { + binary.LittleEndian.PutUint16(bytes[i*2:], r) + } + + return string(bytes) +} + +// validateUsername validates a username according to Active Directory requirements +func validateUsername(username string) error { + if len(username) < 1 || len(username) > 20 { + return fmt.Errorf("username must be between 1 and 20 characters") + } + + regex := regexp.MustCompile("^[a-zA-Z0-9]*$") + if !regex.MatchString(username) { + return fmt.Errorf("username must only contain letters and numbers") + } + + return nil +} + +// validatePasswordReq validates a password according to requirements +func validatePasswordReq(password string) error { + var number, letter bool + if len(password) < 8 { + return fmt.Errorf("password must be at least 8 characters long") + } + + if len(password) > 128 { + return fmt.Errorf("password must not exceed 128 characters") + } + + for _, c := range password { + switch { + case unicode.IsNumber(c): + number = true + case unicode.IsLetter(c): + letter = true + } + } + + if !number || !letter { + return fmt.Errorf("password must contain at least one letter and one number") + } + + return nil +} + +// extractCNFromDN extracts the Common Name (CN) from a Distinguished Name (DN) +func extractCNFromDN(dn string) string { + // Split the DN by commas and look for the CN component + parts := strings.Split(dn, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if strings.HasPrefix(strings.ToLower(part), "cn=") { + // Extract the value after "CN=" + return strings.TrimPrefix(part, strings.Split(part, "=")[0]+"=") + } + } + return "" +} + +// isUserEnabled checks if a user is enabled based on userAccountControl attribute +// In Active Directory, userAccountControl is a bitmask where bit 1 (value 2) indicates "ACCOUNTDISABLE" +// If this bit is set, the account is disabled +func isUserEnabled(userAccountControl string) bool { + if userAccountControl == "" { + return false // Default to disabled if attribute is missing + } + + // Parse the userAccountControl value + uac, err := strconv.ParseInt(userAccountControl, 10, 64) + if err != nil { + return false // Default to disabled if parsing fails + } + + // Check if the ACCOUNTDISABLE bit (0x2) is set + // If bit 1 is set, the account is disabled, so we return the inverse + const ACCOUNTDISABLE = 0x2 + return (uac & ACCOUNTDISABLE) == 0 +} + +// CreateUserWithInfo creates a new user in LDAP with detailed information +func (s *LDAPService) CreateUser(userInfo UserRegistrationInfo) (string, error) { + config := s.client.Config() + + // Create DN for new user in Users container + // TODO: Static + userDN := fmt.Sprintf("CN=%s,OU=KaminoUsers,%s", userInfo.Username, config.BaseDN) + + // Create add request for new user + addReq := ldapv3.NewAddRequest(userDN, nil) + + // Add required object classes + addReq.Attribute("objectClass", []string{"top", "person", "organizationalPerson", "user"}) + + // Add basic attributes + addReq.Attribute("cn", []string{userInfo.Username}) + addReq.Attribute("sAMAccountName", []string{userInfo.Username}) + addReq.Attribute("userPrincipalName", []string{fmt.Sprintf("%s@%s", userInfo.Username, extractDomainFromDN(config.BaseDN))}) + + // Set account control flags - account disabled initially (will be enabled after password is set) + addReq.Attribute("userAccountControl", []string{"546"}) // NORMAL_ACCOUNT + ACCOUNTDISABLE + + // Perform the add operation + err := s.client.Add(addReq) + if err != nil { + return "", fmt.Errorf("failed to create user: %v", err) + } + + return userDN, nil +} + +// SetUserPassword sets the password for a user by DN +func (s *LDAPService) SetUserPassword(userDN string, password string) error { + // For Active Directory, passwords must be set using unicodePwd attribute + // The password must be UTF-16LE encoded and quoted + utf16Password := encodePasswordForAD(password) + + // Create modify request to set password + modifyReq := ldapv3.NewModifyRequest(userDN, nil) + modifyReq.Replace("unicodePwd", []string{utf16Password}) + + err := s.client.Modify(modifyReq) + if err != nil { + return fmt.Errorf("failed to set password: %v", err) + } + + return nil +} + +// EnableUserAccountByDN enables a user account by updating userAccountControl +func (s *LDAPService) EnableUserAccountByDN(userDN string) error { + // Set userAccountControl to 512 (NORMAL_ACCOUNT) to enable the account + modifyReq := ldapv3.NewModifyRequest(userDN, nil) + modifyReq.Replace("userAccountControl", []string{"512"}) + + err := s.client.Modify(modifyReq) + if err != nil { + return fmt.Errorf("failed to enable account: %v", err) + } + + return nil +} + +// DisableUserAccountByDN disables a user account by updating userAccountControl +func (s *LDAPService) DisableUserAccountByDN(userDN string) error { + // Set userAccountControl to 546 (NORMAL_ACCOUNT + ACCOUNTDISABLE) to disable the account + modifyReq := ldapv3.NewModifyRequest(userDN, nil) + modifyReq.Replace("userAccountControl", []string{"546"}) + + err := s.client.Modify(modifyReq) + if err != nil { + return fmt.Errorf("failed to disable account: %v", err) + } + + return nil +} + +// AddToGroup adds a user to a group by DN +func (s *LDAPService) AddToGroup(userDN string, groupDN string) error { + // Create modify request to add user to group + modifyReq := ldapv3.NewModifyRequest(groupDN, nil) + modifyReq.Add("member", []string{userDN}) + + err := s.client.Modify(modifyReq) + if err != nil { + // Check if the error is because the user is already in the group + if strings.Contains(strings.ToLower(err.Error()), "already exists") || + strings.Contains(strings.ToLower(err.Error()), "attribute or value exists") { + return nil // Not an error if user is already in group + } + return fmt.Errorf("failed to add user to group: %v", err) + } + + return nil +} + +// RegisterUser creates, configures, and enables a new user account +func (s *LDAPService) CreateAndRegisterUser(userInfo UserRegistrationInfo) error { + // Validate username and password + if err := userInfo.Validate(); err != nil { + return err + } + + // Create the user with full information + userDN, err := s.CreateUser(userInfo) + if err != nil { + return fmt.Errorf("failed to create user: %v", err) + } + + // Set the password + err = s.SetUserPassword(userDN, userInfo.Password) + if err != nil { + return fmt.Errorf("failed to set password: %v", err) + } + + // Add user to default user group + config := s.client.Config() + userGroupDN := fmt.Sprintf("CN=KaminoUsers,OU=KaminoGroups,%s", config.BaseDN) + err = s.AddToGroup(userDN, userGroupDN) + if err != nil { + return fmt.Errorf("failed to add user to group: %v", err) + } + + // Enable the account + err = s.EnableUserAccountByDN(userDN) + if err != nil { + return fmt.Errorf("failed to enable account: %v", err) + } + + return nil +} + +// AddUserToGroup adds a user to a group in LDAP by names +func (s *LDAPService) AddUserToGroup(username string, groupName string) error { + // Get user DN + userDN, err := s.GetUserDN(username) + if err != nil { + return fmt.Errorf("failed to find user %s: %v", username, err) + } + + // Get group DN + groupDN, err := s.GetGroupDN(groupName) + if err != nil { + return fmt.Errorf("failed to find group %s: %v", groupName, err) + } + + // Create modify request to add user to group + modifyReq := ldapv3.NewModifyRequest(groupDN, nil) + modifyReq.Add("member", []string{userDN}) + + err = s.client.Modify(modifyReq) + if err != nil { + // Check if the error is because the user is already in the group + if strings.Contains(strings.ToLower(err.Error()), "already exists") || + strings.Contains(strings.ToLower(err.Error()), "attribute or value exists") { + return fmt.Errorf("user %s is already a member of group %s", username, groupName) + } + return fmt.Errorf("failed to add user %s to group %s: %v", username, groupName, err) + } + + return nil +} + +// RemoveUserFromGroup removes a user from a group in LDAP +func (s *LDAPService) RemoveUserFromGroup(username string, groupName string) error { + // Get user DN + userDN, err := s.GetUserDN(username) + if err != nil { + return fmt.Errorf("failed to find user %s: %v", username, err) + } + + // Get group DN + groupDN, err := s.GetGroupDN(groupName) + if err != nil { + return fmt.Errorf("failed to find group %s: %v", groupName, err) + } + + // Create modify request to remove user from group + modifyReq := ldapv3.NewModifyRequest(groupDN, nil) + modifyReq.Delete("member", []string{userDN}) + + err = s.client.Modify(modifyReq) + if err != nil { + // Check if the error is because the user is not in the group + if strings.Contains(strings.ToLower(err.Error()), "no such attribute") || + strings.Contains(strings.ToLower(err.Error()), "no such value") { + return fmt.Errorf("user %s is not a member of group %s", username, groupName) + } + return fmt.Errorf("failed to remove user %s from group %s: %v", username, groupName, err) + } + + return nil +} + +func (s *LDAPService) DeleteUser(username string) error { + // Get user DN + userDN, err := s.GetUserDN(username) + if err != nil { + return fmt.Errorf("failed to find user %s: %v", username, err) + } + + // Verify that the user is not an admin + userGroups, err := s.GetUserGroups(userDN) + if err != nil { + return fmt.Errorf("failed to get user groups: %v", err) + } + + isAdmin, err := isAdmin(userGroups) + if err != nil { + return fmt.Errorf("failed to check if user is admin: %v", err) + } + if isAdmin { + return fmt.Errorf("cannot delete admin user %s", username) + } + + // Create delete request + delReq := ldapv3.NewDelRequest(userDN, nil) + + err = s.client.Delete(delReq) + if err != nil { + return fmt.Errorf("failed to delete user %s: %v", username, err) + } + + return nil +} + +func (s *LDAPService) DeleteUsers(usernames []string) []error { + var errors []error + var validUsers []string + var userDNs []string + + // First pass: validate all users and collect their DNs + for _, username := range usernames { + // Get user DN + userDN, err := s.GetUserDN(username) + if err != nil { + errors = append(errors, fmt.Errorf("failed to find user %s: %v", username, err)) + continue + } + + // Verify that the user is not an admin + userGroups, err := s.GetUserGroups(userDN) + if err != nil { + errors = append(errors, fmt.Errorf("failed to get user groups for %s: %v", username, err)) + continue + } + + isAdmin, err := isAdmin(userGroups) + if err != nil { + errors = append(errors, fmt.Errorf("failed to check if user %s is admin: %v", username, err)) + continue + } + if isAdmin { + errors = append(errors, fmt.Errorf("cannot delete admin user %s", username)) + continue + } + + // User passed validation + validUsers = append(validUsers, username) + userDNs = append(userDNs, userDN) + } + + // Second pass: delete all valid users + for i, userDN := range userDNs { + username := validUsers[i] + delReq := ldapv3.NewDelRequest(userDN, nil) + + err := s.client.Delete(delReq) + if err != nil { + errors = append(errors, fmt.Errorf("failed to delete user %s: %v", username, err)) + } + } + + return errors +} + +func (s *LDAPService) GetUserGroups(userDN string) ([]string, error) { + config := s.client.Config() + + // Search for groups that the user is a member of (search entire base DN to find all groups including admin groups) + groupSearchReq := ldapv3.NewSearchRequest( + config.BaseDN, + ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(&(objectClass=group)(member=%s))", userDN), + []string{"cn", "distinguishedName"}, + nil, + ) + + groupEntries, err := s.client.Search(groupSearchReq) + if err != nil { + return nil, fmt.Errorf("failed to search user groups: %v", err) + } + + var groups []string + for _, entry := range groupEntries.Entries { + groupName := entry.GetAttributeValue("cn") + if groupName != "" { + groups = append(groups, groupName) + } + } + + return groups, nil +} + +func isAdmin(groups []string) (bool, error) { + // If groups is empty, user is not an admin + if len(groups) == 0 { + return false, nil + } + + for _, group := range groups { + group = strings.ToLower(group) + if strings.Contains(group, "admins") || strings.Contains(group, "proxmox-admins") { + return true, nil + } + } + + return false, nil +} + +// EnableUserAccount enables a user account by username (wrapper method for Service interface) +func (s *LDAPService) EnableUserAccount(username string) error { + // Get user DN + userDN, err := s.GetUserDN(username) + if err != nil { + return fmt.Errorf("failed to find user %s: %v", username, err) + } + + // Set userAccountControl to 512 (NORMAL_ACCOUNT) to enable the account + modifyReq := ldapv3.NewModifyRequest(userDN, nil) + modifyReq.Replace("userAccountControl", []string{"512"}) + + err = s.client.Modify(modifyReq) + if err != nil { + return fmt.Errorf("failed to enable account for user %s: %v", username, err) + } + + return nil +} + +// DisableUserAccount disables a user account by username (wrapper method for Service interface) +func (s *LDAPService) DisableUserAccount(username string) error { + // Get user DN + userDN, err := s.GetUserDN(username) + if err != nil { + return fmt.Errorf("failed to find user %s: %v", username, err) + } + + // Verify that the user is not an admin (mandatory security check) + userGroups, err := s.GetUserGroups(userDN) + if err != nil { + return fmt.Errorf("failed to get user groups for %s: %v", username, err) + } + + isAdminUser, err := isAdmin(userGroups) + if err != nil { + return fmt.Errorf("failed to check if user %s is admin: %v", username, err) + } + if isAdminUser { + return fmt.Errorf("cannot disable admin user %s", username) + } + + // Set userAccountControl to 546 (NORMAL_ACCOUNT + ACCOUNTDISABLE) to disable the account + modifyReq := ldapv3.NewModifyRequest(userDN, nil) + modifyReq.Replace("userAccountControl", []string{"546"}) + + err = s.client.Modify(modifyReq) + if err != nil { + return fmt.Errorf("failed to disable account for user %s: %v", username, err) + } + + return nil +} + +func (s *LDAPService) SetUserGroups(username string, groups []string) error { + // TODO: Optimize the get userDN since it also gets in the remove and add user + + // Get user DN + userDN, err := s.GetUserDN(username) + if err != nil { + return fmt.Errorf("failed to find user %s: %v", username, err) + } + + // Get current groups + currentGroups, err := s.GetUserGroups(userDN) + if err != nil { + return fmt.Errorf("failed to get user groups: %v", err) + } + + // Convert slices to maps for efficient lookup + currentGroupsMap := make(map[string]bool) + for _, group := range currentGroups { + currentGroupsMap[group] = true + } + + newGroupsMap := make(map[string]bool) + for _, group := range groups { + newGroupsMap[group] = true + } + + // Find groups to remove (in current but not in new) + for _, group := range currentGroups { + if !newGroupsMap[group] { + if err := s.RemoveUserFromGroup(username, group); err != nil { + return fmt.Errorf("failed to remove user %s from group %s: %v", username, group, err) + } + } + } + + // Find groups to add (in new but not in current) + for _, group := range groups { + if !currentGroupsMap[group] { + if err := s.AddUserToGroup(username, group); err != nil { + return fmt.Errorf("failed to add user %s to group %s: %v", username, group, err) + } + } + } + + return nil +} diff --git a/internal/cloning/cloning.go b/internal/cloning/cloning.go new file mode 100644 index 0000000..8160266 --- /dev/null +++ b/internal/cloning/cloning.go @@ -0,0 +1,424 @@ +package cloning + +import ( + "database/sql" + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/cpp-cyber/proclone/internal/auth" + "github.com/cpp-cyber/proclone/internal/proxmox" + "github.com/kelseyhightower/envconfig" +) + +// LoadCloningConfig loads and validates cloning configuration from environment variables +func LoadCloningConfig() (*Config, error) { + var config Config + if err := envconfig.Process("", &config); err != nil { + return nil, fmt.Errorf("failed to process cloning configuration: %w", err) + } + return &config, nil +} + +// NewTemplateClient creates a new template client +func NewTemplateClient(db *sql.DB) *TemplateClient { + return &TemplateClient{ + DB: db, + TemplateConfig: &TemplateConfig{ + UploadDir: os.Getenv("UPLOAD_DIR"), + }, + } +} + +// NewDatabaseService creates a new database service +func NewDatabaseService(db *sql.DB) DatabaseService { + return NewTemplateClient(db) +} + +// GetTemplateConfig returns the template configuration +func (c *TemplateClient) GetTemplateConfig() *TemplateConfig { + return c.TemplateConfig +} + +// NewTemplateClient creates a new template client +func NewCloningManager(proxmoxService proxmox.Service, db *sql.DB, ldapService auth.Service) (*CloningManager, error) { + config, err := LoadCloningConfig() + if err != nil { + return nil, fmt.Errorf("failed to load cloning configuration: %w", err) + } + + if config.Realm == "" || config.RouterVMID == 0 || config.RouterNode == "" { + return nil, fmt.Errorf("incomplete cloning configuration") + } + + return &CloningManager{ + ProxmoxService: proxmoxService, + DatabaseService: NewDatabaseService(db), + LDAPService: ldapService, + Config: config, + }, nil +} + +// CloneTemplate clones a template pool for a user or group +func (cm *CloningManager) CloneTemplate(template string, targetName string, isGroup bool) error { + log.Printf("Starting cloning process for template '%s' to target '%s' (isGroup: %t)", template, targetName, isGroup) + var errors []string + + // 1. Get the template pool and its VMs + log.Printf("Step 1: Retrieving VMs from template pool '%s'", template) + templatePool, err := cm.ProxmoxService.GetPoolVMs(template) + if err != nil { + log.Printf("ERROR: Failed to get template pool '%s': %v", template, err) + return fmt.Errorf("failed to get template pool: %w", err) + } + log.Printf("Successfully retrieved %d VMs from template pool '%s'", len(templatePool), template) + + // 2. Check if the template has already been cloned by the user + + // Extract template name from pool name (remove kamino_template_ prefix) + log.Printf("Step 2: Checking deployment status for template") + templateName := strings.TrimPrefix(template, "kamino_template_") + log.Printf("Extracted template name: '%s' from pool '%s'", templateName, template) + + targetPoolName := fmt.Sprintf("%s_%s", templateName, targetName) + log.Printf("Checking if target pool '%s' is already deployed", targetPoolName) + isDeployed, err := cm.IsDeployed(targetPoolName) + if err != nil { + log.Printf("ERROR: Failed to check deployment status for '%s': %v", targetPoolName, err) + return fmt.Errorf("failed to check if template is deployed: %w", err) + } + + if isDeployed { + log.Printf("ERROR: Template '%s' is already deployed for target '%s'", template, targetName) + return fmt.Errorf("template %s is already or in the process of being deployed %s", template, targetName) + } + log.Printf("Template is not deployed, proceeding with cloning") + + // 3. Identify router and other VMs + log.Printf("Step 3: Analyzing template VMs to identify router and other VMs") + var router *proxmox.VM + var templateVMs []proxmox.VM + + for _, vm := range templatePool { + // Check to see if this VM is the router + lowerVMName := strings.ToLower(vm.Name) + log.Printf("Analyzing VM: '%s' (Type: %s)", vm.Name, vm.Type) + if strings.Contains(lowerVMName, "router") || strings.Contains(lowerVMName, "pfsense") { + log.Printf("Identified VM '%s' as router", vm.Name) + router = &proxmox.VM{ + Name: vm.Name, + Node: vm.NodeName, + VMID: vm.VmId, + } + } else { + log.Printf("Added VM '%s' to template VMs list", vm.Name) + templateVMs = append(templateVMs, proxmox.VM{ + Name: vm.Name, + Node: vm.NodeName, + VMID: vm.VmId, + }) + } + } + + if router != nil { + log.Printf("Router VM identified: '%s' (VMID: %d, Node: %s)", router.Name, router.VMID, router.Node) + } else { + log.Printf("No router VM found in template, will use default router") + } + log.Printf("Template contains %d non-router VMs to clone", len(templateVMs)) + + // 4. Verify that the pool is not empty + if len(templateVMs) == 0 { + log.Printf("ERROR: Template pool '%s' contains no VMs", template) + return fmt.Errorf("template pool %s contains no VMs", template) + } + log.Printf("Template validation passed: %d VMs ready for cloning", len(templateVMs)) + + // 5. Get the next available pod ID and create new pool + log.Printf("Step 5: Allocating pod ID and creating new pool") + newPodID, newPodNumber, err := cm.ProxmoxService.GetNextPodID() + if err != nil { + log.Printf("ERROR: Failed to get next pod ID: %v", err) + return fmt.Errorf("failed to get next pod ID: %w", err) + } + log.Printf("Allocated pod ID: %s (Pod number: %d)", newPodID, newPodNumber) + + newPoolName := fmt.Sprintf("%s_%s_%s", newPodID, templateName, targetName) + log.Printf("Creating new pool: '%s'", newPoolName) + + err = cm.ProxmoxService.CreateNewPool(newPoolName) + if err != nil { + log.Printf("ERROR: Failed to create pool '%s': %v", newPoolName, err) + return fmt.Errorf("failed to create new pool: %w", err) + } + log.Printf("Successfully created new pool: '%s'", newPoolName) + + // 6. Clone the router and all VMs + log.Printf("Step 6: Starting VM cloning process") + + // If no router was found in the template, use the default router template + if router == nil { + router = &proxmox.VM{ + Name: cm.Config.RouterName, + Node: cm.Config.RouterNode, + VMID: cm.Config.RouterVMID, + } + log.Printf("Using default router template: '%s' (VMID: %d)", router.Name, router.VMID) + } + + log.Printf("Cloning router VM '%s' to pool '%s'", router.Name, newPoolName) + newRouter, err := cm.ProxmoxService.CloneVM(*router, newPoolName) + if err != nil { + log.Printf("ERROR: Failed to clone router VM '%s': %v", router.Name, err) + errors = append(errors, fmt.Sprintf("failed to clone router VM: %v", err)) + } else { + log.Printf("Successfully cloned router VM: '%s' -> new VMID: %d", router.Name, newRouter.VMID) + } + + // Clone each VM to new pool + log.Printf("Cloning %d template VMs to pool '%s'", len(templateVMs), newPoolName) + for i, vm := range templateVMs { + log.Printf("Cloning VM %d/%d: '%s' (VMID: %d)", i+1, len(templateVMs), vm.Name, vm.VMID) + clonedVM, err := cm.ProxmoxService.CloneVM(vm, newPoolName) + if err != nil { + log.Printf("ERROR: Failed to clone VM '%s': %v", vm.Name, err) + errors = append(errors, fmt.Sprintf("failed to clone VM %s: %v", vm.Name, err)) + } else { + log.Printf("Successfully cloned VM: '%s' -> new VMID: %d", vm.Name, clonedVM.VMID) + } + } + log.Printf("VM cloning phase completed") + + var vnetName = fmt.Sprintf("kamino%d", newPodNumber) + + // 7. Configure VNet of all VMs + log.Printf("Step 7: Configuring VNet for all VMs in pool '%s'", newPoolName) + log.Printf("Setting VNet to: '%s' for pod number %d", vnetName, newPodNumber) + err = cm.ProxmoxService.SetPodVnet(newPoolName, vnetName) + if err != nil { + log.Printf("ERROR: Failed to configure VNet '%s' for pool '%s': %v", vnetName, newPoolName, err) + errors = append(errors, fmt.Sprintf("failed to update pod vnet: %v", err)) + } else { + log.Printf("Successfully configured VNet '%s' for pool '%s'", vnetName, newPoolName) + } + + // 8. Turn on Router + log.Printf("Step 8: Starting and configuring router VM") + if newRouter != nil { + log.Printf("Waiting for router disk availability (VMID: %d, Node: %s, Timeout: %v)", + newRouter.VMID, newRouter.Node, cm.Config.RouterWaitTimeout) + err = cm.ProxmoxService.WaitForDiskAvailability(newRouter.Node, newRouter.VMID, cm.Config.RouterWaitTimeout) + if err != nil { + log.Printf("ERROR: Router disk unavailable (VMID: %d): %v", newRouter.VMID, err) + errors = append(errors, fmt.Sprintf("router disk unavailable: %v", err)) + } else { + log.Printf("Router disk is available, starting VM (VMID: %d)", newRouter.VMID) + } + + log.Printf("Starting router VM (VMID: %d, Node: %s)", newRouter.VMID, newRouter.Node) + err = cm.ProxmoxService.StartVM(newRouter.Node, newRouter.VMID) + if err != nil { + log.Printf("ERROR: Failed to start router VM (VMID: %d): %v", newRouter.VMID, err) + errors = append(errors, fmt.Sprintf("failed to start router VM: %v", err)) + } else { + log.Printf("Successfully started router VM (VMID: %d)", newRouter.VMID) + } + + // 9. Wait for router to be running + log.Printf("Step 9: Waiting for router to be fully running") + err = cm.ProxmoxService.WaitForRunning(*newRouter) + if err != nil { + log.Printf("ERROR: Router failed to reach running state (VMID: %d): %v", newRouter.VMID, err) + errors = append(errors, fmt.Sprintf("failed to start router VM: %v", err)) + } else { + log.Printf("Router is now running, proceeding with configuration (Pod: %d, VMID: %d)", newPodNumber, newRouter.VMID) + err = cm.configurePodRouter(newPodNumber, newRouter.Node, newRouter.VMID) + if err != nil { + log.Printf("ERROR: Failed to configure pod router (Pod: %d, VMID: %d): %v", newPodNumber, newRouter.VMID, err) + errors = append(errors, fmt.Sprintf("failed to configure pod router: %v", err)) + } else { + log.Printf("Successfully configured pod router (Pod: %d, VMID: %d)", newPodNumber, newRouter.VMID) + } + } + } else { + log.Printf("WARNING: No router VM available to start") + } + + // 10. Set permissions on the pool to the user/group + log.Printf("Step 10: Setting permissions on pool '%s' for target '%s' (isGroup: %t)", newPoolName, targetName, isGroup) + err = cm.ProxmoxService.SetPoolPermission(newPoolName, targetName, cm.Config.Realm, isGroup) + if err != nil { + log.Printf("ERROR: Failed to set permissions on pool '%s' for '%s': %v", newPoolName, targetName, err) + errors = append(errors, fmt.Sprintf("failed to update pool permissions for %s: %v", targetName, err)) + } else { + log.Printf("Successfully set permissions on pool '%s' for '%s'", newPoolName, targetName) + } + + // 11. Add a +1 to the total deployments in the templates database + log.Printf("Step 11: Updating deployment counter for template '%s'", templateName) + err = cm.DatabaseService.AddDeployment(templateName) + if err != nil { + log.Printf("ERROR: Failed to increment deployment counter for template '%s': %v", templateName, err) + errors = append(errors, fmt.Sprintf("failed to increment template deployments for %s: %v", templateName, err)) + } else { + log.Printf("Successfully incremented deployment counter for template '%s'", templateName) + } + + // If there were any errors, clean up if necessary + if len(errors) > 0 { + log.Printf("Cloning completed with %d errors, checking cleanup requirements", len(errors)) + log.Printf("Errors encountered: %v", errors) + + // Check if any VMs were successfully cloned + clonedVMs, checkErr := cm.ProxmoxService.GetPoolVMs(newPoolName) + if checkErr != nil { + log.Printf("WARNING: Could not check cloned VMs for cleanup: %v", checkErr) + } else { + log.Printf("Found %d VMs in pool '%s' after failed cloning", len(clonedVMs), newPoolName) + } + + if len(clonedVMs) == 0 { + log.Printf("No VMs were successfully cloned, deleting empty pool '%s'", newPoolName) + deleteErr := cm.ProxmoxService.DeletePool(newPoolName) + if deleteErr != nil { + log.Printf("WARNING: Failed to cleanup empty pool '%s': %v", newPoolName, deleteErr) + } else { + log.Printf("Successfully cleaned up empty pool '%s'", newPoolName) + } + } else { + log.Printf("Some VMs were cloned successfully, leaving pool '%s' for manual cleanup", newPoolName) + } + + return fmt.Errorf("clone operation completed with errors: %v", errors) + } + + log.Printf("Successfully cloned pool '%s' to '%s' for '%s'", template, newPoolName, targetName) + log.Printf("Cloning process completed successfully - Pool: '%s', VMs: %d, Target: '%s'", + newPoolName, len(templateVMs)+1, targetName) // +1 for router + return nil +} + +// DeletePod deletes a pod pool for a user or group +func (cm *CloningManager) DeletePod(pod string) error { + log.Printf("Starting deletion process for pod '%s'", pod) + + // 1. Check if pool is already empty + log.Printf("Step 1: Checking if pool '%s' is empty", pod) + isEmpty, err := cm.ProxmoxService.IsPoolEmpty(pod) + if err != nil { + log.Printf("ERROR: Failed to check if pool '%s' is empty: %v", pod, err) + return fmt.Errorf("failed to check if pool %s is empty: %w", pod, err) + } + + if isEmpty { + log.Printf("Pool '%s' is already empty, proceeding directly to pool deletion", pod) + err := cm.ProxmoxService.DeletePool(pod) + if err != nil { + log.Printf("ERROR: Failed to delete empty pool '%s': %v", pod, err) + } else { + log.Printf("Successfully deleted empty pool '%s'", pod) + } + return err + } + + // 2. Get all virtual machines in the pool + log.Printf("Step 2: Retrieving all VMs from pool '%s'", pod) + poolVMs, err := cm.ProxmoxService.GetPoolVMs(pod) + if err != nil { + log.Printf("ERROR: Failed to get VMs from pool '%s': %v", pod, err) + return fmt.Errorf("failed to get pool VMs for %s: %w", pod, err) + } + + log.Printf("Found %d VMs in pool '%s', proceeding with deletion", len(poolVMs), pod) + + // 3. Stop all VMs and wait for them to be stopped + log.Printf("Step 3: Stopping all running VMs in pool '%s'", pod) + var runningVMs []proxmox.VM + stoppedCount := 0 + + for _, vm := range poolVMs { + if vm.Type == "qemu" { + // Only stop if VM is running + if vm.RunningStatus == "running" { + log.Printf("Force stopping VM '%s' (ID: %d) on node '%s'", vm.Name, vm.VmId, vm.NodeName) + err := cm.ProxmoxService.StopVM(vm.NodeName, vm.VmId) + if err != nil { + log.Printf("ERROR: Failed to stop VM '%s' (ID: %d): %v", vm.Name, vm.VmId, err) + return fmt.Errorf("failed to stop VM %s: %w", vm.Name, err) + } + + // Only add to wait list if it was actually running + runningVMs = append(runningVMs, proxmox.VM{ + Node: vm.NodeName, + VMID: vm.VmId, + }) + stoppedCount++ + } else { + log.Printf("VM '%s' (ID: %d) is already stopped (status: %s)", vm.Name, vm.VmId, vm.RunningStatus) + } + } else { + log.Printf("Skipping non-qemu resource '%s' (type: %s)", vm.Name, vm.Type) + } + } + + log.Printf("Initiated stop for %d running VMs, waiting for them to stop", stoppedCount) + + // Wait for all previously running VMs to be stopped + for i, vm := range runningVMs { + log.Printf("Waiting for VM %d/%d to stop: VMID %d on node %s", i+1, len(runningVMs), vm.VMID, vm.Node) + err := cm.ProxmoxService.WaitForStopped(vm) + if err != nil { + log.Printf("WARNING: Timeout waiting for VM %d to stop: %v", vm.VMID, err) + // Continue with deletion even if we can't confirm the VM is stopped + } else { + log.Printf("VM %d successfully stopped", vm.VMID) + } + } + + if len(runningVMs) > 0 { + log.Printf("All %d VMs have been processed for stopping", len(runningVMs)) + } + + // 4. Delete all VMs + log.Printf("Step 4: Deleting all VMs from pool '%s'", pod) + deletedCount := 0 + + for i, vm := range poolVMs { + if vm.Type == "qemu" { + log.Printf("Deleting VM %d/%d: '%s' (ID: %d) on node '%s'", i+1, len(poolVMs), vm.Name, vm.VmId, vm.NodeName) + err := cm.ProxmoxService.DeleteVM(vm.NodeName, vm.VmId) + if err != nil { + log.Printf("ERROR: Failed to delete VM '%s' (ID: %d): %v", vm.Name, vm.VmId, err) + return fmt.Errorf("failed to delete VM %s: %w", vm.Name, err) + } + deletedCount++ + log.Printf("Successfully initiated deletion of VM '%s' (ID: %d)", vm.Name, vm.VmId) + } + } + + log.Printf("Initiated deletion for %d VMs from pool '%s'", deletedCount, pod) + + // 5. Wait for all VMs to be deleted and pool to become empty + log.Printf("Step 5: Waiting for all VMs to be deleted from pool '%s' (timeout: 5 minutes)", pod) + err = cm.ProxmoxService.WaitForPoolEmpty(pod, 5*time.Minute) + if err != nil { + log.Printf("WARNING: %v", err) + // Continue with pool deletion even if we can't confirm all VMs are gone + } else { + log.Printf("All VMs successfully deleted from pool '%s'", pod) + } + + // 6. Delete the pool + log.Printf("Step 6: Deleting pool '%s'", pod) + err = cm.ProxmoxService.DeletePool(pod) + if err != nil { + log.Printf("ERROR: Failed to delete pool '%s': %v", pod, err) + return fmt.Errorf("failed to delete pool %s: %w", pod, err) + } + + log.Printf("Successfully deleted template pool '%s' and all its VMs", pod) + log.Printf("Pod deletion process completed successfully for '%s'", pod) + return nil +} diff --git a/internal/cloning/pods.go b/internal/cloning/pods.go new file mode 100644 index 0000000..2e66461 --- /dev/null +++ b/internal/cloning/pods.go @@ -0,0 +1,92 @@ +package cloning + +import ( + "fmt" + "regexp" + "strings" + + "github.com/cpp-cyber/proclone/internal/proxmox" +) + +func (cm *CloningManager) GetPods(username string) ([]Pod, error) { + // Get User DN + userDN, err := cm.LDAPService.GetUserDN(username) + if err != nil { + return nil, fmt.Errorf("failed to get user DN: %w", err) + } + + // Get user's groups + groups, err := cm.LDAPService.GetUserGroups(userDN) + if err != nil { + return nil, fmt.Errorf("failed to get user groups: %w", err) + } + + // Build regex pattern to match username or any of their group names + regexPattern := fmt.Sprintf(`1[0-9]{3}_.*_(%s|%s)`, username, strings.Join(groups, "|")) + + // Get pods based on regex pattern + pods, err := cm.MapVirtualResourcesToPods(regexPattern) + if err != nil { + return nil, err + } + return pods, nil +} + +func (cm *CloningManager) GetAllPods() ([]Pod, error) { + pods, err := cm.MapVirtualResourcesToPods(`1[0-9]{3}_.*`) + if err != nil { + return nil, err + } + return pods, nil +} + +func (cm *CloningManager) MapVirtualResourcesToPods(regex string) ([]Pod, error) { + // Get cluster resources + resources, err := cm.ProxmoxService.GetClusterResources("") + if err != nil { + return nil, err + } + + podMap := make(map[string]*Pod) + reg := regexp.MustCompile(regex) + + // Iterate over cluster resources, this works because proxmox displays pools before VMs + for _, r := range resources { + if r.Type == "pool" && reg.MatchString(r.ResourcePool) { + name := r.ResourcePool + podMap[name] = &Pod{ + Name: name, + VMs: []proxmox.VirtualResource{}, + } + } + if r.Type == "qemu" && reg.MatchString(r.ResourcePool) { + if pod, ok := podMap[r.ResourcePool]; ok { + pod.VMs = append(pod.VMs, r) + } + } + } + + // Convert map to slice + var pods []Pod + for _, pod := range podMap { + pods = append(pods, *pod) + } + + return pods, nil +} + +func (cm *CloningManager) IsDeployed(templateName string) (bool, error) { + podPools, err := cm.GetAllPods() + if err != nil { + return false, fmt.Errorf("failed to get pod pools: %w", err) + } + + for _, pod := range podPools { + // Remove the Pod ID number and _ to compare + if pod.Name[5:] == templateName { + return true, nil + } + } + + return false, nil +} diff --git a/internal/cloning/router.go b/internal/cloning/router.go new file mode 100644 index 0000000..e93cabe --- /dev/null +++ b/internal/cloning/router.go @@ -0,0 +1,80 @@ +package cloning + +import ( + "fmt" + "log" + "math" + "time" + + "github.com/cpp-cyber/proclone/internal/tools" +) + +// configurePodRouter configures the pod router with proper networking settings +func (cm *CloningManager) configurePodRouter(podNumber int, node string, vmid int) error { + // Wait for router agent to be pingable + statusReq := tools.ProxmoxAPIRequest{ + Method: "POST", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/agent/ping", node, vmid), + } + + backoff := time.Second + maxBackoff := 30 * time.Second + timeout := 5 * time.Minute + startTime := time.Now() + + for { + if time.Since(startTime) > timeout { + return fmt.Errorf("router qemu agent timed out") + } + + _, err := cm.ProxmoxService.GetRequestHelper().MakeRequest(statusReq) + if err == nil { + break // Agent is responding + } + + log.Printf("Agent ping failed for VMID %d", vmid) + time.Sleep(backoff) + backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) + } + + // Configure router WAN IP to have correct third octet using qemu agent API call + reqBody := map[string]any{ + "command": []string{ + cm.Config.WANScriptPath, + fmt.Sprintf("%s%d.1", cm.Config.WANIPBase, podNumber), + }, + } + + execReq := tools.ProxmoxAPIRequest{ + Method: "POST", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/agent/exec", node, vmid), + RequestBody: reqBody, + } + + _, err := cm.ProxmoxService.GetRequestHelper().MakeRequest(execReq) + if err != nil { + return fmt.Errorf("failed to make IP change request: %v", err) + } + + // Send agent exec request to change VIP subnet + vipReqBody := map[string]any{ + "command": []string{ + cm.Config.VIPScriptPath, + fmt.Sprintf("%s%d.0", cm.Config.WANIPBase, podNumber), + }, + } + + vipExecReq := tools.ProxmoxAPIRequest{ + Method: "POST", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/agent/exec", node, vmid), + RequestBody: vipReqBody, + } + + _, err = cm.ProxmoxService.GetRequestHelper().MakeRequest(vipExecReq) + if err != nil { + return fmt.Errorf("failed to make VIP change request: %v", err) + } + + log.Printf("Successfully configured router for pod %d on node %s, VMID %d", podNumber, node, vmid) + return nil +} diff --git a/internal/cloning/templates.go b/internal/cloning/templates.go new file mode 100644 index 0000000..93dbca6 --- /dev/null +++ b/internal/cloning/templates.go @@ -0,0 +1,323 @@ +package cloning + +import ( + "database/sql" + "fmt" + "io" + "log" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// Utils + +// buildTemplates builds template structs from SQL rows +func (c *TemplateClient) buildTemplates(rows *sql.Rows) ([]KaminoTemplate, error) { + templates := []KaminoTemplate{} + + for rows.Next() { + var template KaminoTemplate + err := rows.Scan( + &template.Name, + &template.Description, + &template.ImagePath, + &template.TemplateVisible, + &template.PodVisible, + &template.VMsVisible, + &template.VMCount, + &template.Deployments, + &template.CreatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan row: %w", err) + } + templates = append(templates, template) + } + + return templates, nil +} + +// Use a map to define allowed MIME types for better performance +// and to avoid using a switch statement +var allowedMIMEs = map[string]struct{}{ + "image/jpeg": {}, + "image/png": {}, +} + +// detectMIME reads a small buffer to determine the file's MIME type +func detectMIME(f multipart.File) (string, error) { + buffer := make([]byte, 512) + if _, err := f.Read(buffer); err != nil && err != io.EOF { + return "", err + } + return http.DetectContentType(buffer), nil +} + +// Database Operations + +func (c *TemplateClient) GetTemplates() ([]KaminoTemplate, error) { + query := "SELECT * FROM templates WHERE template_visible = true ORDER BY created_at DESC" + rows, err := c.DB.Query(query) + if err != nil { + return nil, fmt.Errorf("failed to execute query: %w", err) + } + defer rows.Close() + + return c.buildTemplates(rows) +} + +func (c *TemplateClient) GetPublishedTemplates() ([]KaminoTemplate, error) { + query := "SELECT * FROM templates" + rows, err := c.DB.Query(query) + if err != nil { + return nil, fmt.Errorf("failed to execute query: %w", err) + } + defer rows.Close() + + return c.buildTemplates(rows) +} + +func (c *TemplateClient) DeleteTemplate(templateName string) error { + // Get template image path and delete the image + template, err := c.GetTemplateInfo(templateName) + if err != nil { + return fmt.Errorf("failed to get template info: %w", err) + } + + // Only attempt to delete image if there's an image path + if template.ImagePath != "" { + err = c.DeleteImage(template.ImagePath) + if err != nil { + return fmt.Errorf("failed to delete template image: %w", err) + } + } + + // Delete template from database + query := "DELETE FROM templates WHERE name = ?" + result, err := c.DB.Exec(query, templateName) + if err != nil { + return fmt.Errorf("failed to execute query: %w", err) + } + + // Check if any rows were affected + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + if rowsAffected == 0 { + return fmt.Errorf("template not found: %s", templateName) + } + + return nil +} + +func (c *TemplateClient) ToggleTemplateVisibility(templateName string) error { + query := "UPDATE templates SET template_visible = NOT template_visible WHERE name = ?" + _, err := c.DB.Exec(query, templateName) + if err != nil { + return fmt.Errorf("failed to execute query: %w", err) + } + + return nil +} + +func (c *TemplateClient) GetAllTemplateNames() ([]string, error) { + templates, err := c.GetPublishedTemplates() + if err != nil { + return nil, err + } + + var templateNames []string + for _, template := range templates { + templateNames = append(templateNames, template.Name) + } + + return templateNames, nil +} + +func (c *TemplateClient) InsertTemplate(template KaminoTemplate) error { + query := "INSERT INTO templates (name, description, image_path, template_visible, vm_count) VALUES (?, ?, ?, ?, ?)" + _, err := c.DB.Exec(query, template.Name, template.Description, template.ImagePath, template.TemplateVisible, template.VMCount) + if err != nil { + return fmt.Errorf("failed to execute query: %w", err) + } + + return nil +} + +func (c *TemplateClient) UpdateTemplate(template KaminoTemplate) error { + query := "UPDATE templates SET description = ?, image_path = ?, template_visible = ?, vm_count = ? WHERE name = ?" + _, err := c.DB.Exec(query, template.Description, template.ImagePath, template.TemplateVisible, template.VMCount, template.Name) + if err != nil { + return fmt.Errorf("failed to execute query: %w", err) + } + + return nil +} + +func (c *TemplateClient) AddDeployment(templateName string) error { + query := "UPDATE templates SET deployments = deployments + 1 WHERE name = ?" + _, err := c.DB.Exec(query, templateName) + if err != nil { + return fmt.Errorf("failed to execute query: %w", err) + } + + return nil +} + +func (c *TemplateClient) GetTemplateInfo(templateName string) (KaminoTemplate, error) { + query := "SELECT * FROM templates WHERE name = ?" + row := c.DB.QueryRow(query, templateName) + + var template KaminoTemplate + err := row.Scan( + &template.Name, + &template.Description, + &template.ImagePath, + &template.TemplateVisible, + &template.PodVisible, + &template.VMsVisible, + &template.VMCount, + &template.Deployments, + &template.CreatedAt, + ) + if err != nil { + return KaminoTemplate{}, fmt.Errorf("failed to get template info: %w", err) + } + + return template, nil +} + +func (cm *CloningManager) GetUnpublishedTemplates() ([]string, error) { + // Gets published templates from the database + publishedTemplates, err := cm.DatabaseService.GetPublishedTemplates() + if err != nil { + return nil, fmt.Errorf("failed to get unpublished templates: %w", err) + } + + // Gets pools that start with "kamino_template_" in Proxmox + proxmoxTemplate, err := cm.ProxmoxService.GetTemplatePools() + if err != nil { + return nil, fmt.Errorf("failed to get Proxmox templates: %w", err) + } + + var unpublished = []string{} + for _, template := range proxmoxTemplate { + trimmedTemplateName := strings.TrimPrefix(template, "kamino_template_") + + found := false + for _, pubTemplate := range publishedTemplates { + if pubTemplate.Name == trimmedTemplateName { + found = true + break + } + } + + if !found { + unpublished = append(unpublished, trimmedTemplateName) + } + } + + return unpublished, nil +} + +func (cm *CloningManager) PublishTemplate(template KaminoTemplate) error { + // Insert template information into database + if err := cm.DatabaseService.InsertTemplate(template); err != nil { + return fmt.Errorf("failed to publish template: %w", err) + } + + // Get all VMs in pool + vms, err := cm.ProxmoxService.GetPoolVMs("kamino_template_" + template.Name) + if err != nil { + log.Printf("Error retrieving VMs in pool: %v", err) + return fmt.Errorf("failed to get VMs in pool: %w", err) + } + + // Convert all VMs to templates + for _, vm := range vms { + if err := cm.ProxmoxService.ConvertVMToTemplate(vm.NodeName, vm.VmId); err != nil { + log.Printf("Error converting VM %d to template: %v", vm.VmId, err) + return fmt.Errorf("failed to convert VM to template: %w", err) + } + } + + return nil +} + +// Template Image Operations + +func (cl *TemplateClient) UploadTemplateImage(c *gin.Context) (*UploadResult, error) { + // Check header for multipart/form-data + if !strings.HasPrefix(c.Request.Header.Get("Content-Type"), "multipart/form-data") { + return nil, fmt.Errorf("invalid content type") + } + + // Parse the multipart form + file, header, err := c.Request.FormFile("image") + if err != nil { + return nil, fmt.Errorf("image field is required") + } + defer file.Close() + + // Basic check: Is file size 0? + if header.Size == 0 { + return nil, fmt.Errorf("uploaded file is empty") + } + + // Block unsupported file types + filetype, err := detectMIME(file) + if err != nil { + return nil, fmt.Errorf("failed to detect file type") + } + if _, ok := allowedMIMEs[filetype]; !ok { + return nil, fmt.Errorf("unsupported file type: %s", filetype) + } + + // Reset file pointer back to beginning + if _, err := file.Seek(0, io.SeekStart); err != nil { + return nil, fmt.Errorf("failed to reset file reader") + } + // File name sanitization + filename := filepath.Base(header.Filename) // basic sanitization + filename = filepath.Clean(filename) // clean up the filename + filename = strings.ReplaceAll(filename, " ", "_") // replace spaces with underscores + + // Unique file name + // Save with a UUID filename to avoid name collisions + // generate unique filename + newFilename := fmt.Sprintf("%s-%s", uuid.NewString(), filename) + outPath := filepath.Join(cl.TemplateConfig.UploadDir, newFilename) + + // Save file using Gin utility + if err := c.SaveUploadedFile(header, outPath); err != nil { + return nil, fmt.Errorf("unable to save file: %w", err) + } + + result := &UploadResult{ + Message: "file uploaded successfully", + Filename: newFilename, + MimeType: filetype, + Path: outPath, + } + + return result, nil +} + +func (c *TemplateClient) DeleteImage(imagePath string) error { + if imagePath == "" { + return fmt.Errorf("image path is empty") + } + + fullPath := filepath.Join(c.TemplateConfig.UploadDir, imagePath) + if err := os.Remove(fullPath); err != nil { + return fmt.Errorf("failed to delete image: %w", err) + } + return nil +} diff --git a/internal/cloning/types.go b/internal/cloning/types.go new file mode 100644 index 0000000..35868d9 --- /dev/null +++ b/internal/cloning/types.go @@ -0,0 +1,95 @@ +package cloning + +import ( + "database/sql" + "time" + + "github.com/cpp-cyber/proclone/internal/auth" + "github.com/cpp-cyber/proclone/internal/proxmox" + "github.com/gin-gonic/gin" +) + +// Config holds the configuration for cloning operations +type Config struct { + Realm string `envconfig:"REALM"` + RouterName string `envconfig:"ROUTER_NAME" default:"1-1NAT-pfsense"` + RouterVMID int `envconfig:"ROUTER_VMID"` + RouterNode string `envconfig:"ROUTER_NODE"` + MinPodID int `envconfig:"MIN_POD_ID" default:"1000"` + MaxPodID int `envconfig:"MAX_POD_ID" default:"1255"` + CloneTimeout time.Duration `envconfig:"CLONE_TIMEOUT" default:"3m"` + RouterWaitTimeout time.Duration `envconfig:"ROUTER_WAIT_TIMEOUT" default:"120s"` + SDNApplyTimeout time.Duration `envconfig:"SDN_APPLY_TIMEOUT" default:"30s"` + WANScriptPath string `envconfig:"WAN_SCRIPT_PATH" default:"/opt/scripts/change-wan-ip.sh"` + VIPScriptPath string `envconfig:"VIP_SCRIPT_PATH" default:"/opt/scripts/change-vip-subnet.sh"` + WANIPBase string `envconfig:"WAN_IP_BASE" default:"172.16."` +} + +// KaminoTemplate represents a template in the system +type KaminoTemplate struct { + Name string `json:"name"` + Description string `json:"description"` + ImagePath string `json:"image_path"` + TemplateVisible bool `json:"template_visible"` + PodVisible bool `json:"pod_visible"` + VMsVisible bool `json:"vms_visible"` + VMCount int `json:"vm_count"` + Deployments int `json:"deployments"` + CreatedAt string `json:"created_at"` +} + +// DatabaseService interface defines the methods for template operations +type DatabaseService interface { + GetTemplates() ([]KaminoTemplate, error) + GetPublishedTemplates() ([]KaminoTemplate, error) + InsertTemplate(template KaminoTemplate) error + DeleteTemplate(templateName string) error + ToggleTemplateVisibility(templateName string) error + UploadTemplateImage(c *gin.Context) (*UploadResult, error) + GetTemplateConfig() *TemplateConfig + GetTemplateInfo(templateName string) (KaminoTemplate, error) + AddDeployment(templateName string) error + UpdateTemplate(template KaminoTemplate) error + GetAllTemplateNames() ([]string, error) + DeleteImage(imagePath string) error +} + +// TemplateConfig holds template configuration +type TemplateConfig struct { + UploadDir string +} + +// UploadResult holds the result of a file upload +type UploadResult struct { + Message string `json:"message"` + Filename string `json:"filename"` + MimeType string `json:"mime_type"` + Path string `json:"path"` +} + +// TemplateClient implements the DatabaseService interface for template operations +type TemplateClient struct { + DB *sql.DB + TemplateConfig *TemplateConfig +} + +// CloningManager combines Proxmox service and templates database functionality +// for handling VM cloning operations +type CloningManager struct { + ProxmoxService proxmox.Service + DatabaseService DatabaseService + LDAPService auth.Service + Config *Config +} + +// PodResponse represents the response structure for pod operations +type PodResponse struct { + Pods []Pod `json:"pods"` +} + +// Pod represents a pod containing VMs and template information +type Pod struct { + Name string `json:"name"` + VMs []proxmox.VirtualResource `json:"vms"` + Template KaminoTemplate `json:"template,omitempty"` +} diff --git a/internal/proxmox/cluster.go b/internal/proxmox/cluster.go new file mode 100644 index 0000000..4e870f7 --- /dev/null +++ b/internal/proxmox/cluster.go @@ -0,0 +1,105 @@ +package proxmox + +import ( + "fmt" + + "github.com/cpp-cyber/proclone/internal/tools" +) + +// GetClusterResources retrieves all cluster resources from the Proxmox cluster +func (c *Client) GetClusterResources(getParams string) ([]VirtualResource, error) { + req := tools.ProxmoxAPIRequest{ + Method: "GET", + Endpoint: fmt.Sprintf("/cluster/resources?%s", getParams), + } + + var resources []VirtualResource + if err := c.RequestHelper.MakeRequestAndUnmarshal(req, &resources); err != nil { + return nil, fmt.Errorf("failed to get cluster resources: %w", err) + } + + return resources, nil +} + +// GetNodeStatus retrieves detailed status for a specific node +func (c *Client) GetNodeStatus(nodeName string) (*ProxmoxNodeStatus, error) { + req := tools.ProxmoxAPIRequest{ + Method: "GET", + Endpoint: fmt.Sprintf("/nodes/%s/status", nodeName), + } + + var nodeStatus ProxmoxNodeStatus + if err := c.RequestHelper.MakeRequestAndUnmarshal(req, &nodeStatus); err != nil { + return nil, fmt.Errorf("failed to get node status for %s: %w", nodeName, err) + } + + return &nodeStatus, nil +} + +// GetClusterResourceUsage retrieves resource usage for the Proxmox cluster +func (c *Client) GetClusterResourceUsage() (*ClusterResourceUsageResponse, error) { + resources, err := c.GetClusterResources("") + if err != nil { + return nil, fmt.Errorf("failed to get cluster resources: %w", err) + } + + nodes, errors := c.collectNodeResourceUsage(resources) + cluster := c.aggregateClusterResourceUsage(nodes, resources) + + response := &ClusterResourceUsageResponse{ + Nodes: nodes, + Total: cluster, + Errors: errors, + } + + // Return error if all nodes failed + if len(errors) > 0 && len(nodes) == 0 { + return nil, fmt.Errorf("failed to fetch resource usage for all nodes: %v", errors) + } + + return response, nil +} + +// FindBestNode finds the node with the most available resources +func (c *Client) FindBestNode() (string, error) { + req := tools.ProxmoxAPIRequest{ + Method: "GET", + Endpoint: "/nodes", + } + + var nodesResponse []struct { + Node string `json:"node"` + Status string `json:"status"` + CPU float64 `json:"cpu"` + MaxCPU int `json:"maxcpu"` + Mem int64 `json:"mem"` + MaxMem int64 `json:"maxmem"` + } + + if err := c.RequestHelper.MakeRequestAndUnmarshal(req, &nodesResponse); err != nil { + return "", fmt.Errorf("failed to get nodes: %w", err) + } + + var bestNode string + var lowestLoad float64 = 1.0 + + for _, node := range nodesResponse { + if node.Status == "online" { + // Calculate combined load (CPU + Memory) + cpuLoad := node.CPU + memLoad := float64(node.Mem) / float64(node.MaxMem) + combinedLoad := (cpuLoad + memLoad) / 2 + + if combinedLoad < lowestLoad { + lowestLoad = combinedLoad + bestNode = node.Node + } + } + } + + if bestNode == "" { + return "", fmt.Errorf("no online nodes available") + } + + return bestNode, nil +} diff --git a/internal/proxmox/networking.go b/internal/proxmox/networking.go new file mode 100644 index 0000000..40683b4 --- /dev/null +++ b/internal/proxmox/networking.go @@ -0,0 +1,36 @@ +package proxmox + +import ( + "fmt" + + "github.com/cpp-cyber/proclone/internal/tools" +) + +// SetPodVnet configures the VNet for all VMs in a pod +func (c *Client) SetPodVnet(poolName, vnetName string) error { + // Get all VMs in the pool + vms, err := c.GetPoolVMs(poolName) + if err != nil { + return fmt.Errorf("failed to get pool VMs: %w", err) + } + + for _, vm := range vms { + // Update VM network configuration + reqBody := map[string]string{ + "net0": fmt.Sprintf("virtio,bridge=%s", vnetName), + } + + req := tools.ProxmoxAPIRequest{ + Method: "PUT", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/config", vm.NodeName, vm.VmId), + RequestBody: reqBody, + } + + _, err := c.RequestHelper.MakeRequest(req) + if err != nil { + return fmt.Errorf("failed to update network for VM %d: %w", vm.VmId, err) + } + } + + return nil +} diff --git a/internal/proxmox/pools.go b/internal/proxmox/pools.go new file mode 100644 index 0000000..e18acef --- /dev/null +++ b/internal/proxmox/pools.go @@ -0,0 +1,212 @@ +package proxmox + +import ( + "fmt" + "log" + "math" + "slices" + "sort" + "strconv" + "strings" + "time" + + "github.com/cpp-cyber/proclone/internal/tools" +) + +// GetPoolVMs retrieves all VMs in a specific pool +func (c *Client) GetPoolVMs(poolName string) ([]VirtualResource, error) { + req := tools.ProxmoxAPIRequest{ + Method: "GET", + Endpoint: fmt.Sprintf("/pools/%s", poolName), + } + + var poolResponse struct { + Members []VirtualResource `json:"members"` + } + if err := c.RequestHelper.MakeRequestAndUnmarshal(req, &poolResponse); err != nil { + return nil, fmt.Errorf("failed to get pool VMs: %w", err) + } + + // Filter for VMs only (type=qemu) + var vms []VirtualResource + for _, member := range poolResponse.Members { + if member.Type == "qemu" { + vms = append(vms, member) + } + } + + return vms, nil +} + +// CreateNewPool creates a new pool with the given name +func (c *Client) CreateNewPool(poolName string) error { + reqBody := map[string]string{ + "poolid": poolName, + } + + req := tools.ProxmoxAPIRequest{ + Method: "POST", + Endpoint: "/pools", + RequestBody: reqBody, + } + + _, err := c.RequestHelper.MakeRequest(req) + if err != nil { + return fmt.Errorf("failed to create pool %s: %w", poolName, err) + } + + return nil +} + +// SetPoolPermission sets permissions for a pool to a user/group +func (c *Client) SetPoolPermission(poolName string, targetName string, realm string, isGroup bool) error { + reqBody := map[string]any{ + "path": fmt.Sprintf("/pool/%s", poolName), + "roles": "PVEVMUser,PVEPoolUser", + "propagate": true, + } + + if isGroup { + reqBody["groups"] = fmt.Sprintf("%s-%s", targetName, realm) + } else { + reqBody["users"] = fmt.Sprintf("%s@%s", targetName, realm) + } + + req := tools.ProxmoxAPIRequest{ + Method: "PUT", + Endpoint: "/access/acl", + RequestBody: reqBody, + } + + _, err := c.RequestHelper.MakeRequest(req) + if err != nil { + return fmt.Errorf("failed to set pool permissions: %w", err) + } + + return nil +} + +// DeletePool removes a pool completely +func (c *Client) DeletePool(poolName string) error { + req := tools.ProxmoxAPIRequest{ + Method: "DELETE", + Endpoint: fmt.Sprintf("/pools/%s", poolName), + } + + _, err := c.RequestHelper.MakeRequest(req) + if err != nil { + return fmt.Errorf("failed to delete pool %s: %w", poolName, err) + } + + log.Printf("Successfully deleted pool: %s", poolName) + return nil +} + +// GetTemplatePools retrieves all template pools +func (c *Client) GetTemplatePools() ([]string, error) { + req := tools.ProxmoxAPIRequest{ + Method: "GET", + Endpoint: "/pools", + } + + var poolResponse []struct { + Name string `json:"poolid"` + } + if err := c.RequestHelper.MakeRequestAndUnmarshal(req, &poolResponse); err != nil { + return nil, fmt.Errorf("failed to get template pools: %w", err) + } + + var templatePools []string + for _, pool := range poolResponse { + if strings.HasPrefix(pool.Name, "kamino_template_") { + templatePools = append(templatePools, pool.Name) + } + } + + return templatePools, nil +} + +// IsPoolEmpty checks if a pool is empty (contains no VMs) +func (c *Client) IsPoolEmpty(poolName string) (bool, error) { + poolVMs, err := c.GetPoolVMs(poolName) + if err != nil { + return false, fmt.Errorf("failed to check if pool %s is empty: %w", poolName, err) + } + + // Count only QEMU VMs (ignore other resource types) + vmCount := 0 + for _, vm := range poolVMs { + if vm.Type == "qemu" { + vmCount++ + } + } + + return vmCount == 0, nil +} + +// WaitForPoolEmpty waits for a pool to become empty with exponential backoff +func (c *Client) WaitForPoolEmpty(poolName string, timeout time.Duration) error { + start := time.Now() + backoff := 2 * time.Second + maxBackoff := 30 * time.Second + + for time.Since(start) < timeout { + poolVMs, err := c.GetPoolVMs(poolName) + if err != nil { + // If we can't get pool VMs, pool might be deleted or empty + log.Printf("Error checking pool %s (might be deleted): %v", poolName, err) + return nil + } + + if len(poolVMs) == 0 { + log.Printf("Pool %s is now empty", poolName) + return nil + } + + log.Printf("Pool %s still contains %d VMs, waiting...", poolName, len(poolVMs)) + time.Sleep(backoff) + backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) + } + + return fmt.Errorf("timeout waiting for pool %s to become empty after %v", poolName, timeout) +} + +// GetNextPodID finds the next available pod ID between MIN_POD_ID and MAX_POD_ID +func (c *Client) GetNextPodID() (string, int, error) { + // Get all existing pools + req := tools.ProxmoxAPIRequest{ + Method: "GET", + Endpoint: "/pools", + } + + var poolsResponse []struct { + PoolID string `json:"poolid"` + } + if err := c.RequestHelper.MakeRequestAndUnmarshal(req, &poolsResponse); err != nil { + return "", 0, fmt.Errorf("failed to get existing pools: %w", err) + } + + // Extract pod IDs from existing pools + var usedIDs []int + for _, pool := range poolsResponse { + if len(pool.PoolID) >= 4 { + if id, err := strconv.Atoi(pool.PoolID[:4]); err == nil { + if id >= 1001 && id <= 1255 { // MIN_POD_ID and MAX_POD_ID constants + usedIDs = append(usedIDs, id) + } + } + } + } + + sort.Ints(usedIDs) + + // Find first available ID + for i := 1001; i <= 1255; i++ { // MIN_POD_ID to MAX_POD_ID + found := slices.Contains(usedIDs, i) + if !found { + return fmt.Sprintf("%04d", i), i - 1000, nil + } + } + + return "", 0, fmt.Errorf("no available pod IDs in range 1000-1255") +} diff --git a/internal/proxmox/proxmox.go b/internal/proxmox/proxmox.go new file mode 100644 index 0000000..884906f --- /dev/null +++ b/internal/proxmox/proxmox.go @@ -0,0 +1,156 @@ +package proxmox + +import ( + "crypto/tls" + "fmt" + "net/http" + "strings" + "time" + + "github.com/cpp-cyber/proclone/internal/tools" + "github.com/kelseyhightower/envconfig" +) + +// ProxmoxConfig holds the configuration for Proxmox API +type ProxmoxConfig struct { + Host string `envconfig:"PROXMOX_HOST" required:"true"` + Port string `envconfig:"PROXMOX_PORT" default:"8006"` + TokenID string `envconfig:"PROXMOX_TOKEN_ID" required:"true"` + TokenSecret string `envconfig:"PROXMOX_TOKEN_SECRET" required:"true"` + VerifySSL bool `envconfig:"PROXMOX_VERIFY_SSL" default:"false"` + CriticalPool string `envconfig:"PROXMOX_CRITICAL_POOL"` + NodesStr string `envconfig:"PROXMOX_NODES"` + Nodes []string // Parsed from NodesStr + APIToken string // Computed from TokenID and TokenSecret +} + +// Service interface defines the methods for Proxmox operations +type Service interface { + // Cluster and Resource Management + GetClusterResourceUsage() (*ClusterResourceUsageResponse, error) + GetClusterResources(getParams string) ([]VirtualResource, error) + GetNodeStatus(nodeName string) (*ProxmoxNodeStatus, error) + FindBestNode() (string, error) + + // Pod Management + GetNextPodID() (string, int, error) + + // VM Management + GetVMs() ([]VirtualResource, error) + StartVM(node string, vmID int) error + ShutdownVM(node string, vmID int) error + RebootVM(node string, vmID int) error + StopVM(node string, vmID int) error + DeleteVM(node string, vmID int) error + ConvertVMToTemplate(node string, vmID int) error + CloneVM(sourceVM VM, newPoolName string) (*VM, error) + WaitForCloneCompletion(vm *VM, timeout time.Duration) error + WaitForDiskAvailability(node string, vmid int, maxWait time.Duration) error + WaitForRunning(vm VM) error + WaitForStopped(vm VM) error + + // Pool Management + GetPoolVMs(poolName string) ([]VirtualResource, error) + CreateNewPool(poolName string) error + SetPoolPermission(poolName string, targetName string, realm string, isGroup bool) error + DeletePool(poolName string) error + IsPoolEmpty(poolName string) (bool, error) + WaitForPoolEmpty(poolName string, timeout time.Duration) error + + // Network Management + SetPodVnet(poolName, vnetName string) error + + // Template Management + GetTemplatePools() ([]string, error) + + // Internal access for router functionality + GetRequestHelper() *tools.ProxmoxRequestHelper +} + +// Client implements the Service interface for Proxmox operations +type Client struct { + Config *ProxmoxConfig + HTTPClient *http.Client + BaseURL string + RequestHelper *tools.ProxmoxRequestHelper +} + +// ProxmoxNode represents a Proxmox node +type ProxmoxNode struct { + Node string `json:"node"` + Status string `json:"status"` +} + +// ProxmoxNodeStatus represents the status response from a Proxmox node +type ProxmoxNodeStatus struct { + CPU float64 `json:"cpu"` + Memory struct { + Total int64 `json:"total"` + Used int64 `json:"used"` + } `json:"memory"` + Uptime int64 `json:"uptime"` +} + +// NewClient creates a new Proxmox client with the given configuration +func NewClient(config ProxmoxConfig) *Client { + // Create HTTP client with appropriate TLS settings + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: !config.VerifySSL, + }, + } + + client := &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + } + + baseURL := fmt.Sprintf("https://%s:%s/api2/json", config.Host, config.Port) + + // Initialize the request helper + requestHelper := tools.NewProxmoxRequestHelper(baseURL, config.APIToken, client) + + return &Client{ + Config: &config, + HTTPClient: client, + BaseURL: baseURL, + RequestHelper: requestHelper, + } +} + +// NewService creates a new Proxmox service, loading configuration internally +func NewService() (Service, error) { + config, err := LoadProxmoxConfig() + if err != nil { + return nil, fmt.Errorf("failed to load Proxmox configuration: %w", err) + } + + return NewClient(*config), nil +} + +// GetRequestHelper returns the Proxmox request helper for internal use +func (c *Client) GetRequestHelper() *tools.ProxmoxRequestHelper { + return c.RequestHelper +} + +// LoadProxmoxConfig loads and validates Proxmox configuration from environment variables +func LoadProxmoxConfig() (*ProxmoxConfig, error) { + var config ProxmoxConfig + if err := envconfig.Process("", &config); err != nil { + return nil, fmt.Errorf("failed to process Proxmox configuration: %w", err) + } + + // Build API token from ID and secret + config.APIToken = fmt.Sprintf("%s=%s", config.TokenID, config.TokenSecret) + + // Parse nodes list if provided + if config.NodesStr != "" { + config.Nodes = strings.Split(config.NodesStr, ",") + // Trim whitespace from each node + for i, node := range config.Nodes { + config.Nodes[i] = strings.TrimSpace(node) + } + } + + return &config, nil +} diff --git a/internal/proxmox/resources.go b/internal/proxmox/resources.go new file mode 100644 index 0000000..27c946c --- /dev/null +++ b/internal/proxmox/resources.go @@ -0,0 +1,103 @@ +package proxmox + +import ( + "fmt" + "log" +) + +func getNodeStorage(resources *[]VirtualResource, node string) (Used int64, Total int64) { + var used int64 = 0 + var total int64 = 0 + + for _, r := range *resources { + if r.Type == "storage" && r.NodeName == node && + (r.Storage == "local" || r.Storage == "local-lvm") && + r.RunningStatus == "available" { + used += r.Disk + total += r.MaxDisk + } + } + + return used, total +} + +func getStorage(resources *[]VirtualResource, storage string) (Used int64, Total int64) { + var used int64 = 0 + var total int64 = 0 + + for _, r := range *resources { + if r.Type == "storage" && r.Storage == storage && r.RunningStatus == "available" { + used = r.Disk + total = r.MaxDisk + break + } + } + + return used, total +} + +// collectNodeResourceUsage gathers resource usage data for all configured nodes +func (c *Client) collectNodeResourceUsage(resources []VirtualResource) ([]NodeResourceUsage, []string) { + var nodes []NodeResourceUsage + var errors []string + + for _, nodeName := range c.Config.Nodes { + nodeUsage, err := c.getNodeResourceUsage(nodeName, resources) + if err != nil { + errorMsg := fmt.Sprintf("Error fetching status for node %s: %v", nodeName, err) + log.Printf("%s", errorMsg) + errors = append(errors, errorMsg) + continue + } + nodes = append(nodes, nodeUsage) + } + + return nodes, errors +} + +// getNodeResourceUsage retrieves resource usage for a single node +func (c *Client) getNodeResourceUsage(nodeName string, resources []VirtualResource) (NodeResourceUsage, error) { + status, err := c.GetNodeStatus(nodeName) + if err != nil { + return NodeResourceUsage{}, fmt.Errorf("failed to get node status: %w", err) + } + + usedStorage, totalStorage := getNodeStorage(&resources, nodeName) + + return NodeResourceUsage{ + Name: nodeName, + Resources: ResourceUsage{ + CPUUsage: status.CPU, + MemoryTotal: status.Memory.Total, + MemoryUsed: status.Memory.Used, + StorageTotal: int64(totalStorage), + StorageUsed: int64(usedStorage), + }, + }, nil +} + +// aggregateClusterResourceUsage calculates cluster-wide resource totals and averages +func (c *Client) aggregateClusterResourceUsage(nodes []NodeResourceUsage, resources []VirtualResource) ResourceUsage { + cluster := ResourceUsage{} + + // Aggregate node resources + for _, node := range nodes { + cluster.MemoryTotal += node.Resources.MemoryTotal + cluster.MemoryUsed += node.Resources.MemoryUsed + cluster.StorageTotal += node.Resources.StorageTotal + cluster.StorageUsed += node.Resources.StorageUsed + cluster.CPUUsage += node.Resources.CPUUsage + } + + // Add shared storage (NAS) + nasUsed, nasTotal := getStorage(&resources, "mufasa-proxmox") + cluster.StorageTotal += int64(nasTotal) + cluster.StorageUsed += int64(nasUsed) + + // Calculate average CPU usage + if len(nodes) > 0 { + cluster.CPUUsage /= float64(len(nodes)) + } + + return cluster +} diff --git a/internal/proxmox/types.go b/internal/proxmox/types.go new file mode 100644 index 0000000..082860e --- /dev/null +++ b/internal/proxmox/types.go @@ -0,0 +1,59 @@ +package proxmox + +// ConfigResponse represents VM configuration response +type ConfigResponse struct { + HardDisk string `json:"scsi0,omitempty"` + Lock string `json:"lock,omitempty"` +} + +// VNetResponse represents the VNet API response +type VNetResponse []struct { + VNet string `json:"vnet"` +} + +// VM represents a Virtual Machine with node and ID information +type VM struct { + Name string `json:"name,omitempty"` + Node string `json:"node"` + VMID int `json:"vmid"` +} + +type VirtualResource struct { + CPU float64 `json:"cpu,omitempty"` + MaxCPU int `json:"maxcpu,omitempty"` + Mem int `json:"mem,omitempty"` + MaxMem int `json:"maxmem,omitempty"` + Type string `json:"type,omitempty"` + Id string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + NodeName string `json:"node,omitempty"` + ResourcePool string `json:"pool,omitempty"` + RunningStatus string `json:"status,omitempty"` + Uptime int `json:"uptime,omitempty"` + VmId int `json:"vmid,omitempty"` + Storage string `json:"storage,omitempty"` + Disk int64 `json:"disk,omitempty"` + MaxDisk int64 `json:"maxdisk,omitempty"` + Template int `json:"template,omitempty"` +} + +type ResourceUsage struct { + CPUUsage float64 `json:"cpu_usage"` // CPU usage percentage + MemoryUsed int64 `json:"memory_used"` // Used memory in bytes + MemoryTotal int64 `json:"memory_total"` // Total memory in bytes + StorageUsed int64 `json:"storage_used"` // Used storage in bytes + StorageTotal int64 `json:"storage_total"` // Total storage in bytes +} + +// NodeResourceUsage represents the resource usage metrics for a single node +type NodeResourceUsage struct { + Name string `json:"name"` + Resources ResourceUsage `json:"resources"` +} + +// ResourceUsageResponse represents the API response containing resource usage for all nodes +type ClusterResourceUsageResponse struct { + Total ResourceUsage `json:"total"` + Nodes []NodeResourceUsage `json:"nodes"` + Errors []string `json:"errors,omitempty"` +} diff --git a/internal/proxmox/vms.go b/internal/proxmox/vms.go new file mode 100644 index 0000000..fd603fb --- /dev/null +++ b/internal/proxmox/vms.go @@ -0,0 +1,375 @@ +package proxmox + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/cpp-cyber/proclone/internal/tools" +) + +func (c *Client) GetVMs() ([]VirtualResource, error) { + vms, err := c.GetClusterResources("type=vm") + if err != nil { + return nil, err + } + return vms, nil +} + +func (c *Client) StartVM(node string, vmID int) error { + if err := c.ValidateVMID(vmID); err != nil { + return err + } + + req := tools.ProxmoxAPIRequest{ + Method: "POST", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/status/start", node, vmID), + } + + _, err := c.RequestHelper.MakeRequest(req) + if err != nil { + return fmt.Errorf("failed to start VM: %w", err) + } + + return nil +} + +func (c *Client) ShutdownVM(node string, vmID int) error { + if err := c.ValidateVMID(vmID); err != nil { + return err + } + + req := tools.ProxmoxAPIRequest{ + Method: "POST", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/status/shutdown", node, vmID), + } + + _, err := c.RequestHelper.MakeRequest(req) + if err != nil { + return fmt.Errorf("failed to shutdown VM: %w", err) + } + + return nil +} + +func (c *Client) RebootVM(node string, vmID int) error { + if err := c.ValidateVMID(vmID); err != nil { + return err + } + + req := tools.ProxmoxAPIRequest{ + Method: "POST", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/status/reboot", node, vmID), + } + + _, err := c.RequestHelper.MakeRequest(req) + if err != nil { + return fmt.Errorf("failed to reboot VM: %w", err) + } + + return nil +} + +func (c *Client) StopVM(node string, vmID int) error { + if err := c.ValidateVMID(vmID); err != nil { + return err + } + + req := tools.ProxmoxAPIRequest{ + Method: "POST", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/status/stop", node, vmID), + } + + _, err := c.RequestHelper.MakeRequest(req) + if err != nil { + return fmt.Errorf("failed to stop VM: %w", err) + } + + return nil +} + +// DeleteVM deletes a VM completely +func (c *Client) DeleteVM(node string, vmID int) error { + if err := c.ValidateVMID(vmID); err != nil { + return err + } + + req := tools.ProxmoxAPIRequest{ + Method: "DELETE", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d", node, vmID), + } + + _, err := c.RequestHelper.MakeRequest(req) + if err != nil { + return fmt.Errorf("failed to delete VM: %w", err) + } + + return nil +} + +func (c *Client) ConvertVMToTemplate(node string, vmID int) error { + if err := c.ValidateVMID(vmID); err != nil { + return err + } + + req := tools.ProxmoxAPIRequest{ + Method: "POST", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/template", node, vmID), + } + + _, err := c.RequestHelper.MakeRequest(req) + if err != nil { + if !strings.Contains(err.Error(), "you can't convert a template to a template") { + return fmt.Errorf("failed to convert VM to template: %w", err) + } + } + + return nil +} + +// CloneVM clones a VM to a new pool +func (c *Client) CloneVM(sourceVM VM, newPoolName string) (*VM, error) { + // Get next available VMID + req := tools.ProxmoxAPIRequest{ + Method: "GET", + Endpoint: "/cluster/nextid", + } + + var nextIDStr string + if err := c.RequestHelper.MakeRequestAndUnmarshal(req, &nextIDStr); err != nil { + return nil, fmt.Errorf("failed to get next VMID: %w", err) + } + + newVMID, err := strconv.Atoi(nextIDStr) + if err != nil { + return nil, fmt.Errorf("invalid VMID received: %w", err) + } + + // Find best node for cloning + bestNode, err := c.FindBestNode() + if err != nil { + return nil, fmt.Errorf("failed to find best node: %w", err) + } + + // Clone VM + cloneBody := map[string]any{ + "newid": newVMID, + "name": sourceVM.Name, + "pool": newPoolName, + "full": 0, // Linked clone + "target": bestNode, + } + + cloneReq := tools.ProxmoxAPIRequest{ + Method: "POST", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/clone", sourceVM.Node, sourceVM.VMID), + RequestBody: cloneBody, + } + + _, err = c.RequestHelper.MakeRequest(cloneReq) + if err != nil { + return nil, fmt.Errorf("failed to initiate VM clone: %w", err) + } + + // Wait for clone to complete + newVM := &VM{ + Node: bestNode, + VMID: newVMID, + } + + err = c.WaitForCloneCompletion(newVM, 5*time.Minute) // CLONE_TIMEOUT + if err != nil { + return nil, fmt.Errorf("clone operation failed: %w", err) + } + + return newVM, nil +} + +// WaitForCloneCompletion waits for a clone operation to complete +func (c *Client) WaitForCloneCompletion(vm *VM, timeout time.Duration) error { + start := time.Now() + backoff := time.Second + maxBackoff := 30 * time.Second + + for time.Since(start) < timeout { + // Check VM status + req := tools.ProxmoxAPIRequest{ + Method: "GET", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/status/current", vm.Node, vm.VMID), + } + + data, err := c.RequestHelper.MakeRequest(req) + if err != nil { + time.Sleep(backoff) + backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) + continue + } + + var statusResponse struct { + Status string `json:"status"` + } + + if err := json.Unmarshal(data, &statusResponse); err != nil { + time.Sleep(backoff) + backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) + continue + } + + if statusResponse.Status == "running" || statusResponse.Status == "stopped" { + // Check if VM is locked (clone in progress) + configReq := tools.ProxmoxAPIRequest{ + Method: "GET", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/config", vm.Node, vm.VMID), + } + + configData, err := c.RequestHelper.MakeRequest(configReq) + if err != nil { + time.Sleep(backoff) + backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) + continue + } + + var configResp ConfigResponse + if err := json.Unmarshal(configData, &configResp); err != nil { + time.Sleep(backoff) + backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) + continue + } + + if configResp.Lock == "" { + return nil // Clone is complete and VM is not locked + } + } + + time.Sleep(backoff) + backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) + } + + return fmt.Errorf("clone operation timed out after %v", timeout) +} + +// WaitForDiskAvailability waits for VM disks to become available +func (c *Client) WaitForDiskAvailability(node string, vmid int, maxWait time.Duration) error { + start := time.Now() + + for time.Since(start) < maxWait { + time.Sleep(2 * time.Second) + + req := tools.ProxmoxAPIRequest{ + Method: "GET", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/config", node, vmid), + } + + data, err := c.RequestHelper.MakeRequest(req) + if err != nil { + continue + } + + var configResp ConfigResponse + if err := json.Unmarshal(data, &configResp); err != nil { + continue + } + + if configResp.HardDisk != "" { + return nil + } + } + + return fmt.Errorf("timeout waiting for VM disks to become available") +} + +// WaitForRunning waits for a VM to be in running state +func (c *Client) WaitForRunning(vm VM) error { + timeout := 2 * time.Minute + start := time.Now() + + for time.Since(start) < timeout { + req := tools.ProxmoxAPIRequest{ + Method: "GET", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/status/current", vm.Node, vm.VMID), + } + + data, err := c.RequestHelper.MakeRequest(req) + if err != nil { + time.Sleep(5 * time.Second) + continue + } + + var statusResponse struct { + Status string `json:"status"` + } + + if err := json.Unmarshal(data, &statusResponse); err != nil { + time.Sleep(5 * time.Second) + continue + } + + if statusResponse.Status == "running" { + return nil + } + + time.Sleep(5 * time.Second) + } + + return fmt.Errorf("timeout waiting for VM to be running") +} + +// WaitForStopped waits for a VM to be in stopped state +func (c *Client) WaitForStopped(vm VM) error { + timeout := 2 * time.Minute + start := time.Now() + + for time.Since(start) < timeout { + req := tools.ProxmoxAPIRequest{ + Method: "GET", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/status/current", vm.Node, vm.VMID), + } + + data, err := c.RequestHelper.MakeRequest(req) + if err != nil { + time.Sleep(5 * time.Second) + continue + } + + var statusResponse struct { + Status string `json:"status"` + } + + if err := json.Unmarshal(data, &statusResponse); err != nil { + time.Sleep(5 * time.Second) + continue + } + + if statusResponse.Status == "stopped" { + return nil + } + + time.Sleep(5 * time.Second) + } + + return fmt.Errorf("timeout waiting for VM to be stopped") +} + +func (c *Client) ValidateVMID(vmID int) error { + // Get VMs + vms, err := c.GetClusterResources("type=vm") + if err != nil { + return err + } + + // Check if VMID exists + for _, vm := range vms { + if vm.VmId == vmID { + // Check if VM is in critical pool + if vm.ResourcePool == c.Config.CriticalPool { + return fmt.Errorf("VMID %d is in critical pool", vmID) + } + return nil + } + } + + return fmt.Errorf("VMID %d not found", vmID) +} diff --git a/internal/tools/database.go b/internal/tools/database.go new file mode 100644 index 0000000..940b97a --- /dev/null +++ b/internal/tools/database.go @@ -0,0 +1,290 @@ +package tools + +import ( + "database/sql" + "fmt" + "log" + "strings" + "sync" + "time" + + "github.com/kelseyhightower/envconfig" +) + +// DatabaseConfig holds database configuration +type DatabaseConfig struct { + Host string `envconfig:"DB_HOST" required:"true"` + Port string `envconfig:"DB_PORT" required:"true"` + User string `envconfig:"DB_USER" required:"true"` + Password string `envconfig:"DB_PASSWORD" required:"true"` + Name string `envconfig:"DB_NAME" required:"true"` +} + +// DBClient wraps database connection and provides reconnection capabilities +type DBClient struct { + db *sql.DB + config *DatabaseConfig + mutex sync.RWMutex + connected bool +} + +// NewDBClient creates a new database client with reconnection capabilities +func NewDBClient() (*DBClient, error) { + var dbConfig DatabaseConfig + if err := envconfig.Process("", &dbConfig); err != nil { + return nil, fmt.Errorf("failed to process database configuration: %w", err) + } + + client := &DBClient{ + config: &dbConfig, + } + + if err := client.Connect(); err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + return client, nil +} + +// Connect establishes connection to the database +func (c *DBClient) Connect() error { + c.mutex.Lock() + defer c.mutex.Unlock() + + // Build the Data Source Name (DSN) + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", + c.config.User, c.config.Password, c.config.Host, c.config.Port, c.config.Name) + + // Open database connection + db, err := sql.Open("mysql", dsn) + if err != nil { + c.connected = false + return fmt.Errorf("failed to open database connection: %w", err) + } + + // Test the connection + err = db.Ping() + if err != nil { + db.Close() + c.connected = false + return fmt.Errorf("failed to ping database: %w", err) + } + + // Configure connection pool settings + db.SetMaxOpenConns(25) // Maximum number of open connections + db.SetMaxIdleConns(25) // Maximum number of idle connections + db.SetConnMaxLifetime(0) // Maximum connection lifetime (0 = unlimited) + + c.db = db + c.connected = true + log.Printf("Successfully connected to MariaDB database: %s", c.config.Name) + return nil +} + +// Disconnect closes the database connection +func (c *DBClient) Disconnect() error { + c.mutex.Lock() + defer c.mutex.Unlock() + + if c.db == nil { + c.connected = false + return nil + } + + err := c.db.Close() + c.connected = false + return err +} + +// isConnectionError checks if an error indicates a connection problem +func (c *DBClient) isConnectionError(err error) bool { + if err == nil { + return false + } + + errorMsg := strings.ToLower(err.Error()) + return strings.Contains(errorMsg, "connection") || + strings.Contains(errorMsg, "broken pipe") || + strings.Contains(errorMsg, "network") || + strings.Contains(errorMsg, "timeout") || + strings.Contains(errorMsg, "eof") || + strings.Contains(errorMsg, "invalid connection") || + strings.Contains(errorMsg, "connection refused") || + strings.Contains(errorMsg, "server has gone away") +} + +// reconnect attempts to reconnect to the database +func (c *DBClient) reconnect() error { + c.mutex.Lock() + defer c.mutex.Unlock() + + // Close existing connection if any + if c.db != nil { + c.db.Close() + } + c.connected = false + + // Wait a moment before retrying + time.Sleep(100 * time.Millisecond) + + // Build the Data Source Name (DSN) + dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true", + c.config.User, c.config.Password, c.config.Host, c.config.Port, c.config.Name) + + // Open database connection + db, err := sql.Open("mysql", dsn) + if err != nil { + return fmt.Errorf("failed to reconnect to database: %w", err) + } + + // Test the connection + err = db.Ping() + if err != nil { + db.Close() + return fmt.Errorf("failed to ping database after reconnection: %w", err) + } + + // Configure connection pool settings + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(25) + db.SetConnMaxLifetime(0) + + c.db = db + c.connected = true + return nil +} + +// executeWithRetry executes a database operation with automatic retry on connection errors +func (c *DBClient) executeWithRetry(operation func(*sql.DB) error, maxRetries int) error { + var lastErr error + + for attempt := 0; attempt <= maxRetries; attempt++ { + c.mutex.RLock() + if !c.connected && c.db != nil { + c.mutex.RUnlock() + if reconnectErr := c.reconnect(); reconnectErr != nil { + lastErr = reconnectErr + continue + } + c.mutex.RLock() + } + db := c.db + c.mutex.RUnlock() + + if db == nil { + lastErr = fmt.Errorf("no database connection available") + if attempt < maxRetries { + if reconnectErr := c.reconnect(); reconnectErr != nil { + lastErr = reconnectErr + } + } + continue + } + + err := operation(db) + if err == nil { + return nil + } + + lastErr = err + + // If it's not a connection error, don't retry + if !c.isConnectionError(err) { + return err + } + + // Mark as disconnected and try to reconnect + c.mutex.Lock() + c.connected = false + c.mutex.Unlock() + + // Don't reconnect on the last attempt + if attempt < maxRetries { + if reconnectErr := c.reconnect(); reconnectErr != nil { + lastErr = reconnectErr + } + } + } + + return fmt.Errorf("database operation failed after %d retries, last error: %v", maxRetries+1, lastErr) +} + +// DB returns the underlying sql.DB with retry mechanism +func (c *DBClient) DB() *sql.DB { + c.mutex.RLock() + defer c.mutex.RUnlock() + return c.db +} + +// Exec executes a query with retry mechanism +func (c *DBClient) Exec(query string, args ...interface{}) (sql.Result, error) { + var result sql.Result + err := c.executeWithRetry(func(db *sql.DB) error { + res, err := db.Exec(query, args...) + if err != nil { + return err + } + result = res + return nil + }, 2) + return result, err +} + +// Query executes a query that returns rows with retry mechanism +func (c *DBClient) Query(query string, args ...interface{}) (*sql.Rows, error) { + var rows *sql.Rows + err := c.executeWithRetry(func(db *sql.DB) error { + res, err := db.Query(query, args...) + if err != nil { + return err + } + rows = res + return nil + }, 2) + return rows, err +} + +// QueryRow executes a query that returns at most one row with retry mechanism +func (c *DBClient) QueryRow(query string, args ...interface{}) *sql.Row { + c.mutex.RLock() + db := c.db + c.mutex.RUnlock() + + if db == nil { + // Return a row with an error that will be caught by Scan() + return &sql.Row{} + } + + return db.QueryRow(query, args...) +} + +// Ping checks if the database connection is alive +func (c *DBClient) Ping() error { + return c.executeWithRetry(func(db *sql.DB) error { + return db.Ping() + }, 2) +} + +// IsConnected returns the current connection status +func (c *DBClient) IsConnected() bool { + c.mutex.RLock() + defer c.mutex.RUnlock() + return c.connected +} + +// HealthCheck performs a simple query to verify the connection is working +func (c *DBClient) HealthCheck() error { + return c.executeWithRetry(func(db *sql.DB) error { + var result int + return db.QueryRow("SELECT 1").Scan(&result) + }, 2) +} + +// Connect to the MariaDB database (legacy function for backward compatibility) +func InitDB() (*sql.DB, error) { + client, err := NewDBClient() + if err != nil { + return nil, err + } + return client.DB(), nil +} diff --git a/internal/tools/requests.go b/internal/tools/requests.go new file mode 100644 index 0000000..9db67ba --- /dev/null +++ b/internal/tools/requests.go @@ -0,0 +1,116 @@ +package tools + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// ProxmoxAPIRequest represents a request to the Proxmox API +type ProxmoxAPIRequest struct { + Method string // GET, POST, PUT, DELETE + Endpoint string // The API endpoint (e.g., "/nodes", "/nodes/node1/status") + RequestBody any // Optional request body for POST/PUT requests +} + +// ProxmoxAPIResponse represents the generic Proxmox API response structure +type ProxmoxAPIResponse struct { + Data json.RawMessage `json:"data"` +} + +// ProxmoxRequestHelper provides a helper for making HTTP requests to Proxmox API +type ProxmoxRequestHelper struct { + BaseURL string + APIToken string + HTTPClient *http.Client +} + +// NewProxmoxRequestHelper creates a new Proxmox request helper +func NewProxmoxRequestHelper(baseURL, apiToken string, httpClient *http.Client) *ProxmoxRequestHelper { + return &ProxmoxRequestHelper{ + BaseURL: baseURL, + APIToken: apiToken, + HTTPClient: httpClient, + } +} + +// MakeRequest performs an HTTP request to the Proxmox API and returns the raw response data +func (prh *ProxmoxRequestHelper) MakeRequest(req ProxmoxAPIRequest) (json.RawMessage, error) { + var reqBody io.Reader + + // Prepare request body for POST/PUT requests + if req.Method == "POST" || req.Method == "PUT" { + var bodyData any + if req.RequestBody != nil { + bodyData = req.RequestBody + } else { + bodyData = map[string]any{} + } + + jsonData, err := json.Marshal(bodyData) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + reqBody = bytes.NewBuffer(jsonData) + } + + // Create the full URL + url := prh.BaseURL + req.Endpoint + + // Create HTTP request + httpReq, err := http.NewRequest(req.Method, url, reqBody) + if err != nil { + return nil, fmt.Errorf("failed to create %s request to %s: %w", req.Method, req.Endpoint, err) + } + + // Set headers + httpReq.Header.Add("Authorization", "PVEAPIToken="+prh.APIToken) + httpReq.Header.Add("Content-Type", "application/json") + + // Execute the request + resp, err := prh.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to execute %s request to %s: %w", req.Method, req.Endpoint, err) + } + defer resp.Body.Close() + + // Read response body first for better error reporting + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body from %s %s: %w", req.Method, req.Endpoint, err) + } + + // Check response status + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("proxmox API returned status %d for %s %s, response: %s", resp.StatusCode, req.Method, req.Endpoint, string(bodyBytes)) + } + + // Don't try to parse into ProxmoxAPIResponse structure for DELETE operations + if req.Method == "DELETE" { + return json.RawMessage("nil"), nil + } + + // Decode the API response for other methods + var apiResponse ProxmoxAPIResponse + if err := json.Unmarshal(bodyBytes, &apiResponse); err != nil { + return nil, fmt.Errorf("failed to decode response from %s %s: %w", req.Method, req.Endpoint, err) + } + + return apiResponse.Data, nil +} + +// MakeRequestAndUnmarshal performs an HTTP request and unmarshals the response into the provided interface +func (prh *ProxmoxRequestHelper) MakeRequestAndUnmarshal(req ProxmoxAPIRequest, target any) error { + data, err := prh.MakeRequest(req) + if err != nil { + return err + } + + if err := json.Unmarshal(data, target); err != nil { + return fmt.Errorf("failed to unmarshal response data from %s %s: %w", req.Method, req.Endpoint, err) + } + + return nil +} diff --git a/internal/tools/telemetry.go b/internal/tools/telemetry.go new file mode 100644 index 0000000..0aaa040 --- /dev/null +++ b/internal/tools/telemetry.go @@ -0,0 +1,3 @@ +package tools + +// TODO: Implement telemetry logging diff --git a/main.go b/main.go deleted file mode 100644 index 3f65ad1..0000000 --- a/main.go +++ /dev/null @@ -1,172 +0,0 @@ -package main - -import ( - "context" - "log" - "os" - "strings" - - "github.com/cpp-cyber/proclone/auth" - "github.com/cpp-cyber/proclone/database" - "github.com/cpp-cyber/proclone/proxmox" - "github.com/cpp-cyber/proclone/proxmox/cloning" - "github.com/cpp-cyber/proclone/proxmox/images" - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/cookie" - "github.com/gin-gonic/gin" - "github.com/joho/godotenv" - - "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" - "google.golang.org/grpc/credentials" - - "go.opentelemetry.io/otel/sdk/resource" - sdktrace "go.opentelemetry.io/otel/sdk/trace" -) - -var ( - serviceName = os.Getenv("SIGNOZ_SERVICE_NAME") - collectorURL = os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") - insecure = os.Getenv("SIGNOZ_INSECURE_MODE") -) - -// init the environment -func init() { - _ = godotenv.Load() -} - -func initTracer() func(context.Context) error { - - var secureOption otlptracegrpc.Option - - if strings.ToLower(insecure) == "false" || insecure == "0" || strings.ToLower(insecure) == "f" { - secureOption = otlptracegrpc.WithTLSCredentials(credentials.NewClientTLSFromCert(nil, "")) - } else { - secureOption = otlptracegrpc.WithInsecure() - } - - exporter, err := otlptrace.New( - context.Background(), - otlptracegrpc.NewClient( - secureOption, - otlptracegrpc.WithEndpoint(collectorURL), - ), - ) - - if err != nil { - log.Fatalf("Failed to create exporter: %v", err) - } - resources, err := resource.New( - context.Background(), - resource.WithAttributes( - attribute.String("service.name", serviceName), - attribute.String("library.language", "go"), - ), - ) - if err != nil { - log.Fatalf("Could not set resources: %v", err) - } - - otel.SetTracerProvider( - sdktrace.NewTracerProvider( - sdktrace.WithSampler(sdktrace.AlwaysSample()), - sdktrace.WithBatcher(exporter), - sdktrace.WithResource(resources), - ), - ) - return exporter.Shutdown -} - -func main() { - // Handle Signoz tracing - cleanup := initTracer() - defer cleanup(context.Background()) - - // Ensure upload directory exists - if err := os.MkdirAll(images.UploadDir, os.ModePerm); err != nil { - log.Fatalf("failed to create upload dir: %v", err) - } - - // Initialize database connection - if err := database.InitializeDB(); err != nil { - log.Fatalf("Failed to initialize database: %v", err) - } - defer database.CloseDB() - - r := gin.Default() - r.Use(otelgin.Middleware(serviceName)) - r.MaxMultipartMemory = 10 << 20 // 10 MiB - - // store session cookie - // **IN PROD USE REAL SECURE KEY** - store := cookie.NewStore([]byte(os.Getenv("SECRET_KEY"))) - - // further cookie security - store.Options(sessions.Options{ - MaxAge: 3600, - HttpOnly: true, - Secure: true, - }) - - r.Use(sessions.Sessions("mysession", store)) - - // export public route - r.POST("/api/login", auth.LoginHandler) - - // authenticated routes - user := r.Group("/api") - user.Use(auth.AuthRequired) - user.GET("/profile", auth.ProfileHandler) - user.GET("/session", auth.SessionHandler) - user.POST("/logout", auth.LogoutHandler) - - // Proxmox User Template endpoints - user.GET("/proxmox/templates", cloning.GetAvailableTemplates) - user.GET("/proxmox/templates/images/:filename", images.HandleGetFile) - user.POST("/proxmox/templates/clone", cloning.CloneTemplateToPod) - user.POST("/proxmox/pods/delete", cloning.DeletePod) - - // Proxmox Pod endpoints - user.GET("/proxmox/pods", cloning.GetUserPods) - - // admin routes - admin := user.Group("/admin") - admin.Use(auth.AdminRequired) - - // Proxmox VM endpoints - admin.GET("/proxmox/virtualmachines", proxmox.GetVirtualMachines) - admin.POST("/proxmox/virtualmachines/shutdown", proxmox.PowerOffVirtualMachine) - admin.POST("/proxmox/virtualmachines/start", proxmox.PowerOnVirtualMachine) - - // Proxmox resource monitoring endpoint - admin.GET("/proxmox/resources", proxmox.GetProxmoxResources) - - // Proxmox Admin Pod endpoints - admin.GET("/proxmox/pods/all", cloning.GetPods) - - // Proxmox Admin Template endpoints - admin.POST("/proxmox/templates/publish", cloning.PublishTemplate) - admin.POST("/proxmox/templates/update", cloning.UpdateTemplate) - admin.POST("/proxmox/templates/delete", cloning.DeleteTemplate) - admin.GET("/proxmox/templates", cloning.GetAllTemplates) - admin.GET("/proxmox/templates/unpublished", cloning.GetUnpublishedTemplates) - admin.POST("/proxmox/templates/toggle", cloning.ToggleTemplateVisibility) - admin.POST("/proxmox/templates/image/upload", images.HandleUpload) - admin.POST("/proxmox/templates/clone/bulk", cloning.BulkCloneTemplate) - - // Active Directory User endpoints - admin.GET("/users", auth.GetUsers) - - // get port to run server on via. PC_PORT env variable - port := os.Getenv("PC_PORT") - if port == "" { - port = "8080" - } - - if err := r.Run(":" + port); err != nil { - log.Fatalf("failed to run server: %v", err) - } -} diff --git a/proxmox/cloning/cloning.go b/proxmox/cloning/cloning.go deleted file mode 100644 index ee00abf..0000000 --- a/proxmox/cloning/cloning.go +++ /dev/null @@ -1,636 +0,0 @@ -package cloning - -import ( - "context" - "crypto/tls" - "encoding/json" - "fmt" - "log" - "math" - "net/http" - "os" - "sort" - "strconv" - "time" - - "github.com/cpp-cyber/proclone/auth" - "github.com/cpp-cyber/proclone/database" - "github.com/cpp-cyber/proclone/proxmox" - "github.com/cpp-cyber/proclone/proxmox/cloning/locking" - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - "golang.org/x/sync/errgroup" -) - -const KAMINO_TEMP_POOL string = "0100_Kamino_Templates" -const ROUTER_NAME string = "1-1NAT-pfsense" - -var STORAGE_ID string = os.Getenv("STORAGE_ID") - -type CloneRequest struct { - TemplateName string `json:"template_name" binding:"required"` -} - -type NewPoolResponse struct { - Success int `json:"success,omitempty"` -} - -type CloneResponse struct { - Success int `json:"success"` - Errors []string `json:"errors,omitempty"` -} - -type NextIDResponse struct { - Data string `json:"data"` -} - -type StorageResponse struct { - Data []Disk `json:"data"` -} -type Disk struct { - Id string `json:"volid"` - Size int64 `json:"size,omitempty"` - Used int64 `json:"used,omitempty"` -} - -/* - * ===== CLONE VMS FROM TEMPLATE POOL TO POD POOL ===== - */ -func CloneTemplateToPod(c *gin.Context) { - session := sessions.Default(c) - username := session.Get("username") - - // Make sure user is authenticated - isAuth, _ := auth.IsAuthenticated(c) - if !isAuth { - log.Printf("Unauthorized access attempt") - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only authenticated users can access pod data", - }) - return - } - - // Parse request body - var req CloneRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Invalid request format", - "details": err.Error(), - }) - return - } - - usernameStr, ok := username.(string) - if !ok { - c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid session username"}) - return - } - - err := ProxmoxCloneTemplate(req.TemplateName, usernameStr) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Pod deployed successfully!"}) -} - -// assign a user to be a VM user for a resource pool -func setPoolPermission(config *proxmox.ProxmoxConfig, pool string, user string) error { - // define json data holding new pool name - jsonString := fmt.Sprintf("{\"path\":\"/pool/%s\", \"users\":\"%s@SDC\", \"roles\":\"PVEVMUser,PVEPoolUser\", \"propagate\": true }", pool, user) - jsonData := []byte(jsonString) - - statusCode, _, err := proxmox.MakeRequest(config, "api2/json/access/acl", "PUT", jsonData, nil) - if err != nil { - return err - } - if statusCode < 200 || statusCode >= 300 { - return fmt.Errorf("failed to assign pool permissions, status code: %d", statusCode) - } - return nil -} - -func cleanupClone(config *proxmox.ProxmoxConfig, nodeName string, vmid int) error { - /* - * ----- IF RUNNING, WAIT FOR VM TO BE TURNED OFF ----- - */ - // assign values to VM struct - var vm proxmox.VM - vm.Node = nodeName - vm.VMID = vmid - - // make request to turn off VM - _, err := proxmox.StopRequest(config, vm) - - if err != nil { - // will error if the VM is alr off so just ignore - } - - // Wait for VM to be "stopped" before continuing - err = proxmox.WaitForStopped(config, vm) - if err != nil { - return fmt.Errorf("stopping vm failed: %v", err) - } - - /* - * ----- HANDLE DELETING VM ----- - */ - - // Prepare request path - path := fmt.Sprintf("api2/json/nodes/%s/qemu/%d", nodeName, vmid) - - statusCode, body, err := proxmox.MakeRequest(config, path, "DELETE", nil, nil) - if err != nil { - return fmt.Errorf("vm delete request failed: %v", err) - } - - if statusCode != http.StatusOK { - return fmt.Errorf("failed to cleanup VM: %s", string(body)) - } - - return nil -} - -// !! Need to refactor to use MakeRequest, idk why I wrote it like this :( -func cloneVM(config *proxmox.ProxmoxConfig, vm proxmox.VirtualResource, newPool string) (newVm *proxmox.VM, err error) { - // Create a single HTTP client for all requests - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: !config.VerifySSL}, - } - client := &http.Client{Transport: tr} - - bestNode, newVMID, err := makeCloneRequest(config, vm, newPool) - if err != nil { - return nil, err - } - - statusURL := fmt.Sprintf("https://%s:%s/api2/json/nodes/%s/qemu/%d/status/current", - config.Host, config.Port, bestNode, newVMID) - - backoff := time.Second - maxBackoff := 30 * time.Second - timeout := 5 * time.Minute - startTime := time.Now() - - for { - if time.Since(startTime) > timeout { - if err := cleanupClone(config, vm.NodeName, newVMID); err != nil { - return nil, fmt.Errorf("clone timed out and cleanup failed: %v", err) - } - return nil, fmt.Errorf("clone operation timed out after %v", timeout) - } - - req, err := http.NewRequest("GET", statusURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create status check request: %v", err) - } - req.Header.Set("Authorization", fmt.Sprintf("PVEAPIToken=%s", config.APIToken)) - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to check clone status: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusOK { - // Verify the VM is actually cloned - var statusResponse struct { - Data struct { - Status string `json:"status"` - } `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&statusResponse); err != nil { - return nil, fmt.Errorf("failed to decode status response: %v", err) - } - if statusResponse.Data.Status == "running" || statusResponse.Data.Status == "stopped" { - lockURL := fmt.Sprintf("https://%s:%s/api2/json/nodes/%s/qemu/%d/config", - config.Host, config.Port, bestNode, newVMID) - lockReq, err := http.NewRequest("GET", lockURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create lock check request: %v", err) - } - lockReq.Header.Set("Authorization", fmt.Sprintf("PVEAPIToken=%s", config.APIToken)) - - lockResp, err := client.Do(lockReq) - if err != nil { - return nil, fmt.Errorf("failed to check lock status: %v", err) - } - defer lockResp.Body.Close() - - var configResp struct { - Data struct { - Lock string `json:"lock"` - } `json:"data"` - } - if err := json.NewDecoder(lockResp.Body).Decode(&configResp); err != nil { - return nil, fmt.Errorf("failed to decode lock status: %v", err) - } - if configResp.Data.Lock == "" { - var newVM proxmox.VM - newVM.VMID = newVMID - - // once node optimization is done must be replaced with new node !!! - newVM.Node = bestNode - - return &newVM, nil // Clone is complete and VM is not locked - } - } - } - - time.Sleep(backoff) - backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) - } -} - -func makeCloneRequest(config *proxmox.ProxmoxConfig, vm proxmox.VirtualResource, newPool string) (node string, vmid int, err error) { - - // lock VMID to prevent race conditions - - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - - lock, err := locking.TryAcquireLockWithBackoff(ctx, "lock:vmid", 30*time.Second, 5, 500*time.Millisecond) - if err != nil { - return "", 0, fmt.Errorf("failed to acquire vmid lock: %v", err) - } - defer lock.Release(ctx) - - // Get next available VMID - statusCode, body, err := proxmox.MakeRequest(config, "api2/json/cluster/nextid", "GET", nil, nil) - if err != nil { - return "", 0, fmt.Errorf("failed to get next VMID: %v", err) - } - - if statusCode != http.StatusOK { - return "", 0, fmt.Errorf("failed to get next VMID: %s", string(body)) - } - - var nextID NextIDResponse - if err := json.Unmarshal(body, &nextID); err != nil { - return "", 0, fmt.Errorf("failed to decode VMID response: %v", err) - } - - newVMID, err := strconv.Atoi(nextID.Data) - if err != nil { - return "", 0, fmt.Errorf("invalid VMID received: %v", err) - } - - // find optimal node - bestNode, err := findBestNode(config) - if err != nil { - return "", 0, fmt.Errorf("failed to calculate optimal compute node: %v", err) - } - - // clone VM - cloneBody := map[string]interface{}{ - "newid": newVMID, - "name": fmt.Sprintf("%s-clone", vm.Name), - "pool": newPool, - "target": bestNode, - } - - jsonBody, err := json.Marshal(cloneBody) - if err != nil { - return "", 0, fmt.Errorf("failed to create request body: %v", err) - } - - clonePath := fmt.Sprintf("api2/json/nodes/%s/qemu/%d/clone", vm.NodeName, vm.VmId) - statusCode, body, err = proxmox.MakeRequest(config, clonePath, "POST", jsonBody, nil) - if err != nil { - return "", 0, fmt.Errorf("failed to clone VM: %v", err) - } - if statusCode != http.StatusOK { - return "", 0, fmt.Errorf("failed to clone VM: %s", string(body)) - } - - return bestNode, newVMID, nil -} - -// finds lowest available POD ID between 1001 - 1255 -func nextPodID(config *proxmox.ProxmoxConfig) (string, int, error) { - podResponse, err := getAdminPodResponse(config) - if err != nil { - return "", 0, fmt.Errorf("failed to fetch pod list from proxmox cluster: %v", err) - } - - pods := podResponse.Pods - var ids []int - - // for each pod name, get id from name and append to int array - for _, pod := range pods { - id, _ := strconv.Atoi(pod.Name[:4]) - ids = append(ids, id) - } - - sort.Ints(ids) - - var nextId int - var gapFound bool = false - - // find first id available starting from 1001 - for i := 1001; i <= 1000+len(ids); i++ { - nextId = i - if ids[i-1001] != i { - gapFound = true - break - } - } - - if !gapFound { - nextId = 1001 + len(ids) - } - - // if no ids available between 0 - 255 return error - if nextId == 1256 { - err = fmt.Errorf("no pod ids available") - return "", 0, err - } - - return strconv.Itoa(nextId), nextId - 1000, nil -} - -// ProxmoxCloneTemplate performs the full cloning flow for a given template and user. -func ProxmoxCloneTemplate(templateName, username string) error { - var errors []string - - templatePool := "kamino_template_" + templateName - - // Load Proxmox configuration - config, err := proxmox.LoadProxmoxConfig() - if err != nil { - return fmt.Errorf("failed to load Proxmox configuration: %v", err) - } - - // Get all virtual resources - apiResp, err := proxmox.GetVirtualResources(config) - if err != nil { - return fmt.Errorf("failed to fetch virtual resources: %v", err) - } - - // Find VMs in template pool - var templateVMs []proxmox.VirtualResource - var routerTemplate proxmox.VirtualResource - for _, r := range *apiResp { - if r.Type == "qemu" && r.ResourcePool == templatePool { - templateVMs = append(templateVMs, r) - } - if r.Name == ROUTER_NAME && r.ResourcePool == KAMINO_TEMP_POOL { - routerTemplate = r - } - } - - if len(templateVMs) == 0 { - return fmt.Errorf("no VMs found in template pool: %s", templatePool) - } - - // get next available pod ID - NewPodID, newPodNumber, err := nextPodID(config) - if err != nil { - return fmt.Errorf("failed to get a pod ID: %v", err) - } - - // create new pod resource pool with ID - NewPodPool, err := createNewPodPool(username, NewPodID, templateName, config) - if err != nil { - return fmt.Errorf("failed to create new pod resource pool: %v", err) - } - - // Clone 1:1 NAT router from template - newRouter, err := cloneVM(config, routerTemplate, NewPodPool) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to clone router VM: %v", err)) - } - - // Clone each VM to new pool - for _, vm := range templateVMs { - _, err := cloneVM(config, vm, NewPodPool) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to clone VM %s: %v", vm.Name, err)) - } - } - - // Check if vnet exists, if not, create it - vnetExists, err := checkForVnet(config, newPodNumber) - var vnetName string - if err != nil { - errors = append(errors, fmt.Sprintf("failed to check current vnets: %v", err)) - } - - if !vnetExists { - vnetName, err = addVNetObject(config, newPodNumber) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to create new vnet object: %v", err)) - } - - err = applySDNChanges(config) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to apply new sdn changes: %v", err)) - } - } else { - vnetName = fmt.Sprintf("kamino%d", newPodNumber) - } - - // Configure VNet of all VMs - err = setPodVnet(config, NewPodPool, vnetName) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to update pod vnet: %v", err)) - } - - // Turn on router - if newRouter != nil { - err = waitForDiskAvailability(config, newRouter.Node, newRouter.VMID, 120*time.Second) - if err != nil { - errors = append(errors, fmt.Sprintf("router disk unavailable: %v", err)) - } - _, err = proxmox.PowerOnRequest(config, *newRouter) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to start router VM: %v", err)) - } - - // Wait for router to be running - err = proxmox.WaitForRunning(config, *newRouter) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to start router VM: %v", err)) - } else { - err = configurePodRouter(config, newPodNumber, newRouter.Node, newRouter.VMID) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to configure pod router: %v", err)) - } - } - } - - // automatically give user who cloned the pod access - err = setPoolPermission(config, NewPodPool, username) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to update pool permissions for %s: %v", username, err)) - } - - if len(errors) > 0 { - // if an error has occured, count # of successfully cloned VMs - var clonedVMs []proxmox.VirtualResource - for _, r := range *apiResp { - if r.Type == "qemu" && r.ResourcePool == NewPodPool { - clonedVMs = append(clonedVMs, r) - } - } - - // if there are no cloned VMs in the resource pool, clean up the resource pool - if len(clonedVMs) == 0 { - _ = cleanupFailedPodPool(config, NewPodPool) - } - - return fmt.Errorf("failed to clone one or more VMs: %v", errors) - } - - database.AddDeployment(templateName) - return nil -} - -func cleanupFailedPodPool(config *proxmox.ProxmoxConfig, poolName string) error { - poolDeletePath := fmt.Sprintf("api2/json/pools/%s", poolName) - - statusCode, body, err := proxmox.MakeRequest(config, poolDeletePath, "DELETE", nil, nil) - if err != nil { - return fmt.Errorf("pool delete request failed: %v", err) - } - - if statusCode != http.StatusOK { - return fmt.Errorf("pool delete request failed: %s", string(body)) - } - - return nil -} - -func createNewPodPool(username string, newPodID string, templateName string, config *proxmox.ProxmoxConfig) (string, error) { - newPoolName := newPodID + "_" + templateName + "_" + username - - poolPath := "api2/extjs/pools" - - // define json data holding new pool name - jsonString := fmt.Sprintf("{\"poolid\":\"%s\"}", newPoolName) - jsonData := []byte(jsonString) - - _, body, err := proxmox.MakeRequest(config, poolPath, "POST", jsonData, nil) - if err != nil { - return "", fmt.Errorf("pool create request failed: %v", err) - } - - // Parse response - var newPoolResponse NewPoolResponse - if err := json.Unmarshal(body, &newPoolResponse); err != nil { - return "", fmt.Errorf("failed to parse new pool response: %v", err) - } - - return newPoolName, nil -} - -/* - * /api/admin/proxmox/templates/clone/bulk - * This function bulk clones a single template for a list of users - */ -func BulkCloneTemplate(c *gin.Context) { - session := sessions.Default(c) - username := session.Get("username") - isAdmin := session.Get("is_admin") - - // Make sure user is authenticated and is an admin - if !isAdmin.(bool) { - log.Printf("Forbidden access attempt") - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only Admin users can delete a template", - }) - return - } - - var form struct { - Template string `json:"template"` - Names []string `json:"names"` - } - - err := c.ShouldBindJSON(&form) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - fmt.Printf("User %s is cloning %d pods\n", username, len(form.Names)) - eg := errgroup.Group{} - for i := 0; i < len(form.Names); i++ { - if form.Names[i] == "" { - continue - } - eg.Go(func() error { - return ProxmoxCloneTemplate(form.Template, form.Names[i]) - }) - } - - if err := eg.Wait(); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Pods deployed successfully!"}) -} - -func waitForDiskAvailability(config *proxmox.ProxmoxConfig, node string, vmid int, maxWait time.Duration) error { - start := time.Now() - var status *ConfigResponse - var err error - for { - time.Sleep(2 * time.Second) - if time.Since(start) > maxWait { - return fmt.Errorf("timeout waiting for VM disks to become available") - } - - status, err = getVMConfig(config, node, vmid) - if err != nil { - continue - } - - if status.Data.HardDisk == "" { - continue - } else { - time.Sleep(5 * time.Second) - return nil - } - - /*imageId := strings.Split(status.Data.HardDisk, ",")[0] - - disks, err := getStorageContent(config, node, STORAGE_ID) - if err != nil { - log.Printf("%v", err) - continue - } - - for _, d := range *disks { - if d.Id == imageId && d.Used > 0 { - return nil - } - } */ - } -} - -/* -func getStorageContent(config *proxmox.ProxmoxConfig, node string, storage string) (response *[]Disk, err error) { - - contentPath := fmt.Sprintf("api2/json/nodes/%s/storage/%s/content", node, storage) - log.Printf("%s", contentPath) - - statusCode, body, err := proxmox.MakeRequest(config, contentPath, "GET", nil, nil) - if err != nil { - return nil, fmt.Errorf("%s storage content request failed: %v", node, err) - } - - if statusCode != http.StatusOK { - return nil, fmt.Errorf("storage content request failed: %s", string(body)) - } - - var apiResp StorageResponse - if err := json.Unmarshal(body, &apiResp); err != nil { - return nil, fmt.Errorf("failed to parse storage content response: %v", err) - } - - return &apiResp.Data, nil -} -*/ diff --git a/proxmox/cloning/deleting.go b/proxmox/cloning/deleting.go deleted file mode 100644 index a075761..0000000 --- a/proxmox/cloning/deleting.go +++ /dev/null @@ -1,185 +0,0 @@ -package cloning - -import ( - "fmt" - "log" - "math" - "net/http" - "strings" - "time" - - "github.com/cpp-cyber/proclone/auth" - "github.com/cpp-cyber/proclone/proxmox" - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" -) - -type DeleteRequest struct { - PodName string `json:"pod_id"` // full pod name i.e. 1015_Some_Template_Administrator -} - -type DeleteResponse = CloneResponse - -/* - * ===== DELETE CLONED VM POD ===== - */ -func DeletePod(c *gin.Context) { - session := sessions.Default(c) - username := session.Get("username") - isAdmin := session.Get("is_admin") - - // Make sure user is authenticated - isAuth, _ := auth.IsAuthenticated(c) - if !isAuth { - log.Printf("Unauthorized access attempt") - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only authenticated users can delete pods", - }) - return - } - - // Parse request body - var req DeleteRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Invalid request format", - "details": err.Error(), - }) - return - } - - // Check if a non-admin user is trying to delete someone else's pod - if !isAdmin.(bool) { - // handle edge-case where username is longer than entire pod name - if len(req.PodName) < len(username.(string)) { - log.Printf("User %s attempted to delete pod %s.", username, req.PodName) - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only admin users can administer other users' pods", - }) - return - } - if !strings.HasSuffix(req.PodName, username.(string)) { - log.Printf("User %s attempted to delete pod %s.", username, req.PodName) - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only admin users can administer other users' pods", - }) - return - } - } - - // Load Proxmox configuration - config, err := proxmox.LoadProxmoxConfig() - if err != nil { - log.Printf("Configuration error for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to load Proxmox configuration: %v", err), - }) - return - } - - // Get all virtual resources - apiResp, err := proxmox.GetVirtualResources(config) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to fetch virtual resources", - "details": err.Error(), - }) - return - } - - // Check if resource pool actually exists - var poolExists = false - for _, r := range *apiResp { - if r.Type == "pool" && r.ResourcePool == req.PodName { - poolExists = true - } - } - - if !poolExists { - log.Printf("User %s attempted to delete pod %s, but the resource pool doesn't exist.", username, req.PodName) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Resource pool does not exist", - }) - return - } - - // Find all vms in resource pool - podVMs, err := proxmox.GetPoolMembers(config, req.PodName) - - if err != nil { - log.Printf("attempted to enumerate pod %s members, but error: %v", req.PodName, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Resource pool does not exist", - "details": err.Error(), - }) - return - } - - var errors []string - - // for each vm in the pool - for _, vm := range podVMs { - // clean up VM (turn off & remove) - err := cleanupClone(config, vm.NodeName, vm.VmId) - if err != nil { - errors = append(errors, fmt.Sprintf("Failed to delete VM %s: %v", vm.Name, err)) - } - } - - // wait until all vms have been deleted - err = waitForEmptyPool(config, req.PodName) - - if err != nil { - errors = append(errors, fmt.Sprintf("waiting for empty pool returned error: %v", err)) - log.Printf("attempted to enumerate pod %s members, but the resource pool doesn't exist.", req.PodName) - } - - // delete resource pool - err = cleanupFailedPodPool(config, req.PodName) - - if err != nil { - errors = append(errors, fmt.Sprintf("Failed to delete pod pool %s: %v", req.PodName, err)) - } - - var success int = 0 - if len(errors) == 0 { - success = 1 - } - - response := DeleteResponse{ - Success: success, - Errors: errors, - } - - if len(errors) > 0 { - c.JSON(http.StatusPartialContent, response) - } else { - c.JSON(http.StatusOK, response) - } -} - -func waitForEmptyPool(config *proxmox.ProxmoxConfig, poolid string) error { - backoff := time.Second - maxBackoff := 30 * time.Second - timeout := 5 * time.Minute - startTime := time.Now() - - for { - if time.Since(startTime) > timeout { - return fmt.Errorf("failed to delete all resource pool members: timeout") - } else { - poolMembers, err := proxmox.GetPoolMembers(config, poolid) - - if err != nil { - return fmt.Errorf("failed to get resource pool members: %v", err) - } - - if len(poolMembers) == 0 { - log.Printf("%s contains no members, proceeding with pool deletion.", poolid) - return nil - } - time.Sleep(backoff) - backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) - } - } -} diff --git a/proxmox/cloning/locking/mutexLock.go b/proxmox/cloning/locking/mutexLock.go deleted file mode 100644 index adcbcc6..0000000 --- a/proxmox/cloning/locking/mutexLock.go +++ /dev/null @@ -1,45 +0,0 @@ -package locking - -import ( - "context" - "fmt" - "os" - "time" - - "github.com/bsm/redislock" - "github.com/redis/go-redis/v9" -) - -var ( - rdb = redis.NewClient(&redis.Options{ - Addr: os.Getenv("REDIS_ADDR"), - Password: os.Getenv("REDIS_PASSWORD"), - DB: 0, - }) -) - -// try to acquire a redis mutex lock for an allotted amount of time with specified backoff -func TryAcquireLockWithBackoff(ctx context.Context, lockKey string, ttl time.Duration, maxAttempts int, initialBackoff time.Duration) (*redislock.Lock, error) { - locker := redislock.New(rdb) - backoff := initialBackoff - - for attempt := 1; attempt <= maxAttempts; attempt++ { - lock, err := locker.Obtain(ctx, lockKey, ttl, nil) - if err == nil { - return lock, nil - } - - if err == redislock.ErrNotObtained { - if attempt == maxAttempts { - break - } - time.Sleep(backoff) - backoff *= 2 - continue - } - - return nil, fmt.Errorf("unexpected error while acquiring lock: %v", err) - } - - return nil, fmt.Errorf("could not obtain lock %q after %d attempts", lockKey, maxAttempts) -} diff --git a/proxmox/cloning/networking.go b/proxmox/cloning/networking.go deleted file mode 100644 index 19972b6..0000000 --- a/proxmox/cloning/networking.go +++ /dev/null @@ -1,337 +0,0 @@ -package cloning - -import ( - "crypto/tls" - "encoding/json" - "fmt" - "math" - "net/http" - "regexp" - "time" - - "github.com/cpp-cyber/proclone/proxmox" -) - -type VNetResponse struct { - VnetArray []VNet `json:"data"` -} - -type VNet struct { - Type string `json:"type"` - Name string `json:"vnet"` - Tag int `json:"tag,omitempty"` - Alias string `json:"alias,omitempty"` - Zone string `json:"zone"` - VlanAware int `json:"vlanaware,omitempty"` -} - -type Config struct { - HardDisk string `json:"scsi0"` - Net0 string `json:"net0"` - Net1 string `json:"net1,omitempty"` -} - -type ConfigResponse struct { - Data Config `json:"data"` - Success int `json:"success"` -} - -const POD_VLAN_BASE int = 1800 -const SDN_ZONE string = "MainZone" -const WAN_SCRIPT_PATH string = "/home/update-wan-ip.sh" -const VIP_SCRIPT_PATH string = "/home/update-wan-vip.sh" -const WAN_IP_BASE string = "172.16." - -/* - * ----- SETS THE WAN IP ADDRESS OF A POD ROUTER ----- - * depends on the pfSense router template having a qemu agent installed and enabled - */ -func configurePodRouter(config *proxmox.ProxmoxConfig, podNum int, node string, vmid int) error { - // Create HTTP client with SSL verification based on config - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: !config.VerifySSL}, - } - client := &http.Client{Transport: tr} - - // wait for router agent to be pingable - - statusPath := fmt.Sprintf("api2/json/nodes/%s/qemu/%d/agent/ping", node, vmid) - - backoff := time.Second - maxBackoff := 30 * time.Second - timeout := 5 * time.Minute - startTime := time.Now() - - for { - if time.Since(startTime) > timeout { - return fmt.Errorf("router qemu agent timed out") - } - - statusCode, _, err := proxmox.MakeRequest(config, statusPath, "POST", nil, client) - if err != nil { - return fmt.Errorf("") - } - - if statusCode == http.StatusOK { - break - } - - time.Sleep(backoff) - backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) - } - - // configure router WAN ip to have correct third octet using qemu agent api call - - execPath := fmt.Sprintf("api2/json/nodes/%s/qemu/%d/agent/exec", node, vmid) - - // define json data holding new WAN IP - reqBody := map[string]interface{}{ - "command": []string{ - WAN_SCRIPT_PATH, - fmt.Sprintf("%s%d.1", WAN_IP_BASE, podNum), - }, - } - - jsonBody, err := json.Marshal(reqBody) - if err != nil { - return fmt.Errorf("failed to create ip request body: %v", err) - } - - statusCode, body, err := proxmox.MakeRequest(config, execPath, "POST", jsonBody, client) - if err != nil { - return fmt.Errorf("failed to make IP change request: %v", err) - } - - // handle response and return - if statusCode != http.StatusOK { - return fmt.Errorf("qemu agent failed to execute ip change script on router: %s", string(body)) - } - - // SEND AGENT EXEC REQUEST TO CHANGE VIP SUBNET - - // define json data holding new VIP subnet - reqBody = map[string]interface{}{ - "command": []string{ - VIP_SCRIPT_PATH, - fmt.Sprintf("%s%d.0", WAN_IP_BASE, podNum), - }, - } - - jsonBody, err = json.Marshal(reqBody) - if err != nil { - return fmt.Errorf("failed to create vip request body: %v", err) - } - - statusCode, body, err = proxmox.MakeRequest(config, execPath, "POST", jsonBody, client) - if err != nil { - return fmt.Errorf("failed to make VIP change request: %v", err) - } - - // handle response and return - if statusCode != http.StatusOK { - return fmt.Errorf("qemu agent failed to execute vip change script on router: %s", string(body)) - } - - return nil -} - -/* - * ----- CHECK BY NAME FOR VNET ALREADY IN CLUSTER ----- - */ -func checkForVnet(config *proxmox.ProxmoxConfig, podID int) (exists bool, err error) { - vnetPath := "api2/json/cluster/sdn/vnets" - - _, body, err := proxmox.MakeRequest(config, vnetPath, "GET", nil, nil) - if err != nil { - return false, fmt.Errorf("failed to request vnets: %v", err) - } - - // Parse response into VMResponse struct - var apiResp VNetResponse - if err := json.Unmarshal(body, &apiResp); err != nil { - return false, fmt.Errorf("failed to parse vnet response: %v", err) - } - - // iterate through list of vnets and compare with desired vnet name - vnetName := fmt.Sprintf("kamino%d", podID) - - for _, vnet := range apiResp.VnetArray { - if vnet.Name == vnetName { - return true, nil - } - } - - return false, nil -} - -/* - * ----- CREATE NEW VNET OBJECT IN THE CLUSTER SDN ----- - * SDN must be refreshed for new vnet to be used by pods - */ -func addVNetObject(config *proxmox.ProxmoxConfig, podID int) (vnet string, err error) { - - // Prepare VNet URL - vnetPath := "api2/json/cluster/sdn/vnets" - - podVlan := POD_VLAN_BASE + podID - - // define json data holding new VNet parameters - reqBody := map[string]interface{}{ - "vnet": fmt.Sprintf("kamino%d", podID), - "zone": SDN_ZONE, - "alias": fmt.Sprintf("%d_pod-vnet", podVlan), - "tag": podVlan, - "vlanaware": true, - } - - jsonBody, err := json.Marshal(reqBody) - if err != nil { - return "", fmt.Errorf("failed to create request body: %v", err) - } - - statusCode, body, err := proxmox.MakeRequest(config, vnetPath, "POST", jsonBody, nil) - if err != nil { - return "", fmt.Errorf("vnet create request failed: %v", err) - } - - // handle response and return - if statusCode != http.StatusOK { - return "", fmt.Errorf("failed to create new vnet object: %s", string(body)) - } else { - return fmt.Sprintf("kamino%d", podID), nil - } -} - -/* - * ----- APPLIES SDN CHANGES ----- - * should be called after adding or removing vnet objects - */ -func applySDNChanges(config *proxmox.ProxmoxConfig) error { - sdnPath := "api2/json/cluster/sdn" - - statusCode, body, err := proxmox.MakeRequest(config, sdnPath, "PUT", nil, nil) - if err != nil { - return fmt.Errorf("failed to apply sdn changes: %v", err) - } - - // return based on response - if statusCode != http.StatusOK { - return fmt.Errorf("failed to apply changes to sdn: %s", string(body)) - } else { - return nil - } -} - -/* - * ----- CONFIGURES NETWORK BRIDGE (VNET) FOR ALL VMS IN A POD ----- - */ -func setPodVnet(config *proxmox.ProxmoxConfig, podName string, vnet string) error { - - // Prepare VNet URL - poolPath := fmt.Sprintf("api2/json/pools/%s", podName) - - _, body, err := proxmox.MakeRequest(config, poolPath, "GET", nil, nil) - if err != nil { - return fmt.Errorf("failed to get pod pool: %v", err) - } - - var apiResp proxmox.PoolResponse - if err := json.Unmarshal(body, &apiResp); err != nil { - return fmt.Errorf("failed to parse pool response: %v", err) - } - - for _, vm := range apiResp.Data.Members { - err = updateVNet(config, &vm, vnet) - if err != nil { - return fmt.Errorf("failed to update VNet: %v", err) - } - } - - return nil -} - -// Gets config of a specific vm -func getVMConfig(config *proxmox.ProxmoxConfig, node string, vmid int) (response *ConfigResponse, err error) { - - // Prepare config URL - configPath := fmt.Sprintf("api2/extjs/nodes/%s/qemu/%d/config", node, vmid) - - _, body, err := proxmox.MakeRequest(config, configPath, "GET", nil, nil) - if err != nil { - return nil, fmt.Errorf("failed to get vm config: %v", err) - } - - // Parse response body - var apiResp ConfigResponse - if err := json.Unmarshal(body, &apiResp); err != nil { - return nil, fmt.Errorf("failed to parse vm config response: %v", err) - } - - return &apiResp, nil -} - -/* - * ----- CONFIGURE NETWORK BRIDGE FOR A SINGLE VM ----- - * automatically handles configuration of normal vms and routers - */ -func updateVNet(config *proxmox.ProxmoxConfig, vm *proxmox.VirtualResource, newBridge string) error { - // ----- get current network config ----- - - apiResp, err := getVMConfig(config, vm.NodeName, vm.VmId) - if err != nil { - return err - } - - // Handle vms with two interfaces (routers) seperately from vms with one interface - if apiResp.Data.Net1 == "" { - newConfig := replaceBridge(apiResp.Data.Net0, newBridge) - err := setNetworkBridge(config, vm, "net0", newConfig) - if err != nil { - return err - } - } else { - newConfig := replaceBridge(apiResp.Data.Net1, newBridge) - err := setNetworkBridge(config, vm, "net1", newConfig) - if err != nil { - return err - } - } - - return nil -} - -// helper function to replace the network bridge in a vm config using regex -func replaceBridge(netStr string, newBridge string) string { - re := regexp.MustCompile(`bridge=[^,]+`) - return re.ReplaceAllString(netStr, "bridge="+newBridge) -} - -/* - * ----- SET NETWORK BRIDGE FOR A SINGLE VM ----- - * automatically handles configuration of normal vms and routers - */ -func setNetworkBridge(config *proxmox.ProxmoxConfig, vm *proxmox.VirtualResource, net string, newConfig string) error { - // ----- set network config ----- - configPath := fmt.Sprintf("api2/extjs/nodes/%s/qemu/%d/config", vm.NodeName, vm.VmId) - - // define json data holding new VNet parameters - reqBody := map[string]interface{}{ - net: newConfig, - } - - jsonBody, err := json.Marshal(reqBody) - if err != nil { - return fmt.Errorf("failed to create request body: %v", err) - } - - statusCode, body, err := proxmox.MakeRequest(config, configPath, "PUT", jsonBody, nil) - if err != nil { - return fmt.Errorf("failed to set network bridge in vm config: %v", err) - } - - if statusCode != http.StatusOK { - return fmt.Errorf("failed to set vm config: %s", string(body)) - } - - return nil -} diff --git a/proxmox/cloning/optimization.go b/proxmox/cloning/optimization.go deleted file mode 100644 index 4817096..0000000 --- a/proxmox/cloning/optimization.go +++ /dev/null @@ -1,82 +0,0 @@ -package cloning - -import ( - "fmt" - "math" - - "github.com/cpp-cyber/proclone/proxmox" -) - -/* - * ----- FIND OPTIMAL COMPUTE NODE FOR NEXT VM CLONE ----- - * this function factors in current memory & cpu utilization, - * total memory allocation, and vm density to decide which node - * has the best resource availability for new VMs - */ -func findBestNode(config *proxmox.ProxmoxConfig) (node string, err error) { - - // define variables and data structures to hold structured values relevant to calculating optimal node - var totalVms int - vmDensityMap := make(map[string]int) - allocatedMemoryMap := make(map[string]int64) - totalMemoryMap := make(map[string]int64) - currentMemoryMap := make(map[string]int64) - cpuUtilizationMap := make(map[string]float64) - - virtualMachines, err := proxmox.GetVirtualMachineResponse(config) - - if err != nil { - return "", fmt.Errorf("failed to get cluster resources: %v", err) - } - - // increment density and allocated memory values per vm - for _, machine := range *virtualMachines { - if machine.Template != 1 { - allocatedMemoryMap[machine.NodeName] += int64(machine.MaxMem) - vmDensityMap[machine.NodeName] += 1 - totalVms += 1 - } - } - - // set default topScore to lowest possible float32 value - var topScore float32 = -1 * math.MaxFloat32 - var bestNode string - - for _, node := range config.Nodes { - nodeStatus, err := proxmox.GetNodeStatus(config, node) - if err != nil { - return "", fmt.Errorf("failed to get node status of %s: %v", node, err) - } - - // set total and current memory values, and cpu utilization values for eaach node - totalMemoryMap[node] = nodeStatus.Memory.Total - currentMemoryMap[node] = nodeStatus.Memory.Used - cpuUtilizationMap[node] = nodeStatus.CPU - - // fraction of node memory that is currently free - freeMemRatio := 1 - float32(currentMemoryMap[node])/float32(totalMemoryMap[node]) - - // fraction of free node cpu resources - freeCpuRatio := 1 - float32(cpuUtilizationMap[node]) - - // fraction of node memory that is currently unallocated - unallocatedMemRatio := 1 - float32(allocatedMemoryMap[node])/float32(totalMemoryMap[node]) - - // inverse vm density value (higher is better) - inverseVmDensity := 1 - float32(vmDensityMap[node])/float32(totalVms) - - // calculate node score (higher is better) - score := - 0.40*freeMemRatio + - 0.25*freeCpuRatio + - 0.30*unallocatedMemRatio + - 0.05*inverseVmDensity - - // if node score is higher than current bestNode, update bestNode - if score > topScore { - topScore = score - bestNode = node - } - } - return bestNode, nil -} diff --git a/proxmox/cloning/pods.go b/proxmox/cloning/pods.go deleted file mode 100644 index 6dfbeab..0000000 --- a/proxmox/cloning/pods.go +++ /dev/null @@ -1,191 +0,0 @@ -package cloning - -import ( - "fmt" - "log" - "net/http" - "regexp" - - "github.com/cpp-cyber/proclone/auth" - "github.com/cpp-cyber/proclone/proxmox" - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" -) - -type PodResponse struct { - Pods []PodWithVMs `json:"pods"` -} - -type PodWithVMs struct { - Name string `json:"name"` - VMs []proxmox.VirtualResource `json:"vms"` -} - -// helper function that builds a maps pod names to their VMs based on the provided regex pattern -func buildPodResponse(config *proxmox.ProxmoxConfig, regexPattern string) (*PodResponse, error) { - // get all virtual resources from proxmox - apiResp, err := proxmox.GetVirtualResources(config) - - // if error, return error - if err != nil { - return nil, err - } - - // map pod pools to their VMs - resources := apiResp - podMap := make(map[string]*PodWithVMs) - reg := regexp.MustCompile(regexPattern) - - // first pass: find all pools that are pods - for _, r := range *resources { - if r.Type == "pool" && reg.MatchString(r.ResourcePool) { - name := r.ResourcePool - podMap[name] = &PodWithVMs{ - Name: name, - VMs: []proxmox.VirtualResource{}, - } - } - } - - // second pass: map VMs to their pod pool - for _, r := range *resources { - if r.Type == "qemu" && reg.MatchString(r.ResourcePool) { - name := r.ResourcePool - if pod, ok := podMap[name]; ok { - pod.VMs = append(pod.VMs, r) - } - } - } - - // build response - var podResponse PodResponse - for _, pod := range podMap { - podResponse.Pods = append(podResponse.Pods, *pod) - } - - return &podResponse, nil -} - -/* - * ===== ADMIN ENDPOINT ===== - * This function returns a list of - * all currently deployed pods - */ -func GetPods(c *gin.Context) { - session := sessions.Default(c) - username := session.Get("username") - isAdmin := session.Get("is_admin") - - // Make sure user is authenticated (redundant) - if !isAdmin.(bool) { - log.Printf("Forbidden access attempt") - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only Admin users can see all deployed pods", - }) - return - } - - // store proxmox config - var config *proxmox.ProxmoxConfig - var err error - config, err = proxmox.LoadProxmoxConfig() - if err != nil { - log.Printf("Configuration error for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to load Proxmox configuration: %v", err), - }) - return - } - - // If no proxmox host specified, return empty repsonse - if config.Host == "" { - log.Printf("No proxmox server configured") - c.JSON(http.StatusOK, proxmox.VirtualMachineResponse{VirtualMachines: []proxmox.VirtualResource{}}) - return - } - - // fetch pod response - var podResponse *PodResponse - var error error - - // get Pod list and assign response - podResponse, error = getAdminPodResponse(config) - - // if error, return error status - if error != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to fetch pod list from proxmox cluster", - "details": error, - }) - return - } - - log.Printf("Successfully fetched full pod list for user %s", username) - c.JSON(http.StatusOK, podResponse) -} - -func getAdminPodResponse(config *proxmox.ProxmoxConfig) (*PodResponse, error) { - return buildPodResponse(config, `1[0-9]{3}_.*`) -} - -/* - * ===== USER ENDPOINT ===== - * This function returns a list of - * this user's deployed pods - */ -func GetUserPods(c *gin.Context) { - session := sessions.Default(c) - username := session.Get("username") - - // Make sure user is authenticated (redundant) - isAuth, _ := auth.IsAuthenticated(c) - if !isAuth { - log.Printf("Unauthorized access attempt") - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only authenticated users can see their deployed pods", - }) - return - } - - // store proxmox config - var config *proxmox.ProxmoxConfig - var err error - config, err = proxmox.LoadProxmoxConfig() - if err != nil { - log.Printf("Configuration error for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to load Proxmox configuration: %v", err), - }) - return - } - - // If no proxmox host specified, return empty repsonse - if config.Host == "" { - log.Printf("No proxmox server configured") - c.JSON(http.StatusOK, proxmox.VirtualMachineResponse{VirtualMachines: []proxmox.VirtualResource{}}) - return - } - - // fetch template reponse - var podResponse *PodResponse - var error error - - // get Pod list and assign response - podResponse, error = getUserPodResponse(username.(string), config) - - // if error, return error status - if error != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to fetch user's pod list from proxmox cluster", - "details": error, - }) - return - } - - log.Printf("Successfully fetched pod list for user %s", username) - c.JSON(http.StatusOK, podResponse) -} - -func getUserPodResponse(user string, config *proxmox.ProxmoxConfig) (*PodResponse, error) { - return buildPodResponse(config, fmt.Sprintf(`1[0-9]{3}_.*_%s`, user)) -} diff --git a/proxmox/cloning/templates.go b/proxmox/cloning/templates.go deleted file mode 100644 index 83242a7..0000000 --- a/proxmox/cloning/templates.go +++ /dev/null @@ -1,424 +0,0 @@ -package cloning - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - "strings" - - "slices" - - "github.com/cpp-cyber/proclone/auth" - "github.com/cpp-cyber/proclone/database" - "github.com/cpp-cyber/proclone/proxmox" - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" -) - -type ProxmoxPool struct { - PoolID string `json:"poolid"` -} - -type TemplateResponse struct { - Templates []database.Template `json:"templates"` -} - -type ProxmoxPoolResponse struct { - Pools []ProxmoxPool `json:"pools"` -} - -type UnpublishedTemplateResponse struct { - Templates []UnpublishedTemplate `json:"templates"` -} - -type UnpublishedTemplate struct { - Name string `json:"name"` -} - -/* - * /api/proxmox/templates - * Returns a list of templates based on their current visibility - */ -func GetAvailableTemplates(c *gin.Context) { - session := sessions.Default(c) - username := session.Get("username") - - // Make sure user is authenticated - isAuth, _ := auth.IsAuthenticated(c) - if !isAuth { - log.Printf("Unauthorized access attempt") - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only authenticated users can access templates", - }) - return - } - - // fetch template response from database - templates, err := database.SelectVisibleTemplates() - if err != nil { - log.Printf("Database error for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to fetch templates from database: %v", err), - }) - return - } - - // convert templates to response format - templatesResponse, err := BuildTemplatesResponse(templates) - if err != nil { - log.Printf("Failed to get available templates response for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to process templates: %v", err), - }) - return - } - - log.Printf("Successfully fetched %d templates for user %s", len(templates), username) - c.JSON(http.StatusOK, templatesResponse) -} - -/* - * /api/admin/proxmox/templates - * Returns a list of all templates - */ -func GetAllTemplates(c *gin.Context) { - session := sessions.Default(c) - username := session.Get("username") - isAdmin := session.Get("is_admin") - - // Make sure user is admin - if !isAdmin.(bool) { - log.Printf("Forbidden access attempt") - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only Admin users can create a template", - }) - return - } - - // fetch template response from database - templates, err := database.SelectAllTemplates() - if err != nil { - log.Printf("Database error for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to fetch templates from database: %v", err), - }) - return - } - - // convert templates to response format - templatesResponse, err := BuildTemplatesResponse(templates) - if err != nil { - log.Printf("Failed to get available templates response for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to process templates: %v", err), - }) - return - } - - log.Printf("Successfully fetched %d templates for user %s", len(templates), username) - c.JSON(http.StatusOK, templatesResponse) -} - -func BuildTemplatesResponse(templates []database.Template) (TemplateResponse, error) { - return TemplateResponse{Templates: templates}, nil -} - -func GetUnpublishedTemplates(c *gin.Context) { - session := sessions.Default(c) - username := session.Get("username") - isAdmin := session.Get("is_admin") - - // Make sure user is admin (redundant) - if !isAdmin.(bool) { - log.Printf("Forbidden access attempt") - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only Admin users can create a template", - }) - return - } - - // store proxmox config - var config *proxmox.ProxmoxConfig - var err error - config, err = proxmox.LoadProxmoxConfig() - if err != nil { - log.Printf("Configuration error for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to load Proxmox configuration: %v", err), - }) - return - } - - // If no proxmox host specified, return empty response - if config.Host == "" { - log.Printf("No proxmox server configured") - c.JSON(http.StatusOK, UnpublishedTemplateResponse{Templates: []UnpublishedTemplate{}}) - return - } - - // Get all Kamino templates from Proxmox - allKaminoTemplates, err := getAllKaminoTemplateNames(config) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to fetch pool list from proxmox cluster", - "details": err, - }) - return - } - - // Get published template names from database - publishedTemplateNames, err := database.SelectAllTemplateNames() - if err != nil { - log.Printf("Database error for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to fetch published templates from database: %v", err), - }) - return - } - - // Get unpublished templates (templates in Proxmox but not in database) - unpublishedTemplates := getUnpublishedTemplateNames(allKaminoTemplates, publishedTemplateNames) - - log.Printf("Successfully fetched unpublished template list for admin user %s", username) - c.JSON(http.StatusOK, UnpublishedTemplateResponse{Templates: unpublishedTemplates}) -} - -func getAllKaminoTemplateNames(config *proxmox.ProxmoxConfig) (*ProxmoxPoolResponse, error) { - // Fetch pools from Proxmox API - statusCode, body, err := proxmox.MakeRequest(config, "api2/json/pools", "GET", nil, nil) - if err != nil { - return nil, fmt.Errorf("failed to fetch pools from Proxmox: %v", err) - } - - if statusCode != http.StatusOK { - return nil, fmt.Errorf("proxmox API returned status %d", statusCode) - } - - // Parse the response - var apiResp proxmox.ProxmoxAPIResponse - if err := json.Unmarshal(body, &apiResp); err != nil { - return nil, fmt.Errorf("failed to parse pools response: %v", err) - } - - // Parse the pools data - var pools []ProxmoxPool - if err := json.Unmarshal(apiResp.Data, &pools); err != nil { - return nil, fmt.Errorf("failed to extract pools from response: %v", err) - } - - // Filter pools that start with "kamino_template_" - var templatePools []ProxmoxPool - for _, pool := range pools { - if strings.HasPrefix(pool.PoolID, "kamino_template_") { - templatePools = append(templatePools, pool) - } - } - - return &ProxmoxPoolResponse{Pools: templatePools}, nil -} - -func GetUnpublishedTemplatesResponse(allKaminoTemplates *ProxmoxPoolResponse, publishedTemplateNames []string) (*ProxmoxPoolResponse, error) { - var unpublishedPools []ProxmoxPool - - for _, pool := range allKaminoTemplates.Pools { - if !slices.Contains(publishedTemplateNames, pool.PoolID) { - unpublishedPools = append(unpublishedPools, pool) - } - } - - return &ProxmoxPoolResponse{Pools: unpublishedPools}, nil -} - -// getUnpublishedTemplateNames returns a list of template names that are in Proxmox but not published in the database -func getUnpublishedTemplateNames(allKaminoTemplates *ProxmoxPoolResponse, publishedTemplateNames []string) []UnpublishedTemplate { - var unpublishedTemplates []UnpublishedTemplate - - for _, pool := range allKaminoTemplates.Pools { - // Remove the "kamino_template_" prefix to get the actual template name - templateName := strings.TrimPrefix(pool.PoolID, "kamino_template_") - - // Check if this template name is not in the published list - if !slices.Contains(publishedTemplateNames, templateName) { - unpublishedTemplates = append(unpublishedTemplates, UnpublishedTemplate{ - Name: templateName, - }) - } - } - - return unpublishedTemplates -} - -/* - * /api/admin/proxmox/templates/publish - * This function publishes a template that is on proxmox - */ -func PublishTemplate(c *gin.Context) { - session := sessions.Default(c) - username := session.Get("username") - isAdmin := session.Get("is_admin") - - // Make sure user is authenticated (redundant) - if !isAdmin.(bool) { - log.Printf("Forbidden access attempt") - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only Admin users can create a template", - }) - return - } - - var template database.Template - if err := c.ShouldBindJSON(&template); err != nil { - log.Printf("Failed to bind JSON for user %s: %v", username, err) - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Invalid request payload", - }) - return - } - - // Insert the new template into the database - if err := database.InsertTemplate(template); err != nil { - log.Printf("Database error for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to create template: %v", err), - }) - return - } - - log.Printf("Successfully created template %s for admin user %s", template.Name, username) - c.JSON(http.StatusCreated, gin.H{ - "message": "Template created successfully", - }) -} - -/* - * /api/admin/proxmox/templates/update - * This function updates an existing template - */ -func UpdateTemplate(c *gin.Context) { - session := sessions.Default(c) - username := session.Get("username") - isAdmin := session.Get("is_admin") - - // Make sure user is authenticated and is an admin - if !isAdmin.(bool) { - log.Printf("Forbidden access attempt") - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only Admin users can update a template", - }) - return - } - - var template database.Template - if err := c.ShouldBindJSON(&template); err != nil { - log.Printf("Failed to bind JSON for user %s: %v", username, err) - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Invalid request payload", - }) - return - } - - // Update the template in the database - if err := database.UpdateTemplate(template); err != nil { - log.Printf("Database error for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to update template: %v", err), - }) - return - } - - log.Printf("Successfully updated template %s for admin user %s", template.Name, username) - c.JSON(http.StatusOK, gin.H{ - "message": "Template updated successfully", - }) -} - -/* - * /api/admin/proxmox/templates/toggle - * This function toggles the visibility of a published template - */ -func ToggleTemplateVisibility(c *gin.Context) { - session := sessions.Default(c) - username := session.Get("username") - isAdmin := session.Get("is_admin") - - // Make sure user is authenticated and is an admin - if !isAdmin.(bool) { - log.Printf("Forbidden access attempt") - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only Admin users can update a template", - }) - return - } - - var req struct { - TemplateName string `json:"template_name"` - } - if err := c.ShouldBindJSON(&req); err != nil { - log.Printf("Failed to bind JSON for user %s: %v", username, err) - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Invalid request payload", - }) - return - } - templateName := req.TemplateName - - // Update the template in the database - if err := database.ToggleVisibility(templateName); err != nil { - log.Printf("Database error for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to update template: %v", err), - }) - return - } - - log.Printf("Successfully toggled template visibility %s for admin user %s", templateName, username) - c.JSON(http.StatusOK, gin.H{ - "message": "Template visibility toggled successfully", - }) -} - -/* - * /api/admin/proxmox/templates/delete - * This function deletes a template - */ -func DeleteTemplate(c *gin.Context) { - session := sessions.Default(c) - username := session.Get("username") - isAdmin := session.Get("is_admin") - - // Make sure user is authenticated and is an admin - if !isAdmin.(bool) { - log.Printf("Forbidden access attempt") - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only Admin users can delete a template", - }) - return - } - - var req struct { - TemplateName string `json:"template_name"` - } - if err := c.ShouldBindJSON(&req); err != nil { - log.Printf("Failed to bind JSON for user %s: %v", username, err) - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Invalid request payload", - }) - return - } - templateName := req.TemplateName - - // Delete the template from the database - if err := database.DeleteTemplate(templateName); err != nil { - log.Printf("Database error for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to delete template: %v", err), - }) - return - } - - log.Printf("Successfully deleted template %s for admin user %s", templateName, username) - c.JSON(http.StatusOK, gin.H{ - "message": "Template deleted successfully", - }) -} diff --git a/proxmox/images/upload.go b/proxmox/images/upload.go deleted file mode 100644 index 6abe49d..0000000 --- a/proxmox/images/upload.go +++ /dev/null @@ -1,116 +0,0 @@ -package images - -import ( - "fmt" - "io" - "log" - "mime/multipart" - "net/http" - "os" - "path/filepath" - "strings" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - "github.com/google/uuid" -) - -var UploadDir = os.Getenv("UPLOADS_DIR") - -// Use a map to define allowed MIME types for better performance -// and to avoid using a switch statement -var allowedMIMEs = map[string]struct{}{ - "image/jpeg": {}, - "image/png": {}, -} - -func HandleUpload(c *gin.Context) { - session := sessions.Default(c) - isAdmin := session.Get("is_admin") - - // Make sure user is admin (redundant) - if !isAdmin.(bool) { - log.Printf("Forbidden access attempt") - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only Admin users can upload a template image", - }) - return - } - - // Check header for multipart/form-data - if !strings.HasPrefix(c.Request.Header.Get("Content-Type"), "multipart/form-data") { - c.JSON(http.StatusBadRequest, gin.H{"error": "Content-Type must be multipart/form-data"}) - return - } - - // Parse the multipart form - file, header, err := c.Request.FormFile("image") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "image field is required"}) - return - } - defer file.Close() - - // Basic check: Is file size 0? - if header.Size == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "uploaded file is empty"}) - return - } - - // Block unsupported file types - filetype, err := detectMIME(file) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to detect file type"}) - return - } - if _, ok := allowedMIMEs[filetype]; !ok { - c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported file type", "type": filetype}) - return - } - - // Reset file pointer back to beginning - if _, err := file.Seek(0, io.SeekStart); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset file reader"}) - return - } - // File name sanitization - filename := filepath.Base(header.Filename) // basic sanitization - filename = filepath.Clean(filename) // clean up the filename - filename = strings.ReplaceAll(filename, " ", "_") // replace spaces with underscores - - // Unique file name - // Save with a UUID filename to avoid name collisions - // generate unique filename - newFilename := fmt.Sprintf("%s-%s", uuid.NewString(), filename) - outPath := filepath.Join(UploadDir, newFilename) - - // Save file using Gin utility - if err := c.SaveUploadedFile(header, outPath); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "unable to save file"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "message": "file uploaded successfully", - "filename": newFilename, - "mime_type": filetype, - "path": outPath, - }) -} - -// detectMIME reads a small buffer to determine the file's MIME type -func detectMIME(f multipart.File) (string, error) { - buffer := make([]byte, 512) - if _, err := f.Read(buffer); err != nil && err != io.EOF { - return "", err - } - return http.DetectContentType(buffer), nil -} - -func HandleGetFile(c *gin.Context) { - filename := c.Param("filename") - filePath := filepath.Join(UploadDir, filename) - - // Serve the file - c.File(filePath) -} diff --git a/proxmox/proxmox.go b/proxmox/proxmox.go deleted file mode 100644 index c7abc87..0000000 --- a/proxmox/proxmox.go +++ /dev/null @@ -1,93 +0,0 @@ -package proxmox - -import ( - "encoding/json" - "fmt" - "os" - "strings" -) - -// ProxmoxConfig holds the configuration for Proxmox API -type ProxmoxConfig struct { - Host string - Port string - APIToken string // API token for authentication - VerifySSL bool - Nodes []string -} - -// ProxmoxAPIResponse represents the generic Proxmox API response structure -type ProxmoxAPIResponse struct { - Data json.RawMessage `json:"data"` -} - -// ProxmoxNodeStatus represents the status response from a Proxmox node -type ProxmoxNodeStatus struct { - CPU float64 `json:"cpu"` - Memory struct { - Total int64 `json:"total"` - Used int64 `json:"used"` - } `json:"memory"` -} - -// LoadProxmoxConfig loads and validates Proxmox configuration from environment variables -func LoadProxmoxConfig() (*ProxmoxConfig, error) { - tokenID := os.Getenv("PROXMOX_TOKEN_ID") // The token ID including user and realm - tokenSecret := os.Getenv("PROXMOX_TOKEN_SECRET") // The secret part of the token - - if tokenID == "" { - return nil, fmt.Errorf("PROXMOX_TOKEN_ID is required") - } - if tokenSecret == "" { - return nil, fmt.Errorf("PROXMOX_TOKEN_SECRET is required") - } - - config := &ProxmoxConfig{ - Host: os.Getenv("PROXMOX_SERVER"), - Port: os.Getenv("PROXMOX_PORT"), - APIToken: fmt.Sprintf("%s=%s", tokenID, tokenSecret), - VerifySSL: os.Getenv("PROXMOX_VERIFY_SSL") == "true", - } - - // Validate required fields - if config.Host == "" { - return nil, fmt.Errorf("PROXMOX_SERVER is required") - } - if config.Port == "" { - config.Port = "443" // Default port - } - - // Parse nodes list - nodesStr := os.Getenv("PROXMOX_NODES") - if nodesStr != "" { - config.Nodes = strings.Split(nodesStr, ",") - } - - return config, nil -} - -// GetNodeStatus fetches the status of a single Proxmox node -func GetNodeStatus(config *ProxmoxConfig, nodeName string) (*ProxmoxNodeStatus, error) { - - // Prepare status endpoint path - path := fmt.Sprintf("api2/json/nodes/%s/status", nodeName) - - _, body, err := MakeRequest(config, path, "GET", nil, nil) - if err != nil { - return nil, fmt.Errorf("proxmox node status request failed: %v", err) - } - - // Parse response - var apiResp ProxmoxAPIResponse - if err := json.Unmarshal(body, &apiResp); err != nil { - return nil, fmt.Errorf("failed to parse status response: %v", err) - } - - // Extract status from response - var status ProxmoxNodeStatus - if err := json.Unmarshal(apiResp.Data, &status); err != nil { - return nil, fmt.Errorf("failed to extract status from response: %v", err) - } - - return &status, nil -} diff --git a/proxmox/requests.go b/proxmox/requests.go deleted file mode 100644 index 96a7ee9..0000000 --- a/proxmox/requests.go +++ /dev/null @@ -1,59 +0,0 @@ -package proxmox - -import ( - "bytes" - "crypto/tls" - "fmt" - "io" - "net/http" -) - -// kind should be "GET", "DELETE", "POST", or "PUT", jsonData and httpClient can be nil -func MakeRequest(config *ProxmoxConfig, path string, kind string, jsonData []byte, httpClient *http.Client) (int, []byte, error) { - if !(kind == "GET" || kind == "DELETE" || kind == "POST" || kind == "PUT") { - return 0, nil, fmt.Errorf("invalid REST method passed: %s", kind) - } - - var client *http.Client = nil - if httpClient == nil { - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: !config.VerifySSL}, - } - client = &http.Client{Transport: tr} - } else { - client = httpClient - } - - reqURL := fmt.Sprintf("https://%s:%s/%s", config.Host, config.Port, path) - - var bodyReader io.Reader = nil - - if jsonData != nil { - bodyReader = bytes.NewBuffer(jsonData) - } - - req, err := http.NewRequest(kind, reqURL, bodyReader) - if err != nil { - return 0, nil, fmt.Errorf("failed to create %s request: %v", kind, err) - } - - if jsonData != nil { - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - } - - req.Header.Set("Authorization", fmt.Sprintf("PVEAPIToken=%s", config.APIToken)) - - resp, err := client.Do(req) - if err != nil { - return 0, nil, fmt.Errorf("failed to make request: %v", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return resp.StatusCode, nil, fmt.Errorf("failed to read response body: %v", err) - } - - return resp.StatusCode, body, nil -} diff --git a/proxmox/resources.go b/proxmox/resources.go deleted file mode 100644 index c2217fa..0000000 --- a/proxmox/resources.go +++ /dev/null @@ -1,173 +0,0 @@ -package proxmox - -import ( - "fmt" - "log" - "net/http" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" -) - -// NodeResourceUsage represents the resource usage metrics for a single node -type NodeResourceUsage struct { - NodeName string `json:"node_name"` - CPUUsage float64 `json:"cpu_usage"` // CPU usage percentage - MemoryTotal int64 `json:"memory_total"` // Total memory in bytes - MemoryUsed int64 `json:"memory_used"` // Used memory in bytes - StorageTotal int64 `json:"storage_total"` // Total storage in bytes - StorageUsed int64 `json:"storage_used"` // Used storage in bytes -} - -// ResourceUsageResponse represents the API response containing resource usage for all nodes -type ResourceUsageResponse struct { - Nodes []NodeResourceUsage `json:"nodes"` - Cluster struct { - TotalCPUUsage float64 `json:"total_cpu_usage"` // Average CPU usage across all nodes - TotalMemoryTotal int64 `json:"total_memory_total"` // Total memory across all nodes - TotalMemoryUsed int64 `json:"total_memory_used"` // Total used memory across all nodes - TotalStorageTotal int64 `json:"total_storage_total"` // Total storage across all nodes - TotalStorageUsed int64 `json:"total_storage_used"` // Total used storage across all nodes - } `json:"cluster"` - Errors []string `json:"errors,omitempty"` -} - -func GetProxmoxResources(c *gin.Context) { - // Get session - session := sessions.Default(c) - username := session.Get("username") - isAdmin := session.Get("is_admin") - - // Double check admin status (although middleware should have caught this) - if !isAdmin.(bool) { - log.Printf("Unauthorized access attempt by user %s", username) - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only admin users can access resource usage data", - }) - return - } - - // Load Proxmox configuration - config, err := LoadProxmoxConfig() - if err != nil { - log.Printf("Configuration error for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to load Proxmox configuration: %v", err), - }) - return - } - - // If no nodes specified, return empty response - if len(config.Nodes) == 0 { - log.Printf("No nodes configured for user %s", username) - c.JSON(http.StatusOK, ResourceUsageResponse{Nodes: []NodeResourceUsage{}}) - return - } - - // Fetch status for each node - var nodes []NodeResourceUsage - var errors []string - response := ResourceUsageResponse{} - - VirtualResources, err := GetVirtualResources(config) - - if err != nil { - log.Printf("Failed to get proxmox cluster resources: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to get proxmox cluster resources: %v", err), - }) - return - } - - for _, nodeName := range config.Nodes { - status, err := GetNodeStatus(config, nodeName) - if err != nil { - errorMsg := fmt.Sprintf("Error fetching status for node %s: %v", nodeName, err) - log.Printf("%s", errorMsg) - errors = append(errors, errorMsg) - continue - } - - usedStorage, totalStorage := getNodeStorage(VirtualResources, nodeName) - - nodes = append(nodes, NodeResourceUsage{ - NodeName: nodeName, - CPUUsage: status.CPU, - MemoryTotal: status.Memory.Total, - MemoryUsed: status.Memory.Used, - StorageTotal: int64(totalStorage), - StorageUsed: int64(usedStorage), - }) - - // Add to cluster totals - response.Cluster.TotalMemoryTotal += status.Memory.Total - response.Cluster.TotalMemoryUsed += status.Memory.Used - response.Cluster.TotalStorageTotal += int64(totalStorage) - response.Cluster.TotalStorageUsed += int64(usedStorage) - response.Cluster.TotalCPUUsage += status.CPU - } - - // Get NAS storage and add that to cluster capacity - usedStorage, totalStorage := getStorage(VirtualResources, "mufasa-proxmox") - - response.Cluster.TotalStorageTotal += int64(totalStorage) - response.Cluster.TotalStorageUsed += int64(usedStorage) - - // Calculate average CPU usage for the cluster - if len(nodes) > 0 { - response.Cluster.TotalCPUUsage /= float64(len(nodes)) - } - - response.Nodes = nodes - response.Errors = errors - - // If we have any errors but also some successful responses, include errors in response - if len(errors) > 0 && len(nodes) > 0 { - c.JSON(http.StatusPartialContent, response) - return - } - - // If we have only errors, return error status - if len(errors) > 0 { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to fetch resource usage for any nodes", - "details": errors, - }) - return - } - - // Success case - log.Printf("Successfully fetched resource usage for user %s", username) - c.JSON(http.StatusOK, response) -} - -func getNodeStorage(resources *[]VirtualResource, node string) (Used int64, Total int64) { - var used int64 = 0 - var total int64 = 0 - - for _, r := range *resources { - if r.Type == "storage" && r.NodeName == node && - (r.Storage == "local" || r.Storage == "local-lvm") && - r.RunningStatus == "available" { - used += r.Disk - total += r.MaxDisk - } - } - log.Printf("%s has used %d of its %d local storage", node, used, total) - return used, total -} - -func getStorage(resources *[]VirtualResource, storage string) (Used int64, Total int64) { - var used int64 = 0 - var total int64 = 0 - - for _, r := range *resources { - if r.Type == "storage" && r.Storage == storage && r.RunningStatus == "available" { - used = r.Disk - total = r.MaxDisk - break - } - } - log.Printf("The cluster has used %d of its %d total storage", used, total) - return used, total -} diff --git a/proxmox/vms.go b/proxmox/vms.go deleted file mode 100644 index 84eafb4..0000000 --- a/proxmox/vms.go +++ /dev/null @@ -1,591 +0,0 @@ -package proxmox - -import ( - "crypto/tls" - "encoding/json" - "fmt" - "log" - "math" - "net/http" - "strconv" - "time" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" -) - -const CRITICAL_POOL string = "0030_Critical" - -type VMResponse struct { - Data []VirtualResource `json:"data"` -} -type VirtualResource struct { - CPU float64 `json:"cpu,omitempty"` - MaxCPU int `json:"maxcpu,omitempty"` - Mem int `json:"mem,omitempty"` - MaxMem int `json:"maxmem,omitempty"` - Type string `json:"type,omitempty"` - Id string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - NodeName string `json:"node,omitempty"` - ResourcePool string `json:"pool,omitempty"` - RunningStatus string `json:"status,omitempty"` - Uptime int `json:"uptime,omitempty"` - VmId int `json:"vmid,omitempty"` - Storage string `json:"storage,omitempty"` - Disk int64 `json:"disk,omitempty"` - MaxDisk int64 `json:"maxdisk,omitempty"` - Template int `json:"template,omitempty"` -} - -type VirtualMachineResponse struct { - VirtualMachines []VirtualResource `json:"virtual_machines"` - VirtualMachineCount int `json:"virtual_machine_count"` - RunningCount int `json:"running_count"` -} - -type VM struct { - VMID int `json:"vmid" binding:"required"` - Node string `json:"node" binding:"required"` -} - -type VMPower struct { - Success int `json:"success"` - Data string `json:"data"` -} - -type VMPowerResponse struct { - Success int `json:"success"` -} - -type Pool struct { - Poolid string `json:"poolid"` - Members []VirtualResource `json:"members"` -} - -type PoolResponse struct { - Data Pool `json:"data"` -} - -type PoolName struct { - PoolName string `json:"poolid"` -} - -type PoolNamesResponse struct { - Data []PoolName `json:"data"` -} - -/* - * ===== GET ALL VIRTUAL MACHINES ===== - */ -func GetVirtualMachines(c *gin.Context) { - session := sessions.Default(c) - username := session.Get("username") - isAdmin := session.Get("is_admin") - - // Make sure user is admin (redundant with middleware) - if !isAdmin.(bool) { - log.Printf("Unauthorized access attempt by user %s", username) - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only admin users can access vm data", - }) - return - } - - // store proxmox config - var config *ProxmoxConfig - var err error - config, err = LoadProxmoxConfig() - if err != nil { - log.Printf("Configuration error for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to load Proxmox configuration: %v", err), - }) - return - } - - // If no proxmox host specified, return empty repsonse - if config.Host == "" { - log.Printf("No proxmox server configured") - c.JSON(http.StatusOK, VirtualMachineResponse{VirtualMachines: []VirtualResource{}}) - return - } - - // fetch all virtual machines - var virtualMachines *[]VirtualResource - var error error - var response VirtualMachineResponse = VirtualMachineResponse{} - response.RunningCount = 0 - - // get virtual machine info and include in response - virtualMachines, error = GetVirtualMachineResponse(config) - - // if error, return error status - if error != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to fetch vm list from proxmox cluster", - "details": error, - }) - return - } - - response.VirtualMachines = *virtualMachines - - // get total # of virtual machines and include in response - response.VirtualMachineCount = len(*virtualMachines) - - // get # of running virtual machines and include in response - for _, vm := range *virtualMachines { - if vm.RunningStatus == "running" { - response.RunningCount++ - } - } - - log.Printf("Successfully fetched vm list for user %s", username) - c.JSON(http.StatusOK, response) - -} - -// handles fetching all the virtual machines on the proxmox cluster -func GetVirtualResources(config *ProxmoxConfig) (*[]VirtualResource, error) { - - path := "api2/json/cluster/resources" - - _, body, err := MakeRequest(config, path, "GET", nil, nil) - if err != nil { - return nil, fmt.Errorf("proxmox cluster resource request failed: %v", err) - } - - // Parse response into VMResponse struct - var apiResp VMResponse - if err := json.Unmarshal(body, &apiResp); err != nil { - return nil, fmt.Errorf("failed to parse status response: %v", err) - } - - return &apiResp.Data, nil - -} - -func GetVirtualMachineResponse(config *ProxmoxConfig) (*[]VirtualResource, error) { - - // get all virtual resources from proxmox - apiResp, err := GetVirtualResources(config) - - // if error, return error - if err != nil { - return nil, err - } - - // Extract virtual machines from response, store in VirtualMachine struct array - var vms []VirtualResource - for _, r := range *apiResp { - // don't return VMS in critical resource pool, for security - if r.Type == "qemu" && r.ResourcePool != CRITICAL_POOL { - vms = append(vms, r) - } - } - - return &vms, nil -} - -/* - * ====== POWERING OFF VIRTUAL MACHINES ====== - * POST requires "vmid" and "node" fields - */ -func PowerOffVirtualMachine(c *gin.Context) { - session := sessions.Default(c) - username := session.Get("username") - isAdmin := session.Get("is_admin") - - // Make sure user is admin (redundant with middleware) - if !isAdmin.(bool) { - log.Printf("Unauthorized access attempt by user %s", username) - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only admin users can access vm data", - }) - return - } - - // store proxmox config - var config *ProxmoxConfig - var err error - config, err = LoadProxmoxConfig() - if err != nil { - log.Printf("Configuration error for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to load Proxmox configuration: %v", err), - }) - return - } - - // If no proxmox host specified, return empty repsonse - if config.Host == "" { - log.Printf("No proxmox server configured") - c.JSON(http.StatusOK, VirtualMachineResponse{VirtualMachines: []VirtualResource{}}) - return - } - - // If no nodes specified, return empty response - if len(config.Nodes) == 0 { - log.Printf("No nodes configured for user %s", username) - c.JSON(http.StatusOK, ResourceUsageResponse{Nodes: []NodeResourceUsage{}}) - return - } - - // get req.VMID, req.Node - var req VM - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: must include 'vmid' and 'node'"}) - return - } - - // log request on backend - log.Printf("User %s requested to power off VM %d on node %s", username, req.VMID, req.Node) - - var error error - var response *VMPower - - response, error = PowerOffRequest(config, req) - - // If we have error , return error status - if error != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to fetch resource usage for any nodes", - "details": error, - }) - return - } - - var finalResponse VMPowerResponse - finalResponse.Success = response.Success - - if finalResponse.Success == 1 { - log.Printf("Successfully powered down VMID %s for %s", strconv.Itoa(req.VMID), username) - c.JSON(http.StatusOK, response) - } else { - log.Printf("Failed to power down VMID %s for %s", strconv.Itoa(req.VMID), username) - c.JSON(http.StatusOK, response) - } - -} - -func PowerOffRequest(config *ProxmoxConfig, vm VM) (*VMPower, error) { - // ----- SECURITY CHECK ----- - // make sure VM is not critical - - criticalMembers, err := GetPoolMembers(config, CRITICAL_POOL) - if err != nil { - return nil, fmt.Errorf("could not verify members of critical pool: %v", err) - } - - // return unauthorized if error or vm is critical - if isCritical, err := isVmCritical(vm, &criticalMembers); err != nil || isCritical { - return nil, fmt.Errorf("not authorized to power off VMID %d: %v", vm.VMID, err) - } - - path := fmt.Sprintf("api2/extjs/nodes/%s/qemu/%s/status/shutdown", vm.Node, strconv.Itoa(vm.VMID)) - - _, body, err := MakeRequest(config, path, "POST", nil, nil) - if err != nil { - return nil, fmt.Errorf("vm power off request failed: %v", err) - } - - // Parse response - var apiResp VMPower - if err := json.Unmarshal(body, &apiResp); err != nil { - return nil, fmt.Errorf("failed to parse VM shutdown response: %v", err) - } - - return &apiResp, nil -} - -func StopRequest(config *ProxmoxConfig, vm VM) (*VMPower, error) { - - path := fmt.Sprintf("api2/extjs/nodes/%s/qemu/%s/status/stop", vm.Node, strconv.Itoa(vm.VMID)) - - _, body, err := MakeRequest(config, path, "POST", nil, nil) - if err != nil { - return nil, fmt.Errorf("vm stop request failed: %v", err) - } - - // Parse response - var apiResp VMPower - if err := json.Unmarshal(body, &apiResp); err != nil { - return nil, fmt.Errorf("failed to parse VM stop response: %v", err) - } - - return &apiResp, nil -} - -/* - * ====== POWERING ON VIRTUAL MACHINES ====== - * POST requires "vmid" and "node" fields - */ -func PowerOnVirtualMachine(c *gin.Context) { - session := sessions.Default(c) - username := session.Get("username") - isAdmin := session.Get("is_admin") - - // Make sure user is admin (redundant with middleware) - if !isAdmin.(bool) { - log.Printf("Unauthorized access attempt by user %s", username) - c.JSON(http.StatusForbidden, gin.H{ - "error": "Only admin users can access vm data", - }) - return - } - - // store proxmox config - var config *ProxmoxConfig - var err error - config, err = LoadProxmoxConfig() - if err != nil { - log.Printf("Configuration error for user %s: %v", username, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to load Proxmox configuration: %v", err), - }) - return - } - - // If no proxmox host specified, return empty repsonse - if config.Host == "" { - log.Printf("No proxmox server configured") - c.JSON(http.StatusOK, VirtualMachineResponse{VirtualMachines: []VirtualResource{}}) - return - } - - // If no nodes specified, return empty response - if len(config.Nodes) == 0 { - log.Printf("No nodes configured for user %s", username) - c.JSON(http.StatusOK, ResourceUsageResponse{Nodes: []NodeResourceUsage{}}) - return - } - - // get req.VMID, req.Node - var req VM - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: must include 'vmid' and 'node'"}) - return - } - - // log request on backend - log.Printf("User %s requested to power on VM %d on node %s", username, req.VMID, req.Node) - var response *VMPower - - response, err = PowerOnRequest(config, req) - - // If we have error , return error status - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "failed to power on virtual machine", - "details": err.Error(), - }) - return - } - - var finalResponse VMPowerResponse - finalResponse.Success = response.Success - - if finalResponse.Success == 1 { - log.Printf("Successfully powered on VMID %s for %s", strconv.Itoa(req.VMID), username) - c.JSON(http.StatusOK, response) - } else { - log.Printf("Failed to power on VMID %s for %s", strconv.Itoa(req.VMID), username) - c.JSON(http.StatusOK, response) - } - -} - -func PowerOnRequest(config *ProxmoxConfig, vm VM) (*VMPower, error) { - - // ----- SECURITY CHECK ----- - // make sure VM is not critical - - criticalMembers, err := GetPoolMembers(config, CRITICAL_POOL) - if err != nil { - return nil, fmt.Errorf("could not verify members of critical pool: %v", err) - } - - // return unauthorized if error or vm is critical - if isCritical, err := isVmCritical(vm, &criticalMembers); err != nil || isCritical { - return nil, fmt.Errorf("not authorized to power on VMID %d: %v", vm.VMID, err) - } - - path := fmt.Sprintf("api2/extjs/nodes/%s/qemu/%s/status/start", vm.Node, strconv.Itoa(vm.VMID)) - - _, body, err := MakeRequest(config, path, "POST", nil, nil) - if err != nil { - return nil, fmt.Errorf("vm start request failed: %v", err) - } - - // Parse response - var apiResp VMPower - if err := json.Unmarshal(body, &apiResp); err != nil { - return nil, fmt.Errorf("failed to parse VM start response: %v", err) - } - - return &apiResp, nil -} - -// !!! should be refactored to use MakeRequest, written in really stupid way idk why I did this -// should change MakeRequest to variadic function to avoid recreating http client many times -func WaitForRunning(config *ProxmoxConfig, vm VM) error { - // Create a single HTTP client for all requests - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: !config.VerifySSL}, - } - client := &http.Client{Transport: tr} - - // Wait for status "running" with exponential backoff - statusURL := fmt.Sprintf("https://%s:%s/api2/json/nodes/%s/qemu/%d/status/current", - config.Host, config.Port, vm.Node, vm.VMID) - - backoff := time.Second - maxBackoff := 30 * time.Second - timeout := 3 * time.Minute - startTime := time.Now() - - for { - if time.Since(startTime) > timeout { - return fmt.Errorf("vm failed to start within %v", timeout) - } - - req, err := http.NewRequest("GET", statusURL, nil) - if err != nil { - return fmt.Errorf("failed to create status check request: %v", err) - } - req.Header.Set("Authorization", fmt.Sprintf("PVEAPIToken=%s", config.APIToken)) - - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("failed to check vm status: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusOK { - // Verify the VM is actually crunning - var statusResponse struct { - Data struct { - Status string `json:"status"` - } `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&statusResponse); err != nil { - return fmt.Errorf("failed to decode status response: %v", err) - } - if statusResponse.Data.Status == "running" { - return nil - } - } - - time.Sleep(backoff) - backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) - } -} - -// !!! should be refactored to use MakeRequest, written in really stupid way idk why I did this -// should change MakeRequest to variadic function to avoid recreating http client many times -func WaitForStopped(config *ProxmoxConfig, vm VM) error { - // Create a single HTTP client for all requests - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: !config.VerifySSL}, - } - client := &http.Client{Transport: tr} - - // Wait for status "stopped" with exponential backoff - statusURL := fmt.Sprintf("https://%s:%s/api2/json/nodes/%s/qemu/%d/status/current", - config.Host, config.Port, vm.Node, vm.VMID) - - backoff := time.Second - maxBackoff := 30 * time.Second - timeout := 3 * time.Minute - startTime := time.Now() - - for { - if time.Since(startTime) > timeout { - return fmt.Errorf("vm failed to stop within %v", timeout) - } - - req, err := http.NewRequest("GET", statusURL, nil) - if err != nil { - return fmt.Errorf("failed to create status check request: %v", err) - } - req.Header.Set("Authorization", fmt.Sprintf("PVEAPIToken=%s", config.APIToken)) - - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("failed to check vm status: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusOK { - // Verify the VM is actually stopped - var statusResponse struct { - Data struct { - Status string `json:"status"` - } `json:"data"` - } - if err := json.NewDecoder(resp.Body).Decode(&statusResponse); err != nil { - return fmt.Errorf("failed to decode status response: %v", err) - } - if statusResponse.Data.Status == "stopped" { - return nil - } - } - - time.Sleep(backoff) - backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) - } -} - -// return whether or not a vm is in a resource pool member list -func isVmCritical(vm VM, poolMembers *[]VirtualResource) (isInCritical bool, err error) { - for _, poolVm := range *poolMembers { - if poolVm.VmId == vm.VMID { - return true, nil - } - } - - return false, nil -} - -func GetPoolMembers(config *ProxmoxConfig, pool string) (members []VirtualResource, err error) { - // Prepare proxmox pool get URL - poolPath := fmt.Sprintf("api2/json/pools/%s", pool) - - _, body, err := MakeRequest(config, poolPath, "GET", nil, nil) - if err != nil { - return nil, fmt.Errorf("failed to request resource pool: %v", err) - } - - // Parse response into VMResponse struct - var apiResp PoolResponse - if err := json.Unmarshal(body, &apiResp); err != nil { - return nil, fmt.Errorf("failed to parse status response: %v", err) - } - - // return array of resource pool members - return apiResp.Data.Members, nil -} - -// Helper function that retrieves all pool names in proxmox -func GetPoolNames(config *ProxmoxConfig) (pools []string, err error) { - // Prepare proxmox pool get URL - poolPath := "api2/json/pools" - - _, body, err := MakeRequest(config, poolPath, "GET", nil, nil) - if err != nil { - return nil, fmt.Errorf("failed to request resource pools: %v", err) - } - - // Parse response into PoolsResponse struct - var apiResp PoolNamesResponse - if err := json.Unmarshal(body, &apiResp); err != nil { - return nil, fmt.Errorf("failed to parse status response: %v", err) - } - - // return array of resource pool names - for _, pool := range apiResp.Data { - pools = append(pools, pool.PoolName) - } - return pools, nil -} diff --git a/uploads/kaminoLogo.svg b/uploads/kaminoLogo.svg deleted file mode 100644 index 1cc6d09..0000000 --- a/uploads/kaminoLogo.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - From 0e5e6eb26ae1f23ba2700f9078e04955682b6ed8 Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Wed, 3 Sep 2025 22:42:20 -0700 Subject: [PATCH 08/23] Fixed build error --- Dockerfile | 1 - cmd/api/main.go | 13 ++++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1e4eb3b..17605eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,5 @@ RUN go build -o server ./cmd/api FROM debian:bookworm-slim WORKDIR /app COPY --from=builder /app/server . -COPY cmd/api/.env . EXPOSE 8080 CMD ["./server"] diff --git a/cmd/api/main.go b/cmd/api/main.go index f4e569e..a310471 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -21,7 +21,11 @@ type Config struct { // init the environment func init() { - _ = godotenv.Load() + if err := godotenv.Load(); err != nil { + log.Println("No .env file found, using environment variables from system") + } else { + log.Println("Loaded configuration from .env file") + } } func main() { @@ -33,10 +37,17 @@ func main() { log.Fatalf("Failed to process environment configuration: %v", err) } + log.Printf("Starting server on port %s", config.Port) + r := gin.Default() // Setup session middleware store := cookie.NewStore([]byte(config.SessionSecret)) + store.Options(sessions.Options{ + MaxAge: 3600, + HttpOnly: true, + Secure: true, + }) r.Use(sessions.Sessions("session", store)) // Initialize handlers From fb27f6d53b5da2201b0e8904ed81df17446f67b6 Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Thu, 4 Sep 2025 15:47:36 -0700 Subject: [PATCH 09/23] Added debug logging for LDAP issues --- cmd/api/main.go | 3 +- internal/auth/auth.go | 72 +++++++++++++++++++++++++-- internal/auth/groups.go | 50 +++++++++++++++++++ internal/auth/ldap.go | 105 +++++++++++++++++++++++++++++++++++++--- internal/auth/users.go | 59 ++++++++++++++++++++-- 5 files changed, 272 insertions(+), 17 deletions(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index a310471..4acba32 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -29,7 +29,8 @@ func init() { } func main() { - gin.SetMode(gin.ReleaseMode) + // TODO: Set gin mode based on environment (development/production) + // gin.SetMode(gin.ReleaseMode) // Load and parse configuration from environment variables var config Config diff --git a/internal/auth/auth.go b/internal/auth/auth.go index a361326..745b866 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -2,6 +2,7 @@ package auth import ( "fmt" + "log" ldapv3 "github.com/go-ldap/ldap/v3" ) @@ -38,16 +39,23 @@ type LDAPService struct { // NewLDAPService creates a new LDAP authentication service func NewLDAPService() (*LDAPService, error) { + log.Println("[DEBUG] NewLDAPService: Starting LDAP service initialization") + config, err := LoadConfig() if err != nil { + log.Printf("[ERROR] NewLDAPService: Failed to load LDAP configuration: %v", err) return nil, fmt.Errorf("failed to load LDAP configuration: %w", err) } + log.Printf("[DEBUG] NewLDAPService: LDAP configuration loaded successfully - URL: %s, BindUser: %s", config.URL, config.BindUser) client := NewClient(config) if err := client.Connect(); err != nil { + log.Printf("[ERROR] NewLDAPService: Failed to connect to LDAP: %v", err) return nil, fmt.Errorf("failed to connect to LDAP: %w", err) } + log.Println("[DEBUG] NewLDAPService: LDAP client connected successfully") + log.Println("[INFO] NewLDAPService: LDAP service initialized successfully") return &LDAPService{ client: client, }, nil @@ -55,34 +63,47 @@ func NewLDAPService() (*LDAPService, error) { // Authenticate performs user authentication against LDAP func (s *LDAPService) Authenticate(username, password string) (bool, error) { + log.Printf("[DEBUG] Authenticate: Starting authentication for user: %s", username) + userDN, err := s.GetUserDN(username) if err != nil { + log.Printf("[ERROR] Authenticate: Failed to get user DN for %s: %v", username, err) return false, fmt.Errorf("failed to get user DN: %v", err) } + log.Printf("[DEBUG] Authenticate: Retrieved user DN for %s: %s", username, userDN) // Bind as user to verify password + log.Printf("[DEBUG] Authenticate: Attempting to bind as user: %s", username) err = s.client.Bind(userDN, password) if err != nil { + log.Printf("[WARN] Authenticate: Authentication failed for user %s: %v", username, err) return false, nil // Invalid credentials, not an error } + log.Printf("[DEBUG] Authenticate: User bind successful for: %s", username) // Rebind as service account for further operations config := s.client.Config() if config.BindUser != "" { + log.Printf("[DEBUG] Authenticate: Rebinding as service account: %s", config.BindUser) err = s.client.Bind(config.BindUser, config.BindPassword) if err != nil { + log.Printf("[ERROR] Authenticate: Failed to rebind as service account: %v", err) return false, fmt.Errorf("failed to rebind as service account: %v", err) } + log.Println("[DEBUG] Authenticate: Service account rebind successful") } + log.Printf("[INFO] Authenticate: Authentication successful for user: %s", username) return true, nil } // IsAdmin checks if a user is a member of the admin group func (s *LDAPService) IsAdmin(username string) (bool, error) { + log.Printf("[DEBUG] IsAdmin: Checking admin status for user: %s", username) config := s.client.Config() // Search for admin group + log.Printf("[DEBUG] IsAdmin: Searching for admin group: %s", config.AdminGroupDN) adminGroupReq := ldapv3.NewSearchRequest( config.AdminGroupDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, @@ -92,6 +113,7 @@ func (s *LDAPService) IsAdmin(username string) (bool, error) { ) // Search for user DN + log.Printf("[DEBUG] IsAdmin: Searching for user DN for: %s", username) userDNReq := ldapv3.NewSearchRequest( config.BaseDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, @@ -102,53 +124,93 @@ func (s *LDAPService) IsAdmin(username string) (bool, error) { adminGroupEntry, err := s.client.SearchEntry(adminGroupReq) if err != nil { + log.Printf("[ERROR] IsAdmin: Failed to search admin group: %v", err) return false, fmt.Errorf("failed to search admin group: %v", err) } userEntry, err := s.client.SearchEntry(userDNReq) if err != nil { + log.Printf("[ERROR] IsAdmin: Failed to search user %s: %v", username, err) return false, fmt.Errorf("failed to search user: %v", err) } if adminGroupEntry == nil { + log.Printf("[ERROR] IsAdmin: Admin group not found: %s", config.AdminGroupDN) return false, fmt.Errorf("admin group not found") } if userEntry == nil { + log.Printf("[ERROR] IsAdmin: User not found: %s", username) return false, fmt.Errorf("user not found") } + log.Printf("[DEBUG] IsAdmin: User DN found: %s", userEntry.DN) + adminMembers := adminGroupEntry.GetAttributeValues("member") + log.Printf("[DEBUG] IsAdmin: Admin group has %d members", len(adminMembers)) + // Check if user DN is in admin group members - for _, member := range adminGroupEntry.GetAttributeValues("member") { + for _, member := range adminMembers { if member == userEntry.DN { + log.Printf("[INFO] IsAdmin: User %s is an admin", username) return true, nil } } + log.Printf("[DEBUG] IsAdmin: User %s is not an admin", username) return false, nil } // Close closes the LDAP connection func (s *LDAPService) Close() error { - return s.client.Disconnect() + log.Println("[DEBUG] Close: Closing LDAP connection") + err := s.client.Disconnect() + if err != nil { + log.Printf("[ERROR] Close: Failed to close LDAP connection: %v", err) + } else { + log.Println("[INFO] Close: LDAP connection closed successfully") + } + return err } // HealthCheck verifies that the LDAP connection is working func (s *LDAPService) HealthCheck() error { - return s.client.HealthCheck() + log.Println("[DEBUG] HealthCheck: Performing LDAP health check") + err := s.client.HealthCheck() + if err != nil { + log.Printf("[ERROR] HealthCheck: LDAP health check failed: %v", err) + } else { + log.Println("[DEBUG] HealthCheck: LDAP health check passed") + } + return err } // Reconnect attempts to reconnect to the LDAP server func (s *LDAPService) Reconnect() error { - return s.client.Connect() + log.Println("[DEBUG] Reconnect: Attempting to reconnect to LDAP server") + err := s.client.Connect() + if err != nil { + log.Printf("[ERROR] Reconnect: Failed to reconnect to LDAP server: %v", err) + } else { + log.Println("[INFO] Reconnect: Successfully reconnected to LDAP server") + } + return err } // SetPassword sets the password for a user using User struct func (s *LDAPService) SetPassword(user User, password string) error { + log.Printf("[DEBUG] SetPassword: Setting password for user: %s", user.Name) userDN, err := s.GetUserDN(user.Name) if err != nil { + log.Printf("[ERROR] SetPassword: Failed to get user DN for %s: %v", user.Name, err) return fmt.Errorf("failed to get user DN: %v", err) } + log.Printf("[DEBUG] SetPassword: Retrieved user DN: %s", userDN) - return s.SetUserPassword(userDN, password) + err = s.SetUserPassword(userDN, password) + if err != nil { + log.Printf("[ERROR] SetPassword: Failed to set password for user %s: %v", user.Name, err) + } else { + log.Printf("[INFO] SetPassword: Password set successfully for user: %s", user.Name) + } + return err } diff --git a/internal/auth/groups.go b/internal/auth/groups.go index 5781034..672f53f 100644 --- a/internal/auth/groups.go +++ b/internal/auth/groups.go @@ -2,6 +2,7 @@ package auth import ( "fmt" + "log" "regexp" "strings" "time" @@ -22,10 +23,12 @@ type Group struct { // GetGroups retrieves all groups from the KaminoGroups OU func (s *LDAPService) GetGroups() ([]Group, error) { + log.Println("[DEBUG] GetGroups: Starting to retrieve all groups from KaminoGroups OU") config := s.client.Config() // Search for all groups in the KaminoGroups OU kaminoGroupsOU := "OU=KaminoGroups," + config.BaseDN + log.Printf("[DEBUG] GetGroups: Searching in OU: %s", kaminoGroupsOU) req := ldapv3.NewSearchRequest( kaminoGroupsOU, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, @@ -36,18 +39,23 @@ func (s *LDAPService) GetGroups() ([]Group, error) { searchResult, err := s.client.Search(req) if err != nil { + log.Printf("[ERROR] GetGroups: Failed to search for groups: %v", err) return nil, fmt.Errorf("failed to search for groups: %v", err) } + log.Printf("[DEBUG] GetGroups: Found %d groups", len(searchResult.Entries)) var groups []Group for _, entry := range searchResult.Entries { cn := entry.GetAttributeValue("cn") + log.Printf("[DEBUG] GetGroups: Processing group: %s", cn) // Check if the group is protected protectedGroup, err := isProtectedGroup(cn) if err != nil { + log.Printf("[ERROR] GetGroups: Failed to determine if group %s is protected: %v", cn, err) return nil, fmt.Errorf("failed to determine if the group %s is protected: %v", cn, err) } + log.Printf("[DEBUG] GetGroups: Group %s protected status: %v", cn, protectedGroup) group := Group{ Name: cn, @@ -61,12 +69,17 @@ func (s *LDAPService) GetGroups() ([]Group, error) { // AD stores dates in GeneralizedTime format: YYYYMMDDHHMMSS.0Z if parsedTime, err := time.Parse("20060102150405.0Z", whenCreated); err == nil { group.CreatedAt = parsedTime.Format("2006-01-02 15:04:05") + log.Printf("[DEBUG] GetGroups: Group %s created at: %s", cn, group.CreatedAt) + } else { + log.Printf("[WARN] GetGroups: Failed to parse creation date for group %s: %s", cn, whenCreated) } } + log.Printf("[DEBUG] GetGroups: Group %s has %d members", cn, group.UserCount) groups = append(groups, group) } + log.Printf("[INFO] GetGroups: Successfully retrieved %d groups", len(groups)) return groups, nil } @@ -89,21 +102,27 @@ func ValidateGroupName(groupName string) error { // CreateGroup creates a new group in LDAP func (s *LDAPService) CreateGroup(groupName string) error { + log.Printf("[DEBUG] CreateGroup: Starting to create group: %s", groupName) config := s.client.Config() // Validate group name if err := ValidateGroupName(groupName); err != nil { + log.Printf("[ERROR] CreateGroup: Invalid group name %s: %v", groupName, err) return fmt.Errorf("invalid group name: %v", err) } + log.Printf("[DEBUG] CreateGroup: Group name validation passed for: %s", groupName) // Check if group already exists _, err := s.GetGroupDN(groupName) if err == nil { + log.Printf("[ERROR] CreateGroup: Group already exists: %s", groupName) return fmt.Errorf("group already exists: %s", groupName) } + log.Printf("[DEBUG] CreateGroup: Confirmed group %s does not exist", groupName) // Construct the DN for the new group groupDN := fmt.Sprintf("CN=%s,OU=KaminoGroups,%s", groupName, config.BaseDN) + log.Printf("[DEBUG] CreateGroup: Creating group with DN: %s", groupDN) // Create the add request addReq := ldapv3.NewAddRequest(groupDN, nil) @@ -116,9 +135,11 @@ func (s *LDAPService) CreateGroup(groupName string) error { err = s.client.Add(addReq) if err != nil { + log.Printf("[ERROR] CreateGroup: Failed to create group %s: %v", groupName, err) return fmt.Errorf("failed to create group %s: %v", groupName, err) } + log.Printf("[INFO] CreateGroup: Successfully created group: %s", groupName) return nil } @@ -179,30 +200,39 @@ func (s *LDAPService) RenameGroup(oldGroupName string, newGroupName string) erro // DeleteGroup deletes a group from LDAP func (s *LDAPService) DeleteGroup(groupName string) error { + log.Printf("[DEBUG] DeleteGroup: Starting to delete group: %s", groupName) + // Check if the group is protected protectedGroup, err := isProtectedGroup(groupName) if err != nil { + log.Printf("[ERROR] DeleteGroup: Failed to determine if group %s is protected: %v", groupName, err) return fmt.Errorf("failed to determine if the group %s is protected: %v", groupName, err) } if protectedGroup { + log.Printf("[ERROR] DeleteGroup: Cannot delete protected group: %s", groupName) return fmt.Errorf("cannot delete protected group: %s", groupName) } + log.Printf("[DEBUG] DeleteGroup: Group %s is not protected, proceeding with deletion", groupName) // Get the DN of the group to delete groupDN, err := s.GetGroupDN(groupName) if err != nil { + log.Printf("[ERROR] DeleteGroup: Failed to find group %s: %v", groupName, err) return fmt.Errorf("failed to find group %s: %v", groupName, err) } + log.Printf("[DEBUG] DeleteGroup: Found group DN: %s", groupDN) // Create delete request delReq := ldapv3.NewDelRequest(groupDN, nil) err = s.client.Delete(delReq) if err != nil { + log.Printf("[ERROR] DeleteGroup: Failed to delete group %s: %v", groupName, err) return fmt.Errorf("failed to delete group %s: %v", groupName, err) } + log.Printf("[INFO] DeleteGroup: Successfully deleted group: %s", groupName) return nil } @@ -267,53 +297,66 @@ func isProtectedGroup(groupName string) (bool, error) { } func (s *LDAPService) AddUsersToGroup(groupName string, usernames []string) error { + log.Printf("[DEBUG] AddUsersToGroup: Adding %d users to group %s", len(usernames), groupName) if len(usernames) == 0 { + log.Printf("[DEBUG] AddUsersToGroup: No users to add to group %s", groupName) return nil // Nothing to do } // Get group DN first groupDN, err := s.GetGroupDN(groupName) if err != nil { + log.Printf("[ERROR] AddUsersToGroup: Failed to find group %s: %v", groupName, err) return fmt.Errorf("failed to find group %s: %v", groupName, err) } + log.Printf("[DEBUG] AddUsersToGroup: Found group DN: %s", groupDN) // Get all user DNs, filtering out invalid users var validUserDNs []string var invalidUsers []string + log.Printf("[DEBUG] AddUsersToGroup: Validating %d usernames", len(usernames)) for _, username := range usernames { if username == "" { + log.Printf("[DEBUG] AddUsersToGroup: Skipping empty username") continue // Skip empty usernames } userDN, err := s.GetUserDN(username) if err != nil { + log.Printf("[WARN] AddUsersToGroup: Invalid user %s: %v", username, err) invalidUsers = append(invalidUsers, username) continue } + log.Printf("[DEBUG] AddUsersToGroup: Valid user %s with DN: %s", username, userDN) validUserDNs = append(validUserDNs, userDN) } // If no valid users found, return error if len(validUserDNs) == 0 { if len(invalidUsers) > 0 { + log.Printf("[ERROR] AddUsersToGroup: No valid users found. Invalid users: %s", strings.Join(invalidUsers, ", ")) return fmt.Errorf("no valid users found. Invalid users: %s", strings.Join(invalidUsers, ", ")) } + log.Printf("[ERROR] AddUsersToGroup: No users provided") return fmt.Errorf("no users provided") } + log.Printf("[DEBUG] AddUsersToGroup: Attempting bulk add of %d valid users", len(validUserDNs)) // Add all users to the group in a single LDAP modify operation modifyReq := ldapv3.NewModifyRequest(groupDN, nil) modifyReq.Add("member", validUserDNs) err = s.client.Modify(modifyReq) if err != nil { + log.Printf("[WARN] AddUsersToGroup: Bulk add failed, trying individual adds: %v", err) // If bulk add fails, try adding users individually to identify which ones are already members var alreadyMembers []string var failedUsers []string var successCount int for i, userDN := range validUserDNs { + log.Printf("[DEBUG] AddUsersToGroup: Individual add attempt for user %s", usernames[i]) individualReq := ldapv3.NewModifyRequest(groupDN, nil) individualReq.Add("member", []string{userDN}) @@ -321,11 +364,14 @@ func (s *LDAPService) AddUsersToGroup(groupName string, usernames []string) erro if individualErr != nil { if strings.Contains(strings.ToLower(individualErr.Error()), "already exists") || strings.Contains(strings.ToLower(individualErr.Error()), "attribute or value exists") { + log.Printf("[DEBUG] AddUsersToGroup: User %s already member", usernames[i]) alreadyMembers = append(alreadyMembers, usernames[i]) } else { + log.Printf("[ERROR] AddUsersToGroup: Failed to add user %s: %v", usernames[i], individualErr) failedUsers = append(failedUsers, fmt.Sprintf("%s: %v", usernames[i], individualErr)) } } else { + log.Printf("[DEBUG] AddUsersToGroup: Successfully added user %s", usernames[i]) successCount++ } } @@ -346,11 +392,15 @@ func (s *LDAPService) AddUsersToGroup(groupName string, usernames []string) erro } if len(failedUsers) > 0 && successCount == 0 { + log.Printf("[ERROR] AddUsersToGroup: All operations failed: %s", strings.Join(messages, "; ")) return fmt.Errorf("failed to add users to group %s: %s", groupName, strings.Join(messages, "; ")) } // If some succeeded, just log the status (don't return error for partial success) + log.Printf("[INFO] AddUsersToGroup result: %s", strings.Join(messages, "; ")) fmt.Printf("AddUsersToGroup result: %s\n", strings.Join(messages, "; ")) + } else { + log.Printf("[INFO] AddUsersToGroup: Bulk add successful for group %s", groupName) } return nil diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go index 580dca5..4436965 100644 --- a/internal/auth/ldap.go +++ b/internal/auth/ldap.go @@ -3,6 +3,7 @@ package auth import ( "crypto/tls" "fmt" + "log" "strings" "sync" "time" @@ -36,10 +37,14 @@ func NewClient(config *Config) *Client { // LoadConfig loads and validates LDAP configuration from environment variables func LoadConfig() (*Config, error) { + log.Println("[DEBUG] LoadConfig: Loading LDAP configuration from environment variables") var config Config if err := envconfig.Process("", &config); err != nil { + log.Printf("[ERROR] LoadConfig: Failed to process LDAP configuration: %v", err) return nil, fmt.Errorf("failed to process LDAP configuration: %w", err) } + log.Printf("[DEBUG] LoadConfig: LDAP configuration loaded - URL: %s, BaseDN: %s, AdminGroupDN: %s", + config.URL, config.BaseDN, config.AdminGroupDN) return &config, nil } @@ -47,52 +52,78 @@ func LoadConfig() (*Config, error) { // Connect establishes connection to LDAP server func (c *Client) Connect() error { + log.Println("[DEBUG] LDAP Connect: Attempting to establish LDAP connection") c.mutex.Lock() defer c.mutex.Unlock() conn, err := c.dial() if err != nil { + log.Printf("[ERROR] LDAP Connect: Failed to dial LDAP server: %v", err) c.connected = false return fmt.Errorf("failed to connect to LDAP server: %v", err) } + log.Printf("[DEBUG] LDAP Connect: Successfully dialed LDAP server: %s", c.config.URL) if c.config.BindUser != "" { + log.Printf("[DEBUG] LDAP Connect: Binding as service user: %s", c.config.BindUser) err = conn.Bind(c.config.BindUser, c.config.BindPassword) if err != nil { + log.Printf("[ERROR] LDAP Connect: Failed to bind as service user: %v", err) conn.Close() c.connected = false return fmt.Errorf("failed to bind to LDAP server: %v", err) } + log.Println("[DEBUG] LDAP Connect: Service user bind successful") + } else { + log.Println("[DEBUG] LDAP Connect: No bind user configured, using anonymous bind") } c.conn = conn c.connected = true + log.Println("[INFO] LDAP Connect: Connection established successfully") return nil } // dial creates a new LDAP connection func (c *Client) dial() (ldap.Client, error) { + log.Printf("[DEBUG] LDAP dial: Attempting to dial %s", c.config.URL) var dialOpts []ldap.DialOpt if strings.HasPrefix(c.config.URL, "ldaps://") { + log.Printf("[DEBUG] LDAP dial: Using LDAPS with TLS config - SkipTLSVerify: %v", c.config.SkipTLSVerify) dialOpts = append(dialOpts, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: c.config.SkipTLSVerify, MinVersion: tls.VersionTLS12})) } else { + log.Printf("[ERROR] LDAP dial: Unsupported URL scheme: %s", c.config.URL) return nil, fmt.Errorf("only ldaps:// is supported") } - return ldap.DialURL(c.config.URL, dialOpts...) + + conn, err := ldap.DialURL(c.config.URL, dialOpts...) + if err != nil { + log.Printf("[ERROR] LDAP dial: Failed to dial %s: %v", c.config.URL, err) + } else { + log.Printf("[DEBUG] LDAP dial: Successfully dialed %s", c.config.URL) + } + return conn, err } // Disconnect closes the LDAP connection func (c *Client) Disconnect() error { + log.Println("[DEBUG] LDAP Disconnect: Closing LDAP connection") c.mutex.Lock() defer c.mutex.Unlock() if c.conn == nil { + log.Println("[DEBUG] LDAP Disconnect: No connection to close") c.connected = false return nil } err := c.conn.Close() c.connected = false + if err != nil { + log.Printf("[ERROR] LDAP Disconnect: Error closing connection: %v", err) + } else { + log.Println("[INFO] LDAP Disconnect: Connection closed successfully") + } return err } @@ -117,50 +148,72 @@ func (c *Client) isConnectionError(err error) bool { // reconnect attempts to reconnect to the LDAP server func (c *Client) reconnect() error { + log.Println("[DEBUG] LDAP reconnect: Attempting to reconnect to LDAP server") c.mutex.Lock() defer c.mutex.Unlock() // Close existing connection if any if c.conn != nil { + log.Println("[DEBUG] LDAP reconnect: Closing existing connection") c.conn.Close() } c.connected = false // Wait a moment before retrying + log.Println("[DEBUG] LDAP reconnect: Waiting 100ms before retry") time.Sleep(100 * time.Millisecond) // Attempt reconnection + log.Println("[DEBUG] LDAP reconnect: Attempting to dial server") conn, err := c.dial() if err != nil { + log.Printf("[ERROR] LDAP reconnect: Failed to reconnect: %v", err) return fmt.Errorf("failed to reconnect to LDAP server: %v", err) } if c.config.BindUser != "" { + log.Printf("[DEBUG] LDAP reconnect: Rebinding as service user: %s", c.config.BindUser) err = conn.Bind(c.config.BindUser, c.config.BindPassword) if err != nil { + log.Printf("[ERROR] LDAP reconnect: Failed to bind after reconnection: %v", err) conn.Close() return fmt.Errorf("failed to bind after reconnection: %v", err) } + log.Println("[DEBUG] LDAP reconnect: Service user rebind successful") } c.conn = conn c.connected = true + log.Println("[INFO] LDAP reconnect: Reconnection successful") return nil } // Bind performs LDAP bind operation func (c *Client) Bind(userDN, password string) error { - return c.executeWithRetry(func() error { + log.Printf("[DEBUG] LDAP Bind: Attempting to bind as user: %s", userDN) + err := c.executeWithRetry(func() error { c.mutex.RLock() conn := c.conn c.mutex.RUnlock() if conn == nil { + log.Println("[ERROR] LDAP Bind: No LDAP connection available") return fmt.Errorf("no LDAP connection available") } - return conn.Bind(userDN, password) + bindErr := conn.Bind(userDN, password) + if bindErr != nil { + log.Printf("[DEBUG] LDAP Bind: Bind failed for %s: %v", userDN, bindErr) + } else { + log.Printf("[DEBUG] LDAP Bind: Bind successful for %s", userDN) + } + return bindErr }, 2) // Retry up to 2 times + + if err != nil { + log.Printf("[ERROR] LDAP Bind: Final bind failure for %s: %v", userDN, err) + } + return err } // Config returns the LDAP configuration @@ -200,6 +253,7 @@ func (c *Client) validateBind() error { // HealthCheck performs a simple search to verify the connection is working func (c *Client) HealthCheck() error { + log.Println("[DEBUG] LDAP HealthCheck: Performing health check") req := ldap.NewSearchRequest( c.config.BaseDN, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false, @@ -208,18 +262,31 @@ func (c *Client) HealthCheck() error { nil, ) - return c.executeWithRetry(func() error { + err := c.executeWithRetry(func() error { c.mutex.RLock() conn := c.conn c.mutex.RUnlock() if conn == nil { + log.Println("[ERROR] LDAP HealthCheck: No LDAP connection available") return fmt.Errorf("no LDAP connection available") } - _, err := conn.Search(req) - return err + _, searchErr := conn.Search(req) + if searchErr != nil { + log.Printf("[DEBUG] LDAP HealthCheck: Search failed: %v", searchErr) + } else { + log.Println("[DEBUG] LDAP HealthCheck: Search successful") + } + return searchErr }, 2) + + if err != nil { + log.Printf("[ERROR] LDAP HealthCheck: Health check failed: %v", err) + } else { + log.Println("[DEBUG] LDAP HealthCheck: Health check passed") + } + return err } /* @@ -229,8 +296,11 @@ func (c *Client) HealthCheck() error { // executeWithRetry executes an LDAP operation with automatic retry on connection errors func (c *Client) executeWithRetry(operation func() error, maxRetries int) error { var lastErr error + log.Printf("[DEBUG] executeWithRetry: Starting operation with max %d retries", maxRetries) for attempt := 0; attempt <= maxRetries; attempt++ { + log.Printf("[DEBUG] executeWithRetry: Attempt %d/%d", attempt+1, maxRetries+1) + c.mutex.RLock() connected := c.connected conn := c.conn @@ -238,15 +308,20 @@ func (c *Client) executeWithRetry(operation func() error, maxRetries int) error // Check if we need to reconnect if !connected || conn == nil { + log.Printf("[DEBUG] executeWithRetry: Connection not available (connected: %v, conn: %v), attempting reconnect", connected, conn != nil) if reconnectErr := c.reconnect(); reconnectErr != nil { + log.Printf("[ERROR] executeWithRetry: Reconnect attempt %d failed: %v", attempt+1, reconnectErr) lastErr = reconnectErr continue } } else { // Validate that the bind is still active + log.Println("[DEBUG] executeWithRetry: Validating existing bind") if bindErr := c.validateBind(); bindErr != nil { if c.isConnectionError(bindErr) { + log.Printf("[DEBUG] executeWithRetry: Bind validation failed with connection error: %v", bindErr) if reconnectErr := c.reconnect(); reconnectErr != nil { + log.Printf("[ERROR] executeWithRetry: Reconnect after bind validation failure: %v", reconnectErr) lastErr = reconnectErr continue } @@ -254,31 +329,39 @@ func (c *Client) executeWithRetry(operation func() error, maxRetries int) error } } + log.Printf("[DEBUG] executeWithRetry: Executing operation (attempt %d)", attempt+1) err := operation() if err == nil { + log.Printf("[DEBUG] executeWithRetry: Operation successful on attempt %d", attempt+1) return nil } lastErr = err + log.Printf("[DEBUG] executeWithRetry: Operation failed on attempt %d: %v", attempt+1, err) // If it's not a connection error, don't retry if !c.isConnectionError(err) { + log.Printf("[DEBUG] executeWithRetry: Not a connection error, not retrying: %v", err) return err } // Mark as disconnected and try to reconnect + log.Println("[DEBUG] executeWithRetry: Connection error detected, marking as disconnected") c.mutex.Lock() c.connected = false c.mutex.Unlock() // Don't reconnect on the last attempt if attempt < maxRetries { + log.Printf("[DEBUG] executeWithRetry: Attempting reconnect before retry %d", attempt+2) if reconnectErr := c.reconnect(); reconnectErr != nil { + log.Printf("[ERROR] executeWithRetry: Pre-retry reconnect failed: %v", reconnectErr) lastErr = reconnectErr } } } + log.Printf("[ERROR] executeWithRetry: Operation failed after %d retries, last error: %v", maxRetries+1, lastErr) return fmt.Errorf("operation failed after %d retries, last error: %v", maxRetries+1, lastErr) } @@ -407,6 +490,7 @@ func (c *Client) ModifyDN(req *ldap.ModifyDNRequest) error { // GetUserDN retrieves the DN for a given username func (s *LDAPService) GetUserDN(username string) (string, error) { + log.Printf("[DEBUG] GetUserDN: Searching for user DN for username: %s", username) config := s.client.Config() req := ldap.NewSearchRequest( @@ -416,21 +500,26 @@ func (s *LDAPService) GetUserDN(username string) (string, error) { []string{"dn"}, nil, ) + log.Printf("[DEBUG] GetUserDN: Search filter: (&(objectClass=user)(sAMAccountName=%s)), BaseDN: %s", username, config.BaseDN) entry, err := s.client.SearchEntry(req) if err != nil { + log.Printf("[ERROR] GetUserDN: Failed to search for user %s: %v", username, err) return "", fmt.Errorf("failed to search for user: %v", err) } if entry == nil { + log.Printf("[ERROR] GetUserDN: User not found: %s", username) return "", fmt.Errorf("user not found") } + log.Printf("[DEBUG] GetUserDN: Found user DN for %s: %s", username, entry.DN) return entry.DN, nil } // GetGroupDN retrieves the DN for a given group name from KaminoGroups OU func (s *LDAPService) GetGroupDN(groupName string) (string, error) { + log.Printf("[DEBUG] GetGroupDN: Searching for group DN for group: %s", groupName) config := s.client.Config() // Search for the group in KaminoGroups OU @@ -442,15 +531,19 @@ func (s *LDAPService) GetGroupDN(groupName string) (string, error) { []string{"dn"}, nil, ) + log.Printf("[DEBUG] GetGroupDN: Search filter: (&(objectClass=group)(cn=%s)), SearchBase: %s", groupName, kaminoGroupsOU) entry, err := s.client.SearchEntry(req) if err != nil { + log.Printf("[ERROR] GetGroupDN: Failed to search for group %s: %v", groupName, err) return "", fmt.Errorf("failed to search for group: %v", err) } if entry == nil { + log.Printf("[ERROR] GetGroupDN: Group not found: %s", groupName) return "", fmt.Errorf("group not found: %s", groupName) } + log.Printf("[DEBUG] GetGroupDN: Found group DN for %s: %s", groupName, entry.DN) return entry.DN, nil } diff --git a/internal/auth/users.go b/internal/auth/users.go index a34840a..bd0be35 100644 --- a/internal/auth/users.go +++ b/internal/auth/users.go @@ -3,6 +3,7 @@ package auth import ( "encoding/binary" "fmt" + "log" "regexp" "strconv" "strings" @@ -50,10 +51,12 @@ func (u *UserRegistrationInfo) Validate() error { // GetUsers retrieves all users from LDAP func (s *LDAPService) GetUsers() ([]User, error) { + log.Println("[DEBUG] GetUsers: Starting to retrieve all users from KaminoUsers group") config := s.client.Config() // Create search request to find all user objects who are members of KaminoUsers group kaminoUsersGroupDN := "CN=KaminoUsers,OU=KaminoGroups," + config.BaseDN + log.Printf("[DEBUG] GetUsers: Searching for users in group: %s", kaminoUsersGroupDN) req := ldapv3.NewSearchRequest( config.BaseDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, fmt.Sprintf("(&(objectClass=user)(sAMAccountName=*)(memberOf=%s))", kaminoUsersGroupDN), // Filter for users in KaminoUsers group @@ -64,13 +67,18 @@ func (s *LDAPService) GetUsers() ([]User, error) { // Perform the search searchResult, err := s.client.Search(req) if err != nil { + log.Printf("[ERROR] GetUsers: Failed to search for users: %v", err) return nil, fmt.Errorf("failed to search for users: %v", err) } + log.Printf("[DEBUG] GetUsers: Found %d users", len(searchResult.Entries)) var users = make([]User, 0) for _, entry := range searchResult.Entries { + username := entry.GetAttributeValue("sAMAccountName") + log.Printf("[DEBUG] GetUsers: Processing user: %s", username) + user := User{ - Name: entry.GetAttributeValue("sAMAccountName"), + Name: username, } // Add creation date if available and convert it @@ -79,29 +87,42 @@ func (s *LDAPService) GetUsers() ([]User, error) { // AD stores dates in GeneralizedTime format: YYYYMMDDHHMMSS.0Z if parsedTime, err := time.Parse("20060102150405.0Z", whenCreated); err == nil { user.CreatedAt = parsedTime.Format("2006-01-02 15:04:05") + log.Printf("[DEBUG] GetUsers: User %s created at: %s", username, user.CreatedAt) + } else { + log.Printf("[WARN] GetUsers: Failed to parse creation date for user %s: %s", username, whenCreated) } } // Check if user is enabled by parsing userAccountControl - user.Enabled = isUserEnabled(entry.GetAttributeValue("userAccountControl")) + userAccountControl := entry.GetAttributeValue("userAccountControl") + user.Enabled = isUserEnabled(userAccountControl) + log.Printf("[DEBUG] GetUsers: User %s enabled status: %v (userAccountControl: %s)", username, user.Enabled, userAccountControl) // Check for admin privileges and add group memberships var groups []Group var isAdmin = false - for _, groupDN := range entry.GetAttributeValues("memberOf") { + memberOfGroups := entry.GetAttributeValues("memberOf") + log.Printf("[DEBUG] GetUsers: User %s is member of %d groups", username, len(memberOfGroups)) + + for _, groupDN := range memberOfGroups { // Check if user is admin based on group membership groupName := extractCNFromDN(groupDN) + log.Printf("[DEBUG] GetUsers: User %s member of group: %s (%s)", username, groupName, groupDN) + if groupName == "Domain Admins" || groupName == "Proxmox-Admins" { + log.Printf("[DEBUG] GetUsers: User %s identified as admin via group: %s", username, groupName) isAdmin = true } // Only include groups from Kamino-Groups OU in the groups list if !strings.Contains(strings.ToLower(groupDN), "ou=kaminogroups") { + log.Printf("[DEBUG] GetUsers: Skipping non-Kamino group for user %s: %s", username, groupName) continue } // Add group to user's groups list if groupName != "" { + log.Printf("[DEBUG] GetUsers: Adding Kamino group %s to user %s", groupName, username) groups = append(groups, Group{ Name: groupName, }) @@ -110,10 +131,13 @@ func (s *LDAPService) GetUsers() ([]User, error) { user.IsAdmin = isAdmin user.Groups = groups + log.Printf("[DEBUG] GetUsers: User %s summary - Admin: %v, Groups: %d, Enabled: %v", + username, user.IsAdmin, len(user.Groups), user.Enabled) users = append(users, user) } + log.Printf("[INFO] GetUsers: Successfully retrieved %d users", len(users)) return users, nil } @@ -321,37 +345,51 @@ func (s *LDAPService) AddToGroup(userDN string, groupDN string) error { // RegisterUser creates, configures, and enables a new user account func (s *LDAPService) CreateAndRegisterUser(userInfo UserRegistrationInfo) error { + log.Printf("[DEBUG] CreateAndRegisterUser: Starting user registration for: %s", userInfo.Username) + // Validate username and password if err := userInfo.Validate(); err != nil { + log.Printf("[ERROR] CreateAndRegisterUser: Validation failed for user %s: %v", userInfo.Username, err) return err } + log.Printf("[DEBUG] CreateAndRegisterUser: Validation passed for user: %s", userInfo.Username) // Create the user with full information userDN, err := s.CreateUser(userInfo) if err != nil { + log.Printf("[ERROR] CreateAndRegisterUser: Failed to create user %s: %v", userInfo.Username, err) return fmt.Errorf("failed to create user: %v", err) } + log.Printf("[DEBUG] CreateAndRegisterUser: User created with DN: %s", userDN) // Set the password err = s.SetUserPassword(userDN, userInfo.Password) if err != nil { + log.Printf("[ERROR] CreateAndRegisterUser: Failed to set password for user %s: %v", userInfo.Username, err) return fmt.Errorf("failed to set password: %v", err) } + log.Printf("[DEBUG] CreateAndRegisterUser: Password set for user: %s", userInfo.Username) // Add user to default user group config := s.client.Config() userGroupDN := fmt.Sprintf("CN=KaminoUsers,OU=KaminoGroups,%s", config.BaseDN) + log.Printf("[DEBUG] CreateAndRegisterUser: Adding user %s to group: %s", userInfo.Username, userGroupDN) err = s.AddToGroup(userDN, userGroupDN) if err != nil { + log.Printf("[ERROR] CreateAndRegisterUser: Failed to add user %s to group: %v", userInfo.Username, err) return fmt.Errorf("failed to add user to group: %v", err) } + log.Printf("[DEBUG] CreateAndRegisterUser: User %s added to default group", userInfo.Username) // Enable the account err = s.EnableUserAccountByDN(userDN) if err != nil { + log.Printf("[ERROR] CreateAndRegisterUser: Failed to enable account for user %s: %v", userInfo.Username, err) return fmt.Errorf("failed to enable account: %v", err) } + log.Printf("[DEBUG] CreateAndRegisterUser: Account enabled for user: %s", userInfo.Username) + log.Printf("[INFO] CreateAndRegisterUser: Successfully registered user: %s", userInfo.Username) return nil } @@ -418,34 +456,45 @@ func (s *LDAPService) RemoveUserFromGroup(username string, groupName string) err } func (s *LDAPService) DeleteUser(username string) error { + log.Printf("[DEBUG] DeleteUser: Starting deletion process for user: %s", username) + // Get user DN userDN, err := s.GetUserDN(username) if err != nil { + log.Printf("[ERROR] DeleteUser: Failed to find user %s: %v", username, err) return fmt.Errorf("failed to find user %s: %v", username, err) } + log.Printf("[DEBUG] DeleteUser: Found user DN for %s: %s", username, userDN) // Verify that the user is not an admin userGroups, err := s.GetUserGroups(userDN) if err != nil { + log.Printf("[ERROR] DeleteUser: Failed to get user groups for %s: %v", username, err) return fmt.Errorf("failed to get user groups: %v", err) } + log.Printf("[DEBUG] DeleteUser: User %s is member of %d groups", username, len(userGroups)) - isAdmin, err := isAdmin(userGroups) + isAdminUser, err := isAdmin(userGroups) if err != nil { + log.Printf("[ERROR] DeleteUser: Failed to check admin status for user %s: %v", username, err) return fmt.Errorf("failed to check if user is admin: %v", err) } - if isAdmin { + if isAdminUser { + log.Printf("[ERROR] DeleteUser: Cannot delete admin user: %s", username) return fmt.Errorf("cannot delete admin user %s", username) } + log.Printf("[DEBUG] DeleteUser: User %s is not an admin, proceeding with deletion", username) // Create delete request delReq := ldapv3.NewDelRequest(userDN, nil) err = s.client.Delete(delReq) if err != nil { + log.Printf("[ERROR] DeleteUser: Failed to delete user %s: %v", username, err) return fmt.Errorf("failed to delete user %s: %v", username, err) } + log.Printf("[INFO] DeleteUser: Successfully deleted user: %s", username) return nil } From cc5a8113b3a0213dcf45f6babcda067c7d324f2e Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Fri, 5 Sep 2025 02:20:30 -0700 Subject: [PATCH 10/23] Fixed bridge assignment for cloning and also cleaned up some proxmox/cloning functions --- internal/cloning/cloning.go | 6 +- internal/cloning/{router.go => networking.go} | 39 +++ internal/cloning/types.go | 8 +- internal/proxmox/cluster.go | 134 ++++++++-- internal/proxmox/networking.go | 36 --- internal/proxmox/pools.go | 12 +- internal/proxmox/proxmox.go | 7 +- internal/proxmox/resources.go | 103 -------- internal/proxmox/types.go | 13 +- internal/proxmox/vms.go | 231 ++++++------------ 10 files changed, 253 insertions(+), 336 deletions(-) rename internal/cloning/{router.go => networking.go} (67%) delete mode 100644 internal/proxmox/networking.go delete mode 100644 internal/proxmox/resources.go diff --git a/internal/cloning/cloning.go b/internal/cloning/cloning.go index 8160266..f2f99b9 100644 --- a/internal/cloning/cloning.go +++ b/internal/cloning/cloning.go @@ -138,7 +138,7 @@ func (cm *CloningManager) CloneTemplate(template string, targetName string, isGr // 5. Get the next available pod ID and create new pool log.Printf("Step 5: Allocating pod ID and creating new pool") - newPodID, newPodNumber, err := cm.ProxmoxService.GetNextPodID() + newPodID, newPodNumber, err := cm.ProxmoxService.GetNextPodID(cm.Config.MinPodID, cm.Config.MaxPodID) if err != nil { log.Printf("ERROR: Failed to get next pod ID: %v", err) return fmt.Errorf("failed to get next pod ID: %w", err) @@ -196,7 +196,7 @@ func (cm *CloningManager) CloneTemplate(template string, targetName string, isGr // 7. Configure VNet of all VMs log.Printf("Step 7: Configuring VNet for all VMs in pool '%s'", newPoolName) log.Printf("Setting VNet to: '%s' for pod number %d", vnetName, newPodNumber) - err = cm.ProxmoxService.SetPodVnet(newPoolName, vnetName) + err = cm.SetPodVnet(newPoolName, vnetName) if err != nil { log.Printf("ERROR: Failed to configure VNet '%s' for pool '%s': %v", vnetName, newPoolName, err) errors = append(errors, fmt.Sprintf("failed to update pod vnet: %v", err)) @@ -209,7 +209,7 @@ func (cm *CloningManager) CloneTemplate(template string, targetName string, isGr if newRouter != nil { log.Printf("Waiting for router disk availability (VMID: %d, Node: %s, Timeout: %v)", newRouter.VMID, newRouter.Node, cm.Config.RouterWaitTimeout) - err = cm.ProxmoxService.WaitForDiskAvailability(newRouter.Node, newRouter.VMID, cm.Config.RouterWaitTimeout) + err = cm.ProxmoxService.WaitForDisk(newRouter.Node, newRouter.VMID, cm.Config.RouterWaitTimeout) if err != nil { log.Printf("ERROR: Router disk unavailable (VMID: %d): %v", newRouter.VMID, err) errors = append(errors, fmt.Sprintf("router disk unavailable: %v", err)) diff --git a/internal/cloning/router.go b/internal/cloning/networking.go similarity index 67% rename from internal/cloning/router.go rename to internal/cloning/networking.go index e93cabe..3eb127f 100644 --- a/internal/cloning/router.go +++ b/internal/cloning/networking.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "math" + "regexp" "time" "github.com/cpp-cyber/proclone/internal/tools" @@ -78,3 +79,41 @@ func (cm *CloningManager) configurePodRouter(podNumber int, node string, vmid in log.Printf("Successfully configured router for pod %d on node %s, VMID %d", podNumber, node, vmid) return nil } + +// SetPodVnet configures the VNet for all VMs in a pod +func (cm *CloningManager) SetPodVnet(poolName string, vnetName string) error { + // Get all VMs in the pool + vms, err := cm.ProxmoxService.GetPoolVMs(poolName) + if err != nil { + return fmt.Errorf("failed to get pool VMs: %w", err) + } + + routerRegex := regexp.MustCompile(`(?i).*(router|pfsense).*`) + + for _, vm := range vms { + vnet := "net0" + + // Detect if VM is a router based on its name (lazy way but requires fewer API calls) + if routerRegex.MatchString(vm.Name) { + vnet = "net1" + } + + // Update VM network configuration + reqBody := map[string]string{ + vnet: fmt.Sprintf("virtio,bridge=%s,firewall=1", vnetName), + } + + req := tools.ProxmoxAPIRequest{ + Method: "PUT", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/config", vm.NodeName, vm.VmId), + RequestBody: reqBody, + } + + _, err := cm.ProxmoxService.GetRequestHelper().MakeRequest(req) + if err != nil { + return fmt.Errorf("failed to update network for VM %d: %w", vm.VmId, err) + } + } + + return nil +} diff --git a/internal/cloning/types.go b/internal/cloning/types.go index 35868d9..4bad64b 100644 --- a/internal/cloning/types.go +++ b/internal/cloning/types.go @@ -15,13 +15,13 @@ type Config struct { RouterName string `envconfig:"ROUTER_NAME" default:"1-1NAT-pfsense"` RouterVMID int `envconfig:"ROUTER_VMID"` RouterNode string `envconfig:"ROUTER_NODE"` - MinPodID int `envconfig:"MIN_POD_ID" default:"1000"` - MaxPodID int `envconfig:"MAX_POD_ID" default:"1255"` + MinPodID int `envconfig:"MIN_POD_ID" default:"1001"` + MaxPodID int `envconfig:"MAX_POD_ID" default:"1250"` CloneTimeout time.Duration `envconfig:"CLONE_TIMEOUT" default:"3m"` RouterWaitTimeout time.Duration `envconfig:"ROUTER_WAIT_TIMEOUT" default:"120s"` SDNApplyTimeout time.Duration `envconfig:"SDN_APPLY_TIMEOUT" default:"30s"` - WANScriptPath string `envconfig:"WAN_SCRIPT_PATH" default:"/opt/scripts/change-wan-ip.sh"` - VIPScriptPath string `envconfig:"VIP_SCRIPT_PATH" default:"/opt/scripts/change-vip-subnet.sh"` + WANScriptPath string `envconfig:"WAN_SCRIPT_PATH" default:"/home/change-wan-ip.sh"` + VIPScriptPath string `envconfig:"VIP_SCRIPT_PATH" default:"/home/change-vip-subnet.sh"` WANIPBase string `envconfig:"WAN_IP_BASE" default:"172.16."` } diff --git a/internal/proxmox/cluster.go b/internal/proxmox/cluster.go index 4e870f7..3648da7 100644 --- a/internal/proxmox/cluster.go +++ b/internal/proxmox/cluster.go @@ -2,24 +2,14 @@ package proxmox import ( "fmt" + "log" "github.com/cpp-cyber/proclone/internal/tools" ) -// GetClusterResources retrieves all cluster resources from the Proxmox cluster -func (c *Client) GetClusterResources(getParams string) ([]VirtualResource, error) { - req := tools.ProxmoxAPIRequest{ - Method: "GET", - Endpoint: fmt.Sprintf("/cluster/resources?%s", getParams), - } - - var resources []VirtualResource - if err := c.RequestHelper.MakeRequestAndUnmarshal(req, &resources); err != nil { - return nil, fmt.Errorf("failed to get cluster resources: %w", err) - } - - return resources, nil -} +// ================================================= +// PUBLIC FUNCTIONS +// ================================================= // GetNodeStatus retrieves detailed status for a specific node func (c *Client) GetNodeStatus(nodeName string) (*ProxmoxNodeStatus, error) { @@ -36,6 +26,21 @@ func (c *Client) GetNodeStatus(nodeName string) (*ProxmoxNodeStatus, error) { return &nodeStatus, nil } +// GetClusterResources retrieves all cluster resources from the Proxmox cluster +func (c *Client) GetClusterResources(getParams string) ([]VirtualResource, error) { + req := tools.ProxmoxAPIRequest{ + Method: "GET", + Endpoint: fmt.Sprintf("/cluster/resources?%s", getParams), + } + + var resources []VirtualResource + if err := c.RequestHelper.MakeRequestAndUnmarshal(req, &resources); err != nil { + return nil, fmt.Errorf("failed to get cluster resources: %w", err) + } + + return resources, nil +} + // GetClusterResourceUsage retrieves resource usage for the Proxmox cluster func (c *Client) GetClusterResourceUsage() (*ClusterResourceUsageResponse, error) { resources, err := c.GetClusterResources("") @@ -103,3 +108,104 @@ func (c *Client) FindBestNode() (string, error) { return bestNode, nil } + +// ================================================= +// PRIVATE FUNCTIONS +// ================================================= + +// collectNodeResourceUsage gathers resource usage data for all configured nodes +func (c *Client) collectNodeResourceUsage(resources []VirtualResource) ([]NodeResourceUsage, []string) { + var nodes []NodeResourceUsage + var errors []string + + for _, nodeName := range c.Config.Nodes { + nodeUsage, err := c.getNodeResourceUsage(nodeName, resources) + if err != nil { + errorMsg := fmt.Sprintf("Error fetching status for node %s: %v", nodeName, err) + log.Printf("%s", errorMsg) + errors = append(errors, errorMsg) + continue + } + nodes = append(nodes, nodeUsage) + } + + return nodes, errors +} + +// getNodeResourceUsage retrieves resource usage for a single node +func (c *Client) getNodeResourceUsage(nodeName string, resources []VirtualResource) (NodeResourceUsage, error) { + status, err := c.GetNodeStatus(nodeName) + if err != nil { + return NodeResourceUsage{}, fmt.Errorf("failed to get node status: %w", err) + } + + usedStorage, totalStorage := getNodeStorage(&resources, nodeName) + + return NodeResourceUsage{ + Name: nodeName, + Resources: ResourceUsage{ + CPUUsage: status.CPU, + MemoryTotal: status.Memory.Total, + MemoryUsed: status.Memory.Used, + StorageTotal: int64(totalStorage), + StorageUsed: int64(usedStorage), + }, + }, nil +} + +// aggregateClusterResourceUsage calculates cluster-wide resource totals and averages +func (c *Client) aggregateClusterResourceUsage(nodes []NodeResourceUsage, resources []VirtualResource) ResourceUsage { + cluster := ResourceUsage{} + + // Aggregate node resources + for _, node := range nodes { + cluster.MemoryTotal += node.Resources.MemoryTotal + cluster.MemoryUsed += node.Resources.MemoryUsed + cluster.StorageTotal += node.Resources.StorageTotal + cluster.StorageUsed += node.Resources.StorageUsed + cluster.CPUUsage += node.Resources.CPUUsage + } + + // Add shared storage (NAS) + nasUsed, nasTotal := getStorage(&resources, "mufasa-proxmox") + cluster.StorageTotal += int64(nasTotal) + cluster.StorageUsed += int64(nasUsed) + + // Calculate average CPU usage + if len(nodes) > 0 { + cluster.CPUUsage /= float64(len(nodes)) + } + + return cluster +} + +func getNodeStorage(resources *[]VirtualResource, node string) (Used int64, Total int64) { + var used int64 = 0 + var total int64 = 0 + + for _, r := range *resources { + if r.Type == "storage" && r.NodeName == node && + (r.Storage == "local" || r.Storage == "local-lvm") && + r.RunningStatus == "available" { + used += r.Disk + total += r.MaxDisk + } + } + + return used, total +} + +func getStorage(resources *[]VirtualResource, storage string) (Used int64, Total int64) { + var used int64 = 0 + var total int64 = 0 + + for _, r := range *resources { + if r.Type == "storage" && r.Storage == storage && r.RunningStatus == "available" { + used = r.Disk + total = r.MaxDisk + break + } + } + + return used, total +} diff --git a/internal/proxmox/networking.go b/internal/proxmox/networking.go deleted file mode 100644 index 40683b4..0000000 --- a/internal/proxmox/networking.go +++ /dev/null @@ -1,36 +0,0 @@ -package proxmox - -import ( - "fmt" - - "github.com/cpp-cyber/proclone/internal/tools" -) - -// SetPodVnet configures the VNet for all VMs in a pod -func (c *Client) SetPodVnet(poolName, vnetName string) error { - // Get all VMs in the pool - vms, err := c.GetPoolVMs(poolName) - if err != nil { - return fmt.Errorf("failed to get pool VMs: %w", err) - } - - for _, vm := range vms { - // Update VM network configuration - reqBody := map[string]string{ - "net0": fmt.Sprintf("virtio,bridge=%s", vnetName), - } - - req := tools.ProxmoxAPIRequest{ - Method: "PUT", - Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/config", vm.NodeName, vm.VmId), - RequestBody: reqBody, - } - - _, err := c.RequestHelper.MakeRequest(req) - if err != nil { - return fmt.Errorf("failed to update network for VM %d: %w", vm.VmId, err) - } - } - - return nil -} diff --git a/internal/proxmox/pools.go b/internal/proxmox/pools.go index e18acef..6dcfb0f 100644 --- a/internal/proxmox/pools.go +++ b/internal/proxmox/pools.go @@ -13,7 +13,6 @@ import ( "github.com/cpp-cyber/proclone/internal/tools" ) -// GetPoolVMs retrieves all VMs in a specific pool func (c *Client) GetPoolVMs(poolName string) ([]VirtualResource, error) { req := tools.ProxmoxAPIRequest{ Method: "GET", @@ -38,7 +37,6 @@ func (c *Client) GetPoolVMs(poolName string) ([]VirtualResource, error) { return vms, nil } -// CreateNewPool creates a new pool with the given name func (c *Client) CreateNewPool(poolName string) error { reqBody := map[string]string{ "poolid": poolName, @@ -58,7 +56,6 @@ func (c *Client) CreateNewPool(poolName string) error { return nil } -// SetPoolPermission sets permissions for a pool to a user/group func (c *Client) SetPoolPermission(poolName string, targetName string, realm string, isGroup bool) error { reqBody := map[string]any{ "path": fmt.Sprintf("/pool/%s", poolName), @@ -86,7 +83,6 @@ func (c *Client) SetPoolPermission(poolName string, targetName string, realm str return nil } -// DeletePool removes a pool completely func (c *Client) DeletePool(poolName string) error { req := tools.ProxmoxAPIRequest{ Method: "DELETE", @@ -102,7 +98,6 @@ func (c *Client) DeletePool(poolName string) error { return nil } -// GetTemplatePools retrieves all template pools func (c *Client) GetTemplatePools() ([]string, error) { req := tools.ProxmoxAPIRequest{ Method: "GET", @@ -126,7 +121,6 @@ func (c *Client) GetTemplatePools() ([]string, error) { return templatePools, nil } -// IsPoolEmpty checks if a pool is empty (contains no VMs) func (c *Client) IsPoolEmpty(poolName string) (bool, error) { poolVMs, err := c.GetPoolVMs(poolName) if err != nil { @@ -172,7 +166,7 @@ func (c *Client) WaitForPoolEmpty(poolName string, timeout time.Duration) error } // GetNextPodID finds the next available pod ID between MIN_POD_ID and MAX_POD_ID -func (c *Client) GetNextPodID() (string, int, error) { +func (c *Client) GetNextPodID(minPodID int, maxPodID int) (string, int, error) { // Get all existing pools req := tools.ProxmoxAPIRequest{ Method: "GET", @@ -191,7 +185,7 @@ func (c *Client) GetNextPodID() (string, int, error) { for _, pool := range poolsResponse { if len(pool.PoolID) >= 4 { if id, err := strconv.Atoi(pool.PoolID[:4]); err == nil { - if id >= 1001 && id <= 1255 { // MIN_POD_ID and MAX_POD_ID constants + if id >= minPodID && id <= maxPodID { usedIDs = append(usedIDs, id) } } @@ -201,7 +195,7 @@ func (c *Client) GetNextPodID() (string, int, error) { sort.Ints(usedIDs) // Find first available ID - for i := 1001; i <= 1255; i++ { // MIN_POD_ID to MAX_POD_ID + for i := minPodID; i <= maxPodID; i++ { found := slices.Contains(usedIDs, i) if !found { return fmt.Sprintf("%04d", i), i - 1000, nil diff --git a/internal/proxmox/proxmox.go b/internal/proxmox/proxmox.go index 884906f..3ecabd0 100644 --- a/internal/proxmox/proxmox.go +++ b/internal/proxmox/proxmox.go @@ -33,7 +33,7 @@ type Service interface { FindBestNode() (string, error) // Pod Management - GetNextPodID() (string, int, error) + GetNextPodID(minPodID int, maxPodID int) (string, int, error) // VM Management GetVMs() ([]VirtualResource, error) @@ -45,7 +45,7 @@ type Service interface { ConvertVMToTemplate(node string, vmID int) error CloneVM(sourceVM VM, newPoolName string) (*VM, error) WaitForCloneCompletion(vm *VM, timeout time.Duration) error - WaitForDiskAvailability(node string, vmid int, maxWait time.Duration) error + WaitForDisk(node string, vmid int, maxWait time.Duration) error WaitForRunning(vm VM) error WaitForStopped(vm VM) error @@ -57,9 +57,6 @@ type Service interface { IsPoolEmpty(poolName string) (bool, error) WaitForPoolEmpty(poolName string, timeout time.Duration) error - // Network Management - SetPodVnet(poolName, vnetName string) error - // Template Management GetTemplatePools() ([]string, error) diff --git a/internal/proxmox/resources.go b/internal/proxmox/resources.go deleted file mode 100644 index 27c946c..0000000 --- a/internal/proxmox/resources.go +++ /dev/null @@ -1,103 +0,0 @@ -package proxmox - -import ( - "fmt" - "log" -) - -func getNodeStorage(resources *[]VirtualResource, node string) (Used int64, Total int64) { - var used int64 = 0 - var total int64 = 0 - - for _, r := range *resources { - if r.Type == "storage" && r.NodeName == node && - (r.Storage == "local" || r.Storage == "local-lvm") && - r.RunningStatus == "available" { - used += r.Disk - total += r.MaxDisk - } - } - - return used, total -} - -func getStorage(resources *[]VirtualResource, storage string) (Used int64, Total int64) { - var used int64 = 0 - var total int64 = 0 - - for _, r := range *resources { - if r.Type == "storage" && r.Storage == storage && r.RunningStatus == "available" { - used = r.Disk - total = r.MaxDisk - break - } - } - - return used, total -} - -// collectNodeResourceUsage gathers resource usage data for all configured nodes -func (c *Client) collectNodeResourceUsage(resources []VirtualResource) ([]NodeResourceUsage, []string) { - var nodes []NodeResourceUsage - var errors []string - - for _, nodeName := range c.Config.Nodes { - nodeUsage, err := c.getNodeResourceUsage(nodeName, resources) - if err != nil { - errorMsg := fmt.Sprintf("Error fetching status for node %s: %v", nodeName, err) - log.Printf("%s", errorMsg) - errors = append(errors, errorMsg) - continue - } - nodes = append(nodes, nodeUsage) - } - - return nodes, errors -} - -// getNodeResourceUsage retrieves resource usage for a single node -func (c *Client) getNodeResourceUsage(nodeName string, resources []VirtualResource) (NodeResourceUsage, error) { - status, err := c.GetNodeStatus(nodeName) - if err != nil { - return NodeResourceUsage{}, fmt.Errorf("failed to get node status: %w", err) - } - - usedStorage, totalStorage := getNodeStorage(&resources, nodeName) - - return NodeResourceUsage{ - Name: nodeName, - Resources: ResourceUsage{ - CPUUsage: status.CPU, - MemoryTotal: status.Memory.Total, - MemoryUsed: status.Memory.Used, - StorageTotal: int64(totalStorage), - StorageUsed: int64(usedStorage), - }, - }, nil -} - -// aggregateClusterResourceUsage calculates cluster-wide resource totals and averages -func (c *Client) aggregateClusterResourceUsage(nodes []NodeResourceUsage, resources []VirtualResource) ResourceUsage { - cluster := ResourceUsage{} - - // Aggregate node resources - for _, node := range nodes { - cluster.MemoryTotal += node.Resources.MemoryTotal - cluster.MemoryUsed += node.Resources.MemoryUsed - cluster.StorageTotal += node.Resources.StorageTotal - cluster.StorageUsed += node.Resources.StorageUsed - cluster.CPUUsage += node.Resources.CPUUsage - } - - // Add shared storage (NAS) - nasUsed, nasTotal := getStorage(&resources, "mufasa-proxmox") - cluster.StorageTotal += int64(nasTotal) - cluster.StorageUsed += int64(nasUsed) - - // Calculate average CPU usage - if len(nodes) > 0 { - cluster.CPUUsage /= float64(len(nodes)) - } - - return cluster -} diff --git a/internal/proxmox/types.go b/internal/proxmox/types.go index 082860e..6f08f52 100644 --- a/internal/proxmox/types.go +++ b/internal/proxmox/types.go @@ -1,9 +1,16 @@ package proxmox -// ConfigResponse represents VM configuration response -type ConfigResponse struct { - HardDisk string `json:"scsi0,omitempty"` +// VirtualResourceConfig represents VM configuration response +type VirtualResourceConfig struct { + HardDisk string `json:"scsi0"` Lock string `json:"lock,omitempty"` + Net0 string `json:"net0"` + Net1 string `json:"net1,omitempty"` +} + +// VirtualResourceStatus represents the status of a virtual resource +type VirtualResourceStatus struct { + Status string `json:"status"` } // VNetResponse represents the VNet API response diff --git a/internal/proxmox/vms.go b/internal/proxmox/vms.go index fd603fb..31cc098 100644 --- a/internal/proxmox/vms.go +++ b/internal/proxmox/vms.go @@ -1,7 +1,6 @@ package proxmox import ( - "encoding/json" "fmt" "math" "strconv" @@ -11,6 +10,10 @@ import ( "github.com/cpp-cyber/proclone/internal/tools" ) +// ================================================= +// PUBLIC FUNCTIONS +// ================================================= + func (c *Client) GetVMs() ([]VirtualResource, error) { vms, err := c.GetClusterResources("type=vm") if err != nil { @@ -20,80 +23,23 @@ func (c *Client) GetVMs() ([]VirtualResource, error) { } func (c *Client) StartVM(node string, vmID int) error { - if err := c.ValidateVMID(vmID); err != nil { - return err - } - - req := tools.ProxmoxAPIRequest{ - Method: "POST", - Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/status/start", node, vmID), - } - - _, err := c.RequestHelper.MakeRequest(req) - if err != nil { - return fmt.Errorf("failed to start VM: %w", err) - } + return c.vmAction(node, vmID, "start") +} - return nil +func (c *Client) StopVM(node string, vmID int) error { + return c.vmAction(node, vmID, "stop") } func (c *Client) ShutdownVM(node string, vmID int) error { - if err := c.ValidateVMID(vmID); err != nil { - return err - } - - req := tools.ProxmoxAPIRequest{ - Method: "POST", - Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/status/shutdown", node, vmID), - } - - _, err := c.RequestHelper.MakeRequest(req) - if err != nil { - return fmt.Errorf("failed to shutdown VM: %w", err) - } - - return nil + return c.vmAction(node, vmID, "shutdown") } func (c *Client) RebootVM(node string, vmID int) error { - if err := c.ValidateVMID(vmID); err != nil { - return err - } - - req := tools.ProxmoxAPIRequest{ - Method: "POST", - Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/status/reboot", node, vmID), - } - - _, err := c.RequestHelper.MakeRequest(req) - if err != nil { - return fmt.Errorf("failed to reboot VM: %w", err) - } - - return nil -} - -func (c *Client) StopVM(node string, vmID int) error { - if err := c.ValidateVMID(vmID); err != nil { - return err - } - - req := tools.ProxmoxAPIRequest{ - Method: "POST", - Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/status/stop", node, vmID), - } - - _, err := c.RequestHelper.MakeRequest(req) - if err != nil { - return fmt.Errorf("failed to stop VM: %w", err) - } - - return nil + return c.vmAction(node, vmID, "reboot") } -// DeleteVM deletes a VM completely func (c *Client) DeleteVM(node string, vmID int) error { - if err := c.ValidateVMID(vmID); err != nil { + if err := c.validateVMID(vmID); err != nil { return err } @@ -111,7 +57,7 @@ func (c *Client) DeleteVM(node string, vmID int) error { } func (c *Client) ConvertVMToTemplate(node string, vmID int) error { - if err := c.ValidateVMID(vmID); err != nil { + if err := c.validateVMID(vmID); err != nil { return err } @@ -130,7 +76,6 @@ func (c *Client) ConvertVMToTemplate(node string, vmID int) error { return nil } -// CloneVM clones a VM to a new pool func (c *Client) CloneVM(sourceVM VM, newPoolName string) (*VM, error) { // Get next available VMID req := tools.ProxmoxAPIRequest{ @@ -188,7 +133,6 @@ func (c *Client) CloneVM(sourceVM VM, newPoolName string) (*VM, error) { return newVM, nil } -// WaitForCloneCompletion waits for a clone operation to complete func (c *Client) WaitForCloneCompletion(vm *VM, timeout time.Duration) error { start := time.Now() backoff := time.Second @@ -196,49 +140,22 @@ func (c *Client) WaitForCloneCompletion(vm *VM, timeout time.Duration) error { for time.Since(start) < timeout { // Check VM status - req := tools.ProxmoxAPIRequest{ - Method: "GET", - Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/status/current", vm.Node, vm.VMID), - } - - data, err := c.RequestHelper.MakeRequest(req) + status, err := c.getVMStatus(vm.Node, vm.VMID) if err != nil { time.Sleep(backoff) backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) continue } - var statusResponse struct { - Status string `json:"status"` - } - - if err := json.Unmarshal(data, &statusResponse); err != nil { - time.Sleep(backoff) - backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) - continue - } - - if statusResponse.Status == "running" || statusResponse.Status == "stopped" { + if status == "running" || status == "stopped" { // Check if VM is locked (clone in progress) - configReq := tools.ProxmoxAPIRequest{ - Method: "GET", - Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/config", vm.Node, vm.VMID), - } - - configData, err := c.RequestHelper.MakeRequest(configReq) + configResp, err := c.getVMConfig(vm.Node, vm.VMID) if err != nil { time.Sleep(backoff) backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) continue } - var configResp ConfigResponse - if err := json.Unmarshal(configData, &configResp); err != nil { - time.Sleep(backoff) - backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) - continue - } - if configResp.Lock == "" { return nil // Clone is complete and VM is not locked } @@ -251,109 +168,77 @@ func (c *Client) WaitForCloneCompletion(vm *VM, timeout time.Duration) error { return fmt.Errorf("clone operation timed out after %v", timeout) } -// WaitForDiskAvailability waits for VM disks to become available -func (c *Client) WaitForDiskAvailability(node string, vmid int, maxWait time.Duration) error { +func (c *Client) WaitForDisk(node string, vmid int, maxWait time.Duration) error { start := time.Now() for time.Since(start) < maxWait { time.Sleep(2 * time.Second) - req := tools.ProxmoxAPIRequest{ - Method: "GET", - Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/config", node, vmid), - } - - data, err := c.RequestHelper.MakeRequest(req) + configResp, err := c.getVMConfig(node, vmid) if err != nil { continue } - var configResp ConfigResponse - if err := json.Unmarshal(data, &configResp); err != nil { - continue - } - if configResp.HardDisk != "" { - return nil + return nil // Disk is available } } return fmt.Errorf("timeout waiting for VM disks to become available") } -// WaitForRunning waits for a VM to be in running state -func (c *Client) WaitForRunning(vm VM) error { - timeout := 2 * time.Minute - start := time.Now() - - for time.Since(start) < timeout { - req := tools.ProxmoxAPIRequest{ - Method: "GET", - Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/status/current", vm.Node, vm.VMID), - } +func (c *Client) WaitForStopped(vm VM) error { + return c.waitForStatus("stopped", vm) +} - data, err := c.RequestHelper.MakeRequest(req) - if err != nil { - time.Sleep(5 * time.Second) - continue - } +func (c *Client) WaitForRunning(vm VM) error { + return c.waitForStatus("running", vm) +} - var statusResponse struct { - Status string `json:"status"` - } +// ================================================= +// PRIVATE FUNCTIONS +// ================================================= - if err := json.Unmarshal(data, &statusResponse); err != nil { - time.Sleep(5 * time.Second) - continue - } +func (c *Client) vmAction(node string, vmID int, action string) error { + if err := c.validateVMID(vmID); err != nil { + return err + } - if statusResponse.Status == "running" { - return nil - } + req := tools.ProxmoxAPIRequest{ + Method: "POST", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/status/%s", node, vmID, action), + } - time.Sleep(5 * time.Second) + _, err := c.RequestHelper.MakeRequest(req) + if err != nil { + return fmt.Errorf("failed to %s VM: %w", action, err) } - return fmt.Errorf("timeout waiting for VM to be running") + return nil } -// WaitForStopped waits for a VM to be in stopped state -func (c *Client) WaitForStopped(vm VM) error { +func (c *Client) waitForStatus(targetStatus string, vm VM) error { timeout := 2 * time.Minute start := time.Now() for time.Since(start) < timeout { - req := tools.ProxmoxAPIRequest{ - Method: "GET", - Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/status/current", vm.Node, vm.VMID), - } - - data, err := c.RequestHelper.MakeRequest(req) + currentStatus, err := c.getVMStatus(vm.Node, vm.VMID) if err != nil { time.Sleep(5 * time.Second) continue } - var statusResponse struct { - Status string `json:"status"` - } - - if err := json.Unmarshal(data, &statusResponse); err != nil { - time.Sleep(5 * time.Second) - continue - } - - if statusResponse.Status == "stopped" { + if currentStatus == targetStatus { return nil } time.Sleep(5 * time.Second) } - return fmt.Errorf("timeout waiting for VM to be stopped") + return fmt.Errorf("timeout waiting for VM to be %s", targetStatus) } -func (c *Client) ValidateVMID(vmID int) error { +func (c *Client) validateVMID(vmID int) error { // Get VMs vms, err := c.GetClusterResources("type=vm") if err != nil { @@ -373,3 +258,31 @@ func (c *Client) ValidateVMID(vmID int) error { return fmt.Errorf("VMID %d not found", vmID) } + +func (c *Client) getVMConfig(node string, VMID int) (*VirtualResourceConfig, error) { + configReq := tools.ProxmoxAPIRequest{ + Method: "GET", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/config", node, VMID), + } + + var config VirtualResourceConfig + if err := c.RequestHelper.MakeRequestAndUnmarshal(configReq, &config); err != nil { + return nil, fmt.Errorf("failed to get VM config: %w", err) + } + + return &config, nil +} + +func (c *Client) getVMStatus(node string, VMID int) (string, error) { + req := tools.ProxmoxAPIRequest{ + Method: "GET", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/status/current", node, VMID), + } + + var response VirtualResourceStatus + if err := c.RequestHelper.MakeRequestAndUnmarshal(req, &response); err != nil { + return "", fmt.Errorf("failed to get VM status: %w", err) + } + + return response.Status, nil +} From b6365718b7edb1b59251b8f9fb4a1e49f1ca3870 Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Fri, 5 Sep 2025 13:02:54 -0700 Subject: [PATCH 11/23] Moved realm to proxmox package and create SyncRealm function --- internal/cloning/cloning.go | 4 ++-- internal/cloning/types.go | 1 - internal/proxmox/cluster.go | 14 ++++++++++++++ internal/proxmox/pools.go | 6 +++--- internal/proxmox/proxmox.go | 3 ++- 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/internal/cloning/cloning.go b/internal/cloning/cloning.go index f2f99b9..5e9852f 100644 --- a/internal/cloning/cloning.go +++ b/internal/cloning/cloning.go @@ -49,7 +49,7 @@ func NewCloningManager(proxmoxService proxmox.Service, db *sql.DB, ldapService a return nil, fmt.Errorf("failed to load cloning configuration: %w", err) } - if config.Realm == "" || config.RouterVMID == 0 || config.RouterNode == "" { + if config.RouterVMID == 0 || config.RouterNode == "" { return nil, fmt.Errorf("incomplete cloning configuration") } @@ -248,7 +248,7 @@ func (cm *CloningManager) CloneTemplate(template string, targetName string, isGr // 10. Set permissions on the pool to the user/group log.Printf("Step 10: Setting permissions on pool '%s' for target '%s' (isGroup: %t)", newPoolName, targetName, isGroup) - err = cm.ProxmoxService.SetPoolPermission(newPoolName, targetName, cm.Config.Realm, isGroup) + err = cm.ProxmoxService.SetPoolPermission(newPoolName, targetName, isGroup) if err != nil { log.Printf("ERROR: Failed to set permissions on pool '%s' for '%s': %v", newPoolName, targetName, err) errors = append(errors, fmt.Sprintf("failed to update pool permissions for %s: %v", targetName, err)) diff --git a/internal/cloning/types.go b/internal/cloning/types.go index 4bad64b..1638557 100644 --- a/internal/cloning/types.go +++ b/internal/cloning/types.go @@ -11,7 +11,6 @@ import ( // Config holds the configuration for cloning operations type Config struct { - Realm string `envconfig:"REALM"` RouterName string `envconfig:"ROUTER_NAME" default:"1-1NAT-pfsense"` RouterVMID int `envconfig:"ROUTER_VMID"` RouterNode string `envconfig:"ROUTER_NODE"` diff --git a/internal/proxmox/cluster.go b/internal/proxmox/cluster.go index 3648da7..f7a3add 100644 --- a/internal/proxmox/cluster.go +++ b/internal/proxmox/cluster.go @@ -109,6 +109,20 @@ func (c *Client) FindBestNode() (string, error) { return bestNode, nil } +func (c *Client) SyncRealm() error { + req := tools.ProxmoxAPIRequest{ + Method: "POST", + Endpoint: fmt.Sprintf("/access/domains/%s/sync", c.Config.Realm), + } + + _, err := c.RequestHelper.MakeRequest(req) + if err != nil { + return fmt.Errorf("failed to sync realm: %w", err) + } + + return nil +} + // ================================================= // PRIVATE FUNCTIONS // ================================================= diff --git a/internal/proxmox/pools.go b/internal/proxmox/pools.go index 6dcfb0f..6a3d7f2 100644 --- a/internal/proxmox/pools.go +++ b/internal/proxmox/pools.go @@ -56,7 +56,7 @@ func (c *Client) CreateNewPool(poolName string) error { return nil } -func (c *Client) SetPoolPermission(poolName string, targetName string, realm string, isGroup bool) error { +func (c *Client) SetPoolPermission(poolName string, targetName string, isGroup bool) error { reqBody := map[string]any{ "path": fmt.Sprintf("/pool/%s", poolName), "roles": "PVEVMUser,PVEPoolUser", @@ -64,9 +64,9 @@ func (c *Client) SetPoolPermission(poolName string, targetName string, realm str } if isGroup { - reqBody["groups"] = fmt.Sprintf("%s-%s", targetName, realm) + reqBody["groups"] = fmt.Sprintf("%s-%s", targetName, c.Config.Realm) } else { - reqBody["users"] = fmt.Sprintf("%s@%s", targetName, realm) + reqBody["users"] = fmt.Sprintf("%s@%s", targetName, c.Config.Realm) } req := tools.ProxmoxAPIRequest{ diff --git a/internal/proxmox/proxmox.go b/internal/proxmox/proxmox.go index 3ecabd0..00173fe 100644 --- a/internal/proxmox/proxmox.go +++ b/internal/proxmox/proxmox.go @@ -19,6 +19,7 @@ type ProxmoxConfig struct { TokenSecret string `envconfig:"PROXMOX_TOKEN_SECRET" required:"true"` VerifySSL bool `envconfig:"PROXMOX_VERIFY_SSL" default:"false"` CriticalPool string `envconfig:"PROXMOX_CRITICAL_POOL"` + Realm string `envconfig:"REALM"` NodesStr string `envconfig:"PROXMOX_NODES"` Nodes []string // Parsed from NodesStr APIToken string // Computed from TokenID and TokenSecret @@ -52,7 +53,7 @@ type Service interface { // Pool Management GetPoolVMs(poolName string) ([]VirtualResource, error) CreateNewPool(poolName string) error - SetPoolPermission(poolName string, targetName string, realm string, isGroup bool) error + SetPoolPermission(poolName string, targetName string, isGroup bool) error DeletePool(poolName string) error IsPoolEmpty(poolName string) (bool, error) WaitForPoolEmpty(poolName string, timeout time.Duration) error From ab2fe4eafbf90bdfa51fafa1f4bbc7b5ebb90d2f Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Fri, 5 Sep 2025 19:53:27 -0700 Subject: [PATCH 12/23] Cleaned up handlers and cloning logging --- internal/api/handlers/auth_handler.go | 312 +++++++++++++++++++-- internal/api/handlers/cloning_handler.go | 120 ++++---- internal/api/handlers/dashboard_handler.go | 10 - internal/api/handlers/groups_handler.go | 113 -------- internal/api/handlers/proxmox_handler.go | 61 ++++ internal/api/handlers/types.go | 79 ++++-- internal/api/handlers/users_handler.go | 146 ---------- internal/api/handlers/vms_handler.go | 69 ----- internal/cloning/cloning.go | 129 +-------- internal/cloning/types.go | 12 +- internal/proxmox/cluster.go | 38 ++- internal/proxmox/proxmox.go | 2 + internal/proxmox/vms.go | 4 +- 13 files changed, 510 insertions(+), 585 deletions(-) delete mode 100644 internal/api/handlers/groups_handler.go delete mode 100644 internal/api/handlers/users_handler.go delete mode 100644 internal/api/handlers/vms_handler.go diff --git a/internal/api/handlers/auth_handler.go b/internal/api/handlers/auth_handler.go index b864919..86438d5 100644 --- a/internal/api/handlers/auth_handler.go +++ b/internal/api/handlers/auth_handler.go @@ -6,14 +6,14 @@ import ( "net/http" "github.com/cpp-cyber/proclone/internal/auth" + "github.com/cpp-cyber/proclone/internal/proxmox" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) -// AuthHandler handles HTTP authentication requests -type AuthHandler struct { - authService auth.Service -} +// ================================================= +// Login / Logout / Session Handlers +// ================================================= // NewAuthHandler creates a new authentication handler func NewAuthHandler() (*AuthHandler, error) { @@ -22,29 +22,30 @@ func NewAuthHandler() (*AuthHandler, error) { return nil, fmt.Errorf("failed to create auth service: %w", err) } + proxmoxService, err := proxmox.NewService() + if err != nil { + return nil, fmt.Errorf("failed to create proxmox service: %w", err) + } + log.Println("Auth handler initialized") return &AuthHandler{ - authService: authService, + authService: authService, + proxmoxService: proxmoxService, }, nil } // LoginHandler handles the login POST request func (h *AuthHandler) LoginHandler(c *gin.Context) { - var loginReq struct { - Username string `json:"username" binding:"required"` - Password string `json:"password" binding:"required"` - } - - if err := c.ShouldBindJSON(&loginReq); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + var req UserRequest + if !ValidateAndBind(c, &req) { return } // Authenticate user - valid, err := h.authService.Authenticate(loginReq.Username, loginReq.Password) + valid, err := h.authService.Authenticate(req.Username, req.Password) if err != nil { - log.Printf("Authentication failed for user %s: %v", loginReq.Username, err) + log.Printf("Authentication failed for user %s: %v", req.Username, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Authentication failed"}) return } @@ -56,18 +57,18 @@ func (h *AuthHandler) LoginHandler(c *gin.Context) { // Create session session := sessions.Default(c) - session.Set("id", loginReq.Username) + session.Set("id", req.Username) // Check if user is admin - isAdmin, err := h.authService.IsAdmin(loginReq.Username) + isAdmin, err := h.authService.IsAdmin(req.Username) if err != nil { - log.Printf("Error checking admin status for user %s: %v", loginReq.Username, err) + log.Printf("Error checking admin status for user %s: %v", req.Username, err) isAdmin = false } session.Set("isAdmin", isAdmin) if err := session.Save(); err != nil { - log.Printf("Failed to save session for user %s: %v", loginReq.Username, err) + log.Printf("Failed to save session for user %s: %v", req.Username, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save session"}) return } @@ -114,9 +115,8 @@ func (h *AuthHandler) SessionHandler(c *gin.Context) { } func (h *AuthHandler) RegisterHandler(c *gin.Context) { - var req CreateUserRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + var req UserRequest + if !ValidateAndBind(c, &req) { return } @@ -140,3 +140,273 @@ func (h *AuthHandler) RegisterHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "User registered successfully"}) } + +// ================================================= +// User Handlers +// ================================================= + +// ADMIN: GetUsersHandler returns a list of all users +func (h *AuthHandler) GetUsersHandler(c *gin.Context) { + users, err := h.authService.GetUsers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve users"}) + return + } + + var adminCount = 0 + var disabledCount = 0 + for _, user := range users { + if user.IsAdmin { + adminCount++ + } + if !user.Enabled { + disabledCount++ + } + } + + c.JSON(http.StatusOK, gin.H{ + "users": users, + "count": len(users), + "disabled_count": disabledCount, + "admin_count": adminCount, + }) +} + +// ADMIN: CreateUsersHandler creates new user(s) +func (h *AuthHandler) CreateUsersHandler(c *gin.Context) { + var req AdminCreateUserRequest + if !ValidateAndBind(c, &req) { + return + } + + var errors []error + + // Create users in AD + for _, user := range req.Users { + if err := h.authService.CreateAndRegisterUser(auth.UserRegistrationInfo(user)); err != nil { + errors = append(errors, fmt.Errorf("failed to create user %s: %v", user.Username, err)) + } + } + + if len(errors) > 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create users", "details": errors}) + return + } + + // Sync users to Proxmox + if err := h.proxmoxService.SyncUsers(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync users with Proxmox", "details": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"message": "Users created successfully"}) +} + +// ADMIN: DeleteUsersHandler deletes existing user(s) +func (h *AuthHandler) DeleteUsersHandler(c *gin.Context) { + var req UsersRequest + if !ValidateAndBind(c, &req) { + return + } + + var errors []error + + // Delete users in AD + for _, username := range req.Usernames { + if err := h.authService.DeleteUser(username); err != nil { + errors = append(errors, fmt.Errorf("failed to delete user %s: %v", username, err)) + } + } + + if len(errors) > 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete users", "details": errors}) + return + } + + // Sync users to Proxmox + if err := h.proxmoxService.SyncUsers(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync users with Proxmox", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Users deleted successfully"}) +} + +// ADMIN: EnableUsersHandler enables existing user(s) +func (h *AuthHandler) EnableUsersHandler(c *gin.Context) { + var req UsersRequest + if !ValidateAndBind(c, &req) { + return + } + + var errors []error + + for _, username := range req.Usernames { + if err := h.authService.EnableUserAccount(username); err != nil { + errors = append(errors, fmt.Errorf("failed to enable user %s: %v", username, err)) + } + } + + if len(errors) > 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable users", "details": errors}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Users enabled successfully"}) +} + +// ADMIN: DisableUsersHandler disables existing user(s) +func (h *AuthHandler) DisableUsersHandler(c *gin.Context) { + var req UsersRequest + if !ValidateAndBind(c, &req) { + return + } + + var errors []error + + for _, username := range req.Usernames { + if err := h.authService.DisableUserAccount(username); err != nil { + errors = append(errors, fmt.Errorf("failed to disable user %s: %v", username, err)) + } + } + + if len(errors) > 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable users", "details": errors}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Users disabled successfully"}) +} + +// ================================================= +// Group Functions +// ================================================= + +// ADMIN: SetUserGroupsHandler sets the groups for an existing user +func (h *AuthHandler) SetUserGroupsHandler(c *gin.Context) { + var req SetUserGroupsRequest + if !ValidateAndBind(c, &req) { + return + } + + if err := h.authService.SetUserGroups(req.Username, req.Groups); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to set user groups", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "User groups updated successfully"}) +} + +func (h *AuthHandler) GetGroupsHandler(c *gin.Context) { + groups, err := h.authService.GetGroups() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve groups"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "groups": groups, + "count": len(groups), + }) +} + +// ADMIN: CreateGroupsHandler creates new group(s) +func (h *AuthHandler) CreateGroupsHandler(c *gin.Context) { + var req GroupsRequest + if !ValidateAndBind(c, &req) { + return + } + + var errors []error + + // Create groups in AD + for _, group := range req.Groups { + if err := h.authService.CreateGroup(group); err != nil { + errors = append(errors, fmt.Errorf("failed to create group %s: %v", group, err)) + } + } + + if len(errors) > 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create groups", "details": errors}) + return + } + + // Sync groups to Proxmox + if err := h.proxmoxService.SyncGroups(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync groups with Proxmox", "details": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"message": "Groups created successfully"}) +} + +func (h *AuthHandler) RenameGroupHandler(c *gin.Context) { + var req RenameGroupRequest + if !ValidateAndBind(c, &req) { + return + } + + if err := h.authService.RenameGroup(req.OldName, req.NewName); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to rename group"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Group renamed successfully"}) +} + +func (h *AuthHandler) DeleteGroupsHandler(c *gin.Context) { + var req GroupsRequest + if !ValidateAndBind(c, &req) { + return + } + + var errors []error + + // Delete groups in AD + for _, group := range req.Groups { + if err := h.authService.DeleteGroup(group); err != nil { + errors = append(errors, fmt.Errorf("failed to delete group %s: %v", group, err)) + } + } + + if len(errors) > 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete groups", "details": errors}) + return + } + + // Sync groups to Proxmox + if err := h.proxmoxService.SyncGroups(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync groups with Proxmox", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Groups deleted successfully"}) +} + +func (h *AuthHandler) AddUsersHandler(c *gin.Context) { + var req ModifyGroupMembersRequest + if !ValidateAndBind(c, &req) { + return + } + + if err := h.authService.AddUsersToGroup(req.Group, req.Usernames); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add users to group"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Users added to group successfully"}) +} + +func (h *AuthHandler) RemoveUsersHandler(c *gin.Context) { + var req ModifyGroupMembersRequest + if !ValidateAndBind(c, &req) { + return + } + + if err := h.authService.RemoveUsersFromGroup(req.Group, req.Usernames); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove users from group"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Users removed from group successfully"}) +} diff --git a/internal/api/handlers/cloning_handler.go b/internal/api/handlers/cloning_handler.go index 5413dc9..9421936 100644 --- a/internal/api/handlers/cloning_handler.go +++ b/internal/api/handlers/cloning_handler.go @@ -60,19 +60,17 @@ func (ch *CloningHandler) CloneTemplateHandler(c *gin.Context) { username := session.Get("id").(string) var req CloneRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Invalid request body", - "details": err.Error(), - }) + if !ValidateAndBind(c, &req) { return } + log.Printf("User %s requested cloning of template %s", username, req.Template) + // Construct the full template pool name templatePoolName := "kamino_template_" + req.Template - err := ch.Manager.CloneTemplate(templatePoolName, username, false) - if err != nil { + if err := ch.Manager.CloneTemplate(templatePoolName, username, false); err != nil { + log.Printf("Error cloning template: %v", err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to clone template", "details": err.Error(), @@ -80,41 +78,21 @@ func (ch *CloningHandler) CloneTemplateHandler(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": fmt.Sprintf("Pod template cloned successfully for user %s", username), - "template": req.Template, - }) + log.Printf("Template %s cloned successfully for user %s", req.Template, username) + c.JSON(http.StatusOK, gin.H{"success": true}) } // ADMIN: BulkCloneTemplateHandler handles POST requests for cloning multiple templates for a list of users func (ch *CloningHandler) AdminCloneTemplateHandler(c *gin.Context) { - var req AdminCloneRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Invalid request body", - "details": err.Error(), - }) - return - } + session := sessions.Default(c) + username := session.Get("id").(string) - // Verify that template is not blank - if req.Template == "" { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "No template specified", - "details": "A template must be specified", - }) + var req AdminCloneRequest + if !ValidateAndBind(c, &req) { return } - // Verify that users and groups are not empty - if len(req.Usernames) == 0 && len(req.Groups) == 0 { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "No users or groups specified", - "details": "At least one user or group must be specified", - }) - return - } + log.Printf("%s requested bulk cloning of template %s", username, req.Template) // Construct the full template pool name templatePoolName := "kamino_template_" + req.Template @@ -138,6 +116,7 @@ func (ch *CloningHandler) AdminCloneTemplateHandler(c *gin.Context) { // Check for errors if len(errors) > 0 { + log.Printf("Admin %s encountered errors while cloning templates: %v", username, errors) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to clone templates", "details": errors, @@ -157,15 +136,13 @@ func (ch *CloningHandler) DeletePodHandler(c *gin.Context) { username := session.Get("id").(string) var req DeletePodRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Invalid request body", - "details": err.Error(), - }) + if !ValidateAndBind(c, &req) { return } - // Check if the pod belongs to the user + log.Printf("User %s requested deletion of pod %s", username, req.Pod) + + // Check if the pod belongs to the user (maybe allow users to delete group pods in the future?) if !strings.Contains(req.Pod, username) { c.JSON(http.StatusForbidden, gin.H{ "error": "You do not have permission to delete this pod", @@ -176,6 +153,7 @@ func (ch *CloningHandler) DeletePodHandler(c *gin.Context) { err := ch.Manager.DeletePod(req.Pod) if err != nil { + log.Printf("Error deleting %s pod: %v", req.Pod, err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to delete pod", "details": err.Error(), @@ -187,15 +165,16 @@ func (ch *CloningHandler) DeletePodHandler(c *gin.Context) { } func (ch *CloningHandler) AdminDeletePodHandler(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("id").(string) + var req AdminDeletePodRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Invalid request body", - "details": err.Error(), - }) + if !ValidateAndBind(c, &req) { return } + log.Printf("Admin %s requested deletion of pods: %v", username, req.Pods) + var errors []error for _, pod := range req.Pods { err := ch.Manager.DeletePod(pod) @@ -205,6 +184,7 @@ func (ch *CloningHandler) AdminDeletePodHandler(c *gin.Context) { } if len(errors) > 0 { + log.Printf("Admin %s encountered errors while deleting pods: %v", username, errors) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to delete pods", "details": errors, @@ -238,7 +218,8 @@ func (ch *CloningHandler) GetPodsHandler(c *gin.Context) { pods, err := ch.Manager.GetPods(username) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve pods for user " + username, "details": err.Error()}) + log.Printf("Error retrieving pods for user %s: %v", username, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve pods", "details": err.Error()}) return } @@ -247,7 +228,8 @@ func (ch *CloningHandler) GetPodsHandler(c *gin.Context) { templateName := strings.Replace(pods[i].Name[5:], fmt.Sprintf("_%s", username), "", 1) templateInfo, err := ch.Manager.DatabaseService.GetTemplateInfo(templateName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve template info for pod " + pods[i].Name, "details": err.Error()}) + log.Printf("Error retrieving template info for pod %s: %v", pods[i].Name, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve template info for pod", "details": err.Error()}) return } pods[i].Template = templateInfo @@ -263,19 +245,19 @@ func (ch *CloningHandler) AdminGetPodsHandler(c *gin.Context) { pods, err := ch.Manager.GetAllPods() if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve pods for user " + username, "details": err.Error()}) + log.Printf("Error retrieving all pods for admin %s: %v", username, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve pods for user", "details": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"pods": pods}) } -// Template-related handlers - // PRIVATE: GetTemplatesHandler handles GET requests for retrieving templates func (ch *CloningHandler) GetTemplatesHandler(c *gin.Context) { templates, err := ch.Manager.DatabaseService.GetTemplates() if err != nil { + log.Printf("Error retrieving templates: %v", err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to retrieve templates", "details": err.Error(), @@ -291,8 +273,12 @@ func (ch *CloningHandler) GetTemplatesHandler(c *gin.Context) { // ADMIN: GetPublishedTemplatesHandler handles GET requests for retrieving all templates func (ch *CloningHandler) AdminGetTemplatesHandler(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("id").(string) + templates, err := ch.Manager.DatabaseService.GetPublishedTemplates() if err != nil { + log.Printf("Error retrieving all templates for admin %s: %v", username, err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to retrieve all templates", "details": err.Error(), @@ -318,14 +304,18 @@ func (ch *CloningHandler) GetTemplateImageHandler(c *gin.Context) { // ADMIN: PublishTemplateHandler handles POST requests for publishing a template func (ch *CloningHandler) PublishTemplateHandler(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("id").(string) + var req PublishTemplateRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) + if !ValidateAndBind(c, &req) { return } + log.Printf("Admin %s requested publishing of template %s", username, req.Template.Name) + if err := ch.Manager.PublishTemplate(req.Template); err != nil { - log.Printf("Error publishing template: %v", err) + log.Printf("Error publishing template for admin %s: %v", username, err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to publish template", "details": err.Error(), @@ -340,13 +330,18 @@ func (ch *CloningHandler) PublishTemplateHandler(c *gin.Context) { // ADMIN: DeleteTemplateHandler handles POST requests for deleting a template func (ch *CloningHandler) DeleteTemplateHandler(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("id").(string) + var req TemplateRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) + if !ValidateAndBind(c, &req) { return } + log.Printf("Admin %s requested deletion of template %s", username, req.Template) + if err := ch.Manager.DatabaseService.DeleteTemplate(req.Template); err != nil { + log.Printf("Error deleting template for admin %s: %v", username, err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to delete template", "details": err.Error(), @@ -361,13 +356,18 @@ func (ch *CloningHandler) DeleteTemplateHandler(c *gin.Context) { // ADMIN: ToggleTemplateVisibilityHandler handles POST requests for toggling a template's visibility func (ch *CloningHandler) ToggleTemplateVisibilityHandler(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("id").(string) + var req TemplateRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) + if !ValidateAndBind(c, &req) { return } + log.Printf("Admin %s requested toggling visibility of template %s", username, req.Template) + if err := ch.Manager.DatabaseService.ToggleTemplateVisibility(req.Template); err != nil { + log.Printf("Error toggling template visibility for admin %s: %v", username, err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to toggle template visibility", "details": err.Error(), @@ -382,8 +382,14 @@ func (ch *CloningHandler) ToggleTemplateVisibilityHandler(c *gin.Context) { // ADMIN: UploadTemplateImageHandler handles POST requests for uploading a template's image func (ch *CloningHandler) UploadTemplateImageHandler(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("id").(string) + + log.Printf("Admin %s requested uploading a template image", username) + result, err := ch.Manager.DatabaseService.UploadTemplateImage(c) if err != nil { + log.Printf("Error uploading template image for admin %s: %v", username, err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to upload template image", "details": err.Error(), diff --git a/internal/api/handlers/dashboard_handler.go b/internal/api/handlers/dashboard_handler.go index 239712d..f95e1cd 100644 --- a/internal/api/handlers/dashboard_handler.go +++ b/internal/api/handlers/dashboard_handler.go @@ -22,16 +22,6 @@ func NewDashboardHandler(authHandler *AuthHandler, proxmoxHandler *ProxmoxHandle } } -// DashboardStats represents the structure of dashboard statistics -type DashboardStats struct { - UserCount int `json:"users"` - GroupCount int `json:"groups"` - PublishedTemplateCount int `json:"published_templates"` - DeployedPodCount int `json:"deployed_pods"` - VirtualMachineCount int `json:"vms"` - ClusterResourceUsage any `json:"cluster"` -} - // ADMIN: GetDashboardStatsHandler retrieves all dashboard statistics in a single request func (dh *DashboardHandler) GetDashboardStatsHandler(c *gin.Context) { stats := DashboardStats{} diff --git a/internal/api/handlers/groups_handler.go b/internal/api/handlers/groups_handler.go deleted file mode 100644 index 31a122f..0000000 --- a/internal/api/handlers/groups_handler.go +++ /dev/null @@ -1,113 +0,0 @@ -package handlers - -import ( - "fmt" - "net/http" - - "github.com/gin-gonic/gin" -) - -func (h *AuthHandler) GetGroupsHandler(c *gin.Context) { - groups, err := h.authService.GetGroups() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve groups"}) - return - } - - c.JSON(http.StatusOK, gin.H{ - "groups": groups, - "count": len(groups), - }) -} - -// ADMIN: CreateGroupsHandler creates new group(s) -func (h *AuthHandler) CreateGroupsHandler(c *gin.Context) { - var req GroupsRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group data"}) - return - } - - var errors []error - - for _, group := range req.Groups { - if err := h.authService.CreateGroup(group); err != nil { - errors = append(errors, fmt.Errorf("failed to create group %s: %v", group, err)) - } - } - - if len(errors) > 0 { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create groups", "details": errors}) - return - } - - c.JSON(http.StatusCreated, gin.H{"message": "Groups created successfully"}) -} - -func (h *AuthHandler) RenameGroupHandler(c *gin.Context) { - var req RenameGroupRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group data"}) - return - } - - if err := h.authService.RenameGroup(req.OldName, req.NewName); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to rename group"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Group renamed successfully"}) -} - -func (h *AuthHandler) DeleteGroupsHandler(c *gin.Context) { - var req GroupsRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group data"}) - return - } - - var errors []error - - for _, group := range req.Groups { - if err := h.authService.DeleteGroup(group); err != nil { - errors = append(errors, fmt.Errorf("failed to delete group %s: %v", group, err)) - } - } - - if len(errors) > 0 { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete groups", "details": errors}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Groups deleted successfully"}) -} - -func (h *AuthHandler) AddUsersHandler(c *gin.Context) { - var req ModifyGroupMembersRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group data"}) - return - } - - if err := h.authService.AddUsersToGroup(req.Group, req.Usernames); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add users to group"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Users added to group successfully"}) -} - -func (h *AuthHandler) RemoveUsersHandler(c *gin.Context) { - var req ModifyGroupMembersRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid group data"}) - return - } - - if err := h.authService.RemoveUsersFromGroup(req.Group, req.Usernames); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove users from group"}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Users removed from group successfully"}) -} diff --git a/internal/api/handlers/proxmox_handler.go b/internal/api/handlers/proxmox_handler.go index d68968b..34a75e5 100644 --- a/internal/api/handlers/proxmox_handler.go +++ b/internal/api/handlers/proxmox_handler.go @@ -32,6 +32,7 @@ func NewProxmoxHandler() (*ProxmoxHandler, error) { func (ph *ProxmoxHandler) GetClusterResourceUsageHandler(c *gin.Context) { response, err := ph.service.GetClusterResourceUsage() if err != nil { + log.Printf("Error retrieving cluster resource usage: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve cluster resource usage", "details": err.Error()}) return } @@ -40,3 +41,63 @@ func (ph *ProxmoxHandler) GetClusterResourceUsageHandler(c *gin.Context) { "cluster": response, }) } + +// ADMIN: GetVMsHandler handles GET requests for retrieving all VMs on Proxmox +func (ph *ProxmoxHandler) GetVMsHandler(c *gin.Context) { + vms, err := ph.service.GetVMs() + if err != nil { + log.Printf("Error retrieving VMs: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve VMs", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"vms": vms}) +} + +// ADMIN: StartVMHandler handles POST requests for starting a VM on Proxmox +func (ph *ProxmoxHandler) StartVMHandler(c *gin.Context) { + var req VMActionRequest + if !ValidateAndBind(c, &req) { + return + } + + if err := ph.service.StartVM(req.Node, req.VMID); err != nil { + log.Printf("Error starting VM %d on node %s: %v", req.VMID, req.Node, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start VM", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "VM started"}) +} + +// ADMIN: ShutdownVMHandler handles POST requests for shutting down a VM on Proxmox +func (ph *ProxmoxHandler) ShutdownVMHandler(c *gin.Context) { + var req VMActionRequest + if !ValidateAndBind(c, &req) { + return + } + + if err := ph.service.ShutdownVM(req.Node, req.VMID); err != nil { + log.Printf("Error shutting down VM %d on node %s: %v", req.VMID, req.Node, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to shutdown VM", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "VM shutdown"}) +} + +// ADMIN: RebootVMHandler handles POST requests for rebooting a VM on Proxmox +func (ph *ProxmoxHandler) RebootVMHandler(c *gin.Context) { + var req VMActionRequest + if !ValidateAndBind(c, &req) { + return + } + + if err := ph.service.RebootVM(req.Node, req.VMID); err != nil { + log.Printf("Error rebooting VM %d on node %s: %v", req.VMID, req.Node, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reboot VM", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "VM rebooted"}) +} diff --git a/internal/api/handlers/types.go b/internal/api/handlers/types.go index 2cf71cb..91c0327 100644 --- a/internal/api/handlers/types.go +++ b/internal/api/handlers/types.go @@ -1,68 +1,101 @@ package handlers -import "github.com/cpp-cyber/proclone/internal/cloning" +import ( + "net/http" + + "github.com/cpp-cyber/proclone/internal/auth" + "github.com/cpp-cyber/proclone/internal/cloning" + "github.com/cpp-cyber/proclone/internal/proxmox" + "github.com/gin-gonic/gin" +) // API endpoint request structures +// AuthHandler handles HTTP authentication requests +type AuthHandler struct { + authService auth.Service + proxmoxService proxmox.Service +} + type VMActionRequest struct { - Node string `json:"node"` - VMID int `json:"vmid"` + Node string `json:"node" binding:"required,min=1,max=100" validate:"alphanum"` + VMID int `json:"vmid" binding:"required,min=100,max=999999"` } type TemplateRequest struct { - Template string `json:"template"` + Template string `json:"template" binding:"required,min=1,max=100" validate:"alphanum,ascii"` } type PublishTemplateRequest struct { - Template cloning.KaminoTemplate `json:"template"` + Template cloning.KaminoTemplate `json:"template" binding:"required"` } type CloneRequest struct { - Template string `json:"template"` + Template string `json:"template" binding:"required,min=1,max=100" validate:"alphanum,ascii"` } type GroupsRequest struct { - Groups []string `json:"groups"` + Groups []string `json:"groups" binding:"required,min=1,dive,min=1,max=100" validate:"dive,alphanum,ascii"` } type AdminCloneRequest struct { - Template string `json:"template"` - Usernames []string `json:"usernames"` - Groups []string `json:"groups"` + Template string `json:"template" binding:"required,min=1,max=100" validate:"alphanum,ascii"` + Usernames []string `json:"usernames" binding:"omitempty,dive,min=1,max=100" validate:"dive,alphanum,ascii"` + Groups []string `json:"groups" binding:"omitempty,dive,min=1,max=100" validate:"dive,alphanum,ascii"` } type DeletePodRequest struct { - Pod string `json:"pod"` + Pod string `json:"pod" binding:"required,min=1,max=100" validate:"alphanum,ascii"` } type AdminDeletePodRequest struct { - Pods []string `json:"pods"` + Pods []string `json:"pods" binding:"required,min=1,dive,min=1,max=100" validate:"dive,alphanum,ascii"` } -type CreateUserRequest struct { - Username string `json:"username"` - Password string `json:"password"` +type UserRequest struct { + Username string `json:"username" binding:"required,min=3,max=50" validate:"alphanum,ascii"` + Password string `json:"password" binding:"required,min=8,max=128"` } type AdminCreateUserRequest struct { - Users []CreateUserRequest `json:"users"` + Users []UserRequest `json:"users" binding:"required,min=1,max=100,dive"` } type UsersRequest struct { - Usernames []string `json:"usernames"` + Usernames []string `json:"usernames" binding:"required,min=1,dive,min=1,max=50" validate:"dive,alphanum,ascii"` } type ModifyGroupMembersRequest struct { - Group string `json:"group"` - Usernames []string `json:"usernames"` + Group string `json:"group" binding:"required,min=1,max=100" validate:"alphanum,ascii"` + Usernames []string `json:"usernames" binding:"required,min=1,dive,min=1,max=50" validate:"dive,alphanum,ascii"` } type SetUserGroupsRequest struct { - Username string `json:"username"` - Groups []string `json:"groups"` + Username string `json:"username" binding:"required,min=3,max=50" validate:"alphanum,ascii"` + Groups []string `json:"groups" binding:"required,min=1,dive,min=1,max=100" validate:"dive,alphanum,ascii"` } type RenameGroupRequest struct { - OldName string `json:"old_name"` - NewName string `json:"new_name"` + OldName string `json:"old_name" binding:"required,min=1,max=100" validate:"alphanum,ascii"` + NewName string `json:"new_name" binding:"required,min=1,max=100" validate:"alphanum,ascii"` +} + +type DashboardStats struct { + UserCount int `json:"users"` + GroupCount int `json:"groups"` + PublishedTemplateCount int `json:"published_templates"` + DeployedPodCount int `json:"deployed_pods"` + VirtualMachineCount int `json:"vms"` + ClusterResourceUsage any `json:"cluster"` +} + +func ValidateAndBind(c *gin.Context, obj any) bool { + if err := c.ShouldBindJSON(obj); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Validation failed", + "details": "Invalid request format or missing required fields", + }) + return false + } + return true } diff --git a/internal/api/handlers/users_handler.go b/internal/api/handlers/users_handler.go deleted file mode 100644 index 36747b6..0000000 --- a/internal/api/handlers/users_handler.go +++ /dev/null @@ -1,146 +0,0 @@ -package handlers - -import ( - "fmt" - "net/http" - - "github.com/cpp-cyber/proclone/internal/auth" - "github.com/gin-gonic/gin" -) - -// ADMIN: GetUsersHandler returns a list of all users -func (h *AuthHandler) GetUsersHandler(c *gin.Context) { - users, err := h.authService.GetUsers() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve users"}) - return - } - - var adminCount = 0 - var disabledCount = 0 - for _, user := range users { - if user.IsAdmin { - adminCount++ - } - if !user.Enabled { - disabledCount++ - } - } - - c.JSON(http.StatusOK, gin.H{ - "users": users, - "count": len(users), - "disabled_count": disabledCount, - "admin_count": adminCount, - }) -} - -// ADMIN: CreateUsersHandler creates new user(s) -func (h *AuthHandler) CreateUsersHandler(c *gin.Context) { - var newUser AdminCreateUserRequest - if err := c.ShouldBindJSON(&newUser); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user data"}) - return - } - - var errors []error - for _, user := range newUser.Users { - if err := h.authService.CreateAndRegisterUser(auth.UserRegistrationInfo(user)); err != nil { - errors = append(errors, fmt.Errorf("failed to create user %s: %v", user.Username, err)) - } - } - - if len(errors) > 0 { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create users", "details": errors}) - return - } - - c.JSON(http.StatusCreated, gin.H{"message": "Users created successfully"}) -} - -// ADMIN: DeleteUsersHandler deletes existing user(s) -func (h *AuthHandler) DeleteUsersHandler(c *gin.Context) { - var req UsersRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) - return - } - - var errors []error - - for _, username := range req.Usernames { - if err := h.authService.DeleteUser(username); err != nil { - errors = append(errors, fmt.Errorf("failed to delete user %s: %v", username, err)) - } - } - - if len(errors) > 0 { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete users", "details": errors}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Users deleted successfully"}) -} - -// ADMIN: EnableUsersHandler enables existing user(s) -func (h *AuthHandler) EnableUsersHandler(c *gin.Context) { - var req UsersRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) - return - } - - var errors []error - for _, username := range req.Usernames { - if err := h.authService.EnableUserAccount(username); err != nil { - errors = append(errors, fmt.Errorf("failed to enable user %s: %v", username, err)) - } - } - - if len(errors) > 0 { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable users", "details": errors}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Users enabled successfully"}) -} - -// ADMIN: DisableUsersHandler disables existing user(s) -func (h *AuthHandler) DisableUsersHandler(c *gin.Context) { - var req UsersRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) - return - } - - var errors []error - - for _, username := range req.Usernames { - if err := h.authService.DisableUserAccount(username); err != nil { - errors = append(errors, fmt.Errorf("failed to disable user %s: %v", username, err)) - } - } - - if len(errors) > 0 { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable users", "details": errors}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Users disabled successfully"}) -} - -// ADMIN: SetUserGroupsHandler sets the groups for an existing user -func (h *AuthHandler) SetUserGroupsHandler(c *gin.Context) { - var req SetUserGroupsRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) - return - } - - if err := h.authService.SetUserGroups(req.Username, req.Groups); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to set user groups", "details": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"message": "User groups updated successfully"}) -} diff --git a/internal/api/handlers/vms_handler.go b/internal/api/handlers/vms_handler.go deleted file mode 100644 index 273fd39..0000000 --- a/internal/api/handlers/vms_handler.go +++ /dev/null @@ -1,69 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -// ADMIN: GetVMsHandler handles GET requests for retrieving all VMs on Proxmox -func (ph *ProxmoxHandler) GetVMsHandler(c *gin.Context) { - vms, err := ph.service.GetVMs() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve VMs", "details": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"vms": vms}) -} - -// ADMIN: StartVMHandler handles POST requests for starting a VM on Proxmox -func (ph *ProxmoxHandler) StartVMHandler(c *gin.Context) { - var req VMActionRequest - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) - return - } - - if err := ph.service.StartVM(req.Node, req.VMID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start VM", "details": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"status": "VM started"}) -} - -// ADMIN: ShutdownVMHandler handles POST requests for shutting down a VM on Proxmox -func (ph *ProxmoxHandler) ShutdownVMHandler(c *gin.Context) { - var req VMActionRequest - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) - return - } - - if err := ph.service.ShutdownVM(req.Node, req.VMID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to shutdown VM", "details": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"status": "VM shutdown"}) -} - -// ADMIN: RebootVMHandler handles POST requests for rebooting a VM on Proxmox -func (ph *ProxmoxHandler) RebootVMHandler(c *gin.Context) { - var req VMActionRequest - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) - return - } - - if err := ph.service.RebootVM(req.Node, req.VMID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reboot VM", "details": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"status": "VM rebooted"}) -} diff --git a/internal/cloning/cloning.go b/internal/cloning/cloning.go index 5e9852f..5af38d6 100644 --- a/internal/cloning/cloning.go +++ b/internal/cloning/cloning.go @@ -3,7 +3,6 @@ package cloning import ( "database/sql" "fmt" - "log" "os" "strings" "time" @@ -63,57 +62,43 @@ func NewCloningManager(proxmoxService proxmox.Service, db *sql.DB, ldapService a // CloneTemplate clones a template pool for a user or group func (cm *CloningManager) CloneTemplate(template string, targetName string, isGroup bool) error { - log.Printf("Starting cloning process for template '%s' to target '%s' (isGroup: %t)", template, targetName, isGroup) var errors []string // 1. Get the template pool and its VMs - log.Printf("Step 1: Retrieving VMs from template pool '%s'", template) templatePool, err := cm.ProxmoxService.GetPoolVMs(template) if err != nil { - log.Printf("ERROR: Failed to get template pool '%s': %v", template, err) return fmt.Errorf("failed to get template pool: %w", err) } - log.Printf("Successfully retrieved %d VMs from template pool '%s'", len(templatePool), template) // 2. Check if the template has already been cloned by the user // Extract template name from pool name (remove kamino_template_ prefix) - log.Printf("Step 2: Checking deployment status for template") templateName := strings.TrimPrefix(template, "kamino_template_") - log.Printf("Extracted template name: '%s' from pool '%s'", templateName, template) targetPoolName := fmt.Sprintf("%s_%s", templateName, targetName) - log.Printf("Checking if target pool '%s' is already deployed", targetPoolName) isDeployed, err := cm.IsDeployed(targetPoolName) if err != nil { - log.Printf("ERROR: Failed to check deployment status for '%s': %v", targetPoolName, err) return fmt.Errorf("failed to check if template is deployed: %w", err) } if isDeployed { - log.Printf("ERROR: Template '%s' is already deployed for target '%s'", template, targetName) return fmt.Errorf("template %s is already or in the process of being deployed %s", template, targetName) } - log.Printf("Template is not deployed, proceeding with cloning") // 3. Identify router and other VMs - log.Printf("Step 3: Analyzing template VMs to identify router and other VMs") var router *proxmox.VM var templateVMs []proxmox.VM for _, vm := range templatePool { // Check to see if this VM is the router lowerVMName := strings.ToLower(vm.Name) - log.Printf("Analyzing VM: '%s' (Type: %s)", vm.Name, vm.Type) if strings.Contains(lowerVMName, "router") || strings.Contains(lowerVMName, "pfsense") { - log.Printf("Identified VM '%s' as router", vm.Name) router = &proxmox.VM{ Name: vm.Name, Node: vm.NodeName, VMID: vm.VmId, } } else { - log.Printf("Added VM '%s' to template VMs list", vm.Name) templateVMs = append(templateVMs, proxmox.VM{ Name: vm.Name, Node: vm.NodeName, @@ -122,41 +107,25 @@ func (cm *CloningManager) CloneTemplate(template string, targetName string, isGr } } - if router != nil { - log.Printf("Router VM identified: '%s' (VMID: %d, Node: %s)", router.Name, router.VMID, router.Node) - } else { - log.Printf("No router VM found in template, will use default router") - } - log.Printf("Template contains %d non-router VMs to clone", len(templateVMs)) - // 4. Verify that the pool is not empty if len(templateVMs) == 0 { - log.Printf("ERROR: Template pool '%s' contains no VMs", template) return fmt.Errorf("template pool %s contains no VMs", template) } - log.Printf("Template validation passed: %d VMs ready for cloning", len(templateVMs)) // 5. Get the next available pod ID and create new pool - log.Printf("Step 5: Allocating pod ID and creating new pool") newPodID, newPodNumber, err := cm.ProxmoxService.GetNextPodID(cm.Config.MinPodID, cm.Config.MaxPodID) if err != nil { - log.Printf("ERROR: Failed to get next pod ID: %v", err) return fmt.Errorf("failed to get next pod ID: %w", err) } - log.Printf("Allocated pod ID: %s (Pod number: %d)", newPodID, newPodNumber) newPoolName := fmt.Sprintf("%s_%s_%s", newPodID, templateName, targetName) - log.Printf("Creating new pool: '%s'", newPoolName) err = cm.ProxmoxService.CreateNewPool(newPoolName) if err != nil { - log.Printf("ERROR: Failed to create pool '%s': %v", newPoolName, err) return fmt.Errorf("failed to create new pool: %w", err) } - log.Printf("Successfully created new pool: '%s'", newPoolName) // 6. Clone the router and all VMs - log.Printf("Step 6: Starting VM cloning process") // If no router was found in the template, use the default router template if router == nil { @@ -165,176 +134,109 @@ func (cm *CloningManager) CloneTemplate(template string, targetName string, isGr Node: cm.Config.RouterNode, VMID: cm.Config.RouterVMID, } - log.Printf("Using default router template: '%s' (VMID: %d)", router.Name, router.VMID) } - log.Printf("Cloning router VM '%s' to pool '%s'", router.Name, newPoolName) newRouter, err := cm.ProxmoxService.CloneVM(*router, newPoolName) if err != nil { - log.Printf("ERROR: Failed to clone router VM '%s': %v", router.Name, err) errors = append(errors, fmt.Sprintf("failed to clone router VM: %v", err)) - } else { - log.Printf("Successfully cloned router VM: '%s' -> new VMID: %d", router.Name, newRouter.VMID) } // Clone each VM to new pool - log.Printf("Cloning %d template VMs to pool '%s'", len(templateVMs), newPoolName) - for i, vm := range templateVMs { - log.Printf("Cloning VM %d/%d: '%s' (VMID: %d)", i+1, len(templateVMs), vm.Name, vm.VMID) - clonedVM, err := cm.ProxmoxService.CloneVM(vm, newPoolName) + for _, vm := range templateVMs { + _, err := cm.ProxmoxService.CloneVM(vm, newPoolName) if err != nil { - log.Printf("ERROR: Failed to clone VM '%s': %v", vm.Name, err) errors = append(errors, fmt.Sprintf("failed to clone VM %s: %v", vm.Name, err)) - } else { - log.Printf("Successfully cloned VM: '%s' -> new VMID: %d", vm.Name, clonedVM.VMID) } } - log.Printf("VM cloning phase completed") var vnetName = fmt.Sprintf("kamino%d", newPodNumber) // 7. Configure VNet of all VMs - log.Printf("Step 7: Configuring VNet for all VMs in pool '%s'", newPoolName) - log.Printf("Setting VNet to: '%s' for pod number %d", vnetName, newPodNumber) err = cm.SetPodVnet(newPoolName, vnetName) if err != nil { - log.Printf("ERROR: Failed to configure VNet '%s' for pool '%s': %v", vnetName, newPoolName, err) errors = append(errors, fmt.Sprintf("failed to update pod vnet: %v", err)) - } else { - log.Printf("Successfully configured VNet '%s' for pool '%s'", vnetName, newPoolName) } // 8. Turn on Router - log.Printf("Step 8: Starting and configuring router VM") if newRouter != nil { - log.Printf("Waiting for router disk availability (VMID: %d, Node: %s, Timeout: %v)", - newRouter.VMID, newRouter.Node, cm.Config.RouterWaitTimeout) err = cm.ProxmoxService.WaitForDisk(newRouter.Node, newRouter.VMID, cm.Config.RouterWaitTimeout) if err != nil { - log.Printf("ERROR: Router disk unavailable (VMID: %d): %v", newRouter.VMID, err) errors = append(errors, fmt.Sprintf("router disk unavailable: %v", err)) - } else { - log.Printf("Router disk is available, starting VM (VMID: %d)", newRouter.VMID) } - log.Printf("Starting router VM (VMID: %d, Node: %s)", newRouter.VMID, newRouter.Node) err = cm.ProxmoxService.StartVM(newRouter.Node, newRouter.VMID) if err != nil { - log.Printf("ERROR: Failed to start router VM (VMID: %d): %v", newRouter.VMID, err) errors = append(errors, fmt.Sprintf("failed to start router VM: %v", err)) - } else { - log.Printf("Successfully started router VM (VMID: %d)", newRouter.VMID) } // 9. Wait for router to be running - log.Printf("Step 9: Waiting for router to be fully running") err = cm.ProxmoxService.WaitForRunning(*newRouter) if err != nil { - log.Printf("ERROR: Router failed to reach running state (VMID: %d): %v", newRouter.VMID, err) errors = append(errors, fmt.Sprintf("failed to start router VM: %v", err)) } else { - log.Printf("Router is now running, proceeding with configuration (Pod: %d, VMID: %d)", newPodNumber, newRouter.VMID) err = cm.configurePodRouter(newPodNumber, newRouter.Node, newRouter.VMID) if err != nil { - log.Printf("ERROR: Failed to configure pod router (Pod: %d, VMID: %d): %v", newPodNumber, newRouter.VMID, err) errors = append(errors, fmt.Sprintf("failed to configure pod router: %v", err)) - } else { - log.Printf("Successfully configured pod router (Pod: %d, VMID: %d)", newPodNumber, newRouter.VMID) } } - } else { - log.Printf("WARNING: No router VM available to start") } // 10. Set permissions on the pool to the user/group - log.Printf("Step 10: Setting permissions on pool '%s' for target '%s' (isGroup: %t)", newPoolName, targetName, isGroup) err = cm.ProxmoxService.SetPoolPermission(newPoolName, targetName, isGroup) if err != nil { - log.Printf("ERROR: Failed to set permissions on pool '%s' for '%s': %v", newPoolName, targetName, err) errors = append(errors, fmt.Sprintf("failed to update pool permissions for %s: %v", targetName, err)) - } else { - log.Printf("Successfully set permissions on pool '%s' for '%s'", newPoolName, targetName) } // 11. Add a +1 to the total deployments in the templates database - log.Printf("Step 11: Updating deployment counter for template '%s'", templateName) err = cm.DatabaseService.AddDeployment(templateName) if err != nil { - log.Printf("ERROR: Failed to increment deployment counter for template '%s': %v", templateName, err) errors = append(errors, fmt.Sprintf("failed to increment template deployments for %s: %v", templateName, err)) - } else { - log.Printf("Successfully incremented deployment counter for template '%s'", templateName) } // If there were any errors, clean up if necessary if len(errors) > 0 { - log.Printf("Cloning completed with %d errors, checking cleanup requirements", len(errors)) - log.Printf("Errors encountered: %v", errors) // Check if any VMs were successfully cloned clonedVMs, checkErr := cm.ProxmoxService.GetPoolVMs(newPoolName) if checkErr != nil { - log.Printf("WARNING: Could not check cloned VMs for cleanup: %v", checkErr) - } else { - log.Printf("Found %d VMs in pool '%s' after failed cloning", len(clonedVMs), newPoolName) } if len(clonedVMs) == 0 { - log.Printf("No VMs were successfully cloned, deleting empty pool '%s'", newPoolName) deleteErr := cm.ProxmoxService.DeletePool(newPoolName) if deleteErr != nil { - log.Printf("WARNING: Failed to cleanup empty pool '%s': %v", newPoolName, deleteErr) - } else { - log.Printf("Successfully cleaned up empty pool '%s'", newPoolName) } - } else { - log.Printf("Some VMs were cloned successfully, leaving pool '%s' for manual cleanup", newPoolName) } return fmt.Errorf("clone operation completed with errors: %v", errors) } - log.Printf("Successfully cloned pool '%s' to '%s' for '%s'", template, newPoolName, targetName) - log.Printf("Cloning process completed successfully - Pool: '%s', VMs: %d, Target: '%s'", - newPoolName, len(templateVMs)+1, targetName) // +1 for router return nil } // DeletePod deletes a pod pool for a user or group func (cm *CloningManager) DeletePod(pod string) error { - log.Printf("Starting deletion process for pod '%s'", pod) // 1. Check if pool is already empty - log.Printf("Step 1: Checking if pool '%s' is empty", pod) isEmpty, err := cm.ProxmoxService.IsPoolEmpty(pod) if err != nil { - log.Printf("ERROR: Failed to check if pool '%s' is empty: %v", pod, err) return fmt.Errorf("failed to check if pool %s is empty: %w", pod, err) } if isEmpty { - log.Printf("Pool '%s' is already empty, proceeding directly to pool deletion", pod) err := cm.ProxmoxService.DeletePool(pod) if err != nil { - log.Printf("ERROR: Failed to delete empty pool '%s': %v", pod, err) } else { - log.Printf("Successfully deleted empty pool '%s'", pod) } return err } // 2. Get all virtual machines in the pool - log.Printf("Step 2: Retrieving all VMs from pool '%s'", pod) poolVMs, err := cm.ProxmoxService.GetPoolVMs(pod) if err != nil { - log.Printf("ERROR: Failed to get VMs from pool '%s': %v", pod, err) return fmt.Errorf("failed to get pool VMs for %s: %w", pod, err) } - log.Printf("Found %d VMs in pool '%s', proceeding with deletion", len(poolVMs), pod) - // 3. Stop all VMs and wait for them to be stopped - log.Printf("Step 3: Stopping all running VMs in pool '%s'", pod) var runningVMs []proxmox.VM stoppedCount := 0 @@ -342,10 +244,8 @@ func (cm *CloningManager) DeletePod(pod string) error { if vm.Type == "qemu" { // Only stop if VM is running if vm.RunningStatus == "running" { - log.Printf("Force stopping VM '%s' (ID: %d) on node '%s'", vm.Name, vm.VmId, vm.NodeName) err := cm.ProxmoxService.StopVM(vm.NodeName, vm.VmId) if err != nil { - log.Printf("ERROR: Failed to stop VM '%s' (ID: %d): %v", vm.Name, vm.VmId, err) return fmt.Errorf("failed to stop VM %s: %w", vm.Name, err) } @@ -356,69 +256,48 @@ func (cm *CloningManager) DeletePod(pod string) error { }) stoppedCount++ } else { - log.Printf("VM '%s' (ID: %d) is already stopped (status: %s)", vm.Name, vm.VmId, vm.RunningStatus) } } else { - log.Printf("Skipping non-qemu resource '%s' (type: %s)", vm.Name, vm.Type) } } - log.Printf("Initiated stop for %d running VMs, waiting for them to stop", stoppedCount) - // Wait for all previously running VMs to be stopped - for i, vm := range runningVMs { - log.Printf("Waiting for VM %d/%d to stop: VMID %d on node %s", i+1, len(runningVMs), vm.VMID, vm.Node) + for _, vm := range runningVMs { err := cm.ProxmoxService.WaitForStopped(vm) if err != nil { - log.Printf("WARNING: Timeout waiting for VM %d to stop: %v", vm.VMID, err) // Continue with deletion even if we can't confirm the VM is stopped } else { - log.Printf("VM %d successfully stopped", vm.VMID) } } if len(runningVMs) > 0 { - log.Printf("All %d VMs have been processed for stopping", len(runningVMs)) } // 4. Delete all VMs - log.Printf("Step 4: Deleting all VMs from pool '%s'", pod) deletedCount := 0 - for i, vm := range poolVMs { + for _, vm := range poolVMs { if vm.Type == "qemu" { - log.Printf("Deleting VM %d/%d: '%s' (ID: %d) on node '%s'", i+1, len(poolVMs), vm.Name, vm.VmId, vm.NodeName) err := cm.ProxmoxService.DeleteVM(vm.NodeName, vm.VmId) if err != nil { - log.Printf("ERROR: Failed to delete VM '%s' (ID: %d): %v", vm.Name, vm.VmId, err) return fmt.Errorf("failed to delete VM %s: %w", vm.Name, err) } deletedCount++ - log.Printf("Successfully initiated deletion of VM '%s' (ID: %d)", vm.Name, vm.VmId) } } - log.Printf("Initiated deletion for %d VMs from pool '%s'", deletedCount, pod) - // 5. Wait for all VMs to be deleted and pool to become empty - log.Printf("Step 5: Waiting for all VMs to be deleted from pool '%s' (timeout: 5 minutes)", pod) err = cm.ProxmoxService.WaitForPoolEmpty(pod, 5*time.Minute) if err != nil { - log.Printf("WARNING: %v", err) // Continue with pool deletion even if we can't confirm all VMs are gone } else { - log.Printf("All VMs successfully deleted from pool '%s'", pod) } // 6. Delete the pool - log.Printf("Step 6: Deleting pool '%s'", pod) err = cm.ProxmoxService.DeletePool(pod) if err != nil { - log.Printf("ERROR: Failed to delete pool '%s': %v", pod, err) return fmt.Errorf("failed to delete pool %s: %w", pod, err) } - log.Printf("Successfully deleted template pool '%s' and all its VMs", pod) - log.Printf("Pod deletion process completed successfully for '%s'", pod) return nil } diff --git a/internal/cloning/types.go b/internal/cloning/types.go index 1638557..e813b41 100644 --- a/internal/cloning/types.go +++ b/internal/cloning/types.go @@ -26,15 +26,15 @@ type Config struct { // KaminoTemplate represents a template in the system type KaminoTemplate struct { - Name string `json:"name"` - Description string `json:"description"` - ImagePath string `json:"image_path"` + Name string `json:"name" binding:"required,min=1,max=100" validate:"alphanum,ascii"` + Description string `json:"description" binding:"required,min=1,max=500"` + ImagePath string `json:"image_path" binding:"omitempty,max=255" validate:"omitempty,file"` TemplateVisible bool `json:"template_visible"` PodVisible bool `json:"pod_visible"` VMsVisible bool `json:"vms_visible"` - VMCount int `json:"vm_count"` - Deployments int `json:"deployments"` - CreatedAt string `json:"created_at"` + VMCount int `json:"vm_count" binding:"min=0,max=100"` + Deployments int `json:"deployments" binding:"min=0"` + CreatedAt string `json:"created_at" binding:"omitempty" validate:"omitempty,datetime=2006-01-02T15:04:05Z07:00"` } // DatabaseService interface defines the methods for template operations diff --git a/internal/proxmox/cluster.go b/internal/proxmox/cluster.go index f7a3add..520294d 100644 --- a/internal/proxmox/cluster.go +++ b/internal/proxmox/cluster.go @@ -8,7 +8,7 @@ import ( ) // ================================================= -// PUBLIC FUNCTIONS +// Public Functions // ================================================= // GetNodeStatus retrieves detailed status for a specific node @@ -109,22 +109,16 @@ func (c *Client) FindBestNode() (string, error) { return bestNode, nil } -func (c *Client) SyncRealm() error { - req := tools.ProxmoxAPIRequest{ - Method: "POST", - Endpoint: fmt.Sprintf("/access/domains/%s/sync", c.Config.Realm), - } - - _, err := c.RequestHelper.MakeRequest(req) - if err != nil { - return fmt.Errorf("failed to sync realm: %w", err) - } +func (c *Client) SyncUsers() error { + return c.syncRealm("users") +} - return nil +func (c *Client) SyncGroups() error { + return c.syncRealm("groups") } // ================================================= -// PRIVATE FUNCTIONS +// Private Functions // ================================================= // collectNodeResourceUsage gathers resource usage data for all configured nodes @@ -223,3 +217,21 @@ func getStorage(resources *[]VirtualResource, storage string) (Used int64, Total return used, total } + +func (c *Client) syncRealm(scope string) error { + req := tools.ProxmoxAPIRequest{ + Method: "POST", + Endpoint: fmt.Sprintf("/access/domains/%s/sync", c.Config.Realm), + RequestBody: map[string]string{ + "scope": scope, // Either "users" or "groups" + "remove-vanished": "acl;properties;entry", // Delete any users/groups that no longer exist in AD + }, + } + + _, err := c.RequestHelper.MakeRequest(req) + if err != nil { + return fmt.Errorf("failed to sync realm: %w", err) + } + + return nil +} diff --git a/internal/proxmox/proxmox.go b/internal/proxmox/proxmox.go index 00173fe..ddbe9fa 100644 --- a/internal/proxmox/proxmox.go +++ b/internal/proxmox/proxmox.go @@ -32,6 +32,8 @@ type Service interface { GetClusterResources(getParams string) ([]VirtualResource, error) GetNodeStatus(nodeName string) (*ProxmoxNodeStatus, error) FindBestNode() (string, error) + SyncUsers() error + SyncGroups() error // Pod Management GetNextPodID(minPodID int, maxPodID int) (string, int, error) diff --git a/internal/proxmox/vms.go b/internal/proxmox/vms.go index 31cc098..118a7b8 100644 --- a/internal/proxmox/vms.go +++ b/internal/proxmox/vms.go @@ -11,7 +11,7 @@ import ( ) // ================================================= -// PUBLIC FUNCTIONS +// Public Functions // ================================================= func (c *Client) GetVMs() ([]VirtualResource, error) { @@ -196,7 +196,7 @@ func (c *Client) WaitForRunning(vm VM) error { } // ================================================= -// PRIVATE FUNCTIONS +// Private Functions // ================================================= func (c *Client) vmAction(node string, vmID int, action string) error { From 2f141a11499d01323efc2f47632e2bee1fe511c6 Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Sat, 6 Sep 2025 14:07:30 -0700 Subject: [PATCH 13/23] Split AuthService to AuthService and LDAPService. Also renamed ProxmoxClient to ProxmoxService. --- internal/api/auth/auth_service.go | 125 ++++ internal/api/auth/types.go | 31 + internal/api/handlers/auth_handler.go | 69 +- internal/api/handlers/cloning_handler.go | 62 +- internal/api/handlers/dashboard_handler.go | 15 +- internal/api/handlers/proxmox_handler.go | 11 +- internal/api/handlers/types.go | 45 +- internal/auth/auth.go | 216 ------ internal/auth/groups.go | 497 ------------- internal/auth/ldap.go | 549 -------------- internal/auth/users.go | 694 ------------------ .../{cloning.go => cloning_service.go} | 69 +- internal/cloning/networking.go | 23 +- internal/cloning/pods.go | 20 +- internal/cloning/templates.go | 102 ++- internal/cloning/types.go | 13 +- internal/ldap/groups.go | 338 +++++++++ internal/ldap/ldap_client.go | 208 ++++++ internal/ldap/ldap_service.go | 44 ++ internal/ldap/types.go | 95 +++ internal/ldap/users.go | 384 ++++++++++ internal/proxmox/cluster.go | 46 +- internal/proxmox/pools.go | 38 +- internal/proxmox/proxmox.go | 156 ---- internal/proxmox/proxmox_service.go | 73 ++ internal/proxmox/types.go | 91 ++- internal/proxmox/vms.go | 84 +-- .../tools/{database.go => database_client.go} | 0 28 files changed, 1688 insertions(+), 2410 deletions(-) create mode 100644 internal/api/auth/auth_service.go create mode 100644 internal/api/auth/types.go delete mode 100644 internal/auth/auth.go delete mode 100644 internal/auth/groups.go delete mode 100644 internal/auth/ldap.go delete mode 100644 internal/auth/users.go rename internal/cloning/{cloning.go => cloning_service.go} (75%) create mode 100644 internal/ldap/groups.go create mode 100644 internal/ldap/ldap_client.go create mode 100644 internal/ldap/ldap_service.go create mode 100644 internal/ldap/types.go create mode 100644 internal/ldap/users.go delete mode 100644 internal/proxmox/proxmox.go create mode 100644 internal/proxmox/proxmox_service.go rename internal/tools/{database.go => database_client.go} (100%) diff --git a/internal/api/auth/auth_service.go b/internal/api/auth/auth_service.go new file mode 100644 index 0000000..85f1b23 --- /dev/null +++ b/internal/api/auth/auth_service.go @@ -0,0 +1,125 @@ +package auth + +import ( + "fmt" + "strings" + + "github.com/cpp-cyber/proclone/internal/ldap" + ldapv3 "github.com/go-ldap/ldap/v3" +) + +func NewAuthService() (*AuthService, error) { + ldapService, err := ldap.NewLDAPService() + if err != nil { + return nil, fmt.Errorf("failed to create LDAP service: %w", err) + } + + return &AuthService{ + ldapService: ldapService, + }, nil +} + +func (s *AuthService) Authenticate(username string, password string) (bool, error) { + // Get the LDAP service to perform authentication + ldapSvc, ok := s.ldapService.(*ldap.LDAPService) + if !ok { + return false, fmt.Errorf("invalid LDAP service type") + } + + userDN, err := ldapSvc.GetUserDN(username) + if err != nil { + return false, fmt.Errorf("failed to get user DN: %v", err) + } + + // Create a temporary client for authentication + config, err := ldap.LoadConfig() + if err != nil { + return false, fmt.Errorf("failed to load LDAP config: %v", err) + } + + authClient := ldap.NewClient(config) + err = authClient.Connect() + if err != nil { + return false, fmt.Errorf("failed to connect to LDAP: %v", err) + } + defer authClient.Disconnect() + + // Try to bind as the user to verify password + err = authClient.Bind(userDN, password) + if err != nil { + return false, nil // Invalid credentials, not an error + } + + return true, nil +} + +func (s *AuthService) IsAdmin(username string) (bool, error) { + config, err := ldap.LoadConfig() + if err != nil { + return false, fmt.Errorf("failed to load LDAP config: %v", err) + } + + // Create a client for admin check + client := ldap.NewClient(config) + err = client.Connect() + if err != nil { + return false, fmt.Errorf("failed to connect to LDAP: %v", err) + } + defer client.Disconnect() + + // Search for admin group + adminGroupReq := ldapv3.NewSearchRequest( + config.AdminGroupDN, + ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, + "(objectClass=group)", + []string{"member"}, + nil, + ) + + // Search for user DN + userDNReq := ldapv3.NewSearchRequest( + config.BaseDN, + ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(&(objectClass=inetOrgPerson)(uid=%s))", ldapv3.EscapeFilter(username)), + []string{"dn"}, + nil, + ) + + adminGroupResult, err := client.Search(adminGroupReq) + if err != nil { + return false, fmt.Errorf("failed to search admin group: %v", err) + } + + userResult, err := client.Search(userDNReq) + if err != nil { + return false, fmt.Errorf("failed to search user: %v", err) + } + + if len(adminGroupResult.Entries) == 0 { + return false, fmt.Errorf("admin group not found") + } + + if len(userResult.Entries) == 0 { + return false, fmt.Errorf("user not found") + } + + adminMembers := adminGroupResult.Entries[0].GetAttributeValues("member") + userDN := userResult.Entries[0].DN + + // Check if user DN is in admin group members + for _, member := range adminMembers { + if strings.EqualFold(member, userDN) { + return true, nil + } + } + + return false, nil +} + +func (s *AuthService) HealthCheck() error { + return s.ldapService.HealthCheck() +} + +func (s *AuthService) Reconnect() error { + return s.ldapService.Reconnect() +} diff --git a/internal/api/auth/types.go b/internal/api/auth/types.go new file mode 100644 index 0000000..34420fa --- /dev/null +++ b/internal/api/auth/types.go @@ -0,0 +1,31 @@ +package auth + +import ( + "github.com/cpp-cyber/proclone/internal/ldap" +) + +// ================================================= +// Auth Service Interface +// ================================================= + +type Service interface { + // Authentication + Authenticate(username, password string) (bool, error) + IsAdmin(username string) (bool, error) + + // Health and Connection + HealthCheck() error + Reconnect() error +} + +type AuthService struct { + ldapService ldap.Service +} + +// ================================================= +// Types for Auth Service (re-exported from ldap) +// ================================================= + +type User = ldap.User +type Group = ldap.Group +type UserRegistrationInfo = ldap.UserRegistrationInfo diff --git a/internal/api/handlers/auth_handler.go b/internal/api/handlers/auth_handler.go index 86438d5..d648586 100644 --- a/internal/api/handlers/auth_handler.go +++ b/internal/api/handlers/auth_handler.go @@ -5,7 +5,8 @@ import ( "log" "net/http" - "github.com/cpp-cyber/proclone/internal/auth" + "github.com/cpp-cyber/proclone/internal/api/auth" + "github.com/cpp-cyber/proclone/internal/ldap" "github.com/cpp-cyber/proclone/internal/proxmox" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" @@ -17,11 +18,16 @@ import ( // NewAuthHandler creates a new authentication handler func NewAuthHandler() (*AuthHandler, error) { - authService, err := auth.NewLDAPService() + authService, err := auth.NewAuthService() if err != nil { return nil, fmt.Errorf("failed to create auth service: %w", err) } + ldapService, err := ldap.NewLDAPService() + if err != nil { + return nil, fmt.Errorf("failed to create LDAP service: %w", err) + } + proxmoxService, err := proxmox.NewService() if err != nil { return nil, fmt.Errorf("failed to create proxmox service: %w", err) @@ -31,14 +37,15 @@ func NewAuthHandler() (*AuthHandler, error) { return &AuthHandler{ authService: authService, + ldapService: ldapService, proxmoxService: proxmoxService, }, nil } // LoginHandler handles the login POST request func (h *AuthHandler) LoginHandler(c *gin.Context) { - var req UserRequest - if !ValidateAndBind(c, &req) { + var req UsernamePasswordRequest + if !validateAndBind(c, &req) { return } @@ -115,14 +122,14 @@ func (h *AuthHandler) SessionHandler(c *gin.Context) { } func (h *AuthHandler) RegisterHandler(c *gin.Context) { - var req UserRequest - if !ValidateAndBind(c, &req) { + var req UsernamePasswordRequest + if !validateAndBind(c, &req) { return } // Check if the username already exists var userDN = "" - userDN, err := h.authService.GetUserDN(req.Username) + userDN, err := h.ldapService.GetUserDN(req.Username) if userDN != "" { c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"}) return @@ -132,7 +139,7 @@ func (h *AuthHandler) RegisterHandler(c *gin.Context) { } // Create user - if err := h.authService.CreateAndRegisterUser(auth.UserRegistrationInfo(req)); err != nil { + if err := h.ldapService.CreateAndRegisterUser(ldap.UserRegistrationInfo(req)); err != nil { log.Printf("Failed to create user %s: %v", req.Username, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) return @@ -147,7 +154,7 @@ func (h *AuthHandler) RegisterHandler(c *gin.Context) { // ADMIN: GetUsersHandler returns a list of all users func (h *AuthHandler) GetUsersHandler(c *gin.Context) { - users, err := h.authService.GetUsers() + users, err := h.ldapService.GetUsers() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve users"}) return @@ -175,7 +182,7 @@ func (h *AuthHandler) GetUsersHandler(c *gin.Context) { // ADMIN: CreateUsersHandler creates new user(s) func (h *AuthHandler) CreateUsersHandler(c *gin.Context) { var req AdminCreateUserRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } @@ -183,7 +190,7 @@ func (h *AuthHandler) CreateUsersHandler(c *gin.Context) { // Create users in AD for _, user := range req.Users { - if err := h.authService.CreateAndRegisterUser(auth.UserRegistrationInfo(user)); err != nil { + if err := h.ldapService.CreateAndRegisterUser(ldap.UserRegistrationInfo(user)); err != nil { errors = append(errors, fmt.Errorf("failed to create user %s: %v", user.Username, err)) } } @@ -205,7 +212,7 @@ func (h *AuthHandler) CreateUsersHandler(c *gin.Context) { // ADMIN: DeleteUsersHandler deletes existing user(s) func (h *AuthHandler) DeleteUsersHandler(c *gin.Context) { var req UsersRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } @@ -213,7 +220,7 @@ func (h *AuthHandler) DeleteUsersHandler(c *gin.Context) { // Delete users in AD for _, username := range req.Usernames { - if err := h.authService.DeleteUser(username); err != nil { + if err := h.ldapService.DeleteUser(username); err != nil { errors = append(errors, fmt.Errorf("failed to delete user %s: %v", username, err)) } } @@ -235,14 +242,14 @@ func (h *AuthHandler) DeleteUsersHandler(c *gin.Context) { // ADMIN: EnableUsersHandler enables existing user(s) func (h *AuthHandler) EnableUsersHandler(c *gin.Context) { var req UsersRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } var errors []error for _, username := range req.Usernames { - if err := h.authService.EnableUserAccount(username); err != nil { + if err := h.ldapService.EnableUserAccount(username); err != nil { errors = append(errors, fmt.Errorf("failed to enable user %s: %v", username, err)) } } @@ -258,14 +265,14 @@ func (h *AuthHandler) EnableUsersHandler(c *gin.Context) { // ADMIN: DisableUsersHandler disables existing user(s) func (h *AuthHandler) DisableUsersHandler(c *gin.Context) { var req UsersRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } var errors []error for _, username := range req.Usernames { - if err := h.authService.DisableUserAccount(username); err != nil { + if err := h.ldapService.DisableUserAccount(username); err != nil { errors = append(errors, fmt.Errorf("failed to disable user %s: %v", username, err)) } } @@ -279,17 +286,17 @@ func (h *AuthHandler) DisableUsersHandler(c *gin.Context) { } // ================================================= -// Group Functions +// Group Handlers // ================================================= // ADMIN: SetUserGroupsHandler sets the groups for an existing user func (h *AuthHandler) SetUserGroupsHandler(c *gin.Context) { var req SetUserGroupsRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } - if err := h.authService.SetUserGroups(req.Username, req.Groups); err != nil { + if err := h.ldapService.SetUserGroups(req.Username, req.Groups); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to set user groups", "details": err.Error()}) return } @@ -298,7 +305,7 @@ func (h *AuthHandler) SetUserGroupsHandler(c *gin.Context) { } func (h *AuthHandler) GetGroupsHandler(c *gin.Context) { - groups, err := h.authService.GetGroups() + groups, err := h.ldapService.GetGroups() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve groups"}) return @@ -313,7 +320,7 @@ func (h *AuthHandler) GetGroupsHandler(c *gin.Context) { // ADMIN: CreateGroupsHandler creates new group(s) func (h *AuthHandler) CreateGroupsHandler(c *gin.Context) { var req GroupsRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } @@ -321,7 +328,7 @@ func (h *AuthHandler) CreateGroupsHandler(c *gin.Context) { // Create groups in AD for _, group := range req.Groups { - if err := h.authService.CreateGroup(group); err != nil { + if err := h.ldapService.CreateGroup(group); err != nil { errors = append(errors, fmt.Errorf("failed to create group %s: %v", group, err)) } } @@ -342,11 +349,11 @@ func (h *AuthHandler) CreateGroupsHandler(c *gin.Context) { func (h *AuthHandler) RenameGroupHandler(c *gin.Context) { var req RenameGroupRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } - if err := h.authService.RenameGroup(req.OldName, req.NewName); err != nil { + if err := h.ldapService.RenameGroup(req.OldName, req.NewName); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to rename group"}) return } @@ -356,7 +363,7 @@ func (h *AuthHandler) RenameGroupHandler(c *gin.Context) { func (h *AuthHandler) DeleteGroupsHandler(c *gin.Context) { var req GroupsRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } @@ -364,7 +371,7 @@ func (h *AuthHandler) DeleteGroupsHandler(c *gin.Context) { // Delete groups in AD for _, group := range req.Groups { - if err := h.authService.DeleteGroup(group); err != nil { + if err := h.ldapService.DeleteGroup(group); err != nil { errors = append(errors, fmt.Errorf("failed to delete group %s: %v", group, err)) } } @@ -385,11 +392,11 @@ func (h *AuthHandler) DeleteGroupsHandler(c *gin.Context) { func (h *AuthHandler) AddUsersHandler(c *gin.Context) { var req ModifyGroupMembersRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } - if err := h.authService.AddUsersToGroup(req.Group, req.Usernames); err != nil { + if err := h.ldapService.AddUsersToGroup(req.Group, req.Usernames); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add users to group"}) return } @@ -399,11 +406,11 @@ func (h *AuthHandler) AddUsersHandler(c *gin.Context) { func (h *AuthHandler) RemoveUsersHandler(c *gin.Context) { var req ModifyGroupMembersRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } - if err := h.authService.RemoveUsersFromGroup(req.Group, req.Usernames); err != nil { + if err := h.ldapService.RemoveUsersFromGroup(req.Group, req.Usernames); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove users from group"}) return } diff --git a/internal/api/handlers/cloning_handler.go b/internal/api/handlers/cloning_handler.go index 9421936..8089f28 100644 --- a/internal/api/handlers/cloning_handler.go +++ b/internal/api/handlers/cloning_handler.go @@ -7,20 +7,14 @@ import ( "path/filepath" "strings" - "github.com/cpp-cyber/proclone/internal/auth" "github.com/cpp-cyber/proclone/internal/cloning" + "github.com/cpp-cyber/proclone/internal/ldap" "github.com/cpp-cyber/proclone/internal/proxmox" "github.com/cpp-cyber/proclone/internal/tools" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) -// CloningHandler holds the cloning manager -type CloningHandler struct { - Manager *cloning.CloningManager - dbClient *tools.DBClient -} - // NewCloningHandler creates a new cloning handler, loading dependencies internally func NewCloningHandler() (*CloningHandler, error) { // Initialize database connection @@ -36,20 +30,20 @@ func NewCloningHandler() (*CloningHandler, error) { } // Initialize LDAP service - ldapService, err := auth.NewLDAPService() + ldapService, err := ldap.NewLDAPService() if err != nil { return nil, fmt.Errorf("failed to create LDAP service: %w", err) } // Initialize Cloning manager - cloningManager, err := cloning.NewCloningManager(proxmoxService, dbClient.DB(), ldapService) + cloningService, err := cloning.NewCloningService(proxmoxService, dbClient.DB(), ldapService) if err != nil { return nil, fmt.Errorf("failed to initialize cloning manager: %w", err) } log.Println("Cloning manager initialized") return &CloningHandler{ - Manager: cloningManager, + Service: cloningService, dbClient: dbClient, }, nil } @@ -60,7 +54,7 @@ func (ch *CloningHandler) CloneTemplateHandler(c *gin.Context) { username := session.Get("id").(string) var req CloneRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } @@ -69,7 +63,7 @@ func (ch *CloningHandler) CloneTemplateHandler(c *gin.Context) { // Construct the full template pool name templatePoolName := "kamino_template_" + req.Template - if err := ch.Manager.CloneTemplate(templatePoolName, username, false); err != nil { + if err := ch.Service.CloneTemplate(templatePoolName, username, false); err != nil { log.Printf("Error cloning template: %v", err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to clone template", @@ -88,7 +82,7 @@ func (ch *CloningHandler) AdminCloneTemplateHandler(c *gin.Context) { username := session.Get("id").(string) var req AdminCloneRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } @@ -100,7 +94,7 @@ func (ch *CloningHandler) AdminCloneTemplateHandler(c *gin.Context) { // Clone for users var errors []error for _, username := range req.Usernames { - err := ch.Manager.CloneTemplate(templatePoolName, username, false) + err := ch.Service.CloneTemplate(templatePoolName, username, false) if err != nil { errors = append(errors, fmt.Errorf("failed to clone template for user %s: %v", username, err)) } @@ -108,7 +102,7 @@ func (ch *CloningHandler) AdminCloneTemplateHandler(c *gin.Context) { // Clone for groups for _, group := range req.Groups { - err := ch.Manager.CloneTemplate(templatePoolName, group, true) + err := ch.Service.CloneTemplate(templatePoolName, group, true) if err != nil { errors = append(errors, fmt.Errorf("failed to clone template for group %s: %v", group, err)) } @@ -136,7 +130,7 @@ func (ch *CloningHandler) DeletePodHandler(c *gin.Context) { username := session.Get("id").(string) var req DeletePodRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } @@ -151,7 +145,7 @@ func (ch *CloningHandler) DeletePodHandler(c *gin.Context) { return } - err := ch.Manager.DeletePod(req.Pod) + err := ch.Service.DeletePod(req.Pod) if err != nil { log.Printf("Error deleting %s pod: %v", req.Pod, err) c.JSON(http.StatusInternalServerError, gin.H{ @@ -169,7 +163,7 @@ func (ch *CloningHandler) AdminDeletePodHandler(c *gin.Context) { username := session.Get("id").(string) var req AdminDeletePodRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } @@ -177,7 +171,7 @@ func (ch *CloningHandler) AdminDeletePodHandler(c *gin.Context) { var errors []error for _, pod := range req.Pods { - err := ch.Manager.DeletePod(pod) + err := ch.Service.DeletePod(pod) if err != nil { errors = append(errors, fmt.Errorf("failed to delete pod %s: %v", pod, err)) } @@ -196,7 +190,7 @@ func (ch *CloningHandler) AdminDeletePodHandler(c *gin.Context) { } func (ch *CloningHandler) GetUnpublishedTemplatesHandler(c *gin.Context) { - templates, err := ch.Manager.GetUnpublishedTemplates() + templates, err := ch.Service.GetUnpublishedTemplates() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to retrieve unpublished templates", @@ -216,7 +210,7 @@ func (ch *CloningHandler) GetPodsHandler(c *gin.Context) { session := sessions.Default(c) username := session.Get("id").(string) - pods, err := ch.Manager.GetPods(username) + pods, err := ch.Service.GetPods(username) if err != nil { log.Printf("Error retrieving pods for user %s: %v", username, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve pods", "details": err.Error()}) @@ -226,7 +220,7 @@ func (ch *CloningHandler) GetPodsHandler(c *gin.Context) { // Loop through the user's deployed pods and add template information for i := range pods { templateName := strings.Replace(pods[i].Name[5:], fmt.Sprintf("_%s", username), "", 1) - templateInfo, err := ch.Manager.DatabaseService.GetTemplateInfo(templateName) + templateInfo, err := ch.Service.DatabaseService.GetTemplateInfo(templateName) if err != nil { log.Printf("Error retrieving template info for pod %s: %v", pods[i].Name, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve template info for pod", "details": err.Error()}) @@ -238,12 +232,12 @@ func (ch *CloningHandler) GetPodsHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"pods": pods}) } -// ADMIN: GetAllPodsHandler handles GET requests for retrieving all pods +// ADMIN: AdminGetPodsHandler handles GET requests for retrieving all pods func (ch *CloningHandler) AdminGetPodsHandler(c *gin.Context) { session := sessions.Default(c) username := session.Get("id").(string) - pods, err := ch.Manager.GetAllPods() + pods, err := ch.Service.AdminGetPods() if err != nil { log.Printf("Error retrieving all pods for admin %s: %v", username, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve pods for user", "details": err.Error()}) @@ -255,7 +249,7 @@ func (ch *CloningHandler) AdminGetPodsHandler(c *gin.Context) { // PRIVATE: GetTemplatesHandler handles GET requests for retrieving templates func (ch *CloningHandler) GetTemplatesHandler(c *gin.Context) { - templates, err := ch.Manager.DatabaseService.GetTemplates() + templates, err := ch.Service.DatabaseService.GetTemplates() if err != nil { log.Printf("Error retrieving templates: %v", err) c.JSON(http.StatusInternalServerError, gin.H{ @@ -276,7 +270,7 @@ func (ch *CloningHandler) AdminGetTemplatesHandler(c *gin.Context) { session := sessions.Default(c) username := session.Get("id").(string) - templates, err := ch.Manager.DatabaseService.GetPublishedTemplates() + templates, err := ch.Service.DatabaseService.GetPublishedTemplates() if err != nil { log.Printf("Error retrieving all templates for admin %s: %v", username, err) c.JSON(http.StatusInternalServerError, gin.H{ @@ -295,7 +289,7 @@ func (ch *CloningHandler) AdminGetTemplatesHandler(c *gin.Context) { // PRIVATE: GetTemplateImageHandler handles GET requests for retrieving a template's image func (ch *CloningHandler) GetTemplateImageHandler(c *gin.Context) { filename := c.Param("filename") - config := ch.Manager.DatabaseService.GetTemplateConfig() + config := ch.Service.DatabaseService.GetTemplateConfig() filePath := filepath.Join(config.UploadDir, filename) // Serve the file @@ -308,13 +302,13 @@ func (ch *CloningHandler) PublishTemplateHandler(c *gin.Context) { username := session.Get("id").(string) var req PublishTemplateRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } log.Printf("Admin %s requested publishing of template %s", username, req.Template.Name) - if err := ch.Manager.PublishTemplate(req.Template); err != nil { + if err := ch.Service.PublishTemplate(req.Template); err != nil { log.Printf("Error publishing template for admin %s: %v", username, err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to publish template", @@ -334,13 +328,13 @@ func (ch *CloningHandler) DeleteTemplateHandler(c *gin.Context) { username := session.Get("id").(string) var req TemplateRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } log.Printf("Admin %s requested deletion of template %s", username, req.Template) - if err := ch.Manager.DatabaseService.DeleteTemplate(req.Template); err != nil { + if err := ch.Service.DatabaseService.DeleteTemplate(req.Template); err != nil { log.Printf("Error deleting template for admin %s: %v", username, err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to delete template", @@ -360,13 +354,13 @@ func (ch *CloningHandler) ToggleTemplateVisibilityHandler(c *gin.Context) { username := session.Get("id").(string) var req TemplateRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } log.Printf("Admin %s requested toggling visibility of template %s", username, req.Template) - if err := ch.Manager.DatabaseService.ToggleTemplateVisibility(req.Template); err != nil { + if err := ch.Service.DatabaseService.ToggleTemplateVisibility(req.Template); err != nil { log.Printf("Error toggling template visibility for admin %s: %v", username, err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to toggle template visibility", @@ -387,7 +381,7 @@ func (ch *CloningHandler) UploadTemplateImageHandler(c *gin.Context) { log.Printf("Admin %s requested uploading a template image", username) - result, err := ch.Manager.DatabaseService.UploadTemplateImage(c) + result, err := ch.Service.DatabaseService.UploadTemplateImage(c) if err != nil { log.Printf("Error uploading template image for admin %s: %v", username, err) c.JSON(http.StatusInternalServerError, gin.H{ diff --git a/internal/api/handlers/dashboard_handler.go b/internal/api/handlers/dashboard_handler.go index f95e1cd..9a058a1 100644 --- a/internal/api/handlers/dashboard_handler.go +++ b/internal/api/handlers/dashboard_handler.go @@ -6,13 +6,6 @@ import ( "github.com/gin-gonic/gin" ) -// DashboardHandler handles HTTP requests for dashboard operations -type DashboardHandler struct { - authHandler *AuthHandler - proxmoxHandler *ProxmoxHandler - cloningHandler *CloningHandler -} - // NewDashboardHandler creates a new dashboard handler func NewDashboardHandler(authHandler *AuthHandler, proxmoxHandler *ProxmoxHandler, cloningHandler *CloningHandler) *DashboardHandler { return &DashboardHandler{ @@ -27,7 +20,7 @@ func (dh *DashboardHandler) GetDashboardStatsHandler(c *gin.Context) { stats := DashboardStats{} // Get user count - users, err := dh.authHandler.authService.GetUsers() + users, err := dh.authHandler.ldapService.GetUsers() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve user count", "details": err.Error()}) return @@ -35,7 +28,7 @@ func (dh *DashboardHandler) GetDashboardStatsHandler(c *gin.Context) { stats.UserCount = len(users) // Get group count - groups, err := dh.authHandler.authService.GetGroups() + groups, err := dh.authHandler.ldapService.GetGroups() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve group count", "details": err.Error()}) return @@ -43,7 +36,7 @@ func (dh *DashboardHandler) GetDashboardStatsHandler(c *gin.Context) { stats.GroupCount = len(groups) // Get published template count - publishedTemplates, err := dh.cloningHandler.Manager.DatabaseService.GetPublishedTemplates() + publishedTemplates, err := dh.cloningHandler.Service.DatabaseService.GetPublishedTemplates() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve published template count", "details": err.Error()}) return @@ -51,7 +44,7 @@ func (dh *DashboardHandler) GetDashboardStatsHandler(c *gin.Context) { stats.PublishedTemplateCount = len(publishedTemplates) // Get deployed pod count - pods, err := dh.cloningHandler.Manager.GetAllPods() + pods, err := dh.cloningHandler.Service.AdminGetPods() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployed pod count", "details": err.Error()}) return diff --git a/internal/api/handlers/proxmox_handler.go b/internal/api/handlers/proxmox_handler.go index 34a75e5..0bc8676 100644 --- a/internal/api/handlers/proxmox_handler.go +++ b/internal/api/handlers/proxmox_handler.go @@ -9,11 +9,6 @@ import ( "github.com/gin-gonic/gin" ) -// ProxmoxHandler handles HTTP requests for Proxmox operations -type ProxmoxHandler struct { - service proxmox.Service -} - // NewProxmoxHandler creates a new Proxmox handler, loading configuration internally func NewProxmoxHandler() (*ProxmoxHandler, error) { proxmoxService, err := proxmox.NewService() @@ -57,7 +52,7 @@ func (ph *ProxmoxHandler) GetVMsHandler(c *gin.Context) { // ADMIN: StartVMHandler handles POST requests for starting a VM on Proxmox func (ph *ProxmoxHandler) StartVMHandler(c *gin.Context) { var req VMActionRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } @@ -73,7 +68,7 @@ func (ph *ProxmoxHandler) StartVMHandler(c *gin.Context) { // ADMIN: ShutdownVMHandler handles POST requests for shutting down a VM on Proxmox func (ph *ProxmoxHandler) ShutdownVMHandler(c *gin.Context) { var req VMActionRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } @@ -89,7 +84,7 @@ func (ph *ProxmoxHandler) ShutdownVMHandler(c *gin.Context) { // ADMIN: RebootVMHandler handles POST requests for rebooting a VM on Proxmox func (ph *ProxmoxHandler) RebootVMHandler(c *gin.Context) { var req VMActionRequest - if !ValidateAndBind(c, &req) { + if !validateAndBind(c, &req) { return } diff --git a/internal/api/handlers/types.go b/internal/api/handlers/types.go index 91c0327..2fc47a0 100644 --- a/internal/api/handlers/types.go +++ b/internal/api/handlers/types.go @@ -3,20 +3,47 @@ package handlers import ( "net/http" - "github.com/cpp-cyber/proclone/internal/auth" + "github.com/cpp-cyber/proclone/internal/api/auth" "github.com/cpp-cyber/proclone/internal/cloning" + "github.com/cpp-cyber/proclone/internal/ldap" "github.com/cpp-cyber/proclone/internal/proxmox" + "github.com/cpp-cyber/proclone/internal/tools" "github.com/gin-gonic/gin" ) -// API endpoint request structures +// ================================================= +// Handler Types +// ================================================= // AuthHandler handles HTTP authentication requests type AuthHandler struct { authService auth.Service + ldapService ldap.Service proxmoxService proxmox.Service } +// CloningHandler holds the cloning service +type CloningHandler struct { + Service *cloning.CloningService + dbClient *tools.DBClient +} + +// DashboardHandler handles HTTP requests for dashboard operations +type DashboardHandler struct { + authHandler *AuthHandler + proxmoxHandler *ProxmoxHandler + cloningHandler *CloningHandler +} + +// ProxmoxHandler handles HTTP requests for Proxmox operations +type ProxmoxHandler struct { + service proxmox.Service +} + +// ================================================= +// API Request Types +// ================================================= + type VMActionRequest struct { Node string `json:"node" binding:"required,min=1,max=100" validate:"alphanum"` VMID int `json:"vmid" binding:"required,min=100,max=999999"` @@ -52,13 +79,13 @@ type AdminDeletePodRequest struct { Pods []string `json:"pods" binding:"required,min=1,dive,min=1,max=100" validate:"dive,alphanum,ascii"` } -type UserRequest struct { - Username string `json:"username" binding:"required,min=3,max=50" validate:"alphanum,ascii"` +type UsernamePasswordRequest struct { + Username string `json:"username" binding:"required,min=3,max=20" validate:"alphanum,ascii"` Password string `json:"password" binding:"required,min=8,max=128"` } type AdminCreateUserRequest struct { - Users []UserRequest `json:"users" binding:"required,min=1,max=100,dive"` + Users []UsernamePasswordRequest `json:"users" binding:"required,min=1,max=100,dive"` } type UsersRequest struct { @@ -71,7 +98,7 @@ type ModifyGroupMembersRequest struct { } type SetUserGroupsRequest struct { - Username string `json:"username" binding:"required,min=3,max=50" validate:"alphanum,ascii"` + Username string `json:"username" binding:"required,min=3,max=20" validate:"alphanum,ascii"` Groups []string `json:"groups" binding:"required,min=1,dive,min=1,max=100" validate:"dive,alphanum,ascii"` } @@ -89,7 +116,11 @@ type DashboardStats struct { ClusterResourceUsage any `json:"cluster"` } -func ValidateAndBind(c *gin.Context, obj any) bool { +// ================================================= +// Private Functions +// ================================================= + +func validateAndBind(c *gin.Context, obj any) bool { if err := c.ShouldBindJSON(obj); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": "Validation failed", diff --git a/internal/auth/auth.go b/internal/auth/auth.go deleted file mode 100644 index 745b866..0000000 --- a/internal/auth/auth.go +++ /dev/null @@ -1,216 +0,0 @@ -package auth - -import ( - "fmt" - "log" - - ldapv3 "github.com/go-ldap/ldap/v3" -) - -// Service defines the authentication service interface -type Service interface { - Authenticate(username, password string) (bool, error) - IsAdmin(username string) (bool, error) - GetUsers() ([]User, error) - CreateAndRegisterUser(userInfo UserRegistrationInfo) error - DeleteUser(username string) error - AddUserToGroup(username string, groupName string) error - SetUserGroups(username string, groups []string) error - CreateGroup(groupName string) error - GetGroups() ([]Group, error) - RenameGroup(oldGroupName string, newGroupName string) error - DeleteGroup(groupName string) error - GetGroupMembers(groupName string) ([]User, error) - RemoveUserFromGroup(username string, groupName string) error - EnableUserAccount(username string) error - DisableUserAccount(username string) error - HealthCheck() error - Reconnect() error - AddUsersToGroup(groupName string, usernames []string) error - RemoveUsersFromGroup(groupName string, usernames []string) error - GetUserGroups(userDN string) ([]string, error) - GetUserDN(username string) (string, error) -} - -// LDAPService implements authentication using LDAP -type LDAPService struct { - client *Client -} - -// NewLDAPService creates a new LDAP authentication service -func NewLDAPService() (*LDAPService, error) { - log.Println("[DEBUG] NewLDAPService: Starting LDAP service initialization") - - config, err := LoadConfig() - if err != nil { - log.Printf("[ERROR] NewLDAPService: Failed to load LDAP configuration: %v", err) - return nil, fmt.Errorf("failed to load LDAP configuration: %w", err) - } - log.Printf("[DEBUG] NewLDAPService: LDAP configuration loaded successfully - URL: %s, BindUser: %s", config.URL, config.BindUser) - - client := NewClient(config) - if err := client.Connect(); err != nil { - log.Printf("[ERROR] NewLDAPService: Failed to connect to LDAP: %v", err) - return nil, fmt.Errorf("failed to connect to LDAP: %w", err) - } - log.Println("[DEBUG] NewLDAPService: LDAP client connected successfully") - - log.Println("[INFO] NewLDAPService: LDAP service initialized successfully") - return &LDAPService{ - client: client, - }, nil -} - -// Authenticate performs user authentication against LDAP -func (s *LDAPService) Authenticate(username, password string) (bool, error) { - log.Printf("[DEBUG] Authenticate: Starting authentication for user: %s", username) - - userDN, err := s.GetUserDN(username) - if err != nil { - log.Printf("[ERROR] Authenticate: Failed to get user DN for %s: %v", username, err) - return false, fmt.Errorf("failed to get user DN: %v", err) - } - log.Printf("[DEBUG] Authenticate: Retrieved user DN for %s: %s", username, userDN) - - // Bind as user to verify password - log.Printf("[DEBUG] Authenticate: Attempting to bind as user: %s", username) - err = s.client.Bind(userDN, password) - if err != nil { - log.Printf("[WARN] Authenticate: Authentication failed for user %s: %v", username, err) - return false, nil // Invalid credentials, not an error - } - log.Printf("[DEBUG] Authenticate: User bind successful for: %s", username) - - // Rebind as service account for further operations - config := s.client.Config() - if config.BindUser != "" { - log.Printf("[DEBUG] Authenticate: Rebinding as service account: %s", config.BindUser) - err = s.client.Bind(config.BindUser, config.BindPassword) - if err != nil { - log.Printf("[ERROR] Authenticate: Failed to rebind as service account: %v", err) - return false, fmt.Errorf("failed to rebind as service account: %v", err) - } - log.Println("[DEBUG] Authenticate: Service account rebind successful") - } - - log.Printf("[INFO] Authenticate: Authentication successful for user: %s", username) - return true, nil -} - -// IsAdmin checks if a user is a member of the admin group -func (s *LDAPService) IsAdmin(username string) (bool, error) { - log.Printf("[DEBUG] IsAdmin: Checking admin status for user: %s", username) - config := s.client.Config() - - // Search for admin group - log.Printf("[DEBUG] IsAdmin: Searching for admin group: %s", config.AdminGroupDN) - adminGroupReq := ldapv3.NewSearchRequest( - config.AdminGroupDN, - ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, - "(objectClass=group)", - []string{"member"}, - nil, - ) - - // Search for user DN - log.Printf("[DEBUG] IsAdmin: Searching for user DN for: %s", username) - userDNReq := ldapv3.NewSearchRequest( - config.BaseDN, - ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&(objectClass=user)(sAMAccountName=%s))", username), - []string{"dn"}, - nil, - ) - - adminGroupEntry, err := s.client.SearchEntry(adminGroupReq) - if err != nil { - log.Printf("[ERROR] IsAdmin: Failed to search admin group: %v", err) - return false, fmt.Errorf("failed to search admin group: %v", err) - } - - userEntry, err := s.client.SearchEntry(userDNReq) - if err != nil { - log.Printf("[ERROR] IsAdmin: Failed to search user %s: %v", username, err) - return false, fmt.Errorf("failed to search user: %v", err) - } - - if adminGroupEntry == nil { - log.Printf("[ERROR] IsAdmin: Admin group not found: %s", config.AdminGroupDN) - return false, fmt.Errorf("admin group not found") - } - - if userEntry == nil { - log.Printf("[ERROR] IsAdmin: User not found: %s", username) - return false, fmt.Errorf("user not found") - } - - log.Printf("[DEBUG] IsAdmin: User DN found: %s", userEntry.DN) - adminMembers := adminGroupEntry.GetAttributeValues("member") - log.Printf("[DEBUG] IsAdmin: Admin group has %d members", len(adminMembers)) - - // Check if user DN is in admin group members - for _, member := range adminMembers { - if member == userEntry.DN { - log.Printf("[INFO] IsAdmin: User %s is an admin", username) - return true, nil - } - } - - log.Printf("[DEBUG] IsAdmin: User %s is not an admin", username) - return false, nil -} - -// Close closes the LDAP connection -func (s *LDAPService) Close() error { - log.Println("[DEBUG] Close: Closing LDAP connection") - err := s.client.Disconnect() - if err != nil { - log.Printf("[ERROR] Close: Failed to close LDAP connection: %v", err) - } else { - log.Println("[INFO] Close: LDAP connection closed successfully") - } - return err -} - -// HealthCheck verifies that the LDAP connection is working -func (s *LDAPService) HealthCheck() error { - log.Println("[DEBUG] HealthCheck: Performing LDAP health check") - err := s.client.HealthCheck() - if err != nil { - log.Printf("[ERROR] HealthCheck: LDAP health check failed: %v", err) - } else { - log.Println("[DEBUG] HealthCheck: LDAP health check passed") - } - return err -} - -// Reconnect attempts to reconnect to the LDAP server -func (s *LDAPService) Reconnect() error { - log.Println("[DEBUG] Reconnect: Attempting to reconnect to LDAP server") - err := s.client.Connect() - if err != nil { - log.Printf("[ERROR] Reconnect: Failed to reconnect to LDAP server: %v", err) - } else { - log.Println("[INFO] Reconnect: Successfully reconnected to LDAP server") - } - return err -} - -// SetPassword sets the password for a user using User struct -func (s *LDAPService) SetPassword(user User, password string) error { - log.Printf("[DEBUG] SetPassword: Setting password for user: %s", user.Name) - userDN, err := s.GetUserDN(user.Name) - if err != nil { - log.Printf("[ERROR] SetPassword: Failed to get user DN for %s: %v", user.Name, err) - return fmt.Errorf("failed to get user DN: %v", err) - } - log.Printf("[DEBUG] SetPassword: Retrieved user DN: %s", userDN) - - err = s.SetUserPassword(userDN, password) - if err != nil { - log.Printf("[ERROR] SetPassword: Failed to set password for user %s: %v", user.Name, err) - } else { - log.Printf("[INFO] SetPassword: Password set successfully for user: %s", user.Name) - } - return err -} diff --git a/internal/auth/groups.go b/internal/auth/groups.go deleted file mode 100644 index 672f53f..0000000 --- a/internal/auth/groups.go +++ /dev/null @@ -1,497 +0,0 @@ -package auth - -import ( - "fmt" - "log" - "regexp" - "strings" - "time" - - ldapv3 "github.com/go-ldap/ldap/v3" -) - -type CreateRequest struct { - Group string `json:"group"` -} - -type Group struct { - Name string `json:"name"` - CanModify bool `json:"can_modify"` - CreatedAt string `json:"created_at,omitempty"` - UserCount int `json:"user_count,omitempty"` -} - -// GetGroups retrieves all groups from the KaminoGroups OU -func (s *LDAPService) GetGroups() ([]Group, error) { - log.Println("[DEBUG] GetGroups: Starting to retrieve all groups from KaminoGroups OU") - config := s.client.Config() - - // Search for all groups in the KaminoGroups OU - kaminoGroupsOU := "OU=KaminoGroups," + config.BaseDN - log.Printf("[DEBUG] GetGroups: Searching in OU: %s", kaminoGroupsOU) - req := ldapv3.NewSearchRequest( - kaminoGroupsOU, - ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, - "(objectClass=group)", - []string{"cn", "whenCreated", "member"}, - nil, - ) - - searchResult, err := s.client.Search(req) - if err != nil { - log.Printf("[ERROR] GetGroups: Failed to search for groups: %v", err) - return nil, fmt.Errorf("failed to search for groups: %v", err) - } - log.Printf("[DEBUG] GetGroups: Found %d groups", len(searchResult.Entries)) - - var groups []Group - for _, entry := range searchResult.Entries { - cn := entry.GetAttributeValue("cn") - log.Printf("[DEBUG] GetGroups: Processing group: %s", cn) - - // Check if the group is protected - protectedGroup, err := isProtectedGroup(cn) - if err != nil { - log.Printf("[ERROR] GetGroups: Failed to determine if group %s is protected: %v", cn, err) - return nil, fmt.Errorf("failed to determine if the group %s is protected: %v", cn, err) - } - log.Printf("[DEBUG] GetGroups: Group %s protected status: %v", cn, protectedGroup) - - group := Group{ - Name: cn, - CanModify: !protectedGroup, - UserCount: len(entry.GetAttributeValues("member")), - } - - // Add creation date if available and convert it - whenCreated := entry.GetAttributeValue("whenCreated") - if whenCreated != "" { - // AD stores dates in GeneralizedTime format: YYYYMMDDHHMMSS.0Z - if parsedTime, err := time.Parse("20060102150405.0Z", whenCreated); err == nil { - group.CreatedAt = parsedTime.Format("2006-01-02 15:04:05") - log.Printf("[DEBUG] GetGroups: Group %s created at: %s", cn, group.CreatedAt) - } else { - log.Printf("[WARN] GetGroups: Failed to parse creation date for group %s: %s", cn, whenCreated) - } - } - - log.Printf("[DEBUG] GetGroups: Group %s has %d members", cn, group.UserCount) - groups = append(groups, group) - } - - log.Printf("[INFO] GetGroups: Successfully retrieved %d groups", len(groups)) - return groups, nil -} - -func ValidateGroupName(groupName string) error { - if groupName == "" { - return fmt.Errorf("group name cannot be empty") - } - - if len(groupName) >= 64 { - return fmt.Errorf("group name must be less than 64 characters") - } - - regex := regexp.MustCompile("^[a-zA-Z0-9-_]*$") - if !regex.MatchString(groupName) { - return fmt.Errorf("group name must only contain letters, numbers, hyphens, and underscores") - } - - return nil -} - -// CreateGroup creates a new group in LDAP -func (s *LDAPService) CreateGroup(groupName string) error { - log.Printf("[DEBUG] CreateGroup: Starting to create group: %s", groupName) - config := s.client.Config() - - // Validate group name - if err := ValidateGroupName(groupName); err != nil { - log.Printf("[ERROR] CreateGroup: Invalid group name %s: %v", groupName, err) - return fmt.Errorf("invalid group name: %v", err) - } - log.Printf("[DEBUG] CreateGroup: Group name validation passed for: %s", groupName) - - // Check if group already exists - _, err := s.GetGroupDN(groupName) - if err == nil { - log.Printf("[ERROR] CreateGroup: Group already exists: %s", groupName) - return fmt.Errorf("group already exists: %s", groupName) - } - log.Printf("[DEBUG] CreateGroup: Confirmed group %s does not exist", groupName) - - // Construct the DN for the new group - groupDN := fmt.Sprintf("CN=%s,OU=KaminoGroups,%s", groupName, config.BaseDN) - log.Printf("[DEBUG] CreateGroup: Creating group with DN: %s", groupDN) - - // Create the add request - addReq := ldapv3.NewAddRequest(groupDN, nil) - addReq.Attribute("objectClass", []string{"top", "group"}) - addReq.Attribute("cn", []string{groupName}) - addReq.Attribute("name", []string{groupName}) - addReq.Attribute("sAMAccountName", []string{groupName}) - // Set group type to Global Security Group (0x80000002) - addReq.Attribute("groupType", []string{"-2147483646"}) - - err = s.client.Add(addReq) - if err != nil { - log.Printf("[ERROR] CreateGroup: Failed to create group %s: %v", groupName, err) - return fmt.Errorf("failed to create group %s: %v", groupName, err) - } - - log.Printf("[INFO] CreateGroup: Successfully created group: %s", groupName) - return nil -} - -// RenameGroup updates an existing group in LDAP -func (s *LDAPService) RenameGroup(oldGroupName string, newGroupName string) error { - // Check if the group is protected - protectedGroup, err := isProtectedGroup(oldGroupName) - if err != nil { - return fmt.Errorf("failed to determine if the group %s is protected: %v", oldGroupName, err) - } - - if protectedGroup { - return fmt.Errorf("cannot rename protected group: %s", oldGroupName) - } - - // If names are the same, nothing to do - if oldGroupName == newGroupName { - return nil - } - - // Validate new group name - if err := ValidateGroupName(newGroupName); err != nil { - return fmt.Errorf("invalid group name: %v", err) - } - - // Get the DN of the existing group - groupDN, err := s.GetGroupDN(oldGroupName) - if err != nil { - return fmt.Errorf("failed to find group %s: %v", oldGroupName, err) - } - - // Check if the new name already exists - _, err = s.GetGroupDN(newGroupName) - if err == nil { - return fmt.Errorf("group with name %s already exists", newGroupName) - } - - // Get config to construct the new DN - config := s.client.Config() - - // Extract the parent DN (everything after the first comma in the current DN) - // Example: "CN=OldName,OU=KaminoGroups,DC=example,DC=com" -> "OU=KaminoGroups,DC=example,DC=com" - parentDN := "OU=KaminoGroups," + config.BaseDN - - // Create new RDN (Relative Distinguished Name) - newRDN := fmt.Sprintf("CN=%s", newGroupName) - - // Use ModifyDN operation to rename the group - modifyDNReq := ldapv3.NewModifyDNRequest(groupDN, newRDN, true, parentDN) - - err = s.client.ModifyDN(modifyDNReq) - if err != nil { - return fmt.Errorf("failed to rename group %s to %s: %v", oldGroupName, newGroupName, err) - } - - return nil -} - -// DeleteGroup deletes a group from LDAP -func (s *LDAPService) DeleteGroup(groupName string) error { - log.Printf("[DEBUG] DeleteGroup: Starting to delete group: %s", groupName) - - // Check if the group is protected - protectedGroup, err := isProtectedGroup(groupName) - if err != nil { - log.Printf("[ERROR] DeleteGroup: Failed to determine if group %s is protected: %v", groupName, err) - return fmt.Errorf("failed to determine if the group %s is protected: %v", groupName, err) - } - - if protectedGroup { - log.Printf("[ERROR] DeleteGroup: Cannot delete protected group: %s", groupName) - return fmt.Errorf("cannot delete protected group: %s", groupName) - } - log.Printf("[DEBUG] DeleteGroup: Group %s is not protected, proceeding with deletion", groupName) - - // Get the DN of the group to delete - groupDN, err := s.GetGroupDN(groupName) - if err != nil { - log.Printf("[ERROR] DeleteGroup: Failed to find group %s: %v", groupName, err) - return fmt.Errorf("failed to find group %s: %v", groupName, err) - } - log.Printf("[DEBUG] DeleteGroup: Found group DN: %s", groupDN) - - // Create delete request - delReq := ldapv3.NewDelRequest(groupDN, nil) - - err = s.client.Delete(delReq) - if err != nil { - log.Printf("[ERROR] DeleteGroup: Failed to delete group %s: %v", groupName, err) - return fmt.Errorf("failed to delete group %s: %v", groupName, err) - } - - log.Printf("[INFO] DeleteGroup: Successfully deleted group: %s", groupName) - return nil -} - -// GetGroupMembers retrieves all members of a specific group -func (s *LDAPService) GetGroupMembers(groupName string) ([]User, error) { - config := s.client.Config() - - // Get the group DN - groupDN, err := s.GetGroupDN(groupName) - if err != nil { - return nil, fmt.Errorf("failed to find group %s: %v", groupName, err) - } - - // Search for users who are members of this group - req := ldapv3.NewSearchRequest( - config.BaseDN, - ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&(objectClass=user)(sAMAccountName=*)(memberOf=%s))", groupDN), - []string{"sAMAccountName", "dn", "whenCreated", "userAccountControl"}, - nil, - ) - - searchResult, err := s.client.Search(req) - if err != nil { - return nil, fmt.Errorf("failed to search for group members: %v", err) - } - - var users []User - for _, entry := range searchResult.Entries { - user := User{ - Name: entry.GetAttributeValue("sAMAccountName"), - } - - // Add creation date if available and convert it - whenCreated := entry.GetAttributeValue("whenCreated") - if whenCreated != "" { - // AD stores dates in GeneralizedTime format: YYYYMMDDHHMMSS.0Z - if parsedTime, err := time.Parse("20060102150405.0Z", whenCreated); err == nil { - user.CreatedAt = parsedTime.Format("2006-01-02 15:04:05") - } - } - - // Check if user is enabled by parsing userAccountControl - user.Enabled = isUserEnabled(entry.GetAttributeValue("userAccountControl")) - - users = append(users, user) - } - - return users, nil -} - -func isProtectedGroup(groupName string) (bool, error) { - groupName = strings.ToLower(groupName) - - blocked := []string{"kamino", "domain", "admin"} - for _, b := range blocked { - if strings.Contains(groupName, b) { - return true, nil - } - } - return false, nil -} - -func (s *LDAPService) AddUsersToGroup(groupName string, usernames []string) error { - log.Printf("[DEBUG] AddUsersToGroup: Adding %d users to group %s", len(usernames), groupName) - if len(usernames) == 0 { - log.Printf("[DEBUG] AddUsersToGroup: No users to add to group %s", groupName) - return nil // Nothing to do - } - - // Get group DN first - groupDN, err := s.GetGroupDN(groupName) - if err != nil { - log.Printf("[ERROR] AddUsersToGroup: Failed to find group %s: %v", groupName, err) - return fmt.Errorf("failed to find group %s: %v", groupName, err) - } - log.Printf("[DEBUG] AddUsersToGroup: Found group DN: %s", groupDN) - - // Get all user DNs, filtering out invalid users - var validUserDNs []string - var invalidUsers []string - - log.Printf("[DEBUG] AddUsersToGroup: Validating %d usernames", len(usernames)) - for _, username := range usernames { - if username == "" { - log.Printf("[DEBUG] AddUsersToGroup: Skipping empty username") - continue // Skip empty usernames - } - - userDN, err := s.GetUserDN(username) - if err != nil { - log.Printf("[WARN] AddUsersToGroup: Invalid user %s: %v", username, err) - invalidUsers = append(invalidUsers, username) - continue - } - log.Printf("[DEBUG] AddUsersToGroup: Valid user %s with DN: %s", username, userDN) - validUserDNs = append(validUserDNs, userDN) - } - - // If no valid users found, return error - if len(validUserDNs) == 0 { - if len(invalidUsers) > 0 { - log.Printf("[ERROR] AddUsersToGroup: No valid users found. Invalid users: %s", strings.Join(invalidUsers, ", ")) - return fmt.Errorf("no valid users found. Invalid users: %s", strings.Join(invalidUsers, ", ")) - } - log.Printf("[ERROR] AddUsersToGroup: No users provided") - return fmt.Errorf("no users provided") - } - - log.Printf("[DEBUG] AddUsersToGroup: Attempting bulk add of %d valid users", len(validUserDNs)) - // Add all users to the group in a single LDAP modify operation - modifyReq := ldapv3.NewModifyRequest(groupDN, nil) - modifyReq.Add("member", validUserDNs) - - err = s.client.Modify(modifyReq) - if err != nil { - log.Printf("[WARN] AddUsersToGroup: Bulk add failed, trying individual adds: %v", err) - // If bulk add fails, try adding users individually to identify which ones are already members - var alreadyMembers []string - var failedUsers []string - var successCount int - - for i, userDN := range validUserDNs { - log.Printf("[DEBUG] AddUsersToGroup: Individual add attempt for user %s", usernames[i]) - individualReq := ldapv3.NewModifyRequest(groupDN, nil) - individualReq.Add("member", []string{userDN}) - - individualErr := s.client.Modify(individualReq) - if individualErr != nil { - if strings.Contains(strings.ToLower(individualErr.Error()), "already exists") || - strings.Contains(strings.ToLower(individualErr.Error()), "attribute or value exists") { - log.Printf("[DEBUG] AddUsersToGroup: User %s already member", usernames[i]) - alreadyMembers = append(alreadyMembers, usernames[i]) - } else { - log.Printf("[ERROR] AddUsersToGroup: Failed to add user %s: %v", usernames[i], individualErr) - failedUsers = append(failedUsers, fmt.Sprintf("%s: %v", usernames[i], individualErr)) - } - } else { - log.Printf("[DEBUG] AddUsersToGroup: Successfully added user %s", usernames[i]) - successCount++ - } - } - - // Prepare result message - var messages []string - if successCount > 0 { - messages = append(messages, fmt.Sprintf("%d users added successfully", successCount)) - } - if len(alreadyMembers) > 0 { - messages = append(messages, fmt.Sprintf("%d users already members (skipped): %s", len(alreadyMembers), strings.Join(alreadyMembers, ", "))) - } - if len(invalidUsers) > 0 { - messages = append(messages, fmt.Sprintf("%d invalid users (skipped): %s", len(invalidUsers), strings.Join(invalidUsers, ", "))) - } - if len(failedUsers) > 0 { - messages = append(messages, fmt.Sprintf("%d users failed: %s", len(failedUsers), strings.Join(failedUsers, "; "))) - } - - if len(failedUsers) > 0 && successCount == 0 { - log.Printf("[ERROR] AddUsersToGroup: All operations failed: %s", strings.Join(messages, "; ")) - return fmt.Errorf("failed to add users to group %s: %s", groupName, strings.Join(messages, "; ")) - } - - // If some succeeded, just log the status (don't return error for partial success) - log.Printf("[INFO] AddUsersToGroup result: %s", strings.Join(messages, "; ")) - fmt.Printf("AddUsersToGroup result: %s\n", strings.Join(messages, "; ")) - } else { - log.Printf("[INFO] AddUsersToGroup: Bulk add successful for group %s", groupName) - } - - return nil -} - -func (s *LDAPService) RemoveUsersFromGroup(groupName string, usernames []string) error { - if len(usernames) == 0 { - return nil // Nothing to do - } - - // Get group DN first - groupDN, err := s.GetGroupDN(groupName) - if err != nil { - return fmt.Errorf("failed to find group %s: %v", groupName, err) - } - - // Get all user DNs, filtering out invalid users - var validUserDNs []string - var invalidUsers []string - - for _, username := range usernames { - if username == "" { - continue // Skip empty usernames - } - - userDN, err := s.GetUserDN(username) - if err != nil { - invalidUsers = append(invalidUsers, username) - continue - } - validUserDNs = append(validUserDNs, userDN) - } - - // If no valid users found, return error - if len(validUserDNs) == 0 { - if len(invalidUsers) > 0 { - return fmt.Errorf("no valid users found. Invalid users: %s", strings.Join(invalidUsers, ", ")) - } - return fmt.Errorf("no users provided") - } - - // Remove all users from the group in a single LDAP modify operation - modifyReq := ldapv3.NewModifyRequest(groupDN, nil) - modifyReq.Delete("member", validUserDNs) - - err = s.client.Modify(modifyReq) - if err != nil { - // If bulk remove fails, try removing users individually to identify which ones are not members - var notMembers []string - var failedUsers []string - var successCount int - - for i, userDN := range validUserDNs { - individualReq := ldapv3.NewModifyRequest(groupDN, nil) - individualReq.Delete("member", []string{userDN}) - - individualErr := s.client.Modify(individualReq) - if individualErr != nil { - if strings.Contains(strings.ToLower(individualErr.Error()), "no such attribute") || - strings.Contains(strings.ToLower(individualErr.Error()), "no such value") { - notMembers = append(notMembers, usernames[i]) - } else { - failedUsers = append(failedUsers, fmt.Sprintf("%s: %v", usernames[i], individualErr)) - } - } else { - successCount++ - } - } - - // Prepare result message - var messages []string - if successCount > 0 { - messages = append(messages, fmt.Sprintf("%d users removed successfully", successCount)) - } - if len(notMembers) > 0 { - messages = append(messages, fmt.Sprintf("%d users not members (skipped): %s", len(notMembers), strings.Join(notMembers, ", "))) - } - if len(invalidUsers) > 0 { - messages = append(messages, fmt.Sprintf("%d invalid users (skipped): %s", len(invalidUsers), strings.Join(invalidUsers, ", "))) - } - if len(failedUsers) > 0 { - messages = append(messages, fmt.Sprintf("%d users failed: %s", len(failedUsers), strings.Join(failedUsers, "; "))) - } - - if len(failedUsers) > 0 && successCount == 0 { - return fmt.Errorf("failed to remove users from group %s: %s", groupName, strings.Join(messages, "; ")) - } - - // If some succeeded, just log the status (don't return error for partial success) - fmt.Printf("RemoveUsersFromGroup result: %s\n", strings.Join(messages, "; ")) - } - - return nil -} diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go deleted file mode 100644 index 4436965..0000000 --- a/internal/auth/ldap.go +++ /dev/null @@ -1,549 +0,0 @@ -package auth - -import ( - "crypto/tls" - "fmt" - "log" - "strings" - "sync" - "time" - - "github.com/go-ldap/ldap/v3" - "github.com/kelseyhightower/envconfig" -) - -// Config holds LDAP configuration -type Config struct { - URL string `envconfig:"LDAP_URL" default:"ldaps://localhost:636"` - BindUser string `envconfig:"LDAP_BIND_USER"` - BindPassword string `envconfig:"LDAP_BIND_PASSWORD"` - SkipTLSVerify bool `envconfig:"LDAP_SKIP_TLS_VERIFY" default:"false"` - AdminGroupDN string `envconfig:"LDAP_ADMIN_GROUP_DN"` - BaseDN string `envconfig:"LDAP_BASE_DN"` -} - -// Client wraps LDAP connection and provides low-level operations -type Client struct { - conn ldap.Client - config *Config - mutex sync.RWMutex - connected bool -} - -// NewClient creates a new LDAP client -func NewClient(config *Config) *Client { - return &Client{config: config} -} - -// LoadConfig loads and validates LDAP configuration from environment variables -func LoadConfig() (*Config, error) { - log.Println("[DEBUG] LoadConfig: Loading LDAP configuration from environment variables") - var config Config - if err := envconfig.Process("", &config); err != nil { - log.Printf("[ERROR] LoadConfig: Failed to process LDAP configuration: %v", err) - return nil, fmt.Errorf("failed to process LDAP configuration: %w", err) - } - log.Printf("[DEBUG] LoadConfig: LDAP configuration loaded - URL: %s, BaseDN: %s, AdminGroupDN: %s", - config.URL, config.BaseDN, config.AdminGroupDN) - return &config, nil -} - -// Connectivity - -// Connect establishes connection to LDAP server -func (c *Client) Connect() error { - log.Println("[DEBUG] LDAP Connect: Attempting to establish LDAP connection") - c.mutex.Lock() - defer c.mutex.Unlock() - - conn, err := c.dial() - if err != nil { - log.Printf("[ERROR] LDAP Connect: Failed to dial LDAP server: %v", err) - c.connected = false - return fmt.Errorf("failed to connect to LDAP server: %v", err) - } - log.Printf("[DEBUG] LDAP Connect: Successfully dialed LDAP server: %s", c.config.URL) - - if c.config.BindUser != "" { - log.Printf("[DEBUG] LDAP Connect: Binding as service user: %s", c.config.BindUser) - err = conn.Bind(c.config.BindUser, c.config.BindPassword) - if err != nil { - log.Printf("[ERROR] LDAP Connect: Failed to bind as service user: %v", err) - conn.Close() - c.connected = false - return fmt.Errorf("failed to bind to LDAP server: %v", err) - } - log.Println("[DEBUG] LDAP Connect: Service user bind successful") - } else { - log.Println("[DEBUG] LDAP Connect: No bind user configured, using anonymous bind") - } - - c.conn = conn - c.connected = true - log.Println("[INFO] LDAP Connect: Connection established successfully") - return nil -} - -// dial creates a new LDAP connection -func (c *Client) dial() (ldap.Client, error) { - log.Printf("[DEBUG] LDAP dial: Attempting to dial %s", c.config.URL) - var dialOpts []ldap.DialOpt - if strings.HasPrefix(c.config.URL, "ldaps://") { - log.Printf("[DEBUG] LDAP dial: Using LDAPS with TLS config - SkipTLSVerify: %v", c.config.SkipTLSVerify) - dialOpts = append(dialOpts, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: c.config.SkipTLSVerify, MinVersion: tls.VersionTLS12})) - } else { - log.Printf("[ERROR] LDAP dial: Unsupported URL scheme: %s", c.config.URL) - return nil, fmt.Errorf("only ldaps:// is supported") - } - - conn, err := ldap.DialURL(c.config.URL, dialOpts...) - if err != nil { - log.Printf("[ERROR] LDAP dial: Failed to dial %s: %v", c.config.URL, err) - } else { - log.Printf("[DEBUG] LDAP dial: Successfully dialed %s", c.config.URL) - } - return conn, err -} - -// Disconnect closes the LDAP connection -func (c *Client) Disconnect() error { - log.Println("[DEBUG] LDAP Disconnect: Closing LDAP connection") - c.mutex.Lock() - defer c.mutex.Unlock() - - if c.conn == nil { - log.Println("[DEBUG] LDAP Disconnect: No connection to close") - c.connected = false - return nil - } - - err := c.conn.Close() - c.connected = false - if err != nil { - log.Printf("[ERROR] LDAP Disconnect: Error closing connection: %v", err) - } else { - log.Println("[INFO] LDAP Disconnect: Connection closed successfully") - } - return err -} - -// isConnectionError checks if an error indicates a connection problem -func (c *Client) isConnectionError(err error) bool { - if err == nil { - return false - } - - errorMsg := strings.ToLower(err.Error()) - return strings.Contains(errorMsg, "connection closed") || - strings.Contains(errorMsg, "network error") || - strings.Contains(errorMsg, "connection reset") || - strings.Contains(errorMsg, "broken pipe") || - strings.Contains(errorMsg, "connection refused") || - strings.Contains(errorMsg, "timeout") || - strings.Contains(errorMsg, "eof") || - strings.Contains(errorMsg, "operations error") || - strings.Contains(errorMsg, "successful bind must be completed") || - strings.Contains(errorMsg, "ldap result code 1") -} - -// reconnect attempts to reconnect to the LDAP server -func (c *Client) reconnect() error { - log.Println("[DEBUG] LDAP reconnect: Attempting to reconnect to LDAP server") - c.mutex.Lock() - defer c.mutex.Unlock() - - // Close existing connection if any - if c.conn != nil { - log.Println("[DEBUG] LDAP reconnect: Closing existing connection") - c.conn.Close() - } - c.connected = false - - // Wait a moment before retrying - log.Println("[DEBUG] LDAP reconnect: Waiting 100ms before retry") - time.Sleep(100 * time.Millisecond) - - // Attempt reconnection - log.Println("[DEBUG] LDAP reconnect: Attempting to dial server") - conn, err := c.dial() - if err != nil { - log.Printf("[ERROR] LDAP reconnect: Failed to reconnect: %v", err) - return fmt.Errorf("failed to reconnect to LDAP server: %v", err) - } - - if c.config.BindUser != "" { - log.Printf("[DEBUG] LDAP reconnect: Rebinding as service user: %s", c.config.BindUser) - err = conn.Bind(c.config.BindUser, c.config.BindPassword) - if err != nil { - log.Printf("[ERROR] LDAP reconnect: Failed to bind after reconnection: %v", err) - conn.Close() - return fmt.Errorf("failed to bind after reconnection: %v", err) - } - log.Println("[DEBUG] LDAP reconnect: Service user rebind successful") - } - - c.conn = conn - c.connected = true - log.Println("[INFO] LDAP reconnect: Reconnection successful") - return nil -} - -// Bind performs LDAP bind operation -func (c *Client) Bind(userDN, password string) error { - log.Printf("[DEBUG] LDAP Bind: Attempting to bind as user: %s", userDN) - err := c.executeWithRetry(func() error { - c.mutex.RLock() - conn := c.conn - c.mutex.RUnlock() - - if conn == nil { - log.Println("[ERROR] LDAP Bind: No LDAP connection available") - return fmt.Errorf("no LDAP connection available") - } - - bindErr := conn.Bind(userDN, password) - if bindErr != nil { - log.Printf("[DEBUG] LDAP Bind: Bind failed for %s: %v", userDN, bindErr) - } else { - log.Printf("[DEBUG] LDAP Bind: Bind successful for %s", userDN) - } - return bindErr - }, 2) // Retry up to 2 times - - if err != nil { - log.Printf("[ERROR] LDAP Bind: Final bind failure for %s: %v", userDN, err) - } - return err -} - -// Config returns the LDAP configuration -func (c *Client) Config() *Config { - return c.config -} - -// IsConnected returns the current connection status -func (c *Client) IsConnected() bool { - c.mutex.RLock() - defer c.mutex.RUnlock() - return c.connected -} - -// validateBind checks if the current bind is still valid by performing a simple operation -func (c *Client) validateBind() error { - c.mutex.RLock() - conn := c.conn - c.mutex.RUnlock() - - if conn == nil { - return fmt.Errorf("no connection available") - } - - // Try a simple search to validate the bind - req := ldap.NewSearchRequest( - c.config.BaseDN, - ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false, - "(objectClass=*)", - []string{"dn"}, - nil, - ) - - _, err := conn.Search(req) - return err -} - -// HealthCheck performs a simple search to verify the connection is working -func (c *Client) HealthCheck() error { - log.Println("[DEBUG] LDAP HealthCheck: Performing health check") - req := ldap.NewSearchRequest( - c.config.BaseDN, - ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false, - "(objectClass=*)", - []string{"dn"}, - nil, - ) - - err := c.executeWithRetry(func() error { - c.mutex.RLock() - conn := c.conn - c.mutex.RUnlock() - - if conn == nil { - log.Println("[ERROR] LDAP HealthCheck: No LDAP connection available") - return fmt.Errorf("no LDAP connection available") - } - - _, searchErr := conn.Search(req) - if searchErr != nil { - log.Printf("[DEBUG] LDAP HealthCheck: Search failed: %v", searchErr) - } else { - log.Println("[DEBUG] LDAP HealthCheck: Search successful") - } - return searchErr - }, 2) - - if err != nil { - log.Printf("[ERROR] LDAP HealthCheck: Health check failed: %v", err) - } else { - log.Println("[DEBUG] LDAP HealthCheck: Health check passed") - } - return err -} - -/* - Operations -*/ - -// executeWithRetry executes an LDAP operation with automatic retry on connection errors -func (c *Client) executeWithRetry(operation func() error, maxRetries int) error { - var lastErr error - log.Printf("[DEBUG] executeWithRetry: Starting operation with max %d retries", maxRetries) - - for attempt := 0; attempt <= maxRetries; attempt++ { - log.Printf("[DEBUG] executeWithRetry: Attempt %d/%d", attempt+1, maxRetries+1) - - c.mutex.RLock() - connected := c.connected - conn := c.conn - c.mutex.RUnlock() - - // Check if we need to reconnect - if !connected || conn == nil { - log.Printf("[DEBUG] executeWithRetry: Connection not available (connected: %v, conn: %v), attempting reconnect", connected, conn != nil) - if reconnectErr := c.reconnect(); reconnectErr != nil { - log.Printf("[ERROR] executeWithRetry: Reconnect attempt %d failed: %v", attempt+1, reconnectErr) - lastErr = reconnectErr - continue - } - } else { - // Validate that the bind is still active - log.Println("[DEBUG] executeWithRetry: Validating existing bind") - if bindErr := c.validateBind(); bindErr != nil { - if c.isConnectionError(bindErr) { - log.Printf("[DEBUG] executeWithRetry: Bind validation failed with connection error: %v", bindErr) - if reconnectErr := c.reconnect(); reconnectErr != nil { - log.Printf("[ERROR] executeWithRetry: Reconnect after bind validation failure: %v", reconnectErr) - lastErr = reconnectErr - continue - } - } - } - } - - log.Printf("[DEBUG] executeWithRetry: Executing operation (attempt %d)", attempt+1) - err := operation() - if err == nil { - log.Printf("[DEBUG] executeWithRetry: Operation successful on attempt %d", attempt+1) - return nil - } - - lastErr = err - log.Printf("[DEBUG] executeWithRetry: Operation failed on attempt %d: %v", attempt+1, err) - - // If it's not a connection error, don't retry - if !c.isConnectionError(err) { - log.Printf("[DEBUG] executeWithRetry: Not a connection error, not retrying: %v", err) - return err - } - - // Mark as disconnected and try to reconnect - log.Println("[DEBUG] executeWithRetry: Connection error detected, marking as disconnected") - c.mutex.Lock() - c.connected = false - c.mutex.Unlock() - - // Don't reconnect on the last attempt - if attempt < maxRetries { - log.Printf("[DEBUG] executeWithRetry: Attempting reconnect before retry %d", attempt+2) - if reconnectErr := c.reconnect(); reconnectErr != nil { - log.Printf("[ERROR] executeWithRetry: Pre-retry reconnect failed: %v", reconnectErr) - lastErr = reconnectErr - } - } - } - - log.Printf("[ERROR] executeWithRetry: Operation failed after %d retries, last error: %v", maxRetries+1, lastErr) - return fmt.Errorf("operation failed after %d retries, last error: %v", maxRetries+1, lastErr) -} - -// SearchEntry performs an LDAP search and returns the first entry -func (c *Client) SearchEntry(req *ldap.SearchRequest) (*ldap.Entry, error) { - var result *ldap.SearchResult - - err := c.executeWithRetry(func() error { - c.mutex.RLock() - conn := c.conn - c.mutex.RUnlock() - - if conn == nil { - return fmt.Errorf("no LDAP connection available") - } - - res, err := conn.Search(req) - if err != nil { - return fmt.Errorf("failed to search entry: %v", err) - } - result = res - return nil - }, 2) // Retry up to 2 times - - if err != nil { - return nil, err - } - - if len(result.Entries) == 0 { - return nil, nil - } - return result.Entries[0], nil -} - -// Search performs an LDAP search and returns all matching entries -func (c *Client) Search(req *ldap.SearchRequest) (*ldap.SearchResult, error) { - var result *ldap.SearchResult - - err := c.executeWithRetry(func() error { - c.mutex.RLock() - conn := c.conn - c.mutex.RUnlock() - - if conn == nil { - return fmt.Errorf("no LDAP connection available") - } - - res, err := conn.Search(req) - if err != nil { - return fmt.Errorf("failed to search: %v", err) - } - result = res - return nil - }, 2) // Retry up to 2 times - - if err != nil { - return nil, err - } - - return result, nil -} - -// Add performs an LDAP add operation -func (c *Client) Add(req *ldap.AddRequest) error { - return c.executeWithRetry(func() error { - c.mutex.RLock() - conn := c.conn - c.mutex.RUnlock() - - if conn == nil { - return fmt.Errorf("no LDAP connection available") - } - - return conn.Add(req) - }, 2) // Retry up to 2 times -} - -// Modify performs an LDAP modify operation -func (c *Client) Modify(req *ldap.ModifyRequest) error { - return c.executeWithRetry(func() error { - c.mutex.RLock() - conn := c.conn - c.mutex.RUnlock() - - if conn == nil { - return fmt.Errorf("no LDAP connection available") - } - - return conn.Modify(req) - }, 2) // Retry up to 2 times -} - -// Delete performs an LDAP delete operation -func (c *Client) Delete(req *ldap.DelRequest) error { - return c.executeWithRetry(func() error { - c.mutex.RLock() - conn := c.conn - c.mutex.RUnlock() - - if conn == nil { - return fmt.Errorf("no LDAP connection available") - } - - return conn.Del(req) - }, 2) // Retry up to 2 times -} - -// ModifyDN performs an LDAP modify DN operation (rename/move) -func (c *Client) ModifyDN(req *ldap.ModifyDNRequest) error { - return c.executeWithRetry(func() error { - c.mutex.RLock() - conn := c.conn - c.mutex.RUnlock() - - if conn == nil { - return fmt.Errorf("no LDAP connection available") - } - - return conn.ModifyDN(req) - }, 2) // Retry up to 2 times -} - -/* - DNs -*/ - -// GetUserDN retrieves the DN for a given username -func (s *LDAPService) GetUserDN(username string) (string, error) { - log.Printf("[DEBUG] GetUserDN: Searching for user DN for username: %s", username) - config := s.client.Config() - - req := ldap.NewSearchRequest( - config.BaseDN, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&(objectClass=user)(sAMAccountName=%s))", username), - []string{"dn"}, - nil, - ) - log.Printf("[DEBUG] GetUserDN: Search filter: (&(objectClass=user)(sAMAccountName=%s)), BaseDN: %s", username, config.BaseDN) - - entry, err := s.client.SearchEntry(req) - if err != nil { - log.Printf("[ERROR] GetUserDN: Failed to search for user %s: %v", username, err) - return "", fmt.Errorf("failed to search for user: %v", err) - } - - if entry == nil { - log.Printf("[ERROR] GetUserDN: User not found: %s", username) - return "", fmt.Errorf("user not found") - } - - log.Printf("[DEBUG] GetUserDN: Found user DN for %s: %s", username, entry.DN) - return entry.DN, nil -} - -// GetGroupDN retrieves the DN for a given group name from KaminoGroups OU -func (s *LDAPService) GetGroupDN(groupName string) (string, error) { - log.Printf("[DEBUG] GetGroupDN: Searching for group DN for group: %s", groupName) - config := s.client.Config() - - // Search for the group in KaminoGroups OU - kaminoGroupsOU := "OU=KaminoGroups," + config.BaseDN - req := ldap.NewSearchRequest( - kaminoGroupsOU, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&(objectClass=group)(cn=%s))", groupName), - []string{"dn"}, - nil, - ) - log.Printf("[DEBUG] GetGroupDN: Search filter: (&(objectClass=group)(cn=%s)), SearchBase: %s", groupName, kaminoGroupsOU) - - entry, err := s.client.SearchEntry(req) - if err != nil { - log.Printf("[ERROR] GetGroupDN: Failed to search for group %s: %v", groupName, err) - return "", fmt.Errorf("failed to search for group: %v", err) - } - - if entry == nil { - log.Printf("[ERROR] GetGroupDN: Group not found: %s", groupName) - return "", fmt.Errorf("group not found: %s", groupName) - } - - log.Printf("[DEBUG] GetGroupDN: Found group DN for %s: %s", groupName, entry.DN) - return entry.DN, nil -} diff --git a/internal/auth/users.go b/internal/auth/users.go deleted file mode 100644 index bd0be35..0000000 --- a/internal/auth/users.go +++ /dev/null @@ -1,694 +0,0 @@ -package auth - -import ( - "encoding/binary" - "fmt" - "log" - "regexp" - "strconv" - "strings" - "time" - "unicode" - "unicode/utf16" - - ldapv3 "github.com/go-ldap/ldap/v3" -) - -type User struct { - Name string `json:"name"` - CreatedAt string `json:"created_at"` - Enabled bool `json:"enabled"` - IsAdmin bool `json:"is_admin"` - Groups []Group `json:"groups"` -} - -// UserRegistrationInfo contains the information needed to register a new user -type UserRegistrationInfo struct { - Username string `json:"username" validate:"required,min=1,max=20"` - Password string `json:"password" validate:"required,min=8"` -} - -// NewUserRegistrationInfo creates a new UserRegistrationInfo with required fields -func NewUserRegistrationInfo(username, password string) *UserRegistrationInfo { - return &UserRegistrationInfo{ - Username: username, - Password: password, - } -} - -// Validate validates the user registration information -func (u *UserRegistrationInfo) Validate() error { - if err := validateUsername(u.Username); err != nil { - return err - } - - if err := validatePasswordReq(u.Password); err != nil { - return err - } - - return nil -} - -// GetUsers retrieves all users from LDAP -func (s *LDAPService) GetUsers() ([]User, error) { - log.Println("[DEBUG] GetUsers: Starting to retrieve all users from KaminoUsers group") - config := s.client.Config() - - // Create search request to find all user objects who are members of KaminoUsers group - kaminoUsersGroupDN := "CN=KaminoUsers,OU=KaminoGroups," + config.BaseDN - log.Printf("[DEBUG] GetUsers: Searching for users in group: %s", kaminoUsersGroupDN) - req := ldapv3.NewSearchRequest( - config.BaseDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&(objectClass=user)(sAMAccountName=*)(memberOf=%s))", kaminoUsersGroupDN), // Filter for users in KaminoUsers group - []string{"sAMAccountName", "dn", "whenCreated", "memberOf", "userAccountControl"}, // Attributes to retrieve - nil, - ) - - // Perform the search - searchResult, err := s.client.Search(req) - if err != nil { - log.Printf("[ERROR] GetUsers: Failed to search for users: %v", err) - return nil, fmt.Errorf("failed to search for users: %v", err) - } - log.Printf("[DEBUG] GetUsers: Found %d users", len(searchResult.Entries)) - - var users = make([]User, 0) - for _, entry := range searchResult.Entries { - username := entry.GetAttributeValue("sAMAccountName") - log.Printf("[DEBUG] GetUsers: Processing user: %s", username) - - user := User{ - Name: username, - } - - // Add creation date if available and convert it - whenCreated := entry.GetAttributeValue("whenCreated") - if whenCreated != "" { - // AD stores dates in GeneralizedTime format: YYYYMMDDHHMMSS.0Z - if parsedTime, err := time.Parse("20060102150405.0Z", whenCreated); err == nil { - user.CreatedAt = parsedTime.Format("2006-01-02 15:04:05") - log.Printf("[DEBUG] GetUsers: User %s created at: %s", username, user.CreatedAt) - } else { - log.Printf("[WARN] GetUsers: Failed to parse creation date for user %s: %s", username, whenCreated) - } - } - - // Check if user is enabled by parsing userAccountControl - userAccountControl := entry.GetAttributeValue("userAccountControl") - user.Enabled = isUserEnabled(userAccountControl) - log.Printf("[DEBUG] GetUsers: User %s enabled status: %v (userAccountControl: %s)", username, user.Enabled, userAccountControl) - - // Check for admin privileges and add group memberships - var groups []Group - var isAdmin = false - memberOfGroups := entry.GetAttributeValues("memberOf") - log.Printf("[DEBUG] GetUsers: User %s is member of %d groups", username, len(memberOfGroups)) - - for _, groupDN := range memberOfGroups { - // Check if user is admin based on group membership - groupName := extractCNFromDN(groupDN) - log.Printf("[DEBUG] GetUsers: User %s member of group: %s (%s)", username, groupName, groupDN) - - if groupName == "Domain Admins" || groupName == "Proxmox-Admins" { - log.Printf("[DEBUG] GetUsers: User %s identified as admin via group: %s", username, groupName) - isAdmin = true - } - - // Only include groups from Kamino-Groups OU in the groups list - if !strings.Contains(strings.ToLower(groupDN), "ou=kaminogroups") { - log.Printf("[DEBUG] GetUsers: Skipping non-Kamino group for user %s: %s", username, groupName) - continue - } - - // Add group to user's groups list - if groupName != "" { - log.Printf("[DEBUG] GetUsers: Adding Kamino group %s to user %s", groupName, username) - groups = append(groups, Group{ - Name: groupName, - }) - } - } - - user.IsAdmin = isAdmin - user.Groups = groups - log.Printf("[DEBUG] GetUsers: User %s summary - Admin: %v, Groups: %d, Enabled: %v", - username, user.IsAdmin, len(user.Groups), user.Enabled) - - users = append(users, user) - } - - log.Printf("[INFO] GetUsers: Successfully retrieved %d users", len(users)) - return users, nil -} - -// extractDomainFromDN extracts the domain name from a Distinguished Name -func extractDomainFromDN(dn string) string { - // Convert DN like "DC=example,DC=com" to "example.com" - parts := strings.Split(strings.ToLower(dn), ",") - var domainParts []string - - for _, part := range parts { - part = strings.TrimSpace(part) - if strings.HasPrefix(part, "dc=") { - domainParts = append(domainParts, strings.TrimPrefix(part, "dc=")) - } - } - - return strings.Join(domainParts, ".") -} - -// encodePasswordForAD encodes a password for Active Directory unicodePwd attribute -func encodePasswordForAD(password string) string { - // AD requires password to be UTF-16LE encoded and surrounded by quotes - quotedPassword := fmt.Sprintf("\"%s\"", password) - utf16Encoded := utf16.Encode([]rune(quotedPassword)) - - // Convert to bytes in little-endian format - bytes := make([]byte, len(utf16Encoded)*2) - for i, r := range utf16Encoded { - binary.LittleEndian.PutUint16(bytes[i*2:], r) - } - - return string(bytes) -} - -// validateUsername validates a username according to Active Directory requirements -func validateUsername(username string) error { - if len(username) < 1 || len(username) > 20 { - return fmt.Errorf("username must be between 1 and 20 characters") - } - - regex := regexp.MustCompile("^[a-zA-Z0-9]*$") - if !regex.MatchString(username) { - return fmt.Errorf("username must only contain letters and numbers") - } - - return nil -} - -// validatePasswordReq validates a password according to requirements -func validatePasswordReq(password string) error { - var number, letter bool - if len(password) < 8 { - return fmt.Errorf("password must be at least 8 characters long") - } - - if len(password) > 128 { - return fmt.Errorf("password must not exceed 128 characters") - } - - for _, c := range password { - switch { - case unicode.IsNumber(c): - number = true - case unicode.IsLetter(c): - letter = true - } - } - - if !number || !letter { - return fmt.Errorf("password must contain at least one letter and one number") - } - - return nil -} - -// extractCNFromDN extracts the Common Name (CN) from a Distinguished Name (DN) -func extractCNFromDN(dn string) string { - // Split the DN by commas and look for the CN component - parts := strings.Split(dn, ",") - for _, part := range parts { - part = strings.TrimSpace(part) - if strings.HasPrefix(strings.ToLower(part), "cn=") { - // Extract the value after "CN=" - return strings.TrimPrefix(part, strings.Split(part, "=")[0]+"=") - } - } - return "" -} - -// isUserEnabled checks if a user is enabled based on userAccountControl attribute -// In Active Directory, userAccountControl is a bitmask where bit 1 (value 2) indicates "ACCOUNTDISABLE" -// If this bit is set, the account is disabled -func isUserEnabled(userAccountControl string) bool { - if userAccountControl == "" { - return false // Default to disabled if attribute is missing - } - - // Parse the userAccountControl value - uac, err := strconv.ParseInt(userAccountControl, 10, 64) - if err != nil { - return false // Default to disabled if parsing fails - } - - // Check if the ACCOUNTDISABLE bit (0x2) is set - // If bit 1 is set, the account is disabled, so we return the inverse - const ACCOUNTDISABLE = 0x2 - return (uac & ACCOUNTDISABLE) == 0 -} - -// CreateUserWithInfo creates a new user in LDAP with detailed information -func (s *LDAPService) CreateUser(userInfo UserRegistrationInfo) (string, error) { - config := s.client.Config() - - // Create DN for new user in Users container - // TODO: Static - userDN := fmt.Sprintf("CN=%s,OU=KaminoUsers,%s", userInfo.Username, config.BaseDN) - - // Create add request for new user - addReq := ldapv3.NewAddRequest(userDN, nil) - - // Add required object classes - addReq.Attribute("objectClass", []string{"top", "person", "organizationalPerson", "user"}) - - // Add basic attributes - addReq.Attribute("cn", []string{userInfo.Username}) - addReq.Attribute("sAMAccountName", []string{userInfo.Username}) - addReq.Attribute("userPrincipalName", []string{fmt.Sprintf("%s@%s", userInfo.Username, extractDomainFromDN(config.BaseDN))}) - - // Set account control flags - account disabled initially (will be enabled after password is set) - addReq.Attribute("userAccountControl", []string{"546"}) // NORMAL_ACCOUNT + ACCOUNTDISABLE - - // Perform the add operation - err := s.client.Add(addReq) - if err != nil { - return "", fmt.Errorf("failed to create user: %v", err) - } - - return userDN, nil -} - -// SetUserPassword sets the password for a user by DN -func (s *LDAPService) SetUserPassword(userDN string, password string) error { - // For Active Directory, passwords must be set using unicodePwd attribute - // The password must be UTF-16LE encoded and quoted - utf16Password := encodePasswordForAD(password) - - // Create modify request to set password - modifyReq := ldapv3.NewModifyRequest(userDN, nil) - modifyReq.Replace("unicodePwd", []string{utf16Password}) - - err := s.client.Modify(modifyReq) - if err != nil { - return fmt.Errorf("failed to set password: %v", err) - } - - return nil -} - -// EnableUserAccountByDN enables a user account by updating userAccountControl -func (s *LDAPService) EnableUserAccountByDN(userDN string) error { - // Set userAccountControl to 512 (NORMAL_ACCOUNT) to enable the account - modifyReq := ldapv3.NewModifyRequest(userDN, nil) - modifyReq.Replace("userAccountControl", []string{"512"}) - - err := s.client.Modify(modifyReq) - if err != nil { - return fmt.Errorf("failed to enable account: %v", err) - } - - return nil -} - -// DisableUserAccountByDN disables a user account by updating userAccountControl -func (s *LDAPService) DisableUserAccountByDN(userDN string) error { - // Set userAccountControl to 546 (NORMAL_ACCOUNT + ACCOUNTDISABLE) to disable the account - modifyReq := ldapv3.NewModifyRequest(userDN, nil) - modifyReq.Replace("userAccountControl", []string{"546"}) - - err := s.client.Modify(modifyReq) - if err != nil { - return fmt.Errorf("failed to disable account: %v", err) - } - - return nil -} - -// AddToGroup adds a user to a group by DN -func (s *LDAPService) AddToGroup(userDN string, groupDN string) error { - // Create modify request to add user to group - modifyReq := ldapv3.NewModifyRequest(groupDN, nil) - modifyReq.Add("member", []string{userDN}) - - err := s.client.Modify(modifyReq) - if err != nil { - // Check if the error is because the user is already in the group - if strings.Contains(strings.ToLower(err.Error()), "already exists") || - strings.Contains(strings.ToLower(err.Error()), "attribute or value exists") { - return nil // Not an error if user is already in group - } - return fmt.Errorf("failed to add user to group: %v", err) - } - - return nil -} - -// RegisterUser creates, configures, and enables a new user account -func (s *LDAPService) CreateAndRegisterUser(userInfo UserRegistrationInfo) error { - log.Printf("[DEBUG] CreateAndRegisterUser: Starting user registration for: %s", userInfo.Username) - - // Validate username and password - if err := userInfo.Validate(); err != nil { - log.Printf("[ERROR] CreateAndRegisterUser: Validation failed for user %s: %v", userInfo.Username, err) - return err - } - log.Printf("[DEBUG] CreateAndRegisterUser: Validation passed for user: %s", userInfo.Username) - - // Create the user with full information - userDN, err := s.CreateUser(userInfo) - if err != nil { - log.Printf("[ERROR] CreateAndRegisterUser: Failed to create user %s: %v", userInfo.Username, err) - return fmt.Errorf("failed to create user: %v", err) - } - log.Printf("[DEBUG] CreateAndRegisterUser: User created with DN: %s", userDN) - - // Set the password - err = s.SetUserPassword(userDN, userInfo.Password) - if err != nil { - log.Printf("[ERROR] CreateAndRegisterUser: Failed to set password for user %s: %v", userInfo.Username, err) - return fmt.Errorf("failed to set password: %v", err) - } - log.Printf("[DEBUG] CreateAndRegisterUser: Password set for user: %s", userInfo.Username) - - // Add user to default user group - config := s.client.Config() - userGroupDN := fmt.Sprintf("CN=KaminoUsers,OU=KaminoGroups,%s", config.BaseDN) - log.Printf("[DEBUG] CreateAndRegisterUser: Adding user %s to group: %s", userInfo.Username, userGroupDN) - err = s.AddToGroup(userDN, userGroupDN) - if err != nil { - log.Printf("[ERROR] CreateAndRegisterUser: Failed to add user %s to group: %v", userInfo.Username, err) - return fmt.Errorf("failed to add user to group: %v", err) - } - log.Printf("[DEBUG] CreateAndRegisterUser: User %s added to default group", userInfo.Username) - - // Enable the account - err = s.EnableUserAccountByDN(userDN) - if err != nil { - log.Printf("[ERROR] CreateAndRegisterUser: Failed to enable account for user %s: %v", userInfo.Username, err) - return fmt.Errorf("failed to enable account: %v", err) - } - log.Printf("[DEBUG] CreateAndRegisterUser: Account enabled for user: %s", userInfo.Username) - - log.Printf("[INFO] CreateAndRegisterUser: Successfully registered user: %s", userInfo.Username) - return nil -} - -// AddUserToGroup adds a user to a group in LDAP by names -func (s *LDAPService) AddUserToGroup(username string, groupName string) error { - // Get user DN - userDN, err := s.GetUserDN(username) - if err != nil { - return fmt.Errorf("failed to find user %s: %v", username, err) - } - - // Get group DN - groupDN, err := s.GetGroupDN(groupName) - if err != nil { - return fmt.Errorf("failed to find group %s: %v", groupName, err) - } - - // Create modify request to add user to group - modifyReq := ldapv3.NewModifyRequest(groupDN, nil) - modifyReq.Add("member", []string{userDN}) - - err = s.client.Modify(modifyReq) - if err != nil { - // Check if the error is because the user is already in the group - if strings.Contains(strings.ToLower(err.Error()), "already exists") || - strings.Contains(strings.ToLower(err.Error()), "attribute or value exists") { - return fmt.Errorf("user %s is already a member of group %s", username, groupName) - } - return fmt.Errorf("failed to add user %s to group %s: %v", username, groupName, err) - } - - return nil -} - -// RemoveUserFromGroup removes a user from a group in LDAP -func (s *LDAPService) RemoveUserFromGroup(username string, groupName string) error { - // Get user DN - userDN, err := s.GetUserDN(username) - if err != nil { - return fmt.Errorf("failed to find user %s: %v", username, err) - } - - // Get group DN - groupDN, err := s.GetGroupDN(groupName) - if err != nil { - return fmt.Errorf("failed to find group %s: %v", groupName, err) - } - - // Create modify request to remove user from group - modifyReq := ldapv3.NewModifyRequest(groupDN, nil) - modifyReq.Delete("member", []string{userDN}) - - err = s.client.Modify(modifyReq) - if err != nil { - // Check if the error is because the user is not in the group - if strings.Contains(strings.ToLower(err.Error()), "no such attribute") || - strings.Contains(strings.ToLower(err.Error()), "no such value") { - return fmt.Errorf("user %s is not a member of group %s", username, groupName) - } - return fmt.Errorf("failed to remove user %s from group %s: %v", username, groupName, err) - } - - return nil -} - -func (s *LDAPService) DeleteUser(username string) error { - log.Printf("[DEBUG] DeleteUser: Starting deletion process for user: %s", username) - - // Get user DN - userDN, err := s.GetUserDN(username) - if err != nil { - log.Printf("[ERROR] DeleteUser: Failed to find user %s: %v", username, err) - return fmt.Errorf("failed to find user %s: %v", username, err) - } - log.Printf("[DEBUG] DeleteUser: Found user DN for %s: %s", username, userDN) - - // Verify that the user is not an admin - userGroups, err := s.GetUserGroups(userDN) - if err != nil { - log.Printf("[ERROR] DeleteUser: Failed to get user groups for %s: %v", username, err) - return fmt.Errorf("failed to get user groups: %v", err) - } - log.Printf("[DEBUG] DeleteUser: User %s is member of %d groups", username, len(userGroups)) - - isAdminUser, err := isAdmin(userGroups) - if err != nil { - log.Printf("[ERROR] DeleteUser: Failed to check admin status for user %s: %v", username, err) - return fmt.Errorf("failed to check if user is admin: %v", err) - } - if isAdminUser { - log.Printf("[ERROR] DeleteUser: Cannot delete admin user: %s", username) - return fmt.Errorf("cannot delete admin user %s", username) - } - log.Printf("[DEBUG] DeleteUser: User %s is not an admin, proceeding with deletion", username) - - // Create delete request - delReq := ldapv3.NewDelRequest(userDN, nil) - - err = s.client.Delete(delReq) - if err != nil { - log.Printf("[ERROR] DeleteUser: Failed to delete user %s: %v", username, err) - return fmt.Errorf("failed to delete user %s: %v", username, err) - } - - log.Printf("[INFO] DeleteUser: Successfully deleted user: %s", username) - return nil -} - -func (s *LDAPService) DeleteUsers(usernames []string) []error { - var errors []error - var validUsers []string - var userDNs []string - - // First pass: validate all users and collect their DNs - for _, username := range usernames { - // Get user DN - userDN, err := s.GetUserDN(username) - if err != nil { - errors = append(errors, fmt.Errorf("failed to find user %s: %v", username, err)) - continue - } - - // Verify that the user is not an admin - userGroups, err := s.GetUserGroups(userDN) - if err != nil { - errors = append(errors, fmt.Errorf("failed to get user groups for %s: %v", username, err)) - continue - } - - isAdmin, err := isAdmin(userGroups) - if err != nil { - errors = append(errors, fmt.Errorf("failed to check if user %s is admin: %v", username, err)) - continue - } - if isAdmin { - errors = append(errors, fmt.Errorf("cannot delete admin user %s", username)) - continue - } - - // User passed validation - validUsers = append(validUsers, username) - userDNs = append(userDNs, userDN) - } - - // Second pass: delete all valid users - for i, userDN := range userDNs { - username := validUsers[i] - delReq := ldapv3.NewDelRequest(userDN, nil) - - err := s.client.Delete(delReq) - if err != nil { - errors = append(errors, fmt.Errorf("failed to delete user %s: %v", username, err)) - } - } - - return errors -} - -func (s *LDAPService) GetUserGroups(userDN string) ([]string, error) { - config := s.client.Config() - - // Search for groups that the user is a member of (search entire base DN to find all groups including admin groups) - groupSearchReq := ldapv3.NewSearchRequest( - config.BaseDN, - ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&(objectClass=group)(member=%s))", userDN), - []string{"cn", "distinguishedName"}, - nil, - ) - - groupEntries, err := s.client.Search(groupSearchReq) - if err != nil { - return nil, fmt.Errorf("failed to search user groups: %v", err) - } - - var groups []string - for _, entry := range groupEntries.Entries { - groupName := entry.GetAttributeValue("cn") - if groupName != "" { - groups = append(groups, groupName) - } - } - - return groups, nil -} - -func isAdmin(groups []string) (bool, error) { - // If groups is empty, user is not an admin - if len(groups) == 0 { - return false, nil - } - - for _, group := range groups { - group = strings.ToLower(group) - if strings.Contains(group, "admins") || strings.Contains(group, "proxmox-admins") { - return true, nil - } - } - - return false, nil -} - -// EnableUserAccount enables a user account by username (wrapper method for Service interface) -func (s *LDAPService) EnableUserAccount(username string) error { - // Get user DN - userDN, err := s.GetUserDN(username) - if err != nil { - return fmt.Errorf("failed to find user %s: %v", username, err) - } - - // Set userAccountControl to 512 (NORMAL_ACCOUNT) to enable the account - modifyReq := ldapv3.NewModifyRequest(userDN, nil) - modifyReq.Replace("userAccountControl", []string{"512"}) - - err = s.client.Modify(modifyReq) - if err != nil { - return fmt.Errorf("failed to enable account for user %s: %v", username, err) - } - - return nil -} - -// DisableUserAccount disables a user account by username (wrapper method for Service interface) -func (s *LDAPService) DisableUserAccount(username string) error { - // Get user DN - userDN, err := s.GetUserDN(username) - if err != nil { - return fmt.Errorf("failed to find user %s: %v", username, err) - } - - // Verify that the user is not an admin (mandatory security check) - userGroups, err := s.GetUserGroups(userDN) - if err != nil { - return fmt.Errorf("failed to get user groups for %s: %v", username, err) - } - - isAdminUser, err := isAdmin(userGroups) - if err != nil { - return fmt.Errorf("failed to check if user %s is admin: %v", username, err) - } - if isAdminUser { - return fmt.Errorf("cannot disable admin user %s", username) - } - - // Set userAccountControl to 546 (NORMAL_ACCOUNT + ACCOUNTDISABLE) to disable the account - modifyReq := ldapv3.NewModifyRequest(userDN, nil) - modifyReq.Replace("userAccountControl", []string{"546"}) - - err = s.client.Modify(modifyReq) - if err != nil { - return fmt.Errorf("failed to disable account for user %s: %v", username, err) - } - - return nil -} - -func (s *LDAPService) SetUserGroups(username string, groups []string) error { - // TODO: Optimize the get userDN since it also gets in the remove and add user - - // Get user DN - userDN, err := s.GetUserDN(username) - if err != nil { - return fmt.Errorf("failed to find user %s: %v", username, err) - } - - // Get current groups - currentGroups, err := s.GetUserGroups(userDN) - if err != nil { - return fmt.Errorf("failed to get user groups: %v", err) - } - - // Convert slices to maps for efficient lookup - currentGroupsMap := make(map[string]bool) - for _, group := range currentGroups { - currentGroupsMap[group] = true - } - - newGroupsMap := make(map[string]bool) - for _, group := range groups { - newGroupsMap[group] = true - } - - // Find groups to remove (in current but not in new) - for _, group := range currentGroups { - if !newGroupsMap[group] { - if err := s.RemoveUserFromGroup(username, group); err != nil { - return fmt.Errorf("failed to remove user %s from group %s: %v", username, group, err) - } - } - } - - // Find groups to add (in new but not in current) - for _, group := range groups { - if !currentGroupsMap[group] { - if err := s.AddUserToGroup(username, group); err != nil { - return fmt.Errorf("failed to add user %s to group %s: %v", username, group, err) - } - } - } - - return nil -} diff --git a/internal/cloning/cloning.go b/internal/cloning/cloning_service.go similarity index 75% rename from internal/cloning/cloning.go rename to internal/cloning/cloning_service.go index 5af38d6..cd6ba6e 100644 --- a/internal/cloning/cloning.go +++ b/internal/cloning/cloning_service.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/cpp-cyber/proclone/internal/auth" + "github.com/cpp-cyber/proclone/internal/ldap" "github.com/cpp-cyber/proclone/internal/proxmox" "github.com/kelseyhightower/envconfig" ) @@ -21,7 +21,6 @@ func LoadCloningConfig() (*Config, error) { return &config, nil } -// NewTemplateClient creates a new template client func NewTemplateClient(db *sql.DB) *TemplateClient { return &TemplateClient{ DB: db, @@ -31,18 +30,15 @@ func NewTemplateClient(db *sql.DB) *TemplateClient { } } -// NewDatabaseService creates a new database service func NewDatabaseService(db *sql.DB) DatabaseService { return NewTemplateClient(db) } -// GetTemplateConfig returns the template configuration func (c *TemplateClient) GetTemplateConfig() *TemplateConfig { return c.TemplateConfig } -// NewTemplateClient creates a new template client -func NewCloningManager(proxmoxService proxmox.Service, db *sql.DB, ldapService auth.Service) (*CloningManager, error) { +func NewCloningService(proxmoxService proxmox.Service, db *sql.DB, ldapService ldap.Service) (*CloningService, error) { config, err := LoadCloningConfig() if err != nil { return nil, fmt.Errorf("failed to load cloning configuration: %w", err) @@ -52,7 +48,7 @@ func NewCloningManager(proxmoxService proxmox.Service, db *sql.DB, ldapService a return nil, fmt.Errorf("incomplete cloning configuration") } - return &CloningManager{ + return &CloningService{ ProxmoxService: proxmoxService, DatabaseService: NewDatabaseService(db), LDAPService: ldapService, @@ -60,12 +56,11 @@ func NewCloningManager(proxmoxService proxmox.Service, db *sql.DB, ldapService a }, nil } -// CloneTemplate clones a template pool for a user or group -func (cm *CloningManager) CloneTemplate(template string, targetName string, isGroup bool) error { +func (cs *CloningService) CloneTemplate(template string, targetName string, isGroup bool) error { var errors []string // 1. Get the template pool and its VMs - templatePool, err := cm.ProxmoxService.GetPoolVMs(template) + templatePool, err := cs.ProxmoxService.GetPoolVMs(template) if err != nil { return fmt.Errorf("failed to get template pool: %w", err) } @@ -76,7 +71,7 @@ func (cm *CloningManager) CloneTemplate(template string, targetName string, isGr templateName := strings.TrimPrefix(template, "kamino_template_") targetPoolName := fmt.Sprintf("%s_%s", templateName, targetName) - isDeployed, err := cm.IsDeployed(targetPoolName) + isDeployed, err := cs.IsDeployed(targetPoolName) if err != nil { return fmt.Errorf("failed to check if template is deployed: %w", err) } @@ -113,37 +108,36 @@ func (cm *CloningManager) CloneTemplate(template string, targetName string, isGr } // 5. Get the next available pod ID and create new pool - newPodID, newPodNumber, err := cm.ProxmoxService.GetNextPodID(cm.Config.MinPodID, cm.Config.MaxPodID) + newPodID, newPodNumber, err := cs.ProxmoxService.GetNextPodID(cs.Config.MinPodID, cs.Config.MaxPodID) if err != nil { return fmt.Errorf("failed to get next pod ID: %w", err) } newPoolName := fmt.Sprintf("%s_%s_%s", newPodID, templateName, targetName) - err = cm.ProxmoxService.CreateNewPool(newPoolName) + err = cs.ProxmoxService.CreateNewPool(newPoolName) if err != nil { return fmt.Errorf("failed to create new pool: %w", err) } // 6. Clone the router and all VMs - // If no router was found in the template, use the default router template if router == nil { router = &proxmox.VM{ - Name: cm.Config.RouterName, - Node: cm.Config.RouterNode, - VMID: cm.Config.RouterVMID, + Name: cs.Config.RouterName, + Node: cs.Config.RouterNode, + VMID: cs.Config.RouterVMID, } } - newRouter, err := cm.ProxmoxService.CloneVM(*router, newPoolName) + newRouter, err := cs.ProxmoxService.CloneVM(*router, newPoolName) if err != nil { errors = append(errors, fmt.Sprintf("failed to clone router VM: %v", err)) } // Clone each VM to new pool for _, vm := range templateVMs { - _, err := cm.ProxmoxService.CloneVM(vm, newPoolName) + _, err := cs.ProxmoxService.CloneVM(vm, newPoolName) if err != nil { errors = append(errors, fmt.Sprintf("failed to clone VM %s: %v", vm.Name, err)) } @@ -152,29 +146,29 @@ func (cm *CloningManager) CloneTemplate(template string, targetName string, isGr var vnetName = fmt.Sprintf("kamino%d", newPodNumber) // 7. Configure VNet of all VMs - err = cm.SetPodVnet(newPoolName, vnetName) + err = cs.SetPodVnet(newPoolName, vnetName) if err != nil { errors = append(errors, fmt.Sprintf("failed to update pod vnet: %v", err)) } // 8. Turn on Router if newRouter != nil { - err = cm.ProxmoxService.WaitForDisk(newRouter.Node, newRouter.VMID, cm.Config.RouterWaitTimeout) + err = cs.ProxmoxService.WaitForDisk(newRouter.Node, newRouter.VMID, cs.Config.RouterWaitTimeout) if err != nil { errors = append(errors, fmt.Sprintf("router disk unavailable: %v", err)) } - err = cm.ProxmoxService.StartVM(newRouter.Node, newRouter.VMID) + err = cs.ProxmoxService.StartVM(newRouter.Node, newRouter.VMID) if err != nil { errors = append(errors, fmt.Sprintf("failed to start router VM: %v", err)) } // 9. Wait for router to be running - err = cm.ProxmoxService.WaitForRunning(*newRouter) + err = cs.ProxmoxService.WaitForRunning(*newRouter) if err != nil { errors = append(errors, fmt.Sprintf("failed to start router VM: %v", err)) } else { - err = cm.configurePodRouter(newPodNumber, newRouter.Node, newRouter.VMID) + err = cs.configurePodRouter(newPodNumber, newRouter.Node, newRouter.VMID) if err != nil { errors = append(errors, fmt.Sprintf("failed to configure pod router: %v", err)) } @@ -182,13 +176,13 @@ func (cm *CloningManager) CloneTemplate(template string, targetName string, isGr } // 10. Set permissions on the pool to the user/group - err = cm.ProxmoxService.SetPoolPermission(newPoolName, targetName, isGroup) + err = cs.ProxmoxService.SetPoolPermission(newPoolName, targetName, isGroup) if err != nil { errors = append(errors, fmt.Sprintf("failed to update pool permissions for %s: %v", targetName, err)) } // 11. Add a +1 to the total deployments in the templates database - err = cm.DatabaseService.AddDeployment(templateName) + err = cs.DatabaseService.AddDeployment(templateName) if err != nil { errors = append(errors, fmt.Sprintf("failed to increment template deployments for %s: %v", templateName, err)) } @@ -197,12 +191,12 @@ func (cm *CloningManager) CloneTemplate(template string, targetName string, isGr if len(errors) > 0 { // Check if any VMs were successfully cloned - clonedVMs, checkErr := cm.ProxmoxService.GetPoolVMs(newPoolName) + clonedVMs, checkErr := cs.ProxmoxService.GetPoolVMs(newPoolName) if checkErr != nil { } if len(clonedVMs) == 0 { - deleteErr := cm.ProxmoxService.DeletePool(newPoolName) + deleteErr := cs.ProxmoxService.DeletePool(newPoolName) if deleteErr != nil { } } @@ -213,17 +207,16 @@ func (cm *CloningManager) CloneTemplate(template string, targetName string, isGr return nil } -// DeletePod deletes a pod pool for a user or group -func (cm *CloningManager) DeletePod(pod string) error { +func (cs *CloningService) DeletePod(pod string) error { // 1. Check if pool is already empty - isEmpty, err := cm.ProxmoxService.IsPoolEmpty(pod) + isEmpty, err := cs.ProxmoxService.IsPoolEmpty(pod) if err != nil { return fmt.Errorf("failed to check if pool %s is empty: %w", pod, err) } if isEmpty { - err := cm.ProxmoxService.DeletePool(pod) + err := cs.ProxmoxService.DeletePool(pod) if err != nil { } else { } @@ -231,7 +224,7 @@ func (cm *CloningManager) DeletePod(pod string) error { } // 2. Get all virtual machines in the pool - poolVMs, err := cm.ProxmoxService.GetPoolVMs(pod) + poolVMs, err := cs.ProxmoxService.GetPoolVMs(pod) if err != nil { return fmt.Errorf("failed to get pool VMs for %s: %w", pod, err) } @@ -244,7 +237,7 @@ func (cm *CloningManager) DeletePod(pod string) error { if vm.Type == "qemu" { // Only stop if VM is running if vm.RunningStatus == "running" { - err := cm.ProxmoxService.StopVM(vm.NodeName, vm.VmId) + err := cs.ProxmoxService.StopVM(vm.NodeName, vm.VmId) if err != nil { return fmt.Errorf("failed to stop VM %s: %w", vm.Name, err) } @@ -263,7 +256,7 @@ func (cm *CloningManager) DeletePod(pod string) error { // Wait for all previously running VMs to be stopped for _, vm := range runningVMs { - err := cm.ProxmoxService.WaitForStopped(vm) + err := cs.ProxmoxService.WaitForStopped(vm) if err != nil { // Continue with deletion even if we can't confirm the VM is stopped } else { @@ -278,7 +271,7 @@ func (cm *CloningManager) DeletePod(pod string) error { for _, vm := range poolVMs { if vm.Type == "qemu" { - err := cm.ProxmoxService.DeleteVM(vm.NodeName, vm.VmId) + err := cs.ProxmoxService.DeleteVM(vm.NodeName, vm.VmId) if err != nil { return fmt.Errorf("failed to delete VM %s: %w", vm.Name, err) } @@ -287,14 +280,14 @@ func (cm *CloningManager) DeletePod(pod string) error { } // 5. Wait for all VMs to be deleted and pool to become empty - err = cm.ProxmoxService.WaitForPoolEmpty(pod, 5*time.Minute) + err = cs.ProxmoxService.WaitForPoolEmpty(pod, 5*time.Minute) if err != nil { // Continue with pool deletion even if we can't confirm all VMs are gone } else { } // 6. Delete the pool - err = cm.ProxmoxService.DeletePool(pod) + err = cs.ProxmoxService.DeletePool(pod) if err != nil { return fmt.Errorf("failed to delete pool %s: %w", pod, err) } diff --git a/internal/cloning/networking.go b/internal/cloning/networking.go index 3eb127f..4c2eb32 100644 --- a/internal/cloning/networking.go +++ b/internal/cloning/networking.go @@ -11,7 +11,7 @@ import ( ) // configurePodRouter configures the pod router with proper networking settings -func (cm *CloningManager) configurePodRouter(podNumber int, node string, vmid int) error { +func (cs *CloningService) configurePodRouter(podNumber int, node string, vmid int) error { // Wait for router agent to be pingable statusReq := tools.ProxmoxAPIRequest{ Method: "POST", @@ -28,7 +28,7 @@ func (cm *CloningManager) configurePodRouter(podNumber int, node string, vmid in return fmt.Errorf("router qemu agent timed out") } - _, err := cm.ProxmoxService.GetRequestHelper().MakeRequest(statusReq) + _, err := cs.ProxmoxService.GetRequestHelper().MakeRequest(statusReq) if err == nil { break // Agent is responding } @@ -41,8 +41,8 @@ func (cm *CloningManager) configurePodRouter(podNumber int, node string, vmid in // Configure router WAN IP to have correct third octet using qemu agent API call reqBody := map[string]any{ "command": []string{ - cm.Config.WANScriptPath, - fmt.Sprintf("%s%d.1", cm.Config.WANIPBase, podNumber), + cs.Config.WANScriptPath, + fmt.Sprintf("%s%d.1", cs.Config.WANIPBase, podNumber), }, } @@ -52,7 +52,7 @@ func (cm *CloningManager) configurePodRouter(podNumber int, node string, vmid in RequestBody: reqBody, } - _, err := cm.ProxmoxService.GetRequestHelper().MakeRequest(execReq) + _, err := cs.ProxmoxService.GetRequestHelper().MakeRequest(execReq) if err != nil { return fmt.Errorf("failed to make IP change request: %v", err) } @@ -60,8 +60,8 @@ func (cm *CloningManager) configurePodRouter(podNumber int, node string, vmid in // Send agent exec request to change VIP subnet vipReqBody := map[string]any{ "command": []string{ - cm.Config.VIPScriptPath, - fmt.Sprintf("%s%d.0", cm.Config.WANIPBase, podNumber), + cs.Config.VIPScriptPath, + fmt.Sprintf("%s%d.0", cs.Config.WANIPBase, podNumber), }, } @@ -71,7 +71,7 @@ func (cm *CloningManager) configurePodRouter(podNumber int, node string, vmid in RequestBody: vipReqBody, } - _, err = cm.ProxmoxService.GetRequestHelper().MakeRequest(vipExecReq) + _, err = cs.ProxmoxService.GetRequestHelper().MakeRequest(vipExecReq) if err != nil { return fmt.Errorf("failed to make VIP change request: %v", err) } @@ -80,10 +80,9 @@ func (cm *CloningManager) configurePodRouter(podNumber int, node string, vmid in return nil } -// SetPodVnet configures the VNet for all VMs in a pod -func (cm *CloningManager) SetPodVnet(poolName string, vnetName string) error { +func (cs *CloningService) SetPodVnet(poolName string, vnetName string) error { // Get all VMs in the pool - vms, err := cm.ProxmoxService.GetPoolVMs(poolName) + vms, err := cs.ProxmoxService.GetPoolVMs(poolName) if err != nil { return fmt.Errorf("failed to get pool VMs: %w", err) } @@ -109,7 +108,7 @@ func (cm *CloningManager) SetPodVnet(poolName string, vnetName string) error { RequestBody: reqBody, } - _, err := cm.ProxmoxService.GetRequestHelper().MakeRequest(req) + _, err := cs.ProxmoxService.GetRequestHelper().MakeRequest(req) if err != nil { return fmt.Errorf("failed to update network for VM %d: %w", vm.VmId, err) } diff --git a/internal/cloning/pods.go b/internal/cloning/pods.go index 2e66461..e97b1b7 100644 --- a/internal/cloning/pods.go +++ b/internal/cloning/pods.go @@ -8,15 +8,15 @@ import ( "github.com/cpp-cyber/proclone/internal/proxmox" ) -func (cm *CloningManager) GetPods(username string) ([]Pod, error) { +func (cs *CloningService) GetPods(username string) ([]Pod, error) { // Get User DN - userDN, err := cm.LDAPService.GetUserDN(username) + userDN, err := cs.LDAPService.GetUserDN(username) if err != nil { return nil, fmt.Errorf("failed to get user DN: %w", err) } // Get user's groups - groups, err := cm.LDAPService.GetUserGroups(userDN) + groups, err := cs.LDAPService.GetUserGroups(userDN) if err != nil { return nil, fmt.Errorf("failed to get user groups: %w", err) } @@ -25,24 +25,24 @@ func (cm *CloningManager) GetPods(username string) ([]Pod, error) { regexPattern := fmt.Sprintf(`1[0-9]{3}_.*_(%s|%s)`, username, strings.Join(groups, "|")) // Get pods based on regex pattern - pods, err := cm.MapVirtualResourcesToPods(regexPattern) + pods, err := cs.MapVirtualResourcesToPods(regexPattern) if err != nil { return nil, err } return pods, nil } -func (cm *CloningManager) GetAllPods() ([]Pod, error) { - pods, err := cm.MapVirtualResourcesToPods(`1[0-9]{3}_.*`) +func (cs *CloningService) AdminGetPods() ([]Pod, error) { + pods, err := cs.MapVirtualResourcesToPods(`1[0-9]{3}_.*`) if err != nil { return nil, err } return pods, nil } -func (cm *CloningManager) MapVirtualResourcesToPods(regex string) ([]Pod, error) { +func (cs *CloningService) MapVirtualResourcesToPods(regex string) ([]Pod, error) { // Get cluster resources - resources, err := cm.ProxmoxService.GetClusterResources("") + resources, err := cs.ProxmoxService.GetClusterResources("") if err != nil { return nil, err } @@ -75,8 +75,8 @@ func (cm *CloningManager) MapVirtualResourcesToPods(regex string) ([]Pod, error) return pods, nil } -func (cm *CloningManager) IsDeployed(templateName string) (bool, error) { - podPools, err := cm.GetAllPods() +func (cs *CloningService) IsDeployed(templateName string) (bool, error) { + podPools, err := cs.AdminGetPods() if err != nil { return false, fmt.Errorf("failed to get pod pools: %w", err) } diff --git a/internal/cloning/templates.go b/internal/cloning/templates.go index 93dbca6..492f587 100644 --- a/internal/cloning/templates.go +++ b/internal/cloning/templates.go @@ -15,51 +15,9 @@ import ( "github.com/google/uuid" ) -// Utils - -// buildTemplates builds template structs from SQL rows -func (c *TemplateClient) buildTemplates(rows *sql.Rows) ([]KaminoTemplate, error) { - templates := []KaminoTemplate{} - - for rows.Next() { - var template KaminoTemplate - err := rows.Scan( - &template.Name, - &template.Description, - &template.ImagePath, - &template.TemplateVisible, - &template.PodVisible, - &template.VMsVisible, - &template.VMCount, - &template.Deployments, - &template.CreatedAt, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan row: %w", err) - } - templates = append(templates, template) - } - - return templates, nil -} - -// Use a map to define allowed MIME types for better performance -// and to avoid using a switch statement -var allowedMIMEs = map[string]struct{}{ - "image/jpeg": {}, - "image/png": {}, -} - -// detectMIME reads a small buffer to determine the file's MIME type -func detectMIME(f multipart.File) (string, error) { - buffer := make([]byte, 512) - if _, err := f.Read(buffer); err != nil && err != io.EOF { - return "", err - } - return http.DetectContentType(buffer), nil -} - -// Database Operations +// ================================================= +// Template Database Operations +// ================================================= func (c *TemplateClient) GetTemplates() ([]KaminoTemplate, error) { query := "SELECT * FROM templates WHERE template_visible = true ORDER BY created_at DESC" @@ -194,15 +152,15 @@ func (c *TemplateClient) GetTemplateInfo(templateName string) (KaminoTemplate, e return template, nil } -func (cm *CloningManager) GetUnpublishedTemplates() ([]string, error) { +func (cs *CloningService) GetUnpublishedTemplates() ([]string, error) { // Gets published templates from the database - publishedTemplates, err := cm.DatabaseService.GetPublishedTemplates() + publishedTemplates, err := cs.DatabaseService.GetPublishedTemplates() if err != nil { return nil, fmt.Errorf("failed to get unpublished templates: %w", err) } // Gets pools that start with "kamino_template_" in Proxmox - proxmoxTemplate, err := cm.ProxmoxService.GetTemplatePools() + proxmoxTemplate, err := cs.ProxmoxService.GetTemplatePools() if err != nil { return nil, fmt.Errorf("failed to get Proxmox templates: %w", err) } @@ -227,14 +185,14 @@ func (cm *CloningManager) GetUnpublishedTemplates() ([]string, error) { return unpublished, nil } -func (cm *CloningManager) PublishTemplate(template KaminoTemplate) error { +func (cs *CloningService) PublishTemplate(template KaminoTemplate) error { // Insert template information into database - if err := cm.DatabaseService.InsertTemplate(template); err != nil { + if err := cs.DatabaseService.InsertTemplate(template); err != nil { return fmt.Errorf("failed to publish template: %w", err) } // Get all VMs in pool - vms, err := cm.ProxmoxService.GetPoolVMs("kamino_template_" + template.Name) + vms, err := cs.ProxmoxService.GetPoolVMs("kamino_template_" + template.Name) if err != nil { log.Printf("Error retrieving VMs in pool: %v", err) return fmt.Errorf("failed to get VMs in pool: %w", err) @@ -242,7 +200,7 @@ func (cm *CloningManager) PublishTemplate(template KaminoTemplate) error { // Convert all VMs to templates for _, vm := range vms { - if err := cm.ProxmoxService.ConvertVMToTemplate(vm.NodeName, vm.VmId); err != nil { + if err := cs.ProxmoxService.ConvertVMToTemplate(vm.NodeName, vm.VmId); err != nil { log.Printf("Error converting VM %d to template: %v", vm.VmId, err) return fmt.Errorf("failed to convert VM to template: %w", err) } @@ -251,7 +209,9 @@ func (cm *CloningManager) PublishTemplate(template KaminoTemplate) error { return nil } +// ================================================= // Template Image Operations +// ================================================= func (cl *TemplateClient) UploadTemplateImage(c *gin.Context) (*UploadResult, error) { // Check header for multipart/form-data @@ -321,3 +281,41 @@ func (c *TemplateClient) DeleteImage(imagePath string) error { } return nil } + +// ================================================= +// Private Functions +// ================================================= + +func (c *TemplateClient) buildTemplates(rows *sql.Rows) ([]KaminoTemplate, error) { + templates := []KaminoTemplate{} + + for rows.Next() { + var template KaminoTemplate + err := rows.Scan( + &template.Name, + &template.Description, + &template.ImagePath, + &template.TemplateVisible, + &template.PodVisible, + &template.VMsVisible, + &template.VMCount, + &template.Deployments, + &template.CreatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan row: %w", err) + } + templates = append(templates, template) + } + + return templates, nil +} + +// detectMIME reads a small buffer to determine the file's MIME type +func detectMIME(f multipart.File) (string, error) { + buffer := make([]byte, 512) + if _, err := f.Read(buffer); err != nil && err != io.EOF { + return "", err + } + return http.DetectContentType(buffer), nil +} diff --git a/internal/cloning/types.go b/internal/cloning/types.go index e813b41..923f5ad 100644 --- a/internal/cloning/types.go +++ b/internal/cloning/types.go @@ -4,7 +4,7 @@ import ( "database/sql" "time" - "github.com/cpp-cyber/proclone/internal/auth" + "github.com/cpp-cyber/proclone/internal/ldap" "github.com/cpp-cyber/proclone/internal/proxmox" "github.com/gin-gonic/gin" ) @@ -72,12 +72,12 @@ type TemplateClient struct { TemplateConfig *TemplateConfig } -// CloningManager combines Proxmox service and templates database functionality +// CloningService combines Proxmox service and templates database functionality // for handling VM cloning operations -type CloningManager struct { +type CloningService struct { ProxmoxService proxmox.Service DatabaseService DatabaseService - LDAPService auth.Service + LDAPService ldap.Service Config *Config } @@ -92,3 +92,8 @@ type Pod struct { VMs []proxmox.VirtualResource `json:"vms"` Template KaminoTemplate `json:"template,omitempty"` } + +var allowedMIMEs = map[string]struct{}{ + "image/jpeg": {}, + "image/png": {}, +} diff --git a/internal/ldap/groups.go b/internal/ldap/groups.go new file mode 100644 index 0000000..95ab624 --- /dev/null +++ b/internal/ldap/groups.go @@ -0,0 +1,338 @@ +package ldap + +import ( + "fmt" + "regexp" + "strings" + "time" + + ldapv3 "github.com/go-ldap/ldap/v3" +) + +// ================================================= +// Public Functions +// ================================================= + +func (s *LDAPService) GetGroups() ([]Group, error) { + // Search for all groups in the KaminoGroups OU + kaminoGroupsOU := "OU=KaminoGroups," + s.client.config.BaseDN + req := ldapv3.NewSearchRequest( + kaminoGroupsOU, + ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, + "(objectClass=group)", + []string{"cn", "whenCreated", "member"}, + nil, + ) + + searchResult, err := s.client.Search(req) + if err != nil { + return nil, fmt.Errorf("failed to search for groups: %v", err) + } + + var groups []Group + for _, entry := range searchResult.Entries { + cn := entry.GetAttributeValue("cn") + + // Check if the group is protected + protectedGroup, err := isProtectedGroup(cn) + if err != nil { + return nil, fmt.Errorf("failed to determine if the group %s is protected: %v", cn, err) + } + + group := Group{ + Name: cn, + CanModify: !protectedGroup, + UserCount: len(entry.GetAttributeValues("member")), + } + + // Add creation date if available and convert it + whenCreated := entry.GetAttributeValue("whenCreated") + if whenCreated != "" { + // AD stores dates in GeneralizedTime format: YYYYMMDDHHMMSS.0Z + if parsedTime, err := time.Parse("20060102150405.0Z", whenCreated); err == nil { + group.CreatedAt = parsedTime.Format("2006-01-02 15:04:05") + } + } + + groups = append(groups, group) + } + + return groups, nil +} + +func (s *LDAPService) CreateGroup(groupName string) error { + // Validate group name + if err := validateGroupName(groupName); err != nil { + return fmt.Errorf("invalid group name: %v", err) + } + + // Check if group already exists + _, err := s.getGroupDN(groupName) + if err == nil { + return fmt.Errorf("group already exists: %s", groupName) + } + + // Construct the DN for the new group + groupDN := fmt.Sprintf("CN=%s,OU=KaminoGroups,%s", groupName, s.client.config.BaseDN) + + // Create the add request + addReq := ldapv3.NewAddRequest(groupDN, nil) + addReq.Attribute("objectClass", []string{"top", "group"}) + addReq.Attribute("cn", []string{groupName}) + addReq.Attribute("sAMAccountName", []string{groupName}) + addReq.Attribute("groupType", []string{"-2147483646"}) + + // Execute the add request + err = s.client.Add(addReq) + if err != nil { + return fmt.Errorf("failed to create group: %v", err) + } + + return nil +} + +func (s *LDAPService) RenameGroup(oldGroupName string, newGroupName string) error { + // Validate new group name + if err := validateGroupName(newGroupName); err != nil { + return fmt.Errorf("invalid new group name: %v", err) + } + + // Check if old group exists + oldGroupDN, err := s.getGroupDN(oldGroupName) + if err != nil { + return fmt.Errorf("old group not found: %v", err) + } + + // Check if new group already exists + _, err = s.getGroupDN(newGroupName) + if err == nil { + return fmt.Errorf("new group name already exists: %s", newGroupName) + } + + // Create modify DN request + newRDN := fmt.Sprintf("CN=%s", newGroupName) + modifyDNReq := ldapv3.NewModifyDNRequest(oldGroupDN, newRDN, true, "") + + // Execute the modify DN request + err = s.client.ModifyDN(modifyDNReq) + if err != nil { + return fmt.Errorf("failed to rename group: %v", err) + } + + return nil +} + +func (s *LDAPService) DeleteGroup(groupName string) error { + // Check if group is protected + protected, err := isProtectedGroup(groupName) + if err != nil { + return fmt.Errorf("failed to check if group is protected: %v", err) + } + if protected { + return fmt.Errorf("cannot delete protected group: %s", groupName) + } + + // Get group DN + groupDN, err := s.getGroupDN(groupName) + if err != nil { + return fmt.Errorf("group not found: %v", err) + } + + // Create delete request + delReq := ldapv3.NewDelRequest(groupDN, nil) + + // Execute the delete request + err = s.client.Del(delReq) + if err != nil { + return fmt.Errorf("failed to delete group: %v", err) + } + + return nil +} + +func (s *LDAPService) GetGroupMembers(groupName string) ([]User, error) { + groupDN, err := s.getGroupDN(groupName) + if err != nil { + return nil, fmt.Errorf("group not found: %v", err) + } + + // Search for the group and get its members + req := ldapv3.NewSearchRequest( + groupDN, + ldapv3.ScopeBaseObject, ldapv3.NeverDerefAliases, 0, 0, false, + "(objectClass=group)", + []string{"member"}, + nil, + ) + + searchResult, err := s.client.Search(req) + if err != nil { + return nil, fmt.Errorf("failed to search for group: %v", err) + } + + if len(searchResult.Entries) == 0 { + return []User{}, nil + } + + memberDNs := searchResult.Entries[0].GetAttributeValues("member") + var users []User + + for _, memberDN := range memberDNs { + // Get user details from DN + userReq := ldapv3.NewSearchRequest( + memberDN, + ldapv3.ScopeBaseObject, ldapv3.NeverDerefAliases, 0, 0, false, + "(objectClass=user)", + []string{"sAMAccountName", "cn", "whenCreated", "userAccountControl"}, + nil, + ) + + userResult, err := s.client.Search(userReq) + if err != nil { + continue // Skip this user if there's an error + } + + if len(userResult.Entries) > 0 { + entry := userResult.Entries[0] + user := User{ + Name: entry.GetAttributeValue("sAMAccountName"), + CreatedAt: entry.GetAttributeValue("whenCreated"), + Enabled: true, // Default, will be updated based on userAccountControl + } + + // Check if user is enabled + userAccountControl := entry.GetAttributeValue("userAccountControl") + if userAccountControl != "" { + // Parse userAccountControl to determine if account is enabled + // UF_ACCOUNTDISABLE = 0x02 + if strings.Contains(userAccountControl, "2") { + user.Enabled = false + } + } + + users = append(users, user) + } + } + + return users, nil +} + +func (s *LDAPService) AddUsersToGroup(groupName string, usernames []string) error { + groupDN, err := s.getGroupDN(groupName) + if err != nil { + return fmt.Errorf("group not found: %v", err) + } + + var userDNs []string + for _, username := range usernames { + userDN, err := s.GetUserDN(username) + if err != nil { + return fmt.Errorf("user %s not found: %v", username, err) + } + userDNs = append(userDNs, userDN) + } + + // Add all users to the group + modifyReq := ldapv3.NewModifyRequest(groupDN, nil) + modifyReq.Add("member", userDNs) + + err = s.client.Modify(modifyReq) + if err != nil { + return fmt.Errorf("failed to add users to group: %v", err) + } + + return nil +} + +func (s *LDAPService) RemoveUsersFromGroup(groupName string, usernames []string) error { + groupDN, err := s.getGroupDN(groupName) + if err != nil { + return fmt.Errorf("group not found: %v", err) + } + + var userDNs []string + for _, username := range usernames { + userDN, err := s.GetUserDN(username) + if err != nil { + return fmt.Errorf("user %s not found: %v", username, err) + } + userDNs = append(userDNs, userDN) + } + + // Remove all users from the group + modifyReq := ldapv3.NewModifyRequest(groupDN, nil) + modifyReq.Delete("member", userDNs) + + err = s.client.Modify(modifyReq) + if err != nil { + return fmt.Errorf("failed to remove users from group: %v", err) + } + + return nil +} + +// ================================================= +// Private Functions +// ================================================= + +func (s *LDAPService) getGroupDN(groupName string) (string, error) { + kaminoGroupsOU := "OU=KaminoGroups," + s.client.config.BaseDN + req := ldapv3.NewSearchRequest( + kaminoGroupsOU, + ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 1, 30, false, + fmt.Sprintf("(&(objectClass=group)(cn=%s))", ldapv3.EscapeFilter(groupName)), + []string{"dn"}, + nil, + ) + + searchResult, err := s.client.Search(req) + if err != nil { + return "", fmt.Errorf("failed to search for group: %v", err) + } + + if len(searchResult.Entries) == 0 { + return "", fmt.Errorf("group %s not found", groupName) + } + + return searchResult.Entries[0].DN, nil +} + +func validateGroupName(groupName string) error { + if groupName == "" { + return fmt.Errorf("group name cannot be empty") + } + + if len(groupName) >= 64 { + return fmt.Errorf("group name must be less than 64 characters") + } + + regex := regexp.MustCompile("^[a-zA-Z0-9-_]*$") + if !regex.MatchString(groupName) { + return fmt.Errorf("group name must only contain letters, numbers, hyphens, and underscores") + } + + return nil +} + +func isProtectedGroup(groupName string) (bool, error) { + protectedGroups := []string{ + "Domain Admins", + "Domain Users", + "Domain Guests", + "Schema Admins", + "Enterprise Admins", + "Administrators", + "Users", + "Guests", + "Proxmox-Admins", + "KaminoUsers", + } + + for _, protectedGroup := range protectedGroups { + if strings.EqualFold(groupName, protectedGroup) { + return true, nil + } + } + + return false, nil +} diff --git a/internal/ldap/ldap_client.go b/internal/ldap/ldap_client.go new file mode 100644 index 0000000..c540f90 --- /dev/null +++ b/internal/ldap/ldap_client.go @@ -0,0 +1,208 @@ +package ldap + +import ( + "crypto/tls" + "fmt" + "strings" + + "github.com/go-ldap/ldap/v3" + "github.com/kelseyhightower/envconfig" +) + +func NewClient(config *Config) *Client { + return &Client{config: config} +} + +func LoadConfig() (*Config, error) { + var config Config + if err := envconfig.Process("", &config); err != nil { + return nil, fmt.Errorf("failed to process LDAP configuration: %w", err) + } + return &config, nil +} + +func (c *Client) Connect() error { + c.mutex.Lock() + defer c.mutex.Unlock() + + conn, err := c.dial() + if err != nil { + c.connected = false + return fmt.Errorf("failed to connect to LDAP server: %v", err) + } + + if c.config.BindUser != "" { + err = conn.Bind(c.config.BindUser, c.config.BindPassword) + if err != nil { + conn.Close() + c.connected = false + return fmt.Errorf("failed to bind to LDAP server: %v", err) + } + } else { + } + + c.conn = conn + c.connected = true + return nil +} + +func (c *Client) dial() (ldap.Client, error) { + if strings.HasPrefix(c.config.URL, "ldaps://") { + return ldap.DialURL(c.config.URL, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: c.config.SkipTLSVerify})) + } else { + return nil, fmt.Errorf("unsupported LDAP URL scheme: %s", c.config.URL) + } +} + +func (c *Client) Disconnect() error { + c.mutex.Lock() + defer c.mutex.Unlock() + + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.connected = false + return nil +} + +func (c *Client) HealthCheck() error { + c.mutex.RLock() + defer c.mutex.RUnlock() + + if !c.connected || c.conn == nil { + return fmt.Errorf("LDAP connection is not established") + } + + // Try a simple search to verify the connection + searchRequest := ldap.NewSearchRequest( + c.config.BaseDN, + ldap.ScopeBaseObject, + ldap.NeverDerefAliases, + 1, + 1, + false, + "(objectClass=*)", + []string{"objectClass"}, + nil, + ) + + _, err := c.conn.Search(searchRequest) + if err != nil { + return fmt.Errorf("LDAP health check failed: %v", err) + } + + return nil +} + +func (c *Client) IsConnected() bool { + c.mutex.RLock() + defer c.mutex.RUnlock() + return c.connected +} + +func (c *Client) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) { + c.mutex.RLock() + defer c.mutex.RUnlock() + + if !c.connected || c.conn == nil { + return nil, fmt.Errorf("LDAP connection is not established") + } + + return c.conn.Search(searchRequest) +} + +func (c *Client) Add(addRequest *ldap.AddRequest) error { + c.mutex.RLock() + defer c.mutex.RUnlock() + + if !c.connected || c.conn == nil { + return fmt.Errorf("LDAP connection is not established") + } + + return c.conn.Add(addRequest) +} + +func (c *Client) Modify(modifyRequest *ldap.ModifyRequest) error { + c.mutex.RLock() + defer c.mutex.RUnlock() + + if !c.connected || c.conn == nil { + return fmt.Errorf("LDAP connection is not established") + } + + return c.conn.Modify(modifyRequest) +} + +func (c *Client) Del(delRequest *ldap.DelRequest) error { + c.mutex.RLock() + defer c.mutex.RUnlock() + + if !c.connected || c.conn == nil { + return fmt.Errorf("LDAP connection is not established") + } + + return c.conn.Del(delRequest) +} + +func (c *Client) ModifyDN(modifyDNRequest *ldap.ModifyDNRequest) error { + c.mutex.RLock() + defer c.mutex.RUnlock() + + if !c.connected || c.conn == nil { + return fmt.Errorf("LDAP connection is not established") + } + + return c.conn.ModifyDN(modifyDNRequest) +} + +func (c *Client) Bind(username, password string) error { + c.mutex.RLock() + defer c.mutex.RUnlock() + + if !c.connected || c.conn == nil { + return fmt.Errorf("LDAP connection is not established") + } + + return c.conn.Bind(username, password) +} + +func (c *Client) SimpleBind(username, password string) error { + c.mutex.RLock() + defer c.mutex.RUnlock() + + if !c.connected || c.conn == nil { + return fmt.Errorf("LDAP connection is not established") + } + + return c.conn.Bind(username, password) +} + +func (s *LDAPService) GetUserDN(username string) (string, error) { + if username == "" { + return "", fmt.Errorf("username cannot be empty") + } + + searchRequest := ldap.NewSearchRequest( + s.client.config.BaseDN, + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, + 1, + 30, + false, + fmt.Sprintf("(&(objectClass=inetOrgPerson)(uid=%s))", ldap.EscapeFilter(username)), + []string{"dn"}, + nil, + ) + + searchResult, err := s.client.Search(searchRequest) + if err != nil { + return "", fmt.Errorf("failed to search for user: %v", err) + } + + if len(searchResult.Entries) == 0 { + return "", fmt.Errorf("user %s not found", username) + } + + return searchResult.Entries[0].DN, nil +} diff --git a/internal/ldap/ldap_service.go b/internal/ldap/ldap_service.go new file mode 100644 index 0000000..b3bae99 --- /dev/null +++ b/internal/ldap/ldap_service.go @@ -0,0 +1,44 @@ +package ldap + +import "fmt" + +func NewLDAPService() (*LDAPService, error) { + config, err := LoadConfig() + if err != nil { + return nil, fmt.Errorf("failed to load LDAP configuration: %w", err) + } + + client := NewClient(config) + if err := client.Connect(); err != nil { + return nil, fmt.Errorf("failed to connect to LDAP: %w", err) + } + + return &LDAPService{ + client: client, + }, nil +} + +func (s *LDAPService) Close() error { + err := s.client.Disconnect() + if err != nil { + return err + } + return nil +} + +func (s *LDAPService) HealthCheck() error { + err := s.client.HealthCheck() + if err != nil { + return err + } + + return nil +} + +func (s *LDAPService) Reconnect() error { + err := s.client.Connect() + if err != nil { + return err + } + return nil +} diff --git a/internal/ldap/types.go b/internal/ldap/types.go new file mode 100644 index 0000000..d21558c --- /dev/null +++ b/internal/ldap/types.go @@ -0,0 +1,95 @@ +package ldap + +import ( + "sync" + + "github.com/go-ldap/ldap/v3" +) + +// ================================================= +// LDAP Service Interface +// ================================================= + +type Service interface { + // User Management + GetUsers() ([]User, error) + CreateAndRegisterUser(userInfo UserRegistrationInfo) error + DeleteUser(username string) error + AddUserToGroup(username string, groupName string) error + SetUserGroups(username string, groups []string) error + EnableUserAccount(username string) error + DisableUserAccount(username string) error + GetUserGroups(userDN string) ([]string, error) + GetUserDN(username string) (string, error) + + // Group Management + CreateGroup(groupName string) error + GetGroups() ([]Group, error) + RenameGroup(oldGroupName string, newGroupName string) error + DeleteGroup(groupName string) error + GetGroupMembers(groupName string) ([]User, error) + RemoveUserFromGroup(username string, groupName string) error + AddUsersToGroup(groupName string, usernames []string) error + RemoveUsersFromGroup(groupName string, usernames []string) error + + // Connection Management + HealthCheck() error + Reconnect() error + Close() error +} + +type LDAPService struct { + client *Client +} + +// ================================================= +// LDAP Client +// ================================================= + +type Config struct { + URL string `envconfig:"LDAP_URL" default:"ldaps://localhost:636"` + BindUser string `envconfig:"LDAP_BIND_USER"` + BindPassword string `envconfig:"LDAP_BIND_PASSWORD"` + SkipTLSVerify bool `envconfig:"LDAP_SKIP_TLS_VERIFY" default:"false"` + AdminGroupDN string `envconfig:"LDAP_ADMIN_GROUP_DN"` + BaseDN string `envconfig:"LDAP_BASE_DN"` +} + +type Client struct { + conn ldap.Client + config *Config + mutex sync.RWMutex + connected bool +} + +// ================================================= +// Groups +// ================================================= + +type CreateRequest struct { + Group string `json:"group"` +} + +type Group struct { + Name string `json:"name"` + CanModify bool `json:"can_modify"` + CreatedAt string `json:"created_at,omitempty"` + UserCount int `json:"user_count,omitempty"` +} + +// ================================================= +// Users +// ================================================= + +type User struct { + Name string `json:"name"` + CreatedAt string `json:"created_at"` + Enabled bool `json:"enabled"` + IsAdmin bool `json:"is_admin"` + Groups []Group `json:"groups"` +} + +type UserRegistrationInfo struct { + Username string `json:"username" validate:"required,min=1,max=20"` + Password string `json:"password" validate:"required,min=8"` +} diff --git a/internal/ldap/users.go b/internal/ldap/users.go new file mode 100644 index 0000000..c5fe319 --- /dev/null +++ b/internal/ldap/users.go @@ -0,0 +1,384 @@ +package ldap + +import ( + "encoding/binary" + "fmt" + "regexp" + "strconv" + "strings" + "unicode/utf16" + + ldapv3 "github.com/go-ldap/ldap/v3" +) + +// ================================================= +// Public Functions +// ================================================= + +func (s *LDAPService) GetUsers() ([]User, error) { + searchRequest := ldapv3.NewSearchRequest( + s.client.config.BaseDN, + ldapv3.ScopeWholeSubtree, + ldapv3.NeverDerefAliases, + 0, + 0, + false, + "(objectClass=inetOrgPerson)", + []string{"uid", "createTimestamp", "userAccountControl", "memberOf", "cn"}, + nil, + ) + + searchResult, err := s.client.Search(searchRequest) + if err != nil { + return nil, fmt.Errorf("failed to search for users: %v", err) + } + + var users []User + for _, entry := range searchResult.Entries { + user := User{ + Name: entry.GetAttributeValue("uid"), + CreatedAt: entry.GetAttributeValue("createTimestamp"), + Enabled: true, // Default enabled, will be updated based on userAccountControl + } + + // Check if user is enabled + userAccountControl := entry.GetAttributeValue("userAccountControl") + if userAccountControl != "" { + uac, err := strconv.Atoi(userAccountControl) + if err == nil { + // UF_ACCOUNTDISABLE = 0x02 + user.Enabled = (uac & 0x02) == 0 + } + } + + // Check if user is admin + memberOfValues := entry.GetAttributeValues("memberOf") + for _, memberOf := range memberOfValues { + if strings.Contains(memberOf, s.client.config.AdminGroupDN) { + user.IsAdmin = true + break + } + } + + // Get user groups + groups, err := getUserGroupsFromMemberOf(memberOfValues) + if err == nil { + user.Groups = groups + } + + users = append(users, user) + } + + return users, nil +} + +func (s *LDAPService) CreateUser(userInfo UserRegistrationInfo) (string, error) { + // Create DN for new user in Users container + // TODO: Static + userDN := fmt.Sprintf("CN=%s,OU=KaminoUsers,%s", userInfo.Username, s.client.config.BaseDN) + + // Create add request for new user + addReq := ldapv3.NewAddRequest(userDN, nil) + + // Add required object classes + addReq.Attribute("objectClass", []string{"top", "person", "organizationalPerson", "user"}) + + // Add basic attributes + addReq.Attribute("cn", []string{userInfo.Username}) + addReq.Attribute("sAMAccountName", []string{userInfo.Username}) + addReq.Attribute("userPrincipalName", []string{fmt.Sprintf("%s@%s", userInfo.Username, extractDomainFromDN(s.client.config.BaseDN))}) + + // Set account control flags - account disabled initially (will be enabled after password is set) + addReq.Attribute("userAccountControl", []string{"546"}) // NORMAL_ACCOUNT + ACCOUNTDISABLE + + // Perform the add operation + err := s.client.Add(addReq) + if err != nil { + return "", fmt.Errorf("failed to create user: %v", err) + } + + return userDN, nil +} + +func (s *LDAPService) SetUserPassword(userDN string, password string) error { + // For Active Directory, passwords must be set using unicodePwd attribute + // The password must be UTF-16LE encoded and quoted + utf16Password := encodePasswordForAD(password) + + // Create modify request to set password + modifyReq := ldapv3.NewModifyRequest(userDN, nil) + modifyReq.Replace("unicodePwd", []string{utf16Password}) + + err := s.client.Modify(modifyReq) + if err != nil { + return fmt.Errorf("failed to set password: %v", err) + } + + return nil +} + +func (s *LDAPService) EnableUserAccountByDN(userDN string) error { + modifyRequest := ldapv3.NewModifyRequest(userDN, nil) + modifyRequest.Replace("userAccountControl", []string{"512"}) // Normal account + + err := s.client.Modify(modifyRequest) + if err != nil { + return fmt.Errorf("failed to enable user account: %v", err) + } + + return nil +} + +// DisableUserAccountByDN disables a user account by DN +func (s *LDAPService) DisableUserAccountByDN(userDN string) error { + modifyRequest := ldapv3.NewModifyRequest(userDN, nil) + modifyRequest.Replace("userAccountControl", []string{"514"}) // Disabled account + + err := s.client.Modify(modifyRequest) + if err != nil { + return fmt.Errorf("failed to disable user account: %v", err) + } + + return nil +} + +func (s *LDAPService) AddToGroup(userDN string, groupDN string) error { + modifyRequest := ldapv3.NewModifyRequest(groupDN, nil) + modifyRequest.Add("member", []string{userDN}) + + err := s.client.Modify(modifyRequest) + if err != nil { + return fmt.Errorf("failed to add user to group: %v", err) + } + + return nil +} + +func (s *LDAPService) CreateAndRegisterUser(userInfo UserRegistrationInfo) error { + // Validate username + if !isValidUsername(userInfo.Username) { + return fmt.Errorf("invalid username: must be alphanumeric and 1-20 characters long") + } + + // Validate password strength + if len(userInfo.Password) < 8 || len(userInfo.Password) > 128 { + return fmt.Errorf("password must be between 8 and 128 characters long") + } + + userDN, err := s.CreateUser(userInfo) + if err != nil { + return fmt.Errorf("failed to create user: %v", err) + } + + // Set password + err = s.SetUserPassword(userDN, userInfo.Password) + if err != nil { + // Clean up created user if password setting fails + delRequest := ldapv3.NewDelRequest(userDN, nil) + s.client.Del(delRequest) + return fmt.Errorf("failed to set user password: %v", err) + } + + // Enable account + err = s.EnableUserAccountByDN(userDN) + if err != nil { + return fmt.Errorf("failed to enable user account: %v", err) + } + + return nil +} + +func (s *LDAPService) AddUserToGroup(username string, groupName string) error { + userDN, err := s.GetUserDN(username) + if err != nil { + return fmt.Errorf("failed to get user DN: %v", err) + } + + groupDN := fmt.Sprintf("cn=%s,%s", groupName, s.client.config.BaseDN) + + return s.AddToGroup(userDN, groupDN) +} + +func (s *LDAPService) RemoveUserFromGroup(username string, groupName string) error { + userDN, err := s.GetUserDN(username) + if err != nil { + return fmt.Errorf("failed to get user DN: %v", err) + } + + groupDN := fmt.Sprintf("cn=%s,%s", groupName, s.client.config.BaseDN) + + modifyRequest := ldapv3.NewModifyRequest(groupDN, nil) + modifyRequest.Delete("member", []string{userDN}) + + err = s.client.Modify(modifyRequest) + if err != nil { + return fmt.Errorf("failed to remove user from group: %v", err) + } + + return nil +} + +func (s *LDAPService) DeleteUser(username string) error { + userDN, err := s.GetUserDN(username) + if err != nil { + return fmt.Errorf("failed to get user DN: %v", err) + } + + delRequest := ldapv3.NewDelRequest(userDN, nil) + err = s.client.Del(delRequest) + if err != nil { + return fmt.Errorf("failed to delete user: %v", err) + } + + return nil +} + +func (s *LDAPService) DeleteUsers(usernames []string) []error { + var errors []error + for _, username := range usernames { + err := s.DeleteUser(username) + if err != nil { + errors = append(errors, fmt.Errorf("failed to delete user %s: %v", username, err)) + } + } + return errors +} + +func (s *LDAPService) GetUserGroups(userDN string) ([]string, error) { + searchRequest := ldapv3.NewSearchRequest( + userDN, + ldapv3.ScopeBaseObject, + ldapv3.NeverDerefAliases, + 1, + 30, + false, + "(objectClass=*)", + []string{"memberOf"}, + nil, + ) + + searchResult, err := s.client.Search(searchRequest) + if err != nil { + return nil, fmt.Errorf("failed to search for user groups: %v", err) + } + + if len(searchResult.Entries) == 0 { + return []string{}, nil + } + + memberOfValues := searchResult.Entries[0].GetAttributeValues("memberOf") + var groups []string + for _, memberOf := range memberOfValues { + // Extract CN from DN + parts := strings.Split(memberOf, ",") + if len(parts) > 0 && strings.HasPrefix(parts[0], "CN=") { + groupName := strings.TrimPrefix(parts[0], "CN=") + groups = append(groups, groupName) + } + } + + return groups, nil +} + +func (s *LDAPService) EnableUserAccount(username string) error { + userDN, err := s.GetUserDN(username) + if err != nil { + return fmt.Errorf("failed to get user DN: %v", err) + } + + return s.EnableUserAccountByDN(userDN) +} + +func (s *LDAPService) DisableUserAccount(username string) error { + userDN, err := s.GetUserDN(username) + if err != nil { + return fmt.Errorf("failed to get user DN: %v", err) + } + + return s.DisableUserAccountByDN(userDN) +} + +func (s *LDAPService) SetUserGroups(username string, groups []string) error { + userDN, err := s.GetUserDN(username) + if err != nil { + return fmt.Errorf("failed to get user DN: %v", err) + } + + // Get current groups + currentGroups, err := s.GetUserGroups(userDN) + if err != nil { + return fmt.Errorf("failed to get current user groups: %v", err) + } + + // Remove from current groups + for _, group := range currentGroups { + err = s.RemoveUserFromGroup(username, group) + if err != nil { + return fmt.Errorf("failed to remove user from group %s: %v", group, err) + } + } + + // Add to new groups + for _, group := range groups { + err = s.AddUserToGroup(username, group) + if err != nil { + return fmt.Errorf("failed to add user to group %s: %v", group, err) + } + } + + return nil +} + +// ================================================= +// Private Functions +// ================================================= + +func getUserGroupsFromMemberOf(memberOfValues []string) ([]Group, error) { + var groups []Group + for _, memberOf := range memberOfValues { + // Extract CN from DN + parts := strings.Split(memberOf, ",") + if len(parts) > 0 && strings.HasPrefix(parts[0], "CN=") { + groupName := strings.TrimPrefix(parts[0], "CN=") + groups = append(groups, Group{Name: groupName}) + } + } + return groups, nil +} + +func isValidUsername(username string) bool { + if len(username) < 1 || len(username) > 20 { + return false + } + matched, _ := regexp.MatchString("^[a-zA-Z0-9]+$", username) + return matched +} + +func extractDomainFromDN(dn string) string { + // Convert DN like "DC=example,DC=com" to "example.com" + parts := strings.Split(strings.ToLower(dn), ",") + var domainParts []string + + for _, part := range parts { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "dc=") { + domainParts = append(domainParts, strings.TrimPrefix(part, "dc=")) + } + } + + return strings.Join(domainParts, ".") +} + +func encodePasswordForAD(password string) string { + // AD requires password to be UTF-16LE encoded and surrounded by quotes + quotedPassword := fmt.Sprintf("\"%s\"", password) + utf16Encoded := utf16.Encode([]rune(quotedPassword)) + + // Convert to bytes in little-endian format + bytes := make([]byte, len(utf16Encoded)*2) + for i, r := range utf16Encoded { + binary.LittleEndian.PutUint16(bytes[i*2:], r) + } + + return string(bytes) +} diff --git a/internal/proxmox/cluster.go b/internal/proxmox/cluster.go index 520294d..9de7983 100644 --- a/internal/proxmox/cluster.go +++ b/internal/proxmox/cluster.go @@ -12,14 +12,14 @@ import ( // ================================================= // GetNodeStatus retrieves detailed status for a specific node -func (c *Client) GetNodeStatus(nodeName string) (*ProxmoxNodeStatus, error) { +func (s *ProxmoxService) GetNodeStatus(nodeName string) (*ProxmoxNodeStatus, error) { req := tools.ProxmoxAPIRequest{ Method: "GET", Endpoint: fmt.Sprintf("/nodes/%s/status", nodeName), } var nodeStatus ProxmoxNodeStatus - if err := c.RequestHelper.MakeRequestAndUnmarshal(req, &nodeStatus); err != nil { + if err := s.RequestHelper.MakeRequestAndUnmarshal(req, &nodeStatus); err != nil { return nil, fmt.Errorf("failed to get node status for %s: %w", nodeName, err) } @@ -27,14 +27,14 @@ func (c *Client) GetNodeStatus(nodeName string) (*ProxmoxNodeStatus, error) { } // GetClusterResources retrieves all cluster resources from the Proxmox cluster -func (c *Client) GetClusterResources(getParams string) ([]VirtualResource, error) { +func (s *ProxmoxService) GetClusterResources(getParams string) ([]VirtualResource, error) { req := tools.ProxmoxAPIRequest{ Method: "GET", Endpoint: fmt.Sprintf("/cluster/resources?%s", getParams), } var resources []VirtualResource - if err := c.RequestHelper.MakeRequestAndUnmarshal(req, &resources); err != nil { + if err := s.RequestHelper.MakeRequestAndUnmarshal(req, &resources); err != nil { return nil, fmt.Errorf("failed to get cluster resources: %w", err) } @@ -42,14 +42,14 @@ func (c *Client) GetClusterResources(getParams string) ([]VirtualResource, error } // GetClusterResourceUsage retrieves resource usage for the Proxmox cluster -func (c *Client) GetClusterResourceUsage() (*ClusterResourceUsageResponse, error) { - resources, err := c.GetClusterResources("") +func (s *ProxmoxService) GetClusterResourceUsage() (*ClusterResourceUsageResponse, error) { + resources, err := s.GetClusterResources("") if err != nil { return nil, fmt.Errorf("failed to get cluster resources: %w", err) } - nodes, errors := c.collectNodeResourceUsage(resources) - cluster := c.aggregateClusterResourceUsage(nodes, resources) + nodes, errors := s.collectNodeResourceUsage(resources) + cluster := s.aggregateClusterResourceUsage(nodes, resources) response := &ClusterResourceUsageResponse{ Nodes: nodes, @@ -66,7 +66,7 @@ func (c *Client) GetClusterResourceUsage() (*ClusterResourceUsageResponse, error } // FindBestNode finds the node with the most available resources -func (c *Client) FindBestNode() (string, error) { +func (s *ProxmoxService) FindBestNode() (string, error) { req := tools.ProxmoxAPIRequest{ Method: "GET", Endpoint: "/nodes", @@ -81,7 +81,7 @@ func (c *Client) FindBestNode() (string, error) { MaxMem int64 `json:"maxmem"` } - if err := c.RequestHelper.MakeRequestAndUnmarshal(req, &nodesResponse); err != nil { + if err := s.RequestHelper.MakeRequestAndUnmarshal(req, &nodesResponse); err != nil { return "", fmt.Errorf("failed to get nodes: %w", err) } @@ -109,12 +109,12 @@ func (c *Client) FindBestNode() (string, error) { return bestNode, nil } -func (c *Client) SyncUsers() error { - return c.syncRealm("users") +func (s *ProxmoxService) SyncUsers() error { + return s.syncRealm("users") } -func (c *Client) SyncGroups() error { - return c.syncRealm("groups") +func (s *ProxmoxService) SyncGroups() error { + return s.syncRealm("groups") } // ================================================= @@ -122,12 +122,12 @@ func (c *Client) SyncGroups() error { // ================================================= // collectNodeResourceUsage gathers resource usage data for all configured nodes -func (c *Client) collectNodeResourceUsage(resources []VirtualResource) ([]NodeResourceUsage, []string) { +func (s *ProxmoxService) collectNodeResourceUsage(resources []VirtualResource) ([]NodeResourceUsage, []string) { var nodes []NodeResourceUsage var errors []string - for _, nodeName := range c.Config.Nodes { - nodeUsage, err := c.getNodeResourceUsage(nodeName, resources) + for _, nodeName := range s.Config.Nodes { + nodeUsage, err := s.getNodeResourceUsage(nodeName, resources) if err != nil { errorMsg := fmt.Sprintf("Error fetching status for node %s: %v", nodeName, err) log.Printf("%s", errorMsg) @@ -141,8 +141,8 @@ func (c *Client) collectNodeResourceUsage(resources []VirtualResource) ([]NodeRe } // getNodeResourceUsage retrieves resource usage for a single node -func (c *Client) getNodeResourceUsage(nodeName string, resources []VirtualResource) (NodeResourceUsage, error) { - status, err := c.GetNodeStatus(nodeName) +func (s *ProxmoxService) getNodeResourceUsage(nodeName string, resources []VirtualResource) (NodeResourceUsage, error) { + status, err := s.GetNodeStatus(nodeName) if err != nil { return NodeResourceUsage{}, fmt.Errorf("failed to get node status: %w", err) } @@ -162,7 +162,7 @@ func (c *Client) getNodeResourceUsage(nodeName string, resources []VirtualResour } // aggregateClusterResourceUsage calculates cluster-wide resource totals and averages -func (c *Client) aggregateClusterResourceUsage(nodes []NodeResourceUsage, resources []VirtualResource) ResourceUsage { +func (s *ProxmoxService) aggregateClusterResourceUsage(nodes []NodeResourceUsage, resources []VirtualResource) ResourceUsage { cluster := ResourceUsage{} // Aggregate node resources @@ -218,17 +218,17 @@ func getStorage(resources *[]VirtualResource, storage string) (Used int64, Total return used, total } -func (c *Client) syncRealm(scope string) error { +func (s *ProxmoxService) syncRealm(scope string) error { req := tools.ProxmoxAPIRequest{ Method: "POST", - Endpoint: fmt.Sprintf("/access/domains/%s/sync", c.Config.Realm), + Endpoint: fmt.Sprintf("/access/domains/%s/sync", s.Config.Realm), RequestBody: map[string]string{ "scope": scope, // Either "users" or "groups" "remove-vanished": "acl;properties;entry", // Delete any users/groups that no longer exist in AD }, } - _, err := c.RequestHelper.MakeRequest(req) + _, err := s.RequestHelper.MakeRequest(req) if err != nil { return fmt.Errorf("failed to sync realm: %w", err) } diff --git a/internal/proxmox/pools.go b/internal/proxmox/pools.go index 6a3d7f2..0322eee 100644 --- a/internal/proxmox/pools.go +++ b/internal/proxmox/pools.go @@ -13,7 +13,7 @@ import ( "github.com/cpp-cyber/proclone/internal/tools" ) -func (c *Client) GetPoolVMs(poolName string) ([]VirtualResource, error) { +func (s *ProxmoxService) GetPoolVMs(poolName string) ([]VirtualResource, error) { req := tools.ProxmoxAPIRequest{ Method: "GET", Endpoint: fmt.Sprintf("/pools/%s", poolName), @@ -22,7 +22,7 @@ func (c *Client) GetPoolVMs(poolName string) ([]VirtualResource, error) { var poolResponse struct { Members []VirtualResource `json:"members"` } - if err := c.RequestHelper.MakeRequestAndUnmarshal(req, &poolResponse); err != nil { + if err := s.RequestHelper.MakeRequestAndUnmarshal(req, &poolResponse); err != nil { return nil, fmt.Errorf("failed to get pool VMs: %w", err) } @@ -37,7 +37,7 @@ func (c *Client) GetPoolVMs(poolName string) ([]VirtualResource, error) { return vms, nil } -func (c *Client) CreateNewPool(poolName string) error { +func (s *ProxmoxService) CreateNewPool(poolName string) error { reqBody := map[string]string{ "poolid": poolName, } @@ -48,7 +48,7 @@ func (c *Client) CreateNewPool(poolName string) error { RequestBody: reqBody, } - _, err := c.RequestHelper.MakeRequest(req) + _, err := s.RequestHelper.MakeRequest(req) if err != nil { return fmt.Errorf("failed to create pool %s: %w", poolName, err) } @@ -56,7 +56,7 @@ func (c *Client) CreateNewPool(poolName string) error { return nil } -func (c *Client) SetPoolPermission(poolName string, targetName string, isGroup bool) error { +func (s *ProxmoxService) SetPoolPermission(poolName string, targetName string, isGroup bool) error { reqBody := map[string]any{ "path": fmt.Sprintf("/pool/%s", poolName), "roles": "PVEVMUser,PVEPoolUser", @@ -64,9 +64,9 @@ func (c *Client) SetPoolPermission(poolName string, targetName string, isGroup b } if isGroup { - reqBody["groups"] = fmt.Sprintf("%s-%s", targetName, c.Config.Realm) + reqBody["groups"] = fmt.Sprintf("%s-%s", targetName, s.Config.Realm) } else { - reqBody["users"] = fmt.Sprintf("%s@%s", targetName, c.Config.Realm) + reqBody["users"] = fmt.Sprintf("%s@%s", targetName, s.Config.Realm) } req := tools.ProxmoxAPIRequest{ @@ -75,7 +75,7 @@ func (c *Client) SetPoolPermission(poolName string, targetName string, isGroup b RequestBody: reqBody, } - _, err := c.RequestHelper.MakeRequest(req) + _, err := s.RequestHelper.MakeRequest(req) if err != nil { return fmt.Errorf("failed to set pool permissions: %w", err) } @@ -83,13 +83,13 @@ func (c *Client) SetPoolPermission(poolName string, targetName string, isGroup b return nil } -func (c *Client) DeletePool(poolName string) error { +func (s *ProxmoxService) DeletePool(poolName string) error { req := tools.ProxmoxAPIRequest{ Method: "DELETE", Endpoint: fmt.Sprintf("/pools/%s", poolName), } - _, err := c.RequestHelper.MakeRequest(req) + _, err := s.RequestHelper.MakeRequest(req) if err != nil { return fmt.Errorf("failed to delete pool %s: %w", poolName, err) } @@ -98,7 +98,7 @@ func (c *Client) DeletePool(poolName string) error { return nil } -func (c *Client) GetTemplatePools() ([]string, error) { +func (s *ProxmoxService) GetTemplatePools() ([]string, error) { req := tools.ProxmoxAPIRequest{ Method: "GET", Endpoint: "/pools", @@ -107,7 +107,7 @@ func (c *Client) GetTemplatePools() ([]string, error) { var poolResponse []struct { Name string `json:"poolid"` } - if err := c.RequestHelper.MakeRequestAndUnmarshal(req, &poolResponse); err != nil { + if err := s.RequestHelper.MakeRequestAndUnmarshal(req, &poolResponse); err != nil { return nil, fmt.Errorf("failed to get template pools: %w", err) } @@ -121,8 +121,8 @@ func (c *Client) GetTemplatePools() ([]string, error) { return templatePools, nil } -func (c *Client) IsPoolEmpty(poolName string) (bool, error) { - poolVMs, err := c.GetPoolVMs(poolName) +func (s *ProxmoxService) IsPoolEmpty(poolName string) (bool, error) { + poolVMs, err := s.GetPoolVMs(poolName) if err != nil { return false, fmt.Errorf("failed to check if pool %s is empty: %w", poolName, err) } @@ -138,14 +138,13 @@ func (c *Client) IsPoolEmpty(poolName string) (bool, error) { return vmCount == 0, nil } -// WaitForPoolEmpty waits for a pool to become empty with exponential backoff -func (c *Client) WaitForPoolEmpty(poolName string, timeout time.Duration) error { +func (s *ProxmoxService) WaitForPoolEmpty(poolName string, timeout time.Duration) error { start := time.Now() backoff := 2 * time.Second maxBackoff := 30 * time.Second for time.Since(start) < timeout { - poolVMs, err := c.GetPoolVMs(poolName) + poolVMs, err := s.GetPoolVMs(poolName) if err != nil { // If we can't get pool VMs, pool might be deleted or empty log.Printf("Error checking pool %s (might be deleted): %v", poolName, err) @@ -165,8 +164,7 @@ func (c *Client) WaitForPoolEmpty(poolName string, timeout time.Duration) error return fmt.Errorf("timeout waiting for pool %s to become empty after %v", poolName, timeout) } -// GetNextPodID finds the next available pod ID between MIN_POD_ID and MAX_POD_ID -func (c *Client) GetNextPodID(minPodID int, maxPodID int) (string, int, error) { +func (s *ProxmoxService) GetNextPodID(minPodID int, maxPodID int) (string, int, error) { // Get all existing pools req := tools.ProxmoxAPIRequest{ Method: "GET", @@ -176,7 +174,7 @@ func (c *Client) GetNextPodID(minPodID int, maxPodID int) (string, int, error) { var poolsResponse []struct { PoolID string `json:"poolid"` } - if err := c.RequestHelper.MakeRequestAndUnmarshal(req, &poolsResponse); err != nil { + if err := s.RequestHelper.MakeRequestAndUnmarshal(req, &poolsResponse); err != nil { return "", 0, fmt.Errorf("failed to get existing pools: %w", err) } diff --git a/internal/proxmox/proxmox.go b/internal/proxmox/proxmox.go deleted file mode 100644 index ddbe9fa..0000000 --- a/internal/proxmox/proxmox.go +++ /dev/null @@ -1,156 +0,0 @@ -package proxmox - -import ( - "crypto/tls" - "fmt" - "net/http" - "strings" - "time" - - "github.com/cpp-cyber/proclone/internal/tools" - "github.com/kelseyhightower/envconfig" -) - -// ProxmoxConfig holds the configuration for Proxmox API -type ProxmoxConfig struct { - Host string `envconfig:"PROXMOX_HOST" required:"true"` - Port string `envconfig:"PROXMOX_PORT" default:"8006"` - TokenID string `envconfig:"PROXMOX_TOKEN_ID" required:"true"` - TokenSecret string `envconfig:"PROXMOX_TOKEN_SECRET" required:"true"` - VerifySSL bool `envconfig:"PROXMOX_VERIFY_SSL" default:"false"` - CriticalPool string `envconfig:"PROXMOX_CRITICAL_POOL"` - Realm string `envconfig:"REALM"` - NodesStr string `envconfig:"PROXMOX_NODES"` - Nodes []string // Parsed from NodesStr - APIToken string // Computed from TokenID and TokenSecret -} - -// Service interface defines the methods for Proxmox operations -type Service interface { - // Cluster and Resource Management - GetClusterResourceUsage() (*ClusterResourceUsageResponse, error) - GetClusterResources(getParams string) ([]VirtualResource, error) - GetNodeStatus(nodeName string) (*ProxmoxNodeStatus, error) - FindBestNode() (string, error) - SyncUsers() error - SyncGroups() error - - // Pod Management - GetNextPodID(minPodID int, maxPodID int) (string, int, error) - - // VM Management - GetVMs() ([]VirtualResource, error) - StartVM(node string, vmID int) error - ShutdownVM(node string, vmID int) error - RebootVM(node string, vmID int) error - StopVM(node string, vmID int) error - DeleteVM(node string, vmID int) error - ConvertVMToTemplate(node string, vmID int) error - CloneVM(sourceVM VM, newPoolName string) (*VM, error) - WaitForCloneCompletion(vm *VM, timeout time.Duration) error - WaitForDisk(node string, vmid int, maxWait time.Duration) error - WaitForRunning(vm VM) error - WaitForStopped(vm VM) error - - // Pool Management - GetPoolVMs(poolName string) ([]VirtualResource, error) - CreateNewPool(poolName string) error - SetPoolPermission(poolName string, targetName string, isGroup bool) error - DeletePool(poolName string) error - IsPoolEmpty(poolName string) (bool, error) - WaitForPoolEmpty(poolName string, timeout time.Duration) error - - // Template Management - GetTemplatePools() ([]string, error) - - // Internal access for router functionality - GetRequestHelper() *tools.ProxmoxRequestHelper -} - -// Client implements the Service interface for Proxmox operations -type Client struct { - Config *ProxmoxConfig - HTTPClient *http.Client - BaseURL string - RequestHelper *tools.ProxmoxRequestHelper -} - -// ProxmoxNode represents a Proxmox node -type ProxmoxNode struct { - Node string `json:"node"` - Status string `json:"status"` -} - -// ProxmoxNodeStatus represents the status response from a Proxmox node -type ProxmoxNodeStatus struct { - CPU float64 `json:"cpu"` - Memory struct { - Total int64 `json:"total"` - Used int64 `json:"used"` - } `json:"memory"` - Uptime int64 `json:"uptime"` -} - -// NewClient creates a new Proxmox client with the given configuration -func NewClient(config ProxmoxConfig) *Client { - // Create HTTP client with appropriate TLS settings - transport := &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: !config.VerifySSL, - }, - } - - client := &http.Client{ - Transport: transport, - Timeout: 30 * time.Second, - } - - baseURL := fmt.Sprintf("https://%s:%s/api2/json", config.Host, config.Port) - - // Initialize the request helper - requestHelper := tools.NewProxmoxRequestHelper(baseURL, config.APIToken, client) - - return &Client{ - Config: &config, - HTTPClient: client, - BaseURL: baseURL, - RequestHelper: requestHelper, - } -} - -// NewService creates a new Proxmox service, loading configuration internally -func NewService() (Service, error) { - config, err := LoadProxmoxConfig() - if err != nil { - return nil, fmt.Errorf("failed to load Proxmox configuration: %w", err) - } - - return NewClient(*config), nil -} - -// GetRequestHelper returns the Proxmox request helper for internal use -func (c *Client) GetRequestHelper() *tools.ProxmoxRequestHelper { - return c.RequestHelper -} - -// LoadProxmoxConfig loads and validates Proxmox configuration from environment variables -func LoadProxmoxConfig() (*ProxmoxConfig, error) { - var config ProxmoxConfig - if err := envconfig.Process("", &config); err != nil { - return nil, fmt.Errorf("failed to process Proxmox configuration: %w", err) - } - - // Build API token from ID and secret - config.APIToken = fmt.Sprintf("%s=%s", config.TokenID, config.TokenSecret) - - // Parse nodes list if provided - if config.NodesStr != "" { - config.Nodes = strings.Split(config.NodesStr, ",") - // Trim whitespace from each node - for i, node := range config.Nodes { - config.Nodes[i] = strings.TrimSpace(node) - } - } - - return &config, nil -} diff --git a/internal/proxmox/proxmox_service.go b/internal/proxmox/proxmox_service.go new file mode 100644 index 0000000..b78fa3c --- /dev/null +++ b/internal/proxmox/proxmox_service.go @@ -0,0 +1,73 @@ +package proxmox + +import ( + "crypto/tls" + "fmt" + "net/http" + "strings" + "time" + + "github.com/cpp-cyber/proclone/internal/tools" + "github.com/kelseyhightower/envconfig" +) + +// NewProxmoxService creates a new Proxmox service with the given configuration +func NewProxmoxService(config ProxmoxConfig) *ProxmoxService { + // Create HTTP client with appropriate TLS settings + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: !config.VerifySSL, + }, + } + + client := &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + } + + baseURL := fmt.Sprintf("https://%s:%s/api2/json", config.Host, config.Port) + + // Initialize the request helper + requestHelper := tools.NewProxmoxRequestHelper(baseURL, config.APIToken, client) + + return &ProxmoxService{ + Config: &config, + HTTPClient: client, + BaseURL: baseURL, + RequestHelper: requestHelper, + } +} + +func NewService() (Service, error) { + config, err := LoadProxmoxConfig() + if err != nil { + return nil, fmt.Errorf("failed to load Proxmox configuration: %w", err) + } + + return NewProxmoxService(*config), nil +} + +func (s *ProxmoxService) GetRequestHelper() *tools.ProxmoxRequestHelper { + return s.RequestHelper +} + +func LoadProxmoxConfig() (*ProxmoxConfig, error) { + var config ProxmoxConfig + if err := envconfig.Process("", &config); err != nil { + return nil, fmt.Errorf("failed to process Proxmox configuration: %w", err) + } + + // Build API token from ID and secret + config.APIToken = fmt.Sprintf("%s=%s", config.TokenID, config.TokenSecret) + + // Parse nodes list if provided + if config.NodesStr != "" { + config.Nodes = strings.Split(config.NodesStr, ",") + // Trim whitespace from each node + for i, node := range config.Nodes { + config.Nodes[i] = strings.TrimSpace(node) + } + } + + return &config, nil +} diff --git a/internal/proxmox/types.go b/internal/proxmox/types.go index 6f08f52..9d65e06 100644 --- a/internal/proxmox/types.go +++ b/internal/proxmox/types.go @@ -1,6 +1,90 @@ package proxmox -// VirtualResourceConfig represents VM configuration response +import ( + "net/http" + "time" + + "github.com/cpp-cyber/proclone/internal/tools" +) + +// ProxmoxConfig holds the configuration for Proxmox API +type ProxmoxConfig struct { + Host string `envconfig:"PROXMOX_HOST" required:"true"` + Port string `envconfig:"PROXMOX_PORT" default:"8006"` + TokenID string `envconfig:"PROXMOX_TOKEN_ID" required:"true"` + TokenSecret string `envconfig:"PROXMOX_TOKEN_SECRET" required:"true"` + VerifySSL bool `envconfig:"PROXMOX_VERIFY_SSL" default:"false"` + CriticalPool string `envconfig:"PROXMOX_CRITICAL_POOL"` + Realm string `envconfig:"REALM"` + NodesStr string `envconfig:"PROXMOX_NODES"` + Nodes []string // Parsed from NodesStr + APIToken string // Computed from TokenID and TokenSecret +} + +// Service interface defines the methods for Proxmox operations +type Service interface { + // Cluster and Resource Management + GetClusterResourceUsage() (*ClusterResourceUsageResponse, error) + GetClusterResources(getParams string) ([]VirtualResource, error) + GetNodeStatus(nodeName string) (*ProxmoxNodeStatus, error) + FindBestNode() (string, error) + SyncUsers() error + SyncGroups() error + + // Pod Management + GetNextPodID(minPodID int, maxPodID int) (string, int, error) + + // VM Management + GetVMs() ([]VirtualResource, error) + StartVM(node string, vmID int) error + ShutdownVM(node string, vmID int) error + RebootVM(node string, vmID int) error + StopVM(node string, vmID int) error + DeleteVM(node string, vmID int) error + ConvertVMToTemplate(node string, vmID int) error + CloneVM(sourceVM VM, newPoolName string) (*VM, error) + WaitForCloneCompletion(vm *VM, timeout time.Duration) error + WaitForDisk(node string, vmid int, maxWait time.Duration) error + WaitForRunning(vm VM) error + WaitForStopped(vm VM) error + + // Pool Management + GetPoolVMs(poolName string) ([]VirtualResource, error) + CreateNewPool(poolName string) error + SetPoolPermission(poolName string, targetName string, isGroup bool) error + DeletePool(poolName string) error + IsPoolEmpty(poolName string) (bool, error) + WaitForPoolEmpty(poolName string, timeout time.Duration) error + + // Template Management + GetTemplatePools() ([]string, error) + + // Internal access for router functionality + GetRequestHelper() *tools.ProxmoxRequestHelper +} + +// ProxmoxService implements the Service interface for Proxmox operations +type ProxmoxService struct { + Config *ProxmoxConfig + HTTPClient *http.Client + BaseURL string + RequestHelper *tools.ProxmoxRequestHelper +} + +type ProxmoxNode struct { + Node string `json:"node"` + Status string `json:"status"` +} + +type ProxmoxNodeStatus struct { + CPU float64 `json:"cpu"` + Memory struct { + Total int64 `json:"total"` + Used int64 `json:"used"` + } `json:"memory"` + Uptime int64 `json:"uptime"` +} + type VirtualResourceConfig struct { HardDisk string `json:"scsi0"` Lock string `json:"lock,omitempty"` @@ -8,17 +92,14 @@ type VirtualResourceConfig struct { Net1 string `json:"net1,omitempty"` } -// VirtualResourceStatus represents the status of a virtual resource type VirtualResourceStatus struct { Status string `json:"status"` } -// VNetResponse represents the VNet API response type VNetResponse []struct { VNet string `json:"vnet"` } -// VM represents a Virtual Machine with node and ID information type VM struct { Name string `json:"name,omitempty"` Node string `json:"node"` @@ -52,13 +133,11 @@ type ResourceUsage struct { StorageTotal int64 `json:"storage_total"` // Total storage in bytes } -// NodeResourceUsage represents the resource usage metrics for a single node type NodeResourceUsage struct { Name string `json:"name"` Resources ResourceUsage `json:"resources"` } -// ResourceUsageResponse represents the API response containing resource usage for all nodes type ClusterResourceUsageResponse struct { Total ResourceUsage `json:"total"` Nodes []NodeResourceUsage `json:"nodes"` diff --git a/internal/proxmox/vms.go b/internal/proxmox/vms.go index 118a7b8..e4c8ee6 100644 --- a/internal/proxmox/vms.go +++ b/internal/proxmox/vms.go @@ -14,32 +14,32 @@ import ( // Public Functions // ================================================= -func (c *Client) GetVMs() ([]VirtualResource, error) { - vms, err := c.GetClusterResources("type=vm") +func (s *ProxmoxService) GetVMs() ([]VirtualResource, error) { + vms, err := s.GetClusterResources("type=vm") if err != nil { return nil, err } return vms, nil } -func (c *Client) StartVM(node string, vmID int) error { - return c.vmAction(node, vmID, "start") +func (s *ProxmoxService) StartVM(node string, vmID int) error { + return s.vmAction("start", node, vmID) } -func (c *Client) StopVM(node string, vmID int) error { - return c.vmAction(node, vmID, "stop") +func (s *ProxmoxService) StopVM(node string, vmID int) error { + return s.vmAction("stop", node, vmID) } -func (c *Client) ShutdownVM(node string, vmID int) error { - return c.vmAction(node, vmID, "shutdown") +func (s *ProxmoxService) ShutdownVM(node string, vmID int) error { + return s.vmAction("shutdown", node, vmID) } -func (c *Client) RebootVM(node string, vmID int) error { - return c.vmAction(node, vmID, "reboot") +func (s *ProxmoxService) RebootVM(node string, vmID int) error { + return s.vmAction("reboot", node, vmID) } -func (c *Client) DeleteVM(node string, vmID int) error { - if err := c.validateVMID(vmID); err != nil { +func (s *ProxmoxService) DeleteVM(node string, vmID int) error { + if err := s.validateVMID(vmID); err != nil { return err } @@ -48,7 +48,7 @@ func (c *Client) DeleteVM(node string, vmID int) error { Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d", node, vmID), } - _, err := c.RequestHelper.MakeRequest(req) + _, err := s.RequestHelper.MakeRequest(req) if err != nil { return fmt.Errorf("failed to delete VM: %w", err) } @@ -56,8 +56,8 @@ func (c *Client) DeleteVM(node string, vmID int) error { return nil } -func (c *Client) ConvertVMToTemplate(node string, vmID int) error { - if err := c.validateVMID(vmID); err != nil { +func (s *ProxmoxService) ConvertVMToTemplate(node string, vmID int) error { + if err := s.validateVMID(vmID); err != nil { return err } @@ -66,7 +66,7 @@ func (c *Client) ConvertVMToTemplate(node string, vmID int) error { Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/template", node, vmID), } - _, err := c.RequestHelper.MakeRequest(req) + _, err := s.RequestHelper.MakeRequest(req) if err != nil { if !strings.Contains(err.Error(), "you can't convert a template to a template") { return fmt.Errorf("failed to convert VM to template: %w", err) @@ -76,7 +76,7 @@ func (c *Client) ConvertVMToTemplate(node string, vmID int) error { return nil } -func (c *Client) CloneVM(sourceVM VM, newPoolName string) (*VM, error) { +func (s *ProxmoxService) CloneVM(sourceVM VM, newPoolName string) (*VM, error) { // Get next available VMID req := tools.ProxmoxAPIRequest{ Method: "GET", @@ -84,7 +84,7 @@ func (c *Client) CloneVM(sourceVM VM, newPoolName string) (*VM, error) { } var nextIDStr string - if err := c.RequestHelper.MakeRequestAndUnmarshal(req, &nextIDStr); err != nil { + if err := s.RequestHelper.MakeRequestAndUnmarshal(req, &nextIDStr); err != nil { return nil, fmt.Errorf("failed to get next VMID: %w", err) } @@ -94,7 +94,7 @@ func (c *Client) CloneVM(sourceVM VM, newPoolName string) (*VM, error) { } // Find best node for cloning - bestNode, err := c.FindBestNode() + bestNode, err := s.FindBestNode() if err != nil { return nil, fmt.Errorf("failed to find best node: %w", err) } @@ -114,7 +114,7 @@ func (c *Client) CloneVM(sourceVM VM, newPoolName string) (*VM, error) { RequestBody: cloneBody, } - _, err = c.RequestHelper.MakeRequest(cloneReq) + _, err = s.RequestHelper.MakeRequest(cloneReq) if err != nil { return nil, fmt.Errorf("failed to initiate VM clone: %w", err) } @@ -125,7 +125,7 @@ func (c *Client) CloneVM(sourceVM VM, newPoolName string) (*VM, error) { VMID: newVMID, } - err = c.WaitForCloneCompletion(newVM, 5*time.Minute) // CLONE_TIMEOUT + err = s.WaitForCloneCompletion(newVM, 5*time.Minute) // CLONE_TIMEOUT if err != nil { return nil, fmt.Errorf("clone operation failed: %w", err) } @@ -133,14 +133,14 @@ func (c *Client) CloneVM(sourceVM VM, newPoolName string) (*VM, error) { return newVM, nil } -func (c *Client) WaitForCloneCompletion(vm *VM, timeout time.Duration) error { +func (s *ProxmoxService) WaitForCloneCompletion(vm *VM, timeout time.Duration) error { start := time.Now() backoff := time.Second maxBackoff := 30 * time.Second for time.Since(start) < timeout { // Check VM status - status, err := c.getVMStatus(vm.Node, vm.VMID) + status, err := s.getVMStatus(vm.Node, vm.VMID) if err != nil { time.Sleep(backoff) backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) @@ -149,7 +149,7 @@ func (c *Client) WaitForCloneCompletion(vm *VM, timeout time.Duration) error { if status == "running" || status == "stopped" { // Check if VM is locked (clone in progress) - configResp, err := c.getVMConfig(vm.Node, vm.VMID) + configResp, err := s.getVMConfig(vm.Node, vm.VMID) if err != nil { time.Sleep(backoff) backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) @@ -168,13 +168,13 @@ func (c *Client) WaitForCloneCompletion(vm *VM, timeout time.Duration) error { return fmt.Errorf("clone operation timed out after %v", timeout) } -func (c *Client) WaitForDisk(node string, vmid int, maxWait time.Duration) error { +func (s *ProxmoxService) WaitForDisk(node string, vmid int, maxWait time.Duration) error { start := time.Now() for time.Since(start) < maxWait { time.Sleep(2 * time.Second) - configResp, err := c.getVMConfig(node, vmid) + configResp, err := s.getVMConfig(node, vmid) if err != nil { continue } @@ -187,20 +187,20 @@ func (c *Client) WaitForDisk(node string, vmid int, maxWait time.Duration) error return fmt.Errorf("timeout waiting for VM disks to become available") } -func (c *Client) WaitForStopped(vm VM) error { - return c.waitForStatus("stopped", vm) +func (s *ProxmoxService) WaitForStopped(vm VM) error { + return s.waitForStatus("stopped", vm) } -func (c *Client) WaitForRunning(vm VM) error { - return c.waitForStatus("running", vm) +func (s *ProxmoxService) WaitForRunning(vm VM) error { + return s.waitForStatus("running", vm) } // ================================================= // Private Functions // ================================================= -func (c *Client) vmAction(node string, vmID int, action string) error { - if err := c.validateVMID(vmID); err != nil { +func (s *ProxmoxService) vmAction(action string, node string, vmID int) error { + if err := s.validateVMID(vmID); err != nil { return err } @@ -209,7 +209,7 @@ func (c *Client) vmAction(node string, vmID int, action string) error { Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/status/%s", node, vmID, action), } - _, err := c.RequestHelper.MakeRequest(req) + _, err := s.RequestHelper.MakeRequest(req) if err != nil { return fmt.Errorf("failed to %s VM: %w", action, err) } @@ -217,12 +217,12 @@ func (c *Client) vmAction(node string, vmID int, action string) error { return nil } -func (c *Client) waitForStatus(targetStatus string, vm VM) error { +func (s *ProxmoxService) waitForStatus(targetStatus string, vm VM) error { timeout := 2 * time.Minute start := time.Now() for time.Since(start) < timeout { - currentStatus, err := c.getVMStatus(vm.Node, vm.VMID) + currentStatus, err := s.getVMStatus(vm.Node, vm.VMID) if err != nil { time.Sleep(5 * time.Second) continue @@ -238,9 +238,9 @@ func (c *Client) waitForStatus(targetStatus string, vm VM) error { return fmt.Errorf("timeout waiting for VM to be %s", targetStatus) } -func (c *Client) validateVMID(vmID int) error { +func (s *ProxmoxService) validateVMID(vmID int) error { // Get VMs - vms, err := c.GetClusterResources("type=vm") + vms, err := s.GetClusterResources("type=vm") if err != nil { return err } @@ -249,7 +249,7 @@ func (c *Client) validateVMID(vmID int) error { for _, vm := range vms { if vm.VmId == vmID { // Check if VM is in critical pool - if vm.ResourcePool == c.Config.CriticalPool { + if vm.ResourcePool == s.Config.CriticalPool { return fmt.Errorf("VMID %d is in critical pool", vmID) } return nil @@ -259,28 +259,28 @@ func (c *Client) validateVMID(vmID int) error { return fmt.Errorf("VMID %d not found", vmID) } -func (c *Client) getVMConfig(node string, VMID int) (*VirtualResourceConfig, error) { +func (s *ProxmoxService) getVMConfig(node string, VMID int) (*VirtualResourceConfig, error) { configReq := tools.ProxmoxAPIRequest{ Method: "GET", Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/config", node, VMID), } var config VirtualResourceConfig - if err := c.RequestHelper.MakeRequestAndUnmarshal(configReq, &config); err != nil { + if err := s.RequestHelper.MakeRequestAndUnmarshal(configReq, &config); err != nil { return nil, fmt.Errorf("failed to get VM config: %w", err) } return &config, nil } -func (c *Client) getVMStatus(node string, VMID int) (string, error) { +func (s *ProxmoxService) getVMStatus(node string, VMID int) (string, error) { req := tools.ProxmoxAPIRequest{ Method: "GET", Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/status/current", node, VMID), } var response VirtualResourceStatus - if err := c.RequestHelper.MakeRequestAndUnmarshal(req, &response); err != nil { + if err := s.RequestHelper.MakeRequestAndUnmarshal(req, &response); err != nil { return "", fmt.Errorf("failed to get VM status: %w", err) } diff --git a/internal/tools/database.go b/internal/tools/database_client.go similarity index 100% rename from internal/tools/database.go rename to internal/tools/database_client.go From 6dd7d7eec0d24cdfeed864e73c4c286b5d8127b6 Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Sat, 6 Sep 2025 19:35:51 -0700 Subject: [PATCH 14/23] Reimplemented retry logic and also fixed broken LDAP queries --- internal/api/auth/auth_service.go | 86 +++---- internal/api/handlers/auth_handler.go | 23 +- internal/cloning/templates.go | 5 +- internal/ldap/groups.go | 28 +-- internal/ldap/ldap_client.go | 340 ++++++++++++++++++++------ internal/ldap/types.go | 2 +- internal/ldap/users.go | 67 +++-- internal/tools/requests.go | 2 + 8 files changed, 389 insertions(+), 164 deletions(-) diff --git a/internal/api/auth/auth_service.go b/internal/api/auth/auth_service.go index 85f1b23..6d93b28 100644 --- a/internal/api/auth/auth_service.go +++ b/internal/api/auth/auth_service.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/cpp-cyber/proclone/internal/ldap" - ldapv3 "github.com/go-ldap/ldap/v3" ) func NewAuthService() (*AuthService, error) { @@ -20,33 +19,31 @@ func NewAuthService() (*AuthService, error) { } func (s *AuthService) Authenticate(username string, password string) (bool, error) { - // Get the LDAP service to perform authentication - ldapSvc, ok := s.ldapService.(*ldap.LDAPService) - if !ok { - return false, fmt.Errorf("invalid LDAP service type") + // Input validation + if username == "" || password == "" { + return false, nil // Invalid credentials, not an error } - userDN, err := ldapSvc.GetUserDN(username) + // Get user DN first to validate user exists + userDN, err := s.ldapService.GetUserDN(username) if err != nil { - return false, fmt.Errorf("failed to get user DN: %v", err) + return false, nil // User not found, not an error for security reasons } - // Create a temporary client for authentication + // Create a temporary client for authentication to avoid privilege escalation config, err := ldap.LoadConfig() if err != nil { - return false, fmt.Errorf("failed to load LDAP config: %v", err) + return false, fmt.Errorf("failed to load LDAP config: %w", err) } authClient := ldap.NewClient(config) - err = authClient.Connect() - if err != nil { - return false, fmt.Errorf("failed to connect to LDAP: %v", err) + if err := authClient.Connect(); err != nil { + return false, fmt.Errorf("failed to connect to LDAP: %w", err) } defer authClient.Disconnect() // Try to bind as the user to verify password - err = authClient.Bind(userDN, password) - if err != nil { + if err := authClient.SimpleBind(userDN, password); err != nil { return false, nil // Invalid credentials, not an error } @@ -54,61 +51,36 @@ func (s *AuthService) Authenticate(username string, password string) (bool, erro } func (s *AuthService) IsAdmin(username string) (bool, error) { - config, err := ldap.LoadConfig() - if err != nil { - return false, fmt.Errorf("failed to load LDAP config: %v", err) + // Input validation + if username == "" { + return false, fmt.Errorf("username cannot be empty") } - // Create a client for admin check - client := ldap.NewClient(config) - err = client.Connect() + // Get user DN + userDN, err := s.ldapService.GetUserDN(username) if err != nil { - return false, fmt.Errorf("failed to connect to LDAP: %v", err) - } - defer client.Disconnect() - - // Search for admin group - adminGroupReq := ldapv3.NewSearchRequest( - config.AdminGroupDN, - ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, - "(objectClass=group)", - []string{"member"}, - nil, - ) - - // Search for user DN - userDNReq := ldapv3.NewSearchRequest( - config.BaseDN, - ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&(objectClass=inetOrgPerson)(uid=%s))", ldapv3.EscapeFilter(username)), - []string{"dn"}, - nil, - ) - - adminGroupResult, err := client.Search(adminGroupReq) - if err != nil { - return false, fmt.Errorf("failed to search admin group: %v", err) + return false, fmt.Errorf("failed to get user DN: %w", err) } - userResult, err := client.Search(userDNReq) + // Get user's groups + userGroups, err := s.ldapService.GetUserGroups(userDN) if err != nil { - return false, fmt.Errorf("failed to search user: %v", err) + return false, fmt.Errorf("failed to get user groups: %w", err) } - if len(adminGroupResult.Entries) == 0 { - return false, fmt.Errorf("admin group not found") + // Load LDAP config to get admin group DN + config, err := ldap.LoadConfig() + if err != nil { + return false, fmt.Errorf("failed to load LDAP config: %w", err) } - if len(userResult.Entries) == 0 { - return false, fmt.Errorf("user not found") + if config.AdminGroupDN == "" { + return false, fmt.Errorf("admin group DN not configured") } - adminMembers := adminGroupResult.Entries[0].GetAttributeValues("member") - userDN := userResult.Entries[0].DN - - // Check if user DN is in admin group members - for _, member := range adminMembers { - if strings.EqualFold(member, userDN) { + // Check if user is in the admin group + for _, groupDN := range userGroups { + if strings.EqualFold(groupDN, "Proxmox-Admins") { return true, nil } } diff --git a/internal/api/handlers/auth_handler.go b/internal/api/handlers/auth_handler.go index d648586..d22f299 100644 --- a/internal/api/handlers/auth_handler.go +++ b/internal/api/handlers/auth_handler.go @@ -131,6 +131,7 @@ func (h *AuthHandler) RegisterHandler(c *gin.Context) { var userDN = "" userDN, err := h.ldapService.GetUserDN(req.Username) if userDN != "" { + log.Printf("Attempt to register existing username: %s", req.Username) c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"}) return } @@ -156,6 +157,7 @@ func (h *AuthHandler) RegisterHandler(c *gin.Context) { func (h *AuthHandler) GetUsersHandler(c *gin.Context) { users, err := h.ldapService.GetUsers() if err != nil { + log.Printf("Failed to retrieve users: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve users"}) return } @@ -196,12 +198,14 @@ func (h *AuthHandler) CreateUsersHandler(c *gin.Context) { } if len(errors) > 0 { + log.Printf("Failed to create users: %v", errors) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create users", "details": errors}) return } // Sync users to Proxmox if err := h.proxmoxService.SyncUsers(); err != nil { + log.Printf("Failed to sync users with Proxmox: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync users with Proxmox", "details": err.Error()}) return } @@ -226,12 +230,14 @@ func (h *AuthHandler) DeleteUsersHandler(c *gin.Context) { } if len(errors) > 0 { + log.Printf("Failed to delete users: %v", errors) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete users", "details": errors}) return } // Sync users to Proxmox if err := h.proxmoxService.SyncUsers(); err != nil { + log.Printf("Failed to sync users with Proxmox: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync users with Proxmox", "details": err.Error()}) return } @@ -255,6 +261,7 @@ func (h *AuthHandler) EnableUsersHandler(c *gin.Context) { } if len(errors) > 0 { + log.Printf("Failed to enable users: %v", errors) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable users", "details": errors}) return } @@ -278,6 +285,7 @@ func (h *AuthHandler) DisableUsersHandler(c *gin.Context) { } if len(errors) > 0 { + log.Printf("Failed to disable users: %v", errors) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable users", "details": errors}) return } @@ -297,6 +305,7 @@ func (h *AuthHandler) SetUserGroupsHandler(c *gin.Context) { } if err := h.ldapService.SetUserGroups(req.Username, req.Groups); err != nil { + log.Printf("Failed to set groups for user %s: %v", req.Username, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to set user groups", "details": err.Error()}) return } @@ -307,6 +316,7 @@ func (h *AuthHandler) SetUserGroupsHandler(c *gin.Context) { func (h *AuthHandler) GetGroupsHandler(c *gin.Context) { groups, err := h.ldapService.GetGroups() if err != nil { + log.Printf("Failed to retrieve groups: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve groups"}) return } @@ -334,12 +344,14 @@ func (h *AuthHandler) CreateGroupsHandler(c *gin.Context) { } if len(errors) > 0 { + log.Printf("Failed to create groups: %v", errors) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create groups", "details": errors}) return } // Sync groups to Proxmox if err := h.proxmoxService.SyncGroups(); err != nil { + log.Printf("Failed to sync groups with Proxmox: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync groups with Proxmox", "details": err.Error()}) return } @@ -354,7 +366,8 @@ func (h *AuthHandler) RenameGroupHandler(c *gin.Context) { } if err := h.ldapService.RenameGroup(req.OldName, req.NewName); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to rename group"}) + log.Printf("Failed to rename group %s to %s: %v", req.OldName, req.NewName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to rename group", "details": err.Error()}) return } @@ -377,12 +390,14 @@ func (h *AuthHandler) DeleteGroupsHandler(c *gin.Context) { } if len(errors) > 0 { + log.Printf("Failed to delete groups: %v", errors) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete groups", "details": errors}) return } // Sync groups to Proxmox if err := h.proxmoxService.SyncGroups(); err != nil { + log.Printf("Failed to sync groups with Proxmox: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync groups with Proxmox", "details": err.Error()}) return } @@ -397,7 +412,8 @@ func (h *AuthHandler) AddUsersHandler(c *gin.Context) { } if err := h.ldapService.AddUsersToGroup(req.Group, req.Usernames); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add users to group"}) + log.Printf("Failed to add users to group %s: %v", req.Group, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add users to group", "details": err.Error()}) return } @@ -411,7 +427,8 @@ func (h *AuthHandler) RemoveUsersHandler(c *gin.Context) { } if err := h.ldapService.RemoveUsersFromGroup(req.Group, req.Usernames); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove users from group"}) + log.Printf("Failed to remove users from group %s: %v", req.Group, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove users from group", "details": err.Error()}) return } diff --git a/internal/cloning/templates.go b/internal/cloning/templates.go index 492f587..992323f 100644 --- a/internal/cloning/templates.go +++ b/internal/cloning/templates.go @@ -146,7 +146,10 @@ func (c *TemplateClient) GetTemplateInfo(templateName string) (KaminoTemplate, e &template.CreatedAt, ) if err != nil { - return KaminoTemplate{}, fmt.Errorf("failed to get template info: %w", err) + if strings.Contains(err.Error(), "no rows in result set") { + return KaminoTemplate{}, nil // No error, but template not found + } + return KaminoTemplate{}, fmt.Errorf("failed to scan row: %w", err) } return template, nil diff --git a/internal/ldap/groups.go b/internal/ldap/groups.go index 95ab624..5f7359d 100644 --- a/internal/ldap/groups.go +++ b/internal/ldap/groups.go @@ -223,22 +223,16 @@ func (s *LDAPService) AddUsersToGroup(groupName string, usernames []string) erro return fmt.Errorf("group not found: %v", err) } - var userDNs []string + // Add users one by one to handle cases where some users might already be in the group for _, username := range usernames { userDN, err := s.GetUserDN(username) if err != nil { return fmt.Errorf("user %s not found: %v", username, err) } - userDNs = append(userDNs, userDN) - } - - // Add all users to the group - modifyReq := ldapv3.NewModifyRequest(groupDN, nil) - modifyReq.Add("member", userDNs) - err = s.client.Modify(modifyReq) - if err != nil { - return fmt.Errorf("failed to add users to group: %v", err) + if err := s.AddToGroup(userDN, groupDN); err != nil { + return fmt.Errorf("failed to add user %s to group: %v", username, err) + } } return nil @@ -250,22 +244,16 @@ func (s *LDAPService) RemoveUsersFromGroup(groupName string, usernames []string) return fmt.Errorf("group not found: %v", err) } - var userDNs []string + // Remove users one by one to handle cases where some users might not be in the group for _, username := range usernames { userDN, err := s.GetUserDN(username) if err != nil { return fmt.Errorf("user %s not found: %v", username, err) } - userDNs = append(userDNs, userDN) - } - - // Remove all users from the group - modifyReq := ldapv3.NewModifyRequest(groupDN, nil) - modifyReq.Delete("member", userDNs) - err = s.client.Modify(modifyReq) - if err != nil { - return fmt.Errorf("failed to remove users from group: %v", err) + if err := s.RemoveFromGroup(userDN, groupDN); err != nil { + return fmt.Errorf("failed to remove user %s from group: %v", username, err) + } } return nil diff --git a/internal/ldap/ldap_client.go b/internal/ldap/ldap_client.go index c540f90..e43582d 100644 --- a/internal/ldap/ldap_client.go +++ b/internal/ldap/ldap_client.go @@ -4,6 +4,7 @@ import ( "crypto/tls" "fmt" "strings" + "time" "github.com/go-ldap/ldap/v3" "github.com/kelseyhightower/envconfig" @@ -66,33 +67,167 @@ func (c *Client) Disconnect() error { return nil } -func (c *Client) HealthCheck() error { +// isConnectionError checks if an error indicates a connection problem +func (c *Client) isConnectionError(err error) bool { + if err == nil { + return false + } + + errorMsg := strings.ToLower(err.Error()) + return strings.Contains(errorMsg, "connection closed") || + strings.Contains(errorMsg, "network error") || + strings.Contains(errorMsg, "connection reset") || + strings.Contains(errorMsg, "broken pipe") || + strings.Contains(errorMsg, "connection refused") || + strings.Contains(errorMsg, "timeout") || + strings.Contains(errorMsg, "eof") || + strings.Contains(errorMsg, "operations error") || + strings.Contains(errorMsg, "successful bind must be completed") || + strings.Contains(errorMsg, "ldap result code 1") +} + +// reconnect attempts to reconnect to the LDAP server +func (c *Client) reconnect() error { + c.mutex.Lock() + defer c.mutex.Unlock() + + // Close existing connection if any + if c.conn != nil { + c.conn.Close() + } + c.connected = false + + // Wait a moment before retrying + time.Sleep(100 * time.Millisecond) + + // Attempt reconnection + conn, err := c.dial() + if err != nil { + return fmt.Errorf("failed to reconnect to LDAP server: %v", err) + } + + if c.config.BindUser != "" { + err = conn.Bind(c.config.BindUser, c.config.BindPassword) + if err != nil { + conn.Close() + return fmt.Errorf("failed to bind after reconnection: %v", err) + } + } + + c.conn = conn + c.connected = true + return nil +} + +// validateBind checks if the current bind is still valid by performing a simple operation +func (c *Client) validateBind() error { c.mutex.RLock() - defer c.mutex.RUnlock() + conn := c.conn + c.mutex.RUnlock() - if !c.connected || c.conn == nil { - return fmt.Errorf("LDAP connection is not established") + if conn == nil { + return fmt.Errorf("no connection available") } - // Try a simple search to verify the connection - searchRequest := ldap.NewSearchRequest( + // Try a simple search to validate the bind + req := ldap.NewSearchRequest( c.config.BaseDN, - ldap.ScopeBaseObject, - ldap.NeverDerefAliases, - 1, - 1, - false, + ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false, "(objectClass=*)", - []string{"objectClass"}, + []string{"dn"}, nil, ) - _, err := c.conn.Search(searchRequest) - if err != nil { - return fmt.Errorf("LDAP health check failed: %v", err) + _, err := conn.Search(req) + return err +} + +// executeWithRetry executes an LDAP operation with automatic retry on connection errors +func (c *Client) executeWithRetry(operation func() error, maxRetries int) error { + var lastErr error + + for attempt := 0; attempt <= maxRetries; attempt++ { + + c.mutex.RLock() + connected := c.connected + conn := c.conn + c.mutex.RUnlock() + + // Check if we need to reconnect + if !connected || conn == nil { + if reconnectErr := c.reconnect(); reconnectErr != nil { + lastErr = reconnectErr + continue + } + } else { + // Validate that the bind is still active + if bindErr := c.validateBind(); bindErr != nil { + if c.isConnectionError(bindErr) { + if reconnectErr := c.reconnect(); reconnectErr != nil { + lastErr = reconnectErr + continue + } + } + } + } + + err := operation() + if err == nil { + return nil + } + + lastErr = err + + // If it's not a connection error, don't retry + if !c.isConnectionError(err) { + return err + } + + // Mark as disconnected and try to reconnect + c.mutex.Lock() + c.connected = false + c.mutex.Unlock() + + // Don't reconnect on the last attempt + if attempt < maxRetries { + if reconnectErr := c.reconnect(); reconnectErr != nil { + lastErr = reconnectErr + } + } } - return nil + return fmt.Errorf("operation failed after %d retries, last error: %v", maxRetries+1, lastErr) +} + +func (c *Client) HealthCheck() error { + req := ldap.NewSearchRequest( + c.config.BaseDN, + ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false, + "(objectClass=*)", + []string{"dn"}, + nil, + ) + + err := c.executeWithRetry(func() error { + c.mutex.RLock() + conn := c.conn + c.mutex.RUnlock() + + if conn == nil { + return fmt.Errorf("no LDAP connection available") + } + + _, searchErr := conn.Search(req) + if searchErr != nil { + } else { + } + return searchErr + }, 2) + + if err != nil { + } else { + } + return err } func (c *Client) IsConnected() bool { @@ -102,80 +237,153 @@ func (c *Client) IsConnected() bool { } func (c *Client) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) { - c.mutex.RLock() - defer c.mutex.RUnlock() + var result *ldap.SearchResult + + err := c.executeWithRetry(func() error { + c.mutex.RLock() + conn := c.conn + c.mutex.RUnlock() + + if conn == nil { + return fmt.Errorf("no LDAP connection available") + } + + res, err := conn.Search(searchRequest) + if err != nil { + return fmt.Errorf("failed to search: %v", err) + } + result = res + return nil + }, 2) // Retry up to 2 times - if !c.connected || c.conn == nil { - return nil, fmt.Errorf("LDAP connection is not established") + if err != nil { + return nil, err } - return c.conn.Search(searchRequest) + return result, nil } -func (c *Client) Add(addRequest *ldap.AddRequest) error { - c.mutex.RLock() - defer c.mutex.RUnlock() +// SearchEntry performs an LDAP search and returns the first entry +func (c *Client) SearchEntry(req *ldap.SearchRequest) (*ldap.Entry, error) { + var result *ldap.SearchResult + + err := c.executeWithRetry(func() error { + c.mutex.RLock() + conn := c.conn + c.mutex.RUnlock() + + if conn == nil { + return fmt.Errorf("no LDAP connection available") + } - if !c.connected || c.conn == nil { - return fmt.Errorf("LDAP connection is not established") + res, err := conn.Search(req) + if err != nil { + return fmt.Errorf("failed to search entry: %v", err) + } + result = res + return nil + }, 2) // Retry up to 2 times + + if err != nil { + return nil, err } - return c.conn.Add(addRequest) + if len(result.Entries) == 0 { + return nil, nil + } + return result.Entries[0], nil +} + +func (c *Client) Add(addRequest *ldap.AddRequest) error { + return c.executeWithRetry(func() error { + c.mutex.RLock() + conn := c.conn + c.mutex.RUnlock() + + if conn == nil { + return fmt.Errorf("no LDAP connection available") + } + + return conn.Add(addRequest) + }, 2) // Retry up to 2 times } func (c *Client) Modify(modifyRequest *ldap.ModifyRequest) error { - c.mutex.RLock() - defer c.mutex.RUnlock() + return c.executeWithRetry(func() error { + c.mutex.RLock() + conn := c.conn + c.mutex.RUnlock() - if !c.connected || c.conn == nil { - return fmt.Errorf("LDAP connection is not established") - } + if conn == nil { + return fmt.Errorf("no LDAP connection available") + } - return c.conn.Modify(modifyRequest) + return conn.Modify(modifyRequest) + }, 2) // Retry up to 2 times } func (c *Client) Del(delRequest *ldap.DelRequest) error { - c.mutex.RLock() - defer c.mutex.RUnlock() + return c.executeWithRetry(func() error { + c.mutex.RLock() + conn := c.conn + c.mutex.RUnlock() - if !c.connected || c.conn == nil { - return fmt.Errorf("LDAP connection is not established") - } + if conn == nil { + return fmt.Errorf("no LDAP connection available") + } - return c.conn.Del(delRequest) + return conn.Del(delRequest) + }, 2) // Retry up to 2 times } func (c *Client) ModifyDN(modifyDNRequest *ldap.ModifyDNRequest) error { - c.mutex.RLock() - defer c.mutex.RUnlock() + return c.executeWithRetry(func() error { + c.mutex.RLock() + conn := c.conn + c.mutex.RUnlock() - if !c.connected || c.conn == nil { - return fmt.Errorf("LDAP connection is not established") - } + if conn == nil { + return fmt.Errorf("no LDAP connection available") + } - return c.conn.ModifyDN(modifyDNRequest) + return conn.ModifyDN(modifyDNRequest) + }, 2) // Retry up to 2 times } func (c *Client) Bind(username, password string) error { - c.mutex.RLock() - defer c.mutex.RUnlock() + err := c.executeWithRetry(func() error { + c.mutex.RLock() + conn := c.conn + c.mutex.RUnlock() - if !c.connected || c.conn == nil { - return fmt.Errorf("LDAP connection is not established") - } + if conn == nil { + return fmt.Errorf("no LDAP connection available") + } + + bindErr := conn.Bind(username, password) + if bindErr != nil { + } else { + } + return bindErr + }, 2) // Retry up to 2 times - return c.conn.Bind(username, password) + if err != nil { + } + return err } func (c *Client) SimpleBind(username, password string) error { - c.mutex.RLock() - defer c.mutex.RUnlock() + return c.executeWithRetry(func() error { + c.mutex.RLock() + conn := c.conn + c.mutex.RUnlock() - if !c.connected || c.conn == nil { - return fmt.Errorf("LDAP connection is not established") - } + if conn == nil { + return fmt.Errorf("no LDAP connection available") + } - return c.conn.Bind(username, password) + return conn.Bind(username, password) + }, 2) // Retry up to 2 times } func (s *LDAPService) GetUserDN(username string) (string, error) { @@ -183,26 +391,22 @@ func (s *LDAPService) GetUserDN(username string) (string, error) { return "", fmt.Errorf("username cannot be empty") } - searchRequest := ldap.NewSearchRequest( + req := ldap.NewSearchRequest( s.client.config.BaseDN, - ldap.ScopeWholeSubtree, - ldap.NeverDerefAliases, - 1, - 30, - false, - fmt.Sprintf("(&(objectClass=inetOrgPerson)(uid=%s))", ldap.EscapeFilter(username)), + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(&(objectClass=user)(sAMAccountName=%s))", username), []string{"dn"}, nil, ) - searchResult, err := s.client.Search(searchRequest) + entry, err := s.client.SearchEntry(req) if err != nil { return "", fmt.Errorf("failed to search for user: %v", err) } - if len(searchResult.Entries) == 0 { - return "", fmt.Errorf("user %s not found", username) + if entry == nil { + return "", fmt.Errorf("user not found") } - return searchResult.Entries[0].DN, nil + return entry.DN, nil } diff --git a/internal/ldap/types.go b/internal/ldap/types.go index d21558c..5de4949 100644 --- a/internal/ldap/types.go +++ b/internal/ldap/types.go @@ -91,5 +91,5 @@ type User struct { type UserRegistrationInfo struct { Username string `json:"username" validate:"required,min=1,max=20"` - Password string `json:"password" validate:"required,min=8"` + Password string `json:"password" validate:"required,min=8,max=128"` } diff --git a/internal/ldap/users.go b/internal/ldap/users.go index c5fe319..c20587b 100644 --- a/internal/ldap/users.go +++ b/internal/ldap/users.go @@ -6,6 +6,7 @@ import ( "regexp" "strconv" "strings" + "time" "unicode/utf16" ldapv3 "github.com/go-ldap/ldap/v3" @@ -16,15 +17,11 @@ import ( // ================================================= func (s *LDAPService) GetUsers() ([]User, error) { + kaminoUsersGroupDN := "CN=KaminoUsers,OU=KaminoGroups," + s.client.config.BaseDN searchRequest := ldapv3.NewSearchRequest( - s.client.config.BaseDN, - ldapv3.ScopeWholeSubtree, - ldapv3.NeverDerefAliases, - 0, - 0, - false, - "(objectClass=inetOrgPerson)", - []string{"uid", "createTimestamp", "userAccountControl", "memberOf", "cn"}, + s.client.config.BaseDN, ldapv3.ScopeWholeSubtree, ldapv3.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(&(objectClass=user)(sAMAccountName=*)(memberOf=%s))", kaminoUsersGroupDN), // Filter for users in KaminoUsers group + []string{"sAMAccountName", "dn", "whenCreated", "memberOf", "userAccountControl"}, // Attributes to retrieve nil, ) @@ -33,12 +30,18 @@ func (s *LDAPService) GetUsers() ([]User, error) { return nil, fmt.Errorf("failed to search for users: %v", err) } - var users []User + var users = []User{} for _, entry := range searchResult.Entries { user := User{ - Name: entry.GetAttributeValue("uid"), - CreatedAt: entry.GetAttributeValue("createTimestamp"), - Enabled: true, // Default enabled, will be updated based on userAccountControl + Name: entry.GetAttributeValue("sAMAccountName"), + } + + whenCreated := entry.GetAttributeValue("whenCreated") + if whenCreated != "" { + // AD stores dates in GeneralizedTime format: YYYYMMDDHHMMSS.0Z + if parsedTime, err := time.Parse("20060102150405.0Z", whenCreated); err == nil { + user.CreatedAt = parsedTime.Format("2006-01-02 15:04:05") + } } // Check if user is enabled @@ -148,12 +151,35 @@ func (s *LDAPService) AddToGroup(userDN string, groupDN string) error { err := s.client.Modify(modifyRequest) if err != nil { + // Check if the error is because the user is already in the group + if strings.Contains(strings.ToLower(err.Error()), "already exists") || + strings.Contains(strings.ToLower(err.Error()), "attribute or value exists") { + return nil // Not an error if user is already in group + } return fmt.Errorf("failed to add user to group: %v", err) } return nil } +func (s *LDAPService) RemoveFromGroup(userDN string, groupDN string) error { + modifyRequest := ldapv3.NewModifyRequest(groupDN, nil) + modifyRequest.Delete("member", []string{userDN}) + + err := s.client.Modify(modifyRequest) + if err != nil { + // Check if the error is because the user is not in the group + if strings.Contains(strings.ToLower(err.Error()), "no such attribute") || + strings.Contains(strings.ToLower(err.Error()), "unwilling to perform") || + strings.Contains(strings.ToLower(err.Error()), "no such object") { + return nil // Not an error if user is not in group + } + return fmt.Errorf("failed to remove user from group: %v", err) + } + + return nil +} + func (s *LDAPService) CreateAndRegisterUser(userInfo UserRegistrationInfo) error { // Validate username if !isValidUsername(userInfo.Username) { @@ -185,6 +211,13 @@ func (s *LDAPService) CreateAndRegisterUser(userInfo UserRegistrationInfo) error return fmt.Errorf("failed to enable user account: %v", err) } + // Add user to KaminoUsers group + kaminoUsersGroupDN := "CN=KaminoUsers,OU=KaminoGroups," + s.client.config.BaseDN + err = s.AddToGroup(userDN, kaminoUsersGroupDN) + if err != nil { + return fmt.Errorf("failed to add user to KaminoUsers group: %v", err) + } + return nil } @@ -194,7 +227,10 @@ func (s *LDAPService) AddUserToGroup(username string, groupName string) error { return fmt.Errorf("failed to get user DN: %v", err) } - groupDN := fmt.Sprintf("cn=%s,%s", groupName, s.client.config.BaseDN) + groupDN, err := s.getGroupDN(groupName) + if err != nil { + return fmt.Errorf("failed to get group DN: %v", err) + } return s.AddToGroup(userDN, groupDN) } @@ -205,7 +241,10 @@ func (s *LDAPService) RemoveUserFromGroup(username string, groupName string) err return fmt.Errorf("failed to get user DN: %v", err) } - groupDN := fmt.Sprintf("cn=%s,%s", groupName, s.client.config.BaseDN) + groupDN, err := s.getGroupDN(groupName) + if err != nil { + return fmt.Errorf("failed to get group DN: %v", err) + } modifyRequest := ldapv3.NewModifyRequest(groupDN, nil) modifyRequest.Delete("member", []string{userDN}) diff --git a/internal/tools/requests.go b/internal/tools/requests.go index 9db67ba..33d8ccf 100644 --- a/internal/tools/requests.go +++ b/internal/tools/requests.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" ) @@ -44,6 +45,7 @@ func (prh *ProxmoxRequestHelper) MakeRequest(req ProxmoxAPIRequest) (json.RawMes if req.Method == "POST" || req.Method == "PUT" { var bodyData any if req.RequestBody != nil { + log.Printf("Request Body: %+v", req.RequestBody) bodyData = req.RequestBody } else { bodyData = map[string]any{} From 15a79b92a1916e067c74437729caaebaf6ba9267 Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Sat, 6 Sep 2025 20:48:17 -0700 Subject: [PATCH 15/23] Made QEMU agent calls use formdata instead of json. Also made routers full clone to hopefully fix file errors --- internal/cloning/cloning_service.go | 4 +-- internal/cloning/networking.go | 15 +++++++-- internal/cloning/types.go | 1 + internal/proxmox/types.go | 2 +- internal/proxmox/vms.go | 18 ++++++++-- internal/tools/requests.go | 52 ++++++++++++++++++++++++++--- 6 files changed, 78 insertions(+), 14 deletions(-) diff --git a/internal/cloning/cloning_service.go b/internal/cloning/cloning_service.go index cd6ba6e..3bd855f 100644 --- a/internal/cloning/cloning_service.go +++ b/internal/cloning/cloning_service.go @@ -130,14 +130,14 @@ func (cs *CloningService) CloneTemplate(template string, targetName string, isGr } } - newRouter, err := cs.ProxmoxService.CloneVM(*router, newPoolName) + newRouter, err := cs.ProxmoxService.CloneVM(*router, newPoolName, true) // Always use full clone for routers if err != nil { errors = append(errors, fmt.Sprintf("failed to clone router VM: %v", err)) } // Clone each VM to new pool for _, vm := range templateVMs { - _, err := cs.ProxmoxService.CloneVM(vm, newPoolName) + _, err := cs.ProxmoxService.CloneVM(vm, newPoolName, cs.Config.UseFullClones) if err != nil { errors = append(errors, fmt.Sprintf("failed to clone VM %s: %v", vm.Name, err)) } diff --git a/internal/cloning/networking.go b/internal/cloning/networking.go index 4c2eb32..db3b2c7 100644 --- a/internal/cloning/networking.go +++ b/internal/cloning/networking.go @@ -33,11 +33,13 @@ func (cs *CloningService) configurePodRouter(podNumber int, node string, vmid in break // Agent is responding } - log.Printf("Agent ping failed for VMID %d", vmid) + log.Printf("Agent ping failed for VMID %d: %s", vmid, err) time.Sleep(backoff) backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) } + log.Printf("QEMU agent is responding for VMID %d, proceeding with configuration", vmid) + // Configure router WAN IP to have correct third octet using qemu agent API call reqBody := map[string]any{ "command": []string{ @@ -46,17 +48,22 @@ func (cs *CloningService) configurePodRouter(podNumber int, node string, vmid in }, } + log.Printf("Request Body: %+v", reqBody) + execReq := tools.ProxmoxAPIRequest{ Method: "POST", Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/agent/exec", node, vmid), RequestBody: reqBody, + UseFormData: true, // Use form data for QEMU agent requests } - _, err := cs.ProxmoxService.GetRequestHelper().MakeRequest(execReq) + response, err := cs.ProxmoxService.GetRequestHelper().MakeRequest(execReq) if err != nil { return fmt.Errorf("failed to make IP change request: %v", err) } + log.Printf("WAN IP configuration response: %s", string(response)) + // Send agent exec request to change VIP subnet vipReqBody := map[string]any{ "command": []string{ @@ -69,13 +76,15 @@ func (cs *CloningService) configurePodRouter(podNumber int, node string, vmid in Method: "POST", Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/agent/exec", node, vmid), RequestBody: vipReqBody, + UseFormData: true, // Use form data for QEMU agent requests } - _, err = cs.ProxmoxService.GetRequestHelper().MakeRequest(vipExecReq) + vipResponse, err := cs.ProxmoxService.GetRequestHelper().MakeRequest(vipExecReq) if err != nil { return fmt.Errorf("failed to make VIP change request: %v", err) } + log.Printf("VIP configuration response: %s", string(vipResponse)) log.Printf("Successfully configured router for pod %d on node %s, VMID %d", podNumber, node, vmid) return nil } diff --git a/internal/cloning/types.go b/internal/cloning/types.go index 923f5ad..5a29494 100644 --- a/internal/cloning/types.go +++ b/internal/cloning/types.go @@ -22,6 +22,7 @@ type Config struct { WANScriptPath string `envconfig:"WAN_SCRIPT_PATH" default:"/home/change-wan-ip.sh"` VIPScriptPath string `envconfig:"VIP_SCRIPT_PATH" default:"/home/change-vip-subnet.sh"` WANIPBase string `envconfig:"WAN_IP_BASE" default:"172.16."` + UseFullClones bool `envconfig:"USE_FULL_CLONES" default:"false"` } // KaminoTemplate represents a template in the system diff --git a/internal/proxmox/types.go b/internal/proxmox/types.go index 9d65e06..d994d0d 100644 --- a/internal/proxmox/types.go +++ b/internal/proxmox/types.go @@ -42,7 +42,7 @@ type Service interface { StopVM(node string, vmID int) error DeleteVM(node string, vmID int) error ConvertVMToTemplate(node string, vmID int) error - CloneVM(sourceVM VM, newPoolName string) (*VM, error) + CloneVM(sourceVM VM, newPoolName string, useFullClone bool) (*VM, error) WaitForCloneCompletion(vm *VM, timeout time.Duration) error WaitForDisk(node string, vmid int, maxWait time.Duration) error WaitForRunning(vm VM) error diff --git a/internal/proxmox/vms.go b/internal/proxmox/vms.go index e4c8ee6..201d057 100644 --- a/internal/proxmox/vms.go +++ b/internal/proxmox/vms.go @@ -76,7 +76,7 @@ func (s *ProxmoxService) ConvertVMToTemplate(node string, vmID int) error { return nil } -func (s *ProxmoxService) CloneVM(sourceVM VM, newPoolName string) (*VM, error) { +func (s *ProxmoxService) CloneVM(sourceVM VM, newPoolName string, useFullClone bool) (*VM, error) { // Get next available VMID req := tools.ProxmoxAPIRequest{ Method: "GET", @@ -99,12 +99,18 @@ func (s *ProxmoxService) CloneVM(sourceVM VM, newPoolName string) (*VM, error) { return nil, fmt.Errorf("failed to find best node: %w", err) } + // Determine clone type based on parameter + cloneType := 0 // Linked clone by default + if useFullClone { + cloneType = 1 // Full clone + } + // Clone VM cloneBody := map[string]any{ "newid": newVMID, "name": sourceVM.Name, "pool": newPoolName, - "full": 0, // Linked clone + "full": cloneType, // Use configurable clone type "target": bestNode, } @@ -180,7 +186,13 @@ func (s *ProxmoxService) WaitForDisk(node string, vmid int, maxWait time.Duratio } if configResp.HardDisk != "" { - return nil // Disk is available + // Additional check: try to get VM status to ensure disk is actually accessible + _, statusErr := s.getVMStatus(node, vmid) + if statusErr == nil { + // Wait a bit more for linked clone dependencies to be fully ready + time.Sleep(5 * time.Second) + return nil // Disk is available and VM is accessible + } } } diff --git a/internal/tools/requests.go b/internal/tools/requests.go index 33d8ccf..37c6d9f 100644 --- a/internal/tools/requests.go +++ b/internal/tools/requests.go @@ -7,6 +7,8 @@ import ( "io" "log" "net/http" + "net/url" + "strings" ) // ProxmoxAPIRequest represents a request to the Proxmox API @@ -14,6 +16,7 @@ type ProxmoxAPIRequest struct { Method string // GET, POST, PUT, DELETE Endpoint string // The API endpoint (e.g., "/nodes", "/nodes/node1/status") RequestBody any // Optional request body for POST/PUT requests + UseFormData bool // Whether to send as form data instead of JSON } // ProxmoxAPIResponse represents the generic Proxmox API response structure @@ -40,6 +43,7 @@ func NewProxmoxRequestHelper(baseURL, apiToken string, httpClient *http.Client) // MakeRequest performs an HTTP request to the Proxmox API and returns the raw response data func (prh *ProxmoxRequestHelper) MakeRequest(req ProxmoxAPIRequest) (json.RawMessage, error) { var reqBody io.Reader + var contentType string // Prepare request body for POST/PUT requests if req.Method == "POST" || req.Method == "PUT" { @@ -51,11 +55,47 @@ func (prh *ProxmoxRequestHelper) MakeRequest(req ProxmoxAPIRequest) (json.RawMes bodyData = map[string]any{} } - jsonData, err := json.Marshal(bodyData) - if err != nil { - return nil, fmt.Errorf("failed to marshal request body: %w", err) + if req.UseFormData { + // Convert to form data + formData := url.Values{} + if bodyMap, ok := bodyData.(map[string]any); ok { + for key, value := range bodyMap { + switch v := value.(type) { + case string: + formData.Set(key, v) + case []string: + // For arrays, Proxmox expects multiple form fields with same name + for _, item := range v { + formData.Add(key, item) + } + case []any: + // Handle generic interface slice (convert to strings) + for _, item := range v { + if str, ok := item.(string); ok { + formData.Add(key, str) + } else { + formData.Add(key, fmt.Sprintf("%v", item)) + } + } + default: + // Convert to JSON string for complex types + jsonBytes, _ := json.Marshal(v) + formData.Set(key, string(jsonBytes)) + } + } + } + reqBody = strings.NewReader(formData.Encode()) + contentType = "application/x-www-form-urlencoded" + log.Printf("Form data: %s", formData.Encode()) + } else { + // Send as JSON + jsonData, err := json.Marshal(bodyData) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + reqBody = bytes.NewBuffer(jsonData) + contentType = "application/json" } - reqBody = bytes.NewBuffer(jsonData) } // Create the full URL @@ -69,7 +109,9 @@ func (prh *ProxmoxRequestHelper) MakeRequest(req ProxmoxAPIRequest) (json.RawMes // Set headers httpReq.Header.Add("Authorization", "PVEAPIToken="+prh.APIToken) - httpReq.Header.Add("Content-Type", "application/json") + if req.Method == "POST" || req.Method == "PUT" { + httpReq.Header.Add("Content-Type", contentType) + } // Execute the request resp, err := prh.HTTPClient.Do(httpReq) From b131d1120e36c53ffbdc8ca63e463bae6abb6a47 Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Sat, 6 Sep 2025 21:46:59 -0700 Subject: [PATCH 16/23] Reverted formdata change and fixed router file paths --- internal/cloning/networking.go | 16 ++-------- internal/cloning/types.go | 4 +-- internal/tools/requests.go | 54 ++++------------------------------ 3 files changed, 9 insertions(+), 65 deletions(-) diff --git a/internal/cloning/networking.go b/internal/cloning/networking.go index db3b2c7..caf3a2a 100644 --- a/internal/cloning/networking.go +++ b/internal/cloning/networking.go @@ -2,7 +2,6 @@ package cloning import ( "fmt" - "log" "math" "regexp" "time" @@ -33,13 +32,10 @@ func (cs *CloningService) configurePodRouter(podNumber int, node string, vmid in break // Agent is responding } - log.Printf("Agent ping failed for VMID %d: %s", vmid, err) time.Sleep(backoff) backoff = time.Duration(math.Min(float64(backoff*2), float64(maxBackoff))) } - log.Printf("QEMU agent is responding for VMID %d, proceeding with configuration", vmid) - // Configure router WAN IP to have correct third octet using qemu agent API call reqBody := map[string]any{ "command": []string{ @@ -48,22 +44,17 @@ func (cs *CloningService) configurePodRouter(podNumber int, node string, vmid in }, } - log.Printf("Request Body: %+v", reqBody) - execReq := tools.ProxmoxAPIRequest{ Method: "POST", Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/agent/exec", node, vmid), RequestBody: reqBody, - UseFormData: true, // Use form data for QEMU agent requests } - response, err := cs.ProxmoxService.GetRequestHelper().MakeRequest(execReq) + _, err := cs.ProxmoxService.GetRequestHelper().MakeRequest(execReq) if err != nil { return fmt.Errorf("failed to make IP change request: %v", err) } - log.Printf("WAN IP configuration response: %s", string(response)) - // Send agent exec request to change VIP subnet vipReqBody := map[string]any{ "command": []string{ @@ -76,16 +67,13 @@ func (cs *CloningService) configurePodRouter(podNumber int, node string, vmid in Method: "POST", Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/agent/exec", node, vmid), RequestBody: vipReqBody, - UseFormData: true, // Use form data for QEMU agent requests } - vipResponse, err := cs.ProxmoxService.GetRequestHelper().MakeRequest(vipExecReq) + _, err = cs.ProxmoxService.GetRequestHelper().MakeRequest(vipExecReq) if err != nil { return fmt.Errorf("failed to make VIP change request: %v", err) } - log.Printf("VIP configuration response: %s", string(vipResponse)) - log.Printf("Successfully configured router for pod %d on node %s, VMID %d", podNumber, node, vmid) return nil } diff --git a/internal/cloning/types.go b/internal/cloning/types.go index 5a29494..b15ca48 100644 --- a/internal/cloning/types.go +++ b/internal/cloning/types.go @@ -19,8 +19,8 @@ type Config struct { CloneTimeout time.Duration `envconfig:"CLONE_TIMEOUT" default:"3m"` RouterWaitTimeout time.Duration `envconfig:"ROUTER_WAIT_TIMEOUT" default:"120s"` SDNApplyTimeout time.Duration `envconfig:"SDN_APPLY_TIMEOUT" default:"30s"` - WANScriptPath string `envconfig:"WAN_SCRIPT_PATH" default:"/home/change-wan-ip.sh"` - VIPScriptPath string `envconfig:"VIP_SCRIPT_PATH" default:"/home/change-vip-subnet.sh"` + WANScriptPath string `envconfig:"WAN_SCRIPT_PATH" default:"/home/update-wan-ip.sh"` + VIPScriptPath string `envconfig:"VIP_SCRIPT_PATH" default:"/home/update-wan-vip.sh"` WANIPBase string `envconfig:"WAN_IP_BASE" default:"172.16."` UseFullClones bool `envconfig:"USE_FULL_CLONES" default:"false"` } diff --git a/internal/tools/requests.go b/internal/tools/requests.go index 37c6d9f..9db67ba 100644 --- a/internal/tools/requests.go +++ b/internal/tools/requests.go @@ -5,10 +5,7 @@ import ( "encoding/json" "fmt" "io" - "log" "net/http" - "net/url" - "strings" ) // ProxmoxAPIRequest represents a request to the Proxmox API @@ -16,7 +13,6 @@ type ProxmoxAPIRequest struct { Method string // GET, POST, PUT, DELETE Endpoint string // The API endpoint (e.g., "/nodes", "/nodes/node1/status") RequestBody any // Optional request body for POST/PUT requests - UseFormData bool // Whether to send as form data instead of JSON } // ProxmoxAPIResponse represents the generic Proxmox API response structure @@ -43,59 +39,21 @@ func NewProxmoxRequestHelper(baseURL, apiToken string, httpClient *http.Client) // MakeRequest performs an HTTP request to the Proxmox API and returns the raw response data func (prh *ProxmoxRequestHelper) MakeRequest(req ProxmoxAPIRequest) (json.RawMessage, error) { var reqBody io.Reader - var contentType string // Prepare request body for POST/PUT requests if req.Method == "POST" || req.Method == "PUT" { var bodyData any if req.RequestBody != nil { - log.Printf("Request Body: %+v", req.RequestBody) bodyData = req.RequestBody } else { bodyData = map[string]any{} } - if req.UseFormData { - // Convert to form data - formData := url.Values{} - if bodyMap, ok := bodyData.(map[string]any); ok { - for key, value := range bodyMap { - switch v := value.(type) { - case string: - formData.Set(key, v) - case []string: - // For arrays, Proxmox expects multiple form fields with same name - for _, item := range v { - formData.Add(key, item) - } - case []any: - // Handle generic interface slice (convert to strings) - for _, item := range v { - if str, ok := item.(string); ok { - formData.Add(key, str) - } else { - formData.Add(key, fmt.Sprintf("%v", item)) - } - } - default: - // Convert to JSON string for complex types - jsonBytes, _ := json.Marshal(v) - formData.Set(key, string(jsonBytes)) - } - } - } - reqBody = strings.NewReader(formData.Encode()) - contentType = "application/x-www-form-urlencoded" - log.Printf("Form data: %s", formData.Encode()) - } else { - // Send as JSON - jsonData, err := json.Marshal(bodyData) - if err != nil { - return nil, fmt.Errorf("failed to marshal request body: %w", err) - } - reqBody = bytes.NewBuffer(jsonData) - contentType = "application/json" + jsonData, err := json.Marshal(bodyData) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) } + reqBody = bytes.NewBuffer(jsonData) } // Create the full URL @@ -109,9 +67,7 @@ func (prh *ProxmoxRequestHelper) MakeRequest(req ProxmoxAPIRequest) (json.RawMes // Set headers httpReq.Header.Add("Authorization", "PVEAPIToken="+prh.APIToken) - if req.Method == "POST" || req.Method == "PUT" { - httpReq.Header.Add("Content-Type", contentType) - } + httpReq.Header.Add("Content-Type", "application/json") // Execute the request resp, err := prh.HTTPClient.Do(httpReq) From 319f953a81924cb257ef3e0edc7dd683d8af1802 Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Sun, 7 Sep 2025 22:07:54 -0700 Subject: [PATCH 17/23] Switched back to linked clones and improved logging for WaitForDisk --- internal/cloning/cloning_service.go | 5 ++--- internal/cloning/types.go | 1 - internal/proxmox/types.go | 2 +- internal/proxmox/vms.go | 21 ++++++--------------- 4 files changed, 9 insertions(+), 20 deletions(-) diff --git a/internal/cloning/cloning_service.go b/internal/cloning/cloning_service.go index 3bd855f..887d3eb 100644 --- a/internal/cloning/cloning_service.go +++ b/internal/cloning/cloning_service.go @@ -66,7 +66,6 @@ func (cs *CloningService) CloneTemplate(template string, targetName string, isGr } // 2. Check if the template has already been cloned by the user - // Extract template name from pool name (remove kamino_template_ prefix) templateName := strings.TrimPrefix(template, "kamino_template_") @@ -130,14 +129,14 @@ func (cs *CloningService) CloneTemplate(template string, targetName string, isGr } } - newRouter, err := cs.ProxmoxService.CloneVM(*router, newPoolName, true) // Always use full clone for routers + newRouter, err := cs.ProxmoxService.CloneVM(*router, newPoolName) if err != nil { errors = append(errors, fmt.Sprintf("failed to clone router VM: %v", err)) } // Clone each VM to new pool for _, vm := range templateVMs { - _, err := cs.ProxmoxService.CloneVM(vm, newPoolName, cs.Config.UseFullClones) + _, err := cs.ProxmoxService.CloneVM(vm, newPoolName) if err != nil { errors = append(errors, fmt.Sprintf("failed to clone VM %s: %v", vm.Name, err)) } diff --git a/internal/cloning/types.go b/internal/cloning/types.go index b15ca48..8665d8e 100644 --- a/internal/cloning/types.go +++ b/internal/cloning/types.go @@ -22,7 +22,6 @@ type Config struct { WANScriptPath string `envconfig:"WAN_SCRIPT_PATH" default:"/home/update-wan-ip.sh"` VIPScriptPath string `envconfig:"VIP_SCRIPT_PATH" default:"/home/update-wan-vip.sh"` WANIPBase string `envconfig:"WAN_IP_BASE" default:"172.16."` - UseFullClones bool `envconfig:"USE_FULL_CLONES" default:"false"` } // KaminoTemplate represents a template in the system diff --git a/internal/proxmox/types.go b/internal/proxmox/types.go index d994d0d..9d65e06 100644 --- a/internal/proxmox/types.go +++ b/internal/proxmox/types.go @@ -42,7 +42,7 @@ type Service interface { StopVM(node string, vmID int) error DeleteVM(node string, vmID int) error ConvertVMToTemplate(node string, vmID int) error - CloneVM(sourceVM VM, newPoolName string, useFullClone bool) (*VM, error) + CloneVM(sourceVM VM, newPoolName string) (*VM, error) WaitForCloneCompletion(vm *VM, timeout time.Duration) error WaitForDisk(node string, vmid int, maxWait time.Duration) error WaitForRunning(vm VM) error diff --git a/internal/proxmox/vms.go b/internal/proxmox/vms.go index 201d057..68bcf5d 100644 --- a/internal/proxmox/vms.go +++ b/internal/proxmox/vms.go @@ -2,6 +2,7 @@ package proxmox import ( "fmt" + "log" "math" "strconv" "strings" @@ -76,7 +77,7 @@ func (s *ProxmoxService) ConvertVMToTemplate(node string, vmID int) error { return nil } -func (s *ProxmoxService) CloneVM(sourceVM VM, newPoolName string, useFullClone bool) (*VM, error) { +func (s *ProxmoxService) CloneVM(sourceVM VM, newPoolName string) (*VM, error) { // Get next available VMID req := tools.ProxmoxAPIRequest{ Method: "GET", @@ -99,18 +100,12 @@ func (s *ProxmoxService) CloneVM(sourceVM VM, newPoolName string, useFullClone b return nil, fmt.Errorf("failed to find best node: %w", err) } - // Determine clone type based on parameter - cloneType := 0 // Linked clone by default - if useFullClone { - cloneType = 1 // Full clone - } - // Clone VM cloneBody := map[string]any{ "newid": newVMID, "name": sourceVM.Name, "pool": newPoolName, - "full": cloneType, // Use configurable clone type + "full": 0, // Linked clone "target": bestNode, } @@ -181,18 +176,14 @@ func (s *ProxmoxService) WaitForDisk(node string, vmid int, maxWait time.Duratio time.Sleep(2 * time.Second) configResp, err := s.getVMConfig(node, vmid) + log.Printf("Disk check for VM %d on node %s: %+v (err: %v)", vmid, node, configResp, err) if err != nil { continue } if configResp.HardDisk != "" { - // Additional check: try to get VM status to ensure disk is actually accessible - _, statusErr := s.getVMStatus(node, vmid) - if statusErr == nil { - // Wait a bit more for linked clone dependencies to be fully ready - time.Sleep(5 * time.Second) - return nil // Disk is available and VM is accessible - } + log.Printf("Disk for VM %d on node %s is available", vmid, node) + return nil // Disk is available } } From df8d5b7a9d892d7095031915a181123a18fa3602 Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Sun, 7 Sep 2025 22:35:17 -0700 Subject: [PATCH 18/23] Check pending changes before attempting to start cloned router --- internal/proxmox/vms.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/proxmox/vms.go b/internal/proxmox/vms.go index 68bcf5d..b99b450 100644 --- a/internal/proxmox/vms.go +++ b/internal/proxmox/vms.go @@ -176,13 +176,23 @@ func (s *ProxmoxService) WaitForDisk(node string, vmid int, maxWait time.Duratio time.Sleep(2 * time.Second) configResp, err := s.getVMConfig(node, vmid) - log.Printf("Disk check for VM %d on node %s: %+v (err: %v)", vmid, node, configResp, err) if err != nil { continue } if configResp.HardDisk != "" { - log.Printf("Disk for VM %d on node %s is available", vmid, node) + pendingReq := tools.ProxmoxAPIRequest{ + Method: "GET", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/pending", node, vmid), + } + + pendingResponse, err := s.RequestHelper.MakeRequest(pendingReq) + log.Printf("Pending response for VMID %d on node %s: %s", vmid, node, string(pendingResponse)) + if err != nil && strings.Contains(err.Error(), "does not exist") { + log.Printf("Disk for VMID %d on node %s not ready yet: %v", vmid, node, err) + continue // Disk not synced yet + } + return nil // Disk is available } } From 17ba9ead7e02aa64d600f2574bd23f4305615be0 Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Sun, 7 Sep 2025 23:10:19 -0700 Subject: [PATCH 19/23] Added realm sync requests where needed --- internal/api/handlers/auth_handler.go | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/internal/api/handlers/auth_handler.go b/internal/api/handlers/auth_handler.go index d22f299..3a15003 100644 --- a/internal/api/handlers/auth_handler.go +++ b/internal/api/handlers/auth_handler.go @@ -266,6 +266,13 @@ func (h *AuthHandler) EnableUsersHandler(c *gin.Context) { return } + // Sync users to Proxmox + if err := h.proxmoxService.SyncUsers(); err != nil { + log.Printf("Failed to sync users with Proxmox: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync users with Proxmox", "details": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Users enabled successfully"}) } @@ -290,6 +297,13 @@ func (h *AuthHandler) DisableUsersHandler(c *gin.Context) { return } + // Sync users to Proxmox + if err := h.proxmoxService.SyncUsers(); err != nil { + log.Printf("Failed to sync users with Proxmox: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync users with Proxmox", "details": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Users disabled successfully"}) } @@ -310,6 +324,13 @@ func (h *AuthHandler) SetUserGroupsHandler(c *gin.Context) { return } + // Sync groups to Proxmox + if err := h.proxmoxService.SyncGroups(); err != nil { + log.Printf("Failed to sync groups with Proxmox: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync groups with Proxmox", "details": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "User groups updated successfully"}) } @@ -371,6 +392,13 @@ func (h *AuthHandler) RenameGroupHandler(c *gin.Context) { return } + // Sync groups to Proxmox + if err := h.proxmoxService.SyncGroups(); err != nil { + log.Printf("Failed to sync groups with Proxmox: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync groups with Proxmox", "details": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Group renamed successfully"}) } @@ -417,6 +445,13 @@ func (h *AuthHandler) AddUsersHandler(c *gin.Context) { return } + // Sync groups to Proxmox + if err := h.proxmoxService.SyncGroups(); err != nil { + log.Printf("Failed to sync groups with Proxmox: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync groups with Proxmox", "details": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Users added to group successfully"}) } @@ -432,5 +467,12 @@ func (h *AuthHandler) RemoveUsersHandler(c *gin.Context) { return } + // Sync groups to Proxmox + if err := h.proxmoxService.SyncGroups(); err != nil { + log.Printf("Failed to sync groups with Proxmox: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync groups with Proxmox", "details": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Users removed from group successfully"}) } From 91fb68ddf755f0e754d1138c4a77587989086507 Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Mon, 8 Sep 2025 17:24:12 -0700 Subject: [PATCH 20/23] Optimized cloning and improved middleware --- cmd/api/main.go | 4 + internal/api/handlers/cloning_handler.go | 58 +++-- internal/api/middleware/authorization.go | 17 ++ internal/cloning/cloning_service.go | 291 ++++++++++++++++------- internal/cloning/networking.go | 24 +- internal/cloning/templates.go | 6 +- internal/cloning/types.go | 25 +- internal/proxmox/pools.go | 47 ++++ internal/proxmox/types.go | 13 +- internal/proxmox/vms.go | 48 ++++ 10 files changed, 416 insertions(+), 117 deletions(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index 4acba32..f7a47a5 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -4,6 +4,7 @@ import ( "log" "github.com/cpp-cyber/proclone/internal/api/handlers" + "github.com/cpp-cyber/proclone/internal/api/middleware" "github.com/cpp-cyber/proclone/internal/api/routes" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" @@ -17,6 +18,7 @@ import ( type Config struct { Port string `envconfig:"PORT" default:":8080"` SessionSecret string `envconfig:"SESSION_SECRET" default:"default-secret-key"` + FrontendURL string `envconfig:"FRONTEND_URL" default:"http://localhost:3000"` } // init the environment @@ -41,6 +43,8 @@ func main() { log.Printf("Starting server on port %s", config.Port) r := gin.Default() + r.Use(middleware.CORSMiddleware(config.FrontendURL)) + r.MaxMultipartMemory = 8 << 20 // 8MiB // Setup session middleware store := cookie.NewStore([]byte(config.SessionSecret)) diff --git a/internal/api/handlers/cloning_handler.go b/internal/api/handlers/cloning_handler.go index 8089f28..b5366e8 100644 --- a/internal/api/handlers/cloning_handler.go +++ b/internal/api/handlers/cloning_handler.go @@ -60,10 +60,19 @@ func (ch *CloningHandler) CloneTemplateHandler(c *gin.Context) { log.Printf("User %s requested cloning of template %s", username, req.Template) - // Construct the full template pool name - templatePoolName := "kamino_template_" + req.Template + // Create the cloning request using the new format + cloneReq := cloning.CloneRequest{ + Template: req.Template, + CheckExistingDeployments: true, // Check for existing deployments for single user clones + Targets: []cloning.CloneTarget{ + { + Name: username, + IsGroup: false, + }, + }, + } - if err := ch.Service.CloneTemplate(templatePoolName, username, false); err != nil { + if err := ch.Service.CloneTemplate(cloneReq); err != nil { log.Printf("Error cloning template: %v", err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to clone template", @@ -88,32 +97,39 @@ func (ch *CloningHandler) AdminCloneTemplateHandler(c *gin.Context) { log.Printf("%s requested bulk cloning of template %s", username, req.Template) - // Construct the full template pool name - templatePoolName := "kamino_template_" + req.Template + // Build targets slice from usernames and groups + var targets []cloning.CloneTarget - // Clone for users - var errors []error - for _, username := range req.Usernames { - err := ch.Service.CloneTemplate(templatePoolName, username, false) - if err != nil { - errors = append(errors, fmt.Errorf("failed to clone template for user %s: %v", username, err)) - } + // Add users as targets + for _, user := range req.Usernames { + targets = append(targets, cloning.CloneTarget{ + Name: user, + IsGroup: false, + }) } - // Clone for groups + // Add groups as targets for _, group := range req.Groups { - err := ch.Service.CloneTemplate(templatePoolName, group, true) - if err != nil { - errors = append(errors, fmt.Errorf("failed to clone template for group %s: %v", group, err)) - } + targets = append(targets, cloning.CloneTarget{ + Name: group, + IsGroup: true, + }) } - // Check for errors - if len(errors) > 0 { - log.Printf("Admin %s encountered errors while cloning templates: %v", username, errors) + // Create clone request + cloneReq := cloning.CloneRequest{ + Template: req.Template, + Targets: targets, + CheckExistingDeployments: false, + } + + // Perform clone operation + err := ch.Service.CloneTemplate(cloneReq) + if err != nil { + log.Printf("Admin %s encountered error while bulk cloning template: %v", username, err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "Failed to clone templates", - "details": errors, + "details": err.Error(), }) return } diff --git a/internal/api/middleware/authorization.go b/internal/api/middleware/authorization.go index 2315095..e173d39 100644 --- a/internal/api/middleware/authorization.go +++ b/internal/api/middleware/authorization.go @@ -60,3 +60,20 @@ func Logout(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "Successfully logged out!"}) } + +func CORSMiddleware(fqdn string) gin.HandlerFunc { + return func(c *gin.Context) { + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.Header().Set("Access-Control-Allow-Origin", fqdn) + c.Writer.Header().Set("Access-Control-Max-Age", "86400") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, Origin") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(200) + } + + c.Next() + } +} diff --git a/internal/cloning/cloning_service.go b/internal/cloning/cloning_service.go index 887d3eb..9073004 100644 --- a/internal/cloning/cloning_service.go +++ b/internal/cloning/cloning_service.go @@ -3,6 +3,7 @@ package cloning import ( "database/sql" "fmt" + "log" "os" "strings" "time" @@ -56,27 +57,29 @@ func NewCloningService(proxmoxService proxmox.Service, db *sql.DB, ldapService l }, nil } -func (cs *CloningService) CloneTemplate(template string, targetName string, isGroup bool) error { +func (cs *CloningService) CloneTemplate(req CloneRequest) error { var errors []string + var createdPools []string + var clonedRouters []RouterInfo // 1. Get the template pool and its VMs - templatePool, err := cs.ProxmoxService.GetPoolVMs(template) + templatePool, err := cs.ProxmoxService.GetPoolVMs("kamino_template_" + req.Template) if err != nil { return fmt.Errorf("failed to get template pool: %w", err) } - // 2. Check if the template has already been cloned by the user - // Extract template name from pool name (remove kamino_template_ prefix) - templateName := strings.TrimPrefix(template, "kamino_template_") - - targetPoolName := fmt.Sprintf("%s_%s", templateName, targetName) - isDeployed, err := cs.IsDeployed(targetPoolName) - if err != nil { - return fmt.Errorf("failed to check if template is deployed: %w", err) - } - - if isDeployed { - return fmt.Errorf("template %s is already or in the process of being deployed %s", template, targetName) + // 2. Check if any template is already deployed (if requested) + if req.CheckExistingDeployments { + for _, target := range req.Targets { + targetPoolName := fmt.Sprintf("%s_%s", req.Template, target.Name) + isDeployed, err := cs.IsDeployed(targetPoolName) + if err != nil { + return fmt.Errorf("failed to check if template is deployed for %s: %w", target.Name, err) + } + if isDeployed { + return fmt.Errorf("template %s is already or in the process of being deployed for %s", req.Template, target.Name) + } + } } // 3. Identify router and other VMs @@ -101,25 +104,6 @@ func (cs *CloningService) CloneTemplate(template string, targetName string, isGr } } - // 4. Verify that the pool is not empty - if len(templateVMs) == 0 { - return fmt.Errorf("template pool %s contains no VMs", template) - } - - // 5. Get the next available pod ID and create new pool - newPodID, newPodNumber, err := cs.ProxmoxService.GetNextPodID(cs.Config.MinPodID, cs.Config.MaxPodID) - if err != nil { - return fmt.Errorf("failed to get next pod ID: %w", err) - } - - newPoolName := fmt.Sprintf("%s_%s_%s", newPodID, templateName, targetName) - - err = cs.ProxmoxService.CreateNewPool(newPoolName) - if err != nil { - return fmt.Errorf("failed to create new pool: %w", err) - } - - // 6. Clone the router and all VMs // If no router was found in the template, use the default router template if router == nil { router = &proxmox.VM{ @@ -129,78 +113,199 @@ func (cs *CloningService) CloneTemplate(template string, targetName string, isGr } } - newRouter, err := cs.ProxmoxService.CloneVM(*router, newPoolName) + // 4. Verify that the pool is not empty + if len(templateVMs) == 0 { + return fmt.Errorf("template pool %s contains no VMs", req.Template) + } + + // 5. Get pod IDs, Numbers, and VMIDs and assign them to targets + numVMsPerTarget := len(templateVMs) + 1 // +1 for router + log.Printf("Number of VMs per target (including router): %d", numVMsPerTarget) + + podIDs, podNumbers, err := cs.ProxmoxService.GetNextPodIDs(cs.Config.MinPodID, cs.Config.MaxPodID, len(req.Targets)) if err != nil { - errors = append(errors, fmt.Sprintf("failed to clone router VM: %v", err)) + return fmt.Errorf("failed to get next pod IDs: %w", err) } - // Clone each VM to new pool - for _, vm := range templateVMs { - _, err := cs.ProxmoxService.CloneVM(vm, newPoolName) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to clone VM %s: %v", vm.Name, err)) - } + vmIDs, err := cs.ProxmoxService.GetNextVMIDs(len(req.Targets) * numVMsPerTarget) + if err != nil { + return fmt.Errorf("failed to get next VM IDs: %w", err) } - var vnetName = fmt.Sprintf("kamino%d", newPodNumber) + for i := range req.Targets { + req.Targets[i].PoolName = fmt.Sprintf("%s_%s_%s", podIDs[i], req.Template, req.Targets[i].Name) + req.Targets[i].PodID = podIDs[i] + req.Targets[i].PodNumber = podNumbers[i] + req.Targets[i].VMIDs = vmIDs[i*(numVMsPerTarget) : (i+1)*(numVMsPerTarget)] - // 7. Configure VNet of all VMs - err = cs.SetPodVnet(newPoolName, vnetName) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to update pod vnet: %v", err)) + log.Printf("Target %s: PodID=%s, PodNumber=%d, VMIDs=%v", + req.Targets[i].Name, req.Targets[i].PodID, req.Targets[i].PodNumber, req.Targets[i].VMIDs) } - // 8. Turn on Router - if newRouter != nil { - err = cs.ProxmoxService.WaitForDisk(newRouter.Node, newRouter.VMID, cs.Config.RouterWaitTimeout) + // 6. Create new pool for each target + for _, target := range req.Targets { + err = cs.ProxmoxService.CreateNewPool(target.PoolName) if err != nil { - errors = append(errors, fmt.Sprintf("router disk unavailable: %v", err)) + cs.cleanupFailedClones(createdPools) + return fmt.Errorf("failed to create new pool for %s: %w", target.Name, err) } + createdPools = append(createdPools, target.PoolName) + } - err = cs.ProxmoxService.StartVM(newRouter.Node, newRouter.VMID) + // 7. Clone targets to proxmox + for _, target := range req.Targets { + // Find best node per target + bestNode, err := cs.ProxmoxService.FindBestNode() if err != nil { - errors = append(errors, fmt.Sprintf("failed to start router VM: %v", err)) + errors = append(errors, fmt.Sprintf("failed to find best node for %s: %v", target.Name, err)) + continue } - // 9. Wait for router to be running - err = cs.ProxmoxService.WaitForRunning(*newRouter) + // Clone router + routerCloneReq := proxmox.VMCloneRequest{ + SourceVM: *router, + PoolName: target.PoolName, + PodID: target.PodID, + NewVMID: target.VMIDs[0], + TargetNode: bestNode, + } + err = cs.ProxmoxService.CloneVMWithConfig(routerCloneReq) if err != nil { - errors = append(errors, fmt.Sprintf("failed to start router VM: %v", err)) + errors = append(errors, fmt.Sprintf("failed to clone router VM for %s: %v", target.Name, err)) } else { - err = cs.configurePodRouter(newPodNumber, newRouter.Node, newRouter.VMID) + // Store router info for later operations + clonedRouters = append(clonedRouters, RouterInfo{ + TargetName: target.Name, + PodNumber: target.PodNumber, + Node: bestNode, + VMID: target.VMIDs[0], + }) + } + + // Clone each VM to new pool + for i, vm := range templateVMs { + vmCloneReq := proxmox.VMCloneRequest{ + SourceVM: vm, + PoolName: target.PoolName, + PodID: target.PodID, + NewVMID: target.VMIDs[i+1], + TargetNode: bestNode, + } + err := cs.ProxmoxService.CloneVMWithConfig(vmCloneReq) if err != nil { - errors = append(errors, fmt.Sprintf("failed to configure pod router: %v", err)) + errors = append(errors, fmt.Sprintf("failed to clone VM %s for %s: %v", vm.Name, target.Name, err)) } } } - // 10. Set permissions on the pool to the user/group - err = cs.ProxmoxService.SetPoolPermission(newPoolName, targetName, isGroup) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to update pool permissions for %s: %v", targetName, err)) + // 8. Wait for all VM clone operations to complete before configuring VNets + log.Printf("Waiting for clone operations to complete for %d targets", len(req.Targets)) + for _, target := range req.Targets { + // Wait for all VMs in the pool to be properly cloned + log.Printf("Waiting for VMs in pool %s to be available", target.PoolName) + time.Sleep(2 * time.Second) + + // Check if pool has the expected number of VMs + for retries := range 30 { + poolVMs, err := cs.ProxmoxService.GetPoolVMs(target.PoolName) + if err != nil { + time.Sleep(2 * time.Second) + continue + } + + if len(poolVMs) >= numVMsPerTarget { + log.Printf("Pool %s has %d VMs (expected %d) - clone operations complete", target.PoolName, len(poolVMs), numVMsPerTarget) + break + } + + log.Printf("Pool %s has %d VMs, waiting for %d (retry %d/30)", target.PoolName, len(poolVMs), numVMsPerTarget, retries+1) + time.Sleep(2 * time.Second) + } } - // 11. Add a +1 to the total deployments in the templates database - err = cs.DatabaseService.AddDeployment(templateName) - if err != nil { - errors = append(errors, fmt.Sprintf("failed to increment template deployments for %s: %v", templateName, err)) + // 9. Configure VNet of all VMs + log.Printf("Configuring VNets for %d targets", len(req.Targets)) + for _, target := range req.Targets { + vnetName := fmt.Sprintf("kamino%d", target.PodNumber) + log.Printf("Setting VNet %s for pool %s (target: %s)", vnetName, target.PoolName, target.Name) + err = cs.SetPodVnet(target.PoolName, vnetName) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to update pod vnet for %s: %v", target.Name, err)) + } } - // If there were any errors, clean up if necessary - if len(errors) > 0 { + // 10. Start all routers and wait for them to be running + log.Printf("Starting %d routers", len(clonedRouters)) + for _, routerInfo := range clonedRouters { + // Wait for router disk to be available + log.Printf("Waiting for router disk to be available for %s (VMID: %d)", routerInfo.TargetName, routerInfo.VMID) + err = cs.ProxmoxService.WaitForDisk(routerInfo.Node, routerInfo.VMID, cs.Config.RouterWaitTimeout) + if err != nil { + errors = append(errors, fmt.Sprintf("router disk unavailable for %s: %v", routerInfo.TargetName, err)) + continue + } - // Check if any VMs were successfully cloned - clonedVMs, checkErr := cs.ProxmoxService.GetPoolVMs(newPoolName) - if checkErr != nil { + // Start the router + log.Printf("Starting router VM for %s (VMID: %d)", routerInfo.TargetName, routerInfo.VMID) + err = cs.ProxmoxService.StartVM(routerInfo.Node, routerInfo.VMID) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to start router VM for %s: %v", routerInfo.TargetName, err)) + continue } - if len(clonedVMs) == 0 { - deleteErr := cs.ProxmoxService.DeletePool(newPoolName) - if deleteErr != nil { - } + // Wait for router to be running + routerVM := proxmox.VM{ + Node: routerInfo.Node, + VMID: routerInfo.VMID, + } + log.Printf("Waiting for router VM to be running for %s (VMID: %d)", routerInfo.TargetName, routerInfo.VMID) + err = cs.ProxmoxService.WaitForRunning(routerVM) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to start router VM for %s: %v", routerInfo.TargetName, err)) + } + } + + // 11. Configure all pod routers (separate step after all routers are running) + log.Printf("Configuring %d pod routers", len(clonedRouters)) + for _, routerInfo := range clonedRouters { + // Only configure routers that successfully started + routerVM := proxmox.VM{ + Node: routerInfo.Node, + VMID: routerInfo.VMID, + } + + // Double-check that router is still running before configuration + err = cs.ProxmoxService.WaitForRunning(routerVM) + if err != nil { + errors = append(errors, fmt.Sprintf("router not running before configuration for %s: %v", routerInfo.TargetName, err)) + continue } - return fmt.Errorf("clone operation completed with errors: %v", errors) + log.Printf("Configuring pod router for %s (Pod: %d, VMID: %d)", routerInfo.TargetName, routerInfo.PodNumber, routerInfo.VMID) + err = cs.configurePodRouter(routerInfo.PodNumber, routerInfo.Node, routerInfo.VMID) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to configure pod router for %s: %v", routerInfo.TargetName, err)) + } + } + + // 12. Set permissions on the pool to the user/group + for _, target := range req.Targets { + err = cs.ProxmoxService.SetPoolPermission(target.PoolName, target.Name, target.IsGroup) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to update pool permissions for %s: %v", target.Name, err)) + } + } + + // 13. Add deployments to the templates database + err = cs.DatabaseService.AddDeployment(req.Template, len(req.Targets)) + if err != nil { + errors = append(errors, fmt.Sprintf("failed to increment template deployments for %s: %v", req.Template, err)) + } + + // Handle errors and cleanup if necessary + if len(errors) > 0 { + cs.cleanupFailedClones(createdPools) + return fmt.Errorf("bulk clone operation completed with errors: %v", errors) } return nil @@ -215,11 +320,10 @@ func (cs *CloningService) DeletePod(pod string) error { } if isEmpty { - err := cs.ProxmoxService.DeletePool(pod) - if err != nil { - } else { + if err := cs.ProxmoxService.DeletePool(pod); err != nil { + return fmt.Errorf("failed to delete empty pool %s: %w", pod, err) } - return err + return nil } // 2. Get all virtual machines in the pool @@ -254,15 +358,12 @@ func (cs *CloningService) DeletePod(pod string) error { } // Wait for all previously running VMs to be stopped - for _, vm := range runningVMs { - err := cs.ProxmoxService.WaitForStopped(vm) - if err != nil { - // Continue with deletion even if we can't confirm the VM is stopped - } else { - } - } - if len(runningVMs) > 0 { + for _, vm := range runningVMs { + if err := cs.ProxmoxService.WaitForStopped(vm); err != nil { + // Continue with deletion even if we can't confirm the VM is stopped + } + } } // 4. Delete all VMs @@ -282,7 +383,6 @@ func (cs *CloningService) DeletePod(pod string) error { err = cs.ProxmoxService.WaitForPoolEmpty(pod, 5*time.Minute) if err != nil { // Continue with pool deletion even if we can't confirm all VMs are gone - } else { } // 6. Delete the pool @@ -293,3 +393,18 @@ func (cs *CloningService) DeletePod(pod string) error { return nil } + +func (cs *CloningService) cleanupFailedClones(createdPools []string) { + for _, poolName := range createdPools { + // Check if pool has any VMs + poolVMs, err := cs.ProxmoxService.GetPoolVMs(poolName) + if err != nil { + continue // Skip if we can't check + } + + // If pool is empty, delete it + if len(poolVMs) == 0 { + _ = cs.ProxmoxService.DeletePool(poolName) + } + } +} diff --git a/internal/cloning/networking.go b/internal/cloning/networking.go index caf3a2a..0cc56f3 100644 --- a/internal/cloning/networking.go +++ b/internal/cloning/networking.go @@ -2,6 +2,7 @@ package cloning import ( "fmt" + "log" "math" "regexp" "time" @@ -81,10 +82,17 @@ func (cs *CloningService) SetPodVnet(poolName string, vnetName string) error { // Get all VMs in the pool vms, err := cs.ProxmoxService.GetPoolVMs(poolName) if err != nil { - return fmt.Errorf("failed to get pool VMs: %w", err) + return fmt.Errorf("failed to get pool VMs for pool %s: %w", poolName, err) } + if len(vms) == 0 { + return fmt.Errorf("pool %s contains no VMs", poolName) + } + + log.Printf("Setting VNet %s for %d VMs in pool %s", vnetName, len(vms), poolName) + routerRegex := regexp.MustCompile(`(?i).*(router|pfsense).*`) + var errors []string for _, vm := range vms { vnet := "net0" @@ -92,6 +100,9 @@ func (cs *CloningService) SetPodVnet(poolName string, vnetName string) error { // Detect if VM is a router based on its name (lazy way but requires fewer API calls) if routerRegex.MatchString(vm.Name) { vnet = "net1" + log.Printf("Detected router VM %s (VMID: %d), using %s interface", vm.Name, vm.VmId, vnet) + } else { + log.Printf("Setting VNet for VM %s (VMID: %d), using %s interface", vm.Name, vm.VmId, vnet) } // Update VM network configuration @@ -107,9 +118,18 @@ func (cs *CloningService) SetPodVnet(poolName string, vnetName string) error { _, err := cs.ProxmoxService.GetRequestHelper().MakeRequest(req) if err != nil { - return fmt.Errorf("failed to update network for VM %d: %w", vm.VmId, err) + errorMsg := fmt.Sprintf("failed to update network for VM %s (VMID: %d): %v", vm.Name, vm.VmId, err) + log.Printf("ERROR: %s", errorMsg) + errors = append(errors, errorMsg) + } else { + log.Printf("Successfully updated VNet for VM %s (VMID: %d) to %s", vm.Name, vm.VmId, vnetName) } } + if len(errors) > 0 { + return fmt.Errorf("VNet configuration completed with errors: %v", errors) + } + + log.Printf("Successfully configured VNet %s for all %d VMs in pool %s", vnetName, len(vms), poolName) return nil } diff --git a/internal/cloning/templates.go b/internal/cloning/templates.go index 992323f..c718bc0 100644 --- a/internal/cloning/templates.go +++ b/internal/cloning/templates.go @@ -119,9 +119,9 @@ func (c *TemplateClient) UpdateTemplate(template KaminoTemplate) error { return nil } -func (c *TemplateClient) AddDeployment(templateName string) error { - query := "UPDATE templates SET deployments = deployments + 1 WHERE name = ?" - _, err := c.DB.Exec(query, templateName) +func (c *TemplateClient) AddDeployment(templateName string, num int) error { + query := "UPDATE templates SET deployments = deployments + ? WHERE name = ?" + _, err := c.DB.Exec(query, num, templateName) if err != nil { return fmt.Errorf("failed to execute query: %w", err) } diff --git a/internal/cloning/types.go b/internal/cloning/types.go index 8665d8e..84569d5 100644 --- a/internal/cloning/types.go +++ b/internal/cloning/types.go @@ -47,7 +47,7 @@ type DatabaseService interface { UploadTemplateImage(c *gin.Context) (*UploadResult, error) GetTemplateConfig() *TemplateConfig GetTemplateInfo(templateName string) (KaminoTemplate, error) - AddDeployment(templateName string) error + AddDeployment(templateName string, num int) error UpdateTemplate(template KaminoTemplate) error GetAllTemplateNames() ([]string, error) DeleteImage(imagePath string) error @@ -97,3 +97,26 @@ var allowedMIMEs = map[string]struct{}{ "image/jpeg": {}, "image/png": {}, } + +type CloneTarget struct { + Name string + IsGroup bool + Node string + PoolName string + PodID string + PodNumber int + VMIDs []int +} + +type CloneRequest struct { + Template string + Targets []CloneTarget + CheckExistingDeployments bool // Whether to check if templates are already deployed +} + +type RouterInfo struct { + TargetName string + PodNumber int + Node string + VMID int +} diff --git a/internal/proxmox/pools.go b/internal/proxmox/pools.go index 0322eee..dee9ffc 100644 --- a/internal/proxmox/pools.go +++ b/internal/proxmox/pools.go @@ -202,3 +202,50 @@ func (s *ProxmoxService) GetNextPodID(minPodID int, maxPodID int) (string, int, return "", 0, fmt.Errorf("no available pod IDs in range 1000-1255") } + +func (s *ProxmoxService) GetNextPodIDs(minPodID int, maxPodID int, num int) ([]string, []int, error) { + // Get all existing pools + req := tools.ProxmoxAPIRequest{ + Method: "GET", + Endpoint: "/pools", + } + + var poolsResponse []struct { + PoolID string `json:"poolid"` + } + if err := s.RequestHelper.MakeRequestAndUnmarshal(req, &poolsResponse); err != nil { + return nil, nil, fmt.Errorf("failed to get existing pools: %w", err) + } + + // Extract pod IDs from existing pools + var usedIDs []int + for _, pool := range poolsResponse { + if len(pool.PoolID) >= 4 { + if id, err := strconv.Atoi(pool.PoolID[:4]); err == nil { + if id >= minPodID && id <= maxPodID { + usedIDs = append(usedIDs, id) + } + } + } + } + + sort.Ints(usedIDs) + + // Find available IDs + var podIDs []string + var adjustedIDs []int + + for i := minPodID; i <= maxPodID && len(podIDs) < num; i++ { + found := slices.Contains(usedIDs, i) + if !found { + podIDs = append(podIDs, fmt.Sprintf("%04d", i)) + adjustedIDs = append(adjustedIDs, i-1000) + } + } + + if len(podIDs) < num { + return nil, nil, fmt.Errorf("only found %d available pod IDs out of %d requested in range %d-%d", len(podIDs), num, minPodID, maxPodID) + } + + return podIDs, adjustedIDs, nil +} diff --git a/internal/proxmox/types.go b/internal/proxmox/types.go index 9d65e06..76f4967 100644 --- a/internal/proxmox/types.go +++ b/internal/proxmox/types.go @@ -32,17 +32,18 @@ type Service interface { SyncGroups() error // Pod Management - GetNextPodID(minPodID int, maxPodID int) (string, int, error) + GetNextPodIDs(minPodID int, maxPodID int, num int) ([]string, []int, error) // VM Management GetVMs() ([]VirtualResource, error) + GetNextVMIDs(num int) ([]int, error) StartVM(node string, vmID int) error ShutdownVM(node string, vmID int) error RebootVM(node string, vmID int) error StopVM(node string, vmID int) error DeleteVM(node string, vmID int) error ConvertVMToTemplate(node string, vmID int) error - CloneVM(sourceVM VM, newPoolName string) (*VM, error) + CloneVMWithConfig(req VMCloneRequest) error WaitForCloneCompletion(vm *VM, timeout time.Duration) error WaitForDisk(node string, vmid int, maxWait time.Duration) error WaitForRunning(vm VM) error @@ -106,6 +107,14 @@ type VM struct { VMID int `json:"vmid"` } +type VMCloneRequest struct { + SourceVM VM + PoolName string + PodID string + NewVMID int + TargetNode string +} + type VirtualResource struct { CPU float64 `json:"cpu,omitempty"` MaxCPU int `json:"maxcpu,omitempty"` diff --git a/internal/proxmox/vms.go b/internal/proxmox/vms.go index b99b450..1f84a69 100644 --- a/internal/proxmox/vms.go +++ b/internal/proxmox/vms.go @@ -134,6 +134,30 @@ func (s *ProxmoxService) CloneVM(sourceVM VM, newPoolName string) (*VM, error) { return newVM, nil } +func (s *ProxmoxService) CloneVMWithConfig(req VMCloneRequest) error { + // Clone VM + cloneBody := map[string]any{ + "newid": req.NewVMID, + "name": req.SourceVM.Name, + "pool": req.PoolName, + "full": 0, // Linked clone + "target": req.TargetNode, + } + + cloneReq := tools.ProxmoxAPIRequest{ + Method: "POST", + Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/clone", req.SourceVM.Node, req.SourceVM.VMID), + RequestBody: cloneBody, + } + + _, err := s.RequestHelper.MakeRequest(cloneReq) + if err != nil { + return fmt.Errorf("failed to initiate VM clone: %w", err) + } + + return nil +} + func (s *ProxmoxService) WaitForCloneCompletion(vm *VM, timeout time.Duration) error { start := time.Now() backoff := time.Second @@ -208,6 +232,30 @@ func (s *ProxmoxService) WaitForRunning(vm VM) error { return s.waitForStatus("running", vm) } +func (s *ProxmoxService) GetNextVMIDs(num int) ([]int, error) { + // Get VMs + resources, err := s.GetClusterResources("type=vm") + if err != nil { + return nil, fmt.Errorf("failed to get cluster resources: %w", err) + } + + // Iterate thought and find the highest VMID under 4000 + highestID := 100 + for _, res := range resources { + if res.VmId > highestID && res.VmId < 4000 { + highestID = res.VmId + } + } + + // Generate the next num VMIDs + var vmIDs []int + for i := 1; i <= num; i++ { + vmIDs = append(vmIDs, highestID+i) + } + + return vmIDs, nil +} + // ================================================= // Private Functions // ================================================= From 2ea08931b6a3ee5ac27024ef1bafb9d107b3f07d Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Mon, 8 Sep 2025 20:58:19 -0700 Subject: [PATCH 21/23] Added ability to edit published template --- internal/api/handlers/cloning_handler.go | 26 +++++++++++++++ internal/api/routes/admin_routes.go | 1 + internal/cloning/templates.go | 40 +++++++++++++++++++++--- internal/cloning/types.go | 5 +-- 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/internal/api/handlers/cloning_handler.go b/internal/api/handlers/cloning_handler.go index b5366e8..f765213 100644 --- a/internal/api/handlers/cloning_handler.go +++ b/internal/api/handlers/cloning_handler.go @@ -338,6 +338,32 @@ func (ch *CloningHandler) PublishTemplateHandler(c *gin.Context) { }) } +// ADMIN: EditTemplateHandler handles POST requests for editing a published template +func (ch *CloningHandler) EditTemplateHandler(c *gin.Context) { + session := sessions.Default(c) + username := session.Get("id").(string) + + var req PublishTemplateRequest + if !validateAndBind(c, &req) { + return + } + + log.Printf("Admin %s requested editing of template %s", username, req.Template.Name) + + if err := ch.Service.DatabaseService.EditTemplate(req.Template); err != nil { + log.Printf("Error editing template for admin %s: %v", username, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to edit template", + "details": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Template edited successfully", + }) +} + // ADMIN: DeleteTemplateHandler handles POST requests for deleting a template func (ch *CloningHandler) DeleteTemplateHandler(c *gin.Context) { session := sessions.Default(c) diff --git a/internal/api/routes/admin_routes.go b/internal/api/routes/admin_routes.go index 721327c..1bb4eb7 100644 --- a/internal/api/routes/admin_routes.go +++ b/internal/api/routes/admin_routes.go @@ -36,6 +36,7 @@ func registerAdminRoutes(g *gin.RouterGroup, authHandler *handlers.AuthHandler, g.POST("/vm/reboot", proxmoxHandler.RebootVMHandler) g.POST("/pods/delete", cloningHandler.AdminDeletePodHandler) g.POST("/template/publish", cloningHandler.PublishTemplateHandler) + g.POST("/template/edit", cloningHandler.EditTemplateHandler) g.POST("/template/delete", cloningHandler.DeleteTemplateHandler) g.POST("/template/visibility", cloningHandler.ToggleTemplateVisibilityHandler) g.POST("/template/image/upload", cloningHandler.UploadTemplateImageHandler) diff --git a/internal/cloning/templates.go b/internal/cloning/templates.go index c718bc0..c895b2e 100644 --- a/internal/cloning/templates.go +++ b/internal/cloning/templates.go @@ -100,8 +100,8 @@ func (c *TemplateClient) GetAllTemplateNames() ([]string, error) { } func (c *TemplateClient) InsertTemplate(template KaminoTemplate) error { - query := "INSERT INTO templates (name, description, image_path, template_visible, vm_count) VALUES (?, ?, ?, ?, ?)" - _, err := c.DB.Exec(query, template.Name, template.Description, template.ImagePath, template.TemplateVisible, template.VMCount) + query := "INSERT INTO templates (name, description, image_path, authors, template_visible, vm_count) VALUES (?, ?, ?, ?, ?, ?)" + _, err := c.DB.Exec(query, template.Name, template.Description, template.ImagePath, template.Authors, template.TemplateVisible, template.VMCount) if err != nil { return fmt.Errorf("failed to execute query: %w", err) } @@ -109,9 +109,37 @@ func (c *TemplateClient) InsertTemplate(template KaminoTemplate) error { return nil } -func (c *TemplateClient) UpdateTemplate(template KaminoTemplate) error { - query := "UPDATE templates SET description = ?, image_path = ?, template_visible = ?, vm_count = ? WHERE name = ?" - _, err := c.DB.Exec(query, template.Description, template.ImagePath, template.TemplateVisible, template.VMCount, template.Name) +func (c *TemplateClient) EditTemplate(template KaminoTemplate) error { + setParts := []string{} + args := []any{} + + // Always update description + setParts = append(setParts, "description = ?") + args = append(args, template.Description) + + // Only update image_path if it's not empty + if template.ImagePath != "" { + setParts = append(setParts, "image_path = ?") + args = append(args, template.ImagePath) + } + + // Always update authors + setParts = append(setParts, "authors = ?") + args = append(args, template.Authors) + + // Always update vm_count + setParts = append(setParts, "vm_count = ?") + args = append(args, template.VMCount) + + // Always update template_visible + setParts = append(setParts, "template_visible = ?") + args = append(args, template.TemplateVisible) + + // Build and execute the query + query := fmt.Sprintf("UPDATE templates SET %s WHERE name = ?", strings.Join(setParts, ", ")) + args = append(args, template.Name) + + _, err := c.DB.Exec(query, args...) if err != nil { return fmt.Errorf("failed to execute query: %w", err) } @@ -138,6 +166,7 @@ func (c *TemplateClient) GetTemplateInfo(templateName string) (KaminoTemplate, e &template.Name, &template.Description, &template.ImagePath, + &template.Authors, &template.TemplateVisible, &template.PodVisible, &template.VMsVisible, @@ -298,6 +327,7 @@ func (c *TemplateClient) buildTemplates(rows *sql.Rows) ([]KaminoTemplate, error &template.Name, &template.Description, &template.ImagePath, + &template.Authors, &template.TemplateVisible, &template.PodVisible, &template.VMsVisible, diff --git a/internal/cloning/types.go b/internal/cloning/types.go index 84569d5..ae88cc7 100644 --- a/internal/cloning/types.go +++ b/internal/cloning/types.go @@ -27,8 +27,9 @@ type Config struct { // KaminoTemplate represents a template in the system type KaminoTemplate struct { Name string `json:"name" binding:"required,min=1,max=100" validate:"alphanum,ascii"` - Description string `json:"description" binding:"required,min=1,max=500"` + Description string `json:"description" binding:"required,min=1,max=5000"` ImagePath string `json:"image_path" binding:"omitempty,max=255" validate:"omitempty,file"` + Authors string `json:"authors" binding:"omitempty,max=255"` TemplateVisible bool `json:"template_visible"` PodVisible bool `json:"pod_visible"` VMsVisible bool `json:"vms_visible"` @@ -48,7 +49,7 @@ type DatabaseService interface { GetTemplateConfig() *TemplateConfig GetTemplateInfo(templateName string) (KaminoTemplate, error) AddDeployment(templateName string, num int) error - UpdateTemplate(template KaminoTemplate) error + EditTemplate(template KaminoTemplate) error GetAllTemplateNames() ([]string, error) DeleteImage(imagePath string) error } From f6323c86f6839c440690cfdb2a4be2c1dd803732 Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Mon, 8 Sep 2025 22:31:47 -0700 Subject: [PATCH 22/23] Changed endpoint and check for WaitForDisk --- internal/proxmox/types.go | 6 ++++++ internal/proxmox/vms.go | 25 ++++++++++++++++++------- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/internal/proxmox/types.go b/internal/proxmox/types.go index 76f4967..6fbcaec 100644 --- a/internal/proxmox/types.go +++ b/internal/proxmox/types.go @@ -17,6 +17,7 @@ type ProxmoxConfig struct { CriticalPool string `envconfig:"PROXMOX_CRITICAL_POOL"` Realm string `envconfig:"REALM"` NodesStr string `envconfig:"PROXMOX_NODES"` + StorageID string `envconfig:"STORAGE_ID" default:"local-lvm"` Nodes []string // Parsed from NodesStr APIToken string // Computed from TokenID and TokenSecret } @@ -152,3 +153,8 @@ type ClusterResourceUsageResponse struct { Nodes []NodeResourceUsage `json:"nodes"` Errors []string `json:"errors,omitempty"` } + +type PendingDiskResponse struct { + Used int64 `json:"used"` + Size int64 `json:"size"` +} diff --git a/internal/proxmox/vms.go b/internal/proxmox/vms.go index 1f84a69..a5322d9 100644 --- a/internal/proxmox/vms.go +++ b/internal/proxmox/vms.go @@ -207,17 +207,28 @@ func (s *ProxmoxService) WaitForDisk(node string, vmid int, maxWait time.Duratio if configResp.HardDisk != "" { pendingReq := tools.ProxmoxAPIRequest{ Method: "GET", - Endpoint: fmt.Sprintf("/nodes/%s/qemu/%d/pending", node, vmid), + Endpoint: fmt.Sprintf("/nodes/%s/storage/%s/content?vmid=%d", node, s.Config.StorageID, vmid), } - pendingResponse, err := s.RequestHelper.MakeRequest(pendingReq) - log.Printf("Pending response for VMID %d on node %s: %s", vmid, node, string(pendingResponse)) - if err != nil && strings.Contains(err.Error(), "does not exist") { - log.Printf("Disk for VMID %d on node %s not ready yet: %v", vmid, node, err) - continue // Disk not synced yet + var diskResponse []PendingDiskResponse + err := s.RequestHelper.MakeRequestAndUnmarshal(pendingReq, &diskResponse) + if err != nil || len(diskResponse) == 0 { + log.Printf("Error retrieving pending disk info for VMID %d on node %s: %v", vmid, node, err) + continue + } + + // Iterate through all disks, if all have valid Used and Size (not 0) consider available + allAvailable := true + for _, disk := range diskResponse { + if disk.Used == 0 || disk.Size == 0 { + allAvailable = false + break + } } - return nil // Disk is available + if allAvailable { + return nil // Disk is available + } } } From c078f3add15687ff6d1e2fc24c06dd83a829f2bb Mon Sep 17 00:00:00 2001 From: MaxwellCaron Date: Mon, 8 Sep 2025 23:09:09 -0700 Subject: [PATCH 23/23] Prepare for PR --- cmd/api/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index f7a47a5..413549c 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -31,8 +31,7 @@ func init() { } func main() { - // TODO: Set gin mode based on environment (development/production) - // gin.SetMode(gin.ReleaseMode) + gin.SetMode(gin.ReleaseMode) // Load and parse configuration from environment variables var config Config @@ -45,6 +44,7 @@ func main() { r := gin.Default() r.Use(middleware.CORSMiddleware(config.FrontendURL)) r.MaxMultipartMemory = 8 << 20 // 8MiB + r.SetTrustedProxies(nil) // Setup session middleware store := cookie.NewStore([]byte(config.SessionSecret))