Skip to content

Commit bde5d53

Browse files
committed
simplfy ssh tunnel logic
1 parent af8af34 commit bde5d53

7 files changed

Lines changed: 121 additions & 170 deletions

File tree

flow/connectors/mysql/mysql.go

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -70,24 +70,21 @@ func NewMySqlConnector(ctx context.Context, config *protos.MySqlConfig) (*MySqlC
7070
rdsAuth: rdsAuth,
7171
}
7272
c.contexts.Store(&contexts)
73+
if ssh != nil {
74+
go func() {
75+
sshErr := <-ssh.Watch()
76+
c.logger.Info("SSH tunnel closed, closing MySQL connection", slog.Any("error", sshErr))
77+
if conn := c.conn.Swap(nil); conn != nil {
78+
if err := conn.Close(); err != nil {
79+
c.logger.Error("Failed to close MySQL connection", slog.Any("error", err))
80+
}
81+
}
82+
}()
83+
}
7384
go func() { //nolint:gosec // G118: long-lived goroutine, not request-scoped
74-
ctx := context.Background()
7585
for {
7686
var ok bool
7787
select {
78-
case <-ssh.GetKeepaliveChan(ctx):
79-
c.logger.Info("SSH keepalive failed, closing connection")
80-
ctx = context.Background()
81-
// close the SSH client so that BinlogSyncer notices too
82-
if err := ssh.Client.Close(); err != nil {
83-
c.logger.Error("Failed to close SSH client", slog.Any("error", err))
84-
}
85-
if conn := c.conn.Swap(nil); conn != nil {
86-
c.logger.Info("Closing connection due to SSH keepalive failure")
87-
if err := conn.Close(); err != nil {
88-
c.logger.Error("Failed to close MySQL connection", slog.Any("error", err))
89-
}
90-
}
9188
case <-ctx.Done():
9289
c.logger.Info("ctx canceled, closing connection")
9390
ctx = context.Background()

flow/connectors/mysql/ssh_keepalive_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ func setupMySQLSSHKeepaliveHarness(ctx context.Context, t *testing.T, proxyName
110110
t.Helper()
111111

112112
connector, sshProxy := setupMySQLConnectorWithSSHProxy(ctx, t, proxyName, proxyPort)
113-
keepaliveChan := connector.ssh.GetKeepaliveChan(ctx)
113+
keepaliveChan := connector.ssh.Watch()
114114

115115
return connector, utils.SSHKeepaliveTestConfig{
116116
SSHProxy: sshProxy,
@@ -198,7 +198,7 @@ func TestMySQLSSHKeepaliveCDCHang(t *testing.T) {
198198
connector, sshProxy := setupMySQLConnectorWithSSHProxy(ctx, t, "my-ssh-cdc-down-test", toxiproxyCDCHangProxyPort)
199199
defer connector.Close()
200200

201-
keepaliveChan := connector.ssh.GetKeepaliveChan(ctx)
201+
keepaliveChan := connector.ssh.Watch()
202202
require.NotNil(t, keepaliveChan, "SSH keepalive channel should exist")
203203

204204
req, otelManager := setupCDCPullRecords(ctx, t, connector, "test_ssh_cdc_hang")
@@ -252,7 +252,7 @@ func TestMySQLSSHKeepaliveCDCCloseHang(t *testing.T) {
252252
connector, sshProxy := setupMySQLConnectorWithSSHProxy(ctx, t, "my-ssh-cdc-latency-test", toxiproxyCDCCloseHangProxyPort)
253253
defer connector.Close()
254254

255-
keepaliveChan := connector.ssh.GetKeepaliveChan(ctx)
255+
keepaliveChan := connector.ssh.Watch()
256256
require.NotNil(t, keepaliveChan, "SSH keepalive channel should exist")
257257

258258
req, otelManager := setupCDCPullRecords(ctx, t, connector, "test_ssh_cdc_close_hang")

flow/connectors/postgres/postgres.go

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -129,24 +129,24 @@ func NewPostgresConnector(ctx context.Context, env map[string]string, pgConfig *
129129
rdsAuth: rdsAuth,
130130
}
131131

132-
tunnel.StartKeepalive(context.Background(), func() {
133-
connector.logger.Info("SSH keepalive failed, closing connections")
134-
bgCtx := context.Background()
135-
timeout, cancel := context.WithTimeout(bgCtx, 5*time.Second)
136-
defer cancel()
137-
if err := connector.conn.Close(timeout); err != nil {
138-
connector.logger.Error("Failed to close Postgres connection on SSH keepalive failure",
139-
slog.Any("error", err))
140-
}
141-
if connector.replConn != nil {
142-
timeout2, cancel2 := context.WithTimeout(bgCtx, 5*time.Second)
143-
defer cancel2()
144-
if err := connector.replConn.Close(timeout2); err != nil {
145-
connector.logger.Error("Failed to close Postgres replication connection on SSH keepalive failure",
146-
slog.Any("error", err))
132+
if tunnel != nil {
133+
go func() {
134+
sshErr := <-tunnel.Watch()
135+
connector.logger.Warn("SSH tunnel closed, closing Postgres connections", slog.Any("error", sshErr))
136+
timeout, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
137+
defer cancel()
138+
if err := connector.conn.Close(timeout); err != nil {
139+
connector.logger.Error("failed to close Postgres connection", slog.Any("error", err))
147140
}
148-
}
149-
})
141+
if connector.replConn != nil {
142+
replTimeout, replCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
143+
defer replCancel()
144+
if err := connector.replConn.Close(replTimeout); err != nil {
145+
connector.logger.Error("failed to close Postgres replication connection", slog.Any("error", err))
146+
}
147+
}
148+
}()
149+
}
150150

151151
return connector, nil
152152
}

flow/connectors/postgres/ssh_keepalive_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ func setupPostgresConnectorWithSSH(ctx context.Context, t *testing.T, proxyName
4444
err = connector.ConnectionActive(ctx)
4545
require.NoError(t, err, "Initial connection should work")
4646

47-
keepaliveChan := connector.ssh.GetKeepaliveChan(ctx)
47+
keepaliveChan := connector.ssh.Watch()
4848

4949
return connector, utils.SSHKeepaliveTestConfig{
5050
SSHProxy: sshProxy,

flow/connectors/postgres/ssh_wrapped_conn.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@ func NewPostgresConnFromConfig(
1818
connConfig *pgx.ConnConfig,
1919
tlsHost string,
2020
rdsAuth *utils.RDSAuth,
21-
tunnel *utils.SSHTunnel,
21+
ssh *utils.SSHTunnel,
2222
) (*pgx.Conn, error) {
23-
if tunnel != nil && tunnel.Client != nil {
23+
if ssh != nil && ssh.Client != nil {
2424
connConfig.DialFunc = func(ctx context.Context, network, addr string) (net.Conn, error) {
25-
conn, err := tunnel.Client.DialContext(ctx, network, addr)
25+
conn, err := ssh.Client.DialContext(ctx, network, addr)
2626
if err != nil {
2727
return nil, err
2828
}

flow/connectors/utils/ssh.go

Lines changed: 83 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ package utils
33
import (
44
"context"
55
"encoding/base64"
6+
"errors"
67
"fmt"
78
"log/slog"
8-
"sync/atomic"
9+
"sync"
910
"time"
1011

12+
"go.temporal.io/sdk/log"
1113
"golang.org/x/crypto/ssh"
1214

1315
"github.com/PeerDB-io/peerdb/flow/generated/protos"
@@ -16,13 +18,17 @@ import (
1618
"github.com/PeerDB-io/peerdb/flow/shared/exceptions"
1719
)
1820

19-
const SSHKeepaliveInterval = 15 * time.Second
21+
const (
22+
SSHKeepaliveInterval = 15 * time.Second
23+
SSHRequestTimeout = 10 * time.Second
24+
)
2025

2126
type SSHTunnel struct {
2227
*ssh.Client
23-
logger *slog.Logger
24-
keepaliveChan atomic.Pointer[chan struct{}]
25-
badTunnel bool
28+
logger log.Logger
29+
watch chan struct{}
30+
err error
31+
closeOnce sync.Once
2632
}
2733

2834
// GetSSHClientConfig returns an *ssh.ClientConfig based on provided credentials.
@@ -76,128 +82,98 @@ func NewSSHTunnel(
7682
ctx context.Context,
7783
sshConfig *protos.SSHConfig,
7884
) (*SSHTunnel, error) {
79-
if sshConfig != nil {
80-
logger := internal.LoggerFromCtx(ctx)
81-
sshServer := shared.JoinHostPort(sshConfig.Host, sshConfig.Port)
82-
clientConfig, err := GetSSHClientConfig(sshConfig)
83-
if err != nil {
84-
logger.Error("Failed to get SSH client config", slog.Any("error", err))
85-
return nil, err
86-
}
85+
if sshConfig == nil {
86+
return nil, nil
87+
}
8788

88-
logger.Info("Setting up SSH connection", slog.String("Server", sshServer))
89-
client, err := ssh.Dial("tcp", sshServer, clientConfig)
90-
if err != nil {
91-
return nil, exceptions.NewSSHTunnelSetupError(err)
92-
}
89+
logger := internal.LoggerFromCtx(ctx)
90+
sshServer := shared.JoinHostPort(sshConfig.Host, sshConfig.Port)
91+
clientConfig, err := GetSSHClientConfig(sshConfig)
92+
if err != nil {
93+
logger.Error("Failed to get SSH client config", slog.Any("error", err))
94+
return nil, err
95+
}
9396

94-
return &SSHTunnel{
95-
Client: client,
96-
logger: internal.SlogLoggerFromCtx(ctx),
97-
}, nil
97+
logger.Info("Setting up SSH connection", slog.String("Server", sshServer))
98+
client, err := ssh.Dial("tcp", sshServer, clientConfig)
99+
if err != nil {
100+
return nil, exceptions.NewSSHTunnelSetupError(err)
98101
}
99102

100-
return nil, nil
103+
tunnel := &SSHTunnel{
104+
Client: client,
105+
logger: internal.SlogLoggerFromCtx(ctx),
106+
watch: make(chan struct{}),
107+
}
108+
109+
go tunnel.runKeepaliveLoop()
110+
111+
return tunnel, nil
112+
}
113+
114+
func (tunnel *SSHTunnel) Watch() <-chan struct{} {
115+
if tunnel == nil || tunnel.Client == nil {
116+
return nil
117+
}
118+
return tunnel.watch
101119
}
102120

103121
func (tunnel *SSHTunnel) Close() error {
104-
if tunnel != nil && tunnel.Client != nil {
105-
if keepaliveChan := tunnel.keepaliveChan.Swap(nil); keepaliveChan != nil {
106-
close(*keepaliveChan)
107-
}
108-
tunnel.badTunnel = true
109-
return tunnel.Client.Close()
122+
if tunnel == nil || tunnel.Client == nil {
123+
return nil
110124
}
111-
return nil
125+
tunnel.shutdown(nil)
126+
return tunnel.err
127+
}
128+
129+
func (tunnel *SSHTunnel) shutdown(err error) {
130+
tunnel.closeOnce.Do(func() {
131+
tunnel.err = err
132+
close(tunnel.watch)
133+
if closeErr := tunnel.Client.Close(); closeErr != nil && tunnel.err == nil {
134+
tunnel.err = closeErr
135+
}
136+
})
112137
}
113138

114-
func (tunnel *SSHTunnel) runKeepaliveLoop(
115-
ctx context.Context, stopChan <-chan struct{}, onFailure func(),
116-
) {
139+
func (tunnel *SSHTunnel) runKeepaliveLoop() {
117140
ticker := time.NewTicker(SSHKeepaliveInterval)
118141
defer ticker.Stop()
119-
logger := tunnel.logger
120-
// in case request hangs, we want to detect that and not send another request
121-
requestSent := atomic.Bool{}
122-
var keepaliveErr error
123-
// closed by request making goroutine to signal error, keepaliveErr
124-
errChan := make(chan struct{})
125-
126142
for {
127143
select {
144+
case <-tunnel.watch:
145+
// tunnel closed
146+
return
128147
case <-ticker.C:
129-
if requestSent.Load() {
130-
// Previous keepalive request didn't return yet, something's wrong
131-
logger.ErrorContext(ctx, "Previous keepalive request still pending, marking tunnel as bad")
132-
if keepaliveChan := tunnel.keepaliveChan.Swap(nil); keepaliveChan != nil {
133-
close(*keepaliveChan)
134-
}
135-
tunnel.badTunnel = true
136-
if onFailure != nil {
137-
onFailure()
138-
}
148+
success := tunnel.sendKeepAlive()
149+
if !success {
139150
return
140151
}
141-
go func() {
142-
requestSent.Store(true)
143-
_, _, err := tunnel.Client.SendRequest("keepalive@openssh.com", true, nil)
144-
requestSent.Store(false)
145-
if err != nil {
146-
keepaliveErr = err
147-
close(errChan)
148-
}
149-
}()
150-
case <-ctx.Done():
151-
if keepaliveChan := tunnel.keepaliveChan.Swap(nil); keepaliveChan != nil {
152-
close(*keepaliveChan)
153-
}
154-
return
155-
case <-stopChan:
156-
// channel closed from outside
157-
return
158-
case <-errChan:
159-
logger.ErrorContext(ctx, "Keepalive request failed, marking tunnel as bad", slog.Any("error", keepaliveErr))
160-
if keepaliveChan := tunnel.keepaliveChan.Swap(nil); keepaliveChan != nil {
161-
close(*keepaliveChan)
162-
}
163-
tunnel.badTunnel = true
164-
if onFailure != nil {
165-
onFailure()
166-
}
167-
return
168152
}
169153
}
170154
}
171155

172-
func (tunnel *SSHTunnel) StartKeepalive(ctx context.Context, onFailure func()) {
173-
if tunnel == nil || tunnel.Client == nil || tunnel.badTunnel {
174-
return
175-
}
176-
if tunnel.keepaliveChan.Load() != nil {
177-
// Already started
178-
return
179-
}
180-
stopChan := make(chan struct{})
181-
tunnel.keepaliveChan.Store(&stopChan)
182-
183-
go tunnel.runKeepaliveLoop(ctx, stopChan, onFailure)
184-
}
185-
186-
// returns a channel that is closed if the SSH keepalive fails,
187-
// or nil if no SSH tunnel is configured
188-
func (tunnel *SSHTunnel) GetKeepaliveChan(ctx context.Context) <-chan struct{} {
189-
if tunnel == nil || tunnel.Client == nil || tunnel.badTunnel {
190-
// nil channel would be of no consequence in a select
191-
// UNLESS it's the only branch in a select, in which case it would block forever
192-
return nil
193-
}
194-
if keepaliveChan := tunnel.keepaliveChan.Load(); keepaliveChan != nil {
195-
// Already started
196-
return *keepaliveChan
156+
func (tunnel *SSHTunnel) sendKeepAlive() bool {
157+
sent := make(chan error, 1)
158+
go func() {
159+
_, _, err := tunnel.SendRequest("keepalive@openssh.com", true, nil)
160+
sent <- err
161+
}()
162+
timer := time.NewTimer(SSHRequestTimeout)
163+
defer timer.Stop()
164+
select {
165+
case err := <-sent:
166+
if err != nil {
167+
tunnel.logger.Error("SSH keepalive failed, tearing down tunnel", slog.Any("error", err))
168+
tunnel.shutdown(err)
169+
return false
170+
}
171+
return true
172+
case <-timer.C:
173+
tunnel.logger.Error("SSH keepalive request timed out, tearing down tunnel")
174+
tunnel.shutdown(errors.New("SSH keepalive request timed out"))
175+
return false
176+
case <-tunnel.watch:
177+
return false
197178
}
198-
keepaliveChan := make(chan struct{})
199-
tunnel.keepaliveChan.Store(&keepaliveChan)
200-
201-
go tunnel.runKeepaliveLoop(ctx, keepaliveChan, nil)
202-
return keepaliveChan
203179
}

0 commit comments

Comments
 (0)