|
1 | 1 | package nvrh_internal_ssh |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "context" |
4 | 5 | "fmt" |
5 | 6 | "io" |
6 | 7 | "log/slog" |
7 | 8 | "net" |
8 | 9 | "os" |
| 10 | + "sync" |
| 11 | + "time" |
9 | 12 |
|
10 | 13 | "golang.org/x/crypto/ssh" |
11 | 14 |
|
12 | | - "nvrh/src/context" |
| 15 | + nvrhcontext "nvrh/src/context" |
13 | 16 | "nvrh/src/ssh_tunnel_info" |
14 | 17 | ) |
15 | 18 |
|
16 | 19 | type NvrhInternalSshClient struct { |
17 | | - Ctx *context.NvrhContext |
| 20 | + Ctx *nvrhcontext.NvrhContext |
18 | 21 | SshClient *ssh.Client |
| 22 | + tunnelCtx context.Context |
| 23 | + cancelTunnel context.CancelFunc |
| 24 | + tunnelMutex sync.Mutex |
19 | 25 | } |
20 | 26 |
|
21 | 27 | func (c *NvrhInternalSshClient) Close() error { |
| 28 | + c.tunnelMutex.Lock() |
| 29 | + defer c.tunnelMutex.Unlock() |
| 30 | + |
| 31 | + // Cancel any active tunnels |
| 32 | + if c.cancelTunnel != nil { |
| 33 | + c.cancelTunnel() |
| 34 | + } |
| 35 | + |
22 | 36 | if c.SshClient == nil { |
23 | 37 | return fmt.Errorf("ssh client not initialized") |
24 | 38 | } |
@@ -58,48 +72,155 @@ func (c *NvrhInternalSshClient) Run(command string, tunnelInfo *ssh_tunnel_info. |
58 | 72 | } |
59 | 73 |
|
60 | 74 | func (c *NvrhInternalSshClient) TunnelSocket(tunnelInfo *ssh_tunnel_info.SshTunnelInfo) { |
61 | | - if c.SshClient == nil { |
62 | | - return |
63 | | - } |
| 75 | + c.TunnelSocketWithTimeout(tunnelInfo, 30*time.Second, 3) |
| 76 | +} |
64 | 77 |
|
65 | | - // Listen on the local Unix socket |
66 | | - localListener, err := LocalListenerFromTunnelInfo(tunnelInfo) |
67 | | - if err != nil { |
68 | | - slog.Error("Failed to listen on local socket", "err", err) |
| 78 | +// TunnelSocketWithTimeout creates an SSH tunnel with automatic cleanup after timeout or repeated errors |
| 79 | +func (c *NvrhInternalSshClient) TunnelSocketWithTimeout(tunnelInfo *ssh_tunnel_info.SshTunnelInfo, timeout time.Duration, maxErrors int) { |
| 80 | + if c.SshClient == nil { |
| 81 | + slog.Error("SSH client not initialized") |
69 | 82 | return |
70 | 83 | } |
71 | 84 |
|
72 | | - defer localListener.Close() |
| 85 | + c.tunnelMutex.Lock() |
| 86 | + c.tunnelCtx, c.cancelTunnel = context.WithTimeout(context.Background(), timeout) |
| 87 | + ctx := c.tunnelCtx |
| 88 | + cancel := c.cancelTunnel |
| 89 | + c.tunnelMutex.Unlock() |
| 90 | + |
| 91 | + defer cancel() |
73 | 92 |
|
74 | | - // Clean up local socket file |
75 | | - defer func() { |
76 | | - if tunnelInfo.Mode == "unix" { |
77 | | - os.Remove(tunnelInfo.LocalSocket) |
| 93 | + errorCount := 0 |
| 94 | + |
| 95 | + for errorCount < maxErrors { |
| 96 | + select { |
| 97 | + case <-ctx.Done(): |
| 98 | + slog.Warn("SSH tunnel timeout reached", "timeout", timeout) |
| 99 | + return |
| 100 | + default: |
78 | 101 | } |
79 | | - }() |
80 | | - |
81 | | - slog.Info("Tunneling SSH socket", "tunnelInfo", tunnelInfo) |
82 | 102 |
|
83 | | - for { |
84 | | - // Accept incoming connections |
85 | | - localConn, err := localListener.Accept() |
| 103 | + // Listen on the local socket |
| 104 | + localListener, err := LocalListenerFromTunnelInfo(tunnelInfo) |
86 | 105 | if err != nil { |
87 | | - slog.Error("Failed to accept connection", "err", err) |
88 | | - continue |
| 106 | + slog.Error("Failed to listen on local socket", "error", err, "attempt", errorCount+1) |
| 107 | + errorCount++ |
| 108 | + if errorCount < maxErrors { |
| 109 | + time.Sleep(2 * time.Second) |
| 110 | + continue |
| 111 | + } |
| 112 | + break |
89 | 113 | } |
90 | 114 |
|
91 | | - // Establish a connection to the remote socket via SSH |
92 | | - remoteConn, err := RemoteListenerFromTunnelInfo(tunnelInfo, c.SshClient) |
93 | | - if err != nil { |
94 | | - slog.Error("Failed to dial remote socket", "err", err) |
95 | | - localConn.Close() |
96 | | - continue |
| 115 | + // Clean up local socket file |
| 116 | + defer func() { |
| 117 | + localListener.Close() |
| 118 | + if tunnelInfo.Mode == "unix" { |
| 119 | + os.Remove(tunnelInfo.LocalSocket) |
| 120 | + } |
| 121 | + }() |
| 122 | + |
| 123 | + slog.Info("Tunneling SSH socket", "tunnelInfo", tunnelInfo, "timeout", timeout, "attempt", errorCount+1) |
| 124 | + |
| 125 | + // Accept connections with timeout |
| 126 | + connectionErrors := 0 |
| 127 | + for { |
| 128 | + select { |
| 129 | + case <-ctx.Done(): |
| 130 | + slog.Warn("SSH tunnel context cancelled", "reason", ctx.Err()) |
| 131 | + return |
| 132 | + default: |
| 133 | + } |
| 134 | + |
| 135 | + // Set a deadline for accepting connections |
| 136 | + if tcpListener, ok := localListener.(*net.TCPListener); ok { |
| 137 | + tcpListener.SetDeadline(time.Now().Add(1 * time.Second)) |
| 138 | + } else if unixListener, ok := localListener.(*net.UnixListener); ok { |
| 139 | + unixListener.SetDeadline(time.Now().Add(1 * time.Second)) |
| 140 | + } |
| 141 | + |
| 142 | + localConn, err := localListener.Accept() |
| 143 | + if err != nil { |
| 144 | + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { |
| 145 | + // Timeout is expected, continue the loop |
| 146 | + continue |
| 147 | + } |
| 148 | + slog.Error("Failed to accept connection", "error", err) |
| 149 | + connectionErrors++ |
| 150 | + if connectionErrors >= 5 { |
| 151 | + slog.Error("Too many connection errors, restarting listener") |
| 152 | + localListener.Close() |
| 153 | + errorCount++ |
| 154 | + break |
| 155 | + } |
| 156 | + continue |
| 157 | + } |
| 158 | + |
| 159 | + // Reset connection error count on successful accept |
| 160 | + connectionErrors = 0 |
| 161 | + |
| 162 | + // Establish a connection to the remote socket via SSH |
| 163 | + remoteConn, err := RemoteListenerFromTunnelInfo(tunnelInfo, c.SshClient) |
| 164 | + if err != nil { |
| 165 | + slog.Error("Failed to dial remote socket", "error", err) |
| 166 | + localConn.Close() |
| 167 | + continue |
| 168 | + } |
| 169 | + |
| 170 | + // Start a goroutine to handle the connection with context |
| 171 | + go c.handleConnectionWithContext(ctx, localConn, remoteConn) |
97 | 172 | } |
98 | 173 |
|
99 | | - // Start a goroutine to handle the connection |
100 | | - go handleConnection(localConn, remoteConn) |
| 174 | + if errorCount >= maxErrors { |
| 175 | + break |
| 176 | + } |
| 177 | + |
| 178 | + time.Sleep(2 * time.Second) // Wait before retry |
101 | 179 | } |
102 | 180 |
|
| 181 | + slog.Error("SSH tunnel failed after maximum attempts", "max_errors", maxErrors, "timeout", timeout) |
| 182 | +} |
| 183 | + |
| 184 | +// handleConnectionWithContext handles a connection with context cancellation support |
| 185 | +func (c *NvrhInternalSshClient) handleConnectionWithContext(ctx context.Context, localConn net.Conn, remoteConn net.Conn) { |
| 186 | + defer localConn.Close() |
| 187 | + defer remoteConn.Close() |
| 188 | + |
| 189 | + // Create a context that gets cancelled when the parent context is cancelled |
| 190 | + connCtx, cancel := context.WithCancel(ctx) |
| 191 | + defer cancel() |
| 192 | + |
| 193 | + // Channel to signal when copying is done |
| 194 | + done := make(chan struct{}, 2) |
| 195 | + |
| 196 | + // Copy data from local to remote |
| 197 | + go func() { |
| 198 | + defer func() { done <- struct{}{} }() |
| 199 | + io.Copy(remoteConn, localConn) |
| 200 | + }() |
| 201 | + |
| 202 | + // Copy data from remote to local |
| 203 | + go func() { |
| 204 | + defer func() { done <- struct{}{} }() |
| 205 | + io.Copy(localConn, remoteConn) |
| 206 | + }() |
| 207 | + |
| 208 | + // Wait for either context cancellation or connection completion |
| 209 | + select { |
| 210 | + case <-connCtx.Done(): |
| 211 | + slog.Debug("Connection cancelled due to context") |
| 212 | + return |
| 213 | + case <-done: |
| 214 | + // One direction finished, wait for the other or timeout |
| 215 | + select { |
| 216 | + case <-done: |
| 217 | + slog.Debug("Connection completed normally") |
| 218 | + case <-time.After(5 * time.Second): |
| 219 | + slog.Debug("Connection cleanup timeout") |
| 220 | + case <-connCtx.Done(): |
| 221 | + slog.Debug("Connection cancelled during cleanup") |
| 222 | + } |
| 223 | + } |
103 | 224 | } |
104 | 225 |
|
105 | 226 | func handleConnection(localConn net.Conn, remoteConn net.Conn) { |
|
0 commit comments