mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2025-09-04 09:46:32 +02:00
617 lines
15 KiB
Go
617 lines
15 KiB
Go
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)
|
|
}
|