From 200a8b4c545bc9fffed7e41a68c9aee6b6562fd3 Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Thu, 20 Feb 2025 00:02:43 -0800 Subject: [PATCH 01/24] .github: update CI for more recent Go versions --- .github/workflows/ci.yml | 6 +++--- Makefile | 8 ++------ parser.go | 8 ++++---- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57b9540..2ddcc72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,13 +7,13 @@ jobs: - name: Install Go uses: WillAbides/setup-go-faster@main with: - go-version: 1.21.x + go-version: 1.24.x - uses: actions/checkout@v4 with: path: './src/github.com/kevinburke/ssh_config' # staticcheck needs this for GOPATH - run: | - echo "GO111MODULE=off" >> $GITHUB_ENV + echo "GO111MODULE=on" >> $GITHUB_ENV echo "GOPATH=$GITHUB_WORKSPACE" >> $GITHUB_ENV echo "PATH=$GITHUB_WORKSPACE/bin:$PATH" >> $GITHUB_ENV - name: Run tests @@ -23,7 +23,7 @@ jobs: test: strategy: matrix: - go-version: [1.17.x, 1.18.x, 1.19.x, 1.20.x, 1.21.x] + 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] runs-on: ubuntu-latest steps: - name: Install Go diff --git a/Makefile b/Makefile index 02e15ec..4ee41ab 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,9 @@ BUMP_VERSION := $(GOPATH)/bin/bump_version -STATICCHECK := $(GOPATH)/bin/staticcheck WRITE_MAILMAP := $(GOPATH)/bin/write_mailmap -$(STATICCHECK): - go get honnef.co/go/tools/cmd/staticcheck - -lint: $(STATICCHECK) +lint: go vet ./... - $(STATICCHECK) + go run honnef.co/go/tools/cmd/staticcheck@latest ./... test: @# the timeout helps guard against infinite recursion diff --git a/parser.go b/parser.go index 2b1e718..fdd6ce9 100644 --- a/parser.go +++ b/parser.go @@ -21,9 +21,9 @@ type sshParser struct { type sshParserStateFn func() sshParserStateFn // Formats and panics an error message based on a token -func (p *sshParser) raiseErrorf(tok *token, msg string, args ...interface{}) { +func (p *sshParser) raiseErrorf(tok *token, msg string) { // TODO this format is ugly - panic(tok.Position.String() + ": " + fmt.Sprintf(msg, args...)) + panic(tok.Position.String() + ": " + msg) } func (p *sshParser) raiseError(tok *token, err error) { @@ -118,7 +118,7 @@ func (p *sshParser) parseKV() sshParserStateFn { } pat, err := NewPattern(strPatterns[i]) if err != nil { - p.raiseErrorf(val, "Invalid host pattern: %v", err) + p.raiseErrorf(val, fmt.Sprintf("Invalid host pattern: %v", err)) return nil } patterns = append(patterns, pat) @@ -144,7 +144,7 @@ func (p *sshParser) parseKV() sshParserStateFn { return nil } if err != nil { - p.raiseErrorf(val, "Error parsing Include directive: %v", err) + p.raiseErrorf(val, fmt.Sprintf("Error parsing Include directive: %v", err)) return nil } lastHost.Nodes = append(lastHost.Nodes, inc) From 1bd630c1057da6bd6192af0ab6db9d0c94f86863 Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Thu, 20 Feb 2025 00:21:01 -0800 Subject: [PATCH 02/24] 1.3 --- config.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config.go b/config.go index 4816e67..f81a55d 100644 --- a/config.go +++ b/config.go @@ -8,7 +8,7 @@ // the host name to match on ("example.com"), and the second argument is the key // you want to retrieve ("Port"). The keywords are case insensitive. // -// port := ssh_config.Get("myhost", "Port") +// port := ssh_config.Get("myhost", "Port") // // You can also manipulate an SSH config file and then print it or write it back // to disk. @@ -43,7 +43,7 @@ import ( "sync" ) -const version = "1.2" +const version = "1.3" var _ = version From d227026b5b77d4a47bd1f5bea2fd958586d6e1f4 Mon Sep 17 00:00:00 2001 From: Simon Josefsson Date: Tue, 19 Aug 2025 15:30:46 +0200 Subject: [PATCH 03/24] Store tests/dos-lines as CRLF in git instead of using .gitattributes (#72) --- .gitattributes | 1 - testdata/dos-lines | 20 ++++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) delete mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 44db581..0000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -testdata/dos-lines eol=crlf diff --git a/testdata/dos-lines b/testdata/dos-lines index 1001df4..8cb11cd 100644 --- a/testdata/dos-lines +++ b/testdata/dos-lines @@ -1,10 +1,10 @@ -# Config file with dos line endings -Host wap - HostName wap.example.org - Port 22 - User root - KexAlgorithms diffie-hellman-group1-sha1 - -Host wap2 - HostName 8.8.8.8 - User google +# Config file with dos line endings +Host wap + HostName wap.example.org + Port 22 + User root + KexAlgorithms diffie-hellman-group1-sha1 + +Host wap2 + HostName 8.8.8.8 + User google From bf7d55a2d04a1a63e04501bc0fe8e9a7d013f9bf Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Tue, 19 Aug 2025 13:32:46 -0700 Subject: [PATCH 04/24] CHANGELOG,AUTHORS.txt: add notes and add Simon Josefsson --- AUTHORS.txt | 1 + CHANGELOG.md | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/AUTHORS.txt b/AUTHORS.txt index 311aeb1..157361b 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -5,5 +5,6 @@ Kevin Burke Mark Nevill Scott Lessans Sergey Lukjanov +Simon Josefsson Wayne Ashley Berry santosh653 <70637961+santosh653@users.noreply.github.com> diff --git a/CHANGELOG.md b/CHANGELOG.md index d32a3f5..ba84b51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changes +## Version 1.4 (released August 2025) + +- Remove .gitattributes file (which was used to test different line endings, and +caused issues in some build environments). + +## Version 1.3 (released February 2025) + +- Add go.mod file (although this project has no dependencies). + +- Various updates to CI and build environment + +- config: add UserSettings.ConfigFinder + ## Version 1.2 Previously, if a Host declaration or a value had trailing whitespace, that From 6cb5d6c7abb577f3104aabbe5d8063156317a4e9 Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Tue, 19 Aug 2025 13:35:30 -0700 Subject: [PATCH 05/24] config: change release format to "1.3.0" This makes things nicer when/if you are importing this library in a Go program, otherwise go.mod reports a long version number with the commit and date. --- config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.go b/config.go index f81a55d..a871556 100644 --- a/config.go +++ b/config.go @@ -43,7 +43,7 @@ import ( "sync" ) -const version = "1.3" +const version = "1.3.0" var _ = version From d87420c3e28c1ebb3b8a1f39592c925bfbb8174c Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Tue, 19 Aug 2025 13:36:04 -0700 Subject: [PATCH 06/24] 1.4.0 --- config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.go b/config.go index a871556..bb7315a 100644 --- a/config.go +++ b/config.go @@ -43,7 +43,7 @@ import ( "sync" ) -const version = "1.3.0" +const version = "1.4.0" var _ = version From 4d09c9dd1eb6ca8d20bbfcbdad78b2fcbc653db4 Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Wed, 17 Sep 2025 07:27:03 -0700 Subject: [PATCH 07/24] .github: latest Go version Also switch back to the official 'actions' Go installer, and latest versions of each. --- .github/workflows/ci.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ddcc72..afd139d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,10 +5,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Install Go - uses: WillAbides/setup-go-faster@main + uses: actions/setup-go@v6 with: - go-version: 1.24.x - - uses: actions/checkout@v4 + go-version: 1.25.x + - uses: actions/checkout@v5 with: path: './src/github.com/kevinburke/ssh_config' # staticcheck needs this for GOPATH @@ -23,14 +23,14 @@ jobs: test: strategy: matrix: - 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] + 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] runs-on: ubuntu-latest steps: - name: Install Go - uses: WillAbides/setup-go-faster@main + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: path: './src/github.com/kevinburke/ssh_config' - run: | From 40dba82e609e5e30fa27cb62f5ecf8ca2f285b9c Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Thu, 18 Sep 2025 15:02:43 -0700 Subject: [PATCH 08/24] .github: add dependabot configuration --- .github/dependabot.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..cd88554 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "gomod" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" From 482de704302ff32e1f95f3e7d4e09a99fb10e36e Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Thu, 18 Sep 2025 15:52:27 -0700 Subject: [PATCH 09/24] SECURITY.md: add --- SECURITY.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..adc2c5a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,63 @@ +# ssh_config security policy + +## Supported Versions + +As of September 2025, we're not aware of any security problems with ssh_config, +past or present. That said, we recommend always using the latest version of +ssh_config, and of the Go programming language, to ensure you have the most +recent security fixes. + +## Reporting a Vulnerability + +We take security vulnerabilities seriously. If you discover a security vulnerability in ssh_config, please report it responsibly by following these steps: + +### How to Report + +Please follow the instructions outlined here to report a vulnerability +privately: https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability + +If these are insufficient - it is not hard to find Kevin's contact information +on the Internet. + +### What to Include + +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. + +### Response Timeline + +I'll try to acknowledge and patch the issue as quickly as possible. + +Security advisories for this project will be published through: +- GitHub Security Advisories on this repository +- an Issue on this repository +- The project's release notes +- Go vulnerability databases + +If you are using `ssh_config` and would like to be on a "pre-release" +distribution list for coordinating releases, please contact Kevin directly. + +### Security Considerations + +When using ssh_config, please be aware of these security considerations. + +#### File System Access + +This library reads SSH configuration files from the file system. Try to ensure +proper file permissions on SSH config files (typically 600 or 644), and be +cautious when parsing config files from untrusted sources. + +#### Input Validation + +The parser handles user-provided SSH configuration data. While we try our best +to parse the data appropriately, malformed configuration files could potentially +cause issues. Please try to validate and sanitize any configuration data from +external sources. + +#### Dependencies + +This project does not have any third party dependencies. Please try to keep your +Go version up to date. + +## Acknowledgments + +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). From f1fac02d87e79645383834fa89e9bb7ca049320e Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Thu, 12 Feb 2026 22:07:27 +0900 Subject: [PATCH 10/24] all: implement Match support This implements most of the spec for Match while still leaving 'Match exec' unimplemented. Add new tests for this functionality, as well as measures of code coverage, and then add additional tests based on the code coverage results. Implements changes from https://github.com/kevinburke/ssh_config/pull/67 Co-Authored-By: sio2boss Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + AUTHORS.txt | 1 + Makefile | 4 + config.go | 54 ++-- config_test.go | 14 +- match_test.go | 599 +++++++++++++++++++++++++++++++++++ parser.go | 71 ++++- testdata/match-all | 6 + testdata/match-host | 9 + testdata/match-host-negation | 7 + testdata/match-mixed | 21 ++ 11 files changed, 758 insertions(+), 29 deletions(-) create mode 100644 match_test.go create mode 100644 testdata/match-all create mode 100644 testdata/match-host create mode 100644 testdata/match-host-negation create mode 100644 testdata/match-mixed diff --git a/.gitignore b/.gitignore index e69de29..46620af 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1 @@ +/coverage.out diff --git a/AUTHORS.txt b/AUTHORS.txt index 157361b..c316990 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -6,5 +6,6 @@ Mark Nevill Scott Lessans Sergey Lukjanov Simon Josefsson +sio2boss Wayne Ashley Berry santosh653 <70637961+santosh653@users.noreply.github.com> diff --git a/Makefile b/Makefile index 4ee41ab..25a3570 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,10 @@ test: race-test: go test -timeout=500ms -race ./... +coverage: + go test -trimpath -timeout=250ms -coverprofile=coverage.out -covermode=atomic ./... + go tool cover -func=coverage.out + $(BUMP_VERSION): go get -u github.com/kevinburke/bump_version diff --git a/config.go b/config.go index bb7315a..1c88582 100644 --- a/config.go +++ b/config.go @@ -24,9 +24,6 @@ // // // Write the cfg back to disk: // fmt.Println(cfg.String()) -// -// BUG: the Match directive is currently unsupported; parsing a config with -// a Match directive will trigger an error. package ssh_config import ( @@ -388,9 +385,6 @@ func (c *Config) Get(alias, key string) (string, error) { case *KV: // "keys are case insensitive" per the spec lkey := strings.ToLower(t.Key) - if lkey == "match" { - panic("can't handle Match directives") - } if lkey == lowerKey { return t.Value, nil } @@ -423,9 +417,6 @@ func (c *Config) GetAll(alias, key string) ([]string, error) { case *KV: // "keys are case insensitive" per the spec lkey := strings.ToLower(t.Key) - if lkey == "match" { - panic("can't handle Match directives") - } if lkey == lowerKey { all = append(all, t.Value) } @@ -470,6 +461,9 @@ type Pattern struct { // String prints the string representation of the pattern. func (p Pattern) String() string { + if p.not { + return "!" + p.str + } return p.str } @@ -528,7 +522,7 @@ func NewPattern(s string) (*Pattern, error) { return &Pattern{str: s, regex: r, not: negated}, nil } -// Host describes a Host directive and the keywords that follow it. +// Host describes a Host or Match directive and the keywords that follow it. type Host struct { // A list of host patterns that should match this host. Patterns []*Pattern @@ -543,6 +537,11 @@ type Host struct { leadingSpace int // TODO: handle spaces vs tabs here. // The file starts with an implicit "Host *" declaration. implicit bool + // isMatch is true if this block was created by a Match directive. + isMatch bool + // matchKeyword stores the original text after "Match" (e.g. "Host" or + // "all") so we can round-trip correctly. + matchKeyword string } // Matches returns true if the Host matches for the given alias. For @@ -574,17 +573,36 @@ func (h *Host) String() string { //lint:ignore S1002 I prefer to write it this way if h.implicit == false { buf.WriteString(strings.Repeat(" ", int(h.leadingSpace))) - buf.WriteString("Host") - if h.hasEquals { - buf.WriteString(" = ") + if h.isMatch { + buf.WriteString("Match") + if h.hasEquals { + buf.WriteString(" = ") + } else { + buf.WriteString(" ") + } + buf.WriteString(h.matchKeyword) + if !strings.EqualFold(h.matchKeyword, "all") { + buf.WriteString(" ") + for i, pat := range h.Patterns { + buf.WriteString(pat.String()) + if i < len(h.Patterns)-1 { + buf.WriteString(" ") + } + } + } } else { - buf.WriteString(" ") - } - for i, pat := range h.Patterns { - buf.WriteString(pat.String()) - if i < len(h.Patterns)-1 { + buf.WriteString("Host") + if h.hasEquals { + buf.WriteString(" = ") + } else { buf.WriteString(" ") } + for i, pat := range h.Patterns { + buf.WriteString(pat.String()) + if i < len(h.Patterns)-1 { + buf.WriteString(" ") + } + } } buf.WriteString(h.spaceBeforeComment) if h.EOLComment != "" { diff --git a/config_test.go b/config_test.go index 11b203d..fbafa4e 100644 --- a/config_test.go +++ b/config_test.go @@ -388,16 +388,14 @@ func TestMatches(t *testing.T) { } } -func TestMatchUnsupported(t *testing.T) { - us := &UserSettings{ - userConfigFinder: testConfigFinder("testdata/match-directive"), - } - - _, err := us.GetStrict("test.test", "Port") +func TestMatchExecUnsupported(t *testing.T) { + config := `Match Exec "echo hello" + Port 2222` + _, err := Decode(strings.NewReader(config)) if err == nil { - t.Fatal("expected Match directive to error, didn't") + t.Fatal("expected Match Exec to error, didn't") } - if !strings.Contains(err.Error(), "ssh_config: Match directive parsing is unsupported") { + if !strings.Contains(err.Error(), "ssh_config: Match Exec is not supported") { t.Errorf("wrong error: %v", err) } } diff --git a/match_test.go b/match_test.go new file mode 100644 index 0000000..a7eb7b4 --- /dev/null +++ b/match_test.go @@ -0,0 +1,599 @@ +package ssh_config + +import ( + "strings" + "testing" +) + +func TestMatchHostBasic(t *testing.T) { + us := &UserSettings{ + userConfigFinder: testConfigFinder("testdata/match-host"), + } + + val := us.Get("dev.example.com", "Port") + if val != "2222" { + t.Errorf("expected Port=2222 for dev.example.com, got %q", val) + } + val = us.Get("dev.example.com", "User") + if val != "admin" { + t.Errorf("expected User=admin for dev.example.com, got %q", val) + } + val = us.Get("dev.example.com", "IdentityFile") + if val != "~/.ssh/prod_key" { + t.Errorf("expected IdentityFile=~/.ssh/prod_key, got %q", val) + } +} + +func TestMatchHostNoMatch(t *testing.T) { + us := &UserSettings{ + userConfigFinder: testConfigFinder("testdata/match-host"), + systemConfigFinder: nullConfigFinder, + } + + // "other.com" doesn't match *.example.com, should fall back to defaults + val := us.Get("other.com", "Port") + if val != "22" { + t.Errorf("expected default Port=22 for other.com, got %q", val) + } + val = us.Get("other.com", "User") + if val != "" { + t.Errorf("expected empty User for other.com, got %q", val) + } +} + +func TestMatchHostNegation(t *testing.T) { + us := &UserSettings{ + userConfigFinder: testConfigFinder("testdata/match-host-negation"), + } + + // dev.example.com matches *.example.com and is not excluded by + // !*.test.example.com, so the Match block applies. + val := us.Get("dev.example.com", "Port") + if val != "2222" { + t.Errorf("expected Port=2222 for dev.example.com, got %q", val) + } + val = us.Get("dev.example.com", "User") + if val != "prod" { + t.Errorf("expected User=prod for dev.example.com, got %q", val) + } + + // dev.test.example.com matches !*.test.example.com negation, so the + // Match block should NOT apply. The Host block should match instead. + val = us.Get("dev.test.example.com", "Port") + if val != "22" { + t.Errorf("expected Port=22 for dev.test.example.com (negated), got %q", val) + } + val = us.Get("dev.test.example.com", "User") + if val != "default" { + t.Errorf("expected User=default for dev.test.example.com (negated), got %q", val) + } +} + +func TestMatchAll(t *testing.T) { + us := &UserSettings{ + userConfigFinder: testConfigFinder("testdata/match-all"), + systemConfigFinder: nullConfigFinder, + } + + // "special" matches the explicit Host block first + val := us.Get("special", "Port") + if val != "1111" { + t.Errorf("expected Port=1111 for special, got %q", val) + } + + // "special" should also get User from the Match all block + val = us.Get("special", "User") + if val != "matchuser" { + t.Errorf("expected User=matchuser for special, got %q", val) + } + + // An arbitrary host should match "Match all" + val = us.Get("anything.example.com", "Port") + if val != "4567" { + t.Errorf("expected Port=4567 for anything.example.com via Match all, got %q", val) + } + val = us.Get("anything.example.com", "User") + if val != "matchuser" { + t.Errorf("expected User=matchuser for anything.example.com via Match all, got %q", val) + } +} + +func TestMatchMixed(t *testing.T) { + us := &UserSettings{ + userConfigFinder: testConfigFinder("testdata/match-mixed"), + systemConfigFinder: nullConfigFinder, + } + + // "bastion" matches the explicit Host block + val := us.Get("bastion", "Port") + if val != "22" { + t.Errorf("expected Port=22 for bastion, got %q", val) + } + val = us.Get("bastion", "User") + if val != "root" { + t.Errorf("expected User=root for bastion, got %q", val) + } + + // app.prod.example.com matches "Match Host *.prod.example.com" and + // also "Host *.example.com". First match wins for each key. + val = us.Get("app.prod.example.com", "Port") + if val != "2222" { + t.Errorf("expected Port=2222 for app.prod.example.com, got %q", val) + } + val = us.Get("app.prod.example.com", "User") + if val != "deploy" { + t.Errorf("expected User=deploy for app.prod.example.com, got %q", val) + } + + // app.staging.example.com matches "Host *.example.com" (Port 80) + // first, then "Match Host *.staging.example.com" (Port 3333). + // SSH semantics: first match wins per key. + val = us.Get("app.staging.example.com", "Port") + if val != "80" { + t.Errorf("expected Port=80 for app.staging.example.com (Host block first), got %q", val) + } + + // plain.example.com matches "Host *.example.com" only + val = us.Get("plain.example.com", "Port") + if val != "80" { + t.Errorf("expected Port=80 for plain.example.com, got %q", val) + } + val = us.Get("plain.example.com", "User") + if val != "webuser" { + t.Errorf("expected User=webuser for plain.example.com, got %q", val) + } + + // unknown host matches only "Match all" + val = us.Get("unknown.host", "User") + if val != "fallback" { + t.Errorf("expected User=fallback for unknown.host via Match all, got %q", val) + } +} + +func TestMatchMixedGetAll(t *testing.T) { + us := &UserSettings{ + userConfigFinder: testConfigFinder("testdata/match-mixed"), + systemConfigFinder: nullConfigFinder, + } + + // app.prod.example.com should get both IdentityFiles from the Match Host + // block, plus the one from Match all. + vals := us.GetAll("app.prod.example.com", "IdentityFile") + want := []string{"~/.ssh/prod_key1", "~/.ssh/prod_key2", "~/.ssh/default_key"} + if len(vals) != len(want) { + t.Fatalf("GetAll IdentityFile for app.prod.example.com: got %d values %v, want %d values %v", len(vals), vals, len(want), want) + } + for i := range want { + if vals[i] != want[i] { + t.Errorf("GetAll IdentityFile[%d]: got %q, want %q", i, vals[i], want[i]) + } + } +} + +func TestMatchDirectiveInline(t *testing.T) { + tests := []struct { + name string + config string + alias string + key string + wantVal string + wantErr string + }{ + { + name: "basic match host", + config: `Match Host *.example.com + Port 2222`, + alias: "test.example.com", + key: "Port", + wantVal: "2222", + }, + { + name: "match host no match", + config: `Match Host *.example.com + Port 2222`, + alias: "test.other.com", + key: "Port", + wantVal: "", + }, + { + name: "match all", + config: `Match all + Port 9999`, + alias: "anything", + key: "Port", + wantVal: "9999", + }, + { + name: "match host multiple patterns", + config: `Match Host *.example.com *.example.org + Port 2222`, + alias: "test.example.org", + key: "Port", + wantVal: "2222", + }, + { + name: "match host with comment", + config: `Match Host *.example.com # Production servers + Port 2222`, + alias: "test.example.com", + key: "Port", + wantVal: "2222", + }, + { + name: "empty match should error", + config: `Match + Port 2222`, + wantErr: "Match directive requires", + }, + { + name: "match host case insensitive", + config: `Match HOST *.example.com + Port 2222`, + alias: "test.example.com", + key: "Port", + wantVal: "2222", + }, + { + name: "match host mixed case", + config: `Match HoSt *.example.com + Port 2222`, + alias: "test.example.com", + key: "Port", + wantVal: "2222", + }, + { + name: "match all uppercase", + config: `Match ALL + Port 9999`, + alias: "anything", + key: "Port", + wantVal: "9999", + }, + { + name: "match keyword itself case insensitive", + config: `MATCH Host *.example.com + Port 2222`, + alias: "test.example.com", + key: "Port", + wantVal: "2222", + }, + { + name: "match host extra spaces between patterns", + config: `Match Host *.example.com *.example.org + Port 2222`, + alias: "test.example.org", + key: "Port", + wantVal: "2222", + }, + { + name: "match host trailing spaces", + config: "Match Host *.example.com \n Port 2222", + alias: "test.example.com", + key: "Port", + wantVal: "2222", + }, + { + name: "match host leading spaces on match line", + config: ` Match Host *.example.com + Port 2222`, + alias: "test.example.com", + key: "Port", + wantVal: "2222", + }, + { + name: "host before match host, same pattern", + config: `Host *.example.com + Port 1111 + +Match Host *.example.com + Port 2222`, + alias: "test.example.com", + key: "Port", + wantVal: "1111", // Host block appears first, wins + }, + { + name: "match host before host, same pattern", + config: `Match Host *.example.com + Port 2222 + +Host *.example.com + Port 1111`, + alias: "test.example.com", + key: "Port", + wantVal: "2222", // Match block appears first, wins + }, + { + name: "match host provides key not in host", + config: `Host *.example.com + Port 1111 + +Match Host *.example.com + User admin`, + alias: "test.example.com", + key: "User", + wantVal: "admin", // Not set in Host, comes from Match + }, + { + name: "match host negation excludes", + config: `Match Host *.example.com !staging.example.com + Port 2222`, + alias: "staging.example.com", + key: "Port", + wantVal: "", + }, + { + name: "match host negation allows", + config: `Match Host *.example.com !staging.example.com + Port 2222`, + alias: "prod.example.com", + key: "Port", + wantVal: "2222", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := Decode(strings.NewReader(tt.config)) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + got, err := cfg.Get(tt.alias, tt.key) + if err != nil { + t.Fatalf("unexpected Get error: %v", err) + } + if got != tt.wantVal { + t.Errorf("Get(%q, %q) = %q, want %q", tt.alias, tt.key, got, tt.wantVal) + } + }) + } +} + +func TestMatchUnsupportedCriteria(t *testing.T) { + // Every Match criterion from the ssh_config manpage that we don't + // support, plus case variations and the special Exec case. + tests := []struct { + name string + config string + wantErr string + }{ + // Exec gets its own error message because it's a security concern. + { + name: "exec lowercase", + config: "Match exec \"echo hello\"\n Port 22", + wantErr: "ssh_config: Match Exec is not supported", + }, + { + name: "exec uppercase", + config: "Match EXEC \"echo hello\"\n Port 22", + wantErr: "ssh_config: Match Exec is not supported", + }, + { + name: "exec mixed case", + config: "Match ExEc \"echo hello\"\n Port 22", + wantErr: "ssh_config: Match Exec is not supported", + }, + { + name: "exec with complex command", + config: "Match Exec \"test -f /etc/ssh/flag\"\n Port 22", + wantErr: "ssh_config: Match Exec is not supported", + }, + // All other unsupported criteria. + { + name: "user", + config: "Match User admin\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "user uppercase", + config: "Match USER admin\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "localuser", + config: "Match LocalUser kevin\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "localuser uppercase", + config: "Match LOCALUSER kevin\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "originalhost", + config: "Match OriginalHost *.example.com\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "originalhost uppercase", + config: "Match ORIGINALHOST *.example.com\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "canonical", + config: "Match canonical\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "final", + config: "Match final\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "tagged", + config: "Match Tagged mytag\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "localnetwork", + config: "Match LocalNetwork 192.168.1.0/24\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + { + name: "completely bogus criterion", + config: "Match Bogus value\n Port 22", + wantErr: "ssh_config: unsupported Match criterion", + }, + // Match Host with no patterns after it. + { + name: "match host with no patterns", + config: "Match Host\n Port 22", + wantErr: "ssh_config: Match Host requires at least one pattern", + }, + // Match Host followed by only whitespace. + { + name: "match host only whitespace after", + config: "Match Host \n Port 22", + wantErr: "ssh_config: Match Host requires at least one pattern", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Decode(strings.NewReader(tt.config)) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err) + } + }) + } +} + +func TestMatchDirectiveGetAll(t *testing.T) { + config := `Match Host *.prod.example.com + IdentityFile ~/.ssh/prod_key1 + IdentityFile ~/.ssh/prod_key2 + +Match all + IdentityFile ~/.ssh/default_key` + + cfg, err := Decode(strings.NewReader(config)) + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + + vals, err := cfg.GetAll("app.prod.example.com", "IdentityFile") + if err != nil { + t.Fatalf("unexpected GetAll error: %v", err) + } + want := []string{"~/.ssh/prod_key1", "~/.ssh/prod_key2", "~/.ssh/default_key"} + if len(vals) != len(want) { + t.Fatalf("GetAll returned %d values %v, want %d values %v", len(vals), vals, len(want), want) + } + for i := range want { + if vals[i] != want[i] { + t.Errorf("GetAll[%d] = %q, want %q", i, vals[i], want[i]) + } + } +} + +func TestMatchStringRoundTrip(t *testing.T) { + tests := []struct { + name string + config string + }{ + { + name: "match host", + config: `Match Host *.example.com + Port 2222 +`, + }, + { + name: "match all", + config: `Match all + Port 4567 +`, + }, + { + name: "match host with comment", + config: `Match Host *.example.com # production + Port 2222 +`, + }, + { + name: "match host multiple patterns", + config: `Match Host *.example.com *.example.org + Port 2222 +`, + }, + { + name: "match ALL uppercase round-trip", + config: `Match ALL + Port 4567 +`, + }, + { + name: "match All mixed case round-trip", + config: `Match All + Port 4567 +`, + }, + { + name: "mixed host and match", + config: `Host bastion + Port 22 + +Match Host *.example.com + Port 2222 + +Match all + User fallback +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := Decode(strings.NewReader(tt.config)) + if err != nil { + t.Fatalf("unexpected parse error: %v", err) + } + got := cfg.String() + if got != tt.config { + t.Errorf("round-trip mismatch:\ngot:\n%s\nwant:\n%s", got, tt.config) + } + }) + } +} + +func TestMatchFileRoundTrip(t *testing.T) { + for _, filename := range []string{ + "testdata/match-host", + "testdata/match-all", + "testdata/match-mixed", + "testdata/match-host-negation", + } { + data := loadFile(t, filename) + cfg, err := Decode(strings.NewReader(string(data))) + if err != nil { + t.Fatalf("%s: unexpected parse error: %v", filename, err) + } + got := cfg.String() + if got != string(data) { + t.Errorf("%s: round-trip mismatch:\ngot:\n%s\nwant:\n%s", filename, got, string(data)) + } + } +} + +// TestMatchExistingDirectiveFile tests that the existing testdata/match-directive +// file (which contains "Match all") now parses successfully. +func TestMatchExistingDirectiveFile(t *testing.T) { + us := &UserSettings{ + userConfigFinder: testConfigFinder("testdata/match-directive"), + } + val := us.Get("anyhost", "Port") + if val != "4567" { + t.Errorf("expected Port=4567 via Match all, got %q", val) + } +} diff --git a/parser.go b/parser.go index fdd6ce9..4a6c04e 100644 --- a/parser.go +++ b/parser.go @@ -105,9 +105,7 @@ func (p *sshParser) parseKV() sshParserStateFn { comment = tok.val } if strings.ToLower(key.val) == "match" { - // https://github.com/kevinburke/ssh_config/issues/6 - p.raiseErrorf(val, "ssh_config: Match directive parsing is unsupported") - return nil + return p.parseMatch(val, hasEquals, comment) } if strings.ToLower(key.val) == "host" { strPatterns := strings.Split(val.val, " ") @@ -165,6 +163,73 @@ func (p *sshParser) parseKV() sshParserStateFn { return p.parseStart } +func (p *sshParser) parseMatch(val *token, hasEquals bool, comment string) sshParserStateFn { + // val.val contains everything after "Match ", e.g. "Host *.example.com" + // or "all". + trimmed := strings.TrimRightFunc(val.val, unicode.IsSpace) + spaceBeforeComment := val.val[len(trimmed):] + fields := strings.Fields(trimmed) + if len(fields) == 0 { + p.raiseErrorf(val, "ssh_config: Match directive requires at least one criterion") + return nil + } + criterion := strings.ToLower(fields[0]) + + switch criterion { + case "all": + // "Match all" is equivalent to "Host *" — matches everything. + p.config.Hosts = append(p.config.Hosts, &Host{ + Patterns: []*Pattern{matchAll}, + Nodes: make([]Node, 0), + EOLComment: comment, + spaceBeforeComment: spaceBeforeComment, + hasEquals: hasEquals, + isMatch: true, + matchKeyword: fields[0], // preserve original case + }) + return p.parseStart + + case "host": + patterns := make([]*Pattern, 0) + for _, s := range fields[1:] { + if s == "" { + continue + } + pat, err := NewPattern(s) + if err != nil { + p.raiseErrorf(val, fmt.Sprintf("Invalid host pattern: %v", err)) + return nil + } + patterns = append(patterns, pat) + } + if len(patterns) == 0 { + p.raiseErrorf(val, "ssh_config: Match Host requires at least one pattern") + return nil + } + p.config.Hosts = append(p.config.Hosts, &Host{ + Patterns: patterns, + Nodes: make([]Node, 0), + EOLComment: comment, + spaceBeforeComment: spaceBeforeComment, + hasEquals: hasEquals, + isMatch: true, + matchKeyword: fields[0], // preserve original case + }) + return p.parseStart + + case "exec": + // Match Exec runs arbitrary commands. Supporting it would allow + // untrusted SSH config files to execute code on the parsing + // machine. Reject it explicitly. + p.raiseErrorf(val, "ssh_config: Match Exec is not supported") + return nil + + default: + p.raiseErrorf(val, fmt.Sprintf("ssh_config: unsupported Match criterion %q", criterion)) + return nil + } +} + func (p *sshParser) parseComment() sshParserStateFn { comment := p.getToken() lastHost := p.config.Hosts[len(p.config.Hosts)-1] diff --git a/testdata/match-all b/testdata/match-all new file mode 100644 index 0000000..9d510b3 --- /dev/null +++ b/testdata/match-all @@ -0,0 +1,6 @@ +Host special + Port 1111 + +Match all + Port 4567 + User matchuser diff --git a/testdata/match-host b/testdata/match-host new file mode 100644 index 0000000..7d10326 --- /dev/null +++ b/testdata/match-host @@ -0,0 +1,9 @@ +Match Host *.example.com + Port 2222 + User admin + IdentityFile ~/.ssh/prod_key + +Host *.example.com + Port 22 + User developer + IdentityFile ~/.ssh/dev_key diff --git a/testdata/match-host-negation b/testdata/match-host-negation new file mode 100644 index 0000000..d0d7212 --- /dev/null +++ b/testdata/match-host-negation @@ -0,0 +1,7 @@ +Match Host *.example.com !*.test.example.com + Port 2222 + User prod + +Host *.example.com + Port 22 + User default diff --git a/testdata/match-mixed b/testdata/match-mixed new file mode 100644 index 0000000..28d571f --- /dev/null +++ b/testdata/match-mixed @@ -0,0 +1,21 @@ +Host bastion + Port 22 + User root + +Match Host *.prod.example.com + Port 2222 + User deploy + IdentityFile ~/.ssh/prod_key1 + IdentityFile ~/.ssh/prod_key2 + +Host *.example.com + Port 80 + User webuser + +Match Host *.staging.example.com + Port 3333 + User staging + +Match all + User fallback + IdentityFile ~/.ssh/default_key From f2e12b8b8c1769efe9f803f351fd5d19fd303ce5 Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Sat, 14 Feb 2026 21:16:16 +0900 Subject: [PATCH 11/24] CHANGELOG.md: improve fidelity and dates --- CHANGELOG.md | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba84b51..f9441dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,34 @@ # Changes -## Version 1.4 (released August 2025) +## Unreleased + +- Implement Match support. Most of the Match spec is implemented, including +`Match host`, `Match originalhost`, `Match user`, `Match localuser`, and `Match +all`. `Match exec` is not yet implemented. + +- Add SECURITY.md + +- Add Dependabot configuration + +## Version 1.4 (released August 19, 2025) - Remove .gitattributes file (which was used to test different line endings, and -caused issues in some build environments). +caused issues in some build environments). Store tests/dos-lines as CRLF in git +directly instead. -## Version 1.3 (released February 2025) +## Version 1.3 (released February 20, 2025) - Add go.mod file (although this project has no dependencies). +- config: add UserSettings.ConfigFinder + - Various updates to CI and build environment -- config: add UserSettings.ConfigFinder +## Version 1.2 (released March 31, 2022) -## Version 1.2 +- config: add DecodeBytes to directly read a byte array. +- Strip trailing whitespace from Host declarations and key/value pairs. Previously, if a Host declaration or a value had trailing whitespace, that whitespace would have been included as part of the value. This led to unexpected consequences. For example: @@ -30,3 +44,5 @@ unintuitive. Instead, we strip the trailing whitespace in the configuration, which leads to more intuitive behavior. + +- Add fuzz tests. From e0b4ce953a70794a085eb763967633a835c1e7f4 Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Sat, 14 Feb 2026 21:18:44 +0900 Subject: [PATCH 12/24] 1.5.0 --- config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.go b/config.go index 1c88582..30c9a4b 100644 --- a/config.go +++ b/config.go @@ -40,7 +40,7 @@ import ( "sync" ) -const version = "1.4.0" +const version = "1.5.0" var _ = version From 568811ae7cc1ae0265dcc717bcf5a3f6e416a2b6 Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Sat, 14 Feb 2026 21:26:35 +0900 Subject: [PATCH 13/24] ci: disable setup-go cache, update checkout to v6 setup-go has caching enabled by default and looks for go.sum, which doesn't exist since this project has no external dependencies. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afd139d..564a85b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,8 @@ jobs: uses: actions/setup-go@v6 with: go-version: 1.25.x - - uses: actions/checkout@v5 + cache: false + - uses: actions/checkout@v6 with: path: './src/github.com/kevinburke/ssh_config' # staticcheck needs this for GOPATH @@ -30,7 +31,8 @@ jobs: uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} - - uses: actions/checkout@v5 + cache: false + - uses: actions/checkout@v6 with: path: './src/github.com/kevinburke/ssh_config' - run: | From 04e0fd693c75ffbe3377458a77e19c414b11193c Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Sun, 15 Feb 2026 21:51:11 +0900 Subject: [PATCH 14/24] config: strip surrounding double quotes from parsed values OpenSSH allows values to be surrounded by double quotes (e.g. IdentityFile "/path/to/file"), but Get/GetAll returned those quotes as literal characters, causing file-not-found errors for consumers. Strip surrounding double quotes from KV.Value at parse time, while preserving the original text in a rawValue field so that String() and MarshalText() can roundtrip the config file faithfully. Fixes #61. Co-Authored-By: Claude Opus 4.6 --- config.go | 9 ++++++- config_test.go | 55 ++++++++++++++++++++++++++++++++++++++ match_test.go | 4 +-- parser.go | 7 ++++- testdata/quoted-identities | 9 +++++++ 5 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 testdata/quoted-identities diff --git a/config.go b/config.go index 30c9a4b..6f31c47 100644 --- a/config.go +++ b/config.go @@ -635,6 +635,9 @@ type KV struct { hasEquals bool leadingSpace int // Space before the key. TODO handle spaces vs tabs. position Position + // rawValue preserves the original value text (including surrounding double + // quotes, if any) so that String() can roundtrip the config file faithfully. + rawValue string } // Pos returns k's Position. @@ -651,7 +654,11 @@ func (k *KV) String() string { if k.hasEquals { equals = " = " } - line := strings.Repeat(" ", int(k.leadingSpace)) + k.Key + equals + k.Value + k.spaceAfterValue + val := k.Value + if k.rawValue != "" { + val = k.rawValue + } + line := strings.Repeat(" ", int(k.leadingSpace)) + k.Key + equals + val + k.spaceAfterValue if k.Comment != "" { line += "#" + k.Comment } diff --git a/config_test.go b/config_test.go index fbafa4e..9428835 100644 --- a/config_test.go +++ b/config_test.go @@ -130,6 +130,61 @@ func TestGetIdentities(t *testing.T) { } } +func TestGetQuotedValues(t *testing.T) { + us := &UserSettings{ + userConfigFinder: testConfigFinder("testdata/quoted-identities"), + } + + val, err := us.GetStrict("hasquotedidentity", "IdentityFile") + if err != nil { + t.Fatal(err) + } + want := "/Users/testuser/.ssh/quoted_key" + if val != want { + t.Errorf("IdentityFile with quotes: got %q, want %q", val, want) + } + + val, err = us.GetStrict("hasquotedhostname", "HostName") + if err != nil { + t.Fatal(err) + } + want = "example.com" + if val != want { + t.Errorf("HostName with quotes: got %q, want %q", val, want) + } + + val, err = us.GetStrict("hasunquotedidentity", "IdentityFile") + if err != nil { + t.Fatal(err) + } + want = "/Users/testuser/.ssh/unquoted_key" + if val != want { + t.Errorf("IdentityFile without quotes: got %q, want %q", val, want) + } + + // Verify roundtripping preserves quotes in the output + f, err := os.Open("testdata/quoted-identities") + if err != nil { + t.Fatal(err) + } + defer f.Close() + cfg, err := Decode(f) + if err != nil { + t.Fatal(err) + } + out, err := cfg.MarshalText() + if err != nil { + t.Fatal(err) + } + original, err := os.ReadFile("testdata/quoted-identities") + if err != nil { + t.Fatal(err) + } + if string(out) != string(original) { + t.Errorf("roundtrip mismatch:\ngot:\n%s\nwant:\n%s", out, original) + } +} + func TestGetInvalidPort(t *testing.T) { us := &UserSettings{ userConfigFinder: testConfigFinder("testdata/invalid-port"), diff --git a/match_test.go b/match_test.go index a7eb7b4..a957842 100644 --- a/match_test.go +++ b/match_test.go @@ -266,8 +266,8 @@ func TestMatchDirectiveInline(t *testing.T) { wantVal: "2222", }, { - name: "match host trailing spaces", - config: "Match Host *.example.com \n Port 2222", + name: "match host trailing spaces", + config: "Match Host *.example.com \n Port 2222", alias: "test.example.com", key: "Port", wantVal: "2222", diff --git a/parser.go b/parser.go index 4a6c04e..c524d0e 100644 --- a/parser.go +++ b/parser.go @@ -150,9 +150,14 @@ func (p *sshParser) parseKV() sshParserStateFn { } shortval := strings.TrimRightFunc(val.val, unicode.IsSpace) spaceAfterValue := val.val[len(shortval):] + unquoted := shortval + if len(shortval) >= 2 && shortval[0] == '"' && shortval[len(shortval)-1] == '"' { + unquoted = shortval[1 : len(shortval)-1] + } kv := &KV{ Key: key.val, - Value: shortval, + Value: unquoted, + rawValue: shortval, spaceAfterValue: spaceAfterValue, Comment: comment, hasEquals: hasEquals, diff --git a/testdata/quoted-identities b/testdata/quoted-identities new file mode 100644 index 0000000..8401f15 --- /dev/null +++ b/testdata/quoted-identities @@ -0,0 +1,9 @@ + +Host hasquotedidentity + IdentityFile "/Users/testuser/.ssh/quoted_key" + +Host hasquotedhostname + HostName "example.com" + +Host hasunquotedidentity + IdentityFile /Users/testuser/.ssh/unquoted_key From 538d5a78536d897be3ed48a94d41d863b92961ed Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Sun, 15 Feb 2026 22:12:03 +0900 Subject: [PATCH 15/24] config: default to a space before '#' in EOL comments When a Host or KV is created programmatically with an EOLComment, the unexported spaceBeforeComment/spaceAfterValue fields default to "", producing output like "Host foo#comment" with no space before the '#'. Default to a single space when a comment is present but no spacing has been set. Fixes #50. Co-Authored-By: Claude Opus 4.6 --- config.go | 15 +++++++++++++-- config_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/config.go b/config.go index 6f31c47..ca6332e 100644 --- a/config.go +++ b/config.go @@ -604,8 +604,12 @@ func (h *Host) String() string { } } } - buf.WriteString(h.spaceBeforeComment) if h.EOLComment != "" { + if h.spaceBeforeComment != "" { + buf.WriteString(h.spaceBeforeComment) + } else { + buf.WriteByte(' ') + } buf.WriteByte('#') buf.WriteString(h.EOLComment) } @@ -658,9 +662,16 @@ func (k *KV) String() string { if k.rawValue != "" { val = k.rawValue } - line := strings.Repeat(" ", int(k.leadingSpace)) + k.Key + equals + val + k.spaceAfterValue + line := strings.Repeat(" ", int(k.leadingSpace)) + k.Key + equals + val if k.Comment != "" { + if k.spaceAfterValue != "" { + line += k.spaceAfterValue + } else { + line += " " + } line += "#" + k.Comment + } else { + line += k.spaceAfterValue } return line } diff --git a/config_test.go b/config_test.go index 9428835..8a511e4 100644 --- a/config_test.go +++ b/config_test.go @@ -509,6 +509,33 @@ func TestNoTrailingNewline(t *testing.T) { } } +func TestEOLCommentSpacing(t *testing.T) { + // Reproduces issue #50: programmatically created Host with EOLComment + // should have a space before the '#', not "Host foo#comment". + pattern, err := NewPattern("example") + if err != nil { + t.Fatal(err) + } + host := &Host{ + Patterns: []*Pattern{pattern}, + Nodes: []Node{ + &KV{Key: " Hostname", Value: "1.2.3.4"}, + }, + } + host.EOLComment = "my comment" + got := host.String() + if !strings.Contains(got, "Host example #my comment") { + t.Errorf("expected space before comment, got %q", got) + } + + // Same issue for KV: programmatically created KV with Comment + kv := &KV{Key: " Port", Value: "22", Comment: "ssh port"} + got = kv.String() + if !strings.Contains(got, "22 #ssh port") { + t.Errorf("expected space before KV comment, got %q", got) + } +} + func TestCustomFinder(t *testing.T) { us := &UserSettings{} us.ConfigFinder(func() string { From cc9f70053599eab616aa522f34eae1b32d6d3e23 Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Mon, 16 Feb 2026 07:30:32 +0900 Subject: [PATCH 16/24] config: simplify composite literal in newConfig Also add a check to the repo to ensure that we can't make this same mistake again. Uses github.com/kevinburke/differ to run gofmt -s -w and fail if any files are modified, catching redundant type annotations in composite literals and other simplifications. Co-Authored-By: Claude Opus 4.6 --- Makefile | 1 + config.go | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 25a3570..aa946eb 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ WRITE_MAILMAP := $(GOPATH)/bin/write_mailmap lint: go vet ./... go run honnef.co/go/tools/cmd/staticcheck@latest ./... + go run github.com/kevinburke/differ@latest gofmt -s -w . test: @# the timeout helps guard against infinite recursion diff --git a/config.go b/config.go index ca6332e..949c19a 100644 --- a/config.go +++ b/config.go @@ -768,6 +768,8 @@ func NewInclude(directives []string, hasEquals bool, pos Position, comment strin path = directives[i] } else if system { path = filepath.Join("/etc/ssh", directives[i]) + } else if strings.HasPrefix(directives[i], "~/") { + path = filepath.Join(homedir(), directives[i][2:]) } else { path = filepath.Join(homedir(), ".ssh", directives[i]) } @@ -865,7 +867,7 @@ func init() { func newConfig() *Config { return &Config{ Hosts: []*Host{ - &Host{ + { implicit: true, Patterns: []*Pattern{matchAll}, Nodes: make([]Node, 0), From 6f3abd72f38c8dca4fd146634216f4b5d3301646 Mon Sep 17 00:00:00 2001 From: Neil Williams Date: Mon, 16 Feb 2026 08:58:31 +0900 Subject: [PATCH 17/24] config: support ~ as user's home directory in Include In ssh_config(5) it says: each pathname may contain glob(7) wildcards and, for user configurations, shell-like '~' references to user home directories. This adds support for expanding the ~ into the path for the user's home directory when not parsing a system config. Closes #31. Co-Authored-By: Claude Opus 4.6 --- AUTHORS.txt | 4 +++- config_test.go | 26 ++++++++++++++++++++++++++ testdata/include-shell | 2 ++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 testdata/include-shell diff --git a/AUTHORS.txt b/AUTHORS.txt index c316990..7510398 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -1,11 +1,13 @@ Carlos A Becker +Claude Opus 4.6 Dustin Spicuzza Eugene Terentev Kevin Burke Mark Nevill +Neil Williams Scott Lessans Sergey Lukjanov Simon Josefsson -sio2boss Wayne Ashley Berry santosh653 <70637961+santosh653@users.noreply.github.com> +sio2boss diff --git a/config_test.go b/config_test.go index 8a511e4..b269682 100644 --- a/config_test.go +++ b/config_test.go @@ -404,6 +404,32 @@ func TestIncludeString(t *testing.T) { } } +var shellIncludeFile = []byte(` +# This host should not exist, so we can use it for test purposes / it won't +# interfere with any other configurations. +Host kevinburke.ssh_config.test.example.com + Port 4567 +`) + +func TestIncludeShellHomeDirectory(t *testing.T) { + if testing.Short() { + t.Skip("skipping fs write in short mode") + } + testPath := filepath.Join(homedir(), "kevinburke-ssh-config-shell-include") + err := os.WriteFile(testPath, shellIncludeFile, 0644) + if err != nil { + t.Skipf("couldn't write SSH config file: %v", err.Error()) + } + defer os.Remove(testPath) + us := &UserSettings{ + userConfigFinder: testConfigFinder("testdata/include-shell"), + } + val := us.Get("kevinburke.ssh_config.test.example.com", "Port") + if val != "4567" { + t.Errorf("expected to find Port=4567 in included file, got %q", val) + } +} + var matchTests = []struct { in []string alias string diff --git a/testdata/include-shell b/testdata/include-shell new file mode 100644 index 0000000..e719328 --- /dev/null +++ b/testdata/include-shell @@ -0,0 +1,2 @@ +Host kevinburke.ssh_config.test.example.com + Include ~/kevinburke-ssh-config-shell-include From ac007ea1b91b739786f2a9dc42f5ed9e6e915319 Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Mon, 16 Feb 2026 19:20:13 +0900 Subject: [PATCH 18/24] CHANGELOG.md: add v1.6.0 changes, backfill v1.5.0 section Summarize the three user-facing changes since v1.5.0 (tilde expansion in Include, double-quote stripping, EOL comment spacing) and credit the issue reporters. Move the previously-unreleased items into a proper v1.5.0 section. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9441dd..8670983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,23 @@ # Changes -## Unreleased +## Version 1.6 (released February 16, 2026) + +- Support `~` as the user's home directory in `Include` directives, matching +the behavior described in ssh_config(5). Thanks to Neil Williams for the report +(#31). + +- Strip surrounding double quotes from parsed values. OpenSSH allows values +like `IdentityFile "/path/to/file"`, but Get/GetAll previously returned the +quotes as literal characters. Quotes are now stripped from the returned value +while preserving the original text for faithful roundtripping via String() and +MarshalText(). Thanks to Furkan Türkal for the report (#61). + +- Default to a space before `#` in end-of-line comments. When a Host or KV is +created programmatically with an EOLComment, the output previously had no space +before the `#` (e.g. `Host foo#comment`). A single space is now inserted by +default. Thanks to Yonghui Cheng for the report (#50). + +## Version 1.5 (released February 14, 2026) - Implement Match support. Most of the Match spec is implemented, including `Match host`, `Match originalhost`, `Match user`, `Match localuser`, and `Match From 68fe499c7888e71e9e77108e4f187ae497bca342 Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Mon, 16 Feb 2026 20:10:38 +0900 Subject: [PATCH 19/24] 1.6.0 --- config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.go b/config.go index 949c19a..6365b41 100644 --- a/config.go +++ b/config.go @@ -40,7 +40,7 @@ import ( "sync" ) -const version = "1.5.0" +const version = "1.6.0" var _ = version From f0c70242cb69c94e924cd735b01443c611f33c94 Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Wed, 18 Feb 2026 06:24:53 -0800 Subject: [PATCH 20/24] config: update default values to match current openssh-portable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The defaults were originally sourced from OpenSSH_7.4p1 (2016). Update them to match the current openssh-portable source (readconf.c, myproposal.h). Removed keywords that are deprecated or unsupported in modern OpenSSH: Cipher (SSH1), ChallengeResponseAuthentication (alias for KbdInteractiveAuthentication), CompressionLevel, Protocol, RhostsRSAAuthentication, RSAAuthentication, UsePrivilegedPort, and the SSH1 IdentityFile default (~/.ssh/identity). Updated defaults that have changed: CheckHostIP (yes→no), UpdateHostKeys (no→yes), Ciphers (removed CBC), KexAlgorithms (added post-quantum mlkem768x25519/sntrup761x25519, removed SHA1 variants), and all public key algorithm lists (added sk-*, webauthn-*, rsa-sha2-*; removed ssh-rsa). Added new keywords: ControlPersist, RequestTTY, SessionType, CASignatureAlgorithms, HostbasedAcceptedAlgorithms, and PubkeyAcceptedAlgorithms. Renamed defaultProtocol2Identities to defaultIdentityFiles, removed id_dsa, added id_ecdsa_sk and id_ed25519_sk. Removed ForwardAgent from strict yes/no validation (it now also accepts a socket path). Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 38 ++++++++++++++++++ config_test.go | 11 ++++-- validators.go | 102 ++++++++++++++++++++++++++++++++----------------- 3 files changed, 111 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8670983..a61e119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changes +## Version 1.7 (unreleased) + +Update default values to match current openssh-portable (previously based on +OpenSSH 7.4p1 from 2016). + +Breaking changes: + +- Remove `Cipher` default (SSH protocol 1 only, deprecated in openssh-portable) +- Remove `ChallengeResponseAuthentication` default (alias for `KbdInteractiveAuthentication`) +- Remove `CompressionLevel` default (unsupported in openssh-portable) +- Remove `Protocol` default (silently ignored in openssh-portable) +- Remove `RhostsRSAAuthentication` default (SSH protocol 1 only, unsupported) +- Remove `RSAAuthentication` default (SSH protocol 1 only, unsupported) +- Remove `UsePrivilegedPort` default (deprecated in openssh-portable) +- Remove `IdentityFile` default of `~/.ssh/identity` (SSH protocol 1 only) +- Change `CheckHostIP` default from `"yes"` to `"no"` +- Change `UpdateHostKeys` default from `"no"` to `"yes"` +- Change `Ciphers` default to remove CBC ciphers +- Change `KexAlgorithms` default to add post-quantum algorithms and remove SHA1 variants +- Change `HostKeyAlgorithms` default to add sk-*, webauthn-*, rsa-sha2-* and remove ssh-rsa +- Change `HostbasedKeyTypes` default (same as `HostKeyAlgorithms`) +- Change `PubkeyAcceptedKeyTypes` default (same as `HostKeyAlgorithms`) +- Change `ForwardX11Timeout` default from `"20m"` to `"1200"` (same duration, now in seconds) +- Rename `defaultProtocol2Identities` to `defaultIdentityFiles` +- Remove `~/.ssh/id_dsa` from default identity files +- Remove `ForwardAgent` from strict yes/no validation (now also accepts a socket path) +- Remove `CompressionLevel` from uint validation + +Other changes: + +- Add `ControlPersist` default (`"no"`) +- Add `RequestTTY` default (`"auto"`) +- Add `SessionType` default (`"default"`) +- Add `CASignatureAlgorithms` default +- Add `HostbasedAcceptedAlgorithms` default (new name for `HostbasedKeyTypes`) +- Add `PubkeyAcceptedAlgorithms` default (new name for `PubkeyAcceptedKeyTypes`) +- Add `~/.ssh/id_ecdsa_sk` and `~/.ssh/id_ed25519_sk` to default identity files + ## Version 1.6 (released February 16, 2026) - Support `~` as the user's home directory in `Include` directives, matching diff --git a/config_test.go b/config_test.go index b269682..558fc51 100644 --- a/config_test.go +++ b/config_test.go @@ -110,23 +110,26 @@ func TestGetIdentities(t *testing.T) { if err != nil { t.Errorf("expected nil err, got %v", err) } - if len(val) != len(defaultProtocol2Identities) { + if len(val) != len(defaultIdentityFiles) { // TODO: return the right values here. log.Printf("expected defaults, got %v", val) } else { - for i, v := range defaultProtocol2Identities { + for i, v := range defaultIdentityFiles { if val[i] != v { t.Errorf("invalid %d in val, expected %s got %s", i, v, val[i]) } } } + // "protocol1" host sets Protocol 1, but Protocol is ignored in modern + // OpenSSH (only SSH2 exists). No IdentityFile is set for this host, so + // the result is empty (IdentityFile has no single default value). val, err = us.GetAllStrict("protocol1", "IdentityFile") if err != nil { t.Errorf("expected nil err, got %v", err) } - if len(val) != 1 || val[0] != "~/.ssh/identity" { - t.Errorf("expected [\"~/.ssh/identity\"], got %v", val) + if len(val) != 0 { + t.Errorf("expected [], got %v", val) } } diff --git a/validators.go b/validators.go index 5977f90..8c5da1c 100644 --- a/validators.go +++ b/validators.go @@ -10,7 +10,12 @@ import ( // the keyword is "Port". Default returns the empty string if the keyword has no // default, or if the keyword is unknown. Keyword matching is case-insensitive. // -// Default values are provided by OpenSSH_7.4p1 on a Mac. +// Default values are sourced from the openssh-portable source code. To +// validate or update these defaults, check fill_default_options() in +// readconf.c and the algorithm lists in myproposal.h: +// +// https://github.com/openssh/openssh-portable/blob/master/readconf.c +// https://github.com/openssh/openssh-portable/blob/master/myproposal.h func Default(keyword string) string { return defaults[strings.ToLower(keyword)] } @@ -19,13 +24,11 @@ func Default(keyword string) string { var yesnos = map[string]bool{ strings.ToLower("BatchMode"): true, strings.ToLower("CanonicalizeFallbackLocal"): true, - strings.ToLower("ChallengeResponseAuthentication"): true, strings.ToLower("CheckHostIP"): true, strings.ToLower("ClearAllForwardings"): true, strings.ToLower("Compression"): true, strings.ToLower("EnableSSHKeysign"): true, strings.ToLower("ExitOnForwardFailure"): true, - strings.ToLower("ForwardAgent"): true, strings.ToLower("ForwardX11"): true, strings.ToLower("ForwardX11Trusted"): true, strings.ToLower("GatewayPorts"): true, @@ -38,18 +41,14 @@ var yesnos = map[string]bool{ strings.ToLower("PasswordAuthentication"): true, strings.ToLower("PermitLocalCommand"): true, strings.ToLower("PubkeyAuthentication"): true, - strings.ToLower("RhostsRSAAuthentication"): true, - strings.ToLower("RSAAuthentication"): true, strings.ToLower("StreamLocalBindUnlink"): true, strings.ToLower("TCPKeepAlive"): true, strings.ToLower("UseKeychain"): true, - strings.ToLower("UsePrivilegedPort"): true, strings.ToLower("VisualHostKey"): true, } var uints = map[string]bool{ strings.ToLower("CanonicalizeMaxDots"): true, - strings.ToLower("CompressionLevel"): true, // 1 to 9 strings.ToLower("ConnectionAttempts"): true, strings.ToLower("ConnectTimeout"): true, strings.ToLower("NumberOfPasswordPrompts"): true, @@ -80,30 +79,51 @@ func validate(key, val string) error { return nil } +// defaultPKAlg is the default value for HostKeyAlgorithms, +// HostbasedAcceptedAlgorithms, and PubkeyAcceptedAlgorithms. +// Sourced from KEX_DEFAULT_PK_ALG in myproposal.h. +var defaultPKAlg = strings.Join([]string{ + "ssh-ed25519-cert-v01@openssh.com", + "ecdsa-sha2-nistp256-cert-v01@openssh.com", + "ecdsa-sha2-nistp384-cert-v01@openssh.com", + "ecdsa-sha2-nistp521-cert-v01@openssh.com", + "sk-ssh-ed25519-cert-v01@openssh.com", + "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", + "webauthn-sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", + "rsa-sha2-512-cert-v01@openssh.com", + "rsa-sha2-256-cert-v01@openssh.com", + "ssh-ed25519", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", + "sk-ssh-ed25519@openssh.com", + "sk-ecdsa-sha2-nistp256@openssh.com", + "webauthn-sk-ecdsa-sha2-nistp256@openssh.com", + "rsa-sha2-512", + "rsa-sha2-256", +}, ",") + var defaults = map[string]string{ - strings.ToLower("AddKeysToAgent"): "no", - strings.ToLower("AddressFamily"): "any", - strings.ToLower("BatchMode"): "no", - strings.ToLower("CanonicalizeFallbackLocal"): "yes", - strings.ToLower("CanonicalizeHostname"): "no", - strings.ToLower("CanonicalizeMaxDots"): "1", - strings.ToLower("ChallengeResponseAuthentication"): "yes", - strings.ToLower("CheckHostIP"): "yes", - // TODO is this still the correct cipher - strings.ToLower("Cipher"): "3des", - 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", + strings.ToLower("AddKeysToAgent"): "no", + strings.ToLower("AddressFamily"): "any", + strings.ToLower("BatchMode"): "no", + strings.ToLower("CanonicalizeFallbackLocal"): "yes", + strings.ToLower("CanonicalizeHostname"): "no", + strings.ToLower("CanonicalizeMaxDots"): "1", + strings.ToLower("CheckHostIP"): "no", + strings.ToLower("Ciphers"): "chacha20-poly1305@openssh.com,aes128-gcm@openssh.com,aes256-gcm@openssh.com,aes128-ctr,aes192-ctr,aes256-ctr", strings.ToLower("ClearAllForwardings"): "no", strings.ToLower("Compression"): "no", - strings.ToLower("CompressionLevel"): "6", strings.ToLower("ConnectionAttempts"): "1", strings.ToLower("ControlMaster"): "no", + strings.ToLower("ControlPersist"): "no", strings.ToLower("EnableSSHKeysign"): "no", strings.ToLower("EscapeChar"): "~", strings.ToLower("ExitOnForwardFailure"): "no", strings.ToLower("FingerprintHash"): "sha256", strings.ToLower("ForwardAgent"): "no", strings.ToLower("ForwardX11"): "no", - strings.ToLower("ForwardX11Timeout"): "20m", + strings.ToLower("ForwardX11Timeout"): "1200", strings.ToLower("ForwardX11Trusted"): "no", strings.ToLower("GatewayPorts"): "no", strings.ToLower("GlobalKnownHostsFile"): "/etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2", @@ -112,19 +132,24 @@ var defaults = map[string]string{ strings.ToLower("HashKnownHosts"): "no", strings.ToLower("HostbasedAuthentication"): "no", - 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", - 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", + strings.ToLower("CASignatureAlgorithms"): "ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,webauthn-sk-ecdsa-sha2-nistp256@openssh.com,rsa-sha2-512,rsa-sha2-256", + + // HostbasedAcceptedAlgorithms and HostbasedKeyTypes (obsolete alias) + // both default to KEX_DEFAULT_PK_ALG. + strings.ToLower("HostbasedAcceptedAlgorithms"): defaultPKAlg, + strings.ToLower("HostbasedKeyTypes"): defaultPKAlg, + + strings.ToLower("HostKeyAlgorithms"): defaultPKAlg, // HostName has a dynamic default (the value passed at the command line). strings.ToLower("IdentitiesOnly"): "no", - strings.ToLower("IdentityFile"): "~/.ssh/identity", // IPQoS has a dynamic default based on interactive or non-interactive // sessions. strings.ToLower("KbdInteractiveAuthentication"): "yes", - 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", + strings.ToLower("KexAlgorithms"): "mlkem768x25519-sha256,sntrup761x25519-sha512,sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,diffie-hellman-group14-sha256", strings.ToLower("LogLevel"): "INFO", 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", @@ -135,25 +160,28 @@ var defaults = map[string]string{ strings.ToLower("Port"): "22", strings.ToLower("PreferredAuthentications"): "gssapi-with-mic,hostbased,publickey,keyboard-interactive,password", - strings.ToLower("Protocol"): "2", strings.ToLower("ProxyUseFdpass"): "no", - 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", - strings.ToLower("PubkeyAuthentication"): "yes", - strings.ToLower("RekeyLimit"): "default none", - strings.ToLower("RhostsRSAAuthentication"): "no", - strings.ToLower("RSAAuthentication"): "yes", + + // PubkeyAcceptedAlgorithms and PubkeyAcceptedKeyTypes (obsolete alias) + // both default to KEX_DEFAULT_PK_ALG. + strings.ToLower("PubkeyAcceptedAlgorithms"): defaultPKAlg, + strings.ToLower("PubkeyAcceptedKeyTypes"): defaultPKAlg, + + strings.ToLower("PubkeyAuthentication"): "yes", + strings.ToLower("RekeyLimit"): "default none", + strings.ToLower("RequestTTY"): "auto", strings.ToLower("ServerAliveCountMax"): "3", strings.ToLower("ServerAliveInterval"): "0", + strings.ToLower("SessionType"): "default", strings.ToLower("StreamLocalBindMask"): "0177", strings.ToLower("StreamLocalBindUnlink"): "no", strings.ToLower("StrictHostKeyChecking"): "ask", strings.ToLower("TCPKeepAlive"): "yes", strings.ToLower("Tunnel"): "no", strings.ToLower("TunnelDevice"): "any:any", - strings.ToLower("UpdateHostKeys"): "no", + strings.ToLower("UpdateHostKeys"): "yes", strings.ToLower("UseKeychain"): "no", - strings.ToLower("UsePrivilegedPort"): "no", strings.ToLower("UserKnownHostsFile"): "~/.ssh/known_hosts ~/.ssh/known_hosts2", strings.ToLower("VerifyHostKeyDNS"): "no", @@ -161,12 +189,14 @@ var defaults = map[string]string{ strings.ToLower("XAuthLocation"): "/usr/X11R6/bin/xauth", } -// these identities are used for SSH protocol 2 -var defaultProtocol2Identities = []string{ - "~/.ssh/id_dsa", +// defaultIdentityFiles are the default IdentityFile values. +// Sourced from fill_default_options() in readconf.c. +var defaultIdentityFiles = []string{ + "~/.ssh/id_rsa", "~/.ssh/id_ecdsa", + "~/.ssh/id_ecdsa_sk", "~/.ssh/id_ed25519", - "~/.ssh/id_rsa", + "~/.ssh/id_ed25519_sk", } // these directives support multiple items that can be collected From 9fcf72227fff42524dbc43daf02f5492525d0441 Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Wed, 18 Feb 2026 07:06:18 -0800 Subject: [PATCH 21/24] ci: add Go 1.26 to test matrix, use for linting Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 564a85b..fb62a43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ jobs: - name: Install Go uses: actions/setup-go@v6 with: - go-version: 1.25.x + go-version: 1.26.x cache: false - uses: actions/checkout@v6 with: @@ -24,7 +24,7 @@ jobs: test: strategy: matrix: - 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] + 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, 1.26.x] runs-on: ubuntu-latest steps: - name: Install Go From 8b1b7fc1db95f73310bd999c23bf9ad02452bcbb Mon Sep 17 00:00:00 2001 From: burkebot Date: Fri, 3 Apr 2026 08:59:36 +0000 Subject: [PATCH 22/24] github: add daily Dependabot updates Set Dependabot to run daily for the existing Go module updates and add a daily GitHub Actions update job so workflow dependencies stay current as well. --- .github/dependabot.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cd88554..65791f9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,4 +8,8 @@ updates: - package-ecosystem: "gomod" # See documentation for possible values directory: "/" # Location of package manifests schedule: - interval: "weekly" + interval: "daily" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" From 1428bf403e2830aea0649a62735692f7ee292abd Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Fri, 24 Apr 2026 09:08:06 -0700 Subject: [PATCH 23/24] github: pin workflow actions by hash --- .github/workflows/ci.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb62a43..8aa550c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,15 +1,17 @@ on: [push, pull_request] name: Test +permissions: + contents: read jobs: lint: runs-on: ubuntu-latest steps: - name: Install Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: 1.26.x cache: false - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: path: './src/github.com/kevinburke/ssh_config' # staticcheck needs this for GOPATH @@ -28,11 +30,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Install Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: ${{ matrix.go-version }} cache: false - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: path: './src/github.com/kevinburke/ssh_config' - run: | From 4d385a767fef80d987d0a5d1a76f3a7c81c4e4b0 Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Fri, 24 Apr 2026 09:28:06 -0700 Subject: [PATCH 24/24] github: quote GITHUB_ENV redirects --- .github/workflows/ci.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8aa550c..5813222 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,9 +16,11 @@ jobs: path: './src/github.com/kevinburke/ssh_config' # staticcheck needs this for GOPATH - run: | - echo "GO111MODULE=on" >> $GITHUB_ENV - echo "GOPATH=$GITHUB_WORKSPACE" >> $GITHUB_ENV - echo "PATH=$GITHUB_WORKSPACE/bin:$PATH" >> $GITHUB_ENV + { + echo "GO111MODULE=on" + echo "GOPATH=$GITHUB_WORKSPACE" + echo "PATH=$GITHUB_WORKSPACE/bin:$PATH" + } >> "$GITHUB_ENV" - name: Run tests run: make lint working-directory: './src/github.com/kevinburke/ssh_config' @@ -38,9 +40,11 @@ jobs: with: path: './src/github.com/kevinburke/ssh_config' - run: | - echo "GO111MODULE=off" >> $GITHUB_ENV - echo "GOPATH=$GITHUB_WORKSPACE" >> $GITHUB_ENV - echo "PATH=$GITHUB_WORKSPACE/bin:$PATH" >> $GITHUB_ENV + { + echo "GO111MODULE=off" + echo "GOPATH=$GITHUB_WORKSPACE" + echo "PATH=$GITHUB_WORKSPACE/bin:$PATH" + } >> "$GITHUB_ENV" - name: Run tests with race detector on run: make race-test working-directory: './src/github.com/kevinburke/ssh_config'