Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,8 @@ bin
# Added by goreleaser init:
dist/

# Misc tools
.codegraph

# Binary output
lazyssh
156 changes: 155 additions & 1 deletion internal/adapters/data/ssh_config_file/config_io.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
163 changes: 163 additions & 0 deletions internal/adapters/data/ssh_config_file/include_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading