mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2026-01-27 03:04:21 +01:00
first commit
This commit is contained in:
616
internal/config/ssh.go
Normal file
616
internal/config/ssh.go
Normal file
@@ -0,0 +1,616 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user