Fix persistent EOF errors: detect and evict dead SSH connections #63
No reviewers
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
keyop-go/nfq_forwarder!63
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue21_EOF_errors"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #21.
Root cause
sshConnections.connection()dialed the SSH connection to the jump host once and cached it forever, with no liveness check. If the underlying connection died silently (e.g. a NAT/firewall idle timeout dropping it without either end sending a FIN/RST), every future packet on that queue kept reusing the same dead client. The per-connection destination dial (sshClient.DialinsidestartTunnel) then failed immediately withEOF- forever, since nothing ever evicted the dead connection, until the whole process was restarted. This matches the reported log pattern exactly.Worse: once a tunnel's DNAT rule existed for a destination, NFQUEUE was never consulted again for it (by design, for normal operation), so a destination whose dial failed even once stayed silently DNAT'd into a dead end forever, with no way to self-heal.
Fix
Three pieces, working together:
sshtunnel.Conn.startKeepalive: a goroutine (started byDial) that periodically sends an SSHkeepalive@openssh.comrequest and force-closes the connection if no reply arrives within one interval.golang.org/x/crypto/sshhas no built-in equivalent of OpenSSH'sServerAliveInterval, so without this a dead connection is only ever discovered reactively. The interval is configurable viassh_keepalive_interval_secondsin a--configYAML file (Configuration.SSHKeepaliveIntervalSeconds), defaulting to 30s (sshtunnel.DefaultKeepaliveInterval) when unset.sshtunnel.Conn.Wait: blocks until the underlying transport closes for any reason.sshConnections.connection()spawns a goroutine (evictOnDeath) that calls this right after a successful dial and removes the conn from the pool once it returns, so the nextconnection()call redials fresh instead of reusing a client whose transport has already failed.sshtunnel.Conn.SetTunnelFailedHook: letsstartTunneltear down (and notify on) a specific tunnel whose destination dial fails, rather than leaving it open and DNAT'd forever.sshConnections.connection()wires this to remove the tunnel's DNAT rule (via a newdeleteDNATRuleForTunnelFuncindirection, matching the existingaddDNATRulesFunc/sshDialFunctestability pattern), so the next connection attempt to that destination gets a fresh chance through NFQUEUE.Testing
internal/sshtunnel: new real (in-process, ephemeral-key, auth-free) SSH client/server test harness (testserver_test.go) exercisesWait,startKeepalive's timeout/reply/close-during-flight paths, andfailTunnel's hook firing on a real rejected destination dial - a mock can't reproduce real SSH transport/global-request/channel-open behavior, so this is a real client/server pair, not a fake.internal/nfq_forwarder: a similar real (file-backed key/known_hosts) SSH server harness (sshserver_test.go) exercisessshConnections' eviction-and-redial and DNAT-cleanup-hook wiring end to end - a bare sentinel*sshtunnel.Conn{}(used by most other tests in this file) deliberately can't exercise either, sinceWait()blocks forever on one by design.internal/config: new tests for the keepalive-interval field (pass-through, left-zero-for-the-consumer-to-default, negative-value rejection).go build,make lint(golangci-lint + yamllint),make test(go test -race -cover ./...) all clean.Not included
CLAUDE.md's Known Issues as still open, same as this project's other real-host-only verification items.mentioned in issue #21
assigned to @danny
requested review from @danny
Looks good to me. Need to do some real-world testing before merge though.
approved this merge request
All seems to work as expected. Long term testing will be required to see if this fully resolves the issue, but it certainly doesn't seem to make anything worse.
Will merge.
mentioned in commit
6e320bbb1bmentioned in commit
be208f5787