feat: add native Go SSH2 client/server implementation#123
Merged
DavidObando merged 16 commits intoMay 1, 2026
Conversation
Full-parity Go implementation of the Dev Tunnels SSH library, matching the existing C# and TypeScript implementations. Includes: - Core SSH2 protocol: session lifecycle, key exchange, algorithm negotiation, channel multiplexing, and flow control - Authentication: none, password, public key, host-based, and keyboard-interactive methods - Algorithms: ECDH/DH key exchange, RSA/ECDSA public keys, AES-256-GCM/CBC/CTR encryption, HMAC-SHA2-256/512 with ETM variants - Key import/export: PKCS#1, PKCS#8, SEC1, OpenSSH, SSH2, JWK formats - TCP client/server wrappers with port forwarding service - Session reconnection and piping (relay) support - Non-blocking per-channel request dispatch via goroutines - Keep-alive, metrics, custom message handlers, service activation - ~679 unit tests, ~158 integration tests, ~48 E2E tests - Cross-implementation interop validated against C# and TypeScript
jasongin
reviewed
Mar 5, 2026
jasongin
reviewed
Mar 5, 2026
DavidObando
reviewed
Mar 5, 2026
DavidObando
left a comment
Member
There was a problem hiding this comment.
I've added some general feedback but I'll need more time to better understand the large change.
Add a comprehensive benchmark suite that measures SSH library performance across all three platform implementations with unified JSON output and automated cross-platform comparison reporting. - Define common JSON result schema for consistent cross-platform output - Add C# benchmarks: encryption, HMAC, KEX, keygen, signatures, serialization - Add TypeScript benchmarks: matching C# coverage with Node.js harness - Add Go benchmarks: native Go SSH implementation benchmarks - Build orchestrator script to coordinate builds, runs, and reporting - Build Markdown report generator with trimmed-mean statistics - Integrate benchmarks into the build system - Add bench/README.md documenting usage and architecture
…ghput benchmarks - Add --verify flag across all platforms (C#, TS, Go) with correctness checks for every benchmark category (encryption, HMAC, KEX, keygen, signatures, serialization, session setup, throughput, port forward, reconnect) - Fix Go benchmarks to use TCP loopback (matching C#/TS) instead of io.Pipe - Fix C#/TS throughput benchmarks to reuse sessions across runs with safety timeouts, eliminating backpressure deadlocks on large encrypted messages - Align throughput duration to 2s across all platforms - Add report validation warnings for zero/negative metrics and >10x cross- platform divergence, with 200x threshold for algorithm categories - Document expected Go performance advantages and verification coverage in README
…pelining Throughput (~1.9x encrypted small-message improvement, 23.7K → 45.2K msgs/s): - Reuse GCM cipher buffers (aadBuf, sealBuf, tag) instead of allocating per-message; Sign() and SetTag() now avoid copies - Coalesce packet + MAC into single Write() call by pre-allocating packet buffer with extra capacity for the MAC - Reuse HMAC input buffers (sendHmacBuf/recvHmacBuf) across messages - Add sync.Pool for packet buffers with security zeroing before return - Add reusable SSHDataWriter for direct message serialization (eliminates ToBuffer() double-serialization) - Use math/rand for unencrypted padding instead of crypto/rand - Replace session.mu lock in SendMessage with atomic connected flag Session setup (latency-sensitive setup reduced from ~1929ms to ~710ms): - Fire-and-forget ServiceRequest in Authenticate() (skip ServiceAccept wait) - Synchronize post-KEX goroutines (extensionInfo, reconnect) before auth to avoid sendMu contention that added ~200ms per round-trip - Inline NewKeys, ExtensionInfo, and reconnect-enable sends from the dispatch loop instead of spawning goroutines, matching C#/TS behavior - Pipeline version string + KexInit in Connect() (send KexInit before reading remote version per RFC 4253, saving one round-trip) - Add 64KB bufio.Writer to reduce write syscall overhead Reconnect reliability: - Add reconnectInfoReady channel so Reconnect() waits for the remote's enable-reconnect to be processed before returning - Explicitly cache the enable-reconnect message in enableReconnect() to solve the chicken-and-egg where the message that triggers caching wasn't itself cached Benchmark verification: - Add key size assertions to keygen and signature verification across all three platforms (Go, C#, TS) to catch parameter mismatches - Fix Go keygen benchmark that was ignoring the key size parameter - Fix data race on kexService.exchanging (bool → atomic int32) - Rewrite latencyStream with queue-based single-drainer for non-blocking writes matching C#/TS behavior Tests: - Add unit tests for SSHDataWriter.Slice(), GenerateKeyPairWithSize(), sendMessageDirect(), buffer reuse with varying payload sizes, GCM/HMAC aliasing contracts - Fix gcm_tampering_test to copy tag before mutating - Fix asyncPipeRWC.Write() race condition (pre-check closeCh)
…nteractive auth Add 4 new e2e test suites (12 tests) covering SSH features that previously had only unit test coverage: exit-status/exit-signal/standalone signals, SendExtendedData (stderr) bidirectional flow, key rotation with low threshold, and multi-round-trip keyboard-interactive authentication.
…sfeedback Address all actionable review comments from PR microsoft#123 code review: Security & correctness: - Upgrade Go from 1.17 to 1.21 for crypto security fixes - Migrate ECDH from deprecated elliptic.ScalarMult to crypto/ecdh (constant-time, automatic IsOnCurve validation) - Fix bcrypt magic string comments (OxychromaticBlowfishSwatDynamite) - Add DefaultPbkdf2Iterations const alongside mutable var Session lifecycle: - Refactor close()/closeWithError() into unified closeImpl() fixing reconnect check, closeMetrics, and custom error bugs - Move done/kexDone channel init under session mutex - Restore currentAlgorithms in reconnect restoreState Channel robustness: - Derive request contexts from session done channel - Log window adjust send failures - Add accept queue bound (64) with resource shortage rejection - Use SendMessage for channel-failure replies (disconnected-buffer) - Add DroppedRequests counter to ChannelMetrics Protocol: - Fix reconnect cache memory leak with slice compaction Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The TCP e2e tests deadlocked because createDuplexStreams used raw io.Pipe which has no write buffering. During SSH Connect, both sides pipeline version string + kexInit writes in a goroutine, then wait for the writes to finish before starting the dispatch loop. With unbuffered pipes, both goroutines block on Flush (pipe Write) waiting for readers that haven't started classic deadlock.yet Replace duplexStream with asyncPipeRWC (matching the ssh package tests) which buffers writes through a channel + background goroutine, simulating the kernel buffer behavior of real TCP sockets. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port the race condition fix from C# PR microsoft#132: microsoft#132 In closeImpl(), s.protocol was accessed outside the mutex (after unlock at the end of the guard block). A concurrent reconnect could replace s.protocol between that unlock and the subsequent use at lines 495/512, causing the close to disconnect/close the wrong (new) connection. Fix by capturing proto := s.protocol inside the lock, then using that captured reference for the disconnect send and protocol close, matching the C# fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…feedback Davidobando/fix pr 123 review feedback
DavidObando
added a commit
that referenced
this pull request
Apr 9, 2026
Address all actionable review comments from PR #123 code review: Security & correctness: - Upgrade Go from 1.17 to 1.21 for crypto security fixes - Migrate ECDH from deprecated elliptic.ScalarMult to crypto/ecdh (constant-time, automatic IsOnCurve validation) - Fix bcrypt magic string comments (OxychromaticBlowfishSwatDynamite) - Add DefaultPbkdf2Iterations const alongside mutable var Session lifecycle: - Refactor close()/closeWithError() into unified closeImpl() fixing reconnect check, closeMetrics, and custom error bugs - Move done/kexDone channel init under session mutex - Restore currentAlgorithms in reconnect restoreState Channel robustness: - Derive request contexts from session done channel - Log window adjust send failures - Add accept queue bound (64) with resource shortage rejection - Use SendMessage for channel-failure replies (disconnected-buffer) - Add DroppedRequests counter to ChannelMetrics Protocol: - Fix reconnect cache memory leak with slice compaction Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adjustments to tests based on Jason's feedback and test pass run
DavidObando
previously approved these changes
Apr 9, 2026
- Fix deadlock in session.Connect(): close s.done channel via defer if Connect() returns before the dispatch loop starts, preventing closeImpl() from blocking forever on <-s.done. - Fix TestMeasureSessionLatency: increase context timeout from 10s to 30s to accommodate -race overhead on slow CI machines (especially Windows). - Fix TestRecordSessionContour: add WaitUntilReconnectEnabled() call before sending data, ensuring the reconnect extension is fully enabled on both sides so latency info is appended to messages. - Fix TestClosedSessionHasNoLatency: increase context timeout from 10s to 30s for consistency with other latency tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fix flaky Go test deadlocks and latency measurement timeouts
DavidObando
approved these changes
Apr 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Add native Go SSH2 client/server implementation
Key overview files
Summary
This PR adds a full-parity Go implementation of the Dev Tunnels SSH library alongside
the existing C# and TypeScript implementations. A client or server built with any of
the three implementations can communicate with any other — they share the same protocol,
algorithm negotiation, extensions, and behavioral semantics.
205 files changed across source (
src/go/), tests (test/go/,test/e2e/),documentation, and CI/CD:
ssh(core protocol)keys(key import/export)tcp(TCP + port forwarding)test/go/(integration + interop)The test-to-production ratio is roughly 2.2:1.
Overview
The Go implementation preserves the same API shape as C# and TypeScript while adapting
to Go's concurrency model and conventions:
Goroutines instead of async/await. C# and TypeScript use
async/awaitto runrequest handlers inline without blocking the dispatch loop. Go doesn't have async/await,
so each channel gets a dedicated goroutine with a buffered request queue (capacity 16).
This gives the same behavioral property — a slow handler on one channel never blocks
another — using the idiomatic Go primitive.
Callbacks instead of events. C# uses
EventHandler<T>, TypeScript usesvscode-jsonrpc
Emitter/Event. Go uses exportedfuncfields(
OnAuthenticating,OnChannelOpening,OnRequest, etc.) with thread-safeSet*Handler()methods for late binding. This avoids pulling in an event frameworkfor what amounts to single-subscriber callbacks.
io.ReadWriteClosereverywhere. SSH sessions accept anyio.ReadWriteCloser— pipes, TCP connections, WebSocket adapters, anything. The
Streamwrapper exposeschannels as standard
io.ReadWriteCloserso they compose withio.Copy,bufio,encoding/json, and the rest of the Go stdlib.Zero external dependencies. The
src/go/module uses only the Go standard library.Crypto is handled by
crypto/ecdsa,crypto/rsa,crypto/aes, etc. which delegate toplatform-native implementations (OpenSSL on Linux, Security.framework on macOS, CNG on
Windows). Test modules use
testifyfor assertions.PascalCase exports, camelCase internals. Follows Go naming conventions: exported
symbols are
PascalCase, unexported arecamelCase. Acronyms likeSSH,HMAC,AESare all-caps per Go convention.
Error values, not exceptions. All fallible operations return
error. Typed errors(
ConnectionError,ChannelError,ReconnectError) carry structured reason codesthat map to SSH disconnect/failure reason codes.
Feature parity with C# and TypeScript
Algorithms — 100% parity
Features — 100% parity
Key formats — full coverage
Test coverage
Unit tests (~679 tests in
src/go/)Colocated with production code following Go convention. Coverage includes:
ssh/_test.gofilesssh/messages/_test.gofilesssh/algorithms/_test.gofilesssh/io/_test.gofilekeys/_test.gofilestcp/_test.gofiles40 explicit parity tests verify behavioral alignment with C# and TypeScript
(named
*_parity_test.go), covering encryption output, HMAC output, channelsemantics, metrics, reconnection, piping, service activation, and secure streams.
Integration tests (~158 tests in
test/go/ssh-test/)Full client/server scenarios over in-memory streams using the
SessionPairtestharness (no TCP, no flakiness):
ssh_test.gochannel_request_test.goport_forwarding_test.gointerop_test.gomulti_channel_stream_test.gokey_exchange_test.goauth_pubkey_test.gochannel_test.gosecure_stream_test.goE2E tests (~48 tests in
test/e2e/)Real TCP connections with real cryptographic handshakes, orchestrated by bash scripts:
e2e-go-selftest.she2e-interop.she2e-go-reconnect.she2e-go-portfwd.she2e-go-concurrent-requests.she2e-go-pipe-request.she2e-cs-selftest.she2e-ts-selftest.shThe orchestrator (
e2e-validate.sh) runs all suites with a gate check(
go vet+go test -race -short) before E2E execution.Major components — review guide
1. Core protocol (
src/go/ssh/)The heart of the implementation. Key files and what to look for:
session.goConnect(),Close(), version exchange. Check thatDone()channel is properly closed on all exit paths.session_dispatch.gosession_channels.gochannel.goPipe(). Check panic recovery doesn't deadlock (usescloseRequestsCh()notClose()). VerifyCtxis set on allRequestEventArgs.session_encryption.goprotocol.goauth_service.gokey_exchange_service.goreconnect.gopipe.go,pipe_session.goconfig.go2. Key management (
src/go/keys/)Import/export for 7 key formats. Review focus:
import_export.gopkcs8_format.goopenssh_format.gojwk_format.gobcrypt.go3. TCP transport (
src/go/tcp/)TCP wrappers and port forwarding. Review focus:
port_forwarding.goclient.go,server.go4. Test infrastructure (
test/go/ssh-test/helpers/)session_pair.goClose()— sessions before streams.mock_network_stream.go5. Interop helper (
test/go/interop/go/main.go)~1,400 line CLI binary supporting 8 test modes. Used by both integration tests
(
interop_test.go) and E2E scripts. Review focus: protocol marker output(
LISTENING,AUTHENTICATED,ECHO_OK, etc.) must be deterministic and parseable.6. CI/CD changes
.github/workflows/pr-build.yamlsetup-go@v5, Go build/test steps, E2E test step (macOS only)build.jsbuild/testcommands,--raceflag supportpackage.jsonbuild-goandtest-gonpm script aliases.gitignore*.testpattern tosrc/go/andtest/go/Public API surface and example usage
The Go library exposes three packages mirroring the C# and TypeScript structure:
Package overview
Core types
Client example
Server example
Stream-based (non-TCP)
Port forwarding
Key import/export
Session reconnection
Session piping (relay)
How to test locally