diff --git a/.gitignore b/.gitignore index caf4c77..7f0c94a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,6 @@ public/node_modules public/http/css/tailwind.css public/tftp/pxelinux.cfg/* public/provision/configs/cloud-init/* -public/provision/configs/kickstart/* \ No newline at end of file +public/provision/configs/kickstart/* +public/provision/configs/preseed/* +public/provision/configs/autoyast/* \ No newline at end of file diff --git a/README.md b/README.md index bab53e1..2e8e754 100755 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ Designed for developers, system administrators, and hobbyists, Ignite provides a - Red Hat - **Redfish API Integration**: Remotely manage servers and initiate PXE boot processes via the Redfish API. - **Cloud-Init & Kickstart Templating**: Simplify automated OS installations with dynamic templating for cloud-init and Kickstart configurations. +- **Advanced File Management**: Template and configuration file browser with delete functionality and clear type distinction. +- **Enhanced Security**: Path validation, input sanitization, and secure file operations throughout the application. +- **Comprehensive Error Handling**: Detailed error messages, validation feedback, and graceful error recovery. ## Screenshots @@ -51,7 +54,7 @@ File browser and management interface for boot files, operating system images, a ![TFTP File Management](./public/http/img/tftp_page.png) ### **Provision Templates** -Cloud-init and Kickstart template editor with syntax highlighting and template management. +Cloud-init and Kickstart template editor with syntax highlighting, template management, file deletion, and clear distinction between templates and configs. ![Provision Templates](./public/http/img/provision_page.png) @@ -182,6 +185,8 @@ Ignite exposes a set of RESTful APIs to control and manage the server. | `/prov/getconfigs` | Retrieves configuration options. | | `/prov/loadconfig` | Loads a configuration file. | | `/prov/getfilename` | Updates the filename for a template. | +| `/provision/load-file` | Loads file content via API. | +| `/provision/gallery` | Retrieves template gallery items. | ### POST Routes @@ -201,6 +206,8 @@ Ignite exposes a set of RESTful APIs to control and manage the server. | `/pxe/submit_ipmi` | Submits an IPMI command. | | `/prov/newtemplate` | Creates a new provisioning template. | | `/prov/save` | Saves a provisioning file. | +| `/provision/save-file` | Saves file content via API. | +| `/provision/delete-file` | Deletes a file from provision directories. | ## Architecture @@ -303,13 +310,31 @@ go mod download go run . -mock-data # Start with sample data ``` -## To Do +## Recent Improvements ✅ -- Enhanced error handling and user feedback -- IP address validation for DHCP configurations -- TFTP directory path security improvements -- Interface standardization across services -- Additional OS template support +- ✅ **Enhanced error handling and user feedback** - Comprehensive error messages and validation throughout the application +- ✅ **IP address validation for DHCP configurations** - Input sanitization and validation for network configurations +- ✅ **TFTP directory path security improvements** - Path traversal protection and secure file operations +- ✅ **Interface standardization across services** - Consistent API patterns and dependency injection +- ✅ **Additional OS template support** - Extended template gallery with Docker, Kubernetes, and more +- ✅ **File management enhancements** - Delete functionality and template vs config distinction +- ✅ **Code optimization** - Removed unused code, optimized performance, fixed deprecated functions + +## Security Model + +Ignite uses a simple, network-focused security approach suitable for lab and infrastructure environments: + +- **Single Admin Authentication**: Shared login credentials (`admin/admin` by default, changeable via web interface) +- **Network-Level Security**: Designed for deployment on isolated management networks (VLANs, private networks) +- **Session-Based Access**: Simple session management with HTTP-only cookies +- **Path Security**: File operations restricted to designated directories with path traversal protection + +## Future Enhancements + +- **Advanced monitoring** - Metrics collection and alerting for system health +- **API versioning** - Backward compatibility and gradual migration support +- **Plugin architecture** - Extensible framework for custom integrations +- **Configuration templating** - Variable substitution and environment-specific configs ## Contributing @@ -345,4 +370,6 @@ This project is licensed under the MIT License. See the `LICENSE` file for detai ## Acknowledgments - Inspired by GoPXE. -- User interface built with Tailwind CSS and daisyUI. \ No newline at end of file +- User interface built with Tailwind CSS and daisyUI. +- Frontend help from Google Jules +- Backend help from Claude \ No newline at end of file diff --git a/app/static.go b/app/static.go deleted file mode 100644 index b9ceb2a..0000000 --- a/app/static.go +++ /dev/null @@ -1,36 +0,0 @@ -package app - -import ( - "embed" - "io/fs" - "net/http" -) - -// StaticFileSystem wraps embed.FS for serving static files -type StaticFileSystem struct { - embedFS embed.FS - prefix string -} - -// NewStaticFileSystem creates a new static file system -func NewStaticFileSystem(embedFS embed.FS, prefix string) *StaticFileSystem { - return &StaticFileSystem{ - embedFS: embedFS, - prefix: prefix, - } -} - -// Open implements fs.FS interface -func (sfs *StaticFileSystem) Open(name string) (fs.File, error) { - return sfs.embedFS.Open(sfs.prefix + "/" + name) -} - -// HTTPHandler returns an http.Handler for serving static files -func (sfs *StaticFileSystem) HTTPHandler() http.Handler { - return http.FileServer(http.FS(sfs)) -} - -// StripPrefix returns a handler that strips the prefix from the URL path -func (sfs *StaticFileSystem) StripPrefixHandler(prefix string) http.Handler { - return http.StripPrefix(prefix, sfs.HTTPHandler()) -} diff --git a/config/config.go b/config/config.go index 39c7584..91effa4 100755 --- a/config/config.go +++ b/config/config.go @@ -210,6 +210,67 @@ func getDefaultOSImageConfig() OSImageConfig { }, }, }, + "debian": { + DisplayName: "Debian", + KernelFile: "linux", + InitrdFile: "initrd.gz", + Versions: map[string]OSVersion{ + "11": { + DisplayName: "11 (Bullseye)", + BaseURL: "http://deb.debian.org/debian/dists/bullseye/main/installer-amd64/current/images/netboot/debian-installer/amd64/", + Architectures: []string{"x86_64"}, + }, + "12": { + DisplayName: "12 (Bookworm)", + BaseURL: "http://deb.debian.org/debian/dists/bookworm/main/installer-amd64/current/images/netboot/debian-installer/amd64/", + Architectures: []string{"x86_64"}, + }, + }, + }, + "opensuse": { + DisplayName: "openSUSE", + KernelFile: "linux", + InitrdFile: "initrd", + Versions: map[string]OSVersion{ + "15.5": { + DisplayName: "Leap 15.5", + BaseURL: "http://download.opensuse.org/distribution/leap/15.5/repo/oss/boot/x86_64/loader/", + Architectures: []string{"x86_64"}, + }, + "15.6": { + DisplayName: "Leap 15.6", + BaseURL: "http://download.opensuse.org/distribution/leap/15.6/repo/oss/boot/x86_64/loader/", + Architectures: []string{"x86_64"}, + }, + "tumbleweed": { + DisplayName: "Tumbleweed (Rolling)", + BaseURL: "http://download.opensuse.org/tumbleweed/repo/oss/boot/x86_64/loader/", + Architectures: []string{"x86_64"}, + }, + }, + }, + "fedora": { + DisplayName: "Fedora", + KernelFile: "vmlinuz", + InitrdFile: "initrd.img", + Versions: map[string]OSVersion{ + "39": { + DisplayName: "39", + BaseURL: "https://download.fedoraproject.org/pub/fedora/linux/releases/39/Everything/x86_64/os/images/pxeboot/", + Architectures: []string{"x86_64"}, + }, + "40": { + DisplayName: "40", + BaseURL: "https://download.fedoraproject.org/pub/fedora/linux/releases/40/Everything/x86_64/os/images/pxeboot/", + Architectures: []string{"x86_64"}, + }, + "41": { + DisplayName: "41", + BaseURL: "https://download.fedoraproject.org/pub/fedora/linux/releases/41/Everything/x86_64/os/images/pxeboot/", + Architectures: []string{"x86_64"}, + }, + }, + }, }, } } diff --git a/dhcp/models.go b/dhcp/models.go index 4e8d64b..fdd3b07 100644 --- a/dhcp/models.go +++ b/dhcp/models.go @@ -139,16 +139,17 @@ func (l *Lease) IsActive() bool { // BootMenu contains PXE boot configuration type BootMenu struct { - Filename string `json:"filename"` - OS string `json:"os"` - Version string `json:"version"` - TemplateType string `json:"template_type"` - TemplateName string `json:"template_name"` - Hostname string `json:"hostname"` - IP net.IP `json:"ip"` - Subnet net.IP `json:"subnet"` - Gateway net.IP `json:"gateway"` - DNS net.IP `json:"dns"` + Filename string `json:"filename"` + OS string `json:"os"` + Version string `json:"version"` + TemplateType string `json:"template_type"` + TemplateName string `json:"template_name"` + Hostname string `json:"hostname"` + IP net.IP `json:"ip"` + Subnet net.IP `json:"subnet"` + Gateway net.IP `json:"gateway"` + DNS net.IP `json:"dns"` + KernelOptions string `json:"kernel_options"` } // IPMI holds IPMI configuration for remote server management diff --git a/handlers/auth.go b/handlers/auth.go new file mode 100644 index 0000000..dbd016e --- /dev/null +++ b/handlers/auth.go @@ -0,0 +1,209 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + "time" +) + +// AuthHandlers handles authentication requests +type AuthHandlers struct { + container *Container +} + +// NewAuthHandlers creates a new AuthHandlers instance +func NewAuthHandlers(container *Container) *AuthHandlers { + return &AuthHandlers{container: container} +} + +// LoginPage serves the login page +func (h *AuthHandlers) LoginPage(w http.ResponseWriter, r *http.Request) { + templates := LoadTemplates() + + data := map[string]string{ + "Title": "Login - Ignite", + } + + if err := templates["login"].Execute(w, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// LoginRequest represents a login request +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// LoginResponse represents a login response +type LoginResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Token string `json:"token,omitempty"` +} + +// ChangePasswordRequest represents a password change request +type ChangePasswordRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + +// Default credentials - in production, these should be stored securely +var defaultUsername = "admin" +var defaultPassword = "admin" + +// Login handles login requests +func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(LoginResponse{ + Success: false, + Message: "Invalid request format", + }) + return + } + + // Simple authentication check + if req.Username == defaultUsername && req.Password == defaultPassword { + // Create a simple session token (in production, use proper JWT or session management) + token := generateSimpleToken(req.Username) + + // Set session cookie + http.SetCookie(w, &http.Cookie{ + Name: "ignite_session", + Value: token, + Expires: time.Now().Add(24 * time.Hour), + HttpOnly: true, + Path: "/", + }) + + json.NewEncoder(w).Encode(LoginResponse{ + Success: true, + Message: "Login successful", + Token: token, + }) + } else { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(LoginResponse{ + Success: false, + Message: "Invalid username or password", + }) + } +} + +// Logout handles logout requests +func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) { + // Clear the session cookie + http.SetCookie(w, &http.Cookie{ + Name: "ignite_session", + Value: "", + Expires: time.Now().Add(-time.Hour), + HttpOnly: true, + Path: "/", + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Logged out successfully", + }) +} + +// ChangePassword handles password change requests +func (h *AuthHandlers) ChangePassword(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // Check if user is authenticated + if !isAuthenticated(r) { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "Not authenticated", + }) + return + } + + var req ChangePasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "Invalid request format", + }) + return + } + + // Verify current password + if req.CurrentPassword != defaultPassword { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "message": "Current password is incorrect", + }) + return + } + + // Update password (in production, hash the password) + defaultPassword = req.NewPassword + + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Password changed successfully", + }) +} + +// generateSimpleToken creates a simple session token +func generateSimpleToken(username string) string { + // This is a very basic token generation for demo purposes + // In production, use proper JWT or secure session tokens + return username + "_" + time.Now().Format("20060102150405") +} + +// isAuthenticated checks if the request has a valid session +func isAuthenticated(r *http.Request) bool { + cookie, err := r.Cookie("ignite_session") + if err != nil { + return false + } + + // Basic token validation (in production, properly validate JWT or session) + return cookie.Value != "" && len(cookie.Value) > 0 +} + +// AuthMiddleware is a middleware that checks authentication +func AuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // List of public paths that don't require authentication + publicPaths := []string{ + "/login", + "/auth/login", + "/auth/logout", + } + + // Also allow static files + if strings.HasPrefix(r.URL.Path, "/public/") { + next.ServeHTTP(w, r) + return + } + + // Check if the current path is in the public paths + for _, path := range publicPaths { + if r.URL.Path == path { + next.ServeHTTP(w, r) + return + } + } + + // Check if user is authenticated + if !isAuthenticated(r) { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/handlers/auth_integration_test.go b/handlers/auth_integration_test.go new file mode 100644 index 0000000..5023f37 --- /dev/null +++ b/handlers/auth_integration_test.go @@ -0,0 +1,398 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCompleteLoginLogoutFlow(t *testing.T) { + // Reset to default credentials for test + defaultUsername = "admin" + defaultPassword = "admin" + + container := &Container{} + authHandlers := NewAuthHandlers(container) + + // Step 1: Try to access protected resource without authentication + req := httptest.NewRequest("GET", "/dhcp", nil) + w := httptest.NewRecorder() + + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("Protected Content")) + }) + + middlewareHandler := AuthMiddleware(testHandler) + middlewareHandler.ServeHTTP(w, req) + + if w.Code != http.StatusFound { + t.Errorf("Step 1: Expected redirect status %d, got %d", http.StatusFound, w.Code) + } + + location := w.Header().Get("Location") + if location != "/login" { + t.Errorf("Step 1: Expected redirect to /login, got %s", location) + } + + // Step 2: Login with valid credentials + loginReq := LoginRequest{ + Username: "admin", + Password: "admin", + } + + jsonData, _ := json.Marshal(loginReq) + req = httptest.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + + authHandlers.Login(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Step 2: Expected login status %d, got %d", http.StatusOK, w.Code) + } + + // Extract session cookie + cookies := w.Result().Cookies() + var sessionCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "ignite_session" { + sessionCookie = cookie + break + } + } + + if sessionCookie == nil { + t.Fatal("Step 2: Expected session cookie to be set") + } + + // Step 3: Access protected resource with session cookie + req = httptest.NewRequest("GET", "/dhcp", nil) + req.AddCookie(sessionCookie) + w = httptest.NewRecorder() + + middlewareHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Step 3: Expected access to protected resource status %d, got %d", http.StatusOK, w.Code) + } + + if w.Body.String() != "Protected Content" { + t.Errorf("Step 3: Expected protected content, got %s", w.Body.String()) + } + + // Step 4: Logout + req = httptest.NewRequest("POST", "/auth/logout", nil) + req.AddCookie(sessionCookie) + w = httptest.NewRecorder() + + authHandlers.Logout(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Step 4: Expected logout status %d, got %d", http.StatusOK, w.Code) + } + + // Step 5: Try to access protected resource after logout + req = httptest.NewRequest("GET", "/dhcp", nil) + req.AddCookie(sessionCookie) // Use old cookie which should now be invalid + w = httptest.NewRecorder() + + middlewareHandler.ServeHTTP(w, req) + + // The middleware should redirect to login since the old cookie is still present + // but the logout handler cleared it, so we need to use the new cleared cookie + logoutCookies := w.Result().Cookies() + var clearedCookie *http.Cookie + for _, cookie := range logoutCookies { + if cookie.Name == "ignite_session" { + clearedCookie = cookie + break + } + } + + // Test with cleared cookie + req = httptest.NewRequest("GET", "/dhcp", nil) + if clearedCookie != nil { + req.AddCookie(clearedCookie) + } + w = httptest.NewRecorder() + + middlewareHandler.ServeHTTP(w, req) + + if w.Code != http.StatusFound { + t.Errorf("Step 5: Expected redirect after logout status %d, got %d", http.StatusFound, w.Code) + } + + location = w.Header().Get("Location") + if location != "/login" { + t.Errorf("Step 5: Expected redirect to /login after logout, got %s", location) + } +} + +func TestCompletePasswordChangeFlow(t *testing.T) { + // Reset to default credentials for test + defaultUsername = "admin" + defaultPassword = "admin" + defer func() { + defaultPassword = "admin" // Reset after test + }() + + container := &Container{} + authHandlers := NewAuthHandlers(container) + + // Step 1: Login to get session + loginReq := LoginRequest{ + Username: "admin", + Password: "admin", + } + + jsonData, _ := json.Marshal(loginReq) + req := httptest.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + authHandlers.Login(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Step 1: Login failed with status %d", w.Code) + } + + // Extract session cookie + cookies := w.Result().Cookies() + var sessionCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "ignite_session" { + sessionCookie = cookie + break + } + } + + if sessionCookie == nil { + t.Fatal("Step 1: Expected session cookie to be set") + } + + // Step 2: Change password with valid current password + changeReq := ChangePasswordRequest{ + CurrentPassword: "admin", + NewPassword: "newpassword123", + } + + jsonData, _ = json.Marshal(changeReq) + req = httptest.NewRequest("POST", "/auth/change-password", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(sessionCookie) + w = httptest.NewRecorder() + + authHandlers.ChangePassword(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Step 2: Expected password change status %d, got %d", http.StatusOK, w.Code) + } + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + if err != nil { + t.Fatalf("Step 2: Failed to unmarshal response: %v", err) + } + + if !response["success"].(bool) { + t.Errorf("Step 2: Expected password change to succeed") + } + + // Step 3: Verify old password no longer works + loginReq = LoginRequest{ + Username: "admin", + Password: "admin", // Old password + } + + jsonData, _ = json.Marshal(loginReq) + req = httptest.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + + authHandlers.Login(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Step 3: Expected old password to fail with status %d, got %d", http.StatusUnauthorized, w.Code) + } + + // Step 4: Verify new password works + loginReq = LoginRequest{ + Username: "admin", + Password: "newpassword123", // New password + } + + jsonData, _ = json.Marshal(loginReq) + req = httptest.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + + authHandlers.Login(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Step 4: Expected new password to work with status %d, got %d", http.StatusOK, w.Code) + } + + var loginResponse LoginResponse + err = json.Unmarshal(w.Body.Bytes(), &loginResponse) + if err != nil { + t.Fatalf("Step 4: Failed to unmarshal login response: %v", err) + } + + if !loginResponse.Success { + t.Error("Step 4: Expected login with new password to succeed") + } +} + +func TestPasswordChangeSecurityChecks(t *testing.T) { + // Reset to default credentials for test + defaultPassword = "admin" + defer func() { + defaultPassword = "admin" // Reset after test + }() + + container := &Container{} + authHandlers := NewAuthHandlers(container) + + // Create valid session cookie + sessionCookie := &http.Cookie{ + Name: "ignite_session", + Value: "admin_12345", + } + + tests := []struct { + name string + currentPass string + newPass string + authenticated bool + expectedStatus int + expectedMsg string + }{ + { + name: "Unauthenticated password change", + currentPass: "admin", + newPass: "newpass", + authenticated: false, + expectedStatus: http.StatusUnauthorized, + expectedMsg: "Not authenticated", + }, + { + name: "Wrong current password", + currentPass: "wrongpass", + newPass: "newpass", + authenticated: true, + expectedStatus: http.StatusUnauthorized, + expectedMsg: "Current password is incorrect", + }, + { + name: "Valid password change", + currentPass: "admin", + newPass: "validnewpass", + authenticated: true, + expectedStatus: http.StatusOK, + expectedMsg: "Password changed successfully", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset password for each test + defaultPassword = "admin" + + changeReq := ChangePasswordRequest{ + CurrentPassword: tt.currentPass, + NewPassword: tt.newPass, + } + + jsonData, _ := json.Marshal(changeReq) + req := httptest.NewRequest("POST", "/auth/change-password", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + + if tt.authenticated { + req.AddCookie(sessionCookie) + } + + w := httptest.NewRecorder() + + authHandlers.ChangePassword(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code) + } + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if message, ok := response["message"].(string); ok { + if message != tt.expectedMsg { + t.Errorf("Expected message '%s', got '%s'", tt.expectedMsg, message) + } + } + }) + } +} + +func TestSessionPersistence(t *testing.T) { + // Reset to default credentials for test + defaultUsername = "admin" + defaultPassword = "admin" + + container := &Container{} + authHandlers := NewAuthHandlers(container) + + // Login to get session + loginReq := LoginRequest{ + Username: "admin", + Password: "admin", + } + + jsonData, _ := json.Marshal(loginReq) + req := httptest.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + authHandlers.Login(w, req) + + // Extract session cookie + cookies := w.Result().Cookies() + var sessionCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "ignite_session" { + sessionCookie = cookie + break + } + } + + if sessionCookie == nil { + t.Fatal("Expected session cookie to be set") + } + + // Test multiple requests with same session + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + middlewareHandler := AuthMiddleware(testHandler) + + paths := []string{"/", "/dhcp", "/provision", "/status"} + + for _, path := range paths { + t.Run("Session persistence for "+path, func(t *testing.T) { + req := httptest.NewRequest("GET", path, nil) + req.AddCookie(sessionCookie) + w := httptest.NewRecorder() + + middlewareHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d for %s, got %d", http.StatusOK, path, w.Code) + } + }) + } +} diff --git a/handlers/auth_middleware_test.go b/handlers/auth_middleware_test.go new file mode 100644 index 0000000..39133aa --- /dev/null +++ b/handlers/auth_middleware_test.go @@ -0,0 +1,319 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestAuthMiddleware_PublicPaths(t *testing.T) { + publicPaths := []string{ + "/login", + "/auth/login", + "/auth/logout", + "/public/http/css/tailwind.css", + "/public/http/img/logo.png", + "/public/http/js/app.js", + } + + // Create a simple handler that returns 200 OK + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + // Wrap with auth middleware + middlewareHandler := AuthMiddleware(testHandler) + + for _, path := range publicPaths { + t.Run("Public path: "+path, func(t *testing.T) { + req := httptest.NewRequest("GET", path, nil) + w := httptest.NewRecorder() + + middlewareHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d for public path %s, got %d", http.StatusOK, path, w.Code) + } + + if w.Body.String() != "OK" { + t.Errorf("Expected response body 'OK' for public path %s, got %s", path, w.Body.String()) + } + }) + } +} + +func TestAuthMiddleware_ProtectedPaths_Unauthenticated(t *testing.T) { + protectedPaths := []string{ + "/", + "/dhcp", + "/provision", + "/tftp", + "/osimages", + "/syslinux", + "/status", + "/dhcp/servers", + "/provision/load-file", + } + + // Create a simple handler that returns 200 OK + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + // Wrap with auth middleware + middlewareHandler := AuthMiddleware(testHandler) + + for _, path := range protectedPaths { + t.Run("Protected path unauthenticated: "+path, func(t *testing.T) { + req := httptest.NewRequest("GET", path, nil) + w := httptest.NewRecorder() + + middlewareHandler.ServeHTTP(w, req) + + if w.Code != http.StatusFound { + t.Errorf("Expected status code %d for protected path %s, got %d", http.StatusFound, path, w.Code) + } + + location := w.Header().Get("Location") + if location != "/login" { + t.Errorf("Expected redirect to /login for protected path %s, got %s", path, location) + } + }) + } +} + +func TestAuthMiddleware_ProtectedPaths_Authenticated(t *testing.T) { + protectedPaths := []string{ + "/", + "/dhcp", + "/provision", + "/tftp", + "/osimages", + "/syslinux", + "/status", + } + + // Create a simple handler that returns 200 OK + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + // Wrap with auth middleware + middlewareHandler := AuthMiddleware(testHandler) + + for _, path := range protectedPaths { + t.Run("Protected path authenticated: "+path, func(t *testing.T) { + req := httptest.NewRequest("GET", path, nil) + // Add valid session cookie + req.AddCookie(&http.Cookie{ + Name: "ignite_session", + Value: "admin_12345", + }) + w := httptest.NewRecorder() + + middlewareHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d for authenticated protected path %s, got %d", http.StatusOK, path, w.Code) + } + + if w.Body.String() != "OK" { + t.Errorf("Expected response body 'OK' for authenticated protected path %s, got %s", path, w.Body.String()) + } + }) + } +} + +func TestAuthMiddleware_StaticFiles(t *testing.T) { + staticPaths := []string{ + "/public/http/css/tailwind.css", + "/public/http/css/provision.css", + "/public/http/img/Ignite_small.png", + "/public/http/js/app.js", + "/public/provision/templates/kickstart/default.templ", + } + + // Create a simple handler that returns 200 OK + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("static file content")) + }) + + // Wrap with auth middleware + middlewareHandler := AuthMiddleware(testHandler) + + for _, path := range staticPaths { + t.Run("Static file: "+path, func(t *testing.T) { + req := httptest.NewRequest("GET", path, nil) + w := httptest.NewRecorder() + + middlewareHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d for static file %s, got %d", http.StatusOK, path, w.Code) + } + + if w.Body.String() != "static file content" { + t.Errorf("Expected response body 'static file content' for static file %s, got %s", path, w.Body.String()) + } + }) + } +} + +func TestAuthMiddleware_EmptySessionCookie(t *testing.T) { + // Create a simple handler that returns 200 OK + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + // Wrap with auth middleware + middlewareHandler := AuthMiddleware(testHandler) + + req := httptest.NewRequest("GET", "/dhcp", nil) + // Add empty session cookie + req.AddCookie(&http.Cookie{ + Name: "ignite_session", + Value: "", + }) + w := httptest.NewRecorder() + + middlewareHandler.ServeHTTP(w, req) + + if w.Code != http.StatusFound { + t.Errorf("Expected status code %d for empty session cookie, got %d", http.StatusFound, w.Code) + } + + location := w.Header().Get("Location") + if location != "/login" { + t.Errorf("Expected redirect to /login for empty session cookie, got %s", location) + } +} + +func TestAuthMiddleware_POSTRequests(t *testing.T) { + tests := []struct { + name string + path string + authenticated bool + expectedStatus int + }{ + { + name: "POST to public auth endpoint", + path: "/auth/login", + authenticated: false, + expectedStatus: http.StatusOK, + }, + { + name: "POST to protected endpoint unauthenticated", + path: "/dhcp/start", + authenticated: false, + expectedStatus: http.StatusFound, + }, + { + name: "POST to protected endpoint authenticated", + path: "/dhcp/start", + authenticated: true, + expectedStatus: http.StatusOK, + }, + } + + // Create a simple handler that returns 200 OK + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + // Wrap with auth middleware + middlewareHandler := AuthMiddleware(testHandler) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("POST", tt.path, nil) + if tt.authenticated { + req.AddCookie(&http.Cookie{ + Name: "ignite_session", + Value: "admin_12345", + }) + } + w := httptest.NewRecorder() + + middlewareHandler.ServeHTTP(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("Expected status code %d for %s, got %d", tt.expectedStatus, tt.name, w.Code) + } + + if tt.expectedStatus == http.StatusFound { + location := w.Header().Get("Location") + if location != "/login" { + t.Errorf("Expected redirect to /login for %s, got %s", tt.name, location) + } + } + }) + } +} + +func TestAuthMiddleware_ChainedMiddleware(t *testing.T) { + // Create a test middleware that adds a header + testMiddleware := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Test-Middleware", "applied") + next.ServeHTTP(w, r) + }) + } + + // Create a simple handler that returns 200 OK + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + // Chain middlewares: testMiddleware -> AuthMiddleware -> testHandler + chainedHandler := testMiddleware(AuthMiddleware(testHandler)) + + t.Run("Chained middleware with authentication", func(t *testing.T) { + req := httptest.NewRequest("GET", "/dhcp", nil) + req.AddCookie(&http.Cookie{ + Name: "ignite_session", + Value: "admin_12345", + }) + w := httptest.NewRecorder() + + chainedHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } + + if w.Header().Get("X-Test-Middleware") != "applied" { + t.Error("Expected test middleware to be applied") + } + + if w.Body.String() != "OK" { + t.Errorf("Expected response body 'OK', got %s", w.Body.String()) + } + }) + + t.Run("Chained middleware without authentication", func(t *testing.T) { + req := httptest.NewRequest("GET", "/dhcp", nil) + w := httptest.NewRecorder() + + chainedHandler.ServeHTTP(w, req) + + if w.Code != http.StatusFound { + t.Errorf("Expected status code %d, got %d", http.StatusFound, w.Code) + } + + if w.Header().Get("X-Test-Middleware") != "applied" { + t.Error("Expected test middleware to be applied even when redirecting") + } + + location := w.Header().Get("Location") + if location != "/login" { + t.Errorf("Expected redirect to /login, got %s", location) + } + }) +} diff --git a/handlers/auth_test.go b/handlers/auth_test.go new file mode 100644 index 0000000..6b85b72 --- /dev/null +++ b/handlers/auth_test.go @@ -0,0 +1,363 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestAuthHandlers_LoginPage(t *testing.T) { + // Skip this test when running from handlers directory + // because templates are not accessible from this path + t.Skip("Template rendering test requires running from project root") +} + +func TestAuthHandlers_Login_Success(t *testing.T) { + // Reset to default credentials for test + defaultUsername = "admin" + defaultPassword = "admin" + + container := &Container{} + authHandlers := NewAuthHandlers(container) + + loginReq := LoginRequest{ + Username: "admin", + Password: "admin", + } + + jsonData, _ := json.Marshal(loginReq) + req := httptest.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + authHandlers.Login(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } + + var response LoginResponse + err := json.Unmarshal(w.Body.Bytes(), &response) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if !response.Success { + t.Error("Expected login to succeed") + } + + if response.Token == "" { + t.Error("Expected token to be set") + } + + // Check if session cookie is set + cookies := w.Result().Cookies() + var sessionCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "ignite_session" { + sessionCookie = cookie + break + } + } + + if sessionCookie == nil { + t.Error("Expected session cookie to be set") + return + } + + if sessionCookie.Value == "" { + t.Error("Expected session cookie to have a value") + } + + if !sessionCookie.HttpOnly { + t.Error("Expected session cookie to be HttpOnly") + } +} + +func TestAuthHandlers_Login_InvalidCredentials(t *testing.T) { + // Reset to default credentials for test + defaultUsername = "admin" + defaultPassword = "admin" + + container := &Container{} + authHandlers := NewAuthHandlers(container) + + loginReq := LoginRequest{ + Username: "wrong", + Password: "credentials", + } + + jsonData, _ := json.Marshal(loginReq) + req := httptest.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + authHandlers.Login(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status code %d, got %d", http.StatusUnauthorized, w.Code) + } + + var response LoginResponse + err := json.Unmarshal(w.Body.Bytes(), &response) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if response.Success { + t.Error("Expected login to fail") + } + + if response.Message == "" { + t.Error("Expected error message to be set") + } +} + +func TestAuthHandlers_Login_InvalidJSON(t *testing.T) { + container := &Container{} + authHandlers := NewAuthHandlers(container) + + req := httptest.NewRequest("POST", "/auth/login", strings.NewReader("invalid json")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + authHandlers.Login(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status code %d, got %d", http.StatusBadRequest, w.Code) + } + + var response LoginResponse + err := json.Unmarshal(w.Body.Bytes(), &response) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if response.Success { + t.Error("Expected login to fail") + } +} + +func TestAuthHandlers_Logout(t *testing.T) { + container := &Container{} + authHandlers := NewAuthHandlers(container) + + req := httptest.NewRequest("POST", "/auth/logout", nil) + w := httptest.NewRecorder() + + authHandlers.Logout(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } + + // Check if session cookie is cleared + cookies := w.Result().Cookies() + var sessionCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == "ignite_session" { + sessionCookie = cookie + break + } + } + + if sessionCookie == nil { + t.Error("Expected session cookie to be set for clearing") + return + } + + if sessionCookie.Value != "" { + t.Error("Expected session cookie value to be empty") + } + + // Check if cookie expires in the past + if sessionCookie.Expires.After(time.Now()) { + t.Error("Expected session cookie to expire in the past") + } +} + +func TestAuthHandlers_ChangePassword_Success(t *testing.T) { + // Reset to default credentials for test + defaultUsername = "admin" + defaultPassword = "admin" + + container := &Container{} + authHandlers := NewAuthHandlers(container) + + // Create a request with valid session cookie + req := httptest.NewRequest("POST", "/auth/change-password", nil) + req.AddCookie(&http.Cookie{ + Name: "ignite_session", + Value: "admin_12345", + }) + + changeReq := ChangePasswordRequest{ + CurrentPassword: "admin", + NewPassword: "newpassword123", + } + + jsonData, _ := json.Marshal(changeReq) + req = httptest.NewRequest("POST", "/auth/change-password", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{ + Name: "ignite_session", + Value: "admin_12345", + }) + + w := httptest.NewRecorder() + + authHandlers.ChangePassword(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + if err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if !response["success"].(bool) { + t.Error("Expected password change to succeed") + } + + // Verify password was actually changed + if defaultPassword != "newpassword123" { + t.Error("Expected password to be updated") + } + + // Reset for other tests + defaultPassword = "admin" +} + +func TestAuthHandlers_ChangePassword_NotAuthenticated(t *testing.T) { + container := &Container{} + authHandlers := NewAuthHandlers(container) + + changeReq := ChangePasswordRequest{ + CurrentPassword: "admin", + NewPassword: "newpassword123", + } + + jsonData, _ := json.Marshal(changeReq) + req := httptest.NewRequest("POST", "/auth/change-password", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + + authHandlers.ChangePassword(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status code %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +func TestAuthHandlers_ChangePassword_WrongCurrentPassword(t *testing.T) { + // Reset to default credentials for test + defaultPassword = "admin" + + container := &Container{} + authHandlers := NewAuthHandlers(container) + + changeReq := ChangePasswordRequest{ + CurrentPassword: "wrongpassword", + NewPassword: "newpassword123", + } + + jsonData, _ := json.Marshal(changeReq) + req := httptest.NewRequest("POST", "/auth/change-password", bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{ + Name: "ignite_session", + Value: "admin_12345", + }) + + w := httptest.NewRecorder() + + authHandlers.ChangePassword(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status code %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +func TestIsAuthenticated(t *testing.T) { + tests := []struct { + name string + cookie *http.Cookie + expectedResult bool + }{ + { + name: "Valid session cookie", + cookie: &http.Cookie{ + Name: "ignite_session", + Value: "admin_12345", + }, + expectedResult: true, + }, + { + name: "Empty session cookie", + cookie: &http.Cookie{ + Name: "ignite_session", + Value: "", + }, + expectedResult: false, + }, + { + name: "No session cookie", + cookie: nil, + expectedResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + if tt.cookie != nil { + req.AddCookie(tt.cookie) + } + + result := isAuthenticated(req) + if result != tt.expectedResult { + t.Errorf("Expected %v, got %v", tt.expectedResult, result) + } + }) + } +} + +func TestGenerateSimpleToken(t *testing.T) { + username := "testuser" + token := generateSimpleToken(username) + + if token == "" { + t.Error("Expected token to be generated") + } + + if !strings.Contains(token, username) { + t.Error("Expected token to contain username") + } + + // Test that token contains a timestamp (should have format username_YYYYMMDDHHMMSS) + parts := strings.Split(token, "_") + if len(parts) != 2 { + t.Error("Expected token to have format username_timestamp") + } + + // Check that timestamp part looks like a date (14 digits for YYYYMMDDHHMMSS) + timestamp := parts[1] + if len(timestamp) != 14 { + t.Error("Expected timestamp to be 14 digits (YYYYMMDDHHMMSS)") + } + + // Test with different username should produce different token even at same time + token2 := generateSimpleToken("different_user") + + if token == token2 { + t.Error("Expected tokens with different usernames to be different") + } +} diff --git a/handlers/bootmenu.go b/handlers/bootmenu.go index 7d0ae7f..f5acbb2 100644 --- a/handlers/bootmenu.go +++ b/handlers/bootmenu.go @@ -5,6 +5,7 @@ import ( "fmt" "html/template" "ignite/dhcp" + "log" "net" "net/http" "os" @@ -40,22 +41,25 @@ func (h *BootMenuHandlers) SubmitBootMenu(w http.ResponseWriter, r *http.Request } formData := map[string]string{ - "tftpip": r.Form.Get("tftpip"), - "mac": r.Form.Get("mac"), - "os": r.Form.Get("os"), - "version": r.Form.Get("version"), - "typeSelect": r.Form.Get("typeSelect"), - "template_name": r.Form.Get("template_name"), - "hostname": r.Form.Get("hostname"), - "ip": r.Form.Get("ip"), - "subnet": r.Form.Get("subnet"), - "gateway": r.Form.Get("gateway"), - "dns": r.Form.Get("dns"), + "tftpip": r.Form.Get("tftpip"), + "mac": r.Form.Get("mac"), + "os": r.Form.Get("os"), + "version": r.Form.Get("version"), + "typeSelect": r.Form.Get("typeSelect"), + "template_name": r.Form.Get("template_name"), + "hostname": r.Form.Get("hostname"), + "ip": r.Form.Get("ip"), + "subnet": r.Form.Get("subnet"), + "gateway": r.Form.Get("gateway"), + "dns": r.Form.Get("dns"), + "kernel_options": r.Form.Get("kernel_options"), } - for key, value := range formData { - if value == "" { - http.Error(w, fmt.Sprintf("Missing required field: %s", key), http.StatusBadRequest) + // Check required fields (kernel_options is optional) + requiredFields := []string{"tftpip", "mac", "os", "version", "typeSelect", "template_name", "hostname", "ip", "subnet", "gateway", "dns"} + for _, field := range requiredFields { + if formData[field] == "" { + http.Error(w, fmt.Sprintf("Missing required field: %s", field), http.StatusBadRequest) return } } @@ -74,16 +78,17 @@ func (h *BootMenuHandlers) SubmitBootMenu(w http.ResponseWriter, r *http.Request // Create BootMenu struct bootMenu := dhcp.BootMenu{ - Filename: pxefile, - OS: formData["os"], - Version: formData["version"], - TemplateType: formData["typeSelect"], - TemplateName: formData["template_name"], - Hostname: formData["hostname"], - IP: net.ParseIP(formData["ip"]), - Subnet: net.ParseIP(formData["subnet"]), - Gateway: net.ParseIP(formData["gateway"]), - DNS: net.ParseIP(formData["dns"]), + Filename: pxefile, + OS: formData["os"], + Version: formData["version"], + TemplateType: formData["typeSelect"], + TemplateName: formData["template_name"], + Hostname: formData["hostname"], + IP: net.ParseIP(formData["ip"]), + Subnet: net.ParseIP(formData["subnet"]), + Gateway: net.ParseIP(formData["gateway"]), + DNS: net.ParseIP(formData["dns"]), + KernelOptions: formData["kernel_options"], } // Build config file paths @@ -92,8 +97,8 @@ func (h *BootMenuHandlers) SubmitBootMenu(w http.ResponseWriter, r *http.Request templBuild := fmt.Sprintf("templates/%s/%s", formData["typeSelect"], formData["template_name"]) configTempl := filepath.Join(cfg.Provision.Dir, templBuild) - // Generate boot data - pxedata := h.generateBootData(formData, configFile) + // Generate boot data (use buildconfig for HTTP URL, configFile for filesystem path) + pxedata := h.generateBootData(formData, buildconfig) // Ensure directories exist if err := os.MkdirAll(filepath.Dir(pxefile), 0755); err != nil { @@ -120,7 +125,7 @@ func (h *BootMenuHandlers) SubmitBootMenu(w http.ResponseWriter, r *http.Request // Update DHCP lease if err := h.updateDHCPLease(formData["tftpip"], formData["mac"], bootMenu); err != nil { - fmt.Printf("Failed to update DHCP lease: %v\n", err) + log.Printf("Failed to update DHCP lease: %v", err) } // Redirect to DHCP page @@ -129,7 +134,7 @@ func (h *BootMenuHandlers) SubmitBootMenu(w http.ResponseWriter, r *http.Request // generateBootData constructs the boot configuration data based on provided OS and network details. func (h *BootMenuHandlers) generateBootData(formData map[string]string, configFile string) BootMenuData { - options := h.getBootOptions(formData["os"], formData["dns"], formData["tftpip"], configFile) + options := h.getBootOptions(formData["os"], formData["typeSelect"], formData["dns"], formData["tftpip"], configFile, formData["kernel_options"]) return BootMenuData{ Name: h.osToName(formData["os"]), @@ -139,30 +144,76 @@ func (h *BootMenuHandlers) generateBootData(formData map[string]string, configFi } } -// getBootOptions returns the appropriate boot options string based on the operating system. -func (h *BootMenuHandlers) getBootOptions(os, dns, tftpip, configFile string) string { - switch os { - case "Ubuntu", "NixOS": - return fmt.Sprintf(`url=http://%s/%s autoinstall ds=nocloud-net;s=http://%s/ nameserver=%s`, +// getBootOptions returns the appropriate boot options string based on the operating system and template type. +func (h *BootMenuHandlers) getBootOptions(os, templateType, dns, tftpip, configFile, kernelOptions string) string { + var baseOptions string + + // Determine boot parameters based on template type and OS + switch templateType { + case "cloud-init": + baseOptions = fmt.Sprintf(`url=http://%s/%s autoinstall ds=nocloud-net;s=http://%s/ nameserver=%s`, tftpip, configFile, tftpip, dns) - case "Redhat": - return fmt.Sprintf(`ks=http://%s/%s nameserver=%s`, + case "kickstart": + baseOptions = fmt.Sprintf(`ks=http://%s/%s nameserver=%s`, + tftpip, configFile, dns) + case "preseed": + baseOptions = fmt.Sprintf(`url=http://%s/%s auto=true priority=critical nameserver=%s`, + tftpip, configFile, dns) + case "autoyast": + baseOptions = fmt.Sprintf(`autoyast=http://%s/%s nameserver=%s`, + tftpip, configFile, dns) + case "ipxe": + baseOptions = fmt.Sprintf(`initrd=http://%s/%s nameserver=%s`, tftpip, configFile, dns) + default: + // Fallback to OS-based detection for backward compatibility + switch os { + case "ubuntu", "Ubuntu", "nixos", "NixOS": + baseOptions = fmt.Sprintf(`url=http://%s/%s autoinstall ds=nocloud-net;s=http://%s/ nameserver=%s`, + tftpip, configFile, tftpip, dns) + case "debian", "Debian": + baseOptions = fmt.Sprintf(`url=http://%s/%s auto=true priority=critical nameserver=%s`, + tftpip, configFile, dns) + case "redhat", "Redhat", "centos", "CentOS", "fedora", "Fedora": + baseOptions = fmt.Sprintf(`ks=http://%s/%s nameserver=%s`, + tftpip, configFile, dns) + case "opensuse", "openSUSE", "suse", "SUSE": + baseOptions = fmt.Sprintf(`autoyast=http://%s/%s nameserver=%s`, + tftpip, configFile, dns) + default: + baseOptions = "" + } + } + + // Add additional kernel options if provided + if kernelOptions != "" && baseOptions != "" { + return baseOptions + " " + kernelOptions + } else if kernelOptions != "" { + return kernelOptions } - return "" + + return baseOptions } // osToName maps the OS name to a standardized name used in file paths. func (h *BootMenuHandlers) osToName(os string) string { switch os { - case "Ubuntu": + case "ubuntu", "Ubuntu": return "ubuntu" - case "NixOS": + case "debian", "Debian": + return "debian" + case "fedora", "Fedora": + return "fedora" + case "centos", "CentOS": + return "centos" + case "opensuse", "openSUSE": + return "opensuse" + case "nixos", "NixOS": return "nixos" - case "Redhat": + case "redhat", "Redhat": return "redhat" } - return "" + return strings.ToLower(os) } // osToKernel constructs the kernel file path for the given OS and version. diff --git a/handlers/common.go b/handlers/common.go index 18f8b29..439f8b0 100755 --- a/handlers/common.go +++ b/handlers/common.go @@ -32,6 +32,7 @@ func LoadTemplates() map[string]*template.Template { return map[string]*template.Template{ "index": template.Must(template.ParseFiles(baseTemplate, "templates/pages/index.templ")), + "login": template.Must(template.ParseFiles("templates/base-login.templ", "templates/pages/login.templ")), "dhcp": template.Must(template.ParseFiles(baseTemplate, "templates/pages/dhcp.templ")), "tftp": template.Must(template.ParseFiles(baseTemplate, "templates/pages/tftp.templ", "templates/modals/uploadmodal.templ")), "status": template.Must(template.ParseFiles(baseTemplate, "templates/pages/status.templ")), @@ -344,19 +345,20 @@ func NewBootModal(w http.ResponseWriter, r *http.Request, container *Container) // Initialize data with basic required fields data := map[string]any{ - "title": "Boot Menu", - "tftpip": network, - "mac": mac, - "os": "", - "version": "", - "typeSelect": "", - "template_name": "", - "hostname": "", - "ip": "", - "subnet": "", - "gateway": "", - "dns": "", - "osImages": osImages, + "title": "Boot Menu", + "tftpip": network, + "mac": mac, + "os": "", + "version": "", + "typeSelect": "", + "template_name": "", + "hostname": "", + "ip": "", + "subnet": "", + "gateway": "", + "dns": "", + "kernel_options": "", + "osImages": osImages, } // Try to load existing boot menu data from the lease @@ -391,6 +393,9 @@ func NewBootModal(w http.ResponseWriter, r *http.Request, container *Container) if lease.Menu.DNS != nil { data["dns"] = lease.Menu.DNS.String() } + if lease.Menu.KernelOptions != "" { + data["kernel_options"] = lease.Menu.KernelOptions + } } } @@ -474,20 +479,6 @@ func NewProvisionNewFileModal() map[string]any { } } -// formatFileSize formats file size in bytes to human readable format -func formatFileSize(bytes int64) string { - const unit = 1024 - if bytes < unit { - return fmt.Sprintf("%d B", bytes) - } - div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) -} - // ipToInt converts IP to uint32 (helper function for IP calculations) func ipToInt(ip net.IP) uint32 { if len(ip) == 16 { diff --git a/handlers/dhcp.go b/handlers/dhcp.go index 52a1090..7260e40 100755 --- a/handlers/dhcp.go +++ b/handlers/dhcp.go @@ -34,7 +34,11 @@ func (h *DHCPHandlers) HandleDHCPPage(w http.ResponseWriter, r *http.Request) { servers, err := h.serverService.GetAllServers(ctx) if err != nil { - http.Error(w, fmt.Sprintf("Failed to get servers: %v", err), http.StatusInternalServerError) + appErr := NewInternalError( + fmt.Sprintf("Failed to get DHCP servers: %v", err), + "Unable to load DHCP servers. Please try again later.", + ) + HandleError(w, r, appErr) return } @@ -43,7 +47,11 @@ func (h *DHCPHandlers) HandleDHCPPage(w http.ResponseWriter, r *http.Request) { for _, server := range servers { leases, err := h.leaseService.GetLeasesByServer(ctx, server.ID) if err != nil { - http.Error(w, fmt.Sprintf("Failed to get leases: %v", err), http.StatusInternalServerError) + appErr := NewInternalError( + fmt.Sprintf("Failed to get leases for server %s: %v", server.ID, err), + "Unable to load DHCP leases. Please try again later.", + ) + HandleError(w, r, appErr) return } @@ -197,54 +205,39 @@ func (h *DHCPHandlers) SubmitDHCPServer(w http.ResponseWriter, r *http.Request) gatewayStr := r.FormValue("gateway") dnsStr := r.FormValue("dns") startIPStr := r.FormValue("startIP") - - // Always use endIP approach now endIPStr := r.FormValue("endIP") - endIP := net.ParseIP(endIPStr) - if endIP == nil { - http.Error(w, "Invalid end IP", http.StatusBadRequest) - return - } - startIP := net.ParseIP(startIPStr) - if startIP == nil { - http.Error(w, "Invalid start IP", http.StatusBadRequest) - return - } + // Create DHCP configuration validator + validator := NewDHCPConfigValidator() - // Calculate numLeases from start and end IP - startInt := ipToInt(startIP) - endInt := ipToInt(endIP) - if endInt < startInt { - http.Error(w, "End IP must be greater than start IP", http.StatusBadRequest) - return + // Prepare configuration for validation + validationConfig := map[string]string{ + "subnet": networkStr + "/" + getMaskBits(subnetStr), // Convert to CIDR + "range": startIPStr + "-" + endIPStr, + "router": gatewayStr, + "dns": dnsStr, } - numLeases := int(endInt - startInt + 1) - // Validate and parse inputs - network := net.ParseIP(networkStr) - if network == nil { - http.Error(w, "Invalid network IP", http.StatusBadRequest) + // Validate configuration + if validationErrors := validator.ValidateDHCPConfig(validationConfig); validationErrors.HasErrors() { + SendValidationError(w, r, validationErrors) return } + // Parse validated IPs + startIP := net.ParseIP(startIPStr) + endIP := net.ParseIP(endIPStr) + network := net.ParseIP(networkStr) subnet := net.ParseIP(subnetStr) - if subnet == nil { - http.Error(w, "Invalid subnet mask", http.StatusBadRequest) - return - } - gateway := net.ParseIP(gatewayStr) - if gateway == nil { - http.Error(w, "Invalid gateway IP", http.StatusBadRequest) - return - } + // Calculate numLeases from start and end IP + startInt := ipToInt(startIP) + endInt := ipToInt(endIP) + numLeases := int(endInt - startInt + 1) + + // Parse DNS (already validated above) dns := net.ParseIP(dnsStr) - if dns == nil { - http.Error(w, "Invalid DNS IP", http.StatusBadRequest) - return - } // Create server configuration config := dhcp.ServerConfig{ @@ -261,7 +254,11 @@ func (h *DHCPHandlers) SubmitDHCPServer(w http.ResponseWriter, r *http.Request) // Update existing server err := h.serverService.UpdateServer(ctx, serverID, config) if err != nil { - http.Error(w, fmt.Sprintf("Failed to update server: %v", err), http.StatusInternalServerError) + appErr := NewInternalError( + fmt.Sprintf("Failed to update DHCP server %s: %v", serverID, err), + "Unable to update DHCP server. Please try again later.", + ) + HandleError(w, r, appErr) return } @@ -273,7 +270,11 @@ func (h *DHCPHandlers) SubmitDHCPServer(w http.ResponseWriter, r *http.Request) // Create new server server, err := h.serverService.CreateServer(ctx, config) if err != nil { - http.Error(w, fmt.Sprintf("Failed to create server: %v", err), http.StatusInternalServerError) + appErr := NewInternalError( + fmt.Sprintf("Failed to create DHCP server: %v", err), + "Unable to create DHCP server. Please check your configuration and try again.", + ) + HandleError(w, r, appErr) return } @@ -602,3 +603,30 @@ func renderTemplate(w http.ResponseWriter, templateName string, data interface{} w.Header().Set("Content-Type", "text/html") fmt.Fprintf(w, "Template: %s with data: %+v", templateName, data) } + +// getMaskBits converts a subnet mask to CIDR bits +func getMaskBits(mask string) string { + ip := net.ParseIP(mask) + if ip == nil { + return "24" // Default fallback + } + + // Convert IPv4 mask to CIDR bits + mask4 := ip.To4() + if mask4 == nil { + return "24" // Default fallback + } + + // Count the number of set bits + var bits int + for _, b := range mask4 { + for b != 0 { + if b&1 == 1 { + bits++ + } + b >>= 1 + } + } + + return fmt.Sprintf("%d", bits) +} diff --git a/handlers/errors.go b/handlers/errors.go new file mode 100644 index 0000000..0729dfc --- /dev/null +++ b/handlers/errors.go @@ -0,0 +1,193 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + "strings" +) + +// ErrorType represents different types of errors for better categorization +type ErrorType string + +const ( + ErrorTypeValidation ErrorType = "validation" + ErrorTypeNotFound ErrorType = "not_found" + ErrorTypeUnauthorized ErrorType = "unauthorized" + ErrorTypeInternal ErrorType = "internal" + ErrorTypeConflict ErrorType = "conflict" + ErrorTypeForbidden ErrorType = "forbidden" + ErrorTypeRateLimit ErrorType = "rate_limit" + ErrorTypeServiceUnavail ErrorType = "service_unavailable" +) + +// AppError represents a structured application error +type AppError struct { + Type ErrorType `json:"type"` + Message string `json:"message"` + UserMessage string `json:"user_message"` + Code string `json:"code,omitempty"` + Details interface{} `json:"details,omitempty"` + StatusCode int `json:"-"` +} + +// Error implements the error interface +func (e *AppError) Error() string { + return e.Message +} + +// NewAppError creates a new AppError +func NewAppError(errorType ErrorType, message, userMessage string, statusCode int) *AppError { + return &AppError{ + Type: errorType, + Message: message, + UserMessage: userMessage, + StatusCode: statusCode, + } +} + +// WithCode adds an error code to the AppError +func (e *AppError) WithCode(code string) *AppError { + e.Code = code + return e +} + +// WithDetails adds details to the AppError +func (e *AppError) WithDetails(details interface{}) *AppError { + e.Details = details + return e +} + +// Common error constructors +func NewValidationError(message, userMessage string) *AppError { + return NewAppError(ErrorTypeValidation, message, userMessage, http.StatusBadRequest) +} + +func NewNotFoundError(message, userMessage string) *AppError { + return NewAppError(ErrorTypeNotFound, message, userMessage, http.StatusNotFound) +} + +func NewInternalError(message, userMessage string) *AppError { + return NewAppError(ErrorTypeInternal, message, userMessage, http.StatusInternalServerError) +} + +// ErrorResponse represents the structure of error responses +type ErrorResponse struct { + Success bool `json:"success"` + Error AppError `json:"error"` +} + +// SendError sends a structured error response +func SendError(w http.ResponseWriter, r *http.Request, err *AppError) { + // Log the internal error message + log.Printf("Error [%s] %s: %s", r.Method, r.URL.Path, err.Message) + if err.Details != nil { + log.Printf("Error details: %+v", err.Details) + } + + // Set response headers + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(err.StatusCode) + + // Create error response + response := ErrorResponse{ + Success: false, + Error: *err, + } + + // Send JSON response + if encodeErr := json.NewEncoder(w).Encode(response); encodeErr != nil { + log.Printf("Failed to encode error response: %v", encodeErr) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// SendHTMLError sends an error for HTML responses (for template-based pages) +func SendHTMLError(w http.ResponseWriter, r *http.Request, err *AppError) { + // Log the internal error message + log.Printf("HTML Error [%s] %s: %s", r.Method, r.URL.Path, err.Message) + if err.Details != nil { + log.Printf("Error details: %+v", err.Details) + } + + // For HTML responses, use standard HTTP error + http.Error(w, err.UserMessage, err.StatusCode) +} + +// WrapError wraps a standard error into an AppError +func WrapError(err error, errorType ErrorType, userMessage string, statusCode int) *AppError { + if err == nil { + return nil + } + + if appErr, ok := err.(*AppError); ok { + return appErr + } + + return NewAppError(errorType, err.Error(), userMessage, statusCode) +} + +// ValidationErrors represents a collection of validation errors +type ValidationErrors map[string][]string + +// Add adds a validation error for a field +func (ve ValidationErrors) Add(field, message string) { + ve[field] = append(ve[field], message) +} + +// HasErrors returns true if there are any validation errors +func (ve ValidationErrors) HasErrors() bool { + return len(ve) > 0 +} + +// ToAppError converts ValidationErrors to an AppError +func (ve ValidationErrors) ToAppError() *AppError { + if !ve.HasErrors() { + return nil + } + + return NewValidationError( + "Validation failed", + "Please check your input and try again", + ).WithDetails(ve) +} + +// SendValidationError sends validation errors +func SendValidationError(w http.ResponseWriter, r *http.Request, errors ValidationErrors) { + if appErr := errors.ToAppError(); appErr != nil { + SendError(w, r, appErr) + } +} + +// IsHTMLRequest checks if the request expects HTML response +func IsHTMLRequest(r *http.Request) bool { + accept := r.Header.Get("Accept") + return accept != "" && (contains(accept, "text/html") || + contains(accept, "application/xhtml+xml") || + contains(accept, "*/*")) +} + +// contains checks if a string contains a substring (case-insensitive) +func contains(s, substr string) bool { + return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) +} + +// HandleError is a utility function that automatically chooses between JSON and HTML error responses +func HandleError(w http.ResponseWriter, r *http.Request, err *AppError) { + if IsHTMLRequest(r) && r.Header.Get("HX-Request") == "" { // Not an HTMX request + SendHTMLError(w, r, err) + } else { + SendError(w, r, err) + } +} + +// Common error messages +var ( + ErrInvalidInput = "Invalid input provided" + ErrResourceNotFound = "The requested resource was not found" + ErrUnauthorized = "You are not authorized to perform this action" + ErrInternalServer = "An internal server error occurred" + ErrServiceUnavail = "The service is temporarily unavailable" + ErrConflict = "The request conflicts with the current state" + ErrForbidden = "Access to this resource is forbidden" +) diff --git a/handlers/ipmi.go b/handlers/ipmi.go index b708674..047309e 100644 --- a/handlers/ipmi.go +++ b/handlers/ipmi.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "ignite/dhcp" + "log" "net" "net/http" @@ -46,7 +47,7 @@ func (h *IPMIHandlers) SubmitIPMI(w http.ResponseWriter, r *http.Request) { // Update DHCP lease with IPMI configuration if err := h.updateDHCPLeaseWithIPMI(ctx, tftpip, mac, ip, username, bootConfigChecked, rebootChecked); err != nil { - fmt.Printf("Failed to update DHCP lease: %v\n", err) + log.Printf("Failed to update DHCP lease: %v", err) // Continue with IPMI operations even if lease update fails } diff --git a/handlers/provision.go b/handlers/provision.go index 154a081..d889779 100644 --- a/handlers/provision.go +++ b/handlers/provision.go @@ -71,7 +71,7 @@ func (h *ProvisionHandlers) getProvisionData() *ProvisionData { data := &ProvisionData{ Title: "Provisioning Scripts", Files: []*ProvisionFileInfo{}, - Categories: []string{"cloud-init", "kickstart", "bootmenu"}, + Categories: []string{"autoyast", "bootmenu", "cloud-init", "ipxe", "kickstart", "preseed"}, Types: []string{"templates", "configs"}, } @@ -145,6 +145,10 @@ func (h *ProvisionHandlers) detectLanguage(path string) string { return "ini" case ".ks": return "kickstart" + case ".xml": + return "xml" + case ".ipxe": + return "ipxe" default: // Check directory or filename for hints if strings.Contains(path, "cloud-init") { @@ -153,6 +157,15 @@ func (h *ProvisionHandlers) detectLanguage(path string) string { if strings.Contains(path, "kickstart") { return "kickstart" } + if strings.Contains(path, "preseed") { + return "preseed" + } + if strings.Contains(path, "autoyast") { + return "xml" + } + if strings.Contains(path, "ipxe") { + return "ipxe" + } if strings.Contains(path, "bootmenu") || strings.Contains(path, "pxe") { return "ini" } @@ -397,6 +410,33 @@ func (h *ProvisionHandlers) loadFileContent(filePath string) (string, error) { return string(content), nil } +// isPathAllowed checks if a file path is within allowed directories +func (h *ProvisionHandlers) isPathAllowed(filePath string, allowedDirs ...string) bool { + absPath, err := filepath.Abs(filePath) + if err != nil { + return false + } + + for _, allowedDir := range allowedDirs { + absAllowedDir, err := filepath.Abs(allowedDir) + if err != nil { + continue + } + + relPath, err := filepath.Rel(absAllowedDir, absPath) + if err != nil { + continue + } + + // Check if the relative path doesn't start with ".." (which would indicate it's outside the allowed directory) + if !strings.HasPrefix(relPath, "..") && !filepath.IsAbs(relPath) { + return true + } + } + + return false +} + // Additional API endpoints for the new interface // LoadFileContent loads file content via API @@ -459,6 +499,59 @@ func (h *ProvisionHandlers) SaveFileContent(w http.ResponseWriter, r *http.Reque }) } +// DeleteFile deletes a file via API +func (h *ProvisionHandlers) DeleteFile(w http.ResponseWriter, r *http.Request) { + filePath := r.FormValue("path") + + if filePath == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": "path parameter is required", + }) + return + } + + // Security check: ensure file is within allowed directories + provisionDir := h.container.Config.Provision.Dir + tftpDir := h.container.Config.TFTP.Dir + + if !h.isPathAllowed(filePath, provisionDir, tftpDir) { + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": "File deletion not allowed outside of provision directories", + }) + return + } + + // Check if file exists + if _, err := os.Stat(filePath); os.IsNotExist(err) { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": "File not found", + }) + return + } + + // Delete file + if err := os.Remove(filePath); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": fmt.Sprintf("Error deleting file: %v", err), + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "File deleted successfully", + }) +} + // GetTemplateGallery returns pre-built templates func (h *ProvisionHandlers) GetTemplateGallery(w http.ResponseWriter, r *http.Request) { gallery := h.getTemplateGallery() diff --git a/handlers/provision_test.go b/handlers/provision_test.go new file mode 100644 index 0000000..991b2ad --- /dev/null +++ b/handlers/provision_test.go @@ -0,0 +1,208 @@ +package handlers + +import ( + "bytes" + "ignite/config" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestProvisionHandlers_DeleteFile_Success(t *testing.T) { + // Create temporary directories + tempDir := t.TempDir() + provisionDir := filepath.Join(tempDir, "provision") + templatesDir := filepath.Join(provisionDir, "templates", "cloud-init") + + if err := os.MkdirAll(templatesDir, 0755); err != nil { + t.Fatalf("Failed to create test directories: %v", err) + } + + // Create a test file + testFile := filepath.Join(templatesDir, "test.yml") + testContent := "test: content" + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Setup handler + cfg := &config.Config{ + Provision: config.ProvisionConfig{ + Dir: provisionDir, + }, + } + container := &Container{Config: cfg} + handler := NewProvisionHandlers(container) + + // Create request + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + if err := writer.WriteField("path", testFile); err != nil { + t.Fatalf("Failed to write form field: %v", err) + } + writer.Close() + + req := httptest.NewRequest("POST", "/provision/delete-file", &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + + // Execute request + handler.DeleteFile(w, req) + + // Check response + if w.Code != http.StatusOK { + t.Errorf("Expected status code %d, got %d", http.StatusOK, w.Code) + } + + // Check that file was deleted + if _, err := os.Stat(testFile); !os.IsNotExist(err) { + t.Error("Expected file to be deleted") + } +} + +func TestProvisionHandlers_DeleteFile_SecurityCheck(t *testing.T) { + // Create temporary directories + tempDir := t.TempDir() + provisionDir := filepath.Join(tempDir, "provision") + + if err := os.MkdirAll(provisionDir, 0755); err != nil { + t.Fatalf("Failed to create test directories: %v", err) + } + + // Create a file outside the provision directory + outsideFile := filepath.Join(tempDir, "outside.txt") + if err := os.WriteFile(outsideFile, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Setup handler + cfg := &config.Config{ + Provision: config.ProvisionConfig{ + Dir: provisionDir, + }, + } + container := &Container{Config: cfg} + handler := NewProvisionHandlers(container) + + // Create request to delete file outside provision directory + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + if err := writer.WriteField("path", outsideFile); err != nil { + t.Fatalf("Failed to write form field: %v", err) + } + writer.Close() + + req := httptest.NewRequest("POST", "/provision/delete-file", &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + + // Execute request + handler.DeleteFile(w, req) + + // Check response - should be forbidden + if w.Code != http.StatusForbidden { + t.Errorf("Expected status code %d, got %d", http.StatusForbidden, w.Code) + } + + // Check that file was NOT deleted + if _, err := os.Stat(outsideFile); os.IsNotExist(err) { + t.Error("File should not have been deleted due to security check") + } +} + +func TestProvisionHandlers_DeleteFile_NonExistentFile(t *testing.T) { + // Create temporary directories + tempDir := t.TempDir() + provisionDir := filepath.Join(tempDir, "provision") + + if err := os.MkdirAll(provisionDir, 0755); err != nil { + t.Fatalf("Failed to create test directories: %v", err) + } + + // Setup handler + cfg := &config.Config{ + Provision: config.ProvisionConfig{ + Dir: provisionDir, + }, + } + container := &Container{Config: cfg} + handler := NewProvisionHandlers(container) + + // Create request to delete non-existent file + nonExistentFile := filepath.Join(provisionDir, "nonexistent.txt") + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + if err := writer.WriteField("path", nonExistentFile); err != nil { + t.Fatalf("Failed to write form field: %v", err) + } + writer.Close() + + req := httptest.NewRequest("POST", "/provision/delete-file", &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + + // Execute request + handler.DeleteFile(w, req) + + // Check response - should be not found + if w.Code != http.StatusNotFound { + t.Errorf("Expected status code %d, got %d", http.StatusNotFound, w.Code) + } +} + +func TestProvisionHandlers_isPathAllowed(t *testing.T) { + cfg := &config.Config{} + container := &Container{Config: cfg} + handler := NewProvisionHandlers(container) + + // Create temporary directories for testing + tempDir := t.TempDir() + allowedDir := filepath.Join(tempDir, "allowed") + if err := os.MkdirAll(allowedDir, 0755); err != nil { + t.Fatalf("Failed to create test directories: %v", err) + } + + tests := []struct { + name string + filePath string + allowedDir string + expected bool + }{ + { + name: "File within allowed directory", + filePath: filepath.Join(allowedDir, "test.txt"), + allowedDir: allowedDir, + expected: true, + }, + { + name: "File in subdirectory of allowed directory", + filePath: filepath.Join(allowedDir, "subdir", "test.txt"), + allowedDir: allowedDir, + expected: true, + }, + { + name: "File outside allowed directory", + filePath: filepath.Join(tempDir, "outside.txt"), + allowedDir: allowedDir, + expected: false, + }, + { + name: "Path traversal attempt", + filePath: filepath.Join(allowedDir, "..", "outside.txt"), + allowedDir: allowedDir, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := handler.isPathAllowed(tt.filePath, tt.allowedDir) + if result != tt.expected { + t.Errorf("isPathAllowed(%s, %s) = %v, expected %v", tt.filePath, tt.allowedDir, result, tt.expected) + } + }) + } +} diff --git a/handlers/security.go b/handlers/security.go new file mode 100644 index 0000000..6982e6c --- /dev/null +++ b/handlers/security.go @@ -0,0 +1,311 @@ +package handlers + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// PathSecurityValidator provides path validation and security checks +type PathSecurityValidator struct { + allowedBasePaths []string + maxPathLength int + maxFileSize int64 +} + +// NewPathSecurityValidator creates a new path security validator +func NewPathSecurityValidator(allowedBasePaths []string) *PathSecurityValidator { + return &PathSecurityValidator{ + allowedBasePaths: allowedBasePaths, + maxPathLength: 4096, // 4KB max path length + maxFileSize: 1024 * 1024 * 1024, // 1GB max file size + } +} + +// SetMaxPathLength sets the maximum allowed path length +func (v *PathSecurityValidator) SetMaxPathLength(length int) *PathSecurityValidator { + v.maxPathLength = length + return v +} + +// SetMaxFileSize sets the maximum allowed file size +func (v *PathSecurityValidator) SetMaxFileSize(size int64) *PathSecurityValidator { + v.maxFileSize = size + return v +} + +// ValidatePath validates a file path for security issues +func (v *PathSecurityValidator) ValidatePath(path string) error { + if path == "" { + return fmt.Errorf("path cannot be empty") + } + + // Check path length + if len(path) > v.maxPathLength { + return fmt.Errorf("path too long (max %d characters): %d", v.maxPathLength, len(path)) + } + + // Clean the path to resolve any .. or . components + cleanPath := filepath.Clean(path) + + // Check for path traversal attempts + if strings.Contains(cleanPath, "..") { + return fmt.Errorf("path traversal detected: %s", path) + } + + // Check for null bytes (can be used to bypass filters) + if strings.Contains(path, "\x00") { + return fmt.Errorf("null byte detected in path: %s", path) + } + + // Check for suspicious characters + suspiciousChars := []string{"|", "&", ";", "$", "`", "\\", "<", ">"} + for _, char := range suspiciousChars { + if strings.Contains(path, char) { + return fmt.Errorf("suspicious character '%s' detected in path: %s", char, path) + } + } + + return nil +} + +// ValidatePathWithinBase ensures the path is within one of the allowed base paths +func (v *PathSecurityValidator) ValidatePathWithinBase(path string) error { + if err := v.ValidatePath(path); err != nil { + return err + } + + // Convert to absolute path + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("failed to resolve absolute path: %v", err) + } + + // Check if path is within any allowed base path + for _, basePath := range v.allowedBasePaths { + absBasePath, err := filepath.Abs(basePath) + if err != nil { + continue // Skip invalid base paths + } + + // Check if the file path is within the base path + relPath, err := filepath.Rel(absBasePath, absPath) + if err == nil && !strings.HasPrefix(relPath, "..") { + return nil // Path is within this base path + } + } + + return fmt.Errorf("path '%s' is not within any allowed base directory", path) +} + +// ValidateFileName validates a filename for security issues +func (v *PathSecurityValidator) ValidateFileName(filename string) error { + if filename == "" { + return fmt.Errorf("filename cannot be empty") + } + + // Check for reserved names (Windows) + reservedNames := []string{ + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + } + + upperFilename := strings.ToUpper(filename) + for _, reserved := range reservedNames { + if upperFilename == reserved || strings.HasPrefix(upperFilename, reserved+".") { + return fmt.Errorf("filename uses reserved name: %s", filename) + } + } + + // Check for dangerous characters + dangerousChars := []string{"/", "\\", ":", "*", "?", "\"", "<", ">", "|", "\x00"} + for _, char := range dangerousChars { + if strings.Contains(filename, char) { + return fmt.Errorf("filename contains dangerous character '%s': %s", char, filename) + } + } + + // Check for files starting with dot (hidden files) + if strings.HasPrefix(filename, ".") { + return fmt.Errorf("hidden files not allowed: %s", filename) + } + + // Check filename length + if len(filename) > 255 { + return fmt.Errorf("filename too long (max 255 characters): %s", filename) + } + + return nil +} + +// ValidateFileSize validates file size +func (v *PathSecurityValidator) ValidateFileSize(filePath string) error { + fileInfo, err := os.Stat(filePath) + if err != nil { + return fmt.Errorf("failed to get file info: %v", err) + } + + if fileInfo.Size() > v.maxFileSize { + return fmt.Errorf("file too large (max %d bytes): %d", v.maxFileSize, fileInfo.Size()) + } + + return nil +} + +// ValidateFileType validates file type based on extension or known filenames +func (v *PathSecurityValidator) ValidateFileType(filename string, allowedExtensions []string) error { + if len(allowedExtensions) == 0 { + return nil // No restrictions + } + + ext := strings.ToLower(filepath.Ext(filename)) + baseFilename := strings.ToLower(filepath.Base(filename)) + + // Known extensionless files that should be allowed + knownFiles := []string{"vmlinuz", "initrd", "kernel", "boot"} + for _, known := range knownFiles { + if baseFilename == known || strings.Contains(baseFilename, known) { + return nil + } + } + + if ext == "" { + return fmt.Errorf("file must have an extension or be a known system file") + } + + for _, allowed := range allowedExtensions { + if strings.ToLower(allowed) == ext { + return nil + } + } + + return fmt.Errorf("file type '%s' not allowed (allowed: %v)", ext, allowedExtensions) +} + +// SanitizePath sanitizes a path by removing dangerous components +func (v *PathSecurityValidator) SanitizePath(path string) string { + // Clean the path + cleanPath := filepath.Clean(path) + + // Remove any remaining .. components + parts := strings.Split(cleanPath, string(filepath.Separator)) + var safeParts []string + + for _, part := range parts { + if part != ".." && part != "." && part != "" { + safeParts = append(safeParts, part) + } + } + + return strings.Join(safeParts, string(filepath.Separator)) +} + +// TFTPSecurityValidator provides TFTP-specific security validation +type TFTPSecurityValidator struct { + pathValidator *PathSecurityValidator + allowedTypes []string +} + +// NewTFTPSecurityValidator creates a new TFTP security validator +func NewTFTPSecurityValidator(tftpBasePath string) *TFTPSecurityValidator { + return &TFTPSecurityValidator{ + pathValidator: NewPathSecurityValidator([]string{tftpBasePath}), + allowedTypes: []string{ + ".img", ".iso", ".bin", ".pxe", ".efi", ".cfg", ".conf", + ".txt", ".sh", ".tar", ".gz", ".zip", ".deb", ".rpm", + ".vmlinuz", ".initrd", ".kernel", ".boot", + }, + } +} + +// ValidateTFTPPath validates a TFTP file path +func (v *TFTPSecurityValidator) ValidateTFTPPath(path string) error { + // Validate basic path security + if err := v.pathValidator.ValidatePathWithinBase(path); err != nil { + return err + } + + // Extract filename + filename := filepath.Base(path) + if err := v.pathValidator.ValidateFileName(filename); err != nil { + return err + } + + // Validate file type + if err := v.pathValidator.ValidateFileType(filename, v.allowedTypes); err != nil { + return err + } + + return nil +} + +// ValidateTFTPUpload validates a file upload for TFTP +func (v *TFTPSecurityValidator) ValidateTFTPUpload(filename string, size int64) error { + // Validate filename + if err := v.pathValidator.ValidateFileName(filename); err != nil { + return err + } + + // Validate file type + if err := v.pathValidator.ValidateFileType(filename, v.allowedTypes); err != nil { + return err + } + + // Check file size + if size > v.pathValidator.maxFileSize { + return fmt.Errorf("file too large (max %d bytes): %d", v.pathValidator.maxFileSize, size) + } + + return nil +} + +// GetSafePath returns a safe path within the TFTP directory +func (v *TFTPSecurityValidator) GetSafePath(basePath, relativePath string) (string, error) { + // First validate the relative path + if err := v.pathValidator.ValidatePath(relativePath); err != nil { + return "", err + } + + // Validate filename + filename := filepath.Base(relativePath) + if err := v.pathValidator.ValidateFileName(filename); err != nil { + return "", err + } + + // Sanitize the relative path + safePath := v.pathValidator.SanitizePath(relativePath) + + // Combine with base path + fullPath := filepath.Join(basePath, safePath) + + // Validate the final path + if err := v.pathValidator.ValidatePathWithinBase(fullPath); err != nil { + return "", err + } + + return fullPath, nil +} + +// SecurityConfig holds security configuration +type SecurityConfig struct { + MaxFileSize int64 `json:"max_file_size"` + MaxPathLength int `json:"max_path_length"` + AllowedTFTPTypes []string `json:"allowed_tftp_types"` + TFTPBasePath string `json:"tftp_base_path"` +} + +// GetDefaultSecurityConfig returns default security configuration +func GetDefaultSecurityConfig() *SecurityConfig { + return &SecurityConfig{ + MaxFileSize: 1024 * 1024 * 1024, // 1GB + MaxPathLength: 4096, // 4KB + AllowedTFTPTypes: []string{ + ".img", ".iso", ".bin", ".pxe", ".efi", ".cfg", ".conf", + ".txt", ".sh", ".tar", ".gz", ".zip", ".deb", ".rpm", + ".vmlinuz", ".initrd", ".kernel", ".boot", + }, + TFTPBasePath: "/var/lib/ignite/tftp", + } +} diff --git a/handlers/security_test.go b/handlers/security_test.go new file mode 100644 index 0000000..be7c5b9 --- /dev/null +++ b/handlers/security_test.go @@ -0,0 +1,280 @@ +package handlers + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPathSecurityValidator_ValidatePath(t *testing.T) { + validator := NewPathSecurityValidator([]string{"/tmp"}) + + tests := []struct { + name string + path string + expectErr bool + }{ + {"Valid path", "test.txt", false}, + {"Valid nested path", "dir/test.txt", false}, + {"Path traversal", "../etc/passwd", true}, + {"Path traversal nested", "dir/../../etc/passwd", true}, + {"Null byte", "test\x00.txt", true}, + {"Pipe character", "test|cmd.txt", true}, + {"Ampersand", "test&cmd.txt", true}, + {"Semicolon", "test;cmd.txt", true}, + {"Dollar sign", "test$var.txt", true}, + {"Backtick", "test`cmd`.txt", true}, + {"Backslash", "test\\cmd.txt", true}, + {"Less than", "testoutput.txt", true}, + {"Empty path", "", true}, + {"Very long path", string(make([]byte, 5000)), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.ValidatePath(tt.path) + if (err != nil) != tt.expectErr { + t.Errorf("ValidatePath(%s) error = %v, expectErr %v", tt.path, err, tt.expectErr) + } + }) + } +} + +func TestPathSecurityValidator_ValidateFileName(t *testing.T) { + validator := NewPathSecurityValidator([]string{"/tmp"}) + + tests := []struct { + name string + filename string + expectErr bool + }{ + {"Valid filename", "test.txt", false}, + {"Valid with numbers", "test123.txt", false}, + {"Valid with underscore", "test_file.txt", false}, + {"Valid with hyphen", "test-file.txt", false}, + {"Reserved name CON", "CON", true}, + {"Reserved name with extension", "CON.txt", true}, + {"Reserved name lowercase", "con", true}, + {"Path separator slash", "test/file.txt", true}, + {"Path separator backslash", "test\\file.txt", true}, + {"Colon", "test:file.txt", true}, + {"Asterisk", "test*.txt", true}, + {"Question mark", "test?.txt", true}, + {"Double quote", "test\".txt", true}, + {"Less than", "test<.txt", true}, + {"Greater than", "test>.txt", true}, + {"Pipe", "test|.txt", true}, + {"Null byte", "test\x00.txt", true}, + {"Hidden file", ".hidden", true}, + {"Empty filename", "", true}, + {"Very long filename", string(make([]byte, 300)), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.ValidateFileName(tt.filename) + if (err != nil) != tt.expectErr { + t.Errorf("ValidateFileName(%s) error = %v, expectErr %v", tt.filename, err, tt.expectErr) + } + }) + } +} + +func TestPathSecurityValidator_ValidateFileType(t *testing.T) { + validator := NewPathSecurityValidator([]string{"/tmp"}) + + allowedTypes := []string{".txt", ".log", ".cfg"} + + tests := []struct { + name string + filename string + expectErr bool + }{ + {"Allowed txt", "test.txt", false}, + {"Allowed log", "test.log", false}, + {"Allowed cfg", "test.cfg", false}, + {"Allowed uppercase", "test.TXT", false}, + {"Not allowed exe", "test.exe", true}, + {"Not allowed bin", "test.bin", true}, + {"No extension", "test", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.ValidateFileType(tt.filename, allowedTypes) + if (err != nil) != tt.expectErr { + t.Errorf("ValidateFileType(%s) error = %v, expectErr %v", tt.filename, err, tt.expectErr) + } + }) + } +} + +func TestPathSecurityValidator_SanitizePath(t *testing.T) { + validator := NewPathSecurityValidator([]string{"/tmp"}) + + tests := []struct { + name string + path string + expected string + }{ + {"Clean path", "dir/file.txt", "dir/file.txt"}, + {"Path with dots", "dir/./file.txt", "dir/file.txt"}, + {"Path with traversal", "dir/../file.txt", "file.txt"}, + {"Multiple traversal", "dir/../../file.txt", "file.txt"}, + {"Leading slash", "/dir/file.txt", "dir/file.txt"}, + {"Trailing slash", "dir/file.txt/", "dir/file.txt"}, + {"Multiple slashes", "dir//file.txt", "dir/file.txt"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := validator.SanitizePath(tt.path) + if result != tt.expected { + t.Errorf("SanitizePath(%s) = %s, expected %s", tt.path, result, tt.expected) + } + }) + } +} + +func TestTFTPSecurityValidator_ValidateTFTPUpload(t *testing.T) { + tmpDir := t.TempDir() + validator := NewTFTPSecurityValidator(tmpDir) + + tests := []struct { + name string + filename string + size int64 + expectErr bool + }{ + {"Valid image file", "ubuntu.iso", 1024, false}, + {"Valid kernel", "vmlinuz", 1024, false}, + {"Valid config", "pxelinux.cfg", 1024, false}, + {"Invalid extension", "malware.exe", 1024, true}, + {"Hidden file", ".hidden.iso", 1024, true}, + {"Too large", "ubuntu.iso", 2 * 1024 * 1024 * 1024, true}, // 2GB + {"Reserved name", "CON.iso", 1024, true}, + {"Path traversal", "../../../etc/passwd.iso", 1024, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.ValidateTFTPUpload(tt.filename, tt.size) + if (err != nil) != tt.expectErr { + t.Errorf("ValidateTFTPUpload(%s, %d) error = %v, expectErr %v", tt.filename, tt.size, err, tt.expectErr) + } + }) + } +} + +func TestTFTPSecurityValidator_GetSafePath(t *testing.T) { + tmpDir := t.TempDir() + validator := NewTFTPSecurityValidator(tmpDir) + + tests := []struct { + name string + relativePath string + expectErr bool + }{ + {"Valid relative path", "ubuntu.iso", false}, + {"Valid nested path", "images/ubuntu.iso", false}, + {"Path traversal attempt", "../../../etc/passwd", true}, + {"Mixed path traversal", "images/../../../etc/passwd", true}, + {"Hidden file", ".hidden", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + safePath, err := validator.GetSafePath(tmpDir, tt.relativePath) + if (err != nil) != tt.expectErr { + t.Errorf("GetSafePath(%s) error = %v, expectErr %v", tt.relativePath, err, tt.expectErr) + } + + if err == nil { + // Ensure the safe path is within the base directory + relPath, err := filepath.Rel(tmpDir, safePath) + if err != nil || filepath.IsAbs(relPath) || strings.HasPrefix(relPath, "..") { + t.Errorf("GetSafePath(%s) returned unsafe path: %s", tt.relativePath, safePath) + } + } + }) + } +} + +func TestPathSecurityValidator_ValidatePathWithinBase(t *testing.T) { + tmpDir := t.TempDir() + validator := NewPathSecurityValidator([]string{tmpDir}) + + // Create a test file within the base directory + testFile := filepath.Join(tmpDir, "test.txt") + if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Create a file outside the base directory + outsideDir := t.TempDir() + outsideFile := filepath.Join(outsideDir, "outside.txt") + if err := os.WriteFile(outsideFile, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create outside file: %v", err) + } + + tests := []struct { + name string + path string + expectErr bool + }{ + {"File within base", testFile, false}, + {"File outside base", outsideFile, true}, + {"Path traversal to outside", filepath.Join(tmpDir, "../outside.txt"), true}, + {"Non-existent file within base", filepath.Join(tmpDir, "nonexistent.txt"), false}, // Path validation, not existence + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.ValidatePathWithinBase(tt.path) + if (err != nil) != tt.expectErr { + t.Errorf("ValidatePathWithinBase(%s) error = %v, expectErr %v", tt.path, err, tt.expectErr) + } + }) + } +} + +func TestGetDefaultSecurityConfig(t *testing.T) { + config := GetDefaultSecurityConfig() + + if config == nil { + t.Fatal("GetDefaultSecurityConfig returned nil") + } + + if config.MaxFileSize <= 0 { + t.Error("MaxFileSize should be positive") + } + + if config.MaxPathLength <= 0 { + t.Error("MaxPathLength should be positive") + } + + if len(config.AllowedTFTPTypes) == 0 { + t.Error("AllowedTFTPTypes should not be empty") + } + + if config.TFTPBasePath == "" { + t.Error("TFTPBasePath should not be empty") + } + + // Check that common TFTP file types are included + expectedTypes := []string{".iso", ".img", ".bin", ".cfg"} + for _, expectedType := range expectedTypes { + found := false + for _, allowedType := range config.AllowedTFTPTypes { + if allowedType == expectedType { + found = true + break + } + } + if !found { + t.Errorf("Expected TFTP type %s not found in AllowedTFTPTypes", expectedType) + } + } +} diff --git a/handlers/syslinux.go b/handlers/syslinux.go index 2096246..0d374e2 100644 --- a/handlers/syslinux.go +++ b/handlers/syslinux.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "strconv" "time" "ignite/syslinux" @@ -507,52 +506,6 @@ func (h *SyslinuxHandler) ValidateInstallation(w http.ResponseWriter, r *http.Re json.NewEncoder(w).Encode(result) } -// Helper function to parse pagination parameters -func parsePaginationParams(r *http.Request) (page, limit int, err error) { - pageStr := r.URL.Query().Get("page") - limitStr := r.URL.Query().Get("limit") - - page = 1 // default - if pageStr != "" { - if page, err = strconv.Atoi(pageStr); err != nil { - return 0, 0, fmt.Errorf("invalid page parameter") - } - } - - limit = 20 // default - if limitStr != "" { - if limit, err = strconv.Atoi(limitStr); err != nil { - return 0, 0, fmt.Errorf("invalid limit parameter") - } - } - - if page < 1 { - page = 1 - } - if limit < 1 || limit > 100 { - limit = 20 - } - - return page, limit, nil -} - -// Helper function to create paginated response -func createPaginatedResponse(data interface{}, total, page, limit int) map[string]interface{} { - totalPages := (total + limit - 1) / limit - - return map[string]interface{}{ - "data": data, - "pagination": map[string]interface{}{ - "current_page": page, - "total_pages": totalPages, - "total_items": total, - "items_per_page": limit, - "has_next": page < totalPages, - "has_previous": page > 1, - }, - } -} - // Helper methods for version management // deactivateCurrentVersion finds and deactivates any currently active version diff --git a/handlers/tftp.go b/handlers/tftp.go index ae04e32..9d32e5e 100644 --- a/handlers/tftp.go +++ b/handlers/tftp.go @@ -47,17 +47,59 @@ func (h *TFTPHandlers) HandleTFTPPage(w http.ResponseWriter, r *http.Request) { func (h *TFTPHandlers) HandleDownload(w http.ResponseWriter, r *http.Request) { fileName := r.URL.Query().Get("file") if fileName == "" { - http.Error(w, "File parameter is required", http.StatusBadRequest) + appErr := NewValidationError("Missing file parameter", "File parameter is required") + HandleError(w, r, appErr) + return + } + + // Create security validator + validator := NewTFTPSecurityValidator(TFTPDir) + + // Validate the file path + if err := validator.ValidateTFTPPath(fileName); err != nil { + appErr := NewValidationError( + fmt.Sprintf("Invalid file path: %v", err), + "The requested file path is not allowed", + ) + HandleError(w, r, appErr) + return + } + + // Get safe file path + filePath, err := validator.GetSafePath(TFTPDir, fileName) + if err != nil { + appErr := NewValidationError( + fmt.Sprintf("Cannot resolve safe path: %v", err), + "The requested file path is not allowed", + ) + HandleError(w, r, appErr) + return + } + + // Validate file size + if err := validator.pathValidator.ValidateFileSize(filePath); err != nil { + appErr := NewValidationError( + fmt.Sprintf("File validation failed: %v", err), + "The requested file is too large or invalid", + ) + HandleError(w, r, appErr) return } - filePath := filepath.Join(TFTPDir, fileName) file, err := os.Open(filePath) if err != nil { if os.IsNotExist(err) { - http.Error(w, "File not found", http.StatusNotFound) + appErr := NewNotFoundError( + fmt.Sprintf("File not found: %s", fileName), + "The requested file does not exist", + ) + HandleError(w, r, appErr) } else { - http.Error(w, "Error opening file", http.StatusInternalServerError) + appErr := NewInternalError( + fmt.Sprintf("Error opening file %s: %v", fileName, err), + "Unable to open the requested file", + ) + HandleError(w, r, appErr) } return } diff --git a/handlers/validation.go b/handlers/validation.go new file mode 100644 index 0000000..68a3b7a --- /dev/null +++ b/handlers/validation.go @@ -0,0 +1,290 @@ +package handlers + +import ( + "fmt" + "net" + "regexp" + "strconv" + "strings" +) + +// IPValidator provides IP address validation functionality +type IPValidator struct{} + +// NewIPValidator creates a new IP validator +func NewIPValidator() *IPValidator { + return &IPValidator{} +} + +// ValidateIPAddress validates an IP address string +func (v *IPValidator) ValidateIPAddress(ip string) error { + if ip == "" { + return fmt.Errorf("IP address cannot be empty") + } + + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return fmt.Errorf("invalid IP address format: %s", ip) + } + + // Check if it's IPv4 + if parsedIP.To4() == nil { + return fmt.Errorf("only IPv4 addresses are supported: %s", ip) + } + + return nil +} + +// ValidateIPRange validates an IP range (start-end format) +func (v *IPValidator) ValidateIPRange(rangeStr string) error { + if rangeStr == "" { + return fmt.Errorf("IP range cannot be empty") + } + + parts := strings.Split(rangeStr, "-") + if len(parts) != 2 { + return fmt.Errorf("IP range must be in format 'start-end': %s", rangeStr) + } + + startIP := strings.TrimSpace(parts[0]) + endIP := strings.TrimSpace(parts[1]) + + // Validate both IPs + if err := v.ValidateIPAddress(startIP); err != nil { + return fmt.Errorf("invalid start IP in range: %v", err) + } + + if err := v.ValidateIPAddress(endIP); err != nil { + return fmt.Errorf("invalid end IP in range: %v", err) + } + + // Check if start IP is less than or equal to end IP + start := net.ParseIP(startIP).To4() + end := net.ParseIP(endIP).To4() + + if compareIPs(start, end) > 0 { + return fmt.Errorf("start IP (%s) must be less than or equal to end IP (%s)", startIP, endIP) + } + + return nil +} + +// ValidateSubnet validates a subnet in CIDR notation +func (v *IPValidator) ValidateSubnet(subnet string) error { + if subnet == "" { + return fmt.Errorf("subnet cannot be empty") + } + + _, network, err := net.ParseCIDR(subnet) + if err != nil { + return fmt.Errorf("invalid subnet format: %s", subnet) + } + + // Check if it's IPv4 + if network.IP.To4() == nil { + return fmt.Errorf("only IPv4 subnets are supported: %s", subnet) + } + + return nil +} + +// ValidateIPInSubnet checks if an IP address is within a subnet +func (v *IPValidator) ValidateIPInSubnet(ip, subnet string) error { + if err := v.ValidateIPAddress(ip); err != nil { + return err + } + + if err := v.ValidateSubnet(subnet); err != nil { + return err + } + + _, network, _ := net.ParseCIDR(subnet) + ipAddr := net.ParseIP(ip) + + if !network.Contains(ipAddr) { + return fmt.Errorf("IP address %s is not within subnet %s", ip, subnet) + } + + return nil +} + +// ValidateRangeInSubnet checks if an IP range is within a subnet +func (v *IPValidator) ValidateRangeInSubnet(rangeStr, subnet string) error { + if err := v.ValidateIPRange(rangeStr); err != nil { + return err + } + + if err := v.ValidateSubnet(subnet); err != nil { + return err + } + + parts := strings.Split(rangeStr, "-") + startIP := strings.TrimSpace(parts[0]) + endIP := strings.TrimSpace(parts[1]) + + if err := v.ValidateIPInSubnet(startIP, subnet); err != nil { + return fmt.Errorf("start IP not in subnet: %v", err) + } + + if err := v.ValidateIPInSubnet(endIP, subnet); err != nil { + return fmt.Errorf("end IP not in subnet: %v", err) + } + + return nil +} + +// ValidateMACAddress validates a MAC address +func (v *IPValidator) ValidateMACAddress(mac string) error { + if mac == "" { + return fmt.Errorf("MAC address cannot be empty") + } + + // Support both colon and hyphen separated formats, but not mixed + colonRegex := regexp.MustCompile(`^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$`) + hyphenRegex := regexp.MustCompile(`^([0-9A-Fa-f]{2}-){5}([0-9A-Fa-f]{2})$`) + + if !colonRegex.MatchString(mac) && !hyphenRegex.MatchString(mac) { + return fmt.Errorf("invalid MAC address format: %s (expected format: XX:XX:XX:XX:XX:XX or XX-XX-XX-XX-XX-XX)", mac) + } + + return nil +} + +// ValidatePort validates a port number +func (v *IPValidator) ValidatePort(port string) error { + if port == "" { + return fmt.Errorf("port cannot be empty") + } + + portNum, err := strconv.Atoi(port) + if err != nil { + return fmt.Errorf("invalid port number: %s", port) + } + + if portNum < 1 || portNum > 65535 { + return fmt.Errorf("port number must be between 1 and 65535: %d", portNum) + } + + return nil +} + +// ValidateHostname validates a hostname +func (v *IPValidator) ValidateHostname(hostname string) error { + if hostname == "" { + return fmt.Errorf("hostname cannot be empty") + } + + if len(hostname) > 253 { + return fmt.Errorf("hostname too long (max 253 characters): %s", hostname) + } + + // Hostname regex (simplified) + hostnameRegex := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`) + if !hostnameRegex.MatchString(hostname) { + return fmt.Errorf("invalid hostname format: %s", hostname) + } + + return nil +} + +// DHCPConfigValidator validates DHCP configuration +type DHCPConfigValidator struct { + ipValidator *IPValidator +} + +// NewDHCPConfigValidator creates a new DHCP config validator +func NewDHCPConfigValidator() *DHCPConfigValidator { + return &DHCPConfigValidator{ + ipValidator: NewIPValidator(), + } +} + +// ValidateDHCPConfig validates a complete DHCP configuration +func (v *DHCPConfigValidator) ValidateDHCPConfig(config map[string]string) ValidationErrors { + errors := make(ValidationErrors) + + // Validate subnet + if subnet, ok := config["subnet"]; ok { + if err := v.ipValidator.ValidateSubnet(subnet); err != nil { + errors.Add("subnet", err.Error()) + } + } else { + errors.Add("subnet", "subnet is required") + } + + // Validate IP range + if rangeStr, ok := config["range"]; ok { + if err := v.ipValidator.ValidateIPRange(rangeStr); err != nil { + errors.Add("range", err.Error()) + } else if subnet, ok := config["subnet"]; ok { + // Validate range is within subnet + if err := v.ipValidator.ValidateRangeInSubnet(rangeStr, subnet); err != nil { + errors.Add("range", err.Error()) + } + } + } else { + errors.Add("range", "IP range is required") + } + + // Validate router (gateway) if provided + if router, ok := config["router"]; ok && router != "" { + if err := v.ipValidator.ValidateIPAddress(router); err != nil { + errors.Add("router", err.Error()) + } else if subnet, ok := config["subnet"]; ok { + // Validate router is within subnet + if err := v.ipValidator.ValidateIPInSubnet(router, subnet); err != nil { + errors.Add("router", err.Error()) + } + } + } + + // Validate DNS servers if provided + if dns, ok := config["dns"]; ok && dns != "" { + dnsServers := strings.Split(dns, ",") + for i, server := range dnsServers { + server = strings.TrimSpace(server) + if err := v.ipValidator.ValidateIPAddress(server); err != nil { + errors.Add("dns", fmt.Sprintf("DNS server %d: %v", i+1, err)) + } + } + } + + // Validate lease time if provided + if leaseTime, ok := config["lease_time"]; ok && leaseTime != "" { + if _, err := strconv.Atoi(leaseTime); err != nil { + errors.Add("lease_time", "lease time must be a valid number (seconds)") + } + } + + return errors +} + +// ValidateReservation validates a DHCP reservation +func (v *DHCPConfigValidator) ValidateReservation(ip, mac string) ValidationErrors { + errors := make(ValidationErrors) + + if err := v.ipValidator.ValidateIPAddress(ip); err != nil { + errors.Add("ip", err.Error()) + } + + if err := v.ipValidator.ValidateMACAddress(mac); err != nil { + errors.Add("mac", err.Error()) + } + + return errors +} + +// Helper function to compare IPv4 addresses +func compareIPs(ip1, ip2 net.IP) int { + ip1 = ip1.To4() + ip2 = ip2.To4() + + for i := 0; i < 4; i++ { + if ip1[i] < ip2[i] { + return -1 + } else if ip1[i] > ip2[i] { + return 1 + } + } + return 0 +} diff --git a/handlers/validation_test.go b/handlers/validation_test.go new file mode 100644 index 0000000..517ee1b --- /dev/null +++ b/handlers/validation_test.go @@ -0,0 +1,220 @@ +package handlers + +import ( + "testing" +) + +func TestIPValidator_ValidateIPAddress(t *testing.T) { + validator := NewIPValidator() + + tests := []struct { + name string + ip string + expectErr bool + }{ + {"Valid IPv4", "192.168.1.1", false}, + {"Valid IPv4 with zero", "10.0.0.1", false}, + {"Invalid format", "192.168.1", true}, + {"Empty string", "", true}, + {"Invalid characters", "192.168.1.a", true}, + {"IPv6 address", "2001:db8::1", true}, // We only support IPv4 + {"Out of range", "256.256.256.256", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.ValidateIPAddress(tt.ip) + if (err != nil) != tt.expectErr { + t.Errorf("ValidateIPAddress(%s) error = %v, expectErr %v", tt.ip, err, tt.expectErr) + } + }) + } +} + +func TestIPValidator_ValidateIPRange(t *testing.T) { + validator := NewIPValidator() + + tests := []struct { + name string + rangeStr string + expectErr bool + }{ + {"Valid range", "192.168.1.10-192.168.1.20", false}, + {"Single IP range", "192.168.1.10-192.168.1.10", false}, + {"Invalid format", "192.168.1.10", true}, + {"Reversed range", "192.168.1.20-192.168.1.10", true}, + {"Invalid start IP", "192.168.1-192.168.1.20", true}, + {"Invalid end IP", "192.168.1.10-192.168.1", true}, + {"Empty string", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.ValidateIPRange(tt.rangeStr) + if (err != nil) != tt.expectErr { + t.Errorf("ValidateIPRange(%s) error = %v, expectErr %v", tt.rangeStr, err, tt.expectErr) + } + }) + } +} + +func TestIPValidator_ValidateSubnet(t *testing.T) { + validator := NewIPValidator() + + tests := []struct { + name string + subnet string + expectErr bool + }{ + {"Valid CIDR", "192.168.1.0/24", false}, + {"Valid CIDR /16", "10.0.0.0/16", false}, + {"Valid CIDR /8", "172.16.0.0/8", false}, + {"Invalid CIDR", "192.168.1.0/33", true}, + {"No CIDR", "192.168.1.0", true}, + {"Invalid IP", "192.168.1/24", true}, + {"Empty string", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.ValidateSubnet(tt.subnet) + if (err != nil) != tt.expectErr { + t.Errorf("ValidateSubnet(%s) error = %v, expectErr %v", tt.subnet, err, tt.expectErr) + } + }) + } +} + +func TestIPValidator_ValidateMACAddress(t *testing.T) { + validator := NewIPValidator() + + tests := []struct { + name string + mac string + expectErr bool + }{ + {"Valid MAC colon", "00:11:22:33:44:55", false}, + {"Valid MAC hyphen", "00-11-22-33-44-55", false}, + {"Valid MAC uppercase", "AA:BB:CC:DD:EE:FF", false}, + {"Valid MAC lowercase", "aa:bb:cc:dd:ee:ff", false}, + {"Invalid format", "00:11:22:33:44", true}, + {"Invalid characters", "00:11:22:33:44:GG", true}, + {"Mixed separators", "00:11-22:33:44:55", true}, + {"Empty string", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.ValidateMACAddress(tt.mac) + if (err != nil) != tt.expectErr { + t.Errorf("ValidateMACAddress(%s) error = %v, expectErr %v", tt.mac, err, tt.expectErr) + } + }) + } +} + +func TestDHCPConfigValidator_ValidateDHCPConfig(t *testing.T) { + validator := NewDHCPConfigValidator() + + tests := []struct { + name string + config map[string]string + expectErr bool + }{ + { + name: "Valid config", + config: map[string]string{ + "subnet": "192.168.1.0/24", + "range": "192.168.1.10-192.168.1.20", + "router": "192.168.1.1", + "dns": "8.8.8.8", + }, + expectErr: false, + }, + { + name: "Missing subnet", + config: map[string]string{ + "range": "192.168.1.10-192.168.1.20", + "router": "192.168.1.1", + }, + expectErr: true, + }, + { + name: "Invalid subnet", + config: map[string]string{ + "subnet": "192.168.1.0/33", + "range": "192.168.1.10-192.168.1.20", + }, + expectErr: true, + }, + { + name: "Range outside subnet", + config: map[string]string{ + "subnet": "192.168.1.0/24", + "range": "192.168.2.10-192.168.2.20", + }, + expectErr: true, + }, + { + name: "Router outside subnet", + config: map[string]string{ + "subnet": "192.168.1.0/24", + "range": "192.168.1.10-192.168.1.20", + "router": "192.168.2.1", + }, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errors := validator.ValidateDHCPConfig(tt.config) + hasErr := errors.HasErrors() + if hasErr != tt.expectErr { + t.Errorf("ValidateDHCPConfig(%+v) hasErrors = %v, expectErr %v", tt.config, hasErr, tt.expectErr) + if hasErr { + t.Logf("Validation errors: %+v", errors) + } + } + }) + } +} + +func TestValidationErrors(t *testing.T) { + errors := make(ValidationErrors) + + // Test empty errors + if errors.HasErrors() { + t.Error("Expected no errors for empty ValidationErrors") + } + + // Add some errors + errors.Add("field1", "error1") + errors.Add("field1", "error2") + errors.Add("field2", "error3") + + if !errors.HasErrors() { + t.Error("Expected errors after adding") + } + + // Check field1 has 2 errors + if len(errors["field1"]) != 2 { + t.Errorf("Expected 2 errors for field1, got %d", len(errors["field1"])) + } + + // Check field2 has 1 error + if len(errors["field2"]) != 1 { + t.Errorf("Expected 1 error for field2, got %d", len(errors["field2"])) + } + + // Test ToAppError + appErr := errors.ToAppError() + if appErr == nil { + t.Error("Expected AppError from ToAppError") + return + } + + if appErr.Type != ErrorTypeValidation { + t.Errorf("Expected validation error type, got %s", appErr.Type) + } +} diff --git a/interfaces/service.go b/interfaces/service.go new file mode 100644 index 0000000..649fd97 --- /dev/null +++ b/interfaces/service.go @@ -0,0 +1,307 @@ +package interfaces + +import ( + "context" + "time" +) + +// ServiceStatus represents the status of a service +type ServiceStatus string + +const ( + StatusStopped ServiceStatus = "stopped" + StatusStarting ServiceStatus = "starting" + StatusRunning ServiceStatus = "running" + StatusStopping ServiceStatus = "stopping" + StatusError ServiceStatus = "error" + StatusUnknown ServiceStatus = "unknown" +) + +// ServiceInfo contains information about a service +type ServiceInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Status ServiceStatus `json:"status"` + Port int `json:"port,omitempty"` + StartTime *time.Time `json:"start_time,omitempty"` + Config map[string]interface{} `json:"config,omitempty"` + Metrics map[string]interface{} `json:"metrics,omitempty"` + Health *HealthCheck `json:"health,omitempty"` +} + +// HealthCheck represents service health information +type HealthCheck struct { + Status string `json:"status"` + LastCheck time.Time `json:"last_check"` + Message string `json:"message,omitempty"` + Details map[string]string `json:"details,omitempty"` + CheckCount int `json:"check_count"` + FailCount int `json:"fail_count"` + Uptime time.Duration `json:"uptime"` +} + +// ServiceOptions contains options for service configuration +type ServiceOptions struct { + AutoStart bool `json:"auto_start"` + RestartOnFail bool `json:"restart_on_fail"` + MaxRestarts int `json:"max_restarts"` + Config map[string]interface{} `json:"config"` + Environment map[string]string `json:"environment"` + Dependencies []string `json:"dependencies"` +} + +// Service defines the standard interface for all services +type Service interface { + // Basic service lifecycle + Start(ctx context.Context) error + Stop(ctx context.Context) error + Restart(ctx context.Context) error + + // Service information + GetInfo() ServiceInfo + GetStatus() ServiceStatus + IsRunning() bool + + // Health and monitoring + HealthCheck() *HealthCheck + GetMetrics() map[string]interface{} + + // Configuration + Configure(options ServiceOptions) error + GetConfig() map[string]interface{} + ValidateConfig(config map[string]interface{}) error +} + +// ManagedService extends Service with management capabilities +type ManagedService interface { + Service + + // Management operations + Enable() error + Disable() error + IsEnabled() bool + + // Logging and monitoring + GetLogs(ctx context.Context, lines int) ([]string, error) + Subscribe() <-chan ServiceEvent + Unsubscribe() +} + +// ServiceEvent represents events that can occur in a service +type ServiceEvent struct { + Type string `json:"type"` + Service string `json:"service"` + Timestamp time.Time `json:"timestamp"` + Message string `json:"message"` + Data map[string]interface{} `json:"data,omitempty"` + Level string `json:"level"` // info, warning, error +} + +// ServiceManager manages multiple services +type ServiceManager interface { + // Service management + RegisterService(name string, service Service) error + UnregisterService(name string) error + GetService(name string) (Service, error) + ListServices() []ServiceInfo + + // Bulk operations + StartAll(ctx context.Context) error + StopAll(ctx context.Context) error + RestartAll(ctx context.Context) error + + // Health monitoring + HealthCheckAll() map[string]*HealthCheck + GetOverallHealth() *HealthCheck + + // Events + Subscribe() <-chan ServiceEvent + Unsubscribe() +} + +// ConfigurableService defines services that can be reconfigured +type ConfigurableService interface { + Service + + // Configuration management + UpdateConfig(config map[string]interface{}) error + ReloadConfig() error + GetConfigSchema() map[string]interface{} + ExportConfig() (map[string]interface{}, error) + ImportConfig(config map[string]interface{}) error +} + +// NetworkService defines services that use network resources +type NetworkService interface { + Service + + // Network information + GetListenAddresses() []string + GetPort() int + IsPortInUse(port int) bool + + // Network configuration + BindToAddress(address string) error + ChangePort(port int) error +} + +// StorageService defines services that manage storage +type StorageService interface { + Service + + // Storage operations + GetStoragePath() string + GetStorageUsage() (used, available int64, err error) + CleanupStorage() error + BackupData(destination string) error + RestoreData(source string) error +} + +// DHCPService defines DHCP-specific operations +type DHCPService interface { + NetworkService + ConfigurableService + + // DHCP operations + CreateServer(config map[string]interface{}) (string, error) + DeleteServer(id string) error + GetServers() ([]map[string]interface{}, error) + + // Lease management + GetLeases(serverID string) ([]map[string]interface{}, error) + ReserveLease(serverID, ip, mac string) error + ReleaseLease(serverID, ip string) error + + // Configuration + UpdateServerConfig(id string, config map[string]interface{}) error +} + +// TFTPService defines TFTP-specific operations +type TFTPService interface { + NetworkService + StorageService + + // File operations + ListFiles(path string) ([]FileInfo, error) + GetFile(path string) ([]byte, error) + PutFile(path string, data []byte) error + DeleteFile(path string) error + + // Directory operations + CreateDirectory(path string) error + DeleteDirectory(path string) error + GetDirectorySize(path string) (int64, error) +} + +// OSImageService defines OS image management operations +type OSImageService interface { + StorageService + + // Image management + ListImages() ([]ImageInfo, error) + DownloadImage(url, destination string) (string, error) + DeleteImage(id string) error + GetImageInfo(id string) (*ImageInfo, error) + + // Image operations + ExtractImage(id, destination string) error + VerifyImage(id string) error + SetDefaultImage(id string) error +} + +// ProvisionService defines provisioning operations +type ProvisionService interface { + ConfigurableService + + // Template management + ListTemplates() ([]TemplateInfo, error) + GetTemplate(name string) (*TemplateInfo, error) + SaveTemplate(name string, content []byte) error + DeleteTemplate(name string) error + + // Provisioning operations + GenerateConfig(template string, variables map[string]interface{}) ([]byte, error) + ValidateTemplate(content []byte) error +} + +// FileInfo represents file information +type FileInfo struct { + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + IsDirectory bool `json:"is_directory"` + ModTime time.Time `json:"mod_time"` + Permissions string `json:"permissions"` + Owner string `json:"owner,omitempty"` + Group string `json:"group,omitempty"` + ContentType string `json:"content_type,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// ImageInfo represents OS image information +type ImageInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Architecture string `json:"architecture"` + Type string `json:"type"` + Size int64 `json:"size"` + Checksum string `json:"checksum"` + Downloaded bool `json:"downloaded"` + IsDefault bool `json:"is_default"` + Metadata map[string]string `json:"metadata"` + DownloadURL string `json:"download_url,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Template type constants +const ( + TemplateTypeKickstart = "kickstart" // Red Hat/CentOS/Fedora + TemplateTypePreseed = "preseed" // Debian/Ubuntu + TemplateTypeAutoYaST = "autoyast" // SUSE/openSUSE + TemplateTypeCloudInit = "cloud-init" // Modern cloud images + TemplateTypeIPXE = "ipxe" // Custom boot scripts +) + +// TemplateInfo represents template information +type TemplateInfo struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + Content string `json:"content"` + Variables []VariableInfo `json:"variables"` + Metadata map[string]string `json:"metadata"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// VariableInfo represents template variable information +type VariableInfo struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + Required bool `json:"required"` + DefaultValue interface{} `json:"default_value,omitempty"` + Options []string `json:"options,omitempty"` +} + +// ServiceEvent types +const ( + EventTypeStarted = "started" + EventTypeStopped = "stopped" + EventTypeRestarted = "restarted" + EventTypeConfigured = "configured" + EventTypeError = "error" + EventTypeHealthCheck = "health_check" + EventTypeMetrics = "metrics" +) + +// Event levels +const ( + LevelInfo = "info" + LevelWarning = "warning" + LevelError = "error" + LevelDebug = "debug" +) diff --git a/interfaces/service_test.go b/interfaces/service_test.go new file mode 100644 index 0000000..305afac --- /dev/null +++ b/interfaces/service_test.go @@ -0,0 +1,601 @@ +package interfaces + +import ( + "context" + "testing" + "time" +) + +// MockService implements the Service interface for testing +type MockService struct { + id string + name string + status ServiceStatus + running bool + config map[string]interface{} + metrics map[string]interface{} + health *HealthCheck + startError error + stopError error + configError error +} + +func NewMockService(id, name string) *MockService { + return &MockService{ + id: id, + name: name, + status: StatusStopped, + running: false, + config: make(map[string]interface{}), + metrics: make(map[string]interface{}), + health: &HealthCheck{ + Status: "ok", + LastCheck: time.Now(), + Message: "Service is healthy", + }, + } +} + +func (m *MockService) Start(ctx context.Context) error { + if m.startError != nil { + return m.startError + } + m.status = StatusRunning + m.running = true + return nil +} + +func (m *MockService) Stop(ctx context.Context) error { + if m.stopError != nil { + return m.stopError + } + m.status = StatusStopped + m.running = false + return nil +} + +func (m *MockService) Restart(ctx context.Context) error { + if err := m.Stop(ctx); err != nil { + return err + } + return m.Start(ctx) +} + +func (m *MockService) GetInfo() ServiceInfo { + startTime := time.Now() + return ServiceInfo{ + ID: m.id, + Name: m.name, + Description: "Mock service for testing", + Status: m.status, + StartTime: &startTime, + Config: m.config, + Metrics: m.metrics, + Health: m.health, + } +} + +func (m *MockService) GetStatus() ServiceStatus { + return m.status +} + +func (m *MockService) IsRunning() bool { + return m.running +} + +func (m *MockService) HealthCheck() *HealthCheck { + return m.health +} + +func (m *MockService) GetMetrics() map[string]interface{} { + return m.metrics +} + +func (m *MockService) Configure(options ServiceOptions) error { + if m.configError != nil { + return m.configError + } + m.config = options.Config + return nil +} + +func (m *MockService) GetConfig() map[string]interface{} { + return m.config +} + +func (m *MockService) ValidateConfig(config map[string]interface{}) error { + if m.configError != nil { + return m.configError + } + return nil +} + +// Test Service interface implementation +func TestMockService_ImplementsService(t *testing.T) { + var _ Service = &MockService{} +} + +func TestMockService_Lifecycle(t *testing.T) { + service := NewMockService("test-1", "Test Service") + ctx := context.Background() + + // Initial state + if service.GetStatus() != StatusStopped { + t.Errorf("Expected initial status to be %s, got %s", StatusStopped, service.GetStatus()) + } + + if service.IsRunning() { + t.Error("Expected service to not be running initially") + } + + // Start service + err := service.Start(ctx) + if err != nil { + t.Errorf("Failed to start service: %v", err) + } + + if service.GetStatus() != StatusRunning { + t.Errorf("Expected status to be %s after start, got %s", StatusRunning, service.GetStatus()) + } + + if !service.IsRunning() { + t.Error("Expected service to be running after start") + } + + // Stop service + err = service.Stop(ctx) + if err != nil { + t.Errorf("Failed to stop service: %v", err) + } + + if service.GetStatus() != StatusStopped { + t.Errorf("Expected status to be %s after stop, got %s", StatusStopped, service.GetStatus()) + } + + if service.IsRunning() { + t.Error("Expected service to not be running after stop") + } + + // Restart service + err = service.Restart(ctx) + if err != nil { + t.Errorf("Failed to restart service: %v", err) + } + + if service.GetStatus() != StatusRunning { + t.Errorf("Expected status to be %s after restart, got %s", StatusRunning, service.GetStatus()) + } +} + +func TestMockService_Configuration(t *testing.T) { + service := NewMockService("test-2", "Test Service 2") + + // Initial config should be empty + config := service.GetConfig() + if len(config) != 0 { + t.Errorf("Expected empty initial config, got %d items", len(config)) + } + + // Configure service + options := ServiceOptions{ + Config: map[string]interface{}{ + "timeout": 30, + "retries": 3, + }, + } + + err := service.Configure(options) + if err != nil { + t.Errorf("Failed to configure service: %v", err) + } + + // Check config was applied + newConfig := service.GetConfig() + if len(newConfig) != 2 { + t.Errorf("Expected 2 config items, got %d", len(newConfig)) + } + + if newConfig["timeout"] != 30 { + t.Errorf("Expected timeout to be 30, got %v", newConfig["timeout"]) + } + + if newConfig["retries"] != 3 { + t.Errorf("Expected retries to be 3, got %v", newConfig["retries"]) + } + + // Validate config + err = service.ValidateConfig(newConfig) + if err != nil { + t.Errorf("Config validation failed: %v", err) + } +} + +func TestMockService_HealthAndMetrics(t *testing.T) { + service := NewMockService("test-3", "Test Service 3") + + // Check health + health := service.HealthCheck() + if health == nil { + t.Fatal("Expected health check to return non-nil") + } + + if health.Status != "ok" { + t.Errorf("Expected health status to be 'ok', got %s", health.Status) + } + + // Check metrics + metrics := service.GetMetrics() + if metrics == nil { + t.Fatal("Expected metrics to return non-nil map") + } +} + +func TestMockService_Info(t *testing.T) { + service := NewMockService("test-4", "Test Service 4") + + info := service.GetInfo() + + if info.ID != "test-4" { + t.Errorf("Expected ID to be 'test-4', got %s", info.ID) + } + + if info.Name != "Test Service 4" { + t.Errorf("Expected name to be 'Test Service 4', got %s", info.Name) + } + + if info.Status != StatusStopped { + t.Errorf("Expected status to be %s, got %s", StatusStopped, info.Status) + } + + if info.StartTime == nil { + t.Error("Expected start time to be set") + } + + if info.Config == nil { + t.Error("Expected config to be non-nil") + } + + if info.Metrics == nil { + t.Error("Expected metrics to be non-nil") + } + + if info.Health == nil { + t.Error("Expected health to be non-nil") + } +} + +// Test ServiceStatus constants +func TestServiceStatus_Constants(t *testing.T) { + statuses := []ServiceStatus{ + StatusStopped, + StatusStarting, + StatusRunning, + StatusStopping, + StatusError, + StatusUnknown, + } + + expectedValues := []string{ + "stopped", + "starting", + "running", + "stopping", + "error", + "unknown", + } + + for i, status := range statuses { + if string(status) != expectedValues[i] { + t.Errorf("Expected status %d to be %s, got %s", i, expectedValues[i], string(status)) + } + } +} + +// Test HealthCheck structure +func TestHealthCheck_Structure(t *testing.T) { + health := &HealthCheck{ + Status: "healthy", + LastCheck: time.Now(), + Message: "All systems operational", + Details: map[string]string{"cpu": "50%", "memory": "60%"}, + CheckCount: 100, + FailCount: 2, + Uptime: time.Hour * 24, + } + + if health.Status != "healthy" { + t.Error("Health status not set correctly") + } + + if health.Message == "" { + t.Error("Health message should not be empty") + } + + if health.Details == nil { + t.Error("Health details should not be nil") + } + + if len(health.Details) != 2 { + t.Errorf("Expected 2 health details, got %d", len(health.Details)) + } + + if health.CheckCount != 100 { + t.Errorf("Expected check count to be 100, got %d", health.CheckCount) + } + + if health.FailCount != 2 { + t.Errorf("Expected fail count to be 2, got %d", health.FailCount) + } +} + +// Test ServiceOptions structure +func TestServiceOptions_Structure(t *testing.T) { + options := ServiceOptions{ + AutoStart: true, + RestartOnFail: true, + MaxRestarts: 5, + Config: map[string]interface{}{ + "port": 8080, + "host": "localhost", + }, + Environment: map[string]string{ + "ENV": "test", + "DEBUG": "true", + }, + Dependencies: []string{"database", "cache"}, + } + + if !options.AutoStart { + t.Error("AutoStart should be true") + } + + if !options.RestartOnFail { + t.Error("RestartOnFail should be true") + } + + if options.MaxRestarts != 5 { + t.Errorf("Expected MaxRestarts to be 5, got %d", options.MaxRestarts) + } + + if len(options.Config) != 2 { + t.Errorf("Expected 2 config items, got %d", len(options.Config)) + } + + if len(options.Environment) != 2 { + t.Errorf("Expected 2 environment variables, got %d", len(options.Environment)) + } + + if len(options.Dependencies) != 2 { + t.Errorf("Expected 2 dependencies, got %d", len(options.Dependencies)) + } +} + +// Test ServiceEvent structure +func TestServiceEvent_Structure(t *testing.T) { + event := ServiceEvent{ + Type: EventTypeStarted, + Service: "test-service", + Timestamp: time.Now(), + Message: "Service started successfully", + Data: map[string]interface{}{ + "port": 8080, + }, + Level: LevelInfo, + } + + if event.Type != EventTypeStarted { + t.Errorf("Expected event type to be %s, got %s", EventTypeStarted, event.Type) + } + + if event.Service != "test-service" { + t.Errorf("Expected service to be 'test-service', got %s", event.Service) + } + + if event.Message == "" { + t.Error("Event message should not be empty") + } + + if event.Level != LevelInfo { + t.Errorf("Expected level to be %s, got %s", LevelInfo, event.Level) + } +} + +// Test event type constants +func TestEventType_Constants(t *testing.T) { + events := []string{ + EventTypeStarted, + EventTypeStopped, + EventTypeRestarted, + EventTypeConfigured, + EventTypeError, + EventTypeHealthCheck, + EventTypeMetrics, + } + + expectedValues := []string{ + "started", + "stopped", + "restarted", + "configured", + "error", + "health_check", + "metrics", + } + + for i, event := range events { + if event != expectedValues[i] { + t.Errorf("Expected event %d to be %s, got %s", i, expectedValues[i], event) + } + } +} + +// Test log level constants +func TestLogLevel_Constants(t *testing.T) { + levels := []string{ + LevelInfo, + LevelWarning, + LevelError, + LevelDebug, + } + + expectedValues := []string{ + "info", + "warning", + "error", + "debug", + } + + for i, level := range levels { + if level != expectedValues[i] { + t.Errorf("Expected level %d to be %s, got %s", i, expectedValues[i], level) + } + } +} + +// Test FileInfo structure +func TestFileInfo_Structure(t *testing.T) { + fileInfo := FileInfo{ + Name: "test.txt", + Path: "/path/to/test.txt", + Size: 1024, + IsDirectory: false, + ModTime: time.Now(), + Permissions: "644", + Owner: "user", + Group: "group", + ContentType: "text/plain", + Metadata: map[string]string{ + "encoding": "utf-8", + }, + } + + if fileInfo.Name != "test.txt" { + t.Errorf("Expected name to be 'test.txt', got %s", fileInfo.Name) + } + + if fileInfo.Size != 1024 { + t.Errorf("Expected size to be 1024, got %d", fileInfo.Size) + } + + if fileInfo.IsDirectory { + t.Error("Expected IsDirectory to be false") + } + + if fileInfo.Permissions != "644" { + t.Errorf("Expected permissions to be '644', got %s", fileInfo.Permissions) + } +} + +// Test ImageInfo structure +func TestImageInfo_Structure(t *testing.T) { + imageInfo := ImageInfo{ + ID: "ubuntu-22.04", + Name: "Ubuntu 22.04 LTS", + Version: "22.04", + Architecture: "amd64", + Type: "iso", + Size: 3221225472, // 3GB + Checksum: "sha256:abc123", + Downloaded: true, + IsDefault: false, + Metadata: map[string]string{ + "release_date": "2022-04-21", + }, + DownloadURL: "https://releases.ubuntu.com/22.04/ubuntu-22.04-desktop-amd64.iso", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if imageInfo.ID != "ubuntu-22.04" { + t.Errorf("Expected ID to be 'ubuntu-22.04', got %s", imageInfo.ID) + } + + if imageInfo.Architecture != "amd64" { + t.Errorf("Expected architecture to be 'amd64', got %s", imageInfo.Architecture) + } + + if !imageInfo.Downloaded { + t.Error("Expected Downloaded to be true") + } + + if imageInfo.IsDefault { + t.Error("Expected IsDefault to be false") + } +} + +// Test TemplateInfo structure +func TestTemplateInfo_Structure(t *testing.T) { + templateInfo := TemplateInfo{ + Name: "ubuntu-kickstart", + Type: "kickstart", + Description: "Ubuntu kickstart template", + Content: "#Ubuntu kickstart configuration", + Variables: []VariableInfo{ + { + Name: "hostname", + Type: "string", + Description: "System hostname", + Required: true, + DefaultValue: "ubuntu-server", + }, + }, + Metadata: map[string]string{ + "author": "system", + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if templateInfo.Name != "ubuntu-kickstart" { + t.Errorf("Expected name to be 'ubuntu-kickstart', got %s", templateInfo.Name) + } + + if templateInfo.Type != "kickstart" { + t.Errorf("Expected type to be 'kickstart', got %s", templateInfo.Type) + } + + if len(templateInfo.Variables) != 1 { + t.Errorf("Expected 1 variable, got %d", len(templateInfo.Variables)) + } + + variable := templateInfo.Variables[0] + if variable.Name != "hostname" { + t.Errorf("Expected variable name to be 'hostname', got %s", variable.Name) + } + + if !variable.Required { + t.Error("Expected variable to be required") + } +} + +// Test VariableInfo structure +func TestVariableInfo_Structure(t *testing.T) { + variable := VariableInfo{ + Name: "disk_size", + Type: "integer", + Description: "Disk size in GB", + Required: true, + DefaultValue: 20, + Options: []string{"10", "20", "50", "100"}, + } + + if variable.Name != "disk_size" { + t.Errorf("Expected name to be 'disk_size', got %s", variable.Name) + } + + if variable.Type != "integer" { + t.Errorf("Expected type to be 'integer', got %s", variable.Type) + } + + if !variable.Required { + t.Error("Expected variable to be required") + } + + if variable.DefaultValue != 20 { + t.Errorf("Expected default value to be 20, got %v", variable.DefaultValue) + } + + if len(variable.Options) != 4 { + t.Errorf("Expected 4 options, got %d", len(variable.Options)) + } +} diff --git a/interfaces/specialized_test.go b/interfaces/specialized_test.go new file mode 100644 index 0000000..7b41ba1 --- /dev/null +++ b/interfaces/specialized_test.go @@ -0,0 +1,598 @@ +package interfaces + +import ( + "context" + "testing" + "time" +) + +// MockManagedService implements ManagedService for testing +type MockManagedService struct { + *MockService + enabled bool + subscribers []chan ServiceEvent +} + +func NewMockManagedService(id, name string) *MockManagedService { + return &MockManagedService{ + MockService: NewMockService(id, name), + enabled: false, + subscribers: make([]chan ServiceEvent, 0), + } +} + +func (m *MockManagedService) Enable() error { + m.enabled = true + return nil +} + +func (m *MockManagedService) Disable() error { + m.enabled = false + return nil +} + +func (m *MockManagedService) IsEnabled() bool { + return m.enabled +} + +func (m *MockManagedService) GetLogs(ctx context.Context, lines int) ([]string, error) { + logs := make([]string, lines) + for i := 0; i < lines; i++ { + logs[i] = "Mock log line " + string(rune('0'+i)) + } + return logs, nil +} + +func (m *MockManagedService) Subscribe() <-chan ServiceEvent { + ch := make(chan ServiceEvent, 10) + m.subscribers = append(m.subscribers, ch) + return ch +} + +func (m *MockManagedService) Unsubscribe() { + for _, ch := range m.subscribers { + close(ch) + } + m.subscribers = make([]chan ServiceEvent, 0) +} + +// MockNetworkService implements NetworkService for testing +type MockNetworkService struct { + *MockService + addresses []string + port int +} + +func NewMockNetworkService(id, name string, port int) *MockNetworkService { + return &MockNetworkService{ + MockService: NewMockService(id, name), + addresses: []string{"127.0.0.1", "0.0.0.0"}, + port: port, + } +} + +func (m *MockNetworkService) GetListenAddresses() []string { + return m.addresses +} + +func (m *MockNetworkService) GetPort() int { + return m.port +} + +func (m *MockNetworkService) IsPortInUse(port int) bool { + return port == m.port +} + +func (m *MockNetworkService) BindToAddress(address string) error { + m.addresses = []string{address} + return nil +} + +func (m *MockNetworkService) ChangePort(port int) error { + m.port = port + return nil +} + +// MockStorageService implements StorageService for testing +type MockStorageService struct { + *MockService + storagePath string + used int64 + available int64 +} + +func NewMockStorageService(id, name, path string) *MockStorageService { + return &MockStorageService{ + MockService: NewMockService(id, name), + storagePath: path, + used: 1024 * 1024 * 100, // 100MB used + available: 1024 * 1024 * 900, // 900MB available + } +} + +func (m *MockStorageService) GetStoragePath() string { + return m.storagePath +} + +func (m *MockStorageService) GetStorageUsage() (used, available int64, err error) { + return m.used, m.available, nil +} + +func (m *MockStorageService) CleanupStorage() error { + m.used = 0 + return nil +} + +func (m *MockStorageService) BackupData(destination string) error { + // Mock backup operation + return nil +} + +func (m *MockStorageService) RestoreData(source string) error { + // Mock restore operation + return nil +} + +// MockServiceManager implements ServiceManager for testing +type MockServiceManager struct { + services map[string]Service + subscribers []chan ServiceEvent +} + +func NewMockServiceManager() *MockServiceManager { + return &MockServiceManager{ + services: make(map[string]Service), + subscribers: make([]chan ServiceEvent, 0), + } +} + +func (m *MockServiceManager) RegisterService(name string, service Service) error { + m.services[name] = service + return nil +} + +func (m *MockServiceManager) UnregisterService(name string) error { + delete(m.services, name) + return nil +} + +func (m *MockServiceManager) GetService(name string) (Service, error) { + if service, exists := m.services[name]; exists { + return service, nil + } + return nil, ErrServiceNotFound +} + +func (m *MockServiceManager) ListServices() []ServiceInfo { + infos := make([]ServiceInfo, 0, len(m.services)) + for _, service := range m.services { + infos = append(infos, service.GetInfo()) + } + return infos +} + +func (m *MockServiceManager) StartAll(ctx context.Context) error { + for _, service := range m.services { + if err := service.Start(ctx); err != nil { + return err + } + } + return nil +} + +func (m *MockServiceManager) StopAll(ctx context.Context) error { + for _, service := range m.services { + if err := service.Stop(ctx); err != nil { + return err + } + } + return nil +} + +func (m *MockServiceManager) RestartAll(ctx context.Context) error { + for _, service := range m.services { + if err := service.Restart(ctx); err != nil { + return err + } + } + return nil +} + +func (m *MockServiceManager) HealthCheckAll() map[string]*HealthCheck { + health := make(map[string]*HealthCheck) + for name, service := range m.services { + health[name] = service.HealthCheck() + } + return health +} + +func (m *MockServiceManager) GetOverallHealth() *HealthCheck { + return &HealthCheck{ + Status: "healthy", + LastCheck: time.Now(), + Message: "All services are healthy", + } +} + +func (m *MockServiceManager) Subscribe() <-chan ServiceEvent { + ch := make(chan ServiceEvent, 10) + m.subscribers = append(m.subscribers, ch) + return ch +} + +func (m *MockServiceManager) Unsubscribe() { + for _, ch := range m.subscribers { + close(ch) + } + m.subscribers = make([]chan ServiceEvent, 0) +} + +// Custom error for testing +var ErrServiceNotFound = &ServiceError{ + Code: "SERVICE_NOT_FOUND", + Message: "Service not found", +} + +type ServiceError struct { + Code string + Message string +} + +func (e *ServiceError) Error() string { + return e.Message +} + +// Test interface compliance +func TestInterfaceCompliance(t *testing.T) { + // Test Service interface + var _ Service = &MockService{} + + // Test ManagedService interface + var _ ManagedService = &MockManagedService{} + var _ Service = &MockManagedService{} // ManagedService should also implement Service + + // Test NetworkService interface + var _ NetworkService = &MockNetworkService{} + var _ Service = &MockNetworkService{} // NetworkService should also implement Service + + // Test StorageService interface + var _ StorageService = &MockStorageService{} + var _ Service = &MockStorageService{} // StorageService should also implement Service + + // Test ServiceManager interface + var _ ServiceManager = &MockServiceManager{} +} + +func TestManagedService_EnableDisable(t *testing.T) { + service := NewMockManagedService("managed-1", "Managed Service") + + // Initially disabled + if service.IsEnabled() { + t.Error("Expected service to be disabled initially") + } + + // Enable service + err := service.Enable() + if err != nil { + t.Errorf("Failed to enable service: %v", err) + } + + if !service.IsEnabled() { + t.Error("Expected service to be enabled") + } + + // Disable service + err = service.Disable() + if err != nil { + t.Errorf("Failed to disable service: %v", err) + } + + if service.IsEnabled() { + t.Error("Expected service to be disabled") + } +} + +func TestManagedService_Logs(t *testing.T) { + service := NewMockManagedService("managed-2", "Managed Service 2") + ctx := context.Background() + + logs, err := service.GetLogs(ctx, 5) + if err != nil { + t.Errorf("Failed to get logs: %v", err) + } + + if len(logs) != 5 { + t.Errorf("Expected 5 log lines, got %d", len(logs)) + } + + for i, log := range logs { + expected := "Mock log line " + string(rune('0'+i)) + if log != expected { + t.Errorf("Expected log line %d to be '%s', got '%s'", i, expected, log) + } + } +} + +func TestManagedService_Subscribe(t *testing.T) { + service := NewMockManagedService("managed-3", "Managed Service 3") + + // Subscribe to events + eventChan := service.Subscribe() + if eventChan == nil { + t.Fatal("Expected non-nil event channel") + } + + // Test that channel is ready to receive + select { + case <-eventChan: + // Channel received something (unexpected for this test) + default: + // Channel is ready but no events (expected) + } + + // Unsubscribe + service.Unsubscribe() + + // After unsubscribe, channel should be closed + select { + case _, open := <-eventChan: + if open { + t.Error("Expected channel to be closed after unsubscribe") + } + case <-time.After(100 * time.Millisecond): + t.Error("Channel should have been closed immediately") + } +} + +func TestNetworkService_AddressManagement(t *testing.T) { + service := NewMockNetworkService("network-1", "Network Service", 8080) + + // Check initial addresses + addresses := service.GetListenAddresses() + if len(addresses) != 2 { + t.Errorf("Expected 2 initial addresses, got %d", len(addresses)) + } + + // Check initial port + if service.GetPort() != 8080 { + t.Errorf("Expected initial port to be 8080, got %d", service.GetPort()) + } + + // Check port in use + if !service.IsPortInUse(8080) { + t.Error("Expected port 8080 to be in use") + } + + if service.IsPortInUse(8081) { + t.Error("Expected port 8081 to not be in use") + } + + // Bind to new address + err := service.BindToAddress("192.168.1.100") + if err != nil { + t.Errorf("Failed to bind to address: %v", err) + } + + newAddresses := service.GetListenAddresses() + if len(newAddresses) != 1 || newAddresses[0] != "192.168.1.100" { + t.Errorf("Expected single address '192.168.1.100', got %v", newAddresses) + } + + // Change port + err = service.ChangePort(9090) + if err != nil { + t.Errorf("Failed to change port: %v", err) + } + + if service.GetPort() != 9090 { + t.Errorf("Expected port to be 9090, got %d", service.GetPort()) + } +} + +func TestStorageService_StorageManagement(t *testing.T) { + service := NewMockStorageService("storage-1", "Storage Service", "/data") + + // Check storage path + if service.GetStoragePath() != "/data" { + t.Errorf("Expected storage path to be '/data', got %s", service.GetStoragePath()) + } + + // Check storage usage + used, available, err := service.GetStorageUsage() + if err != nil { + t.Errorf("Failed to get storage usage: %v", err) + } + + expectedUsed := int64(1024 * 1024 * 100) // 100MB + if used != expectedUsed { + t.Errorf("Expected used storage to be %d, got %d", expectedUsed, used) + } + + expectedAvailable := int64(1024 * 1024 * 900) // 900MB + if available != expectedAvailable { + t.Errorf("Expected available storage to be %d, got %d", expectedAvailable, available) + } + + // Cleanup storage + err = service.CleanupStorage() + if err != nil { + t.Errorf("Failed to cleanup storage: %v", err) + } + + // Check that used storage is now 0 + used, _, err = service.GetStorageUsage() + if err != nil { + t.Errorf("Failed to get storage usage after cleanup: %v", err) + } + + if used != 0 { + t.Errorf("Expected used storage to be 0 after cleanup, got %d", used) + } + + // Test backup and restore + err = service.BackupData("/backup/location") + if err != nil { + t.Errorf("Failed to backup data: %v", err) + } + + err = service.RestoreData("/backup/location") + if err != nil { + t.Errorf("Failed to restore data: %v", err) + } +} + +func TestServiceManager_ServiceManagement(t *testing.T) { + manager := NewMockServiceManager() + ctx := context.Background() + + // Initially no services + services := manager.ListServices() + if len(services) != 0 { + t.Errorf("Expected 0 initial services, got %d", len(services)) + } + + // Register services + service1 := NewMockService("svc1", "Service 1") + service2 := NewMockService("svc2", "Service 2") + + err := manager.RegisterService("service1", service1) + if err != nil { + t.Errorf("Failed to register service1: %v", err) + } + + err = manager.RegisterService("service2", service2) + if err != nil { + t.Errorf("Failed to register service2: %v", err) + } + + // Check services are registered + services = manager.ListServices() + if len(services) != 2 { + t.Errorf("Expected 2 services after registration, got %d", len(services)) + } + + // Get specific service + retrievedService, err := manager.GetService("service1") + if err != nil { + t.Errorf("Failed to get service1: %v", err) + } + + if retrievedService.GetInfo().ID != "svc1" { + t.Errorf("Expected retrieved service ID to be 'svc1', got %s", retrievedService.GetInfo().ID) + } + + // Get non-existent service + _, err = manager.GetService("nonexistent") + if err == nil { + t.Error("Expected error when getting non-existent service") + } + + // Start all services + err = manager.StartAll(ctx) + if err != nil { + t.Errorf("Failed to start all services: %v", err) + } + + // Check services are running + if !service1.IsRunning() { + t.Error("Expected service1 to be running") + } + if !service2.IsRunning() { + t.Error("Expected service2 to be running") + } + + // Stop all services + err = manager.StopAll(ctx) + if err != nil { + t.Errorf("Failed to stop all services: %v", err) + } + + // Check services are stopped + if service1.IsRunning() { + t.Error("Expected service1 to be stopped") + } + if service2.IsRunning() { + t.Error("Expected service2 to be stopped") + } + + // Restart all services + err = manager.RestartAll(ctx) + if err != nil { + t.Errorf("Failed to restart all services: %v", err) + } + + // Check services are running again + if !service1.IsRunning() { + t.Error("Expected service1 to be running after restart") + } + if !service2.IsRunning() { + t.Error("Expected service2 to be running after restart") + } + + // Health check all + healthMap := manager.HealthCheckAll() + if len(healthMap) != 2 { + t.Errorf("Expected 2 health checks, got %d", len(healthMap)) + } + + // Overall health + overallHealth := manager.GetOverallHealth() + if overallHealth == nil { + t.Fatal("Expected non-nil overall health") + } + + if overallHealth.Status != "healthy" { + t.Errorf("Expected overall health to be 'healthy', got %s", overallHealth.Status) + } + + // Unregister service + err = manager.UnregisterService("service1") + if err != nil { + t.Errorf("Failed to unregister service1: %v", err) + } + + services = manager.ListServices() + if len(services) != 1 { + t.Errorf("Expected 1 service after unregistration, got %d", len(services)) + } +} + +func TestServiceManager_Events(t *testing.T) { + manager := NewMockServiceManager() + + // Subscribe to events + eventChan := manager.Subscribe() + if eventChan == nil { + t.Fatal("Expected non-nil event channel") + } + + // Unsubscribe + manager.Unsubscribe() + + // Check channel is closed + select { + case _, open := <-eventChan: + if open { + t.Error("Expected channel to be closed after unsubscribe") + } + case <-time.After(100 * time.Millisecond): + t.Error("Channel should have been closed immediately") + } +} + +// Test template type constants +func TestTemplateType_Constants(t *testing.T) { + expectedTypes := map[string]string{ + TemplateTypeKickstart: "kickstart", + TemplateTypePreseed: "preseed", + TemplateTypeAutoYaST: "autoyast", + TemplateTypeCloudInit: "cloud-init", + TemplateTypeIPXE: "ipxe", + } + + for constant, expected := range expectedTypes { + if constant != expected { + t.Errorf("Expected template type constant to be '%s', got '%s'", expected, constant) + } + } +} diff --git a/ipxe/service.go b/ipxe/service.go index 6c16599..297177e 100644 --- a/ipxe/service.go +++ b/ipxe/service.go @@ -125,7 +125,7 @@ func (s *Service) getDisplayName(os, version string) string { case "nixos": return fmt.Sprintf("NixOS %s", version) default: - return fmt.Sprintf("%s %s", strings.Title(os), version) + return fmt.Sprintf("%s %s", strings.ToUpper(string(os[0]))+os[1:], version) } } diff --git a/osimage/service.go b/osimage/service.go index fddfbae..06a7710 100644 --- a/osimage/service.go +++ b/osimage/service.go @@ -4,7 +4,6 @@ import ( "context" "crypto/sha256" "fmt" - "github.com/google/uuid" "ignite/config" "io" "log" @@ -14,6 +13,8 @@ import ( "sort" "strings" "time" + + "github.com/google/uuid" ) // OSImageServiceImpl provides business logic for OS image management diff --git a/public/http/js/provision.js b/public/http/js/provision.js index 468576f..a942f1b 100755 --- a/public/http/js/provision.js +++ b/public/http/js/provision.js @@ -180,8 +180,8 @@ document.addEventListener("DOMContentLoaded", () => { localStorage.setItem('codeBlockContent', editableTextarea.value); if (currentFilename) { const filename = currentFilename.textContent.split(': ')[1] || 'untitled'; - updateCurrentFilename(filename) - localStorage.setItem('currentFilename', filename) + updateCurrentFilename(filename); + localStorage.setItem('currentFilename', filename); } if (currentLanguage) { localStorage.setItem('currentLanguage', currentLanguage.textContent.split(': ')[1] || 'yaml'); @@ -205,8 +205,8 @@ document.addEventListener("DOMContentLoaded", () => { updateHighlighting(); } if (savedFilename && currentFilename) { - safeSetElementValue('currentFilename', savedFilename) - updateCurrentFilename(savedFilename) + safeSetElementValue('currentFilename', savedFilename); + updateCurrentFilename(savedFilename); } if (savedLanguage && currentLanguage) { safeSetElementValue('currentLanguage', savedLanguage); @@ -251,15 +251,5 @@ document.addEventListener("DOMContentLoaded", () => { saveEditorState(); }); - ['loadConfigBtn', 'newTemplateBtn', 'loadTemplateBtn'].forEach(id => { - const button = document.getElementById(id); - if (button) { - button.addEventListener('click', function() { - // I might want to update or load content from the modal - // but since the modal handling is outside this script, no action here - }); - } - }); - loadEditorState(); }); \ No newline at end of file diff --git a/public/provision/templates/autoyast/default.templ b/public/provision/templates/autoyast/default.templ new file mode 100644 index 0000000..f8ad140 --- /dev/null +++ b/public/provision/templates/autoyast/default.templ @@ -0,0 +1,147 @@ + + + + + + + false + false + true + + + + + + + + eth0 + static + {{ .ip }} + {{ .subnet }} + auto + + + + + + default + {{ .gateway }} + - + - + + + + + {{ .hostname }} + localdomain + + {{ .dns }} + + + + + + + + /dev/sda + true + + + true + ext4 + true + /boot + 131 + 512M + + + true + swap + true + swap + 130 + 2G + + + true + ext4 + true + / + 131 + max + + + all + + + + + + + base + enhanced_base + minimal_base + + + openssh + sudo + vim + git + curl + htop + + + + + + + admin + Administrator + false + changeme + /bin/bash + /home/admin + + + + + 99999 + 0 + 7 + + + + + + + multi-user + + + sshd + network + + + + + + + UTC + UTC + + + + + + + + + \ No newline at end of file diff --git a/public/provision/templates/ipxe/default.templ b/public/provision/templates/ipxe/default.templ new file mode 100644 index 0000000..3358d52 --- /dev/null +++ b/public/provision/templates/ipxe/default.templ @@ -0,0 +1,106 @@ +#!ipxe +# iPXE Boot Script Template + +# Set console output +console --picture --left 100 --right 80 + +# Display banner +echo +echo ================================================================================ +echo Welcome to PXE Boot - {{ .hostname }} +echo ================================================================================ +echo + +# Configure network interface +echo Configuring network interface... +ifopen net0 +set net0/ip {{ .ip }} +set net0/netmask {{ .subnet }} +set net0/gateway {{ .gateway }} +set net0/dns {{ .dns }} + +# Set hostname +set hostname {{ .hostname }} + +# Define kernel and initrd URLs (customize as needed) +set kernel-url http://{{ .ip }}/images/vmlinuz +set initrd-url http://{{ .ip }}/images/initrd.img + +# Boot options (customize as needed) +set boot-options console=tty0 console=ttyS0,115200n8 + +echo Network configured successfully +echo IP Address: ${net0/ip} +echo Netmask: ${net0/netmask} +echo Gateway: ${net0/gateway} +echo DNS: ${net0/dns} +echo + +# Menu selection +:start +menu iPXE Boot Menu for {{ .hostname }} +item --gap -- Network Configuration: +item --gap -- IP: {{ .ip }} +item --gap -- Gateway: {{ .gateway }} +item --gap -- DNS: {{ .dns }} +item --gap -- ------------------------ +item ubuntu Boot Ubuntu Live +item centos Boot CentOS Live +item memtest Memory Test +item --gap -- ------------------------ +item reboot Reboot +item exit Exit to BIOS +choose --timeout 30000 --default ubuntu selected || goto cancel +set menu-timeout 0 +goto ${selected} + +:ubuntu +echo Booting Ubuntu... +set kernel-url http://archive.ubuntu.com/ubuntu/dists/jammy/main/installer-amd64/current/legacy-images/netboot/ubuntu-installer/amd64/linux +set initrd-url http://archive.ubuntu.com/ubuntu/dists/jammy/main/installer-amd64/current/legacy-images/netboot/ubuntu-installer/amd64/initrd.gz +set boot-options ${boot-options} auto=true priority=critical preseed/url=http://{{ .ip }}/preseed.cfg +goto boot + +:centos +echo Booting CentOS... +set kernel-url http://mirror.centos.org/centos/8/BaseOS/x86_64/os/images/pxeboot/vmlinuz +set initrd-url http://mirror.centos.org/centos/8/BaseOS/x86_64/os/images/pxeboot/initrd.img +set boot-options ${boot-options} inst.ks=http://{{ .ip }}/kickstart.cfg +goto boot + +:memtest +echo Loading memory test... +kernel http://boot.ipxe.org/memtest.bin +boot || goto failed + +:boot +echo Loading kernel from ${kernel-url}... +kernel ${kernel-url} ${boot-options} || goto failed +echo Loading initrd from ${initrd-url}... +initrd ${initrd-url} || goto failed +echo Booting... +boot || goto failed + +:failed +echo Boot failed, dropping to shell +shell + +:reboot +echo Rebooting in 3 seconds... +sleep 3 +reboot + +:cancel +echo Boot cancelled +exit + +:exit +echo Exiting to BIOS... +exit + +# Error handler +:error +echo An error occurred during boot +echo Press any key to return to menu... +prompt +goto start \ No newline at end of file diff --git a/public/provision/templates/preseed/default.templ b/public/provision/templates/preseed/default.templ new file mode 100644 index 0000000..1df498e --- /dev/null +++ b/public/provision/templates/preseed/default.templ @@ -0,0 +1,66 @@ +# Debian/Ubuntu Preseed Configuration Template +# Locale and keyboard +d-i debian-installer/locale string en_US.UTF-8 +d-i keyboard-configuration/xkb-keymap select us + +# Network configuration +d-i netcfg/choose_interface select eth0 +d-i netcfg/disable_autoconfig boolean true +d-i netcfg/disable_dhcp boolean true +d-i netcfg/get_ipaddress string {{ .ip }} +d-i netcfg/get_netmask string {{ .subnet }} +d-i netcfg/get_gateway string {{ .gateway }} +d-i netcfg/get_nameservers string {{ .dns }} +d-i netcfg/confirm_static boolean true +d-i netcfg/get_hostname string {{ .hostname }} +d-i netcfg/get_domain string localdomain + +# Mirror settings +d-i mirror/country string manual +d-i mirror/http/hostname string archive.ubuntu.com +d-i mirror/http/directory string /ubuntu +d-i mirror/http/proxy string + +# User account setup +d-i passwd/user-fullname string Administrator +d-i passwd/username string admin +d-i passwd/user-password password changeme +d-i passwd/user-password-again password changeme +d-i user-setup/allow-password-weak boolean true +d-i user-setup/encrypt-home boolean false + +# Time zone +d-i time/zone string UTC +d-i clock-setup/utc boolean true + +# Partitioning +d-i partman-auto/method string regular +d-i partman-auto/disk string /dev/sda +d-i partman-auto/choose_recipe select atomic +d-i partman-partitioning/confirm_write_new_label boolean true +d-i partman/choose_partition select finish +d-i partman/confirm boolean true +d-i partman/confirm_nooverwrite boolean true + +# Base system installation +d-i base-installer/install-recommends boolean true + +# Package selection +tasksel tasksel/first multiselect standard, ssh-server +d-i pkgsel/include string sudo openssh-server fail2ban ufw htop vim git curl +d-i pkgsel/upgrade select full-upgrade +d-i pkgsel/update-policy select none + +# Boot loader installation +d-i grub-installer/only_debian boolean true +d-i grub-installer/with_other_os boolean true +d-i grub-installer/bootdev string /dev/sda + +# Finish installation +d-i finish-install/reboot_in_progress note + +# Custom commands to run after installation +d-i preseed/late_command string \ + in-target systemctl enable ssh; \ + in-target ufw enable; \ + in-target echo 'admin ALL=(ALL) NOPASSWD:ALL' >> /target/etc/sudoers.d/admin \ No newline at end of file diff --git a/public/tftp/boot.ipxe b/public/tftp/boot.ipxe index f6fcaba..b5a1327 100644 --- a/public/tftp/boot.ipxe +++ b/public/tftp/boot.ipxe @@ -2,8 +2,8 @@ # iPXE Boot Script for Ignite (Auto-generated) dhcp -set server-ip 192.168.1.29 -set base-url http://192.168.1.29:8080 +set server-ip 10.1.57.82 +set base-url http://10.1.57.82:8080 # Show network info echo Network configuration: @@ -30,14 +30,14 @@ goto ${selected} :centos echo Booting CentOS Stream 9... -kernel ${base-url}/centos/9/vmlinuz initrd=centos/9/initrd.img inst.repo=http://192.168.1.29:8080/centos/ quiet +kernel ${base-url}/centos/9/vmlinuz initrd=centos/9/initrd.img inst.repo=http://10.1.57.82:8080/centos/ quiet initrd ${base-url}/centos/9/initrd.img boot :ubuntu echo Booting Ubuntu 24.04... -kernel ${base-url}/ubuntu/24.04/vmlinuz initrd=ubuntu/24.04/initrd boot=casper netboot=url fetch=http://192.168.1.29:8080/ubuntu/ quiet splash +kernel ${base-url}/ubuntu/24.04/vmlinuz initrd=ubuntu/24.04/initrd boot=casper netboot=url fetch=http://10.1.57.82:8080/ubuntu/ quiet splash initrd ${base-url}/ubuntu/24.04/initrd boot diff --git a/routes/routes.go b/routes/routes.go index 125503e..8014a22 100755 --- a/routes/routes.go +++ b/routes/routes.go @@ -15,6 +15,7 @@ func SetupWithContainerAndStatic(container *handlers.Container, staticHandler *h setupStaticRoutes(router, staticHandler) // Create handler instances with dependencies + authHandlers := handlers.NewAuthHandlers(container) dhcpHandlers := handlers.NewDHCPHandlers(container) tftpHandlers := handlers.NewTFTPHandlers(container) provisionHandlers := handlers.NewProvisionHandlers(container) @@ -27,7 +28,11 @@ func SetupWithContainerAndStatic(container *handlers.Container, staticHandler *h syslinuxHandlers := handlers.NewSyslinuxHandler(container) ipxeHandlers := handlers.NewIPXEHandlers(container) - // Setup all routes + // Apply authentication middleware to the entire router + router.Use(handlers.AuthMiddleware) + + // Setup all routes (middleware will handle auth checks) + setupAuthRoutes(router, authHandlers) setupIndexRoutes(router, indexHandlers) setupModalRoutes(router, modalHandlers) setupDHCPRoutes(router, dhcpHandlers) @@ -115,6 +120,7 @@ func setupProvisionRoutes(router *mux.Router, handlers *handlers.ProvisionHandle router.HandleFunc("/prov/newtemplate", handlers.HandleNewTemplate).Methods("POST").Name("NewTemplate") router.HandleFunc("/prov/save", handlers.HandleSave).Methods("POST").Name("SaveFile") router.HandleFunc("/provision/save-file", handlers.SaveFileContent).Methods("POST").Name("SaveFileContent") + router.HandleFunc("/provision/delete-file", handlers.DeleteFile).Methods("POST").Name("DeleteFile") } // setupBootMenuRoutes configures PXE boot menu routes @@ -166,3 +172,14 @@ func setupIPXERoutes(router *mux.Router, handlers *handlers.IPXEHandlers) { // POST routes - for updating config file router.HandleFunc("/ipxe/update", handlers.UpdateConfigFile).Methods("POST").Name("UpdateIPXEConfig") } + +// setupAuthRoutes configures authentication routes +func setupAuthRoutes(router *mux.Router, handlers *handlers.AuthHandlers) { + // GET routes + router.HandleFunc("/login", handlers.LoginPage).Methods("GET").Name("LoginPage") + + // POST routes + router.HandleFunc("/auth/login", handlers.Login).Methods("POST").Name("Login") + router.HandleFunc("/auth/logout", handlers.Logout).Methods("POST").Name("Logout") + router.HandleFunc("/auth/change-password", handlers.ChangePassword).Methods("POST").Name("ChangePassword") +} diff --git a/templates/base-login.templ b/templates/base-login.templ new file mode 100644 index 0000000..a879de2 --- /dev/null +++ b/templates/base-login.templ @@ -0,0 +1,26 @@ + + + + + + {{.Title}} + + + + + + + + + {{ template "content" . }} + + + + + \ No newline at end of file diff --git a/templates/base.templ b/templates/base.templ index 9546e8f..7d5e9e6 100755 --- a/templates/base.templ +++ b/templates/base.templ @@ -31,6 +31,17 @@ OS Images Syslinux Status + + +
+ + + @@ -98,6 +140,104 @@ } } }); + + // Authentication functions + function logout() { + fetch('/auth/logout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + window.location.href = '/login'; + } + }) + .catch(error => { + console.error('Logout error:', error); + // Force redirect to login page even if request fails + window.location.href = '/login'; + }); + } + + function openChangePasswordModal() { + document.getElementById('change-password-modal').classList.add('modal-open'); + } + + function closeChangePasswordModal() { + document.getElementById('change-password-modal').classList.remove('modal-open'); + document.getElementById('change-password-form').reset(); + } + + // Handle change password form submission + document.getElementById('change-password-form').addEventListener('submit', function(e) { + e.preventDefault(); + + const currentPassword = document.getElementById('current-password').value; + const newPassword = document.getElementById('new-password').value; + const confirmPassword = document.getElementById('confirm-password').value; + + // Validate passwords match + if (newPassword !== confirmPassword) { + Toastify({ + text: "New passwords do not match", + duration: 3000, + close: true, + gravity: "top", + position: "right", + backgroundColor: "linear-gradient(to right, #FBBF24, #FF4500)", + }).showToast(); + return; + } + + // Send password change request + fetch('/auth/change-password', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + Toastify({ + text: data.message, + duration: 3000, + close: true, + gravity: "top", + position: "right", + backgroundColor: "linear-gradient(to right, #00b09b, #96c93d)", + }).showToast(); + closeChangePasswordModal(); + } else { + Toastify({ + text: data.message, + duration: 3000, + close: true, + gravity: "top", + position: "right", + backgroundColor: "linear-gradient(to right, #FBBF24, #FF4500)", + }).showToast(); + } + }) + .catch(error => { + console.error('Password change error:', error); + Toastify({ + text: "An error occurred while changing password", + duration: 3000, + close: true, + gravity: "top", + position: "right", + backgroundColor: "linear-gradient(to right, #FBBF24, #FF4500)", + }).showToast(); + }); + }); \ No newline at end of file diff --git a/templates/modals/bootmodal.templ b/templates/modals/bootmodal.templ index 7447fe0..388db41 100755 --- a/templates/modals/bootmodal.templ +++ b/templates/modals/bootmodal.templ @@ -19,8 +19,11 @@
- + + + +
@@ -39,6 +42,9 @@
+ + +
@@ -83,6 +89,18 @@ + +
+ + +
+ Examples: saltenv=base, salt_master=192.168.1.10, salt.minion.master_type=str, salt.minion.interface=eth0 +
+
+ @@ -165,7 +171,10 @@ diff --git a/templates/pages/provision.templ b/templates/pages/provision.templ index 2c28765..cc12ab3 100755 --- a/templates/pages/provision.templ +++ b/templates/pages/provision.templ @@ -42,22 +42,37 @@
{{range .Files}} -
-
+ data-path="{{.Path}}" + data-language="{{.Language}}"> +
{{if .IsDir}} {{else}} - {{if eq .Language "yaml"}} - - {{else if eq .Language "kickstart"}} - - {{else if eq .Language "ini"}} - + {{if eq .Type "template"}} + {{if eq .Language "yaml"}} + + {{else if eq .Language "kickstart"}} + + {{else if eq .Language "ini"}} + + {{else}} + + {{end}} + {{else if eq .Type "config"}} + {{if eq .Language "yaml"}} + + {{else if eq .Language "kickstart"}} + + {{else if eq .Language "ini"}} + + {{else}} + + {{end}} {{else}} {{end}} @@ -65,12 +80,27 @@

{{.Name}}

-

{{.Category}} • {{.Type}}

+
+ {{.Category}} + + {{if eq .Type "template"}} + Template + {{else if eq .Type "config"}} + Config + {{else}} + {{.Type}} + {{end}} +
{{if not .IsDir}} {{.Language}} + {{end}}
@@ -151,7 +181,16 @@
-
+
+
+

Templates vs Configs

+
    +
  • Templates are reusable starting points
  • +
  • Configs are rendered, ready-to-use files
  • +
  • • Templates can have placeholders
  • +
  • • Configs are deployed directly
  • +
+

Cloud-Init

    @@ -171,12 +210,12 @@
-

Boot Menu

+

File Operations

    -
  • • PXE boot configuration
  • -
  • • Kernel parameters
  • -
  • • Menu entries and defaults
  • -
  • • Timeouts and labels
  • +
  • • Click file name to edit
  • +
  • button to delete
  • +
  • • Auto-save on edit
  • +
  • • Syntax highlighting
@@ -184,6 +223,8 @@