package config import ( "bufio" "fmt" "io" "os" "path/filepath" "strings" "sync" ) // SSHHost represents an SSH host configuration type SSHHost struct { Name string Hostname string User string Port string Identity string ProxyJump string Options string Tags []string } // configMutex protects SSH config file operations from race conditions var configMutex sync.Mutex // backupConfig creates a backup of the SSH config file func backupConfig(configPath string) error { backupPath := configPath + ".backup" src, err := os.Open(configPath) if err != nil { return err } defer src.Close() dst, err := os.Create(backupPath) if err != nil { return err } defer dst.Close() _, err = io.Copy(dst, src) return err } // ParseSSHConfig parses the SSH config file and returns the list of hosts func ParseSSHConfig() ([]SSHHost, error) { homeDir, err := os.UserHomeDir() if err != nil { return nil, err } configPath := filepath.Join(homeDir, ".ssh", "config") return ParseSSHConfigFile(configPath) } // ParseSSHConfigFile parses a specific SSH config file and returns the list of hosts func ParseSSHConfigFile(configPath string) ([]SSHHost, error) { // Check if the file exists, otherwise create it (and the parent directory if needed) if _, err := os.Stat(configPath); os.IsNotExist(err) { dir := filepath.Dir(configPath) if _, err := os.Stat(dir); os.IsNotExist(err) { err = os.MkdirAll(dir, 0700) if err != nil { return nil, fmt.Errorf("failed to create .ssh directory: %w", err) } } file, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY, 0600) if err != nil { return nil, fmt.Errorf("failed to create SSH config file: %w", err) } file.Close() // File created, return empty host list return []SSHHost{}, nil } file, err := os.Open(configPath) if err != nil { return nil, err } defer file.Close() var hosts []SSHHost var currentHost *SSHHost var pendingTags []string scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) // Ignore empty lines if line == "" { continue } // Check for tags comment if strings.HasPrefix(line, "# Tags:") { tagsStr := strings.TrimPrefix(line, "# Tags:") tagsStr = strings.TrimSpace(tagsStr) if tagsStr != "" { // Split tags by comma and trim whitespace for _, tag := range strings.Split(tagsStr, ",") { tag = strings.TrimSpace(tag) if tag != "" { pendingTags = append(pendingTags, tag) } } } continue } // Ignore other comments if strings.HasPrefix(line, "#") { continue } // Split line into words parts := strings.Fields(line) if len(parts) < 2 { continue } key := strings.ToLower(parts[0]) value := strings.Join(parts[1:], " ") switch key { case "host": // New host, save previous one if it exists if currentHost != nil { hosts = append(hosts, *currentHost) } // Create new host currentHost = &SSHHost{ Name: value, Port: "22", // Default port Tags: pendingTags, // Assign pending tags to this host } // Clear pending tags for next host pendingTags = nil case "hostname": if currentHost != nil { currentHost.Hostname = value } case "user": if currentHost != nil { currentHost.User = value } case "port": if currentHost != nil { currentHost.Port = value } case "identityfile": if currentHost != nil { currentHost.Identity = value } case "proxyjump": if currentHost != nil { currentHost.ProxyJump = value } default: // Handle other SSH options if currentHost != nil && strings.TrimSpace(line) != "" { // Store options in config format (key value), not command format if currentHost.Options == "" { currentHost.Options = parts[0] + " " + value } else { currentHost.Options += "\n" + parts[0] + " " + value } } } } // Add the last host if it exists if currentHost != nil { hosts = append(hosts, *currentHost) } return hosts, scanner.Err() } // AddSSHHost adds a new SSH host to the config file func AddSSHHost(host SSHHost) error { configMutex.Lock() defer configMutex.Unlock() homeDir, err := os.UserHomeDir() if err != nil { return err } configPath := filepath.Join(homeDir, ".ssh", "config") // Create backup before modification if file exists if _, err := os.Stat(configPath); err == nil { if err := backupConfig(configPath); err != nil { return fmt.Errorf("failed to create backup: %w", err) } } // Check if host already exists exists, err := HostExists(host.Name) if err != nil { return err } if exists { return fmt.Errorf("host '%s' already exists", host.Name) } // Open file in append mode file, err := os.OpenFile(configPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) if err != nil { return err } defer file.Close() // Write the configuration _, err = file.WriteString("\n") if err != nil { return err } // Write tags if present if len(host.Tags) > 0 { _, err = file.WriteString("# Tags: " + strings.Join(host.Tags, ", ") + "\n") if err != nil { return err } } // Write host configuration _, err = file.WriteString(fmt.Sprintf("Host %s\n", host.Name)) if err != nil { return err } _, err = file.WriteString(fmt.Sprintf(" HostName %s\n", host.Hostname)) if err != nil { return err } if host.User != "" { _, err = file.WriteString(fmt.Sprintf(" User %s\n", host.User)) if err != nil { return err } } if host.Port != "" && host.Port != "22" { _, err = file.WriteString(fmt.Sprintf(" Port %s\n", host.Port)) if err != nil { return err } } if host.Identity != "" { _, err = file.WriteString(fmt.Sprintf(" IdentityFile %s\n", host.Identity)) if err != nil { return err } } if host.ProxyJump != "" { _, err = file.WriteString(fmt.Sprintf(" ProxyJump %s\n", host.ProxyJump)) if err != nil { return err } } // Write SSH options if host.Options != "" { // Split options by newlines and write each one options := strings.Split(host.Options, "\n") for _, option := range options { option = strings.TrimSpace(option) if option != "" { _, err = file.WriteString(fmt.Sprintf(" %s\n", option)) if err != nil { return err } } } } return nil } // ParseSSHOptionsFromCommand converts SSH command line options to config format // Input: "-o Compression=yes -o ServerAliveInterval=60" // Output: "Compression yes\nServerAliveInterval 60" func ParseSSHOptionsFromCommand(options string) string { if options == "" { return "" } var result []string parts := strings.Split(options, "-o") for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } // Replace = with space for SSH config format option := strings.ReplaceAll(part, "=", " ") result = append(result, option) } return strings.Join(result, "\n") } // FormatSSHOptionsForCommand converts SSH config options to command line format // Input: "Compression yes\nServerAliveInterval 60" // Output: "-o Compression=yes -o ServerAliveInterval=60" func FormatSSHOptionsForCommand(options string) string { if options == "" { return "" } var result []string lines := strings.Split(options, "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } // Replace space with = for command line format parts := strings.SplitN(line, " ", 2) if len(parts) == 2 { result = append(result, fmt.Sprintf("-o %s=%s", parts[0], parts[1])) } else { result = append(result, fmt.Sprintf("-o %s", line)) } } return strings.Join(result, " ") } // HostExists checks if a host already exists in the config func HostExists(hostName string) (bool, error) { hosts, err := ParseSSHConfig() if err != nil { return false, err } for _, host := range hosts { if host.Name == hostName { return true, nil } } return false, nil } // GetSSHHost retrieves a specific host configuration by name func GetSSHHost(hostName string) (*SSHHost, error) { hosts, err := ParseSSHConfig() if err != nil { return nil, err } for _, host := range hosts { if host.Name == hostName { return &host, nil } } return nil, fmt.Errorf("host '%s' not found", hostName) } // UpdateSSHHost updates an existing SSH host configuration func UpdateSSHHost(oldName string, newHost SSHHost) error { configMutex.Lock() defer configMutex.Unlock() homeDir, err := os.UserHomeDir() if err != nil { return err } configPath := filepath.Join(homeDir, ".ssh", "config") // Create backup before modification if err := backupConfig(configPath); err != nil { return fmt.Errorf("failed to create backup: %w", err) } // Read the current config content, err := os.ReadFile(configPath) if err != nil { return err } lines := strings.Split(string(content), "\n") var newLines []string i := 0 hostFound := false for i < len(lines) { line := strings.TrimSpace(lines[i]) // Check for tags comment followed by Host if strings.HasPrefix(line, "# Tags:") && i+1 < len(lines) { nextLine := strings.TrimSpace(lines[i+1]) if nextLine == "Host "+oldName { // Found the host to update, skip the old configuration hostFound = true // Skip until we find the end of this host block (empty line or next Host) i += 2 // Skip tags and Host line for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { i++ } // Skip any trailing empty lines after the host block for i < len(lines) && strings.TrimSpace(lines[i]) == "" { i++ } // Insert new configuration at this position // Add empty line only if the previous line is not empty if len(newLines) > 0 && strings.TrimSpace(newLines[len(newLines)-1]) != "" { newLines = append(newLines, "") } if len(newHost.Tags) > 0 { newLines = append(newLines, "# Tags: "+strings.Join(newHost.Tags, ", ")) } newLines = append(newLines, "Host "+newHost.Name) newLines = append(newLines, " HostName "+newHost.Hostname) if newHost.User != "" { newLines = append(newLines, " User "+newHost.User) } if newHost.Port != "" && newHost.Port != "22" { newLines = append(newLines, " Port "+newHost.Port) } if newHost.Identity != "" { newLines = append(newLines, " IdentityFile "+newHost.Identity) } if newHost.ProxyJump != "" { newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) } // Write SSH options if newHost.Options != "" { options := strings.Split(newHost.Options, "\n") for _, option := range options { option = strings.TrimSpace(option) if option != "" { newLines = append(newLines, " "+option) } } } // Add empty line after the host configuration for separation newLines = append(newLines, "") continue } } // Check for Host line without tags if strings.HasPrefix(line, "Host ") && strings.Fields(line)[1] == oldName { hostFound = true // Skip until we find the end of this host block i++ // Skip Host line for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { i++ } // Skip any trailing empty lines after the host block for i < len(lines) && strings.TrimSpace(lines[i]) == "" { i++ } // Insert new configuration // Add empty line only if the previous line is not empty if len(newLines) > 0 && strings.TrimSpace(newLines[len(newLines)-1]) != "" { newLines = append(newLines, "") } if len(newHost.Tags) > 0 { newLines = append(newLines, "# Tags: "+strings.Join(newHost.Tags, ", ")) } newLines = append(newLines, "Host "+newHost.Name) newLines = append(newLines, " HostName "+newHost.Hostname) if newHost.User != "" { newLines = append(newLines, " User "+newHost.User) } if newHost.Port != "" && newHost.Port != "22" { newLines = append(newLines, " Port "+newHost.Port) } if newHost.Identity != "" { newLines = append(newLines, " IdentityFile "+newHost.Identity) } if newHost.ProxyJump != "" { newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) } // Write SSH options if newHost.Options != "" { options := strings.Split(newHost.Options, "\n") for _, option := range options { option = strings.TrimSpace(option) if option != "" { newLines = append(newLines, " "+option) } } } // Add empty line after the host configuration for separation newLines = append(newLines, "") continue } // Keep other lines as-is newLines = append(newLines, lines[i]) i++ } if !hostFound { return fmt.Errorf("host '%s' not found", oldName) } // Write back to file newContent := strings.Join(newLines, "\n") return os.WriteFile(configPath, []byte(newContent), 0600) } // DeleteSSHHost removes an SSH host configuration from the config file func DeleteSSHHost(hostName string) error { configMutex.Lock() defer configMutex.Unlock() homeDir, err := os.UserHomeDir() if err != nil { return err } configPath := filepath.Join(homeDir, ".ssh", "config") // Create backup before modification if err := backupConfig(configPath); err != nil { return fmt.Errorf("failed to create backup: %w", err) } // Read the current config content, err := os.ReadFile(configPath) if err != nil { return err } lines := strings.Split(string(content), "\n") var newLines []string i := 0 hostFound := false for i < len(lines) { line := strings.TrimSpace(lines[i]) // Check for tags comment followed by Host if strings.HasPrefix(line, "# Tags:") && i+1 < len(lines) { nextLine := strings.TrimSpace(lines[i+1]) if nextLine == "Host "+hostName { // Found the host to delete, skip the configuration hostFound = true // Skip tags comment and Host line i += 2 // Skip until we find the end of this host block (empty line or next Host) for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { i++ } // Skip any trailing empty lines after the host block for i < len(lines) && strings.TrimSpace(lines[i]) == "" { i++ } continue } } // Check for Host line without tags if strings.HasPrefix(line, "Host ") && strings.Fields(line)[1] == hostName { hostFound = true // Skip Host line i++ // Skip until we find the end of this host block for i < len(lines) && strings.TrimSpace(lines[i]) != "" && !strings.HasPrefix(strings.TrimSpace(lines[i]), "Host ") { i++ } // Skip any trailing empty lines after the host block for i < len(lines) && strings.TrimSpace(lines[i]) == "" { i++ } continue } // Keep other lines as-is newLines = append(newLines, lines[i]) i++ } if !hostFound { return fmt.Errorf("host '%s' not found", hostName) } // Write back to file newContent := strings.Join(newLines, "\n") return os.WriteFile(configPath, []byte(newContent), 0600) }