Skip to content

Commit bba2fa8

Browse files
pyjamsGiacomohansva
authored
Native SSH Tunnel support for database connections #6573 (#6776)
* - Add native SSH tunnel support for database connections, allowing users to connect to databases in private networks through an SSH bastion host - Supports password, keyboard-interactive, and private key authentication - New "SSH Tunnel" tab in the database connection editor with enable/disable logic - SSH keepalive (30s interval) to prevent VPN/firewall from dropping idle connections - Tunnel lifecycle properly managed even for pipeline connection groups - **SshTunnelManager** (new): manages JSch sessions with local port forwarding - **IDatabase/BaseDatabaseMeta**: SSH tunnel fields persisted via `@HopMetadataProperty` - **Database.java**: opens tunnel before JDBC connect, closes in `closeConnectionOnly()` (not `disconnect()`) to prevent tunnel leaks with grouped connections - **DatabaseMetaEditor**: new SSH Tunnel tab with field enable/disable based on config - i18n: English and Italian labels - [x] Unit tests for SshTunnelManager (5 tests) - [x] Unit tests for Database SSH tunnel integration (5 tests) - [x] All 10 tests pass - [x] Manual testing with MySQL over SSH tunnel (verified working) UPGRADE JUNIT 5 revert ldap changes * Add IT test --------- Co-authored-by: Giacomo <gscaglione@paa.it> Co-authored-by: Hans Van Akelyen <hans.van.akelyen@gmail.com>
1 parent ecbf0ff commit bba2fa8

File tree

16 files changed

+1005
-1
lines changed

16 files changed

+1005
-1
lines changed

core/pom.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@
5858
<groupId>com.fasterxml.jackson.dataformat</groupId>
5959
<artifactId>jackson-dataformat-avro</artifactId>
6060
</dependency>
61+
<dependency>
62+
<groupId>com.github.mwiede</groupId>
63+
<artifactId>jsch</artifactId>
64+
</dependency>
6165
<dependency>
6266
<groupId>com.google.code.gson</groupId>
6367
<artifactId>gson</artifactId>

core/src/main/java/org/apache/hop/core/database/BaseDatabaseMeta.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,21 @@ public abstract class BaseDatabaseMeta implements Cloneable, IDatabase {
184184
@HopMetadataProperty protected String pluginId;
185185
@HopMetadataProperty protected String pluginName;
186186

187+
// SSH Tunnel fields
188+
@HopMetadataProperty protected boolean sshTunnelEnabled;
189+
@HopMetadataProperty protected String sshTunnelHost;
190+
@HopMetadataProperty protected String sshTunnelPort;
191+
@HopMetadataProperty protected String sshTunnelUsername;
192+
193+
@HopMetadataProperty(password = true)
194+
protected String sshTunnelPassword;
195+
196+
@HopMetadataProperty protected boolean sshTunnelUsePrivateKey;
197+
@HopMetadataProperty protected String sshTunnelPrivateKeyFile;
198+
199+
@HopMetadataProperty(password = true)
200+
protected String sshTunnelPassphrase;
201+
187202
public BaseDatabaseMeta() {
188203
attributes = Collections.synchronizedMap(new HashMap<>());
189204
changed = false;

core/src/main/java/org/apache/hop/core/database/Database.java

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ public class Database implements IVariables, ILoggingObject, AutoCloseable {
160160

161161
private int nrExecutedCommits;
162162

163+
private SshTunnelManager sshTunnelManager;
164+
163165
private static final List<IValueMeta> valueMetaPluginClasses;
164166

165167
static {
@@ -435,7 +437,24 @@ private void connectUsingClass(String classname, String partitionId) throws HopD
435437
}
436438

437439
try {
438-
String url = resolve(databaseMeta.getURL(this));
440+
// Open SSH tunnel if configured
441+
String url;
442+
if (databaseMeta.isSshTunnelEnabled() && !Utils.isEmpty(databaseMeta.getSshTunnelHost())) {
443+
sshTunnelManager = new SshTunnelManager();
444+
int localPort = sshTunnelManager.openTunnel(this, databaseMeta, log);
445+
446+
// Build URL using tunnel endpoint (localhost + forwarded port)
447+
String tunnelUrl =
448+
databaseMeta
449+
.getIDatabase()
450+
.getURL(
451+
"localhost",
452+
String.valueOf(localPort),
453+
resolve(databaseMeta.getDatabaseName()));
454+
url = resolve(tunnelUrl);
455+
} else {
456+
url = resolve(databaseMeta.getURL(this));
457+
}
439458
log.logDebug("Connecting to database using URL: " + url);
440459

441460
String username = resolve(databaseMeta.getUsername());
@@ -608,6 +627,14 @@ public synchronized void closeConnectionOnly() throws HopDatabaseException {
608627
}
609628
} catch (SQLException e) {
610629
throw new HopDatabaseException("Error disconnecting from database '" + this + "'", e);
630+
} finally {
631+
// Close SSH tunnel after JDBC connection is closed.
632+
// This must be here (not in disconnect()) because grouped connections
633+
// use closeConnectionOnly() directly and would otherwise leak tunnels.
634+
if (sshTunnelManager != null) {
635+
sshTunnelManager.closeTunnel(log);
636+
sshTunnelManager = null;
637+
}
611638
}
612639
}
613640

core/src/main/java/org/apache/hop/core/database/DatabaseMeta.java

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2328,4 +2328,70 @@ public List<String> getRemoveItems() {
23282328
public boolean isHideUrlInTestConnection() {
23292329
return iDatabase.isHideUrlInTestConnection();
23302330
}
2331+
2332+
// SSH Tunnel delegation methods
2333+
2334+
public boolean isSshTunnelEnabled() {
2335+
return iDatabase.isSshTunnelEnabled();
2336+
}
2337+
2338+
public void setSshTunnelEnabled(boolean enabled) {
2339+
iDatabase.setSshTunnelEnabled(enabled);
2340+
}
2341+
2342+
public String getSshTunnelHost() {
2343+
return iDatabase.getSshTunnelHost();
2344+
}
2345+
2346+
public void setSshTunnelHost(String host) {
2347+
iDatabase.setSshTunnelHost(host);
2348+
}
2349+
2350+
public String getSshTunnelPort() {
2351+
return iDatabase.getSshTunnelPort();
2352+
}
2353+
2354+
public void setSshTunnelPort(String port) {
2355+
iDatabase.setSshTunnelPort(port);
2356+
}
2357+
2358+
public String getSshTunnelUsername() {
2359+
return iDatabase.getSshTunnelUsername();
2360+
}
2361+
2362+
public void setSshTunnelUsername(String username) {
2363+
iDatabase.setSshTunnelUsername(username);
2364+
}
2365+
2366+
public String getSshTunnelPassword() {
2367+
return iDatabase.getSshTunnelPassword();
2368+
}
2369+
2370+
public void setSshTunnelPassword(String password) {
2371+
iDatabase.setSshTunnelPassword(password);
2372+
}
2373+
2374+
public boolean isSshTunnelUsePrivateKey() {
2375+
return iDatabase.isSshTunnelUsePrivateKey();
2376+
}
2377+
2378+
public void setSshTunnelUsePrivateKey(boolean usePrivateKey) {
2379+
iDatabase.setSshTunnelUsePrivateKey(usePrivateKey);
2380+
}
2381+
2382+
public String getSshTunnelPrivateKeyFile() {
2383+
return iDatabase.getSshTunnelPrivateKeyFile();
2384+
}
2385+
2386+
public void setSshTunnelPrivateKeyFile(String privateKeyFile) {
2387+
iDatabase.setSshTunnelPrivateKeyFile(privateKeyFile);
2388+
}
2389+
2390+
public String getSshTunnelPassphrase() {
2391+
return iDatabase.getSshTunnelPassphrase();
2392+
}
2393+
2394+
public void setSshTunnelPassphrase(String passphrase) {
2395+
iDatabase.setSshTunnelPassphrase(passphrase);
2396+
}
23312397
}

core/src/main/java/org/apache/hop/core/database/IDatabase.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1209,4 +1209,38 @@ default String getLegacyColumnName(
12091209
* @return true to hide URL information in test connection results
12101210
*/
12111211
boolean isHideUrlInTestConnection();
1212+
1213+
// SSH Tunnel configuration methods
1214+
1215+
boolean isSshTunnelEnabled();
1216+
1217+
void setSshTunnelEnabled(boolean enabled);
1218+
1219+
String getSshTunnelHost();
1220+
1221+
void setSshTunnelHost(String host);
1222+
1223+
String getSshTunnelPort();
1224+
1225+
void setSshTunnelPort(String port);
1226+
1227+
String getSshTunnelUsername();
1228+
1229+
void setSshTunnelUsername(String username);
1230+
1231+
String getSshTunnelPassword();
1232+
1233+
void setSshTunnelPassword(String password);
1234+
1235+
boolean isSshTunnelUsePrivateKey();
1236+
1237+
void setSshTunnelUsePrivateKey(boolean usePrivateKey);
1238+
1239+
String getSshTunnelPrivateKeyFile();
1240+
1241+
void setSshTunnelPrivateKeyFile(String privateKeyFile);
1242+
1243+
String getSshTunnelPassphrase();
1244+
1245+
void setSshTunnelPassphrase(String passphrase);
12121246
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.hop.core.database;
19+
20+
import com.jcraft.jsch.JSch;
21+
import com.jcraft.jsch.Session;
22+
import com.jcraft.jsch.UIKeyboardInteractive;
23+
import com.jcraft.jsch.UserInfo;
24+
import java.util.Properties;
25+
import org.apache.hop.core.Const;
26+
import org.apache.hop.core.encryption.Encr;
27+
import org.apache.hop.core.exception.HopDatabaseException;
28+
import org.apache.hop.core.logging.ILogChannel;
29+
import org.apache.hop.core.util.Utils;
30+
import org.apache.hop.core.variables.IVariables;
31+
32+
/** Manages SSH tunnels for database connections using JSch port forwarding. */
33+
public class SshTunnelManager {
34+
35+
private Session session;
36+
private int localPort;
37+
38+
/**
39+
* Opens an SSH tunnel with local port forwarding.
40+
*
41+
* @param variables variables for resolving expressions
42+
* @param databaseMeta the database metadata containing SSH tunnel configuration
43+
* @param log the log channel
44+
* @return the local port that forwards to the remote database
45+
* @throws HopDatabaseException if the tunnel cannot be established
46+
*/
47+
public int openTunnel(IVariables variables, DatabaseMeta databaseMeta, ILogChannel log)
48+
throws HopDatabaseException {
49+
try {
50+
String sshHost = variables.resolve(databaseMeta.getSshTunnelHost());
51+
int sshPort = Const.toInt(variables.resolve(databaseMeta.getSshTunnelPort()), 22);
52+
String sshUser = variables.resolve(databaseMeta.getSshTunnelUsername());
53+
String sshPassword =
54+
Encr.decryptPasswordOptionallyEncrypted(
55+
variables.resolve(databaseMeta.getSshTunnelPassword()));
56+
57+
String remoteHost = variables.resolve(databaseMeta.getHostname());
58+
int remotePort =
59+
Const.toInt(
60+
variables.resolve(databaseMeta.getPort()), databaseMeta.getDefaultDatabasePort());
61+
62+
JSch jsch = new JSch();
63+
64+
// Private key authentication
65+
if (databaseMeta.isSshTunnelUsePrivateKey()) {
66+
String keyFile = variables.resolve(databaseMeta.getSshTunnelPrivateKeyFile());
67+
String passphrase =
68+
Encr.decryptPasswordOptionallyEncrypted(
69+
variables.resolve(databaseMeta.getSshTunnelPassphrase()));
70+
if (!Utils.isEmpty(passphrase)) {
71+
jsch.addIdentity(keyFile, passphrase);
72+
} else {
73+
jsch.addIdentity(keyFile);
74+
}
75+
}
76+
77+
session = jsch.getSession(sshUser, sshHost, sshPort);
78+
79+
// Password authentication (supports both password and keyboard-interactive)
80+
if (!databaseMeta.isSshTunnelUsePrivateKey() && !Utils.isEmpty(sshPassword)) {
81+
session.setPassword(sshPassword);
82+
session.setUserInfo((UserInfo) new SshPasswordUserInfo(sshPassword));
83+
}
84+
85+
Properties config = new Properties();
86+
config.put("StrictHostKeyChecking", "no");
87+
config.put("PreferredAuthentications", "publickey,keyboard-interactive,password");
88+
session.setConfig(config);
89+
90+
// Send keepalive every 30s to prevent VPN/firewall from dropping idle connections
91+
session.setServerAliveInterval(30000);
92+
session.setServerAliveCountMax(3);
93+
94+
log.logBasic("Opening SSH tunnel to " + sshHost + ":" + sshPort + " as user " + sshUser);
95+
session.connect(30000);
96+
97+
// Local port 0 = auto-assign a free port
98+
localPort = session.setPortForwardingL(0, remoteHost, remotePort);
99+
log.logBasic(
100+
"SSH tunnel established: localhost:"
101+
+ localPort
102+
+ " -> "
103+
+ remoteHost
104+
+ ":"
105+
+ remotePort);
106+
107+
return localPort;
108+
} catch (Exception e) {
109+
closeTunnel(log);
110+
throw new HopDatabaseException("Failed to open SSH tunnel", e);
111+
}
112+
}
113+
114+
/** Closes the SSH tunnel and disconnects the session. */
115+
public void closeTunnel(ILogChannel log) {
116+
if (session != null && session.isConnected()) {
117+
try {
118+
session.delPortForwardingL(localPort);
119+
} catch (Exception e) {
120+
log.logDebug("Error removing port forwarding: " + e.getMessage());
121+
}
122+
session.disconnect();
123+
log.logBasic("SSH tunnel closed");
124+
}
125+
session = null;
126+
}
127+
128+
public boolean isOpen() {
129+
return session != null && session.isConnected();
130+
}
131+
132+
public int getLocalPort() {
133+
return localPort;
134+
}
135+
136+
/** Handles keyboard-interactive authentication by responding with the password. */
137+
private static class SshPasswordUserInfo implements UserInfo, UIKeyboardInteractive {
138+
private final String password;
139+
140+
SshPasswordUserInfo(String password) {
141+
this.password = password;
142+
}
143+
144+
@Override
145+
public String[] promptKeyboardInteractive(
146+
String destination, String name, String instruction, String[] prompt, boolean[] echo) {
147+
return new String[] {password};
148+
}
149+
150+
@Override
151+
public String getPassphrase() {
152+
return null;
153+
}
154+
155+
@Override
156+
public String getPassword() {
157+
return password;
158+
}
159+
160+
@Override
161+
public boolean promptPassword(String message) {
162+
return true;
163+
}
164+
165+
@Override
166+
public boolean promptPassphrase(String message) {
167+
return false;
168+
}
169+
170+
@Override
171+
public boolean promptYesNo(String message) {
172+
return true;
173+
}
174+
175+
@Override
176+
public void showMessage(String message) {}
177+
}
178+
}

0 commit comments

Comments
 (0)