From df3c9feb535f4e84051f4ef19e73868da51dd7c0 Mon Sep 17 00:00:00 2001 From: Ona Date: Fri, 26 Sep 2025 00:08:52 +0000 Subject: [PATCH] Add support for SSH config includes - Support Include directive in SSH config files - Handle glob patterns (e.g., Include ~/.ssh/config.d/*) - Prevent circular includes with cycle detection - Gracefully handle missing or invalid include files - Maintain compatibility with existing config parsing This allows users to organize their SSH configs across multiple files as supported by OpenSSH. Co-authored-by: Ona --- ssh_config.go | 61 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/ssh_config.go b/ssh_config.go index ade0e58..c60ef50 100644 --- a/ssh_config.go +++ b/ssh_config.go @@ -42,9 +42,27 @@ func (sc *SSHConfig) LoadConfig() error { // LoadConfigFromFile loads SSH configuration from a specific file func (sc *SSHConfig) LoadConfigFromFile(path string) error { + return sc.loadConfigFromFileRecursive(path, make(map[string]bool)) +} + +// loadConfigFromFileRecursive loads SSH config with include support and cycle detection +func (sc *SSHConfig) loadConfigFromFileRecursive(path string, visited map[string]bool) error { + // Resolve absolute path to detect cycles + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("failed to resolve path %s: %w", path, err) + } + + // Check for cycles + if visited[absPath] { + return fmt.Errorf("circular include detected: %s", absPath) + } + visited[absPath] = true + defer delete(visited, absPath) + file, err := os.Open(path) if err != nil { - return fmt.Errorf("failed to open SSH config file: %w", err) + return fmt.Errorf("failed to open SSH config file %s: %w", path, err) } defer file.Close() @@ -68,6 +86,12 @@ func (sc *SSHConfig) LoadConfigFromFile(path string) error { value := strings.Join(parts[1:], " ") switch key { + case "include": + // Handle include directive + if err := sc.processInclude(value, visited); err != nil { + // Log error but continue processing + fmt.Fprintf(os.Stderr, "Warning: failed to process include %s: %v\n", value, err) + } case "host": // Save previous host if exists if currentHost != nil { @@ -103,7 +127,40 @@ func (sc *SSHConfig) LoadConfigFromFile(path string) error { } if err := scanner.Err(); err != nil { - return fmt.Errorf("error reading SSH config file: %w", err) + return fmt.Errorf("error reading SSH config file %s: %w", path, err) + } + + return nil +} + +// processInclude handles SSH config include directives +func (sc *SSHConfig) processInclude(pattern string, visited map[string]bool) error { + // Expand tilde to home directory + if strings.HasPrefix(pattern, "~/") { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + pattern = filepath.Join(homeDir, pattern[2:]) + } + + // Handle glob patterns + matches, err := filepath.Glob(pattern) + if err != nil { + return fmt.Errorf("invalid glob pattern %s: %w", pattern, err) + } + + // Process each matching file + for _, match := range matches { + // Skip directories + if info, err := os.Stat(match); err == nil && info.IsDir() { + continue + } + + // Recursively load the included file + if err := sc.loadConfigFromFileRecursive(match, visited); err != nil { + return fmt.Errorf("failed to load included file %s: %w", match, err) + } } return nil