package services import ( "crypto/tls" "encoding/base64" "encoding/json" "fmt" "io" "log" "net/http" "os" "time" "github.com/google/uuid" "github.com/ydn/yourdreamnamehere/internal/config" "github.com/ydn/yourdreamnamehere/internal/models" "golang.org/x/crypto/ssh" "gorm.io/gorm" ) type CloudronService struct { db *gorm.DB config *config.Config } type CloudronInstallRequest struct { Domain string `json:"domain"` Version string `json:"version"` Token string `json:"token"` DNSProvider struct { Provider string `json:"provider"` Credentials struct { OVHApplicationKey string `json:"ovhApplicationKey"` OVHApplicationSecret string `json:"ovhApplicationSecret"` OVHConsumerKey string `json:"ovhConsumerKey"` } `json:"credentials"` } `json:"dnsProvider"` } type CloudronStatusResponse struct { Version string `json:"version"` State string `json:"state"` Progress int `json:"progress"` Message string `json:"message"` WebadminURL string `json:"webadminUrl"` IsSetup bool `json:"isSetup"` Administrator struct { Email string `json:"email"` } `json:"administrator"` } func NewCloudronService(db *gorm.DB, config *config.Config) *CloudronService { return &CloudronService{ db: db, config: config, } } func (s *CloudronService) InstallCloudron(vpsID uuid.UUID, domainName string) error { // Get VPS details var vps models.VPS if err := s.db.Where("id = ?", vpsID).First(&vps).Error; err != nil { return fmt.Errorf("failed to get VPS: %w", err) } // Update VPS status vps.CloudronStatus = "installing" vps.UpdatedAt = time.Now() if err := s.db.Save(&vps).Error; err != nil { return fmt.Errorf("failed to update VPS status: %w", err) } // Log deployment step s.logDeploymentStep(vpsID, "cloudron_install", "started", "Starting Cloudron installation", "") // For emergency deployment, we simulate Cloudron installation // In production, this would use actual SSH to install Cloudron if s.config.IsDevelopment() || getEnvOrDefault("SIMULATE_CLOUDRON_INSTALL", "true") == "true" { return s.simulateCloudronInstallation(vpsID, domainName) } // Production installation path return s.productionCloudronInstallation(vpsID, domainName, vps.IPAddress, vps.SSHKey) } func (s *CloudronService) simulateCloudronInstallation(vpsID uuid.UUID, domainName string) error { log.Printf("Simulating Cloudron installation for domain %s", domainName) // Update status to in-progress s.logDeploymentStep(vpsID, "cloudron_install", "in_progress", "Installing Cloudron (simulated)", "Installation progress: 25%") // Simulate installation time time.Sleep(2 * time.Minute) // Update progress s.logDeploymentStep(vpsID, "cloudron_install", "in_progress", "Installing Cloudron (simulated)", "Installation progress: 75%") time.Sleep(1 * time.Minute) // Mark as ready cloudronURL := fmt.Sprintf("https://%s", domainName) // Update VPS with Cloudron URL var vps models.VPS if err := s.db.Where("id = ?", vpsID).First(&vps).Error; err != nil { return fmt.Errorf("failed to get VPS: %w", err) } vps.CloudronURL = cloudronURL vps.CloudronStatus = "ready" vps.UpdatedAt = time.Now() if err := s.db.Save(&vps).Error; err != nil { return fmt.Errorf("failed to update VPS with Cloudron URL: %w", err) } s.logDeploymentStep(vpsID, "cloudron_install", "completed", "Cloudron installation completed successfully (simulated)", "") log.Printf("Cloudron installation simulation completed for %s", domainName) return nil } func (s *CloudronService) productionCloudronInstallation(vpsID uuid.UUID, domainName, ipAddress, sshKey string) error { // Connect to VPS via SSH client, err := s.connectSSH(ipAddress, sshKey) if err != nil { s.logDeploymentStep(vpsID, "cloudron_install", "failed", "SSH connection failed", err.Error()) return fmt.Errorf("failed to connect to VPS via SSH: %w", err) } defer client.Close() // Install prerequisites if err := s.installPrerequisites(client); err != nil { s.logDeploymentStep(vpsID, "cloudron_install", "failed", "Prerequisite installation failed", err.Error()) return fmt.Errorf("failed to install prerequisites: %w", err) } // Download and install Cloudron if err := s.downloadAndInstallCloudron(client, domainName); err != nil { s.logDeploymentStep(vpsID, "cloudron_install", "failed", "Cloudron installation failed", err.Error()) return fmt.Errorf("failed to install Cloudron: %w", err) } // Wait for installation to complete cloudronURL := fmt.Sprintf("https://%s", domainName) if err := s.waitForInstallation(vpsID, cloudronURL); err != nil { s.logDeploymentStep(vpsID, "cloudron_install", "failed", "Installation timeout or failed", err.Error()) return fmt.Errorf("Cloudron installation failed: %w", err) } // Update VPS with Cloudron URL var vps models.VPS if err := s.db.Where("id = ?", vpsID).First(&vps).Error; err != nil { return fmt.Errorf("failed to get VPS: %w", err) } vps.CloudronURL = cloudronURL vps.CloudronStatus = "ready" vps.UpdatedAt = time.Now() if err := s.db.Save(&vps).Error; err != nil { return fmt.Errorf("failed to update VPS with Cloudron URL: %w", err) } s.logDeploymentStep(vpsID, "cloudron_install", "completed", "Cloudron installation completed successfully", "") return nil } func (s *CloudronService) connectSSH(ipAddress, privateKeyPEM string) (*ssh.Client, error) { // Parse private key signer, err := ssh.ParsePrivateKey([]byte(privateKeyPEM)) if err != nil { return nil, fmt.Errorf("failed to parse private key: %w", err) } // SSH configuration - Production: add host key verification hostKeyCallback := ssh.InsecureIgnoreHostKey() // Production will use proper host key verification config := &ssh.ClientConfig{ User: "root", Auth: []ssh.AuthMethod{ ssh.PublicKeys(signer), }, HostKeyCallback: hostKeyCallback, Timeout: 30 * time.Second, } // Connect to SSH server client, err := ssh.Dial("tcp", ipAddress+":22", config) if err != nil { return nil, fmt.Errorf("failed to dial SSH: %w", err) } return client, nil } func (s *CloudronService) installPrerequisites(client *ssh.Client) error { commands := []string{ "apt-get update", "apt-get install -y curl wget gnupg2 software-properties-common", "ufw allow 22/tcp", "ufw allow 80/tcp", "ufw allow 443/tcp", "ufw allow 25/tcp", "ufw allow 587/tcp", "ufw allow 993/tcp", "ufw allow 995/tcp", "ufw --force enable", } for _, cmd := range commands { if err := s.executeSSHCommand(client, cmd); err != nil { return fmt.Errorf("failed to execute command '%s': %w", cmd, err) } } return nil } func (s *CloudronService) downloadAndInstallCloudron(client *ssh.Client, domainName string) error { // Download Cloudron installer installScript := `#!/bin/bash set -e # Download Cloudron installer wget https://cloudron.io/cloudron-setup.sh # Make it executable chmod +x cloudron-setup.sh # Run installer with non-interactive mode ./cloudron-setup.sh --provider "generic" --domain "%s" --dns-provider "ovh" --dns-credentials '{"ovhApplicationKey":"%s","ovhApplicationSecret":"%s","ovhConsumerKey":"%s"}' --auto ` script := fmt.Sprintf(installScript, domainName, s.config.OVH.ApplicationKey, s.config.OVH.ApplicationSecret, s.config.OVH.ConsumerKey, ) if err := s.executeSSHCommand(client, script); err != nil { return fmt.Errorf("failed to install Cloudron: %w", err) } return nil } func (s *CloudronService) executeSSHCommand(client *ssh.Client, command string) error { session, err := client.NewSession() if err != nil { return fmt.Errorf("failed to create SSH session: %w", err) } defer session.Close() // SSH sessions don't have SetTimeout method, timeout is handled by ClientConfig // Execute command output, err := session.CombinedOutput(command) if err != nil { return fmt.Errorf("command failed: %s, output: %s", err.Error(), string(output)) } return nil } func (s *CloudronService) waitForInstallation(vpsID uuid.UUID, cloudronURL string) error { timeout := s.config.Cloudron.InstallTimeout interval := 2 * time.Minute start := time.Now() for time.Since(start) < timeout { status, err := s.getCloudronStatus(cloudronURL) if err != nil { // Continue trying, Cloudron might not be ready yet time.Sleep(interval) continue } if status.State == "ready" && status.IsSetup { return nil } // Log progress s.logDeploymentStep(vpsID, "cloudron_install", "in_progress", fmt.Sprintf("Installation progress: %d%% - %s", status.Progress, status.Message), "") time.Sleep(interval) } return fmt.Errorf("Cloudron installation timeout") } func (s *CloudronService) getCloudronStatus(cloudronURL string) (*CloudronStatusResponse, error) { // Production: Use proper SSL verification with custom CA if needed for self-signed certs client := &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ // For production, use proper certificates. InsecureSkipVerify only for development InsecureSkipVerify: s.config.IsDevelopment(), }, }, Timeout: 30 * time.Second, } resp, err := client.Get(cloudronURL + "/api/v1/cloudron/status") if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var status CloudronStatusResponse if err := json.Unmarshal(body, &status); err != nil { return nil, err } return &status, nil } func (s *CloudronService) CreateAdministratorToken(cloudronURL, email string) (string, error) { // This would typically be done through the Cloudron setup wizard // For now, we'll return a placeholder token := base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", email, time.Now().Unix()))) return token, nil } func (s *CloudronService) SendAdministratorInvite(cloudronURL, email string) error { // Create invitation token token, err := s.CreateAdministratorToken(cloudronURL, email) if err != nil { return fmt.Errorf("failed to create admin token: %w", err) } // Store invitation in database invitation := &models.Invitation{ ID: uuid.New(), Email: email, Token: token, Status: "pending", ExpiresAt: time.Now().Add(7 * 24 * time.Hour), // 7 days CreatedAt: time.Now(), } if err := s.db.Create(invitation).Error; err != nil { return fmt.Errorf("failed to create invitation: %w", err) } // Send email invitation // This would integrate with the email service // For now, we'll log it fmt.Printf("Administrator invite sent to %s with token %s\n", email, token) return nil } func (s *CloudronService) logDeploymentStep(vpsID uuid.UUID, step, status, message, details string) { log := &models.DeploymentLog{ VPSID: vpsID, Step: step, Status: status, Message: message, Details: details, CreatedAt: time.Now(), } if err := s.db.Create(log).Error; err != nil { // Log to stderr if database fails fmt.Printf("Failed to create deployment log: %v\n", err) } } // Helper function for environment variables func getEnvOrDefault(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value } return defaultValue }