├── .gitignore ├── testdata ├── system-include ├── match-directive ├── extraspace ├── invalid-port ├── config-no-ending-newline ├── eqsign ├── negated ├── anotherfile ├── config4 ├── identities ├── include ├── include-recursive ├── dos-lines ├── eol-comments ├── config3 ├── fuzz │ └── FuzzDecode │ │ ├── 4f8b378d89916e9b4fd796f74f5b12efb5cd85faaba9fea8fbe419d6af63add8 │ │ └── 3cfc035ae4867ca13fa7bfaf2793731f05fd4d59c3af8761ea365c7485c752fd ├── config1 └── config2 ├── go.mod ├── .mailmap ├── fuzz_test.go ├── AUTHORS.txt ├── parser_test.go ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── Makefile ├── position.go ├── CHANGELOG.md ├── token.go ├── validators_test.go ├── example_test.go ├── LICENSE ├── SECURITY.md ├── README.md ├── lexer.go ├── parser.go ├── validators.go ├── config_test.go └── config.go /.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/system-include: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/match-directive: -------------------------------------------------------------------------------- 1 | Match all 2 | Port 4567 3 | -------------------------------------------------------------------------------- /testdata/extraspace: -------------------------------------------------------------------------------- 1 | Host test.test 2 | Port 1234 3 | -------------------------------------------------------------------------------- /testdata/invalid-port: -------------------------------------------------------------------------------- 1 | Host test.test 2 | Port notanumber 3 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kevinburke/ssh_config 2 | 3 | go 1.18 4 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Kevin Burke Kevin Burke 2 | -------------------------------------------------------------------------------- /testdata/config-no-ending-newline: -------------------------------------------------------------------------------- 1 | Host example 2 | HostName example.com 3 | Port 4242 -------------------------------------------------------------------------------- /testdata/eqsign: -------------------------------------------------------------------------------- 1 | Host=test.test 2 | Port =1234 3 | Port2= 5678 4 | Compression yes 5 | -------------------------------------------------------------------------------- /testdata/negated: -------------------------------------------------------------------------------- 1 | Host *.example.com !*.dialup.example.com 2 | Port 1234 3 | 4 | Host * 5 | Port 5678 6 | -------------------------------------------------------------------------------- /testdata/anotherfile: -------------------------------------------------------------------------------- 1 | # Not sure that this actually works; Include might need to be relative to the 2 | # load directory. 3 | Compression yes 4 | -------------------------------------------------------------------------------- /testdata/config4: -------------------------------------------------------------------------------- 1 | # Extra space at end of line is important. 2 | Host wap 3 | User root 4 | KexAlgorithms diffie-hellman-group1-sha1 5 | -------------------------------------------------------------------------------- /testdata/identities: -------------------------------------------------------------------------------- 1 | 2 | Host hasidentity 3 | IdentityFile file1 4 | 5 | Host has2identity 6 | IdentityFile f1 7 | IdentityFile f2 8 | 9 | Host protocol1 10 | Protocol 1 11 | 12 | -------------------------------------------------------------------------------- /testdata/include: -------------------------------------------------------------------------------- 1 | Host kevinburke.ssh_config.test.example.com 2 | # This file (or files) needs to be found in ~/.ssh or /etc/ssh, depending on 3 | # the test. 4 | Include kevinburke-ssh-config-*-file 5 | -------------------------------------------------------------------------------- /testdata/include-recursive: -------------------------------------------------------------------------------- 1 | Host kevinburke.ssh_config.test.example.com 2 | # This file (or files) needs to be found in ~/.ssh or /etc/ssh, depending on 3 | # the test. It should include itself. 4 | Include kevinburke-ssh-config-recursive-include 5 | -------------------------------------------------------------------------------- /testdata/dos-lines: -------------------------------------------------------------------------------- 1 | # Config file with dos line endings 2 | Host wap 3 | HostName wap.example.org 4 | Port 22 5 | User root 6 | KexAlgorithms diffie-hellman-group1-sha1 7 | 8 | Host wap2 9 | HostName 8.8.8.8 10 | User google 11 | -------------------------------------------------------------------------------- /fuzz_test.go: -------------------------------------------------------------------------------- 1 | //go:build 1.18 2 | // +build 1.18 3 | 4 | package ssh_config 5 | 6 | import ( 7 | "bytes" 8 | "testing" 9 | ) 10 | 11 | func FuzzDecode(f *testing.F) { 12 | f.Fuzz(func(t *testing.T, in []byte) { 13 | _, err := Decode(bytes.NewReader(in)) 14 | if err != nil { 15 | t.Fatalf("decode %q: %v", string(in), err) 16 | } 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /testdata/eol-comments: -------------------------------------------------------------------------------- 1 | Host example # this comment terminates a Host line 2 | HostName example.com # aligned eol comment 1 3 | ForwardX11Timeout 52w # aligned eol comment 2 4 | # This comment takes up a whole line 5 | # This comment is offset and takes up a whole line 6 | AddressFamily inet # aligned eol comment 3 7 | Port 4242 #compact comment 8 | -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | Carlos A Becker 2 | Dustin Spicuzza 3 | Eugene Terentev 4 | Kevin Burke 5 | Mark Nevill 6 | Scott Lessans 7 | Sergey Lukjanov 8 | Simon Josefsson 9 | Wayne Ashley Berry 10 | santosh653 <70637961+santosh653@users.noreply.github.com> 11 | -------------------------------------------------------------------------------- /parser_test.go: -------------------------------------------------------------------------------- 1 | package ssh_config 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | type errReader struct { 9 | } 10 | 11 | func (b *errReader) Read(p []byte) (n int, err error) { 12 | return 0, errors.New("read error occurred") 13 | } 14 | 15 | func TestIOError(t *testing.T) { 16 | buf := &errReader{} 17 | _, err := Decode(buf) 18 | if err == nil { 19 | t.Fatal("expected non-nil err, got nil") 20 | } 21 | if err.Error() != "read error occurred" { 22 | t.Errorf("expected read error msg, got %v", err) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BUMP_VERSION := $(GOPATH)/bin/bump_version 2 | WRITE_MAILMAP := $(GOPATH)/bin/write_mailmap 3 | 4 | lint: 5 | go vet ./... 6 | go run honnef.co/go/tools/cmd/staticcheck@latest ./... 7 | 8 | test: 9 | @# the timeout helps guard against infinite recursion 10 | go test -timeout=250ms ./... 11 | 12 | race-test: 13 | go test -timeout=500ms -race ./... 14 | 15 | $(BUMP_VERSION): 16 | go get -u github.com/kevinburke/bump_version 17 | 18 | $(WRITE_MAILMAP): 19 | go get -u github.com/kevinburke/write_mailmap 20 | 21 | release: test | $(BUMP_VERSION) 22 | $(BUMP_VERSION) --tag-prefix=v minor config.go 23 | 24 | force: ; 25 | 26 | AUTHORS.txt: force | $(WRITE_MAILMAP) 27 | $(WRITE_MAILMAP) > AUTHORS.txt 28 | 29 | authors: AUTHORS.txt 30 | -------------------------------------------------------------------------------- /position.go: -------------------------------------------------------------------------------- 1 | package ssh_config 2 | 3 | import "fmt" 4 | 5 | // Position of a document element within a SSH document. 6 | // 7 | // Line and Col are both 1-indexed positions for the element's line number and 8 | // column number, respectively. Values of zero or less will cause Invalid(), 9 | // to return true. 10 | type Position struct { 11 | Line int // line within the document 12 | Col int // column within the line 13 | } 14 | 15 | // String representation of the position. 16 | // Displays 1-indexed line and column numbers. 17 | func (p Position) String() string { 18 | return fmt.Sprintf("(%d, %d)", p.Line, p.Col) 19 | } 20 | 21 | // Invalid returns whether or not the position is valid (i.e. with negative or 22 | // null values) 23 | func (p Position) Invalid() bool { 24 | return p.Line <= 0 || p.Col <= 0 25 | } 26 | -------------------------------------------------------------------------------- /testdata/config3: -------------------------------------------------------------------------------- 1 | Host bastion.*.i.*.example.net 2 | User simon.thulbourn 3 | Port 22 4 | ForwardAgent yes 5 | IdentityFile /Users/%u/.ssh/example.net/%r/id_rsa 6 | UseKeychain yes 7 | 8 | Host 10.* 9 | User simon.thulbourn 10 | Port 23 11 | ForwardAgent yes 12 | StrictHostKeyChecking no 13 | UserKnownHostsFile /dev/null 14 | IdentityFile /Users/%u/.ssh/example.net/%r/id_rsa 15 | UseKeychain yes 16 | ProxyCommand >&1; h="%h"; exec ssh -q $(ssh-bastion -ip $h) nc %h %p 17 | 18 | Host 20.20.20.? 19 | User simon.thulbourn 20 | Port 24 21 | ForwardAgent yes 22 | StrictHostKeyChecking no 23 | UserKnownHostsFile /dev/null 24 | IdentityFile /Users/%u/.ssh/example.net/%r/id_rsa 25 | UseKeychain yes 26 | ProxyCommand >&1; h="%h"; exec ssh -q $(ssh-bastion -ip $h) nc %h %p 27 | 28 | Host * 29 | IdentityFile /Users/%u/.ssh/%h/%r/id_rsa 30 | UseKeychain yes 31 | Port 25 32 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzDecode/4f8b378d89916e9b4fd796f74f5b12efb5cd85faaba9fea8fbe419d6af63add8: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | []byte("Host localhost 127.0.0.1 # A comment at the end of a host line.\n NoHostAuthenticationForLocalhost yes\n\n# A comment\n # A comment with leading spaces.\n\nHost wap\n User root\n KexAlgorithms diffie-hellman-group1-sha1\n\nHost [some stuff behind a NAT]\n Compression yes\n ProxyCommand ssh -qW %h:%p [NATrouter]\n\nHost wopr # there are 2 proxies available for this one...\n User root\n ProxyCommand sh -c \"ssh proxy1 -qW %h:22 || ssh proxy2 -qW %h:22\"\n\nHost dhcp-??\n UserKnownHostsFile /dev/null\n StrictHostKeyChecking no\n User root\n\nHost [my boxes] [*.mydomain]\n ForwardAgent yes\n ForwardX11 yes\n ForwardX11Trusted yes\n\nHost *\n #ControlMaster auto\n #ControlPath /tmp/ssh-master-%C\n #ControlPath /tmp/ssh-%u-%r@%h:%p\n #ControlPersist yes\n ForwardX11Timeout 52w\n XAuthLocation /usr/bin/xauth\n SendEnv LANG LC_*\n HostKeyAlgorithms ssh-ed25519,ssh-rsa\n AddressFamily inet\n #UpdateHostKeys ask\n") -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changes 2 | 3 | ## Version 1.4 (released August 2025) 4 | 5 | - Remove .gitattributes file (which was used to test different line endings, and 6 | caused issues in some build environments). 7 | 8 | ## Version 1.3 (released February 2025) 9 | 10 | - Add go.mod file (although this project has no dependencies). 11 | 12 | - Various updates to CI and build environment 13 | 14 | - config: add UserSettings.ConfigFinder 15 | 16 | ## Version 1.2 17 | 18 | Previously, if a Host declaration or a value had trailing whitespace, that 19 | whitespace would have been included as part of the value. This led to unexpected 20 | consequences. For example: 21 | 22 | ``` 23 | Host example # A comment 24 | HostName example.com # Another comment 25 | ``` 26 | 27 | Prior to version 1.2, the value for Host would have been "example " and the 28 | value for HostName would have been "example.com ". Both of these are 29 | unintuitive. 30 | 31 | Instead, we strip the trailing whitespace in the configuration, which leads to 32 | more intuitive behavior. 33 | -------------------------------------------------------------------------------- /token.go: -------------------------------------------------------------------------------- 1 | package ssh_config 2 | 3 | import "fmt" 4 | 5 | type token struct { 6 | Position 7 | typ tokenType 8 | val string 9 | } 10 | 11 | func (t token) String() string { 12 | switch t.typ { 13 | case tokenEOF: 14 | return "EOF" 15 | } 16 | return fmt.Sprintf("%q", t.val) 17 | } 18 | 19 | type tokenType int 20 | 21 | const ( 22 | eof = -(iota + 1) 23 | ) 24 | 25 | const ( 26 | tokenError tokenType = iota 27 | tokenEOF 28 | tokenEmptyLine 29 | tokenComment 30 | tokenKey 31 | tokenEquals 32 | tokenString 33 | ) 34 | 35 | func isSpace(r rune) bool { 36 | return r == ' ' || r == '\t' 37 | } 38 | 39 | func isKeyStartChar(r rune) bool { 40 | return !(isSpace(r) || r == '\r' || r == '\n' || r == eof) 41 | } 42 | 43 | // I'm not sure that this is correct 44 | func isKeyChar(r rune) bool { 45 | // Keys start with the first character that isn't whitespace or [ and end 46 | // with the last non-whitespace character before the equals sign. Keys 47 | // cannot contain a # character." 48 | return !(r == '\r' || r == '\n' || r == eof || r == '=') 49 | } 50 | -------------------------------------------------------------------------------- /testdata/config1: -------------------------------------------------------------------------------- 1 | Host localhost 127.0.0.1 # A comment at the end of a host line. 2 | NoHostAuthenticationForLocalhost yes 3 | 4 | # A comment 5 | # A comment with leading spaces. 6 | 7 | Host wap 8 | User root 9 | KexAlgorithms diffie-hellman-group1-sha1 10 | 11 | Host [some stuff behind a NAT] 12 | Compression yes 13 | ProxyCommand ssh -qW %h:%p [NATrouter] 14 | 15 | Host wopr # there are 2 proxies available for this one... 16 | User root 17 | ProxyCommand sh -c "ssh proxy1 -qW %h:22 || ssh proxy2 -qW %h:22" 18 | 19 | Host dhcp-?? 20 | UserKnownHostsFile /dev/null 21 | StrictHostKeyChecking no 22 | User root 23 | 24 | Host [my boxes] [*.mydomain] 25 | ForwardAgent yes 26 | ForwardX11 yes 27 | ForwardX11Trusted yes 28 | 29 | Host * 30 | #ControlMaster auto 31 | #ControlPath /tmp/ssh-master-%C 32 | #ControlPath /tmp/ssh-%u-%r@%h:%p 33 | #ControlPersist yes 34 | ForwardX11Timeout 52w 35 | XAuthLocation /usr/bin/xauth 36 | SendEnv LANG LC_* 37 | HostKeyAlgorithms ssh-ed25519,ssh-rsa 38 | AddressFamily inet 39 | #UpdateHostKeys ask 40 | -------------------------------------------------------------------------------- /validators_test.go: -------------------------------------------------------------------------------- 1 | package ssh_config 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | var validateTests = []struct { 8 | key string 9 | val string 10 | err string 11 | }{ 12 | {"IdentitiesOnly", "yes", ""}, 13 | {"IdentitiesOnly", "Yes", `ssh_config: value for key "IdentitiesOnly" must be 'yes' or 'no', got "Yes"`}, 14 | {"Port", "22", ``}, 15 | {"Port", "yes", `ssh_config: strconv.ParseUint: parsing "yes": invalid syntax`}, 16 | } 17 | 18 | func TestValidate(t *testing.T) { 19 | for _, tt := range validateTests { 20 | err := validate(tt.key, tt.val) 21 | if tt.err == "" && err != nil { 22 | t.Errorf("validate(%q, %q): got %v, want nil", tt.key, tt.val, err) 23 | } 24 | if tt.err != "" { 25 | if err == nil { 26 | t.Errorf("validate(%q, %q): got nil error, want %v", tt.key, tt.val, tt.err) 27 | } else if err.Error() != tt.err { 28 | t.Errorf("validate(%q, %q): got err %v, want %v", tt.key, tt.val, err, tt.err) 29 | } 30 | } 31 | } 32 | } 33 | 34 | func TestDefault(t *testing.T) { 35 | if v := Default("VisualHostKey"); v != "no" { 36 | t.Errorf("Default(%q): got %v, want 'no'", "VisualHostKey", v) 37 | } 38 | if v := Default("visualhostkey"); v != "no" { 39 | t.Errorf("Default(%q): got %v, want 'no'", "visualhostkey", v) 40 | } 41 | if v := Default("notfound"); v != "" { 42 | t.Errorf("Default(%q): got %v, want ''", "notfound", v) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test 3 | jobs: 4 | lint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Install Go 8 | uses: actions/setup-go@v6 9 | with: 10 | go-version: 1.25.x 11 | - uses: actions/checkout@v5 12 | with: 13 | path: './src/github.com/kevinburke/ssh_config' 14 | # staticcheck needs this for GOPATH 15 | - run: | 16 | echo "GO111MODULE=on" >> $GITHUB_ENV 17 | echo "GOPATH=$GITHUB_WORKSPACE" >> $GITHUB_ENV 18 | echo "PATH=$GITHUB_WORKSPACE/bin:$PATH" >> $GITHUB_ENV 19 | - name: Run tests 20 | run: make lint 21 | working-directory: './src/github.com/kevinburke/ssh_config' 22 | 23 | test: 24 | strategy: 25 | matrix: 26 | go-version: [1.17.x, 1.18.x, 1.19.x, 1.20.x, 1.21.x, 1.22.x, 1.23.x, 1.24.x, 1.25.x] 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Install Go 30 | uses: actions/setup-go@v6 31 | with: 32 | go-version: ${{ matrix.go-version }} 33 | - uses: actions/checkout@v5 34 | with: 35 | path: './src/github.com/kevinburke/ssh_config' 36 | - run: | 37 | echo "GO111MODULE=off" >> $GITHUB_ENV 38 | echo "GOPATH=$GITHUB_WORKSPACE" >> $GITHUB_ENV 39 | echo "PATH=$GITHUB_WORKSPACE/bin:$PATH" >> $GITHUB_ENV 40 | - name: Run tests with race detector on 41 | run: make race-test 42 | working-directory: './src/github.com/kevinburke/ssh_config' 43 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package ssh_config_test 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/kevinburke/ssh_config" 9 | ) 10 | 11 | func ExampleHost_Matches() { 12 | pat, _ := ssh_config.NewPattern("test.*.example.com") 13 | host := &ssh_config.Host{Patterns: []*ssh_config.Pattern{pat}} 14 | fmt.Println(host.Matches("test.stage.example.com")) 15 | fmt.Println(host.Matches("othersubdomain.example.com")) 16 | // Output: 17 | // true 18 | // false 19 | } 20 | 21 | func ExamplePattern() { 22 | pat, _ := ssh_config.NewPattern("*") 23 | host := &ssh_config.Host{Patterns: []*ssh_config.Pattern{pat}} 24 | fmt.Println(host.Matches("test.stage.example.com")) 25 | fmt.Println(host.Matches("othersubdomain.any.any")) 26 | // Output: 27 | // true 28 | // true 29 | } 30 | 31 | func ExampleDecode() { 32 | var config = ` 33 | Host *.example.com 34 | Compression yes 35 | ` 36 | 37 | cfg, _ := ssh_config.Decode(strings.NewReader(config)) 38 | val, _ := cfg.Get("test.example.com", "Compression") 39 | fmt.Println(val) 40 | // Output: yes 41 | } 42 | 43 | func ExampleDefault() { 44 | fmt.Println(ssh_config.Default("Port")) 45 | fmt.Println(ssh_config.Default("UnknownVar")) 46 | // Output: 47 | // 22 48 | // 49 | } 50 | 51 | func ExampleUserSettings_ConfigFinder() { 52 | // This can be used to test SSH config parsing. 53 | u := ssh_config.UserSettings{} 54 | u.ConfigFinder(func() string { 55 | return filepath.Join("testdata", "test_config") 56 | }) 57 | u.Get("example.com", "Host") 58 | } 59 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzDecode/3cfc035ae4867ca13fa7bfaf2793731f05fd4d59c3af8761ea365c7485c752fd: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | []byte("#\t$OpenBSD: ssh_config,v 1.30 2016/02/20 23:06:23 sobrado Exp $\n\n# This is the ssh client system-wide configuration file. See\n# ssh_config(5) for more information. This file provides defaults for\n# users, and the values can be changed in per-user configuration files\n# or on the command line.\n\n# Configuration data is parsed as follows:\n# 1. command line options\n# 2. user-specific file\n# 3. system-wide file\n# Any configuration value is only changed the first time it is set.\n# Thus, host-specific definitions should be at the beginning of the\n# configuration file, and defaults at the end.\n\n# Site-wide defaults for some commonly used options. For a comprehensive\n# list of available options, their meanings and defaults, please see the\n# ssh_config(5) man page.\n\n# Host *\n# ForwardAgent no\n# ForwardX11 no\n# RhostsRSAAuthentication no\n# RSAAuthentication yes\n# PasswordAuthentication yes\n# HostbasedAuthentication no\n# GSSAPIAuthentication no\n# GSSAPIDelegateCredentials no\n# BatchMode no\n# CheckHostIP yes\n# AddressFamily any\n# ConnectTimeout 0\n# StrictHostKeyChecking ask\n# IdentityFile ~/.ssh/identity\n# IdentityFile ~/.ssh/id_rsa\n# IdentityFile ~/.ssh/id_dsa\n# IdentityFile ~/.ssh/id_ecdsa\n# IdentityFile ~/.ssh/id_ed25519\n# Port 22\n# Protocol 2\n# Cipher 3des\n# Ciphers aes128-ctr,aes192-ctr,aes256-ctr,arcfour256,arcfour128,aes128-cbc,3des-cbc\n# MACs hmac-md5,hmac-sha1,umac-64@openssh.com,hmac-ripemd160\n# EscapeChar ~\n# Tunnel no\n# TunnelDevice any:any\n# PermitLocalCommand no\n# VisualHostKey no\n# ProxyCommand ssh -q -W %h:%p gateway.example.com\n# RekeyLimit 1G 1h\n") -------------------------------------------------------------------------------- /testdata/config2: -------------------------------------------------------------------------------- 1 | # $OpenBSD: ssh_config,v 1.30 2016/02/20 23:06:23 sobrado Exp $ 2 | 3 | # This is the ssh client system-wide configuration file. See 4 | # ssh_config(5) for more information. This file provides defaults for 5 | # users, and the values can be changed in per-user configuration files 6 | # or on the command line. 7 | 8 | # Configuration data is parsed as follows: 9 | # 1. command line options 10 | # 2. user-specific file 11 | # 3. system-wide file 12 | # Any configuration value is only changed the first time it is set. 13 | # Thus, host-specific definitions should be at the beginning of the 14 | # configuration file, and defaults at the end. 15 | 16 | # Site-wide defaults for some commonly used options. For a comprehensive 17 | # list of available options, their meanings and defaults, please see the 18 | # ssh_config(5) man page. 19 | 20 | # Host * 21 | # ForwardAgent no 22 | # ForwardX11 no 23 | # RhostsRSAAuthentication no 24 | # RSAAuthentication yes 25 | # PasswordAuthentication yes 26 | # HostbasedAuthentication no 27 | # GSSAPIAuthentication no 28 | # GSSAPIDelegateCredentials no 29 | # BatchMode no 30 | # CheckHostIP yes 31 | # AddressFamily any 32 | # ConnectTimeout 0 33 | # StrictHostKeyChecking ask 34 | # IdentityFile ~/.ssh/identity 35 | # IdentityFile ~/.ssh/id_rsa 36 | # IdentityFile ~/.ssh/id_dsa 37 | # IdentityFile ~/.ssh/id_ecdsa 38 | # IdentityFile ~/.ssh/id_ed25519 39 | # Port 22 40 | # Protocol 2 41 | # Cipher 3des 42 | # Ciphers aes128-ctr,aes192-ctr,aes256-ctr,arcfour256,arcfour128,aes128-cbc,3des-cbc 43 | # MACs hmac-md5,hmac-sha1,umac-64@openssh.com,hmac-ripemd160 44 | # EscapeChar ~ 45 | # Tunnel no 46 | # TunnelDevice any:any 47 | # PermitLocalCommand no 48 | # VisualHostKey no 49 | # ProxyCommand ssh -q -W %h:%p gateway.example.com 50 | # RekeyLimit 1G 1h 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Kevin Burke. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | =================== 25 | 26 | The lexer and parser borrow heavily from github.com/pelletier/go-toml. The 27 | license for that project is copied below. 28 | 29 | The MIT License (MIT) 30 | 31 | Copyright (c) 2013 - 2017 Thomas Pelletier, Eric Anderton 32 | 33 | Permission is hereby granted, free of charge, to any person obtaining a copy 34 | of this software and associated documentation files (the "Software"), to deal 35 | in the Software without restriction, including without limitation the rights 36 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 37 | copies of the Software, and to permit persons to whom the Software is 38 | furnished to do so, subject to the following conditions: 39 | 40 | The above copyright notice and this permission notice shall be included in all 41 | copies or substantial portions of the Software. 42 | 43 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 44 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 45 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 46 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 47 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 48 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 49 | SOFTWARE. 50 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # ssh_config security policy 2 | 3 | ## Supported Versions 4 | 5 | As of September 2025, we're not aware of any security problems with ssh_config, 6 | past or present. That said, we recommend always using the latest version of 7 | ssh_config, and of the Go programming language, to ensure you have the most 8 | recent security fixes. 9 | 10 | ## Reporting a Vulnerability 11 | 12 | We take security vulnerabilities seriously. If you discover a security vulnerability in ssh_config, please report it responsibly by following these steps: 13 | 14 | ### How to Report 15 | 16 | Please follow the instructions outlined here to report a vulnerability 17 | privately: https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability 18 | 19 | If these are insufficient - it is not hard to find Kevin's contact information 20 | on the Internet. 21 | 22 | ### What to Include 23 | 24 | When reporting a vulnerability, please include a clear description of the vulnerability, steps to reproduce the issue, the potential impact, as well as any fixes you might have. 25 | 26 | ### Response Timeline 27 | 28 | I'll try to acknowledge and patch the issue as quickly as possible. 29 | 30 | Security advisories for this project will be published through: 31 | - GitHub Security Advisories on this repository 32 | - an Issue on this repository 33 | - The project's release notes 34 | - Go vulnerability databases 35 | 36 | If you are using `ssh_config` and would like to be on a "pre-release" 37 | distribution list for coordinating releases, please contact Kevin directly. 38 | 39 | ### Security Considerations 40 | 41 | When using ssh_config, please be aware of these security considerations. 42 | 43 | #### File System Access 44 | 45 | This library reads SSH configuration files from the file system. Try to ensure 46 | proper file permissions on SSH config files (typically 600 or 644), and be 47 | cautious when parsing config files from untrusted sources. 48 | 49 | #### Input Validation 50 | 51 | The parser handles user-provided SSH configuration data. While we try our best 52 | to parse the data appropriately, malformed configuration files could potentially 53 | cause issues. Please try to validate and sanitize any configuration data from 54 | external sources. 55 | 56 | #### Dependencies 57 | 58 | This project does not have any third party dependencies. Please try to keep your 59 | Go version up to date. 60 | 61 | ## Acknowledgments 62 | 63 | We appreciate security researchers and users who responsibly disclose vulnerabilities. Contributors who report valid security issues will be acknowledged in our security advisories (unless they prefer to remain anonymous). 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ssh_config 2 | 3 | This is a Go parser for `ssh_config` files. Importantly, this parser attempts 4 | to preserve comments in a given file, so you can manipulate a `ssh_config` file 5 | from a program, if your heart desires. 6 | 7 | It's designed to be used with the excellent 8 | [x/crypto/ssh](https://golang.org/x/crypto/ssh) package, which handles SSH 9 | negotiation but isn't very easy to configure. 10 | 11 | The `ssh_config` `Get()` and `GetStrict()` functions will attempt to read values 12 | from `$HOME/.ssh/config` and fall back to `/etc/ssh/ssh_config`. The first 13 | argument is the host name to match on, and the second argument is the key you 14 | want to retrieve. 15 | 16 | ```go 17 | port := ssh_config.Get("myhost", "Port") 18 | ``` 19 | 20 | Certain directives can occur multiple times for a host (such as `IdentityFile`), 21 | so you should use the `GetAll` or `GetAllStrict` directive to retrieve those 22 | instead. 23 | 24 | ```go 25 | files := ssh_config.GetAll("myhost", "IdentityFile") 26 | ``` 27 | 28 | You can also load a config file and read values from it. 29 | 30 | ```go 31 | var config = ` 32 | Host *.test 33 | Compression yes 34 | ` 35 | 36 | cfg, err := ssh_config.Decode(strings.NewReader(config)) 37 | fmt.Println(cfg.Get("example.test", "Port")) 38 | ``` 39 | 40 | Some SSH arguments have default values - for example, the default value for 41 | `KeyboardAuthentication` is `"yes"`. If you call Get(), and no value for the 42 | given Host/keyword pair exists in the config, we'll return a default for the 43 | keyword if one exists. 44 | 45 | ### Manipulating SSH config files 46 | 47 | Here's how you can manipulate an SSH config file, and then write it back to 48 | disk. 49 | 50 | ```go 51 | f, _ := os.Open(filepath.Join(os.Getenv("HOME"), ".ssh", "config")) 52 | cfg, _ := ssh_config.Decode(f) 53 | for _, host := range cfg.Hosts { 54 | fmt.Println("patterns:", host.Patterns) 55 | for _, node := range host.Nodes { 56 | // Manipulate the nodes as you see fit, or use a type switch to 57 | // distinguish between Empty, KV, and Include nodes. 58 | fmt.Println(node.String()) 59 | } 60 | } 61 | 62 | // Print the config to stdout: 63 | fmt.Println(cfg.String()) 64 | ``` 65 | 66 | ## Spec compliance 67 | 68 | Wherever possible we try to implement the specification as documented in 69 | the `ssh_config` manpage. Unimplemented features should be present in the 70 | [issues][issues] list. 71 | 72 | Notably, the `Match` directive is currently unsupported. 73 | 74 | [issues]: https://github.com/kevinburke/ssh_config/issues 75 | 76 | ## Errata 77 | 78 | This is the second [comment-preserving configuration parser][blog] I've written, after 79 | [an /etc/hosts parser][hostsfile]. Eventually, I will write one for every Linux 80 | file format. 81 | 82 | [blog]: https://kev.inburke.com/kevin/more-comment-preserving-configuration-parsers/ 83 | [hostsfile]: https://github.com/kevinburke/hostsfile 84 | 85 | ## Sponsorships 86 | 87 | Thank you very much to Tailscale and Indeed for sponsoring development of this 88 | library. [Sponsors][sponsors] will get their names featured in the README. 89 | 90 | You can also reach out about a consulting engagement: https://burke.services 91 | 92 | [sponsors]: https://github.com/sponsors/kevinburke 93 | -------------------------------------------------------------------------------- /lexer.go: -------------------------------------------------------------------------------- 1 | package ssh_config 2 | 3 | import ( 4 | "bytes" 5 | ) 6 | 7 | // Define state functions 8 | type sshLexStateFn func() sshLexStateFn 9 | 10 | type sshLexer struct { 11 | inputIdx int 12 | input []rune // Textual source 13 | 14 | buffer []rune // Runes composing the current token 15 | tokens chan token 16 | line int 17 | col int 18 | endbufferLine int 19 | endbufferCol int 20 | } 21 | 22 | func (s *sshLexer) lexComment(previousState sshLexStateFn) sshLexStateFn { 23 | return func() sshLexStateFn { 24 | growingString := "" 25 | for next := s.peek(); next != '\n' && next != eof; next = s.peek() { 26 | if next == '\r' && s.follow("\r\n") { 27 | break 28 | } 29 | growingString += string(next) 30 | s.next() 31 | } 32 | s.emitWithValue(tokenComment, growingString) 33 | s.skip() 34 | return previousState 35 | } 36 | } 37 | 38 | // lex the space after an equals sign in a function 39 | func (s *sshLexer) lexRspace() sshLexStateFn { 40 | for { 41 | next := s.peek() 42 | if !isSpace(next) { 43 | break 44 | } 45 | s.skip() 46 | } 47 | return s.lexRvalue 48 | } 49 | 50 | func (s *sshLexer) lexEquals() sshLexStateFn { 51 | for { 52 | next := s.peek() 53 | if next == '=' { 54 | s.emit(tokenEquals) 55 | s.skip() 56 | return s.lexRspace 57 | } 58 | // TODO error handling here; newline eof etc. 59 | if !isSpace(next) { 60 | break 61 | } 62 | s.skip() 63 | } 64 | return s.lexRvalue 65 | } 66 | 67 | func (s *sshLexer) lexKey() sshLexStateFn { 68 | growingString := "" 69 | 70 | for r := s.peek(); isKeyChar(r); r = s.peek() { 71 | // simplified a lot here 72 | if isSpace(r) || r == '=' { 73 | s.emitWithValue(tokenKey, growingString) 74 | s.skip() 75 | return s.lexEquals 76 | } 77 | growingString += string(r) 78 | s.next() 79 | } 80 | s.emitWithValue(tokenKey, growingString) 81 | return s.lexEquals 82 | } 83 | 84 | func (s *sshLexer) lexRvalue() sshLexStateFn { 85 | growingString := "" 86 | for { 87 | next := s.peek() 88 | switch next { 89 | case '\r': 90 | if s.follow("\r\n") { 91 | s.emitWithValue(tokenString, growingString) 92 | s.skip() 93 | return s.lexVoid 94 | } 95 | case '\n': 96 | s.emitWithValue(tokenString, growingString) 97 | s.skip() 98 | return s.lexVoid 99 | case '#': 100 | s.emitWithValue(tokenString, growingString) 101 | s.skip() 102 | return s.lexComment(s.lexVoid) 103 | case eof: 104 | s.next() 105 | } 106 | if next == eof { 107 | break 108 | } 109 | growingString += string(next) 110 | s.next() 111 | } 112 | s.emit(tokenEOF) 113 | return nil 114 | } 115 | 116 | func (s *sshLexer) read() rune { 117 | r := s.peek() 118 | if r == '\n' { 119 | s.endbufferLine++ 120 | s.endbufferCol = 1 121 | } else { 122 | s.endbufferCol++ 123 | } 124 | s.inputIdx++ 125 | return r 126 | } 127 | 128 | func (s *sshLexer) next() rune { 129 | r := s.read() 130 | 131 | if r != eof { 132 | s.buffer = append(s.buffer, r) 133 | } 134 | return r 135 | } 136 | 137 | func (s *sshLexer) lexVoid() sshLexStateFn { 138 | for { 139 | next := s.peek() 140 | switch next { 141 | case '#': 142 | s.skip() 143 | return s.lexComment(s.lexVoid) 144 | case '\r': 145 | fallthrough 146 | case '\n': 147 | s.emit(tokenEmptyLine) 148 | s.skip() 149 | continue 150 | } 151 | 152 | if isSpace(next) { 153 | s.skip() 154 | } 155 | 156 | if isKeyStartChar(next) { 157 | return s.lexKey 158 | } 159 | 160 | // removed IsKeyStartChar and lexKey. probably will need to readd 161 | 162 | if next == eof { 163 | s.next() 164 | break 165 | } 166 | } 167 | 168 | s.emit(tokenEOF) 169 | return nil 170 | } 171 | 172 | func (s *sshLexer) ignore() { 173 | s.buffer = make([]rune, 0) 174 | s.line = s.endbufferLine 175 | s.col = s.endbufferCol 176 | } 177 | 178 | func (s *sshLexer) skip() { 179 | s.next() 180 | s.ignore() 181 | } 182 | 183 | func (s *sshLexer) emit(t tokenType) { 184 | s.emitWithValue(t, string(s.buffer)) 185 | } 186 | 187 | func (s *sshLexer) emitWithValue(t tokenType, value string) { 188 | tok := token{ 189 | Position: Position{s.line, s.col}, 190 | typ: t, 191 | val: value, 192 | } 193 | s.tokens <- tok 194 | s.ignore() 195 | } 196 | 197 | func (s *sshLexer) peek() rune { 198 | if s.inputIdx >= len(s.input) { 199 | return eof 200 | } 201 | 202 | r := s.input[s.inputIdx] 203 | return r 204 | } 205 | 206 | func (s *sshLexer) follow(next string) bool { 207 | inputIdx := s.inputIdx 208 | for _, expectedRune := range next { 209 | if inputIdx >= len(s.input) { 210 | return false 211 | } 212 | r := s.input[inputIdx] 213 | inputIdx++ 214 | if expectedRune != r { 215 | return false 216 | } 217 | } 218 | return true 219 | } 220 | 221 | func (s *sshLexer) run() { 222 | for state := s.lexVoid; state != nil; { 223 | state = state() 224 | } 225 | close(s.tokens) 226 | } 227 | 228 | func lexSSH(input []byte) chan token { 229 | runes := bytes.Runes(input) 230 | l := &sshLexer{ 231 | input: runes, 232 | tokens: make(chan token), 233 | line: 1, 234 | col: 1, 235 | endbufferLine: 1, 236 | endbufferCol: 1, 237 | } 238 | go l.run() 239 | return l.tokens 240 | } 241 | -------------------------------------------------------------------------------- /parser.go: -------------------------------------------------------------------------------- 1 | package ssh_config 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "unicode" 7 | ) 8 | 9 | type sshParser struct { 10 | flow chan token 11 | config *Config 12 | tokensBuffer []token 13 | currentTable []string 14 | seenTableKeys []string 15 | // /etc/ssh parser or local parser - used to find the default for relative 16 | // filepaths in the Include directive 17 | system bool 18 | depth uint8 19 | } 20 | 21 | type sshParserStateFn func() sshParserStateFn 22 | 23 | // Formats and panics an error message based on a token 24 | func (p *sshParser) raiseErrorf(tok *token, msg string) { 25 | // TODO this format is ugly 26 | panic(tok.Position.String() + ": " + msg) 27 | } 28 | 29 | func (p *sshParser) raiseError(tok *token, err error) { 30 | if err == ErrDepthExceeded { 31 | panic(err) 32 | } 33 | // TODO this format is ugly 34 | panic(tok.Position.String() + ": " + err.Error()) 35 | } 36 | 37 | func (p *sshParser) run() { 38 | for state := p.parseStart; state != nil; { 39 | state = state() 40 | } 41 | } 42 | 43 | func (p *sshParser) peek() *token { 44 | if len(p.tokensBuffer) != 0 { 45 | return &(p.tokensBuffer[0]) 46 | } 47 | 48 | tok, ok := <-p.flow 49 | if !ok { 50 | return nil 51 | } 52 | p.tokensBuffer = append(p.tokensBuffer, tok) 53 | return &tok 54 | } 55 | 56 | func (p *sshParser) getToken() *token { 57 | if len(p.tokensBuffer) != 0 { 58 | tok := p.tokensBuffer[0] 59 | p.tokensBuffer = p.tokensBuffer[1:] 60 | return &tok 61 | } 62 | tok, ok := <-p.flow 63 | if !ok { 64 | return nil 65 | } 66 | return &tok 67 | } 68 | 69 | func (p *sshParser) parseStart() sshParserStateFn { 70 | tok := p.peek() 71 | 72 | // end of stream, parsing is finished 73 | if tok == nil { 74 | return nil 75 | } 76 | 77 | switch tok.typ { 78 | case tokenComment, tokenEmptyLine: 79 | return p.parseComment 80 | case tokenKey: 81 | return p.parseKV 82 | case tokenEOF: 83 | return nil 84 | default: 85 | p.raiseErrorf(tok, fmt.Sprintf("unexpected token %q\n", tok)) 86 | } 87 | return nil 88 | } 89 | 90 | func (p *sshParser) parseKV() sshParserStateFn { 91 | key := p.getToken() 92 | hasEquals := false 93 | val := p.getToken() 94 | if val.typ == tokenEquals { 95 | hasEquals = true 96 | val = p.getToken() 97 | } 98 | comment := "" 99 | tok := p.peek() 100 | if tok == nil { 101 | tok = &token{typ: tokenEOF} 102 | } 103 | if tok.typ == tokenComment && tok.Position.Line == val.Position.Line { 104 | tok = p.getToken() 105 | comment = tok.val 106 | } 107 | if strings.ToLower(key.val) == "match" { 108 | // https://github.com/kevinburke/ssh_config/issues/6 109 | p.raiseErrorf(val, "ssh_config: Match directive parsing is unsupported") 110 | return nil 111 | } 112 | if strings.ToLower(key.val) == "host" { 113 | strPatterns := strings.Split(val.val, " ") 114 | patterns := make([]*Pattern, 0) 115 | for i := range strPatterns { 116 | if strPatterns[i] == "" { 117 | continue 118 | } 119 | pat, err := NewPattern(strPatterns[i]) 120 | if err != nil { 121 | p.raiseErrorf(val, fmt.Sprintf("Invalid host pattern: %v", err)) 122 | return nil 123 | } 124 | patterns = append(patterns, pat) 125 | } 126 | // val.val at this point could be e.g. "example.com " 127 | hostval := strings.TrimRightFunc(val.val, unicode.IsSpace) 128 | spaceBeforeComment := val.val[len(hostval):] 129 | val.val = hostval 130 | p.config.Hosts = append(p.config.Hosts, &Host{ 131 | Patterns: patterns, 132 | Nodes: make([]Node, 0), 133 | EOLComment: comment, 134 | spaceBeforeComment: spaceBeforeComment, 135 | hasEquals: hasEquals, 136 | }) 137 | return p.parseStart 138 | } 139 | lastHost := p.config.Hosts[len(p.config.Hosts)-1] 140 | if strings.ToLower(key.val) == "include" { 141 | inc, err := NewInclude(strings.Split(val.val, " "), hasEquals, key.Position, comment, p.system, p.depth+1) 142 | if err == ErrDepthExceeded { 143 | p.raiseError(val, err) 144 | return nil 145 | } 146 | if err != nil { 147 | p.raiseErrorf(val, fmt.Sprintf("Error parsing Include directive: %v", err)) 148 | return nil 149 | } 150 | lastHost.Nodes = append(lastHost.Nodes, inc) 151 | return p.parseStart 152 | } 153 | shortval := strings.TrimRightFunc(val.val, unicode.IsSpace) 154 | spaceAfterValue := val.val[len(shortval):] 155 | kv := &KV{ 156 | Key: key.val, 157 | Value: shortval, 158 | spaceAfterValue: spaceAfterValue, 159 | Comment: comment, 160 | hasEquals: hasEquals, 161 | leadingSpace: key.Position.Col - 1, 162 | position: key.Position, 163 | } 164 | lastHost.Nodes = append(lastHost.Nodes, kv) 165 | return p.parseStart 166 | } 167 | 168 | func (p *sshParser) parseComment() sshParserStateFn { 169 | comment := p.getToken() 170 | lastHost := p.config.Hosts[len(p.config.Hosts)-1] 171 | lastHost.Nodes = append(lastHost.Nodes, &Empty{ 172 | Comment: comment.val, 173 | // account for the "#" as well 174 | leadingSpace: comment.Position.Col - 2, 175 | position: comment.Position, 176 | }) 177 | return p.parseStart 178 | } 179 | 180 | func parseSSH(flow chan token, system bool, depth uint8) *Config { 181 | // Ensure we consume tokens to completion even if parser exits early 182 | defer func() { 183 | for range flow { 184 | } 185 | }() 186 | 187 | result := newConfig() 188 | result.position = Position{1, 1} 189 | parser := &sshParser{ 190 | flow: flow, 191 | config: result, 192 | tokensBuffer: make([]token, 0), 193 | currentTable: make([]string, 0), 194 | seenTableKeys: make([]string, 0), 195 | system: system, 196 | depth: depth, 197 | } 198 | parser.run() 199 | return result 200 | } 201 | -------------------------------------------------------------------------------- /validators.go: -------------------------------------------------------------------------------- 1 | package ssh_config 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | // Default returns the default value for the given keyword, for example "22" if 10 | // the keyword is "Port". Default returns the empty string if the keyword has no 11 | // default, or if the keyword is unknown. Keyword matching is case-insensitive. 12 | // 13 | // Default values are provided by OpenSSH_7.4p1 on a Mac. 14 | func Default(keyword string) string { 15 | return defaults[strings.ToLower(keyword)] 16 | } 17 | 18 | // Arguments where the value must be "yes" or "no" and *only* yes or no. 19 | var yesnos = map[string]bool{ 20 | strings.ToLower("BatchMode"): true, 21 | strings.ToLower("CanonicalizeFallbackLocal"): true, 22 | strings.ToLower("ChallengeResponseAuthentication"): true, 23 | strings.ToLower("CheckHostIP"): true, 24 | strings.ToLower("ClearAllForwardings"): true, 25 | strings.ToLower("Compression"): true, 26 | strings.ToLower("EnableSSHKeysign"): true, 27 | strings.ToLower("ExitOnForwardFailure"): true, 28 | strings.ToLower("ForwardAgent"): true, 29 | strings.ToLower("ForwardX11"): true, 30 | strings.ToLower("ForwardX11Trusted"): true, 31 | strings.ToLower("GatewayPorts"): true, 32 | strings.ToLower("GSSAPIAuthentication"): true, 33 | strings.ToLower("GSSAPIDelegateCredentials"): true, 34 | strings.ToLower("HostbasedAuthentication"): true, 35 | strings.ToLower("IdentitiesOnly"): true, 36 | strings.ToLower("KbdInteractiveAuthentication"): true, 37 | strings.ToLower("NoHostAuthenticationForLocalhost"): true, 38 | strings.ToLower("PasswordAuthentication"): true, 39 | strings.ToLower("PermitLocalCommand"): true, 40 | strings.ToLower("PubkeyAuthentication"): true, 41 | strings.ToLower("RhostsRSAAuthentication"): true, 42 | strings.ToLower("RSAAuthentication"): true, 43 | strings.ToLower("StreamLocalBindUnlink"): true, 44 | strings.ToLower("TCPKeepAlive"): true, 45 | strings.ToLower("UseKeychain"): true, 46 | strings.ToLower("UsePrivilegedPort"): true, 47 | strings.ToLower("VisualHostKey"): true, 48 | } 49 | 50 | var uints = map[string]bool{ 51 | strings.ToLower("CanonicalizeMaxDots"): true, 52 | strings.ToLower("CompressionLevel"): true, // 1 to 9 53 | strings.ToLower("ConnectionAttempts"): true, 54 | strings.ToLower("ConnectTimeout"): true, 55 | strings.ToLower("NumberOfPasswordPrompts"): true, 56 | strings.ToLower("Port"): true, 57 | strings.ToLower("ServerAliveCountMax"): true, 58 | strings.ToLower("ServerAliveInterval"): true, 59 | } 60 | 61 | func mustBeYesOrNo(lkey string) bool { 62 | return yesnos[lkey] 63 | } 64 | 65 | func mustBeUint(lkey string) bool { 66 | return uints[lkey] 67 | } 68 | 69 | func validate(key, val string) error { 70 | lkey := strings.ToLower(key) 71 | if mustBeYesOrNo(lkey) && (val != "yes" && val != "no") { 72 | return fmt.Errorf("ssh_config: value for key %q must be 'yes' or 'no', got %q", key, val) 73 | } 74 | if mustBeUint(lkey) { 75 | _, err := strconv.ParseUint(val, 10, 64) 76 | if err != nil { 77 | return fmt.Errorf("ssh_config: %v", err) 78 | } 79 | } 80 | return nil 81 | } 82 | 83 | var defaults = map[string]string{ 84 | strings.ToLower("AddKeysToAgent"): "no", 85 | strings.ToLower("AddressFamily"): "any", 86 | strings.ToLower("BatchMode"): "no", 87 | strings.ToLower("CanonicalizeFallbackLocal"): "yes", 88 | strings.ToLower("CanonicalizeHostname"): "no", 89 | strings.ToLower("CanonicalizeMaxDots"): "1", 90 | strings.ToLower("ChallengeResponseAuthentication"): "yes", 91 | strings.ToLower("CheckHostIP"): "yes", 92 | // TODO is this still the correct cipher 93 | strings.ToLower("Cipher"): "3des", 94 | strings.ToLower("Ciphers"): "chacha20-poly1305@openssh.com,aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com,aes128-cbc,aes192-cbc,aes256-cbc", 95 | strings.ToLower("ClearAllForwardings"): "no", 96 | strings.ToLower("Compression"): "no", 97 | strings.ToLower("CompressionLevel"): "6", 98 | strings.ToLower("ConnectionAttempts"): "1", 99 | strings.ToLower("ControlMaster"): "no", 100 | strings.ToLower("EnableSSHKeysign"): "no", 101 | strings.ToLower("EscapeChar"): "~", 102 | strings.ToLower("ExitOnForwardFailure"): "no", 103 | strings.ToLower("FingerprintHash"): "sha256", 104 | strings.ToLower("ForwardAgent"): "no", 105 | strings.ToLower("ForwardX11"): "no", 106 | strings.ToLower("ForwardX11Timeout"): "20m", 107 | strings.ToLower("ForwardX11Trusted"): "no", 108 | strings.ToLower("GatewayPorts"): "no", 109 | strings.ToLower("GlobalKnownHostsFile"): "/etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2", 110 | strings.ToLower("GSSAPIAuthentication"): "no", 111 | strings.ToLower("GSSAPIDelegateCredentials"): "no", 112 | strings.ToLower("HashKnownHosts"): "no", 113 | strings.ToLower("HostbasedAuthentication"): "no", 114 | 115 | strings.ToLower("HostbasedKeyTypes"): "ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,ssh-ed25519,ssh-rsa", 116 | strings.ToLower("HostKeyAlgorithms"): "ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,ssh-ed25519,ssh-rsa", 117 | // HostName has a dynamic default (the value passed at the command line). 118 | 119 | strings.ToLower("IdentitiesOnly"): "no", 120 | strings.ToLower("IdentityFile"): "~/.ssh/identity", 121 | 122 | // IPQoS has a dynamic default based on interactive or non-interactive 123 | // sessions. 124 | 125 | strings.ToLower("KbdInteractiveAuthentication"): "yes", 126 | 127 | strings.ToLower("KexAlgorithms"): "curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group-exchange-sha1,diffie-hellman-group14-sha1", 128 | strings.ToLower("LogLevel"): "INFO", 129 | strings.ToLower("MACs"): "umac-64-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha1-etm@openssh.com,umac-64@openssh.com,umac-128@openssh.com,hmac-sha2-256,hmac-sha2-512,hmac-sha1", 130 | 131 | strings.ToLower("NoHostAuthenticationForLocalhost"): "no", 132 | strings.ToLower("NumberOfPasswordPrompts"): "3", 133 | strings.ToLower("PasswordAuthentication"): "yes", 134 | strings.ToLower("PermitLocalCommand"): "no", 135 | strings.ToLower("Port"): "22", 136 | 137 | strings.ToLower("PreferredAuthentications"): "gssapi-with-mic,hostbased,publickey,keyboard-interactive,password", 138 | strings.ToLower("Protocol"): "2", 139 | strings.ToLower("ProxyUseFdpass"): "no", 140 | strings.ToLower("PubkeyAcceptedKeyTypes"): "ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,ssh-ed25519,ssh-rsa", 141 | strings.ToLower("PubkeyAuthentication"): "yes", 142 | strings.ToLower("RekeyLimit"): "default none", 143 | strings.ToLower("RhostsRSAAuthentication"): "no", 144 | strings.ToLower("RSAAuthentication"): "yes", 145 | 146 | strings.ToLower("ServerAliveCountMax"): "3", 147 | strings.ToLower("ServerAliveInterval"): "0", 148 | strings.ToLower("StreamLocalBindMask"): "0177", 149 | strings.ToLower("StreamLocalBindUnlink"): "no", 150 | strings.ToLower("StrictHostKeyChecking"): "ask", 151 | strings.ToLower("TCPKeepAlive"): "yes", 152 | strings.ToLower("Tunnel"): "no", 153 | strings.ToLower("TunnelDevice"): "any:any", 154 | strings.ToLower("UpdateHostKeys"): "no", 155 | strings.ToLower("UseKeychain"): "no", 156 | strings.ToLower("UsePrivilegedPort"): "no", 157 | 158 | strings.ToLower("UserKnownHostsFile"): "~/.ssh/known_hosts ~/.ssh/known_hosts2", 159 | strings.ToLower("VerifyHostKeyDNS"): "no", 160 | strings.ToLower("VisualHostKey"): "no", 161 | strings.ToLower("XAuthLocation"): "/usr/X11R6/bin/xauth", 162 | } 163 | 164 | // these identities are used for SSH protocol 2 165 | var defaultProtocol2Identities = []string{ 166 | "~/.ssh/id_dsa", 167 | "~/.ssh/id_ecdsa", 168 | "~/.ssh/id_ed25519", 169 | "~/.ssh/id_rsa", 170 | } 171 | 172 | // these directives support multiple items that can be collected 173 | // across multiple files 174 | var pluralDirectives = map[string]bool{ 175 | "CertificateFile": true, 176 | "IdentityFile": true, 177 | "DynamicForward": true, 178 | "RemoteForward": true, 179 | "SendEnv": true, 180 | "SetEnv": true, 181 | } 182 | 183 | // SupportsMultiple reports whether a directive can be specified multiple times. 184 | func SupportsMultiple(key string) bool { 185 | return pluralDirectives[strings.ToLower(key)] 186 | } 187 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package ssh_config 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func loadFile(t *testing.T, filename string) []byte { 13 | t.Helper() 14 | data, err := os.ReadFile(filename) 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | return data 19 | } 20 | 21 | var files = []string{ 22 | "testdata/config1", 23 | "testdata/config2", 24 | "testdata/eol-comments", 25 | } 26 | 27 | func TestDecode(t *testing.T) { 28 | for _, filename := range files { 29 | data := loadFile(t, filename) 30 | cfg, err := Decode(bytes.NewReader(data)) 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | out := cfg.String() 35 | if out != string(data) { 36 | t.Errorf("%s out != data: got:\n%s\nwant:\n%s\n", filename, out, string(data)) 37 | } 38 | } 39 | } 40 | 41 | func testConfigFinder(filename string) func() string { 42 | return func() string { return filename } 43 | } 44 | 45 | func nullConfigFinder() string { 46 | return "" 47 | } 48 | 49 | func TestGet(t *testing.T) { 50 | us := &UserSettings{ 51 | userConfigFinder: testConfigFinder("testdata/config1"), 52 | } 53 | 54 | val := us.Get("wap", "User") 55 | if val != "root" { 56 | t.Errorf("expected to find User root, got %q", val) 57 | } 58 | } 59 | 60 | func TestGetWithDefault(t *testing.T) { 61 | us := &UserSettings{ 62 | userConfigFinder: testConfigFinder("testdata/config1"), 63 | } 64 | 65 | val, err := us.GetStrict("wap", "PasswordAuthentication") 66 | if err != nil { 67 | t.Fatalf("expected nil err, got %v", err) 68 | } 69 | if val != "yes" { 70 | t.Errorf("expected to get PasswordAuthentication yes, got %q", val) 71 | } 72 | } 73 | 74 | func TestGetAllWithDefault(t *testing.T) { 75 | us := &UserSettings{ 76 | userConfigFinder: testConfigFinder("testdata/config1"), 77 | } 78 | 79 | val, err := us.GetAllStrict("wap", "PasswordAuthentication") 80 | if err != nil { 81 | t.Fatalf("expected nil err, got %v", err) 82 | } 83 | if len(val) != 1 || val[0] != "yes" { 84 | t.Errorf("expected to get PasswordAuthentication yes, got %q", val) 85 | } 86 | } 87 | 88 | func TestGetIdentities(t *testing.T) { 89 | us := &UserSettings{ 90 | userConfigFinder: testConfigFinder("testdata/identities"), 91 | } 92 | 93 | val, err := us.GetAllStrict("hasidentity", "IdentityFile") 94 | if err != nil { 95 | t.Errorf("expected nil err, got %v", err) 96 | } 97 | if len(val) != 1 || val[0] != "file1" { 98 | t.Errorf(`expected ["file1"], got %v`, val) 99 | } 100 | 101 | val, err = us.GetAllStrict("has2identity", "IdentityFile") 102 | if err != nil { 103 | t.Errorf("expected nil err, got %v", err) 104 | } 105 | if len(val) != 2 || val[0] != "f1" || val[1] != "f2" { 106 | t.Errorf(`expected [\"f1\", \"f2\"], got %v`, val) 107 | } 108 | 109 | val, err = us.GetAllStrict("randomhost", "IdentityFile") 110 | if err != nil { 111 | t.Errorf("expected nil err, got %v", err) 112 | } 113 | if len(val) != len(defaultProtocol2Identities) { 114 | // TODO: return the right values here. 115 | log.Printf("expected defaults, got %v", val) 116 | } else { 117 | for i, v := range defaultProtocol2Identities { 118 | if val[i] != v { 119 | t.Errorf("invalid %d in val, expected %s got %s", i, v, val[i]) 120 | } 121 | } 122 | } 123 | 124 | val, err = us.GetAllStrict("protocol1", "IdentityFile") 125 | if err != nil { 126 | t.Errorf("expected nil err, got %v", err) 127 | } 128 | if len(val) != 1 || val[0] != "~/.ssh/identity" { 129 | t.Errorf("expected [\"~/.ssh/identity\"], got %v", val) 130 | } 131 | } 132 | 133 | func TestGetInvalidPort(t *testing.T) { 134 | us := &UserSettings{ 135 | userConfigFinder: testConfigFinder("testdata/invalid-port"), 136 | } 137 | 138 | val, err := us.GetStrict("test.test", "Port") 139 | if err == nil { 140 | t.Fatalf("expected non-nil err, got nil") 141 | } 142 | if val != "" { 143 | t.Errorf("expected to get '' for val, got %q", val) 144 | } 145 | if err.Error() != `ssh_config: strconv.ParseUint: parsing "notanumber": invalid syntax` { 146 | t.Errorf("wrong error: got %v", err) 147 | } 148 | } 149 | 150 | func TestGetNotFoundNoDefault(t *testing.T) { 151 | us := &UserSettings{ 152 | userConfigFinder: testConfigFinder("testdata/config1"), 153 | } 154 | 155 | val, err := us.GetStrict("wap", "CanonicalDomains") 156 | if err != nil { 157 | t.Fatalf("expected nil err, got %v", err) 158 | } 159 | if val != "" { 160 | t.Errorf("expected to get CanonicalDomains '', got %q", val) 161 | } 162 | } 163 | 164 | func TestGetAllNotFoundNoDefault(t *testing.T) { 165 | us := &UserSettings{ 166 | userConfigFinder: testConfigFinder("testdata/config1"), 167 | } 168 | 169 | val, err := us.GetAllStrict("wap", "CanonicalDomains") 170 | if err != nil { 171 | t.Fatalf("expected nil err, got %v", err) 172 | } 173 | if len(val) != 0 { 174 | t.Errorf("expected to get CanonicalDomains '', got %q", val) 175 | } 176 | } 177 | 178 | func TestGetWildcard(t *testing.T) { 179 | us := &UserSettings{ 180 | userConfigFinder: testConfigFinder("testdata/config3"), 181 | } 182 | 183 | val := us.Get("bastion.stage.i.us.example.net", "Port") 184 | if val != "22" { 185 | t.Errorf("expected to find Port 22, got %q", val) 186 | } 187 | 188 | val = us.Get("bastion.net", "Port") 189 | if val != "25" { 190 | t.Errorf("expected to find Port 24, got %q", val) 191 | } 192 | 193 | val = us.Get("10.2.3.4", "Port") 194 | if val != "23" { 195 | t.Errorf("expected to find Port 23, got %q", val) 196 | } 197 | val = us.Get("101.2.3.4", "Port") 198 | if val != "25" { 199 | t.Errorf("expected to find Port 24, got %q", val) 200 | } 201 | val = us.Get("20.20.20.4", "Port") 202 | if val != "24" { 203 | t.Errorf("expected to find Port 24, got %q", val) 204 | } 205 | val = us.Get("20.20.20.20", "Port") 206 | if val != "25" { 207 | t.Errorf("expected to find Port 25, got %q", val) 208 | } 209 | } 210 | 211 | func TestGetExtraSpaces(t *testing.T) { 212 | us := &UserSettings{ 213 | userConfigFinder: testConfigFinder("testdata/extraspace"), 214 | } 215 | 216 | val := us.Get("test.test", "Port") 217 | if val != "1234" { 218 | t.Errorf("expected to find Port 1234, got %q", val) 219 | } 220 | } 221 | 222 | func TestGetCaseInsensitive(t *testing.T) { 223 | us := &UserSettings{ 224 | userConfigFinder: testConfigFinder("testdata/config1"), 225 | } 226 | 227 | val := us.Get("wap", "uSER") 228 | if val != "root" { 229 | t.Errorf("expected to find User root, got %q", val) 230 | } 231 | } 232 | 233 | func TestGetEmpty(t *testing.T) { 234 | us := &UserSettings{ 235 | userConfigFinder: nullConfigFinder, 236 | systemConfigFinder: nullConfigFinder, 237 | } 238 | val, err := us.GetStrict("wap", "User") 239 | if err != nil { 240 | t.Errorf("expected nil error, got %v", err) 241 | } 242 | if val != "" { 243 | t.Errorf("expected to get empty string, got %q", val) 244 | } 245 | } 246 | 247 | func TestGetEqsign(t *testing.T) { 248 | us := &UserSettings{ 249 | userConfigFinder: testConfigFinder("testdata/eqsign"), 250 | } 251 | 252 | val := us.Get("test.test", "Port") 253 | if val != "1234" { 254 | t.Errorf("expected to find Port 1234, got %q", val) 255 | } 256 | val = us.Get("test.test", "Port2") 257 | if val != "5678" { 258 | t.Errorf("expected to find Port2 5678, got %q", val) 259 | } 260 | } 261 | 262 | var includeFile = []byte(` 263 | # This host should not exist, so we can use it for test purposes / it won't 264 | # interfere with any other configurations. 265 | Host kevinburke.ssh_config.test.example.com 266 | Port 4567 267 | `) 268 | 269 | func TestInclude(t *testing.T) { 270 | if testing.Short() { 271 | t.Skip("skipping fs write in short mode") 272 | } 273 | testPath := filepath.Join(homedir(), ".ssh", "kevinburke-ssh-config-test-file") 274 | err := os.WriteFile(testPath, includeFile, 0644) 275 | if err != nil { 276 | t.Skipf("couldn't write SSH config file: %v", err.Error()) 277 | } 278 | defer os.Remove(testPath) 279 | us := &UserSettings{ 280 | userConfigFinder: testConfigFinder("testdata/include"), 281 | } 282 | val := us.Get("kevinburke.ssh_config.test.example.com", "Port") 283 | if val != "4567" { 284 | t.Errorf("expected to find Port=4567 in included file, got %q", val) 285 | } 286 | } 287 | 288 | func TestIncludeSystem(t *testing.T) { 289 | if testing.Short() { 290 | t.Skip("skipping fs write in short mode") 291 | } 292 | testPath := filepath.Join("/", "etc", "ssh", "kevinburke-ssh-config-test-file") 293 | err := os.WriteFile(testPath, includeFile, 0644) 294 | if err != nil { 295 | t.Skipf("couldn't write SSH config file: %v", err.Error()) 296 | } 297 | defer os.Remove(testPath) 298 | us := &UserSettings{ 299 | systemConfigFinder: testConfigFinder("testdata/include"), 300 | } 301 | val := us.Get("kevinburke.ssh_config.test.example.com", "Port") 302 | if val != "4567" { 303 | t.Errorf("expected to find Port=4567 in included file, got %q", val) 304 | } 305 | } 306 | 307 | var recursiveIncludeFile = []byte(` 308 | Host kevinburke.ssh_config.test.example.com 309 | Include kevinburke-ssh-config-recursive-include 310 | `) 311 | 312 | func TestIncludeRecursive(t *testing.T) { 313 | if testing.Short() { 314 | t.Skip("skipping fs write in short mode") 315 | } 316 | testPath := filepath.Join(homedir(), ".ssh", "kevinburke-ssh-config-recursive-include") 317 | err := os.WriteFile(testPath, recursiveIncludeFile, 0644) 318 | if err != nil { 319 | t.Skipf("couldn't write SSH config file: %v", err.Error()) 320 | } 321 | defer os.Remove(testPath) 322 | us := &UserSettings{ 323 | userConfigFinder: testConfigFinder("testdata/include-recursive"), 324 | } 325 | val, err := us.GetStrict("kevinburke.ssh_config.test.example.com", "Port") 326 | if err != ErrDepthExceeded { 327 | t.Errorf("Recursive include: expected ErrDepthExceeded, got %v", err) 328 | } 329 | if val != "" { 330 | t.Errorf("non-empty string value %s", val) 331 | } 332 | } 333 | 334 | func TestIncludeString(t *testing.T) { 335 | if testing.Short() { 336 | t.Skip("skipping fs write in short mode") 337 | } 338 | data, err := os.ReadFile("testdata/include") 339 | if err != nil { 340 | log.Fatal(err) 341 | } 342 | c, err := Decode(bytes.NewReader(data)) 343 | if err != nil { 344 | t.Fatal(err) 345 | } 346 | s := c.String() 347 | if s != string(data) { 348 | t.Errorf("mismatch: got %q\nwant %q", s, string(data)) 349 | } 350 | } 351 | 352 | var matchTests = []struct { 353 | in []string 354 | alias string 355 | want bool 356 | }{ 357 | {[]string{"*"}, "any.test", true}, 358 | {[]string{"a", "b", "*", "c"}, "any.test", true}, 359 | {[]string{"a", "b", "c"}, "any.test", false}, 360 | {[]string{"any.test"}, "any1test", false}, 361 | {[]string{"192.168.0.?"}, "192.168.0.1", true}, 362 | {[]string{"192.168.0.?"}, "192.168.0.10", false}, 363 | {[]string{"*.co.uk"}, "bbc.co.uk", true}, 364 | {[]string{"*.co.uk"}, "subdomain.bbc.co.uk", true}, 365 | {[]string{"*.*.co.uk"}, "bbc.co.uk", false}, 366 | {[]string{"*.*.co.uk"}, "subdomain.bbc.co.uk", true}, 367 | {[]string{"*.example.com", "!*.dialup.example.com", "foo.dialup.example.com"}, "foo.dialup.example.com", false}, 368 | {[]string{"test.*", "!test.host"}, "test.host", false}, 369 | } 370 | 371 | func TestMatches(t *testing.T) { 372 | for _, tt := range matchTests { 373 | patterns := make([]*Pattern, len(tt.in)) 374 | for i := range tt.in { 375 | pat, err := NewPattern(tt.in[i]) 376 | if err != nil { 377 | t.Fatalf("error compiling pattern %s: %v", tt.in[i], err) 378 | } 379 | patterns[i] = pat 380 | } 381 | host := &Host{ 382 | Patterns: patterns, 383 | } 384 | got := host.Matches(tt.alias) 385 | if got != tt.want { 386 | t.Errorf("host(%q).Matches(%q): got %v, want %v", tt.in, tt.alias, got, tt.want) 387 | } 388 | } 389 | } 390 | 391 | func TestMatchUnsupported(t *testing.T) { 392 | us := &UserSettings{ 393 | userConfigFinder: testConfigFinder("testdata/match-directive"), 394 | } 395 | 396 | _, err := us.GetStrict("test.test", "Port") 397 | if err == nil { 398 | t.Fatal("expected Match directive to error, didn't") 399 | } 400 | if !strings.Contains(err.Error(), "ssh_config: Match directive parsing is unsupported") { 401 | t.Errorf("wrong error: %v", err) 402 | } 403 | } 404 | 405 | func TestIndexInRange(t *testing.T) { 406 | us := &UserSettings{ 407 | userConfigFinder: testConfigFinder("testdata/config4"), 408 | } 409 | 410 | user, err := us.GetStrict("wap", "User") 411 | if err != nil { 412 | t.Fatal(err) 413 | } 414 | if user != "root" { 415 | t.Errorf("expected User to be %q, got %q", "root", user) 416 | } 417 | } 418 | 419 | func TestDosLinesEndingsDecode(t *testing.T) { 420 | us := &UserSettings{ 421 | userConfigFinder: testConfigFinder("testdata/dos-lines"), 422 | } 423 | 424 | user, err := us.GetStrict("wap", "User") 425 | if err != nil { 426 | t.Fatal(err) 427 | } 428 | 429 | if user != "root" { 430 | t.Errorf("expected User to be %q, got %q", "root", user) 431 | } 432 | 433 | host, err := us.GetStrict("wap2", "HostName") 434 | if err != nil { 435 | t.Fatal(err) 436 | } 437 | 438 | if host != "8.8.8.8" { 439 | t.Errorf("expected HostName to be %q, got %q", "8.8.8.8", host) 440 | } 441 | } 442 | 443 | func TestNoTrailingNewline(t *testing.T) { 444 | us := &UserSettings{ 445 | userConfigFinder: testConfigFinder("testdata/config-no-ending-newline"), 446 | systemConfigFinder: nullConfigFinder, 447 | } 448 | 449 | port, err := us.GetStrict("example", "Port") 450 | if err != nil { 451 | t.Fatal(err) 452 | } 453 | 454 | if port != "4242" { 455 | t.Errorf("wrong port: got %q want 4242", port) 456 | } 457 | } 458 | 459 | func TestCustomFinder(t *testing.T) { 460 | us := &UserSettings{} 461 | us.ConfigFinder(func() string { 462 | return "testdata/config1" 463 | }) 464 | 465 | val := us.Get("wap", "User") 466 | if val != "root" { 467 | t.Errorf("expected to find User root, got %q", val) 468 | } 469 | } 470 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | // Package ssh_config provides tools for manipulating SSH config files. 2 | // 3 | // Importantly, this parser attempts to preserve comments in a given file, so 4 | // you can manipulate a `ssh_config` file from a program, if your heart desires. 5 | // 6 | // The Get() and GetStrict() functions will attempt to read values from 7 | // $HOME/.ssh/config, falling back to /etc/ssh/ssh_config. The first argument is 8 | // the host name to match on ("example.com"), and the second argument is the key 9 | // you want to retrieve ("Port"). The keywords are case insensitive. 10 | // 11 | // port := ssh_config.Get("myhost", "Port") 12 | // 13 | // You can also manipulate an SSH config file and then print it or write it back 14 | // to disk. 15 | // 16 | // f, _ := os.Open(filepath.Join(os.Getenv("HOME"), ".ssh", "config")) 17 | // cfg, _ := ssh_config.Decode(f) 18 | // for _, host := range cfg.Hosts { 19 | // fmt.Println("patterns:", host.Patterns) 20 | // for _, node := range host.Nodes { 21 | // fmt.Println(node.String()) 22 | // } 23 | // } 24 | // 25 | // // Write the cfg back to disk: 26 | // fmt.Println(cfg.String()) 27 | // 28 | // BUG: the Match directive is currently unsupported; parsing a config with 29 | // a Match directive will trigger an error. 30 | package ssh_config 31 | 32 | import ( 33 | "bytes" 34 | "errors" 35 | "fmt" 36 | "io" 37 | "os" 38 | osuser "os/user" 39 | "path/filepath" 40 | "regexp" 41 | "runtime" 42 | "strings" 43 | "sync" 44 | ) 45 | 46 | const version = "1.4.0" 47 | 48 | var _ = version 49 | 50 | type configFinder func() string 51 | 52 | // UserSettings checks ~/.ssh and /etc/ssh for configuration files. The config 53 | // files are parsed and cached the first time Get() or GetStrict() is called. 54 | type UserSettings struct { 55 | IgnoreErrors bool 56 | customConfig *Config 57 | customConfigFinder configFinder 58 | systemConfig *Config 59 | systemConfigFinder configFinder 60 | userConfig *Config 61 | userConfigFinder configFinder 62 | loadConfigs sync.Once 63 | onceErr error 64 | } 65 | 66 | func homedir() string { 67 | user, err := osuser.Current() 68 | if err == nil { 69 | return user.HomeDir 70 | } else { 71 | return os.Getenv("HOME") 72 | } 73 | } 74 | 75 | func userConfigFinder() string { 76 | return filepath.Join(homedir(), ".ssh", "config") 77 | } 78 | 79 | // DefaultUserSettings is the default UserSettings and is used by Get and 80 | // GetStrict. It checks both $HOME/.ssh/config and /etc/ssh/ssh_config for keys, 81 | // and it will return parse errors (if any) instead of swallowing them. 82 | var DefaultUserSettings = &UserSettings{ 83 | IgnoreErrors: false, 84 | systemConfigFinder: systemConfigFinder, 85 | userConfigFinder: userConfigFinder, 86 | } 87 | 88 | func systemConfigFinder() string { 89 | return filepath.Join("/", "etc", "ssh", "ssh_config") 90 | } 91 | 92 | func findVal(c *Config, alias, key string) (string, error) { 93 | if c == nil { 94 | return "", nil 95 | } 96 | val, err := c.Get(alias, key) 97 | if err != nil || val == "" { 98 | return "", err 99 | } 100 | if err := validate(key, val); err != nil { 101 | return "", err 102 | } 103 | return val, nil 104 | } 105 | 106 | func findAll(c *Config, alias, key string) ([]string, error) { 107 | if c == nil { 108 | return nil, nil 109 | } 110 | return c.GetAll(alias, key) 111 | } 112 | 113 | // Get finds the first value for key within a declaration that matches the 114 | // alias. Get returns the empty string if no value was found, or if IgnoreErrors 115 | // is false and we could not parse the configuration file. Use GetStrict to 116 | // disambiguate the latter cases. 117 | // 118 | // The match for key is case insensitive. 119 | // 120 | // Get is a wrapper around DefaultUserSettings.Get. 121 | func Get(alias, key string) string { 122 | return DefaultUserSettings.Get(alias, key) 123 | } 124 | 125 | // GetAll retrieves zero or more directives for key for the given alias. GetAll 126 | // returns nil if no value was found, or if IgnoreErrors is false and we could 127 | // not parse the configuration file. Use GetAllStrict to disambiguate the 128 | // latter cases. 129 | // 130 | // In most cases you want to use Get or GetStrict, which returns a single value. 131 | // However, a subset of ssh configuration values (IdentityFile, for example) 132 | // allow you to specify multiple directives. 133 | // 134 | // The match for key is case insensitive. 135 | // 136 | // GetAll is a wrapper around DefaultUserSettings.GetAll. 137 | func GetAll(alias, key string) []string { 138 | return DefaultUserSettings.GetAll(alias, key) 139 | } 140 | 141 | // GetStrict finds the first value for key within a declaration that matches the 142 | // alias. If key has a default value and no matching configuration is found, the 143 | // default will be returned. For more information on default values and the way 144 | // patterns are matched, see the manpage for ssh_config. 145 | // 146 | // The returned error will be non-nil if and only if a user's configuration file 147 | // or the system configuration file could not be parsed, and u.IgnoreErrors is 148 | // false. 149 | // 150 | // GetStrict is a wrapper around DefaultUserSettings.GetStrict. 151 | func GetStrict(alias, key string) (string, error) { 152 | return DefaultUserSettings.GetStrict(alias, key) 153 | } 154 | 155 | // GetAllStrict retrieves zero or more directives for key for the given alias. 156 | // 157 | // In most cases you want to use Get or GetStrict, which returns a single value. 158 | // However, a subset of ssh configuration values (IdentityFile, for example) 159 | // allow you to specify multiple directives. 160 | // 161 | // The returned error will be non-nil if and only if a user's configuration file 162 | // or the system configuration file could not be parsed, and u.IgnoreErrors is 163 | // false. 164 | // 165 | // GetAllStrict is a wrapper around DefaultUserSettings.GetAllStrict. 166 | func GetAllStrict(alias, key string) ([]string, error) { 167 | return DefaultUserSettings.GetAllStrict(alias, key) 168 | } 169 | 170 | // Get finds the first value for key within a declaration that matches the 171 | // alias. Get returns the empty string if no value was found, or if IgnoreErrors 172 | // is false and we could not parse the configuration file. Use GetStrict to 173 | // disambiguate the latter cases. 174 | // 175 | // The match for key is case insensitive. 176 | func (u *UserSettings) Get(alias, key string) string { 177 | val, err := u.GetStrict(alias, key) 178 | if err != nil { 179 | return "" 180 | } 181 | return val 182 | } 183 | 184 | // GetAll retrieves zero or more directives for key for the given alias. GetAll 185 | // returns nil if no value was found, or if IgnoreErrors is false and we could 186 | // not parse the configuration file. Use GetStrict to disambiguate the latter 187 | // cases. 188 | // 189 | // The match for key is case insensitive. 190 | func (u *UserSettings) GetAll(alias, key string) []string { 191 | val, _ := u.GetAllStrict(alias, key) 192 | return val 193 | } 194 | 195 | // GetStrict finds the first value for key within a declaration that matches the 196 | // alias. If key has a default value and no matching configuration is found, the 197 | // default will be returned. For more information on default values and the way 198 | // patterns are matched, see the manpage for ssh_config. 199 | // 200 | // error will be non-nil if and only if a user's configuration file or the 201 | // system configuration file could not be parsed, and u.IgnoreErrors is false. 202 | func (u *UserSettings) GetStrict(alias, key string) (string, error) { 203 | u.doLoadConfigs() 204 | //lint:ignore S1002 I prefer it this way 205 | if u.onceErr != nil && u.IgnoreErrors == false { 206 | return "", u.onceErr 207 | } 208 | // TODO this is getting repetitive 209 | if u.customConfig != nil { 210 | val, err := findVal(u.customConfig, alias, key) 211 | if err != nil || val != "" { 212 | return val, err 213 | } 214 | } 215 | val, err := findVal(u.userConfig, alias, key) 216 | if err != nil || val != "" { 217 | return val, err 218 | } 219 | val2, err2 := findVal(u.systemConfig, alias, key) 220 | if err2 != nil || val2 != "" { 221 | return val2, err2 222 | } 223 | return Default(key), nil 224 | } 225 | 226 | // GetAllStrict retrieves zero or more directives for key for the given alias. 227 | // If key has a default value and no matching configuration is found, the 228 | // default will be returned. For more information on default values and the way 229 | // patterns are matched, see the manpage for ssh_config. 230 | // 231 | // The returned error will be non-nil if and only if a user's configuration file 232 | // or the system configuration file could not be parsed, and u.IgnoreErrors is 233 | // false. 234 | func (u *UserSettings) GetAllStrict(alias, key string) ([]string, error) { 235 | u.doLoadConfigs() 236 | //lint:ignore S1002 I prefer it this way 237 | if u.onceErr != nil && u.IgnoreErrors == false { 238 | return nil, u.onceErr 239 | } 240 | if u.customConfig != nil { 241 | val, err := findAll(u.customConfig, alias, key) 242 | if err != nil || val != nil { 243 | return val, err 244 | } 245 | } 246 | val, err := findAll(u.userConfig, alias, key) 247 | if err != nil || val != nil { 248 | return val, err 249 | } 250 | val2, err2 := findAll(u.systemConfig, alias, key) 251 | if err2 != nil || val2 != nil { 252 | return val2, err2 253 | } 254 | // TODO: IdentityFile has multiple default values that we should return. 255 | if def := Default(key); def != "" { 256 | return []string{def}, nil 257 | } 258 | return []string{}, nil 259 | } 260 | 261 | // ConfigFinder will invoke f to try to find a ssh config file in a custom 262 | // location on disk, instead of in /etc/ssh or $HOME/.ssh. f should return the 263 | // name of a file containing SSH configuration. 264 | // 265 | // ConfigFinder must be invoked before any calls to Get or GetStrict and panics 266 | // if f is nil. Most users should not need to use this function. 267 | func (u *UserSettings) ConfigFinder(f func() string) { 268 | if f == nil { 269 | panic("cannot call ConfigFinder with nil function") 270 | } 271 | u.customConfigFinder = f 272 | } 273 | 274 | func (u *UserSettings) doLoadConfigs() { 275 | u.loadConfigs.Do(func() { 276 | var filename string 277 | var err error 278 | if u.customConfigFinder != nil { 279 | filename = u.customConfigFinder() 280 | u.customConfig, err = parseFile(filename) 281 | // IsNotExist should be returned because a user specified this 282 | // function - not existing likely means they made an error 283 | if err != nil { 284 | u.onceErr = err 285 | } 286 | return 287 | } 288 | if u.userConfigFinder == nil { 289 | filename = userConfigFinder() 290 | } else { 291 | filename = u.userConfigFinder() 292 | } 293 | u.userConfig, err = parseFile(filename) 294 | //lint:ignore S1002 I prefer it this way 295 | if err != nil && os.IsNotExist(err) == false { 296 | u.onceErr = err 297 | return 298 | } 299 | if u.systemConfigFinder == nil { 300 | filename = systemConfigFinder() 301 | } else { 302 | filename = u.systemConfigFinder() 303 | } 304 | u.systemConfig, err = parseFile(filename) 305 | //lint:ignore S1002 I prefer it this way 306 | if err != nil && os.IsNotExist(err) == false { 307 | u.onceErr = err 308 | return 309 | } 310 | }) 311 | } 312 | 313 | func parseFile(filename string) (*Config, error) { 314 | return parseWithDepth(filename, 0) 315 | } 316 | 317 | func parseWithDepth(filename string, depth uint8) (*Config, error) { 318 | b, err := os.ReadFile(filename) 319 | if err != nil { 320 | return nil, err 321 | } 322 | return decodeBytes(b, isSystem(filename), depth) 323 | } 324 | 325 | func isSystem(filename string) bool { 326 | // TODO: not sure this is the best way to detect a system repo 327 | return strings.HasPrefix(filepath.Clean(filename), "/etc/ssh") 328 | } 329 | 330 | // Decode reads r into a Config, or returns an error if r could not be parsed as 331 | // an SSH config file. 332 | func Decode(r io.Reader) (*Config, error) { 333 | b, err := io.ReadAll(r) 334 | if err != nil { 335 | return nil, err 336 | } 337 | return decodeBytes(b, false, 0) 338 | } 339 | 340 | // DecodeBytes reads b into a Config, or returns an error if r could not be 341 | // parsed as an SSH config file. 342 | func DecodeBytes(b []byte) (*Config, error) { 343 | return decodeBytes(b, false, 0) 344 | } 345 | 346 | func decodeBytes(b []byte, system bool, depth uint8) (c *Config, err error) { 347 | defer func() { 348 | if r := recover(); r != nil { 349 | if _, ok := r.(runtime.Error); ok { 350 | panic(r) 351 | } 352 | if e, ok := r.(error); ok && e == ErrDepthExceeded { 353 | err = e 354 | return 355 | } 356 | err = errors.New(r.(string)) 357 | } 358 | }() 359 | 360 | c = parseSSH(lexSSH(b), system, depth) 361 | return c, err 362 | } 363 | 364 | // Config represents an SSH config file. 365 | type Config struct { 366 | // A list of hosts to match against. The file begins with an implicit 367 | // "Host *" declaration matching all hosts. 368 | Hosts []*Host 369 | depth uint8 370 | position Position 371 | } 372 | 373 | // Get finds the first value in the configuration that matches the alias and 374 | // contains key. Get returns the empty string if no value was found, or if the 375 | // Config contains an invalid conditional Include value. 376 | // 377 | // The match for key is case insensitive. 378 | func (c *Config) Get(alias, key string) (string, error) { 379 | lowerKey := strings.ToLower(key) 380 | for _, host := range c.Hosts { 381 | if !host.Matches(alias) { 382 | continue 383 | } 384 | for _, node := range host.Nodes { 385 | switch t := node.(type) { 386 | case *Empty: 387 | continue 388 | case *KV: 389 | // "keys are case insensitive" per the spec 390 | lkey := strings.ToLower(t.Key) 391 | if lkey == "match" { 392 | panic("can't handle Match directives") 393 | } 394 | if lkey == lowerKey { 395 | return t.Value, nil 396 | } 397 | case *Include: 398 | val := t.Get(alias, key) 399 | if val != "" { 400 | return val, nil 401 | } 402 | default: 403 | return "", fmt.Errorf("unknown Node type %v", t) 404 | } 405 | } 406 | } 407 | return "", nil 408 | } 409 | 410 | // GetAll returns all values in the configuration that match the alias and 411 | // contains key, or nil if none are present. 412 | func (c *Config) GetAll(alias, key string) ([]string, error) { 413 | lowerKey := strings.ToLower(key) 414 | all := []string(nil) 415 | for _, host := range c.Hosts { 416 | if !host.Matches(alias) { 417 | continue 418 | } 419 | for _, node := range host.Nodes { 420 | switch t := node.(type) { 421 | case *Empty: 422 | continue 423 | case *KV: 424 | // "keys are case insensitive" per the spec 425 | lkey := strings.ToLower(t.Key) 426 | if lkey == "match" { 427 | panic("can't handle Match directives") 428 | } 429 | if lkey == lowerKey { 430 | all = append(all, t.Value) 431 | } 432 | case *Include: 433 | val, _ := t.GetAll(alias, key) 434 | if len(val) > 0 { 435 | all = append(all, val...) 436 | } 437 | default: 438 | return nil, fmt.Errorf("unknown Node type %v", t) 439 | } 440 | } 441 | } 442 | 443 | return all, nil 444 | } 445 | 446 | // String returns a string representation of the Config file. 447 | func (c Config) String() string { 448 | return marshal(c).String() 449 | } 450 | 451 | func (c Config) MarshalText() ([]byte, error) { 452 | return marshal(c).Bytes(), nil 453 | } 454 | 455 | func marshal(c Config) *bytes.Buffer { 456 | var buf bytes.Buffer 457 | for i := range c.Hosts { 458 | buf.WriteString(c.Hosts[i].String()) 459 | } 460 | return &buf 461 | } 462 | 463 | // Pattern is a pattern in a Host declaration. Patterns are read-only values; 464 | // create a new one with NewPattern(). 465 | type Pattern struct { 466 | str string // Its appearance in the file, not the value that gets compiled. 467 | regex *regexp.Regexp 468 | not bool // True if this is a negated match 469 | } 470 | 471 | // String prints the string representation of the pattern. 472 | func (p Pattern) String() string { 473 | return p.str 474 | } 475 | 476 | // Copied from regexp.go with * and ? removed. 477 | var specialBytes = []byte(`\.+()|[]{}^$`) 478 | 479 | func special(b byte) bool { 480 | return bytes.IndexByte(specialBytes, b) >= 0 481 | } 482 | 483 | // NewPattern creates a new Pattern for matching hosts. NewPattern("*") creates 484 | // a Pattern that matches all hosts. 485 | // 486 | // From the manpage, a pattern consists of zero or more non-whitespace 487 | // characters, `*' (a wildcard that matches zero or more characters), or `?' (a 488 | // wildcard that matches exactly one character). For example, to specify a set 489 | // of declarations for any host in the ".co.uk" set of domains, the following 490 | // pattern could be used: 491 | // 492 | // Host *.co.uk 493 | // 494 | // The following pattern would match any host in the 192.168.0.[0-9] network range: 495 | // 496 | // Host 192.168.0.? 497 | func NewPattern(s string) (*Pattern, error) { 498 | if s == "" { 499 | return nil, errors.New("ssh_config: empty pattern") 500 | } 501 | negated := false 502 | if s[0] == '!' { 503 | negated = true 504 | s = s[1:] 505 | } 506 | var buf bytes.Buffer 507 | buf.WriteByte('^') 508 | for i := 0; i < len(s); i++ { 509 | // A byte loop is correct because all metacharacters are ASCII. 510 | switch b := s[i]; b { 511 | case '*': 512 | buf.WriteString(".*") 513 | case '?': 514 | buf.WriteString(".?") 515 | default: 516 | // borrowing from QuoteMeta here. 517 | if special(b) { 518 | buf.WriteByte('\\') 519 | } 520 | buf.WriteByte(b) 521 | } 522 | } 523 | buf.WriteByte('$') 524 | r, err := regexp.Compile(buf.String()) 525 | if err != nil { 526 | return nil, err 527 | } 528 | return &Pattern{str: s, regex: r, not: negated}, nil 529 | } 530 | 531 | // Host describes a Host directive and the keywords that follow it. 532 | type Host struct { 533 | // A list of host patterns that should match this host. 534 | Patterns []*Pattern 535 | // A Node is either a key/value pair or a comment line. 536 | Nodes []Node 537 | // EOLComment is the comment (if any) terminating the Host line. 538 | EOLComment string 539 | // Whitespace if any between the Host declaration and a trailing comment. 540 | spaceBeforeComment string 541 | 542 | hasEquals bool 543 | leadingSpace int // TODO: handle spaces vs tabs here. 544 | // The file starts with an implicit "Host *" declaration. 545 | implicit bool 546 | } 547 | 548 | // Matches returns true if the Host matches for the given alias. For 549 | // a description of the rules that provide a match, see the manpage for 550 | // ssh_config. 551 | func (h *Host) Matches(alias string) bool { 552 | found := false 553 | for i := range h.Patterns { 554 | if h.Patterns[i].regex.MatchString(alias) { 555 | if h.Patterns[i].not { 556 | // Negated match. "A pattern entry may be negated by prefixing 557 | // it with an exclamation mark (`!'). If a negated entry is 558 | // matched, then the Host entry is ignored, regardless of 559 | // whether any other patterns on the line match. Negated matches 560 | // are therefore useful to provide exceptions for wildcard 561 | // matches." 562 | return false 563 | } 564 | found = true 565 | } 566 | } 567 | return found 568 | } 569 | 570 | // String prints h as it would appear in a config file. Minor tweaks may be 571 | // present in the whitespace in the printed file. 572 | func (h *Host) String() string { 573 | var buf strings.Builder 574 | //lint:ignore S1002 I prefer to write it this way 575 | if h.implicit == false { 576 | buf.WriteString(strings.Repeat(" ", int(h.leadingSpace))) 577 | buf.WriteString("Host") 578 | if h.hasEquals { 579 | buf.WriteString(" = ") 580 | } else { 581 | buf.WriteString(" ") 582 | } 583 | for i, pat := range h.Patterns { 584 | buf.WriteString(pat.String()) 585 | if i < len(h.Patterns)-1 { 586 | buf.WriteString(" ") 587 | } 588 | } 589 | buf.WriteString(h.spaceBeforeComment) 590 | if h.EOLComment != "" { 591 | buf.WriteByte('#') 592 | buf.WriteString(h.EOLComment) 593 | } 594 | buf.WriteByte('\n') 595 | } 596 | for i := range h.Nodes { 597 | buf.WriteString(h.Nodes[i].String()) 598 | buf.WriteByte('\n') 599 | } 600 | return buf.String() 601 | } 602 | 603 | // Node represents a line in a Config. 604 | type Node interface { 605 | Pos() Position 606 | String() string 607 | } 608 | 609 | // KV is a line in the config file that contains a key, a value, and possibly 610 | // a comment. 611 | type KV struct { 612 | Key string 613 | Value string 614 | // Whitespace after the value but before any comment 615 | spaceAfterValue string 616 | Comment string 617 | hasEquals bool 618 | leadingSpace int // Space before the key. TODO handle spaces vs tabs. 619 | position Position 620 | } 621 | 622 | // Pos returns k's Position. 623 | func (k *KV) Pos() Position { 624 | return k.position 625 | } 626 | 627 | // String prints k as it was parsed in the config file. 628 | func (k *KV) String() string { 629 | if k == nil { 630 | return "" 631 | } 632 | equals := " " 633 | if k.hasEquals { 634 | equals = " = " 635 | } 636 | line := strings.Repeat(" ", int(k.leadingSpace)) + k.Key + equals + k.Value + k.spaceAfterValue 637 | if k.Comment != "" { 638 | line += "#" + k.Comment 639 | } 640 | return line 641 | } 642 | 643 | // Empty is a line in the config file that contains only whitespace or comments. 644 | type Empty struct { 645 | Comment string 646 | leadingSpace int // TODO handle spaces vs tabs. 647 | position Position 648 | } 649 | 650 | // Pos returns e's Position. 651 | func (e *Empty) Pos() Position { 652 | return e.position 653 | } 654 | 655 | // String prints e as it was parsed in the config file. 656 | func (e *Empty) String() string { 657 | if e == nil { 658 | return "" 659 | } 660 | if e.Comment == "" { 661 | return "" 662 | } 663 | return fmt.Sprintf("%s#%s", strings.Repeat(" ", int(e.leadingSpace)), e.Comment) 664 | } 665 | 666 | // Include holds the result of an Include directive, including the config files 667 | // that have been parsed as part of that directive. At most 5 levels of Include 668 | // statements will be parsed. 669 | type Include struct { 670 | // Comment is the contents of any comment at the end of the Include 671 | // statement. 672 | Comment string 673 | // an include directive can include several different files, and wildcards 674 | directives []string 675 | 676 | mu sync.Mutex 677 | // 1:1 mapping between matches and keys in files array; matches preserves 678 | // ordering 679 | matches []string 680 | // actual filenames are listed here 681 | files map[string]*Config 682 | leadingSpace int 683 | position Position 684 | depth uint8 685 | hasEquals bool 686 | } 687 | 688 | const maxRecurseDepth = 5 689 | 690 | // ErrDepthExceeded is returned if too many Include directives are parsed. 691 | // Usually this indicates a recursive loop (an Include directive pointing to the 692 | // file it contains). 693 | var ErrDepthExceeded = errors.New("ssh_config: max recurse depth exceeded") 694 | 695 | func removeDups(arr []string) []string { 696 | // Use map to record duplicates as we find them. 697 | encountered := make(map[string]bool, len(arr)) 698 | result := make([]string, 0) 699 | 700 | for v := range arr { 701 | //lint:ignore S1002 I prefer it this way 702 | if encountered[arr[v]] == false { 703 | encountered[arr[v]] = true 704 | result = append(result, arr[v]) 705 | } 706 | } 707 | return result 708 | } 709 | 710 | // NewInclude creates a new Include with a list of file globs to include. 711 | // Configuration files are parsed greedily (e.g. as soon as this function runs). 712 | // Any error encountered while parsing nested configuration files will be 713 | // returned. 714 | func NewInclude(directives []string, hasEquals bool, pos Position, comment string, system bool, depth uint8) (*Include, error) { 715 | if depth > maxRecurseDepth { 716 | return nil, ErrDepthExceeded 717 | } 718 | inc := &Include{ 719 | Comment: comment, 720 | directives: directives, 721 | files: make(map[string]*Config), 722 | position: pos, 723 | leadingSpace: pos.Col - 1, 724 | depth: depth, 725 | hasEquals: hasEquals, 726 | } 727 | // no need for inc.mu.Lock() since nothing else can access this inc 728 | matches := make([]string, 0) 729 | for i := range directives { 730 | var path string 731 | if filepath.IsAbs(directives[i]) { 732 | path = directives[i] 733 | } else if system { 734 | path = filepath.Join("/etc/ssh", directives[i]) 735 | } else { 736 | path = filepath.Join(homedir(), ".ssh", directives[i]) 737 | } 738 | theseMatches, err := filepath.Glob(path) 739 | if err != nil { 740 | return nil, err 741 | } 742 | matches = append(matches, theseMatches...) 743 | } 744 | matches = removeDups(matches) 745 | inc.matches = matches 746 | for i := range matches { 747 | config, err := parseWithDepth(matches[i], depth) 748 | if err != nil { 749 | return nil, err 750 | } 751 | inc.files[matches[i]] = config 752 | } 753 | return inc, nil 754 | } 755 | 756 | // Pos returns the position of the Include directive in the larger file. 757 | func (i *Include) Pos() Position { 758 | return i.position 759 | } 760 | 761 | // Get finds the first value in the Include statement matching the alias and the 762 | // given key. 763 | func (inc *Include) Get(alias, key string) string { 764 | inc.mu.Lock() 765 | defer inc.mu.Unlock() 766 | // TODO: we search files in any order which is not correct 767 | for i := range inc.matches { 768 | cfg := inc.files[inc.matches[i]] 769 | if cfg == nil { 770 | panic("nil cfg") 771 | } 772 | val, err := cfg.Get(alias, key) 773 | if err == nil && val != "" { 774 | return val 775 | } 776 | } 777 | return "" 778 | } 779 | 780 | // GetAll finds all values in the Include statement matching the alias and the 781 | // given key. 782 | func (inc *Include) GetAll(alias, key string) ([]string, error) { 783 | inc.mu.Lock() 784 | defer inc.mu.Unlock() 785 | var vals []string 786 | 787 | // TODO: we search files in any order which is not correct 788 | for i := range inc.matches { 789 | cfg := inc.files[inc.matches[i]] 790 | if cfg == nil { 791 | panic("nil cfg") 792 | } 793 | val, err := cfg.GetAll(alias, key) 794 | if err == nil && len(val) != 0 { 795 | // In theory if SupportsMultiple was false for this key we could 796 | // stop looking here. But the caller has asked us to find all 797 | // instances of the keyword (and could use Get() if they wanted) so 798 | // let's keep looking. 799 | vals = append(vals, val...) 800 | } 801 | } 802 | return vals, nil 803 | } 804 | 805 | // String prints out a string representation of this Include directive. Note 806 | // included Config files are not printed as part of this representation. 807 | func (inc *Include) String() string { 808 | equals := " " 809 | if inc.hasEquals { 810 | equals = " = " 811 | } 812 | line := fmt.Sprintf("%sInclude%s%s", strings.Repeat(" ", int(inc.leadingSpace)), equals, strings.Join(inc.directives, " ")) 813 | if inc.Comment != "" { 814 | line += " #" + inc.Comment 815 | } 816 | return line 817 | } 818 | 819 | var matchAll *Pattern 820 | 821 | func init() { 822 | var err error 823 | matchAll, err = NewPattern("*") 824 | if err != nil { 825 | panic(err) 826 | } 827 | } 828 | 829 | func newConfig() *Config { 830 | return &Config{ 831 | Hosts: []*Host{ 832 | &Host{ 833 | implicit: true, 834 | Patterns: []*Pattern{matchAll}, 835 | Nodes: make([]Node, 0), 836 | }, 837 | }, 838 | depth: 0, 839 | } 840 | } 841 | --------------------------------------------------------------------------------