diff --git a/.gitignore b/.gitignore index 1403ef5..802a747 100644 --- a/.gitignore +++ b/.gitignore @@ -37,5 +37,8 @@ bin # Added by goreleaser init: dist/ +# Misc tools +.codegraph + # Binary output lazyssh diff --git a/internal/adapters/data/ssh_config_file/config_io.go b/internal/adapters/data/ssh_config_file/config_io.go index b5a5da1..1940c0f 100644 --- a/internal/adapters/data/ssh_config_file/config_io.go +++ b/internal/adapters/data/ssh_config_file/config_io.go @@ -15,15 +15,17 @@ package ssh_config_file import ( + "bufio" "fmt" "os" "path/filepath" + "strings" "time" "github.com/kevinburke/ssh_config" ) -// loadConfig reads and parses the SSH config file. +// loadConfig reads and parses the SSH config file, including any files specified by Include directives. // If the file does not exist, it returns an empty config without error to support first-run behavior. func (r *Repository) loadConfig() (*ssh_config.Config, error) { file, err := r.fileSystem.Open(r.configPath) @@ -44,9 +46,161 @@ func (r *Repository) loadConfig() (*ssh_config.Config, error) { return nil, fmt.Errorf("failed to decode config: %w", err) } + // Process include directives to merge hosts from included files + if err := r.processIncludes(cfg, r.configPath); err != nil { + r.logger.Warnf("error processing include directives: %v", err) + // Continue without the included files rather than failing completely + } + return cfg, nil } +// processIncludes recursively processes Include directives in the config and merges hosts from included files. +// It tracks visited files to prevent infinite loops. +func (r *Repository) processIncludes(cfg *ssh_config.Config, configPath string) error { + visited := make(map[string]bool) + includePatterns, err := r.extractIncludePatterns(configPath) + if err != nil { + return err + } + + return r.processIncludePatterns(cfg, configPath, includePatterns, visited, 0) +} + +// extractIncludePatterns extracts all Include directive patterns from a config file +func (r *Repository) extractIncludePatterns(filePath string) ([]string, error) { + file, err := r.fileSystem.Open(filePath) + if err != nil { + return nil, err + } + defer func() { + if cerr := file.Close(); cerr != nil { + r.logger.Warnf("failed to close file: %v", cerr) + } + }() + + var patterns []string + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Check if line starts with "Include" (case-insensitive) + if strings.HasPrefix(strings.ToLower(line), "include") { + // Parse the include line to extract the pattern(s) + // Format: Include /path/to/file [/another/path/to/file] ... + parts := strings.Fields(line) + if len(parts) > 1 { + // Add all patterns after "Include" + for i := 1; i < len(parts); i++ { + patterns = append(patterns, parts[i]) + } + } + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading file: %w", err) + } + + return patterns, nil +} + +// processIncludePatterns recursively processes include patterns with depth limit to prevent infinite recursion. +func (r *Repository) processIncludePatterns(cfg *ssh_config.Config, configPath string, patterns []string, visited map[string]bool, depth uint8) error { + const maxDepth = 5 + + if depth > maxDepth { + return fmt.Errorf("include nesting depth exceeded (max: %d)", maxDepth) + } + + configDir := filepath.Dir(configPath) + + for _, pattern := range patterns { + includedHosts, err := r.loadIncludedConfig(pattern, configDir, visited, depth+1) + if err != nil { + r.logger.Warnf("failed to process include pattern %s: %v", pattern, err) + continue + } + + // Merge hosts from included file into main config + cfg.Hosts = append(cfg.Hosts, includedHosts...) + } + + return nil +} + +// loadIncludedConfig loads and parses a single included config file and any of its includes +func (r *Repository) loadIncludedConfig(pattern, baseDir string, visited map[string]bool, depth uint8) ([]*ssh_config.Host, error) { + var includePath string + + // Handle tilde expansion for home directory + if strings.HasPrefix(pattern, "~") { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get home directory: %w", err) + } + includePath = filepath.Join(home, pattern[1:]) + } else if filepath.IsAbs(pattern) { + // Resolve the include path + includePath = pattern + } else { + includePath = filepath.Join(baseDir, pattern) + } + + // Handle wildcards + matches, err := filepath.Glob(includePath) + if err != nil { + return nil, fmt.Errorf("invalid glob pattern %s: %w", includePath, err) + } + + var allHosts []*ssh_config.Host + + for _, match := range matches { + // Check if already visited (prevent cycles) + absPath, _ := filepath.Abs(match) + if visited[absPath] { + continue + } + visited[absPath] = true + + file, err := r.fileSystem.Open(match) + if err != nil { + r.logger.Warnf("failed to open included config file %s: %v", match, err) + continue + } + + includedCfg, err := ssh_config.Decode(file) + if cerr := file.Close(); cerr != nil { + r.logger.Warnf("failed to close file %s: %v", match, cerr) + } + + if err != nil { + r.logger.Warnf("failed to decode included config file %s: %v", match, err) + continue + } + + // Recursively process any includes in the included file + nestedPatterns, err := r.extractIncludePatterns(match) + if err != nil { + r.logger.Warnf("error extracting includes from %s: %v", match, err) + } else if len(nestedPatterns) > 0 { + if err := r.processIncludePatterns(includedCfg, match, nestedPatterns, visited, depth+1); err != nil { + r.logger.Warnf("error processing nested includes in %s: %v", match, err) + } + } + + allHosts = append(allHosts, includedCfg.Hosts...) + } + + return allHosts, nil +} + // saveConfig writes the SSH config back to the file with atomic operations and backup management. func (r *Repository) saveConfig(cfg *ssh_config.Config) error { configDir := filepath.Dir(r.configPath) diff --git a/internal/adapters/data/ssh_config_file/include_test.go b/internal/adapters/data/ssh_config_file/include_test.go new file mode 100644 index 0000000..9cf97dd --- /dev/null +++ b/internal/adapters/data/ssh_config_file/include_test.go @@ -0,0 +1,163 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ssh_config_file + +import ( + "os" + "path/filepath" + "testing" + + "go.uber.org/zap" +) + +func TestLoadConfigWithIncludes(t *testing.T) { + // Create temporary directory + tmpDir, err := os.MkdirTemp("", "ssh_config_test_*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create .ssh directory and subdirectories + sshDir := filepath.Join(tmpDir, ".ssh") + configDDir := filepath.Join(sshDir, "config.d") + if err := os.MkdirAll(configDDir, 0o700); err != nil { + t.Fatalf("failed to create directories: %v", err) + } + + // Create included config file + includedConfigPath := filepath.Join(configDDir, "hosts") + includedContent := `Host server1 + HostName 192.168.1.1 + User admin + Port 2222 + +Host server2 + HostName 192.168.1.2 + User testuser + Port 3333 +` + if err := os.WriteFile(includedConfigPath, []byte(includedContent), 0o600); err != nil { + t.Fatalf("failed to write included config: %v", err) + } + + // Create main config file with Include directive + mainConfigPath := filepath.Join(sshDir, "config") + mainContent := `Host server0 + HostName localhost + User root + Port 22 + +Include ~/.ssh/config.d/hosts +` + if err := os.WriteFile(mainConfigPath, []byte(mainContent), 0o600); err != nil { + t.Fatalf("failed to write main config: %v", err) + } + + // Create logger + logger, _ := zap.NewDevelopment() + sugared := logger.Sugar() + + // Create repository + repo := &Repository{ + configPath: mainConfigPath, + fileSystem: DefaultFileSystem{}, + logger: sugared, + } + + // Load config + cfg, err := repo.loadConfig() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + // Verify that all hosts were loaded + if len(cfg.Hosts) != 3 { + t.Errorf("expected 3 hosts, got %d", len(cfg.Hosts)) + } + + // Check that we have the expected hosts + hostNames := make(map[string]bool) + for _, host := range cfg.Hosts { + for _, pattern := range host.Patterns { + hostNames[pattern.String()] = true + } + } + + expectedHosts := []string{"server0", "server1", "server2"} + for _, expected := range expectedHosts { + if !hostNames[expected] { + t.Errorf("expected host %q not found", expected) + } + } +} + +func TestLoadConfigWithWildcardIncludes(t *testing.T) { + // Create temporary directory + tmpDir, err := os.MkdirTemp("", "ssh_config_wildcard_test_*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create .ssh directory and subdirectories + sshDir := filepath.Join(tmpDir, ".ssh") + configDDir := filepath.Join(sshDir, "config.d") + if err := os.MkdirAll(configDDir, 0o700); err != nil { + t.Fatalf("failed to create directories: %v", err) + } + + // Create multiple included config files + for i := 1; i <= 2; i++ { + fileName := filepath.Join(configDDir, "config"+string(rune('0'+i))) + content := "Host server" + string(rune('0'+i)) + "\n HostName 192.168.1." + string(rune('0'+i)) + "\n" + if err := os.WriteFile(fileName, []byte(content), 0o600); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + } + + // Create main config file with wildcard Include directive + mainConfigPath := filepath.Join(sshDir, "config") + mainContent := `Host server0 + HostName localhost + +Include ~/.ssh/config.d/config* +` + if err := os.WriteFile(mainConfigPath, []byte(mainContent), 0o600); err != nil { + t.Fatalf("failed to write main config: %v", err) + } + + // Create logger + logger, _ := zap.NewDevelopment() + sugared := logger.Sugar() + + // Create repository + repo := &Repository{ + configPath: mainConfigPath, + fileSystem: DefaultFileSystem{}, + logger: sugared, + } + + // Load config + cfg, err := repo.loadConfig() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + // Verify that all hosts were loaded (server0 + server1 + server2) + if len(cfg.Hosts) != 3 { + t.Errorf("expected 3 hosts, got %d", len(cfg.Hosts)) + } +}