diff --git a/cmd/backup_domain.go b/cmd/backup_domain.go deleted file mode 100644 index ec96cab..0000000 --- a/cmd/backup_domain.go +++ /dev/null @@ -1,85 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "os/exec" - "strings" - "time" - - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" -) - -var backupDomainCmd = &cobra.Command{ - Use: "backup-domain", - Short: "Backup public_html and MySQL DB of a domain", - Run: func(cmd *cobra.Command, args []string) { - domain, _ := cmd.Flags().GetString("domain") - backupType, _ := cmd.Flags().GetString("type") - - if internal.IsNilOrEmpty(domain) { - logger.Error("Please provide a domain using --domain") - os.Exit(1) - } - if backupType == "" { - backupType = "tar.gz" - } - - username := strings.Split(domain, ".")[0] - timestamp := time.Now().Format("20060102_150405") - backupDir := "/var/backups" - os.MkdirAll(backupDir, 0755) - - publicPath := fmt.Sprintf("/home/%s/public_html", username) - sqlDump := fmt.Sprintf("%s/%s-db.sql", backupDir, domain) - - logger.Info(fmt.Sprintf("Dumping MySQL database for %s", username)) - dumpCmd := []string{"mysqldump", "-u", username, fmt.Sprintf("-p%s", username), username} - sqlFile, err := os.Create(sqlDump) - if err != nil { - logger.Error(fmt.Sprintf("Failed to create dump file: %v", err)) - os.Exit(1) - } - defer sqlFile.Close() - cmdDump := exec.Command("sudo", dumpCmd...) - cmdDump.Stdout = sqlFile - cmdDump.Stderr = os.Stderr - if err := cmdDump.Run(); err != nil { - logger.Warn("Could not dump MySQL DB (likely bad credentials)") - } - - baseName := fmt.Sprintf("%s/%s-%s", backupDir, domain, timestamp) - - switch backupType { - case "tar.gz": - output := baseName + ".tar.gz" - logger.Info("Creating tar.gz archive") - internal.RunCommand("sudo", "tar", "-czf", output, publicPath, sqlDump) - logger.Success(fmt.Sprintf("Backup created: %s", output)) - case "tar": - output := baseName + ".tar" - logger.Info("Creating tar archive") - internal.RunCommand("sudo", "tar", "-cf", output, publicPath, sqlDump) - logger.Success(fmt.Sprintf("Backup created: %s", output)) - case "zip": - output := baseName + ".zip" - logger.Info("Creating zip archive") - internal.RunCommand("sudo", "zip", "-r", output, publicPath, sqlDump) - logger.Success(fmt.Sprintf("Backup created: %s", output)) - default: - logger.Error("Unsupported backup type. Use: tar.gz, tar, zip") - } - - // Optional: clean up raw SQL dump - os.Remove(sqlDump) - }, -} - -func init() { - rootCmd.AddCommand(backupDomainCmd) - backupDomainCmd.Flags().String("domain", "", "Domain name to back up") - backupDomainCmd.Flags().String("type", "tar.gz", "Backup type: tar.gz, zip, tar") - backupDomainCmd.MarkFlagRequired("domain") -} diff --git a/cmd/check_port.go b/cmd/check_port.go deleted file mode 100644 index 933a69d..0000000 --- a/cmd/check_port.go +++ /dev/null @@ -1,50 +0,0 @@ -package cmd - -import ( - "fmt" - "net" - "os" - "time" - - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" -) - -var checkPortCmd = &cobra.Command{ - Use: "check-port", - Short: "Check if a specific port is open for a domain", - Run: func(cmd *cobra.Command, args []string) { - domain, _ := cmd.Flags().GetString("domain") - port, _ := cmd.Flags().GetString("port") - timeoutSec, _ := cmd.Flags().GetInt("timeout") - - if internal.IsNilOrEmpty(domain) || internal.IsNilOrEmpty(port) { - logger.Error("Please provide both --domain and --port") - os.Exit(1) - } - - address := fmt.Sprintf("%s:%s", domain, port) - timeout := time.Duration(timeoutSec) * time.Second - - logger.Info(fmt.Sprintf("Checking port %s on domain %s...", port, domain)) - - conn, err := net.DialTimeout("tcp", address, timeout) - if err != nil { - logger.Error(fmt.Sprintf("Port %s is not reachable on %s (%v)", port, domain, err)) - os.Exit(1) - } - conn.Close() - - logger.Success(fmt.Sprintf("Port %s is open and reachable on %s", port, domain)) - }, -} - -func init() { - rootCmd.AddCommand(checkPortCmd) - checkPortCmd.Flags().String("domain", "", "Domain to check") - checkPortCmd.Flags().String("port", "", "Port to check (e.g., 80, 443, 3000)") - checkPortCmd.Flags().Int("timeout", 3, "Timeout in seconds (default: 3s)") - checkPortCmd.MarkFlagRequired("domain") - checkPortCmd.MarkFlagRequired("port") -} diff --git a/cmd/clone_domain.go b/cmd/clone_domain.go deleted file mode 100644 index b2a4a02..0000000 --- a/cmd/clone_domain.go +++ /dev/null @@ -1,98 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" -) - -var cloneDomainCmd = &cobra.Command{ - Use: "clone-domain", - Short: "Clone configuration, files, and database from one domain to another", - Run: func(cmd *cobra.Command, args []string) { - src, _ := cmd.Flags().GetString("source") - dest, _ := cmd.Flags().GetString("target") - cloneDB, _ := cmd.Flags().GetBool("clone-db") - cloneUser, _ := cmd.Flags().GetBool("clone-user") - - if internal.IsNilOrEmpty(src) || internal.IsNilOrEmpty(dest) { - logger.Error("Please provide both --source and --target domains") - os.Exit(1) - } - - logger.Info(fmt.Sprintf("Cloning domain from %s to %s", src, dest)) - - serverType := internal.DetectServerType(src) - if serverType == "" { - logger.Error("Could not detect server type for source domain") - os.Exit(1) - } - - // 1. Copy config - srcConf := filepath.Join("/etc", serverType, "sites-available", src+".conf") - destConf := filepath.Join("/etc", serverType, "sites-available", dest+".conf") - - logger.Info("Copying config...") - internal.RunCommand("sudo", "cp", srcConf, destConf) - internal.RunCommand("sudo", "sed", "-i", fmt.Sprintf("s/%s/%s/g", src, dest), destConf) - - // 2. Copy public_html - srcUser := strings.Split(src, ".")[0] - destUser := strings.Split(dest, ".")[0] - - srcPath := fmt.Sprintf("/home/%s/public_html", srcUser) - destPath := fmt.Sprintf("/home/%s/public_html", destUser) - - logger.Info("Copying website files...") - internal.RunCommand("sudo", "mkdir", "-p", destPath) - internal.RunCommand("sudo", "cp", "-r", srcPath+"/.", destPath) - - if cloneUser { - logger.Info("Cloning user...") - internal.RunCommand("sudo", "useradd", "-m", "-s", "/bin/bash", destUser) - internal.RunCommand("sudo", "chown", "-R", fmt.Sprintf("%s:%s", destUser, destUser), "/home/"+destUser) - } - - if cloneDB { - logger.Info("Cloning MySQL database...") - dumpFile := fmt.Sprintf("/tmp/%s.sql", srcUser) - internal.RunCommand("sudo", "mysqldump", "-u", "root", srcUser, "-r", dumpFile) - internal.CreateMySQLUserAndDatabase(destUser, "changeme123") - internal.RunCommand("sudo", "mysql", "-u", "root", destUser, "-e", fmt.Sprintf("source %s", dumpFile)) - internal.RunCommand("sudo", "rm", "-f", dumpFile) - } - - // 3. Enable new config - logger.Info("Enabling cloned site...") - switch serverType { - case "apache": - internal.RunCommand("sudo", "a2ensite", dest+".conf") - internal.RunCommand("sudo", "systemctl", "reload", "apache2") - case "nginx": - link := filepath.Join("/etc/nginx/sites-enabled", dest+".conf") - internal.RunCommand("sudo", "ln", "-s", destConf, link) - internal.RunCommand("sudo", "systemctl", "reload", "nginx") - case "caddy": - link := filepath.Join("/etc/caddy/sites-enabled", dest+".conf") - internal.RunCommand("sudo", "ln", "-s", destConf, link) - internal.RunCommand("sudo", "systemctl", "reload", "caddy") - } - - logger.Success(fmt.Sprintf("Domain %s cloned to %s", src, dest)) - }, -} - -func init() { - rootCmd.AddCommand(cloneDomainCmd) - cloneDomainCmd.Flags().String("source", "", "Source domain to clone from") - cloneDomainCmd.Flags().String("target", "", "New domain name") - cloneDomainCmd.Flags().Bool("clone-db", false, "Clone MySQL database") - cloneDomainCmd.Flags().Bool("clone-user", false, "Clone shell user") - cloneDomainCmd.MarkFlagRequired("source") - cloneDomainCmd.MarkFlagRequired("target") -} diff --git a/cmd/delete_user.go b/cmd/delete_user.go deleted file mode 100644 index d2e1a0a..0000000 --- a/cmd/delete_user.go +++ /dev/null @@ -1,56 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "os/user" - - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" -) - -var deleteUserCmd = &cobra.Command{ - Use: "delete-user", - Short: "Delete a system user and optionally remove their home directory", - Run: func(cmd *cobra.Command, args []string) { - username, _ := cmd.Flags().GetString("user") - removeHome, _ := cmd.Flags().GetBool("remove-home") - - if internal.IsNilOrEmpty(username) { - logger.Error("Please provide a username using --user") - os.Exit(1) - } - - // Check if user exists - _, err := user.Lookup(username) - if err != nil { - logger.Warn(fmt.Sprintf("User '%s' does not exist", username)) - return - } - - logger.Info(fmt.Sprintf("Deleting user: %s", username)) - - // Build command args - cmdArgs := []string{"userdel"} - if removeHome { - cmdArgs = append(cmdArgs, "-r") - } - cmdArgs = append(cmdArgs, username) - - // Execute command - if err := internal.RunCommand("sudo", cmdArgs...); err != nil { - logger.Error(fmt.Sprintf("Failed to delete user %s: %v", username, err)) - os.Exit(1) - } - - logger.Success(fmt.Sprintf("User '%s' deleted", username)) - }, -} - -func init() { - rootCmd.AddCommand(deleteUserCmd) - deleteUserCmd.Flags().String("user", "", "Username to delete") - deleteUserCmd.Flags().Bool("remove-home", false, "Remove the user's home directory") - deleteUserCmd.MarkFlagRequired("user") -} diff --git a/cmd/disable_firewall.go b/cmd/disable_firewall.go deleted file mode 100644 index 31e9da2..0000000 --- a/cmd/disable_firewall.go +++ /dev/null @@ -1,58 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "os/exec" - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" - "strings" -) - -var disableFirewallCmd = &cobra.Command{ - Use: "disable-firewall", - Short: "Disable the system firewall (UFW) safely", - Run: func(cmd *cobra.Command, args []string) { - flush, _ := cmd.Flags().GetBool("flush") - - logger.Info("Checking firewall status...") - statusOutput := CMDRUN("sudo", "ufw", "status") - - if statusOutput == "" || statusOutput == "Status: inactive\n" { - logger.Warn("Firewall is already inactive") - return - } - - logger.Info("Disabling firewall (UFW)...") - if err := internal.RunCommand("sudo", "ufw", "disable"); err != nil { - logger.Error(fmt.Sprintf("Failed to disable firewall: %v", err)) - os.Exit(1) - } - - if flush { - logger.Warn("Flushing all UFW rules...") - if err := internal.RunCommand("sudo", "ufw", "reset"); err != nil { - logger.Error(fmt.Sprintf("Failed to flush firewall rules: %v", err)) - os.Exit(1) - } - logger.Success("All firewall rules flushed.") - } - - logger.Success("Firewall disabled successfully.") - }, -} - -func init() { - rootCmd.AddCommand(disableFirewallCmd) - disableFirewallCmd.Flags().Bool("flush", false, "Flush all UFW rules after disabling") -} - -func CMDRUN(name string, args ...string) string { - var out strings.Builder - cmd := exec.Command(name, args...) - cmd.Stdout = &out - cmd.Stderr = &out - _ = cmd.Run() - return out.String() -} \ No newline at end of file diff --git a/cmd/disable_ssl.go b/cmd/disable_ssl.go deleted file mode 100644 index 07b093b..0000000 --- a/cmd/disable_ssl.go +++ /dev/null @@ -1,58 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" -) - -var disableSSLCmd = &cobra.Command{ - Use: "disable-ssl", - Short: "Disable and remove SSL certificate for a specific domain", - Run: func(cmd *cobra.Command, args []string) { - domain, _ := cmd.Flags().GetString("domain") - if internal.IsNilOrEmpty(domain) { - logger.Error("Please provide a domain using --domain") - os.Exit(1) - } - - serverType := internal.DetectServerType(domain) - if serverType == "" { - logger.Error(fmt.Sprintf("Could not detect server type for domain: %s", domain)) - os.Exit(1) - } - - if serverType == "caddy" { - logger.Info("Caddy auto-manages SSL — no need to disable manually.") - return - } - - logger.Info(fmt.Sprintf("Detected %s configuration for %s", serverType, domain)) - logger.Info("Removing SSL certificate using Certbot") - - cmdArgs := []string{ - "delete", - "--cert-name", domain, - "--non-interactive", - "--quiet", - "--agree-tos", - } - - err := internal.RunCommand("sudo", append([]string{"certbot"}, cmdArgs...)...) - if err != nil { - logger.Warn(fmt.Sprintf("Certbot failed to delete certificate: %v", err)) - os.Exit(1) - } - - logger.Success(fmt.Sprintf("SSL certificate removed for %s", domain)) - }, -} - -func init() { - rootCmd.AddCommand(disableSSLCmd) - disableSSLCmd.Flags().String("domain", "", "Domain name to disable SSL for") - disableSSLCmd.MarkFlagRequired("domain") -} diff --git a/cmd/domain/backup.go b/cmd/domain/backup.go new file mode 100644 index 0000000..acdf087 --- /dev/null +++ b/cmd/domain/backup.go @@ -0,0 +1,86 @@ +package domain + +import ( + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +func GetBackupCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "backup-domain", + Short: "Backup public_html and MySQL DB of a domain", + Run: func(cmd *cobra.Command, args []string) { + domain, _ := cmd.Flags().GetString("domain") + backupType, _ := cmd.Flags().GetString("type") + + if internal.IsNilOrEmpty(domain) { + logger.Error("Please provide a domain using --domain") + os.Exit(1) + } + if backupType == "" { + backupType = "tar.gz" + } + + username := strings.Split(domain, ".")[0] + timestamp := time.Now().Format("20060102_150405") + backupDir := "/var/backups" + os.MkdirAll(backupDir, 0755) + + publicPath := fmt.Sprintf("/home/%s/public_html", username) + sqlDump := fmt.Sprintf("%s/%s-db.sql", backupDir, domain) + + logger.Info(fmt.Sprintf("Dumping MySQL database for %s", username)) + dumpCmd := []string{"mysqldump", "-u", username, fmt.Sprintf("-p%s", username), username} + sqlFile, err := os.Create(sqlDump) + if err != nil { + logger.Error(fmt.Sprintf("Failed to create dump file: %v", err)) + os.Exit(1) + } + defer sqlFile.Close() + + cmdDump := exec.Command("sudo", dumpCmd...) + cmdDump.Stdout = sqlFile + cmdDump.Stderr = os.Stderr + if err := cmdDump.Run(); err != nil { + logger.Warn("Could not dump MySQL DB (likely bad credentials)") + } + + baseName := fmt.Sprintf("%s/%s-%s", backupDir, domain, timestamp) + + switch backupType { + case "tar.gz": + output := baseName + ".tar.gz" + logger.Info("Creating tar.gz archive") + internal.RunCommand("sudo", "tar", "-czf", output, publicPath, sqlDump) + logger.Success(fmt.Sprintf("Backup created: %s", output)) + case "tar": + output := baseName + ".tar" + logger.Info("Creating tar archive") + internal.RunCommand("sudo", "tar", "-cf", output, publicPath, sqlDump) + logger.Success(fmt.Sprintf("Backup created: %s", output)) + case "zip": + output := baseName + ".zip" + logger.Info("Creating zip archive") + internal.RunCommand("sudo", "zip", "-r", output, publicPath, sqlDump) + logger.Success(fmt.Sprintf("Backup created: %s", output)) + default: + logger.Error("Unsupported backup type. Use: tar.gz, tar, zip") + } + + os.Remove(sqlDump) + }, + } + + cmd.Flags().String("domain", "", "Domain name to back up") + cmd.Flags().String("type", "tar.gz", "Backup type: tar.gz, zip, tar") + cmd.MarkFlagRequired("domain") + + return cmd +} diff --git a/cmd/domain/clone.go b/cmd/domain/clone.go new file mode 100644 index 0000000..27b2909 --- /dev/null +++ b/cmd/domain/clone.go @@ -0,0 +1,97 @@ +package domain + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +func GetCloneCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "clone-domain", + Short: "Clone configuration, files, and database from one domain to another", + Run: func(cmd *cobra.Command, args []string) { + src, _ := cmd.Flags().GetString("source") + dest, _ := cmd.Flags().GetString("target") + cloneDB, _ := cmd.Flags().GetBool("clone-db") + cloneUser, _ := cmd.Flags().GetBool("clone-user") + + if internal.IsNilOrEmpty(src) || internal.IsNilOrEmpty(dest) { + logger.Error("Please provide both --source and --target domains") + os.Exit(1) + } + + logger.Info(fmt.Sprintf("Cloning domain from %s to %s", src, dest)) + + serverType := internal.DetectServerType(src) + if serverType == "" { + logger.Error("Could not detect server type for source domain") + os.Exit(1) + } + + // Copy config + srcConf := filepath.Join("/etc", serverType, "sites-available", src+".conf") + destConf := filepath.Join("/etc", serverType, "sites-available", dest+".conf") + logger.Info("Copying config...") + internal.RunCommand("sudo", "cp", srcConf, destConf) + internal.RunCommand("sudo", "sed", "-i", fmt.Sprintf("s/%s/%s/g", src, dest), destConf) + + // Copy public_html + srcUser := strings.Split(src, ".")[0] + destUser := strings.Split(dest, ".")[0] + srcPath := fmt.Sprintf("/home/%s/public_html", srcUser) + destPath := fmt.Sprintf("/home/%s/public_html", destUser) + + logger.Info("Copying website files...") + internal.RunCommand("sudo", "mkdir", "-p", destPath) + internal.RunCommand("sudo", "cp", "-r", srcPath+"/.", destPath) + + if cloneUser { + logger.Info("Cloning user...") + internal.RunCommand("sudo", "useradd", "-m", "-s", "/bin/bash", destUser) + internal.RunCommand("sudo", "chown", "-R", fmt.Sprintf("%s:%s", destUser, destUser), "/home/"+destUser) + } + + if cloneDB { + logger.Info("Cloning MySQL database...") + dumpFile := fmt.Sprintf("/tmp/%s.sql", srcUser) + internal.RunCommand("sudo", "mysqldump", "-u", "root", srcUser, "-r", dumpFile) + internal.CreateMySQLUserAndDatabase(destUser, "changeme123") + internal.RunCommand("sudo", "mysql", "-u", "root", destUser, "-e", fmt.Sprintf("source %s", dumpFile)) + internal.RunCommand("sudo", "rm", "-f", dumpFile) + } + + // Enable site + logger.Info("Enabling cloned site...") + switch serverType { + case "apache": + internal.RunCommand("sudo", "a2ensite", dest+".conf") + internal.RunCommand("sudo", "systemctl", "reload", "apache2") + case "nginx": + link := filepath.Join("/etc/nginx/sites-enabled", dest+".conf") + internal.RunCommand("sudo", "ln", "-s", destConf, link) + internal.RunCommand("sudo", "systemctl", "reload", "nginx") + case "caddy": + link := filepath.Join("/etc/caddy/sites-enabled", dest+".conf") + internal.RunCommand("sudo", "ln", "-s", destConf, link) + internal.RunCommand("sudo", "systemctl", "reload", "caddy") + } + + logger.Success(fmt.Sprintf("Domain %s cloned to %s", src, dest)) + }, + } + + cmd.Flags().String("source", "", "Source domain to clone from") + cmd.Flags().String("target", "", "New domain name") + cmd.Flags().Bool("clone-db", false, "Clone MySQL database") + cmd.Flags().Bool("clone-user", false, "Clone shell user") + cmd.MarkFlagRequired("source") + cmd.MarkFlagRequired("target") + + return cmd +} diff --git a/cmd/domain/list.go b/cmd/domain/list.go new file mode 100644 index 0000000..7602984 --- /dev/null +++ b/cmd/domain/list.go @@ -0,0 +1,133 @@ +package domain + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "stackroost/internal/logger" +) + +type DomainInfo struct { + Server string `json:"server"` + Domain string `json:"domain"` + Status string `json:"status"` + User string `json:"user"` + Port string `json:"port,omitempty"` +} + +func GetListCmd() *cobra.Command { + var ( + filterServer string + outputJSON bool + showEnabled bool + showDisabled bool + ) + + cmd := &cobra.Command{ + Use: "list-domains", + Short: "List all configured domains and their status", + Run: func(cmd *cobra.Command, args []string) { + var results []DomainInfo + servers := []string{"apache", "nginx", "caddy"} + if filterServer != "" { + servers = []string{filterServer} + } + + for _, server := range servers { + availableDir := getSitesAvailableDir(server) + enabledDir := getSitesEnabledDir(server) + + files, err := os.ReadDir(availableDir) + if err != nil { + logger.Warn(fmt.Sprintf("Skipping %s: %v", server, err)) + continue + } + + for _, file := range files { + if file.IsDir() || !strings.HasSuffix(file.Name(), ".conf") { + continue + } + + domain := strings.TrimSuffix(file.Name(), ".conf") + username := strings.Split(domain, ".")[0] + + linkPath := filepath.Join(enabledDir, file.Name()) + enabled := isSymlink(linkPath) + + status := "DISABLED" + if enabled { + status = "ENABLED" + } + + if showEnabled && !enabled { + continue + } + if showDisabled && enabled { + continue + } + + results = append(results, DomainInfo{ + Server: server, + Domain: domain, + Status: status, + User: username, + }) + } + } + + if outputJSON { + jsonOutput, _ := json.MarshalIndent(results, "", " ") + fmt.Println(string(jsonOutput)) + } else { + for _, d := range results { + logger.Info(fmt.Sprintf("[%s] %-20s %-9s user: %-10s", strings.ToUpper(d.Server), d.Domain, d.Status, d.User)) + } + } + }, + } + + cmd.Flags().StringVar(&filterServer, "server", "", "Filter by server type (apache, nginx, caddy)") + cmd.Flags().BoolVar(&outputJSON, "json", false, "Output as JSON") + cmd.Flags().BoolVar(&showEnabled, "enabled", false, "Only show enabled domains") + cmd.Flags().BoolVar(&showDisabled, "disabled", false, "Only show disabled domains") + + return cmd +} + +func getSitesAvailableDir(server string) string { + switch server { + case "apache": + return "/etc/apache2/sites-available" + case "nginx": + return "/etc/nginx/sites-available" + case "caddy": + return "/etc/caddy/sites-available" + default: + return "" + } +} + +func getSitesEnabledDir(server string) string { + switch server { + case "apache": + return "/etc/apache2/sites-enabled" + case "nginx": + return "/etc/nginx/sites-enabled" + case "caddy": + return "/etc/caddy/sites-enabled" + default: + return "" + } +} + +func isSymlink(path string) bool { + info, err := os.Lstat(path) + if err != nil { + return false + } + return info.Mode()&os.ModeSymlink != 0 +} diff --git a/cmd/domain/remove.go b/cmd/domain/remove.go new file mode 100644 index 0000000..de1ca8d --- /dev/null +++ b/cmd/domain/remove.go @@ -0,0 +1,104 @@ +package domain + +import ( + "fmt" + "os" + "os/user" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +func GetRemoveCmd() *cobra.Command { + var domain, serverType string + var keepUser bool + + cmd := &cobra.Command{ + Use: "remove-domain", + Short: "Remove a domain configuration, user, and database", + Run: func(cmd *cobra.Command, args []string) { + if internal.IsNilOrEmpty(domain) { + logger.Error("Domain name is required") + os.Exit(1) + } + + username := strings.Split(domain, ".")[0] + filename := domain + ".conf" + + logger.Info(fmt.Sprintf("Removing domain: %s", domain)) + + // Step 1: Disable web server site + switch serverType { + case "apache": + logger.Info("Disabling Apache site") + internal.RunCommand("sudo", "a2dissite", filename) + internal.RunCommand("sudo", "systemctl", "reload", "apache2") + case "nginx": + link := filepath.Join("/etc/nginx/sites-enabled", filename) + internal.RunCommand("sudo", "rm", "-f", link) + internal.RunCommand("sudo", "systemctl", "reload", "nginx") + case "caddy": + link := filepath.Join("/etc/caddy/sites-enabled", filename) + internal.RunCommand("sudo", "rm", "-f", link) + internal.RunCommand("sudo", "systemctl", "reload", "caddy") + default: + logger.Error(fmt.Sprintf("Unsupported server type: %s", serverType)) + os.Exit(1) + } + + // Step 2: Remove config file + configPath := getConfigPath(serverType, domain) + if err := os.Remove(configPath); err != nil { + logger.Warn(fmt.Sprintf("Could not delete config file: %v", err)) + } else { + logger.Success(fmt.Sprintf("Removed config file: %s", configPath)) + } + + // Step 3: Remove MySQL database and user + if err := internal.DropMySQLUserAndDatabase(username); err != nil { + logger.Warn(fmt.Sprintf("MySQL cleanup failed: %v", err)) + } else { + logger.Success("MySQL user and database removed") + } + + // Step 4: Remove system user + if !keepUser { + logger.Info(fmt.Sprintf("Removing Linux user: %s", username)) + if currentUser, _ := user.Current(); currentUser.Username == username { + logger.Error("Refusing to delete the current executing user") + os.Exit(1) + } + internal.RunCommand("sudo", "userdel", "-r", username) + logger.Success(fmt.Sprintf("User '%s' and home directory removed", username)) + } else { + logger.Info("Keeping shell user and home directory (per flag)") + } + + logger.Success(fmt.Sprintf("Domain '%s' removed successfully", domain)) + }, + } + + cmd.Flags().StringVarP(&domain, "name", "n", "", "Domain name to remove") + cmd.Flags().StringVarP(&serverType, "server", "s", "apache", "Server type (apache, nginx, caddy)") + cmd.Flags().BoolVar(&keepUser, "keep-user", false, "Keep the Linux user and home directory") + cmd.MarkFlagRequired("name") + + return cmd +} + +func getConfigPath(serverType, domain string) string { + filename := domain + ".conf" + switch serverType { + case "apache": + return filepath.Join("/etc/apache2/sites-available", filename) + case "nginx": + return filepath.Join("/etc/nginx/sites-available", filename) + case "caddy": + return filepath.Join("/etc/caddy/sites-available", filename) + default: + return "" + } +} diff --git a/cmd/domain/restore.go b/cmd/domain/restore.go new file mode 100644 index 0000000..87e1525 --- /dev/null +++ b/cmd/domain/restore.go @@ -0,0 +1,89 @@ +package domain + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +func GetRestoreCmd() *cobra.Command { + var domain string + var backupFile string + + cmd := &cobra.Command{ + Use: "restore-domain", + Short: "Restore domain files and MySQL DB from backup archive", + Run: func(cmd *cobra.Command, args []string) { + if internal.IsNilOrEmpty(domain) || internal.IsNilOrEmpty(backupFile) { + logger.Error("Please provide both --domain and --file") + os.Exit(1) + } + + username := strings.Split(domain, ".")[0] + restoreDir := fmt.Sprintf("/tmp/restore-%s", username) + os.MkdirAll(restoreDir, 0755) + + ext := filepath.Ext(backupFile) + if ext == ".gz" || strings.HasSuffix(backupFile, ".tar.gz") { + logger.Info("Extracting tar.gz archive") + internal.RunCommand("sudo", "tar", "-xzf", backupFile, "-C", restoreDir) + } else if ext == ".tar" { + logger.Info("Extracting tar archive") + internal.RunCommand("sudo", "tar", "-xf", backupFile, "-C", restoreDir) + } else if ext == ".zip" { + logger.Info("Extracting zip archive") + internal.RunCommand("sudo", "unzip", "-o", backupFile, "-d", restoreDir) + } else { + logger.Error("Unsupported file type. Use: tar.gz, tar, or zip") + os.Exit(1) + } + + // Restore public_html + publicPath := fmt.Sprintf("/home/%s/public_html", username) + logger.Info(fmt.Sprintf("Restoring files to %s", publicPath)) + internal.RunCommand("sudo", "cp", "-r", filepath.Join(restoreDir, "home", username, "public_html"), filepath.Join("/home", username)) + internal.RunCommand("sudo", "chown", "-R", fmt.Sprintf("%s:%s", username, username), publicPath) + + // Restore MySQL if .sql found + sqlPath := "" + filepath.Walk(restoreDir, func(path string, info os.FileInfo, err error) error { + if strings.HasSuffix(path, ".sql") { + sqlPath = path + } + return nil + }) + + if sqlPath != "" { + logger.Info(fmt.Sprintf("Restoring MySQL DB from %s", sqlPath)) + restoreCmd := exec.Command("sudo", "mysql", "-u", username, fmt.Sprintf("-p%s", username), username) + sqlFile, _ := os.Open(sqlPath) + defer sqlFile.Close() + restoreCmd.Stdin = sqlFile + restoreCmd.Stdout = os.Stdout + restoreCmd.Stderr = os.Stderr + if err := restoreCmd.Run(); err != nil { + logger.Warn(fmt.Sprintf("MySQL restore failed: %v", err)) + } else { + logger.Success("MySQL database restored") + } + } else { + logger.Warn("No SQL file found in backup. Skipping DB restore.") + } + + logger.Success(fmt.Sprintf("Domain '%s' restored successfully", domain)) + }, + } + + cmd.Flags().StringVar(&domain, "domain", "", "Domain name to restore") + cmd.Flags().StringVar(&backupFile, "file", "", "Path to backup archive file (.tar.gz, .zip, .tar)") + cmd.MarkFlagRequired("domain") + cmd.MarkFlagRequired("file") + + return cmd +} diff --git a/cmd/domain/status.go b/cmd/domain/status.go new file mode 100644 index 0000000..9955686 --- /dev/null +++ b/cmd/domain/status.go @@ -0,0 +1,91 @@ +package domain + +import ( + "fmt" + "os" + "os/user" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +func GetStatusCmd() *cobra.Command { + var domain string + + cmd := &cobra.Command{ + Use: "status-domain", + Short: "Inspect the configuration, user, and SSL status of a domain", + Run: func(cmd *cobra.Command, args []string) { + if internal.IsNilOrEmpty(domain) { + logger.Error("Please provide a domain using --domain") + os.Exit(1) + } + + logger.Info(fmt.Sprintf("Inspecting domain: %s", domain)) + + serverType := internal.DetectServerType(domain) + if serverType == "" { + logger.Warn("Could not detect server type (no config found)") + } else { + logger.Info(fmt.Sprintf("Server: %s", serverType)) + } + + // Check if site is enabled + enabled := false + switch serverType { + case "apache": + out := internal.CaptureCommand("a2query", "-s", domain) + enabled = strings.Contains(out, "is enabled") + case "nginx": + linkPath := filepath.Join("/etc/nginx/sites-enabled", domain+".conf") + _, err := os.Stat(linkPath) + enabled = err == nil + case "caddy": + linkPath := filepath.Join("/etc/caddy/sites-enabled", domain+".conf") + _, err := os.Stat(linkPath) + enabled = err == nil + } + + if enabled { + logger.Info("Status: ENABLED") + } else { + logger.Info("Status: DISABLED") + } + + // Shell user check + username := strings.Split(domain, ".")[0] + if _, err := user.Lookup(username); err == nil { + logger.Info(fmt.Sprintf("Shell User: %s ✔", username)) + } else { + logger.Warn(fmt.Sprintf("Shell User: %s", username)) + } + + // public_html check + htmlPath := fmt.Sprintf("/home/%s/public_html", username) + if _, err := os.Stat(htmlPath); err == nil { + logger.Info(fmt.Sprintf("Public HTML: %s ✔", htmlPath)) + } else { + logger.Warn(fmt.Sprintf("Public HTML: %s", htmlPath)) + } + + // SSL cert status + if serverType == "caddy" { + logger.Info("SSL Certificate: Handled automatically by Caddy") + } else { + out := internal.CaptureCommand("sudo", "certbot", "certificates", "--cert-name", domain) + if strings.Contains(out, domain) { + logger.Info("SSL Certificate: Installed via Certbot") + } else { + logger.Warn("SSL Certificate: Not found") + } + } + }, + } + + cmd.Flags().StringVar(&domain, "domain", "", "Domain name to inspect") + cmd.MarkFlagRequired("domain") + return cmd +} diff --git a/cmd/domain/toggle.go b/cmd/domain/toggle.go new file mode 100644 index 0000000..9628dcf --- /dev/null +++ b/cmd/domain/toggle.go @@ -0,0 +1,80 @@ +package domain + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" + "strings" +) + +func GetToggleCmd() *cobra.Command { + var domain string + + cmd := &cobra.Command{ + Use: "toggle-site", + Short: "Enable or disable a site's configuration", + Run: func(cmd *cobra.Command, args []string) { + if internal.IsNilOrEmpty(domain) { + logger.Error("Please provide a domain using --domain") + os.Exit(1) + } + + serverType := internal.DetectServerType(domain) + if serverType == "" { + logger.Warn("Could not detect server type") + os.Exit(1) + } + logger.Info(fmt.Sprintf("Detected server: %s", serverType)) + + filename := domain + ".conf" + enabled := false + + switch serverType { + case "apache": + output := internal.CaptureCommand("a2query", "-s", domain) + enabled = strings.Contains(output, "is enabled") + if enabled { + logger.Info(fmt.Sprintf("Disabling Apache site: %s", domain)) + internal.RunCommand("sudo", "a2dissite", filename) + } else { + logger.Info(fmt.Sprintf("Enabling Apache site: %s", domain)) + internal.RunCommand("sudo", "a2ensite", filename) + } + internal.RunCommand("sudo", "systemctl", "reload", "apache2") + + case "nginx": + sitesAvailable := "/etc/nginx/sites-available/" + filename + sitesEnabled := "/etc/nginx/sites-enabled/" + filename + if _, err := os.Stat(sitesEnabled); err == nil { + logger.Info(fmt.Sprintf("Disabling Nginx site: %s", domain)) + internal.RunCommand("sudo", "rm", "-f", sitesEnabled) + } else { + logger.Info(fmt.Sprintf("Enabling Nginx site: %s", domain)) + internal.RunCommand("sudo", "ln", "-s", sitesAvailable, sitesEnabled) + } + internal.RunCommand("sudo", "systemctl", "reload", "nginx") + + case "caddy": + sitesAvailable := "/etc/caddy/sites-available/" + filename + sitesEnabled := "/etc/caddy/sites-enabled/" + filename + if _, err := os.Stat(sitesEnabled); err == nil { + logger.Info(fmt.Sprintf("Disabling Caddy site: %s", domain)) + internal.RunCommand("sudo", "rm", "-f", sitesEnabled) + } else { + logger.Info(fmt.Sprintf("Enabling Caddy site: %s", domain)) + internal.RunCommand("sudo", "ln", "-s", sitesAvailable, sitesEnabled) + } + internal.RunCommand("sudo", "systemctl", "reload", "caddy") + } + + logger.Success(fmt.Sprintf("Site %s toggled successfully", domain)) + }, + } + + cmd.Flags().StringVar(&domain, "domain", "", "Domain name to toggle") + cmd.MarkFlagRequired("domain") + return cmd +} \ No newline at end of file diff --git a/cmd/domain/update_port.go b/cmd/domain/update_port.go new file mode 100644 index 0000000..60b0f23 --- /dev/null +++ b/cmd/domain/update_port.go @@ -0,0 +1,86 @@ +package domain + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +func GetUpdatePortCmd() *cobra.Command { + var domain string + var newPort string + + cmd := &cobra.Command{ + Use: "update-domain-port", + Short: "Update the port for a domain and reload the web server", + Run: func(cmd *cobra.Command, args []string) { + if internal.IsNilOrEmpty(domain) || internal.IsNilOrEmpty(newPort) { + logger.Error("Both --domain and --port are required") + os.Exit(1) + } + + server := internal.DetectServerType(domain) + if server == "" { + logger.Error("Could not detect server type (no config found)") + os.Exit(1) + } + + var configPath string + switch server { + case "apache": + configPath = filepath.Join("/etc/apache2/sites-available", domain+".conf") + case "nginx": + configPath = filepath.Join("/etc/nginx/sites-available", domain+".conf") + case "caddy": + configPath = filepath.Join("/etc/caddy/sites-available", domain+".conf") + default: + logger.Error("Unsupported server type") + os.Exit(1) + } + + logger.Info(fmt.Sprintf("Updating port in %s configuration", server)) + + content, err := os.ReadFile(configPath) + if err != nil { + logger.Error(fmt.Sprintf("Failed to read config file: %v", err)) + os.Exit(1) + } + + backupPath := configPath + ".bak" + _ = os.WriteFile(backupPath, content, 0644) + logger.Info(fmt.Sprintf("Backup created: %s", backupPath)) + + updated := strings.ReplaceAll(string(content), ":80", ":"+newPort) + if err := os.WriteFile(configPath, []byte(updated), 0644); err != nil { + logger.Error(fmt.Sprintf("Failed to update config file: %v", err)) + os.Exit(1) + } + + logger.Success(fmt.Sprintf("Port updated to %s for domain %s", newPort, domain)) + + // Reload web server + switch server { + case "apache": + internal.RunCommand("sudo", "systemctl", "reload", "apache2") + case "nginx": + internal.RunCommand("sudo", "systemctl", "reload", "nginx") + case "caddy": + internal.RunCommand("sudo", "systemctl", "reload", "caddy") + } + + logger.Success(fmt.Sprintf("%s server reloaded successfully", strings.ToUpper(server))) + }, + } + + cmd.Flags().StringVar(&domain, "domain", "", "Domain name to update") + cmd.Flags().StringVar(&newPort, "port", "", "New port number") + cmd.MarkFlagRequired("domain") + cmd.MarkFlagRequired("port") + + return cmd +} diff --git a/cmd/email/test_email.go b/cmd/email/test.go similarity index 88% rename from cmd/email/test_email.go rename to cmd/email/test.go index 01bd6c3..85ed53d 100644 --- a/cmd/email/test_email.go +++ b/cmd/email/test.go @@ -9,7 +9,6 @@ import ( "stackroost/internal/logger" ) - var TestEmailCmd = &cobra.Command{ Use: "test-email", Short: "Check if email capability is available on this system (mail/sendmail/msmtp)", @@ -39,3 +38,8 @@ func init() { TestEmailCmd.Flags().String("to", "", "Recipient email address") TestEmailCmd.MarkFlagRequired("to") } + +// This is the getter function for use in root registration +func GetTestCmd() *cobra.Command { + return TestEmailCmd +} diff --git a/cmd/enable_firewall.go b/cmd/enable_firewall.go deleted file mode 100644 index 24b26fb..0000000 --- a/cmd/enable_firewall.go +++ /dev/null @@ -1,54 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" -) - -var firewallPorts []int - -var enableFirewallCmd = &cobra.Command{ - Use: "enable-firewall", - Short: "Enable UFW and allow common and custom ports", - Run: func(cmd *cobra.Command, args []string) { - logger.Info("Enabling UFW (Uncomplicated Firewall)") - - // Install ufw if not installed - if err := internal.RunCommand("sudo", "apt-get", "install", "-y", "ufw"); err != nil { - logger.Error(fmt.Sprintf("Failed to install UFW: %v", err)) - os.Exit(1) - } - - // Allow essential ports - defaultPorts := []int{22, 80, 443} - for _, port := range defaultPorts { - logger.Info(fmt.Sprintf("Allowing port: %d", port)) - internal.RunCommand("sudo", "ufw", "allow", fmt.Sprintf("%d", port)) - } - - // Allow custom ports - for _, port := range firewallPorts { - logger.Info(fmt.Sprintf("Allowing custom port: %d", port)) - internal.RunCommand("sudo", "ufw", "allow", fmt.Sprintf("%d", port)) - } - - // Enable ufw - logger.Info("Enabling UFW") - internal.RunCommand("sudo", "ufw", "--force", "enable") - - // Show status - logger.Info("Firewall status:") - internal.RunCommand("sudo", "ufw", "status", "verbose") - - logger.Success("Firewall configured and enabled successfully") - }, -} - -func init() { - rootCmd.AddCommand(enableFirewallCmd) - enableFirewallCmd.Flags().IntSliceVarP(&firewallPorts, "port", "p", []int{}, "Additional custom ports to allow (comma separated)") -} diff --git a/cmd/enable_ssl.go b/cmd/enable_ssl.go deleted file mode 100644 index ce06bf0..0000000 --- a/cmd/enable_ssl.go +++ /dev/null @@ -1,50 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" -) - -var sslDomain string - -var enableSSLCmd = &cobra.Command{ - Use: "enable-ssl", - Short: "Enable Let's Encrypt SSL for a specific domain", - Run: func(cmd *cobra.Command, args []string) { - if sslDomain == "" { - logger.Error("Please provide a domain using --domain") - os.Exit(1) - } - - serverType := internal.DetectServerType(sslDomain) - if serverType == "" { - logger.Error(fmt.Sprintf("Could not detect server type for domain: %s", sslDomain)) - os.Exit(1) - } - - if serverType == "caddy" { - logger.Info("Caddy automatically handles SSL — no need to enable manually.") - return - } - - logger.Info(fmt.Sprintf("Detected %s configuration for %s", serverType, sslDomain)) - - err := internal.EnableSSLCertbot(sslDomain, serverType) - if err != nil { - logger.Error(fmt.Sprintf("Failed to enable SSL for %s: %v", sslDomain, err)) - os.Exit(1) - } - - logger.Success(fmt.Sprintf("SSL enabled successfully for %s", sslDomain)) - }, -} - -func init() { - rootCmd.AddCommand(enableSSLCmd) - enableSSLCmd.Flags().StringVar(&sslDomain, "domain", "", "Domain name to enable SSL for") - enableSSLCmd.MarkFlagRequired("domain") -} diff --git a/cmd/firewall/disable.go b/cmd/firewall/disable.go new file mode 100644 index 0000000..e10f1cd --- /dev/null +++ b/cmd/firewall/disable.go @@ -0,0 +1,59 @@ +package firewall + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +func GetDisableCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "disable-firewall", + Short: "Disable the system firewall (UFW) safely", + Run: func(cmd *cobra.Command, args []string) { + flush, _ := cmd.Flags().GetBool("flush") + + logger.Info("Checking firewall status...") + statusOutput := runCommand("sudo", "ufw", "status") + + if statusOutput == "" || strings.Contains(statusOutput, "inactive") { + logger.Warn("Firewall is already inactive") + return + } + + logger.Info("Disabling firewall (UFW)...") + if err := internal.RunCommand("sudo", "ufw", "disable"); err != nil { + logger.Error(fmt.Sprintf("Failed to disable firewall: %v", err)) + os.Exit(1) + } + + if flush { + logger.Warn("Flushing all UFW rules...") + if err := internal.RunCommand("sudo", "ufw", "reset"); err != nil { + logger.Error(fmt.Sprintf("Failed to flush firewall rules: %v", err)) + os.Exit(1) + } + logger.Success("All firewall rules flushed.") + } + + logger.Success("Firewall disabled successfully.") + }, + } + + cmd.Flags().Bool("flush", false, "Flush all UFW rules after disabling") + return cmd +} + +func runCommand(name string, args ...string) string { + var out strings.Builder + cmd := exec.Command(name, args...) + cmd.Stdout = &out + cmd.Stderr = &out + _ = cmd.Run() + return out.String() +} diff --git a/cmd/firewall/enable.go b/cmd/firewall/enable.go new file mode 100644 index 0000000..086178d --- /dev/null +++ b/cmd/firewall/enable.go @@ -0,0 +1,55 @@ +package firewall + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +var firewallPorts []int + +// GetEnableCmd returns the Cobra command to enable the firewall +func GetEnableCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "enable-firewall", + Short: "Enable UFW and allow common and custom ports", + Run: func(cmd *cobra.Command, args []string) { + logger.Info("Enabling UFW (Uncomplicated Firewall)") + + // Install ufw if not installed + if err := internal.RunCommand("sudo", "apt-get", "install", "-y", "ufw"); err != nil { + logger.Error(fmt.Sprintf("Failed to install UFW: %v", err)) + os.Exit(1) + } + + // Allow essential ports + defaultPorts := []int{22, 80, 443} + for _, port := range defaultPorts { + logger.Info(fmt.Sprintf("Allowing port: %d", port)) + internal.RunCommand("sudo", "ufw", "allow", fmt.Sprintf("%d", port)) + } + + // Allow custom ports + for _, port := range firewallPorts { + logger.Info(fmt.Sprintf("Allowing custom port: %d", port)) + internal.RunCommand("sudo", "ufw", "allow", fmt.Sprintf("%d", port)) + } + + // Enable ufw + logger.Info("Enabling UFW") + internal.RunCommand("sudo", "ufw", "--force", "enable") + + // Show status + logger.Info("Firewall status:") + internal.RunCommand("sudo", "ufw", "status", "verbose") + + logger.Success("Firewall configured and enabled successfully") + }, + } + + cmd.Flags().IntSliceVarP(&firewallPorts, "port", "p", []int{}, "Additional custom ports to allow (comma separated)") + return cmd +} diff --git a/cmd/inspect_config.go b/cmd/inspect_config.go deleted file mode 100644 index 7cbc325..0000000 --- a/cmd/inspect_config.go +++ /dev/null @@ -1,56 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" -) - -var inspectConfigCmd = &cobra.Command{ - Use: "inspect-config", - Short: "View the web server configuration file of a domain", - Run: func(cmd *cobra.Command, args []string) { - domain, _ := cmd.Flags().GetString("domain") - if internal.IsNilOrEmpty(domain) { - logger.Error("Please provide a domain using --domain") - os.Exit(1) - } - - serverType := internal.DetectServerType(domain) - if serverType == "" { - logger.Error("Could not detect server type. No config file found.") - os.Exit(1) - } - - var configPath string - switch serverType { - case "apache": - configPath = filepath.Join("/etc/apache2/sites-available", domain+".conf") - case "nginx": - configPath = filepath.Join("/etc/nginx/sites-available", domain+".conf") - case "caddy": - configPath = filepath.Join("/etc/caddy/sites-available", domain+".conf") - default: - logger.Error("Unsupported server type") - os.Exit(1) - } - - if _, err := os.Stat(configPath); os.IsNotExist(err) { - logger.Error(fmt.Sprintf("Config file not found at: %s", configPath)) - os.Exit(1) - } - - logger.Info(fmt.Sprintf("Showing config: %s", configPath)) - internal.RunCommand("sudo", "cat", configPath) - }, -} - -func init() { - rootCmd.AddCommand(inspectConfigCmd) - inspectConfigCmd.Flags().String("domain", "", "Domain name to inspect config for") - inspectConfigCmd.MarkFlagRequired("domain") -} diff --git a/cmd/list_domains.go b/cmd/list_domains.go deleted file mode 100644 index 88d6b21..0000000 --- a/cmd/list_domains.go +++ /dev/null @@ -1,136 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/spf13/cobra" - "stackroost/internal/logger" -) - -type DomainInfo struct { - Server string `json:"server"` - Domain string `json:"domain"` - Status string `json:"status"` - User string `json:"user"` - Port string `json:"port,omitempty"` -} - -var ( - filterServer string - outputJSON bool - showEnabled bool - showDisabled bool -) - -var listDomainsCmd = &cobra.Command{ - Use: "list-domains", - Short: "List all configured domains and their status", - Run: func(cmd *cobra.Command, args []string) { - var results []DomainInfo - - servers := []string{"apache", "nginx", "caddy"} - if filterServer != "" { - servers = []string{filterServer} - } - - for _, server := range servers { - availableDir := getSitesAvailableDir(server) - enabledDir := getSitesEnabledDir(server) - - files, err := os.ReadDir(availableDir) - if err != nil { - logger.Warn(fmt.Sprintf("Skipping %s: %v", server, err)) - continue - } - - for _, file := range files { - if file.IsDir() || !strings.HasSuffix(file.Name(), ".conf") { - continue - } - - domain := strings.TrimSuffix(file.Name(), ".conf") - username := strings.Split(domain, ".")[0] - - linkPath := filepath.Join(enabledDir, file.Name()) - enabled := isSymlink(linkPath) - - status := "DISABLED" - if enabled { - status = "ENABLED" - } - - // Filter logic - if showEnabled && !enabled { - continue - } - if showDisabled && enabled { - continue - } - - info := DomainInfo{ - Server: server, - Domain: domain, - Status: status, - User: username, - } - results = append(results, info) - } - } - - // Output formatting - if outputJSON { - jsonOutput, _ := json.MarshalIndent(results, "", " ") - fmt.Println(string(jsonOutput)) - } else { - for _, d := range results { - logger.Info(fmt.Sprintf("[%s] %-20s %-9s user: %-10s", strings.ToUpper(d.Server), d.Domain, d.Status, d.User)) - } - } - }, -} - -func init() { - rootCmd.AddCommand(listDomainsCmd) - listDomainsCmd.Flags().StringVar(&filterServer, "server", "", "Filter by server type (apache, nginx, caddy)") - listDomainsCmd.Flags().BoolVar(&outputJSON, "json", false, "Output as JSON") - listDomainsCmd.Flags().BoolVar(&showEnabled, "enabled", false, "Only show enabled domains") - listDomainsCmd.Flags().BoolVar(&showDisabled, "disabled", false, "Only show disabled domains") -} - -func getSitesAvailableDir(server string) string { - switch server { - case "apache": - return "/etc/apache2/sites-available" - case "nginx": - return "/etc/nginx/sites-available" - case "caddy": - return "/etc/caddy/sites-available" - default: - return "" - } -} - -func getSitesEnabledDir(server string) string { - switch server { - case "apache": - return "/etc/apache2/sites-enabled" - case "nginx": - return "/etc/nginx/sites-enabled" - case "caddy": - return "/etc/caddy/sites-enabled" - default: - return "" - } -} - -func isSymlink(path string) bool { - info, err := os.Lstat(path) - if err != nil { - return false - } - return info.Mode()&os.ModeSymlink != 0 -} diff --git a/cmd/list_users.go b/cmd/list_users.go deleted file mode 100644 index 98c26f9..0000000 --- a/cmd/list_users.go +++ /dev/null @@ -1,54 +0,0 @@ -package cmd - -import ( - "bufio" - "fmt" - "os" - "os/user" - "strconv" - "strings" - - "github.com/spf13/cobra" - "stackroost/internal/logger" -) - -var listUsersCmd = &cobra.Command{ - Use: "list-users", - Short: "List all regular system users (UID ≥ 1000)", - Run: func(cmd *cobra.Command, args []string) { - file, err := os.Open("/etc/passwd") - if err != nil { - logger.Error(fmt.Sprintf("Failed to open /etc/passwd: %v", err)) - return - } - defer file.Close() - - scanner := bufio.NewScanner(file) - logger.Info("Listing non-system shell users (UID ≥ 1000)") - for scanner.Scan() { - line := scanner.Text() - parts := strings.Split(line, ":") - if len(parts) < 7 { - continue - } - - username := parts[0] - uidStr := parts[2] - shell := parts[6] - - uid, err := strconv.Atoi(uidStr) - if err != nil || uid < 1000 { - continue - } - - if shell == "/bin/bash" || shell == "/bin/sh" { - u, _ := user.Lookup(username) - logger.Info(fmt.Sprintf("User: %-15s UID: %-5d Shell: %s Home: %s", username, uid, shell, u.HomeDir)) - } - } - }, -} - -func init() { - rootCmd.AddCommand(listUsersCmd) -} diff --git a/cmd/logs/analyze_traffic.go b/cmd/logs/analyze.go similarity index 95% rename from cmd/logs/analyze_traffic.go rename to cmd/logs/analyze.go index 820cb2b..a36de76 100644 --- a/cmd/logs/analyze_traffic.go +++ b/cmd/logs/analyze.go @@ -25,6 +25,7 @@ func init() { AnalyzeTrafficCmd.MarkFlagRequired("domain") } +// Main logic func runAnalyzeTraffic(cmd *cobra.Command, args []string) { domain, _ := cmd.Flags().GetString("domain") lines, _ := cmd.Flags().GetInt("lines") @@ -109,3 +110,8 @@ func printTopN(data map[string]int, n int) { fmt.Printf(" %s → %d\n", sorted[i].Key, sorted[i].Value) } } + +// Exportable getter +func GetAnalyzeCmd() *cobra.Command { + return AnalyzeTrafficCmd +} diff --git a/cmd/logs/domain_logs.go b/cmd/logs/domain_logs.go new file mode 100644 index 0000000..dae4708 --- /dev/null +++ b/cmd/logs/domain_logs.go @@ -0,0 +1,87 @@ +package logs + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +// GetDomainLogsCmd returns the CLI command for viewing domain logs +func GetDomainLogsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "logs-domain", + Short: "View recent access and error logs for a domain", + Run: func(cmd *cobra.Command, args []string) { + domain, _ := cmd.Flags().GetString("domain") + lines, _ := cmd.Flags().GetInt("lines") + + if internal.IsNilOrEmpty(domain) { + logger.Error("Please provide a domain using --domain") + os.Exit(1) + } + + if lines <= 0 { + lines = 50 + } + + server := internal.DetectServerType(domain) + if server == "" { + logger.Error("Could not detect server type for the domain") + os.Exit(1) + } + + var accessLogPath, errorLogPath string + switch server { + case "apache": + accessLogPath = fmt.Sprintf("/var/log/apache2/%s-access.log", domain) + errorLogPath = fmt.Sprintf("/var/log/apache2/%s-error.log", domain) + case "nginx": + accessLogPath = fmt.Sprintf("/var/log/nginx/%s-access.log", domain) + errorLogPath = fmt.Sprintf("/var/log/nginx/%s-error.log", domain) + case "caddy": + accessLogPath = fmt.Sprintf("/var/log/caddy/%s-access.log", domain) + errorLogPath = "" // Caddy doesn't have default error logs per domain + default: + logger.Error("Unsupported server type") + os.Exit(1) + } + + // Access log + if stat, err := os.Stat(accessLogPath); err == nil { + logger.Info(fmt.Sprintf("Access Log (%s):", accessLogPath)) + if stat.Size() == 0 { + logger.Warn("Access log is empty") + } else { + internal.RunCommand("sudo", "tail", "-n", fmt.Sprintf("%d", lines), accessLogPath) + } + } else { + logger.Warn("Access log not found") + } + + // Error log + if errorLogPath != "" { + if stat, err := os.Stat(errorLogPath); err == nil { + logger.Info(fmt.Sprintf("Error Log (%s):", errorLogPath)) + if stat.Size() == 0 { + logger.Warn("Error log is empty") + } else { + internal.RunCommand("sudo", "tail", "-n", fmt.Sprintf("%d", lines), errorLogPath) + } + } else { + logger.Warn("Error log not found") + } + } else if server == "caddy" { + logger.Info("Caddy does not maintain a separate error log by default") + } + }, + } + + cmd.Flags().String("domain", "", "Domain name to view logs for") + cmd.Flags().Int("lines", 50, "Number of lines to show from each log file") + cmd.MarkFlagRequired("domain") + + return cmd +} diff --git a/cmd/logs/purge.go b/cmd/logs/purge.go new file mode 100644 index 0000000..0f53492 --- /dev/null +++ b/cmd/logs/purge.go @@ -0,0 +1,66 @@ +package logs + +import ( + "os" + "path/filepath" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +// GetPurgeCmd returns the cobra command to purge domain logs +func GetPurgeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "purge-domain-logs", + Short: "Delete access and error logs for a specific domain", + Run: func(cmd *cobra.Command, args []string) { + domain, _ := cmd.Flags().GetString("domain") + if internal.IsNilOrEmpty(domain) { + logger.Error("Please provide a domain using --domain") + os.Exit(1) + } + + server := internal.DetectServerType(domain) + if server == "" { + logger.Error("Could not detect server type for domain: " + domain) + os.Exit(1) + } + + var accessLog, errorLog string + + switch server { + case "apache": + accessLog = filepath.Join("/var/log/apache2", domain+"-access.log") + errorLog = filepath.Join("/var/log/apache2", domain+"-error.log") + case "nginx": + accessLog = filepath.Join("/var/log/nginx", domain+"-access.log") + errorLog = filepath.Join("/var/log/nginx", domain+"-error.log") + case "caddy": + logger.Warn("Caddy does not maintain traditional per-domain logs") + return + default: + logger.Error("Unsupported server type: " + server) + return + } + + for _, logFile := range []string{accessLog, errorLog} { + if _, err := os.Stat(logFile); err == nil { + logger.Info("Deleting log: " + logFile) + if err := internal.RunCommand("sudo", "rm", "-f", logFile); err != nil { + logger.Error("Failed to delete log file: " + err.Error()) + } else { + logger.Success("Deleted: " + logFile) + } + } else { + logger.Warn("Log not found: " + logFile) + } + } + }, + } + + cmd.Flags().String("domain", "", "Domain name to purge logs for") + cmd.MarkFlagRequired("domain") + + return cmd +} diff --git a/cmd/logs_domain.go b/cmd/logs_domain.go deleted file mode 100644 index faf0322..0000000 --- a/cmd/logs_domain.go +++ /dev/null @@ -1,84 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" -) - -var logsDomainCmd = &cobra.Command{ - Use: "logs-domain", - Short: "View recent access and error logs for a domain", - Run: func(cmd *cobra.Command, args []string) { - domain, _ := cmd.Flags().GetString("domain") - lines, _ := cmd.Flags().GetInt("lines") - - if internal.IsNilOrEmpty(domain) { - logger.Error("Please provide a domain using --domain") - os.Exit(1) - } - - if lines <= 0 { - lines = 50 - } - - server := internal.DetectServerType(domain) - if server == "" { - logger.Error("Could not detect server type for the domain") - os.Exit(1) - } - - var accessLogPath, errorLogPath string - - switch server { - case "apache": - accessLogPath = fmt.Sprintf("/var/log/apache2/%s-access.log", domain) - errorLogPath = fmt.Sprintf("/var/log/apache2/%s-error.log", domain) - case "nginx": - accessLogPath = fmt.Sprintf("/var/log/nginx/%s-access.log", domain) - errorLogPath = fmt.Sprintf("/var/log/nginx/%s-error.log", domain) - case "caddy": - accessLogPath = fmt.Sprintf("/var/log/caddy/%s-access.log", domain) - errorLogPath = "" // Caddy typically does not separate error logs - default: - logger.Error("Unsupported server type") - os.Exit(1) - } - - // Show access log - if _, err := os.Stat(accessLogPath); err == nil { - logger.Info(fmt.Sprintf("Access Log (%s):", accessLogPath)) - if fi, _ := os.Stat(accessLogPath); fi.Size() == 0 { - logger.Warn("Access log is empty") - } else { - internal.RunCommand("sudo", "tail", "-n", fmt.Sprintf("%d", lines), accessLogPath) - } - } else { - logger.Warn("Access log not found") - } - // Show error log - if errorLogPath != "" { - if _, err := os.Stat(errorLogPath); err == nil { - logger.Info(fmt.Sprintf("Error Log (%s):", errorLogPath)) - if fi, _ := os.Stat(errorLogPath); fi.Size() == 0 { - logger.Warn("Error log is empty") - } else { - internal.RunCommand("sudo", "tail", "-n", fmt.Sprintf("%d", lines), errorLogPath) - } - } else { - logger.Warn("Error log not found") - } - } else if server == "caddy" { - logger.Info("Caddy does not maintain a separate error log by default") - } - }, -} - -func init() { - rootCmd.AddCommand(logsDomainCmd) - logsDomainCmd.Flags().String("domain", "", "Domain name to view logs for") - logsDomainCmd.Flags().Int("lines", 50, "Number of lines to show from each log file") - logsDomainCmd.MarkFlagRequired("domain") -} diff --git a/cmd/purge_logs.go b/cmd/purge_logs.go deleted file mode 100644 index 82c5cfc..0000000 --- a/cmd/purge_logs.go +++ /dev/null @@ -1,64 +0,0 @@ -package cmd - -import ( - "os" - "path/filepath" - - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" -) - -var purgeDomainLogsCmd = &cobra.Command{ - Use: "purge-domain-logs", - Short: "Delete access and error logs for a specific domain", - Run: func(cmd *cobra.Command, args []string) { - domain, _ := cmd.Flags().GetString("domain") - if internal.IsNilOrEmpty(domain) { - logger.Error("Please provide a domain using --domain") - os.Exit(1) - } - - server := internal.DetectServerType(domain) - if server == "" { - logger.Error("Could not detect server type for domain: " + domain) - os.Exit(1) - } - - var accessLog, errorLog string - - switch server { - case "apache": - accessLog = filepath.Join("/var/log/apache2", domain+"-access.log") - errorLog = filepath.Join("/var/log/apache2", domain+"-error.log") - case "nginx": - accessLog = filepath.Join("/var/log/nginx", domain+"-access.log") - errorLog = filepath.Join("/var/log/nginx", domain+"-error.log") - case "caddy": - logger.Warn("Caddy does not maintain traditional per-domain logs") - return - default: - logger.Error("Unsupported server type: " + server) - return - } - - for _, logFile := range []string{accessLog, errorLog} { - if _, err := os.Stat(logFile); err == nil { - logger.Info("Deleting log: " + logFile) - if err := internal.RunCommand("sudo", "rm", "-f", logFile); err != nil { - logger.Error("Failed to delete log file: " + err.Error()) - } else { - logger.Success("Deleted: " + logFile) - } - } else { - logger.Warn("Log not found: " + logFile) - } - } - }, -} - -func init() { - rootCmd.AddCommand(purgeDomainLogsCmd) - purgeDomainLogsCmd.Flags().String("domain", "", "Domain name to purge logs for") - purgeDomainLogsCmd.MarkFlagRequired("domain") -} diff --git a/cmd/remove.go b/cmd/remove.go deleted file mode 100644 index 767a599..0000000 --- a/cmd/remove.go +++ /dev/null @@ -1,108 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "os/user" - "path/filepath" - "strings" - - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" -) - -var removeDomainCmd = &cobra.Command{ - Use: "remove-domain", - Short: "Remove a domain configuration, user, and database", - Run: func(cmd *cobra.Command, args []string) { - domain, _ := cmd.Flags().GetString("name") - serverType, _ := cmd.Flags().GetString("server") - keepUser, _ := cmd.Flags().GetBool("keep-user") - - if internal.IsNilOrEmpty(domain) { - logger.Error("Domain name is required") - os.Exit(1) - } - - username := strings.Split(domain, ".")[0] - filename := domain + ".conf" - - logger.Info(fmt.Sprintf("Removing domain: %s", domain)) - - // Step 1: Disable web server site - switch serverType { - case "apache": - logger.Info("Disabling Apache site") - internal.RunCommand("sudo", "a2dissite", filename) - internal.RunCommand("sudo", "systemctl", "reload", "apache2") - case "nginx": - link := filepath.Join("/etc/nginx/sites-enabled", filename) - internal.RunCommand("sudo", "rm", "-f", link) - internal.RunCommand("sudo", "systemctl", "reload", "nginx") - case "caddy": - link := filepath.Join("/etc/caddy/sites-enabled", filename) - internal.RunCommand("sudo", "rm", "-f", link) - internal.RunCommand("sudo", "systemctl", "reload", "caddy") - default: - logger.Error(fmt.Sprintf("Unsupported server type: %s", serverType)) - os.Exit(1) - } - - // Step 2: Remove config file - configPath := getServerConfigPath(serverType, domain) - if err := os.Remove(configPath); err != nil { - logger.Warn(fmt.Sprintf("Could not delete config file: %v", err)) - } else { - logger.Success(fmt.Sprintf("Removed config file: %s", configPath)) - } - - // Step 3: Remove MySQL database and user - if err := internal.DropMySQLUserAndDatabase(username); err != nil { - logger.Warn(fmt.Sprintf("MySQL cleanup failed: %v", err)) - } else { - logger.Success("MySQL user and database removed") - } - - // Step 4: Remove system user - if !keepUser { - logger.Info(fmt.Sprintf("Removing Linux user: %s", username)) - - // Sanity check - prevent deleting yourself - currentUser, _ := user.Current() - if currentUser.Username == username { - logger.Error("Refusing to delete the current executing user") - os.Exit(1) - } - - internal.RunCommand("sudo", "userdel", "-r", username) - logger.Success(fmt.Sprintf("User '%s' and home directory removed", username)) - } else { - logger.Info("Keeping shell user and home directory (per flag)") - } - - logger.Success(fmt.Sprintf("Domain '%s' removed successfully", domain)) - }, -} - -func getServerConfigPath(serverType, domain string) string { - filename := domain + ".conf" - switch serverType { - case "apache": - return filepath.Join("/etc/apache2/sites-available", filename) - case "nginx": - return filepath.Join("/etc/nginx/sites-available", filename) - case "caddy": - return filepath.Join("/etc/caddy/sites-available", filename) - default: - return "" - } -} - -func init() { - rootCmd.AddCommand(removeDomainCmd) - removeDomainCmd.Flags().StringP("name", "n", "", "Domain name to remove") - removeDomainCmd.Flags().StringP("server", "s", "apache", "Server type (apache, nginx, caddy)") - removeDomainCmd.Flags().Bool("keep-user", false, "Keep the Linux user and home directory") - removeDomainCmd.MarkFlagRequired("name") -} diff --git a/cmd/renew_ssl.go b/cmd/renew_ssl.go deleted file mode 100644 index ff49bc6..0000000 --- a/cmd/renew_ssl.go +++ /dev/null @@ -1,73 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" -) - -var ( - renewAll bool - domainName string - forceFlag bool -) - -var renewSSLCmd = &cobra.Command{ - Use: "renew-ssl", - Short: "Renew SSL certificates for all or specific domains", - Run: func(cmd *cobra.Command, args []string) { - if renewAll { - logger.Info("Renewing SSL certificates for all domains") - err := internal.RunCommand("sudo", "certbot", "renew") - if err != nil { - logger.Error(fmt.Sprintf("SSL renewal failed: %v", err)) - os.Exit(1) - } - logger.Success("All certificates renewed successfully") - return - } - - if domainName == "" { - logger.Error("Either --all or --domain must be provided") - os.Exit(1) - } - - serverType := internal.DetectServerType(domainName) - if serverType == "" { - logger.Error(fmt.Sprintf("Could not detect server type for domain: %s", domainName)) - os.Exit(1) - } - - logger.Info(fmt.Sprintf("Renewing certificate for %s (%s)", domainName, serverType)) - - cmdArgs := []string{ - fmt.Sprintf("--%s", serverType), - "-d", domainName, - "-d", "www." + domainName, - "--non-interactive", - "--agree-tos", - "--register-unsafely-without-email", - } - - if forceFlag { - cmdArgs = append(cmdArgs, "--force-renewal") - } - - if err := internal.RunCommand("sudo", append([]string{"certbot"}, cmdArgs...)...); err != nil { - logger.Error(fmt.Sprintf("SSL renewal failed for %s: %v", domainName, err)) - os.Exit(1) - } - - logger.Success(fmt.Sprintf("Certificate renewed for %s", domainName)) - }, -} - -func init() { - rootCmd.AddCommand(renewSSLCmd) - renewSSLCmd.Flags().BoolVar(&renewAll, "all", false, "Renew all certificates") - renewSSLCmd.Flags().StringVar(&domainName, "domain", "", "Domain to renew certificate for") - renewSSLCmd.Flags().BoolVar(&forceFlag, "force", false, "Force renew the certificate") -} \ No newline at end of file diff --git a/cmd/restart_server.go b/cmd/restart_server.go deleted file mode 100644 index 60ef2ab..0000000 --- a/cmd/restart_server.go +++ /dev/null @@ -1,52 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "strings" - - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" -) - -var restartServerCmd = &cobra.Command{ - Use: "restart-server", - Short: "Restart a specific web server (apache, nginx, or caddy)", - Run: func(cmd *cobra.Command, args []string) { - server, _ := cmd.Flags().GetString("server") - - if internal.IsNilOrEmpty(server) { - logger.Error("Please provide a server type using --server (apache, nginx, or caddy)") - os.Exit(1) - } - - server = strings.ToLower(server) - validServers := map[string]string{ - "apache": "apache2", - "nginx": "nginx", - "caddy": "caddy", - } - - systemName, ok := validServers[server] - if !ok { - logger.Error("Unsupported server type. Use one of: apache, nginx, or caddy") - os.Exit(1) - } - - logger.Info(fmt.Sprintf("Restarting %s server...", server)) - err := internal.RunCommand("sudo", "systemctl", "restart", systemName) - if err != nil { - logger.Error(fmt.Sprintf("Failed to restart %s: %v", server, err)) - os.Exit(1) - } - - logger.Success(fmt.Sprintf("%s restarted successfully", strings.Title(server))) - }, -} - -func init() { - rootCmd.AddCommand(restartServerCmd) - restartServerCmd.Flags().String("server", "", "Web server to restart (apache, nginx, or caddy)") - restartServerCmd.MarkFlagRequired("server") -} diff --git a/cmd/restore_domain.go b/cmd/restore_domain.go deleted file mode 100644 index 3d31353..0000000 --- a/cmd/restore_domain.go +++ /dev/null @@ -1,88 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" -) - -var restoreDomainCmd = &cobra.Command{ - Use: "restore-domain", - Short: "Restore domain files and MySQL DB from backup archive", - Run: func(cmd *cobra.Command, args []string) { - domain, _ := cmd.Flags().GetString("domain") - backupFile, _ := cmd.Flags().GetString("file") - - if internal.IsNilOrEmpty(domain) || internal.IsNilOrEmpty(backupFile) { - logger.Error("Please provide both --domain and --file") - os.Exit(1) - } - - username := strings.Split(domain, ".")[0] - restoreDir := fmt.Sprintf("/tmp/restore-%s", username) - os.MkdirAll(restoreDir, 0755) - - ext := filepath.Ext(backupFile) - if ext == ".gz" || strings.HasSuffix(backupFile, ".tar.gz") { - logger.Info("Extracting tar.gz archive") - internal.RunCommand("sudo", "tar", "-xzf", backupFile, "-C", restoreDir) - } else if ext == ".tar" { - logger.Info("Extracting tar archive") - internal.RunCommand("sudo", "tar", "-xf", backupFile, "-C", restoreDir) - } else if ext == ".zip" { - logger.Info("Extracting zip archive") - internal.RunCommand("sudo", "unzip", "-o", backupFile, "-d", restoreDir) - } else { - logger.Error("Unsupported file type. Use: tar.gz, tar, or zip") - os.Exit(1) - } - - // Restore public_html - publicPath := fmt.Sprintf("/home/%s/public_html", username) - logger.Info(fmt.Sprintf("Restoring files to %s", publicPath)) - internal.RunCommand("sudo", "cp", "-r", filepath.Join(restoreDir, "home", username, "public_html"), filepath.Join("/home", username)) - internal.RunCommand("sudo", "chown", "-R", fmt.Sprintf("%s:%s", username, username), publicPath) - - // Restore MySQL if .sql found - sqlPath := "" - filepath.Walk(restoreDir, func(path string, info os.FileInfo, err error) error { - if strings.HasSuffix(path, ".sql") { - sqlPath = path - } - return nil - }) - - if sqlPath != "" { - logger.Info(fmt.Sprintf("Restoring MySQL DB from %s", sqlPath)) - restoreCmd := exec.Command("sudo", "mysql", "-u", username, fmt.Sprintf("-p%s", username), username) - sqlFile, _ := os.Open(sqlPath) - defer sqlFile.Close() - restoreCmd.Stdin = sqlFile - restoreCmd.Stdout = os.Stdout - restoreCmd.Stderr = os.Stderr - if err := restoreCmd.Run(); err != nil { - logger.Warn(fmt.Sprintf("MySQL restore failed: %v", err)) - } else { - logger.Success("MySQL database restored") - } - } else { - logger.Warn("No SQL file found in backup. Skipping DB restore.") - } - - logger.Success(fmt.Sprintf("Domain '%s' restored successfully", domain)) - }, -} - -func init() { - rootCmd.AddCommand(restoreDomainCmd) - restoreDomainCmd.Flags().String("domain", "", "Domain name to restore") - restoreDomainCmd.Flags().String("file", "", "Path to backup archive file (.tar.gz, .zip, .tar)") - restoreDomainCmd.MarkFlagRequired("domain") - restoreDomainCmd.MarkFlagRequired("file") -} diff --git a/cmd/root.go b/cmd/root.go index bda493c..c6c4ea6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,9 +9,14 @@ import ( "stackroost/internal" "stackroost/internal/logger" "strings" - "stackroost/cmd/ssl" - "stackroost/cmd/logs" + "stackroost/cmd/domain" "stackroost/cmd/email" + "stackroost/cmd/firewall" + "stackroost/cmd/logs" + "stackroost/cmd/security" + "stackroost/cmd/server" + "stackroost/cmd/ssl" + "stackroost/cmd/user" ) var rootCmd = &cobra.Command{ @@ -211,9 +216,15 @@ func init() { createDomainCmd.Flags().StringP("server", "s", "apache", "Web server type (e.g., apache, nginx, caddy)") createDomainCmd.Flags().Bool("ssl", false, "Enable Let's Encrypt SSL (Apache/Nginx only)") createDomainCmd.MarkFlagRequired("name") - rootCmd.AddCommand(ssl.CheckSSLExpiryCmd) - rootCmd.AddCommand(logs.AnalyzeTrafficCmd) - rootCmd.AddCommand(email.TestEmailCmd) + + registerSSLCmds() + registerLogCmds() + registerEmailCmds() + registerUserCmds() + registerFirewallCmds() + registerServerCmds() + registerSecurityCmds() + registerDomainExtras() } func Execute() { @@ -298,3 +309,71 @@ func writeConfigFile(domain, content, extension string) error { logger.Success(fmt.Sprintf("Configuration file written to %s", outputPath)) return nil } + + +func registerDomainExtras() { + rootCmd.AddCommand( + domain.GetBackupCmd(), + domain.GetCloneCmd(), + domain.GetListCmd(), + domain.GetRemoveCmd(), + domain.GetRestoreCmd(), + domain.GetStatusCmd(), + domain.GetToggleCmd(), + domain.GetUpdatePortCmd(), + ) +} + +func registerEmailCmds() { + rootCmd.AddCommand(email.GetTestCmd()) +} + +func registerFirewallCmds() { + rootCmd.AddCommand( + firewall.GetEnableCmd(), + firewall.GetDisableCmd(), + ) +} + +func registerLogCmds() { + rootCmd.AddCommand( + logs.GetAnalyzeCmd(), + logs.GetPurgeCmd(), + logs.GetDomainLogsCmd(), + ) +} + +func registerSecurityCmds() { + rootCmd.AddCommand( + security.GetCheckCmd(), + security.GetSecureCmd(), + ) + +} + +func registerServerCmds() { + rootCmd.AddCommand( + server.GetHealthCmd(), + server.GetRestartCmd(), + server.GetScheduleRestartCmd(), + server.GetCheckPortCmd(), + server.GetSyncTimeCmd(), + server.GetInspectCmd(), + ) +} + +func registerSSLCmds() { + rootCmd.AddCommand( + ssl.GetEnableCmd(), + ssl.GetDisableCmd(), + ssl.GetRenewCmd(), + ssl.GetExpiryCmd(), + ) +} + +func registerUserCmds() { + rootCmd.AddCommand( + user.GetListCmd(), + user.GetDeleteCmd(), + ) +} \ No newline at end of file diff --git a/cmd/schedule_restart.go b/cmd/schedule_restart.go deleted file mode 100644 index 9425280..0000000 --- a/cmd/schedule_restart.go +++ /dev/null @@ -1,60 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "strings" - "time" - - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" -) - -var scheduleRestartCmd = &cobra.Command{ - Use: "schedule-restart", - Short: "Schedule a server restart after a delay", - Run: func(cmd *cobra.Command, args []string) { - server, _ := cmd.Flags().GetString("server") - delay, _ := cmd.Flags().GetInt("delay") - - server = strings.ToLower(server) - if server != "apache" && server != "nginx" && server != "caddy" { - logger.Error("Invalid server type. Use --server apache|nginx|caddy") - os.Exit(1) - } - - if delay <= 0 { - delay = 5 - } - - logger.Info(fmt.Sprintf("Server: %s", server)) - logger.Info(fmt.Sprintf("Restart scheduled in %d seconds...", delay)) - time.Sleep(time.Duration(delay) * time.Second) - - var service string - switch server { - case "apache": - service = "apache2" - case "nginx": - service = "nginx" - case "caddy": - service = "caddy" - } - - logger.Info("Restarting now...") - if err := internal.RunCommand("sudo", "systemctl", "restart", service); err != nil { - logger.Error(fmt.Sprintf("Failed to restart %s: %v", service, err)) - os.Exit(1) - } - - logger.Success(fmt.Sprintf("%s restarted successfully", strings.Title(server))) - }, -} - -func init() { - rootCmd.AddCommand(scheduleRestartCmd) - scheduleRestartCmd.Flags().String("server", "", "Server to restart (apache|nginx|caddy)") - scheduleRestartCmd.Flags().Int("delay", 5, "Delay in seconds before restart") - scheduleRestartCmd.MarkFlagRequired("server") -} diff --git a/cmd/security/check.go b/cmd/security/check.go new file mode 100644 index 0000000..1779a04 --- /dev/null +++ b/cmd/security/check.go @@ -0,0 +1,70 @@ +package security + +import ( + "os/exec" + "strings" + + "github.com/spf13/cobra" + "stackroost/internal/logger" +) + +// GetCheckCmd returns the Cobra command for security check +func GetCheckCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "run-security-check", + Short: "Run basic security checks on the server", + Run: func(cmd *cobra.Command, args []string) { + logger.Info("Running security checks...") + + // SSH service check + out := capture("systemctl", "is-active", "ssh") + if strings.Contains(out, "active") { + logger.Success("SSH service: Active") + } else { + logger.Warn("SSH service: Inactive or not installed") + } + + // Check root login via SSH + sshdConfig := capture("sudo", "grep", "^PermitRootLogin", "/etc/ssh/sshd_config") + if strings.Contains(sshdConfig, "no") { + logger.Success("SSH root login: Disabled") + } else { + logger.Warn("SSH root login: Possibly enabled (check PermitRootLogin)") + } + + // Password authentication + passAuth := capture("sudo", "grep", "^PasswordAuthentication", "/etc/ssh/sshd_config") + if strings.Contains(passAuth, "no") { + logger.Success("Password authentication: Disabled (good)") + } else { + logger.Warn("Password authentication: Enabled (not recommended)") + } + + // Firewall check (UFW) + ufwStatus := capture("sudo", "ufw", "status") + if strings.Contains(ufwStatus, "Status: active") { + logger.Success("Firewall (UFW): Active") + } else { + logger.Warn("Firewall (UFW): Inactive or not installed") + } + + // Fail2ban check + fail2banStatus := capture("systemctl", "is-active", "fail2ban") + if strings.Contains(fail2banStatus, "active") { + logger.Success("Fail2ban: Running") + } else { + logger.Warn("Fail2ban: Not active or not installed") + } + }, + } + return cmd +} + +func capture(name string, args ...string) string { + var out strings.Builder + cmd := exec.Command(name, args...) + cmd.Stdout = &out + cmd.Stderr = &out + _ = cmd.Run() + return out.String() +} diff --git a/cmd/security/secure.go b/cmd/security/secure.go new file mode 100644 index 0000000..67b232b --- /dev/null +++ b/cmd/security/secure.go @@ -0,0 +1,68 @@ +package security + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +var ( + ports string + disableRootLogin bool + enforceKeyAuth bool +) + +var secureServerCmd = &cobra.Command{ + Use: "secure-server", + Short: "Secure the server by enabling firewall, restricting SSH, and hardening config", + Run: func(cmd *cobra.Command, args []string) { + logger.Info("Starting server hardening...") + + // Enable UFW and set defaults + logger.Info("Enabling UFW...") + internal.RunCommand("sudo", "ufw", "--force", "enable") + internal.RunCommand("sudo", "ufw", "default", "deny", "incoming") + internal.RunCommand("sudo", "ufw", "default", "allow", "outgoing") + + for _, port := range strings.Split(ports, ",") { + port = strings.TrimSpace(port) + if port != "" { + internal.RunCommand("sudo", "ufw", "allow", port) + logger.Success(fmt.Sprintf("Allowed port: %s", port)) + } + } + + // SSH Hardening + if disableRootLogin || enforceKeyAuth { + sshConf := "/etc/ssh/sshd_config" + + if disableRootLogin { + internal.RunCommand("sudo", "sed", "-i", "s/^#*PermitRootLogin.*/PermitRootLogin no/", sshConf) + logger.Success("Root login disabled in SSH config") + } + + if enforceKeyAuth { + internal.RunCommand("sudo", "sed", "-i", "s/^#*PasswordAuthentication.*/PasswordAuthentication no/", sshConf) + logger.Success("PasswordAuthentication disabled — key-based auth enforced") + } + + internal.RunCommand("sudo", "systemctl", "restart", "ssh") + } + + logger.Success("Server hardening completed.") + }, +} + +func init() { + secureServerCmd.Flags().StringVar(&ports, "allow-ports", "22,80,443", "Comma-separated ports to allow through UFW") + secureServerCmd.Flags().BoolVar(&disableRootLogin, "disable-root-login", false, "Disable SSH root login") + secureServerCmd.Flags().BoolVar(&enforceKeyAuth, "enforce-ssh-key-only", false, "Disable SSH password login") +} + +// Exported for root registration +func GetSecureCmd() *cobra.Command { + return secureServerCmd +} diff --git a/cmd/security_check.go b/cmd/security_check.go deleted file mode 100644 index 5c5c1e0..0000000 --- a/cmd/security_check.go +++ /dev/null @@ -1,70 +0,0 @@ -package cmd - -import ( - "os/exec" - "strings" - - "github.com/spf13/cobra" - "stackroost/internal/logger" -) - -var securityCheckCmd = &cobra.Command{ - Use: "run-security-check", - Short: "Run basic security checks on the server", - Run: func(cmd *cobra.Command, args []string) { - logger.Info("Running security checks...") - - // SSH service check - out := securityCaptureCommand("systemctl", "is-active", "ssh") - if strings.Contains(out, "active") { - logger.Success("SSH service: Active") - } else { - logger.Warn("SSH service: Inactive or not installed") - } - - // Check root login via SSH - sshdConfig := securityCaptureCommand("sudo", "grep", "^PermitRootLogin", "/etc/ssh/sshd_config") - if strings.Contains(sshdConfig, "no") { - logger.Success("SSH root login: Disabled") - } else { - logger.Warn("SSH root login: Possibly enabled (check PermitRootLogin)") - } - - // Password authentication - passAuth := securityCaptureCommand("sudo", "grep", "^PasswordAuthentication", "/etc/ssh/sshd_config") - if strings.Contains(passAuth, "no") { - logger.Success("Password authentication: Disabled (good)") - } else { - logger.Warn("Password authentication: Enabled (not recommended)") - } - - // Firewall check (UFW) - ufwStatus := securityCaptureCommand("sudo", "ufw", "status") - if strings.Contains(ufwStatus, "Status: active") { - logger.Success("Firewall (UFW): Active") - } else { - logger.Warn("Firewall (UFW): Inactive or not installed") - } - - // fail2ban check - fail2banStatus := securityCaptureCommand("systemctl", "is-active", "fail2ban") - if strings.Contains(fail2banStatus, "active") { - logger.Success("Fail2ban: Running") - } else { - logger.Warn("Fail2ban: Not active or not installed") - } - }, -} - -func init() { - rootCmd.AddCommand(securityCheckCmd) -} - -func securityCaptureCommand(name string, args ...string) string { - var out strings.Builder - cmd := exec.Command(name, args...) - cmd.Stdout = &out - cmd.Stderr = &out - _ = cmd.Run() - return out.String() -} \ No newline at end of file diff --git a/cmd/server/check_port.go b/cmd/server/check_port.go new file mode 100644 index 0000000..b2edaad --- /dev/null +++ b/cmd/server/check_port.go @@ -0,0 +1,52 @@ +package server + +import ( + "fmt" + "net" + "os" + "time" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +// GetCheckPortCmd returns the cobra command for checking open ports +func GetCheckPortCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "check-port", + Short: "Check if a specific port is open for a domain", + Run: func(cmd *cobra.Command, args []string) { + domain, _ := cmd.Flags().GetString("domain") + port, _ := cmd.Flags().GetString("port") + timeoutSec, _ := cmd.Flags().GetInt("timeout") + + if internal.IsNilOrEmpty(domain) || internal.IsNilOrEmpty(port) { + logger.Error("Please provide both --domain and --port") + os.Exit(1) + } + + address := fmt.Sprintf("%s:%s", domain, port) + timeout := time.Duration(timeoutSec) * time.Second + + logger.Info(fmt.Sprintf("Checking port %s on domain %s...", port, domain)) + + conn, err := net.DialTimeout("tcp", address, timeout) + if err != nil { + logger.Error(fmt.Sprintf("Port %s is not reachable on %s (%v)", port, domain, err)) + os.Exit(1) + } + conn.Close() + + logger.Success(fmt.Sprintf("Port %s is open and reachable on %s", port, domain)) + }, + } + + cmd.Flags().String("domain", "", "Domain to check") + cmd.Flags().String("port", "", "Port to check (e.g., 80, 443, 3000)") + cmd.Flags().Int("timeout", 3, "Timeout in seconds (default: 3s)") + cmd.MarkFlagRequired("domain") + cmd.MarkFlagRequired("port") + + return cmd +} diff --git a/cmd/server_health.go b/cmd/server/health.go similarity index 81% rename from cmd/server_health.go rename to cmd/server/health.go index f7a8889..669595b 100644 --- a/cmd/server_health.go +++ b/cmd/server/health.go @@ -1,4 +1,4 @@ -package cmd +package server import ( "fmt" @@ -8,30 +8,37 @@ import ( "github.com/shirou/gopsutil/v3/cpu" "github.com/shirou/gopsutil/v3/disk" - "github.com/shirou/gopsutil/v3/mem" "github.com/shirou/gopsutil/v3/host" + "github.com/shirou/gopsutil/v3/mem" "github.com/spf13/cobra" "stackroost/internal/logger" ) -var serverHealthCmd = &cobra.Command{ - Use: "server-health", - Short: "Check system resource usage and web server status", - Run: func(cmd *cobra.Command, args []string) { - printHostname() - printUptime() - printCPU() - printMemory() - printDisk() - printWebServerStatus("apache2") - printWebServerStatus("nginx") - printWebServerStatus("caddy") - }, +func GetHealthCmd() *cobra.Command { + return &cobra.Command{ + Use: "server-health", + Short: "Check system resource usage and web server status", + Run: func(cmd *cobra.Command, args []string) { + printHostname() + printUptime() + printCPU() + printMemory() + printDisk() + printWebServerStatus("apache2") + printWebServerStatus("nginx") + printWebServerStatus("caddy") + }, + } } -func init() { - rootCmd.AddCommand(serverHealthCmd) +func printHostname() { + info, err := host.Info() + if err != nil { + logger.Warn(fmt.Sprintf("Hostname fetch error: %v", err)) + return + } + logger.Info(fmt.Sprintf("Hostname: %s", info.Hostname)) } func printUptime() { @@ -83,12 +90,3 @@ func printWebServerStatus(service string) { logger.Warn(fmt.Sprintf("%s: Inactive or not installed", strings.Title(service))) } } - -func printHostname() { - info, err := host.Info() - if err != nil { - logger.Warn(fmt.Sprintf("Hostname fetch error: %v", err)) - return - } - logger.Info(fmt.Sprintf("Hostname: %s", info.Hostname)) -} diff --git a/cmd/server/inspect.go b/cmd/server/inspect.go new file mode 100644 index 0000000..e753135 --- /dev/null +++ b/cmd/server/inspect.go @@ -0,0 +1,56 @@ +package server + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +func GetInspectCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "inspect-config", + Short: "View the web server configuration file of a domain", + Run: func(cmd *cobra.Command, args []string) { + domain, _ := cmd.Flags().GetString("domain") + if internal.IsNilOrEmpty(domain) { + logger.Error("Please provide a domain using --domain") + os.Exit(1) + } + + serverType := internal.DetectServerType(domain) + if serverType == "" { + logger.Error("Could not detect server type. No config file found.") + os.Exit(1) + } + + var configPath string + switch serverType { + case "apache": + configPath = filepath.Join("/etc/apache2/sites-available", domain+".conf") + case "nginx": + configPath = filepath.Join("/etc/nginx/sites-available", domain+".conf") + case "caddy": + configPath = filepath.Join("/etc/caddy/sites-available", domain+".conf") + default: + logger.Error("Unsupported server type") + os.Exit(1) + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + logger.Error(fmt.Sprintf("Config file not found at: %s", configPath)) + os.Exit(1) + } + + logger.Info(fmt.Sprintf("Showing config: %s", configPath)) + internal.RunCommand("sudo", "cat", configPath) + }, + } + + cmd.Flags().String("domain", "", "Domain name to inspect config for") + cmd.MarkFlagRequired("domain") + return cmd +} diff --git a/cmd/server/restart.go b/cmd/server/restart.go new file mode 100644 index 0000000..7543a1a --- /dev/null +++ b/cmd/server/restart.go @@ -0,0 +1,52 @@ +package server + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +func GetRestartCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "restart-server", + Short: "Restart a specific web server (apache, nginx, or caddy)", + Run: func(cmd *cobra.Command, args []string) { + server, _ := cmd.Flags().GetString("server") + + if internal.IsNilOrEmpty(server) { + logger.Error("Please provide a server type using --server (apache, nginx, or caddy)") + os.Exit(1) + } + + server = strings.ToLower(server) + validServers := map[string]string{ + "apache": "apache2", + "nginx": "nginx", + "caddy": "caddy", + } + + systemName, ok := validServers[server] + if !ok { + logger.Error("Unsupported server type. Use one of: apache, nginx, or caddy") + os.Exit(1) + } + + logger.Info(fmt.Sprintf("Restarting %s server...", server)) + err := internal.RunCommand("sudo", "systemctl", "restart", systemName) + if err != nil { + logger.Error(fmt.Sprintf("Failed to restart %s: %v", server, err)) + os.Exit(1) + } + + logger.Success(fmt.Sprintf("%s restarted successfully", strings.Title(server))) + }, + } + + cmd.Flags().String("server", "", "Web server to restart (apache, nginx, or caddy)") + cmd.MarkFlagRequired("server") + return cmd +} diff --git a/cmd/server/schedule_restart.go b/cmd/server/schedule_restart.go new file mode 100644 index 0000000..96e9b41 --- /dev/null +++ b/cmd/server/schedule_restart.go @@ -0,0 +1,60 @@ +package server + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +func GetScheduleRestartCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "schedule-restart", + Short: "Schedule a server restart after a delay", + Run: func(cmd *cobra.Command, args []string) { + server, _ := cmd.Flags().GetString("server") + delay, _ := cmd.Flags().GetInt("delay") + + server = strings.ToLower(server) + if server != "apache" && server != "nginx" && server != "caddy" { + logger.Error("Invalid server type. Use --server apache|nginx|caddy") + os.Exit(1) + } + + if delay <= 0 { + delay = 5 + } + + logger.Info(fmt.Sprintf("Server: %s", server)) + logger.Info(fmt.Sprintf("Restart scheduled in %d seconds...", delay)) + time.Sleep(time.Duration(delay) * time.Second) + + var service string + switch server { + case "apache": + service = "apache2" + case "nginx": + service = "nginx" + case "caddy": + service = "caddy" + } + + logger.Info("Restarting now...") + if err := internal.RunCommand("sudo", "systemctl", "restart", service); err != nil { + logger.Error(fmt.Sprintf("Failed to restart %s: %v", service, err)) + os.Exit(1) + } + + logger.Success(fmt.Sprintf("%s restarted successfully", strings.Title(server))) + }, + } + + cmd.Flags().String("server", "", "Server to restart (apache|nginx|caddy)") + cmd.Flags().Int("delay", 5, "Delay in seconds before restart") + cmd.MarkFlagRequired("server") + return cmd +} diff --git a/cmd/server/sync_time.go b/cmd/server/sync_time.go new file mode 100644 index 0000000..85d23aa --- /dev/null +++ b/cmd/server/sync_time.go @@ -0,0 +1,38 @@ +package server + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +func GetSyncTimeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sync-time", + Short: "Sync server time with NTP using systemd-timesyncd", + Run: func(cmd *cobra.Command, args []string) { + logger.Info("Enabling NTP time sync...") + if err := internal.RunCommand("sudo", "timedatectl", "set-ntp", "true"); err != nil { + logger.Error(fmt.Sprintf("Failed to enable NTP sync: %v", err)) + os.Exit(1) + } + + logger.Info("Restarting time sync service...") + if err := internal.RunCommand("sudo", "systemctl", "restart", "systemd-timesyncd"); err != nil { + logger.Error(fmt.Sprintf("Failed to restart timesync service: %v", err)) + os.Exit(1) + } + + logger.Success("Time synchronization triggered successfully.") + + logger.Info("Current time sync status:") + status := internal.CaptureCommand("timedatectl", "status") + fmt.Println(status) + }, + } + return cmd +} + diff --git a/cmd/ssl/check_expiry.go b/cmd/ssl/check_expiry.go index 1a4ff5f..a5868c0 100644 --- a/cmd/ssl/check_expiry.go +++ b/cmd/ssl/check_expiry.go @@ -1,34 +1,39 @@ package ssl import ( + "crypto/tls" "fmt" "os" - "crypto/tls" "time" + "github.com/spf13/cobra" "stackroost/internal/logger" ) -var CheckSSLExpiryCmd = &cobra.Command{ - Use: "check-ssl-expiry", - Short: "Check the SSL certificate expiry for a domain", - Run: func(cmd *cobra.Command, args []string) { - domain, _ := cmd.Flags().GetString("domain") - if domain == "" { - logger.Error("Please provide a domain using --domain") - os.Exit(1) - } - checkSSLExpiry(domain) - }, -} +// GetExpiryCmd returns the cobra command to check SSL expiry +func GetExpiryCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "expiry", + Short: "Check the SSL certificate expiry for a domain", + Run: func(cmd *cobra.Command, args []string) { + domain, _ := cmd.Flags().GetString("domain") + if domain == "" { + logger.Error("Please provide a domain using --domain") + os.Exit(1) + } + checkSSLExpiry(domain) + }, + } -func init() { - CheckSSLExpiryCmd.Flags().String("domain", "", "Domain to check SSL expiry for") - CheckSSLExpiryCmd.MarkFlagRequired("domain") + cmd.Flags().String("domain", "", "Domain to check SSL expiry for") + cmd.MarkFlagRequired("domain") + return cmd } func checkSSLExpiry(domain string) { - conn, err := tls.Dial("tcp", domain+":443", nil) + conn, err := tls.Dial("tcp", domain+":443", &tls.Config{ + ServerName: domain, // Required for SNI + }) if err != nil { logger.Error(fmt.Sprintf("Failed to connect: %v", err)) os.Exit(1) diff --git a/cmd/ssl/disable.go b/cmd/ssl/disable.go new file mode 100644 index 0000000..3210a79 --- /dev/null +++ b/cmd/ssl/disable.go @@ -0,0 +1,58 @@ +package ssl + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +func GetDisableCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "disable", + Short: "Disable and remove SSL certificate for a specific domain", + Run: func(cmd *cobra.Command, args []string) { + domain, _ := cmd.Flags().GetString("domain") + if internal.IsNilOrEmpty(domain) { + logger.Error("Please provide a domain using --domain") + os.Exit(1) + } + + serverType := internal.DetectServerType(domain) + if serverType == "" { + logger.Error(fmt.Sprintf("Could not detect server type for domain: %s", domain)) + os.Exit(1) + } + + if serverType == "caddy" { + logger.Info("Caddy auto-manages SSL — no need to disable manually.") + return + } + + logger.Info(fmt.Sprintf("Detected %s configuration for %s", serverType, domain)) + logger.Info("Removing SSL certificate using Certbot...") + + cmdArgs := []string{ + "delete", + "--cert-name", domain, + "--non-interactive", + "--quiet", + "--agree-tos", + } + + if err := internal.RunCommand("sudo", append([]string{"certbot"}, cmdArgs...)...); err != nil { + logger.Warn(fmt.Sprintf("Certbot failed to delete certificate: %v", err)) + os.Exit(1) + } + + logger.Success(fmt.Sprintf("SSL certificate removed for %s", domain)) + }, + } + + cmd.Flags().String("domain", "", "Domain name to disable SSL for") + cmd.MarkFlagRequired("domain") + + return cmd +} diff --git a/cmd/ssl/enable.go b/cmd/ssl/enable.go new file mode 100644 index 0000000..86079ef --- /dev/null +++ b/cmd/ssl/enable.go @@ -0,0 +1,51 @@ +package ssl + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +// GetEnableCmd returns the command to enable SSL for a domain +func GetEnableCmd() *cobra.Command { + var domain string + + cmd := &cobra.Command{ + Use: "enable", + Short: "Enable Let's Encrypt SSL for a specific domain", + Run: func(cmd *cobra.Command, args []string) { + if internal.IsNilOrEmpty(domain) { + logger.Error("Please provide a domain using --domain") + os.Exit(1) + } + + serverType := internal.DetectServerType(domain) + if serverType == "" { + logger.Error(fmt.Sprintf("Could not detect server type for domain: %s", domain)) + os.Exit(1) + } + + if serverType == "caddy" { + logger.Info("Caddy automatically handles SSL — no need to enable manually.") + return + } + + logger.Info(fmt.Sprintf("Detected %s configuration for %s", serverType, domain)) + + err := internal.EnableSSLCertbot(domain, serverType) + if err != nil { + logger.Error(fmt.Sprintf("Failed to enable SSL for %s: %v", domain, err)) + os.Exit(1) + } + + logger.Success(fmt.Sprintf("SSL enabled successfully for %s", domain)) + }, + } + + cmd.Flags().StringVar(&domain, "domain", "", "Domain name to enable SSL for") + cmd.MarkFlagRequired("domain") + return cmd +} diff --git a/cmd/ssl/renew.go b/cmd/ssl/renew.go new file mode 100644 index 0000000..18b2c81 --- /dev/null +++ b/cmd/ssl/renew.go @@ -0,0 +1,72 @@ +package ssl + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +// GetRenewCmd returns the command to renew SSL certificates +func GetRenewCmd() *cobra.Command { + var renewAll bool + var domain string + var force bool + + cmd := &cobra.Command{ + Use: "renew", + Short: "Renew SSL certificates for all or a specific domain", + Run: func(cmd *cobra.Command, args []string) { + if renewAll { + logger.Info("Renewing SSL certificates for all domains") + err := internal.RunCommand("sudo", "certbot", "renew") + if err != nil { + logger.Error(fmt.Sprintf("SSL renewal failed: %v", err)) + os.Exit(1) + } + logger.Success("All certificates renewed successfully") + return + } + + if internal.IsNilOrEmpty(domain) { + logger.Error("Either --all or --domain must be provided") + os.Exit(1) + } + + serverType := internal.DetectServerType(domain) + if serverType == "" { + logger.Error(fmt.Sprintf("Could not detect server type for domain: %s", domain)) + os.Exit(1) + } + + logger.Info(fmt.Sprintf("Renewing certificate for %s (%s)", domain, serverType)) + + cmdArgs := []string{ + fmt.Sprintf("--%s", serverType), + "-d", domain, + "-d", "www." + domain, + "--non-interactive", + "--agree-tos", + "--register-unsafely-without-email", + } + + if force { + cmdArgs = append(cmdArgs, "--force-renewal") + } + + if err := internal.RunCommand("sudo", append([]string{"certbot"}, cmdArgs...)...); err != nil { + logger.Error(fmt.Sprintf("SSL renewal failed for %s: %v", domain, err)) + os.Exit(1) + } + + logger.Success(fmt.Sprintf("Certificate renewed for %s", domain)) + }, + } + + cmd.Flags().BoolVar(&renewAll, "all", false, "Renew all certificates") + cmd.Flags().StringVar(&domain, "domain", "", "Domain to renew certificate for") + cmd.Flags().BoolVar(&force, "force", false, "Force renew the certificate") + return cmd +} diff --git a/cmd/ssl/test.go b/cmd/ssl/test.go new file mode 100644 index 0000000..54df81f --- /dev/null +++ b/cmd/ssl/test.go @@ -0,0 +1,63 @@ +package ssl + +import ( + "crypto/tls" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +// GetTestCmd returns the SSL test command +func GetTestCmd() *cobra.Command { + var domain string + var port string + + cmd := &cobra.Command{ + Use: "test", + Short: "Check SSL certificate status for a domain", + Run: func(cmd *cobra.Command, args []string) { + if internal.IsNilOrEmpty(domain) { + logger.Error("Please provide a domain using --domain") + os.Exit(1) + } + + address := fmt.Sprintf("%s:%s", domain, port) + logger.Info(fmt.Sprintf("Testing SSL certificate for %s...", domain)) + + conn, err := tls.Dial("tcp", address, &tls.Config{ + ServerName: domain, // SNI support + }) + if err != nil { + logger.Error(fmt.Sprintf("Failed to connect to %s: %v", address, err)) + os.Exit(1) + } + defer conn.Close() + + certs := conn.ConnectionState().PeerCertificates + if len(certs) == 0 { + logger.Error("No certificate found") + os.Exit(1) + } + + cert := certs[0] + now := time.Now() + if now.Before(cert.NotBefore) || now.After(cert.NotAfter) { + logger.Error("SSL certificate is invalid or expired") + } else { + logger.Success("SSL is valid") + logger.Info(fmt.Sprintf("Issuer: %s", cert.Issuer.CommonName)) + logger.Info(fmt.Sprintf("Expires: %s (in %d days)", cert.NotAfter.Format(time.RFC1123), int(cert.NotAfter.Sub(now).Hours()/24))) + } + }, + } + + cmd.Flags().StringVar(&domain, "domain", "", "Domain to test (required)") + cmd.Flags().StringVar(&port, "port", "443", "Port to test SSL (default: 443)") + cmd.MarkFlagRequired("domain") + + return cmd +} diff --git a/cmd/status_domain.go b/cmd/status_domain.go deleted file mode 100644 index 4afcfa2..0000000 --- a/cmd/status_domain.go +++ /dev/null @@ -1,101 +0,0 @@ -package cmd - -import ( - "bytes" - "fmt" - "os" - "os/exec" - "os/user" - "path/filepath" - "strings" - - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" -) - -var statusDomainCmd = &cobra.Command{ - Use: "status-domain", - Short: "Inspect the configuration, user, and SSL status of a domain", - Run: func(cmd *cobra.Command, args []string) { - domain, _ := cmd.Flags().GetString("domain") - if internal.IsNilOrEmpty(domain) { - logger.Error("Please provide a domain using --domain") - os.Exit(1) - } - - logger.Info(fmt.Sprintf("Inspecting domain: %s", domain)) - - serverType := internal.DetectServerType(domain) - if serverType == "" { - logger.Warn("Could not detect server type (no config found)") - } else { - logger.Info(fmt.Sprintf("Server: %s", serverType)) - } - - // Check if enabled - enabled := false - switch serverType { - case "apache": - out := captureCommand("a2query", "-s", domain) - enabled = strings.Contains(out, "is enabled") - case "nginx": - linkPath := filepath.Join("/etc/nginx/sites-enabled", domain+".conf") - _, err := os.Stat(linkPath) - enabled = err == nil - case "caddy": - linkPath := filepath.Join("/etc/caddy/sites-enabled", domain+".conf") - _, err := os.Stat(linkPath) - enabled = err == nil - } - - if enabled { - logger.Info("Status: ENABLED") - } else { - logger.Info("Status: DISABLED") - } - - // Shell user - username := strings.Split(domain, ".")[0] - if _, err := user.Lookup(username); err == nil { - logger.Info(fmt.Sprintf("Shell User: %s ✔", username)) - } else { - logger.Warn(fmt.Sprintf("Shell User: %s ", username)) - } - - // Public HTML - htmlPath := fmt.Sprintf("/home/%s/public_html", username) - if _, err := os.Stat(htmlPath); err == nil { - logger.Info(fmt.Sprintf("Public HTML: %s ✔", htmlPath)) - } else { - logger.Warn(fmt.Sprintf("Public HTML: %s ", htmlPath)) - } - - // SSL check - if serverType == "caddy" { - logger.Info("SSL Certificate: Handled automatically by Caddy") - } else { - out := captureCommand("sudo", "certbot", "certificates", "--cert-name", domain) - if strings.Contains(out, domain) { - logger.Info("SSL Certificate: Installed via Certbot") - } else { - logger.Warn("SSL Certificate: Not found") - } - } - }, -} - -func captureCommand(name string, args ...string) string { - var out bytes.Buffer - cmd := exec.Command(name, args...) - cmd.Stdout = &out - cmd.Stderr = &out - _ = cmd.Run() - return out.String() -} - -func init() { - rootCmd.AddCommand(statusDomainCmd) - statusDomainCmd.Flags().String("domain", "", "Domain name to inspect") - statusDomainCmd.MarkFlagRequired("domain") -} diff --git a/cmd/sync_time.go b/cmd/sync_time.go deleted file mode 100644 index c8eae85..0000000 --- a/cmd/sync_time.go +++ /dev/null @@ -1,48 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "os/exec" - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" - "strings" -) - -var syncTimeCmd = &cobra.Command{ - Use: "sync-time", - Short: "Sync server time with NTP using systemd-timesyncd", - Run: func(cmd *cobra.Command, args []string) { - logger.Info("Enabling NTP time sync...") - if err := internal.RunCommand("sudo", "timedatectl", "set-ntp", "true"); err != nil { - logger.Error(fmt.Sprintf("Failed to enable NTP sync: %v", err)) - os.Exit(1) - } - - logger.Info("Restarting time sync service...") - if err := internal.RunCommand("sudo", "systemctl", "restart", "systemd-timesyncd"); err != nil { - logger.Error(fmt.Sprintf("Failed to restart timesync service: %v", err)) - os.Exit(1) - } - - logger.Success("Time synchronization triggered successfully.") - - logger.Info("Current time sync status:") - status := TimeCaptureCommand("timedatectl", "status") - fmt.Println(status) - }, -} - -func init() { - rootCmd.AddCommand(syncTimeCmd) -} - -func TimeCaptureCommand(name string, args ...string) string { - var out strings.Builder - cmd := exec.Command(name, args...) - cmd.Stdout = &out - cmd.Stderr = &out - _ = cmd.Run() - return out.String() -} \ No newline at end of file diff --git a/cmd/test_ssl.go b/cmd/test_ssl.go deleted file mode 100644 index e6e0388..0000000 --- a/cmd/test_ssl.go +++ /dev/null @@ -1,59 +0,0 @@ -package cmd - -import ( - "crypto/tls" - "fmt" - "os" - "time" - - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" -) - -var testSSLCmd = &cobra.Command{ - Use: "test-ssl", - Short: "Check SSL certificate status for a domain", - Run: func(cmd *cobra.Command, args []string) { - domain, _ := cmd.Flags().GetString("domain") - port, _ := cmd.Flags().GetString("port") - - if internal.IsNilOrEmpty(domain) { - logger.Error("Please provide a domain using --domain") - os.Exit(1) - } - - address := fmt.Sprintf("%s:%s", domain, port) - logger.Info(fmt.Sprintf("Testing SSL certificate for %s...", domain)) - - conn, err := tls.Dial("tcp", address, nil) - if err != nil { - logger.Error(fmt.Sprintf("Failed to connect to %s: %v", address, err)) - os.Exit(1) - } - defer conn.Close() - - certs := conn.ConnectionState().PeerCertificates - if len(certs) == 0 { - logger.Error("No certificate found") - os.Exit(1) - } - - cert := certs[0] - now := time.Now() - if now.Before(cert.NotBefore) || now.After(cert.NotAfter) { - logger.Error("SSL certificate is invalid or expired ") - } else { - logger.Success("SSL is valid ") - logger.Info(fmt.Sprintf("Issuer: %s", cert.Issuer.CommonName)) - logger.Info(fmt.Sprintf("Expires: %s (in %d days)", cert.NotAfter.Format(time.RFC1123), int(cert.NotAfter.Sub(now).Hours()/24))) - } - }, -} - -func init() { - rootCmd.AddCommand(testSSLCmd) - testSSLCmd.Flags().String("domain", "", "Domain to test (required)") - testSSLCmd.Flags().String("port", "443", "Port to test SSL (default: 443)") - testSSLCmd.MarkFlagRequired("domain") -} diff --git a/cmd/toggle_site.go b/cmd/toggle_site.go deleted file mode 100644 index db6b190..0000000 --- a/cmd/toggle_site.go +++ /dev/null @@ -1,88 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "strings" - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" - "os/exec" -) - -var toggleSiteCmd = &cobra.Command{ - Use: "toggle-site", - Short: "Enable or disable a site's configuration", - Run: func(cmd *cobra.Command, args []string) { - domain, _ := cmd.Flags().GetString("domain") - if internal.IsNilOrEmpty(domain) { - logger.Error("Please provide a domain using --domain") - os.Exit(1) - } - - serverType := internal.DetectServerType(domain) - if serverType == "" { - logger.Warn("Could not detect server type") - os.Exit(1) - } - logger.Info(fmt.Sprintf("Detected server: %s", serverType)) - - filename := domain + ".conf" - enabled := false - - switch serverType { - case "apache": - output := CaptureCommand("a2query", "-s", domain) - enabled = strings.Contains(output, "is enabled") - if enabled { - logger.Info(fmt.Sprintf("Disabling Apache site: %s", domain)) - internal.RunCommand("sudo", "a2dissite", filename) - } else { - logger.Info(fmt.Sprintf("Enabling Apache site: %s", domain)) - internal.RunCommand("sudo", "a2ensite", filename) - } - internal.RunCommand("sudo", "systemctl", "reload", "apache2") - - case "nginx": - sitesAvailable := "/etc/nginx/sites-available/" + filename - sitesEnabled := "/etc/nginx/sites-enabled/" + filename - if _, err := os.Stat(sitesEnabled); err == nil { - logger.Info(fmt.Sprintf("Disabling Nginx site: %s", domain)) - internal.RunCommand("sudo", "rm", "-f", sitesEnabled) - } else { - logger.Info(fmt.Sprintf("Enabling Nginx site: %s", domain)) - internal.RunCommand("sudo", "ln", "-s", sitesAvailable, sitesEnabled) - } - internal.RunCommand("sudo", "systemctl", "reload", "nginx") - - case "caddy": - sitesAvailable := "/etc/caddy/sites-available/" + filename - sitesEnabled := "/etc/caddy/sites-enabled/" + filename - if _, err := os.Stat(sitesEnabled); err == nil { - logger.Info(fmt.Sprintf("Disabling Caddy site: %s", domain)) - internal.RunCommand("sudo", "rm", "-f", sitesEnabled) - } else { - logger.Info(fmt.Sprintf("Enabling Caddy site: %s", domain)) - internal.RunCommand("sudo", "ln", "-s", sitesAvailable, sitesEnabled) - } - internal.RunCommand("sudo", "systemctl", "reload", "caddy") - } - - logger.Success(fmt.Sprintf("Site %s toggled successfully", domain)) - }, -} - -func init() { - rootCmd.AddCommand(toggleSiteCmd) - toggleSiteCmd.Flags().String("domain", "", "Domain name to toggle") - toggleSiteCmd.MarkFlagRequired("domain") -} - -func CaptureCommand(name string, args ...string) string { - var out strings.Builder - cmd := exec.Command(name, args...) - cmd.Stdout = &out - cmd.Stderr = &out - _ = cmd.Run() - return out.String() -} \ No newline at end of file diff --git a/cmd/update_domain_port.go b/cmd/update_domain_port.go deleted file mode 100644 index c83ca5b..0000000 --- a/cmd/update_domain_port.go +++ /dev/null @@ -1,83 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/spf13/cobra" - "stackroost/internal" - "stackroost/internal/logger" -) - -var updatePortCmd = &cobra.Command{ - Use: "update-domain-port", - Short: "Update the port for a domain and reload the web server", - Run: func(cmd *cobra.Command, args []string) { - domain, _ := cmd.Flags().GetString("domain") - newPort, _ := cmd.Flags().GetString("port") - - if internal.IsNilOrEmpty(domain) || internal.IsNilOrEmpty(newPort) { - logger.Error("Both --domain and --port are required") - os.Exit(1) - } - - server := internal.DetectServerType(domain) - if server == "" { - logger.Error("Could not detect server type (no config found)") - os.Exit(1) - } - - var configPath string - switch server { - case "apache": - configPath = filepath.Join("/etc/apache2/sites-available", domain+".conf") - case "nginx": - configPath = filepath.Join("/etc/nginx/sites-available", domain+".conf") - case "caddy": - configPath = filepath.Join("/etc/caddy/sites-available", domain+".conf") - } - - logger.Info(fmt.Sprintf("Updating port in %s configuration", server)) - - content, err := os.ReadFile(configPath) - if err != nil { - logger.Error(fmt.Sprintf("Failed to read config file: %v", err)) - os.Exit(1) - } - - // Backup - backupPath := configPath + ".bak" - _ = os.WriteFile(backupPath, content, 0644) - logger.Info(fmt.Sprintf("Backup created: %s", backupPath)) - - updated := strings.ReplaceAll(string(content), ":80", ":"+newPort) - if err := os.WriteFile(configPath, []byte(updated), 0644); err != nil { - logger.Error(fmt.Sprintf("Failed to update config file: %v", err)) - os.Exit(1) - } - - logger.Success(fmt.Sprintf("Port updated to %s for domain %s", newPort, domain)) - - // Reload - switch server { - case "apache": - internal.RunCommand("sudo", "systemctl", "reload", "apache2") - case "nginx": - internal.RunCommand("sudo", "systemctl", "reload", "nginx") - case "caddy": - internal.RunCommand("sudo", "systemctl", "reload", "caddy") - } - - logger.Success(fmt.Sprintf("%s server reloaded successfully", strings.ToUpper(server))) - }, -} - -func init() { - rootCmd.AddCommand(updatePortCmd) - updatePortCmd.Flags().String("domain", "", "Domain name to update") - updatePortCmd.Flags().String("port", "", "New port number") - updatePortCmd.MarkFlagRequired("domain") - updatePortCmd.MarkFlagRequired("port") -} diff --git a/cmd/user/delete.go b/cmd/user/delete.go new file mode 100644 index 0000000..9b721f6 --- /dev/null +++ b/cmd/user/delete.go @@ -0,0 +1,57 @@ +package user + +import ( + "fmt" + "os" + "os/user" + + "github.com/spf13/cobra" + "stackroost/internal" + "stackroost/internal/logger" +) + +func GetDeleteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete-user", + Short: "Delete a system user and optionally remove their home directory", + Run: func(cmd *cobra.Command, args []string) { + username, _ := cmd.Flags().GetString("user") + removeHome, _ := cmd.Flags().GetBool("remove-home") + + if internal.IsNilOrEmpty(username) { + logger.Error("Please provide a username using --user") + os.Exit(1) + } + + // Check if user exists + _, err := user.Lookup(username) + if err != nil { + logger.Warn(fmt.Sprintf("User '%s' does not exist", username)) + return + } + + logger.Info(fmt.Sprintf("Deleting user: %s", username)) + + // Build command args + cmdArgs := []string{"userdel"} + if removeHome { + cmdArgs = append(cmdArgs, "-r") + } + cmdArgs = append(cmdArgs, username) + + // Execute command + if err := internal.RunCommand("sudo", cmdArgs...); err != nil { + logger.Error(fmt.Sprintf("Failed to delete user %s: %v", username, err)) + os.Exit(1) + } + + logger.Success(fmt.Sprintf("User '%s' deleted", username)) + }, + } + + cmd.Flags().String("user", "", "Username to delete") + cmd.Flags().Bool("remove-home", false, "Remove the user's home directory") + cmd.MarkFlagRequired("user") + + return cmd +} diff --git a/cmd/user/list.go b/cmd/user/list.go new file mode 100644 index 0000000..a777f37 --- /dev/null +++ b/cmd/user/list.go @@ -0,0 +1,52 @@ +package user + +import ( + "bufio" + "fmt" + "os" + "os/user" + "strconv" + "strings" + + "github.com/spf13/cobra" + "stackroost/internal/logger" +) + +func GetListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list-users", + Short: "List all regular system users (UID ≥ 1000)", + Run: func(cmd *cobra.Command, args []string) { + file, err := os.Open("/etc/passwd") + if err != nil { + logger.Error(fmt.Sprintf("Failed to open /etc/passwd: %v", err)) + return + } + defer file.Close() + + scanner := bufio.NewScanner(file) + logger.Info("Listing non-system shell users (UID ≥ 1000)") + for scanner.Scan() { + line := scanner.Text() + parts := strings.Split(line, ":") + if len(parts) < 7 { + continue + } + + username := parts[0] + uidStr := parts[2] + shell := parts[6] + + uid, err := strconv.Atoi(uidStr) + if err != nil || uid < 1000 { + continue + } + + if shell == "/bin/bash" || shell == "/bin/sh" { + u, _ := user.Lookup(username) + logger.Info(fmt.Sprintf("User: %-15s UID: %-5d Shell: %s Home: %s", username, uid, shell, u.HomeDir)) + } + } + }, + } +} diff --git a/internal/utils.go b/internal/utils.go index a165280..85911b6 100644 --- a/internal/utils.go +++ b/internal/utils.go @@ -5,6 +5,7 @@ import ( "os/exec" "os" "path/filepath" + "strings" ) @@ -37,4 +38,13 @@ func DetectServerType(domain string) string { } } return "" +} + +func CaptureCommand(name string, args ...string) string { + var out strings.Builder + cmd := exec.Command(name, args...) + cmd.Stdout = &out + cmd.Stderr = &out + _ = cmd.Run() + return out.String() } \ No newline at end of file