This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new bba2fa875e Native SSH Tunnel support for database connections #6573
(#6776)
bba2fa875e is described below
commit bba2fa875ec8ef1895574fbe78b2d39bff076e3a
Author: pyjams <[email protected]>
AuthorDate: Mon Mar 16 13:00:16 2026 +0100
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 <[email protected]>
Co-authored-by: Hans Van Akelyen <[email protected]>
---
core/pom.xml | 4 +
.../apache/hop/core/database/BaseDatabaseMeta.java | 15 ++
.../org/apache/hop/core/database/Database.java | 29 ++-
.../org/apache/hop/core/database/DatabaseMeta.java | 66 ++++++
.../org/apache/hop/core/database/IDatabase.java | 34 +++
.../apache/hop/core/database/SshTunnelManager.java | 178 +++++++++++++++
.../hop/core/database/DatabaseSshTunnelTest.java | 130 +++++++++++
.../hop/core/database/SshTunnelManagerTest.java | 105 +++++++++
docker/integration-tests/Dockerfile.ssh | 35 +++
.../integration-tests-database.yaml | 14 ++
.../database/main-0038-postgres-via-ssh.hwf | 80 +++++++
.../database/metadata/rdbms/unit-test-db-ssh.json | 34 +++
integration-tests/scripts/run-tests.sh | 18 ++
.../hop/ui/core/database/DatabaseMetaEditor.java | 246 +++++++++++++++++++++
.../database/messages/messages_en_US.properties | 9 +
.../database/messages/messages_it_IT.properties | 9 +
16 files changed, 1005 insertions(+), 1 deletion(-)
diff --git a/core/pom.xml b/core/pom.xml
index 93d89c5864..53dff82fcd 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -58,6 +58,10 @@
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-avro</artifactId>
</dependency>
+ <dependency>
+ <groupId>com.github.mwiede</groupId>
+ <artifactId>jsch</artifactId>
+ </dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
diff --git
a/core/src/main/java/org/apache/hop/core/database/BaseDatabaseMeta.java
b/core/src/main/java/org/apache/hop/core/database/BaseDatabaseMeta.java
index a664e07789..ab263afa56 100644
--- a/core/src/main/java/org/apache/hop/core/database/BaseDatabaseMeta.java
+++ b/core/src/main/java/org/apache/hop/core/database/BaseDatabaseMeta.java
@@ -184,6 +184,21 @@ public abstract class BaseDatabaseMeta implements
Cloneable, IDatabase {
@HopMetadataProperty protected String pluginId;
@HopMetadataProperty protected String pluginName;
+ // SSH Tunnel fields
+ @HopMetadataProperty protected boolean sshTunnelEnabled;
+ @HopMetadataProperty protected String sshTunnelHost;
+ @HopMetadataProperty protected String sshTunnelPort;
+ @HopMetadataProperty protected String sshTunnelUsername;
+
+ @HopMetadataProperty(password = true)
+ protected String sshTunnelPassword;
+
+ @HopMetadataProperty protected boolean sshTunnelUsePrivateKey;
+ @HopMetadataProperty protected String sshTunnelPrivateKeyFile;
+
+ @HopMetadataProperty(password = true)
+ protected String sshTunnelPassphrase;
+
public BaseDatabaseMeta() {
attributes = Collections.synchronizedMap(new HashMap<>());
changed = false;
diff --git a/core/src/main/java/org/apache/hop/core/database/Database.java
b/core/src/main/java/org/apache/hop/core/database/Database.java
index 79cde7173a..8e03a54940 100644
--- a/core/src/main/java/org/apache/hop/core/database/Database.java
+++ b/core/src/main/java/org/apache/hop/core/database/Database.java
@@ -160,6 +160,8 @@ public class Database implements IVariables,
ILoggingObject, AutoCloseable {
private int nrExecutedCommits;
+ private SshTunnelManager sshTunnelManager;
+
private static final List<IValueMeta> valueMetaPluginClasses;
static {
@@ -435,7 +437,24 @@ public class Database implements IVariables,
ILoggingObject, AutoCloseable {
}
try {
- String url = resolve(databaseMeta.getURL(this));
+ // Open SSH tunnel if configured
+ String url;
+ if (databaseMeta.isSshTunnelEnabled() &&
!Utils.isEmpty(databaseMeta.getSshTunnelHost())) {
+ sshTunnelManager = new SshTunnelManager();
+ int localPort = sshTunnelManager.openTunnel(this, databaseMeta, log);
+
+ // Build URL using tunnel endpoint (localhost + forwarded port)
+ String tunnelUrl =
+ databaseMeta
+ .getIDatabase()
+ .getURL(
+ "localhost",
+ String.valueOf(localPort),
+ resolve(databaseMeta.getDatabaseName()));
+ url = resolve(tunnelUrl);
+ } else {
+ url = resolve(databaseMeta.getURL(this));
+ }
log.logDebug("Connecting to database using URL: " + url);
String username = resolve(databaseMeta.getUsername());
@@ -608,6 +627,14 @@ public class Database implements IVariables,
ILoggingObject, AutoCloseable {
}
} catch (SQLException e) {
throw new HopDatabaseException("Error disconnecting from database '" +
this + "'", e);
+ } finally {
+ // Close SSH tunnel after JDBC connection is closed.
+ // This must be here (not in disconnect()) because grouped connections
+ // use closeConnectionOnly() directly and would otherwise leak tunnels.
+ if (sshTunnelManager != null) {
+ sshTunnelManager.closeTunnel(log);
+ sshTunnelManager = null;
+ }
}
}
diff --git a/core/src/main/java/org/apache/hop/core/database/DatabaseMeta.java
b/core/src/main/java/org/apache/hop/core/database/DatabaseMeta.java
index af7200fb77..c7011179c8 100644
--- a/core/src/main/java/org/apache/hop/core/database/DatabaseMeta.java
+++ b/core/src/main/java/org/apache/hop/core/database/DatabaseMeta.java
@@ -2328,4 +2328,70 @@ public class DatabaseMeta extends HopMetadataBase
implements Cloneable, IHopMeta
public boolean isHideUrlInTestConnection() {
return iDatabase.isHideUrlInTestConnection();
}
+
+ // SSH Tunnel delegation methods
+
+ public boolean isSshTunnelEnabled() {
+ return iDatabase.isSshTunnelEnabled();
+ }
+
+ public void setSshTunnelEnabled(boolean enabled) {
+ iDatabase.setSshTunnelEnabled(enabled);
+ }
+
+ public String getSshTunnelHost() {
+ return iDatabase.getSshTunnelHost();
+ }
+
+ public void setSshTunnelHost(String host) {
+ iDatabase.setSshTunnelHost(host);
+ }
+
+ public String getSshTunnelPort() {
+ return iDatabase.getSshTunnelPort();
+ }
+
+ public void setSshTunnelPort(String port) {
+ iDatabase.setSshTunnelPort(port);
+ }
+
+ public String getSshTunnelUsername() {
+ return iDatabase.getSshTunnelUsername();
+ }
+
+ public void setSshTunnelUsername(String username) {
+ iDatabase.setSshTunnelUsername(username);
+ }
+
+ public String getSshTunnelPassword() {
+ return iDatabase.getSshTunnelPassword();
+ }
+
+ public void setSshTunnelPassword(String password) {
+ iDatabase.setSshTunnelPassword(password);
+ }
+
+ public boolean isSshTunnelUsePrivateKey() {
+ return iDatabase.isSshTunnelUsePrivateKey();
+ }
+
+ public void setSshTunnelUsePrivateKey(boolean usePrivateKey) {
+ iDatabase.setSshTunnelUsePrivateKey(usePrivateKey);
+ }
+
+ public String getSshTunnelPrivateKeyFile() {
+ return iDatabase.getSshTunnelPrivateKeyFile();
+ }
+
+ public void setSshTunnelPrivateKeyFile(String privateKeyFile) {
+ iDatabase.setSshTunnelPrivateKeyFile(privateKeyFile);
+ }
+
+ public String getSshTunnelPassphrase() {
+ return iDatabase.getSshTunnelPassphrase();
+ }
+
+ public void setSshTunnelPassphrase(String passphrase) {
+ iDatabase.setSshTunnelPassphrase(passphrase);
+ }
}
diff --git a/core/src/main/java/org/apache/hop/core/database/IDatabase.java
b/core/src/main/java/org/apache/hop/core/database/IDatabase.java
index f38aee46b7..db9e804a84 100644
--- a/core/src/main/java/org/apache/hop/core/database/IDatabase.java
+++ b/core/src/main/java/org/apache/hop/core/database/IDatabase.java
@@ -1209,4 +1209,38 @@ public interface IDatabase extends Cloneable {
* @return true to hide URL information in test connection results
*/
boolean isHideUrlInTestConnection();
+
+ // SSH Tunnel configuration methods
+
+ boolean isSshTunnelEnabled();
+
+ void setSshTunnelEnabled(boolean enabled);
+
+ String getSshTunnelHost();
+
+ void setSshTunnelHost(String host);
+
+ String getSshTunnelPort();
+
+ void setSshTunnelPort(String port);
+
+ String getSshTunnelUsername();
+
+ void setSshTunnelUsername(String username);
+
+ String getSshTunnelPassword();
+
+ void setSshTunnelPassword(String password);
+
+ boolean isSshTunnelUsePrivateKey();
+
+ void setSshTunnelUsePrivateKey(boolean usePrivateKey);
+
+ String getSshTunnelPrivateKeyFile();
+
+ void setSshTunnelPrivateKeyFile(String privateKeyFile);
+
+ String getSshTunnelPassphrase();
+
+ void setSshTunnelPassphrase(String passphrase);
}
diff --git
a/core/src/main/java/org/apache/hop/core/database/SshTunnelManager.java
b/core/src/main/java/org/apache/hop/core/database/SshTunnelManager.java
new file mode 100644
index 0000000000..71b00ed3c8
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/core/database/SshTunnelManager.java
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.database;
+
+import com.jcraft.jsch.JSch;
+import com.jcraft.jsch.Session;
+import com.jcraft.jsch.UIKeyboardInteractive;
+import com.jcraft.jsch.UserInfo;
+import java.util.Properties;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.encryption.Encr;
+import org.apache.hop.core.exception.HopDatabaseException;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+
+/** Manages SSH tunnels for database connections using JSch port forwarding. */
+public class SshTunnelManager {
+
+ private Session session;
+ private int localPort;
+
+ /**
+ * Opens an SSH tunnel with local port forwarding.
+ *
+ * @param variables variables for resolving expressions
+ * @param databaseMeta the database metadata containing SSH tunnel
configuration
+ * @param log the log channel
+ * @return the local port that forwards to the remote database
+ * @throws HopDatabaseException if the tunnel cannot be established
+ */
+ public int openTunnel(IVariables variables, DatabaseMeta databaseMeta,
ILogChannel log)
+ throws HopDatabaseException {
+ try {
+ String sshHost = variables.resolve(databaseMeta.getSshTunnelHost());
+ int sshPort =
Const.toInt(variables.resolve(databaseMeta.getSshTunnelPort()), 22);
+ String sshUser = variables.resolve(databaseMeta.getSshTunnelUsername());
+ String sshPassword =
+ Encr.decryptPasswordOptionallyEncrypted(
+ variables.resolve(databaseMeta.getSshTunnelPassword()));
+
+ String remoteHost = variables.resolve(databaseMeta.getHostname());
+ int remotePort =
+ Const.toInt(
+ variables.resolve(databaseMeta.getPort()),
databaseMeta.getDefaultDatabasePort());
+
+ JSch jsch = new JSch();
+
+ // Private key authentication
+ if (databaseMeta.isSshTunnelUsePrivateKey()) {
+ String keyFile =
variables.resolve(databaseMeta.getSshTunnelPrivateKeyFile());
+ String passphrase =
+ Encr.decryptPasswordOptionallyEncrypted(
+ variables.resolve(databaseMeta.getSshTunnelPassphrase()));
+ if (!Utils.isEmpty(passphrase)) {
+ jsch.addIdentity(keyFile, passphrase);
+ } else {
+ jsch.addIdentity(keyFile);
+ }
+ }
+
+ session = jsch.getSession(sshUser, sshHost, sshPort);
+
+ // Password authentication (supports both password and
keyboard-interactive)
+ if (!databaseMeta.isSshTunnelUsePrivateKey() &&
!Utils.isEmpty(sshPassword)) {
+ session.setPassword(sshPassword);
+ session.setUserInfo((UserInfo) new SshPasswordUserInfo(sshPassword));
+ }
+
+ Properties config = new Properties();
+ config.put("StrictHostKeyChecking", "no");
+ config.put("PreferredAuthentications",
"publickey,keyboard-interactive,password");
+ session.setConfig(config);
+
+ // Send keepalive every 30s to prevent VPN/firewall from dropping idle
connections
+ session.setServerAliveInterval(30000);
+ session.setServerAliveCountMax(3);
+
+ log.logBasic("Opening SSH tunnel to " + sshHost + ":" + sshPort + " as
user " + sshUser);
+ session.connect(30000);
+
+ // Local port 0 = auto-assign a free port
+ localPort = session.setPortForwardingL(0, remoteHost, remotePort);
+ log.logBasic(
+ "SSH tunnel established: localhost:"
+ + localPort
+ + " -> "
+ + remoteHost
+ + ":"
+ + remotePort);
+
+ return localPort;
+ } catch (Exception e) {
+ closeTunnel(log);
+ throw new HopDatabaseException("Failed to open SSH tunnel", e);
+ }
+ }
+
+ /** Closes the SSH tunnel and disconnects the session. */
+ public void closeTunnel(ILogChannel log) {
+ if (session != null && session.isConnected()) {
+ try {
+ session.delPortForwardingL(localPort);
+ } catch (Exception e) {
+ log.logDebug("Error removing port forwarding: " + e.getMessage());
+ }
+ session.disconnect();
+ log.logBasic("SSH tunnel closed");
+ }
+ session = null;
+ }
+
+ public boolean isOpen() {
+ return session != null && session.isConnected();
+ }
+
+ public int getLocalPort() {
+ return localPort;
+ }
+
+ /** Handles keyboard-interactive authentication by responding with the
password. */
+ private static class SshPasswordUserInfo implements UserInfo,
UIKeyboardInteractive {
+ private final String password;
+
+ SshPasswordUserInfo(String password) {
+ this.password = password;
+ }
+
+ @Override
+ public String[] promptKeyboardInteractive(
+ String destination, String name, String instruction, String[] prompt,
boolean[] echo) {
+ return new String[] {password};
+ }
+
+ @Override
+ public String getPassphrase() {
+ return null;
+ }
+
+ @Override
+ public String getPassword() {
+ return password;
+ }
+
+ @Override
+ public boolean promptPassword(String message) {
+ return true;
+ }
+
+ @Override
+ public boolean promptPassphrase(String message) {
+ return false;
+ }
+
+ @Override
+ public boolean promptYesNo(String message) {
+ return true;
+ }
+
+ @Override
+ public void showMessage(String message) {}
+ }
+}
diff --git
a/core/src/test/java/org/apache/hop/core/database/DatabaseSshTunnelTest.java
b/core/src/test/java/org/apache/hop/core/database/DatabaseSshTunnelTest.java
new file mode 100644
index 0000000000..5953f68e48
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/core/database/DatabaseSshTunnelTest.java
@@ -0,0 +1,130 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.database;
+
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Field;
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.logging.LogLevel;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class DatabaseSshTunnelTest {
+
+ private final DatabaseMeta meta = mock(DatabaseMeta.class);
+ private final ILoggingObject log = mock(ILoggingObject.class);
+ private IVariables variables;
+
+ @BeforeEach
+ void setUp() {
+ when(log.getLogLevel()).thenReturn(LogLevel.NOTHING);
+ variables = new Variables();
+ }
+
+ @Test
+ void testCloseConnectionOnlyClosesSshTunnel() throws Exception {
+ Connection conn = mockConnection();
+ SshTunnelManager tunnelManager = mock(SshTunnelManager.class);
+
+ Database db = new Database(log, variables, meta);
+ db.setConnection(conn);
+ setSshTunnelManager(db, tunnelManager);
+
+ db.closeConnectionOnly();
+
+ verify(tunnelManager).closeTunnel(any(ILogChannel.class));
+ verify(conn).close();
+ }
+
+ @Test
+ void testCloseConnectionOnlyWithoutTunnel() throws Exception {
+ Connection conn = mockConnection();
+
+ Database db = new Database(log, variables, meta);
+ db.setConnection(conn);
+
+ db.closeConnectionOnly();
+
+ verify(conn).close();
+ }
+
+ @Test
+ void testDisconnectClosesSshTunnelViaCloseConnectionOnly() throws Exception {
+ Connection conn = mockConnection();
+ SshTunnelManager tunnelManager = mock(SshTunnelManager.class);
+
+ Database db = new Database(log, variables, meta);
+ db.setConnection(conn);
+ setSshTunnelManager(db, tunnelManager);
+
+ db.disconnect();
+
+ verify(tunnelManager).closeTunnel(any(ILogChannel.class));
+ }
+
+ @Test
+ void testTunnelManagerIsNulledAfterClose() throws Exception {
+ Connection conn = mockConnection();
+ SshTunnelManager tunnelManager = mock(SshTunnelManager.class);
+
+ Database db = new Database(log, variables, meta);
+ db.setConnection(conn);
+ setSshTunnelManager(db, tunnelManager);
+
+ db.closeConnectionOnly();
+
+ Field field = Database.class.getDeclaredField("sshTunnelManager");
+ field.setAccessible(true);
+ assertNull(field.get(db));
+ }
+
+ @Test
+ void testNoTunnelOpenedWhenSshDisabled() throws Exception {
+ when(meta.isSshTunnelEnabled()).thenReturn(false);
+
+ Database db = new Database(log, variables, meta);
+
+ Field field = Database.class.getDeclaredField("sshTunnelManager");
+ field.setAccessible(true);
+ assertNull(field.get(db));
+ }
+
+ private Connection mockConnection() throws Exception {
+ Connection conn = mock(Connection.class);
+ DatabaseMetaData dbMetaData = mock(DatabaseMetaData.class);
+ when(conn.getMetaData()).thenReturn(dbMetaData);
+ return conn;
+ }
+
+ private void setSshTunnelManager(Database db, SshTunnelManager manager)
+ throws NoSuchFieldException, IllegalAccessException {
+ Field field = Database.class.getDeclaredField("sshTunnelManager");
+ field.setAccessible(true);
+ field.set(db, manager);
+ }
+}
diff --git
a/core/src/test/java/org/apache/hop/core/database/SshTunnelManagerTest.java
b/core/src/test/java/org/apache/hop/core/database/SshTunnelManagerTest.java
new file mode 100644
index 0000000000..f02eb645f9
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/core/database/SshTunnelManagerTest.java
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.database;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.apache.hop.core.exception.HopDatabaseException;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.variables.IVariables;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class SshTunnelManagerTest {
+
+ private SshTunnelManager tunnelManager;
+ private ILogChannel log;
+
+ @BeforeEach
+ void setUp() {
+ tunnelManager = new SshTunnelManager();
+ log = mock(ILogChannel.class);
+ }
+
+ @Test
+ void testInitialState() {
+ assertFalse(tunnelManager.isOpen());
+ assertEquals(0, tunnelManager.getLocalPort());
+ }
+
+ @Test
+ void testCloseTunnelWhenNotOpen() {
+ tunnelManager.closeTunnel(log);
+ assertFalse(tunnelManager.isOpen());
+ }
+
+ @Test
+ void testCloseTunnelMultipleTimes() {
+ tunnelManager.closeTunnel(log);
+ tunnelManager.closeTunnel(log);
+ assertFalse(tunnelManager.isOpen());
+ }
+
+ @Test
+ void testOpenTunnelWithInvalidHost() {
+ IVariables variables = mock(IVariables.class);
+ when(variables.resolve(anyString())).thenAnswer(inv -> inv.getArgument(0));
+
+ DatabaseMeta databaseMeta = mock(DatabaseMeta.class);
+
when(databaseMeta.getSshTunnelHost()).thenReturn("invalid-host-that-does-not-exist");
+ when(databaseMeta.getSshTunnelPort()).thenReturn("22");
+ when(databaseMeta.getSshTunnelUsername()).thenReturn("testuser");
+ when(databaseMeta.getSshTunnelPassword()).thenReturn("testpass");
+ when(databaseMeta.getHostname()).thenReturn("localhost");
+ when(databaseMeta.getPort()).thenReturn("3306");
+ when(databaseMeta.getDefaultDatabasePort()).thenReturn(3306);
+ when(databaseMeta.isSshTunnelUsePrivateKey()).thenReturn(false);
+
+ assertThrows(
+ HopDatabaseException.class, () -> tunnelManager.openTunnel(variables,
databaseMeta, log));
+ }
+
+ @Test
+ void testOpenTunnelWithInvalidHostCleansTunnel() {
+ IVariables variables = mock(IVariables.class);
+ when(variables.resolve(anyString())).thenAnswer(inv -> inv.getArgument(0));
+
+ DatabaseMeta databaseMeta = mock(DatabaseMeta.class);
+
when(databaseMeta.getSshTunnelHost()).thenReturn("invalid-host-that-does-not-exist");
+ when(databaseMeta.getSshTunnelPort()).thenReturn("22");
+ when(databaseMeta.getSshTunnelUsername()).thenReturn("testuser");
+ when(databaseMeta.getSshTunnelPassword()).thenReturn("testpass");
+ when(databaseMeta.getHostname()).thenReturn("localhost");
+ when(databaseMeta.getPort()).thenReturn("3306");
+ when(databaseMeta.getDefaultDatabasePort()).thenReturn(3306);
+ when(databaseMeta.isSshTunnelUsePrivateKey()).thenReturn(false);
+
+ try {
+ tunnelManager.openTunnel(variables, databaseMeta, log);
+ } catch (Exception e) {
+ // Expected
+ }
+
+ assertFalse(tunnelManager.isOpen());
+ }
+}
diff --git a/docker/integration-tests/Dockerfile.ssh
b/docker/integration-tests/Dockerfile.ssh
new file mode 100644
index 0000000000..fab96c1a11
--- /dev/null
+++ b/docker/integration-tests/Dockerfile.ssh
@@ -0,0 +1,35 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+# Minimal SSH server for integration tests: allows password auth and TCP
forwarding
+# so Hop can tunnel to Postgres through this container.
+#
+FROM alpine:3.19
+
+RUN apk add --no-cache openssh && \
+ ssh-keygen -A && \
+ adduser -D -s /bin/sh hop && \
+ echo "hop:hop_ssh_password" | chpasswd && \
+ sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
&& \
+ sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication yes/'
/etc/ssh/sshd_config && \
+ sed -i 's/^#\?AllowTcpForwarding.*/AllowTcpForwarding yes/'
/etc/ssh/sshd_config && \
+ sed -i 's/^#\?GatewayPorts.*/GatewayPorts no/' /etc/ssh/sshd_config
+
+EXPOSE 22
+
+CMD ["/usr/sbin/sshd", "-D"]
diff --git a/docker/integration-tests/integration-tests-database.yaml
b/docker/integration-tests/integration-tests-database.yaml
index 08530955ed..59226e7bb7 100644
--- a/docker/integration-tests/integration-tests-database.yaml
+++ b/docker/integration-tests/integration-tests-database.yaml
@@ -23,13 +23,27 @@ services:
depends_on:
postgres:
condition: service_healthy
+ ssh:
+ condition: service_started
links:
- postgres
- mssql
- mysql
+ - ssh
+ environment:
+ - SSH_TUNNEL_HOST=ssh
+ - SSH_TUNNEL_PORT=22
+ - SSH_TUNNEL_USER=hop
+ - SSH_TUNNEL_PASSWORD=hop_ssh_password
volumes:
- ./resource/mssql/mssql_bulkload.csv:/tmp/mssql_bulkload.csv
+ ssh:
+ build:
+ context: .
+ dockerfile: ssh.Dockerfile
+ # No host port exposure needed; test container connects via service name
"ssh"
+
postgres:
image: postgres:latest
env_file:
diff --git a/integration-tests/database/main-0038-postgres-via-ssh.hwf
b/integration-tests/database/main-0038-postgres-via-ssh.hwf
new file mode 100644
index 0000000000..ed6b259734
--- /dev/null
+++ b/integration-tests/database/main-0038-postgres-via-ssh.hwf
@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+-->
+<workflow>
+ <name>main-0038-postgres-via-ssh</name>
+ <name_sync_with_filename>Y</name_sync_with_filename>
+ <description>PostgreSQL via SSH tunnel</description>
+ <extended_description/>
+ <workflow_version/>
+ <created_user>-</created_user>
+ <created_date>2025/03/16 00:00:00.000</created_date>
+ <modified_user>-</modified_user>
+ <modified_date>2025/03/16 00:00:00.000</modified_date>
+ <parameters>
+ </parameters>
+ <actions>
+ <action>
+ <name>Start</name>
+ <description/>
+ <type>SPECIAL</type>
+ <attributes/>
+ <DayOfMonth>1</DayOfMonth>
+ <doNotWaitOnFirstExecution>N</doNotWaitOnFirstExecution>
+ <hour>12</hour>
+ <intervalMinutes>60</intervalMinutes>
+ <intervalSeconds>0</intervalSeconds>
+ <minutes>0</minutes>
+ <repeat>N</repeat>
+ <schedulerType>0</schedulerType>
+ <weekDay>1</weekDay>
+ <parallel>N</parallel>
+ <xloc>50</xloc>
+ <yloc>50</yloc>
+ <attributes_hac/>
+ </action>
+ <action>
+ <name>SQL</name>
+ <description/>
+ <type>SQL</type>
+ <attributes/>
+ <connection>unit-test-db-ssh</connection>
+ <sendOneStatement>N</sendOneStatement>
+ <sql>SELECT 1 AS one, current_database() AS db</sql>
+ <sqlfromfile>N</sqlfromfile>
+ <useVariableSubstitution>N</useVariableSubstitution>
+ <parallel>N</parallel>
+ <xloc>195</xloc>
+ <yloc>50</yloc>
+ <attributes_hac/>
+ </action>
+ </actions>
+ <hops>
+ <hop>
+ <from>Start</from>
+ <to>SQL</to>
+ <enabled>Y</enabled>
+ <evaluation>Y</evaluation>
+ <unconditional>Y</unconditional>
+ </hop>
+ </hops>
+ <notepads>
+ </notepads>
+ <attributes/>
+</workflow>
diff --git a/integration-tests/database/metadata/rdbms/unit-test-db-ssh.json
b/integration-tests/database/metadata/rdbms/unit-test-db-ssh.json
new file mode 100644
index 0000000000..9d08c415e3
--- /dev/null
+++ b/integration-tests/database/metadata/rdbms/unit-test-db-ssh.json
@@ -0,0 +1,34 @@
+{
+ "rdbms": {
+ "POSTGRESQL": {
+ "sshTunnelPort": "${SSH_TUNNEL_PORT}",
+ "sshTunnelUsePrivateKey": false,
+ "databaseName": "${POSTGRES_DATABASE}",
+ "pluginId": "POSTGRESQL",
+ "sshTunnelEnabled": true,
+ "accessType": 0,
+ "sshTunnelUsername": "${SSH_TUNNEL_USER}",
+ "hostname": "${POSTGRES_HOST}",
+ "password": "${POSTGRES_PASSWORD}",
+ "pluginName": "PostgreSQL",
+ "port": "${POSTGRES_PORT}",
+ "sshTunnelHost": "${SSH_TUNNEL_HOST}",
+ "attributes": {
+ "SUPPORTS_TIMESTAMP_DATA_TYPE": "Y",
+ "QUOTE_ALL_FIELDS": "N",
+ "SUPPORTS_BOOLEAN_DATA_TYPE": "Y",
+ "FORCE_IDENTIFIERS_TO_LOWERCASE": "N",
+ "PRESERVE_RESERVED_WORD_CASE": "Y",
+ "SQL_CONNECT": "",
+ "FORCE_IDENTIFIERS_TO_UPPERCASE": "N",
+ "PREFERRED_SCHEMA_NAME": ""
+ },
+ "manualUrl": "",
+ "sshTunnelPassphrase": "Encrypted ",
+ "sshTunnelPassword": "${SSH_TUNNEL_PASSWORD}",
+ "sshTunnelPrivateKeyFile": "",
+ "username": "${POSTGRES_USER}"
+ }
+ },
+ "name": "unit-test-db-ssh"
+}
\ No newline at end of file
diff --git a/integration-tests/scripts/run-tests.sh
b/integration-tests/scripts/run-tests.sh
index fab9236766..5d9e5305c3 100755
--- a/integration-tests/scripts/run-tests.sh
+++ b/integration-tests/scripts/run-tests.sh
@@ -61,6 +61,20 @@ if [ -z "${POSTGRES_PASSWORD}" ]; then
POSTGRES_PASSWORD=hop_password
fi
+# SSH tunnel parameters (for PostgreSQL-via-SSH integration tests)
+if [ -z "${SSH_TUNNEL_HOST}" ]; then
+ SSH_TUNNEL_HOST=ssh
+fi
+if [ -z "${SSH_TUNNEL_PORT}" ]; then
+ SSH_TUNNEL_PORT=22
+fi
+if [ -z "${SSH_TUNNEL_USER}" ]; then
+ SSH_TUNNEL_USER=hop
+fi
+if [ -z "${SSH_TUNNEL_PASSWORD}" ]; then
+ SSH_TUNNEL_PASSWORD=hop_ssh_password
+fi
+
if [ -z "${PROJECT_NAME}" ]; then
PROJECT_NAME="*"
fi
@@ -152,6 +166,10 @@ for d in "${CURRENT_DIR}"/../${PROJECT_NAME}/; do
-p POSTGRES_PORT=${POSTGRES_PORT} \
-p POSTGRES_USER=${POSTGRES_USER} \
-p POSTGRES_PASSWORD=${POSTGRES_PASSWORD} \
+ -p SSH_TUNNEL_HOST=${SSH_TUNNEL_HOST} \
+ -p SSH_TUNNEL_PORT=${SSH_TUNNEL_PORT} \
+ -p SSH_TUNNEL_USER=${SSH_TUNNEL_USER} \
+ -p SSH_TUNNEL_PASSWORD=${SSH_TUNNEL_PASSWORD} \
-p BOOTSTRAP_SERVERS=${BOOTSTRAP_SERVERS} \
-f $hop_file > >(tee /tmp/test_output) 2> >(tee /tmp/test_output_err
>&1)
diff --git
a/ui/src/main/java/org/apache/hop/ui/core/database/DatabaseMetaEditor.java
b/ui/src/main/java/org/apache/hop/ui/core/database/DatabaseMetaEditor.java
index 06f799942d..111dc48f5c 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/database/DatabaseMetaEditor.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/database/DatabaseMetaEditor.java
@@ -106,6 +106,18 @@ public class DatabaseMetaEditor extends
MetadataEditor<DatabaseMeta> {
private TableView wOptions;
+ // SSH Tunnel tab widgets
+ private Button wSshTunnelEnabled;
+ private TextVar wSshTunnelHost;
+ private TextVar wSshTunnelPort;
+ private TextVar wSshTunnelUsername;
+ private TextVar wSshTunnelPassword;
+ private Button wSshTunnelUsePrivateKey;
+ private TextVar wSshTunnelPrivateKeyFile;
+ private TextVar wSshTunnelPassphrase;
+ private Label wlSshTunnelPrivateKeyFile;
+ private Label wlSshTunnelPassphrase;
+
private PropsUi props;
private int middle;
private int margin;
@@ -212,6 +224,7 @@ public class DatabaseMetaEditor extends
MetadataEditor<DatabaseMeta> {
addGeneralTab();
addAdvancedTab();
addOptionsTab();
+ addSshTunnelTab();
// Select the general tab
//
@@ -255,6 +268,16 @@ public class DatabaseMetaEditor extends
MetadataEditor<DatabaseMeta> {
wPreferredSchema.addListener(SWT.Modify, modifyListener);
wSqlStatements.addListener(SWT.Modify, modifyListener);
wOptions.addListener(SWT.Modify, modifyListener);
+ wSshTunnelEnabled.addListener(SWT.Selection, modifyListener);
+ wSshTunnelEnabled.addListener(SWT.Selection, event ->
enableSshTunnelFields());
+ wSshTunnelHost.addListener(SWT.Modify, modifyListener);
+ wSshTunnelPort.addListener(SWT.Modify, modifyListener);
+ wSshTunnelUsername.addListener(SWT.Modify, modifyListener);
+ wSshTunnelPassword.addListener(SWT.Modify, modifyListener);
+ wSshTunnelUsePrivateKey.addListener(SWT.Selection, modifyListener);
+ wSshTunnelUsePrivateKey.addListener(SWT.Selection, event ->
enableSshTunnelFields());
+ wSshTunnelPrivateKeyFile.addListener(SWT.Modify, modifyListener);
+ wSshTunnelPassphrase.addListener(SWT.Modify, modifyListener);
}
private void addGeneralTab() {
@@ -786,6 +809,208 @@ public class DatabaseMetaEditor extends
MetadataEditor<DatabaseMeta> {
wOptionsTab.setControl(wOptionsComp);
}
+ private void addSshTunnelTab() {
+
+ CTabItem wSshTunnelTab = new CTabItem(wTabFolder, SWT.NONE);
+ wSshTunnelTab.setFont(GuiResource.getInstance().getFontDefault());
+ wSshTunnelTab.setText(
+ " " + BaseMessages.getString(PKG,
"DatabaseDialog.SshTunnelTab.title") + " ");
+
+ Composite wSshTunnelComp = new Composite(wTabFolder, SWT.NONE);
+ PropsUi.setLook(wSshTunnelComp);
+
+ FormLayout sshLayout = new FormLayout();
+ sshLayout.marginWidth = PropsUi.getFormMargin() * 2;
+ sshLayout.marginHeight = PropsUi.getFormMargin() * 2;
+ wSshTunnelComp.setLayout(sshLayout);
+
+ // Enable SSH Tunnel checkbox
+ Label wlSshTunnelEnabled = new Label(wSshTunnelComp, SWT.RIGHT);
+ PropsUi.setLook(wlSshTunnelEnabled);
+ wlSshTunnelEnabled.setText(
+ BaseMessages.getString(PKG, "DatabaseDialog.label.SshTunnelEnabled"));
+ FormData fdlSshTunnelEnabled = new FormData();
+ fdlSshTunnelEnabled.top = new FormAttachment(0, 0);
+ fdlSshTunnelEnabled.left = new FormAttachment(0, 0);
+ fdlSshTunnelEnabled.right = new FormAttachment(middle, 0);
+ wlSshTunnelEnabled.setLayoutData(fdlSshTunnelEnabled);
+ wSshTunnelEnabled = new Button(wSshTunnelComp, SWT.CHECK | SWT.LEFT);
+ PropsUi.setLook(wSshTunnelEnabled);
+ FormData fdSshTunnelEnabled = new FormData();
+ fdSshTunnelEnabled.top = new FormAttachment(wlSshTunnelEnabled, 0,
SWT.CENTER);
+ fdSshTunnelEnabled.left = new FormAttachment(middle, margin);
+ fdSshTunnelEnabled.right = new FormAttachment(100, 0);
+ wSshTunnelEnabled.setLayoutData(fdSshTunnelEnabled);
+ Control lastControl = wSshTunnelEnabled;
+
+ // SSH Host
+ Label wlSshTunnelHost = new Label(wSshTunnelComp, SWT.RIGHT);
+ PropsUi.setLook(wlSshTunnelHost);
+ wlSshTunnelHost.setText(BaseMessages.getString(PKG,
"DatabaseDialog.label.SshTunnelHost"));
+ FormData fdlSshTunnelHost = new FormData();
+ fdlSshTunnelHost.top = new FormAttachment(lastControl, margin);
+ fdlSshTunnelHost.left = new FormAttachment(0, 0);
+ fdlSshTunnelHost.right = new FormAttachment(middle, 0);
+ wlSshTunnelHost.setLayoutData(fdlSshTunnelHost);
+ wSshTunnelHost =
+ new TextVar(manager.getVariables(), wSshTunnelComp, SWT.SINGLE |
SWT.LEFT | SWT.BORDER);
+ PropsUi.setLook(wSshTunnelHost);
+ FormData fdSshTunnelHost = new FormData();
+ fdSshTunnelHost.top = new FormAttachment(wlSshTunnelHost, 0, SWT.CENTER);
+ fdSshTunnelHost.left = new FormAttachment(middle, margin);
+ fdSshTunnelHost.right = new FormAttachment(100, 0);
+ wSshTunnelHost.setLayoutData(fdSshTunnelHost);
+ lastControl = wSshTunnelHost;
+
+ // SSH Port
+ Label wlSshTunnelPort = new Label(wSshTunnelComp, SWT.RIGHT);
+ PropsUi.setLook(wlSshTunnelPort);
+ wlSshTunnelPort.setText(BaseMessages.getString(PKG,
"DatabaseDialog.label.SshTunnelPort"));
+ FormData fdlSshTunnelPort = new FormData();
+ fdlSshTunnelPort.top = new FormAttachment(lastControl, margin);
+ fdlSshTunnelPort.left = new FormAttachment(0, 0);
+ fdlSshTunnelPort.right = new FormAttachment(middle, 0);
+ wlSshTunnelPort.setLayoutData(fdlSshTunnelPort);
+ wSshTunnelPort =
+ new TextVar(manager.getVariables(), wSshTunnelComp, SWT.SINGLE |
SWT.LEFT | SWT.BORDER);
+ PropsUi.setLook(wSshTunnelPort);
+ FormData fdSshTunnelPort = new FormData();
+ fdSshTunnelPort.top = new FormAttachment(wlSshTunnelPort, 0, SWT.CENTER);
+ fdSshTunnelPort.left = new FormAttachment(middle, margin);
+ fdSshTunnelPort.right = new FormAttachment(100, 0);
+ wSshTunnelPort.setLayoutData(fdSshTunnelPort);
+ lastControl = wSshTunnelPort;
+
+ // SSH Username
+ Label wlSshTunnelUsername = new Label(wSshTunnelComp, SWT.RIGHT);
+ PropsUi.setLook(wlSshTunnelUsername);
+ wlSshTunnelUsername.setText(
+ BaseMessages.getString(PKG, "DatabaseDialog.label.SshTunnelUsername"));
+ FormData fdlSshTunnelUsername = new FormData();
+ fdlSshTunnelUsername.top = new FormAttachment(lastControl, margin);
+ fdlSshTunnelUsername.left = new FormAttachment(0, 0);
+ fdlSshTunnelUsername.right = new FormAttachment(middle, 0);
+ wlSshTunnelUsername.setLayoutData(fdlSshTunnelUsername);
+ wSshTunnelUsername =
+ new TextVar(manager.getVariables(), wSshTunnelComp, SWT.SINGLE |
SWT.LEFT | SWT.BORDER);
+ PropsUi.setLook(wSshTunnelUsername);
+ FormData fdSshTunnelUsername = new FormData();
+ fdSshTunnelUsername.top = new FormAttachment(wlSshTunnelUsername, 0,
SWT.CENTER);
+ fdSshTunnelUsername.left = new FormAttachment(middle, margin);
+ fdSshTunnelUsername.right = new FormAttachment(100, 0);
+ wSshTunnelUsername.setLayoutData(fdSshTunnelUsername);
+ lastControl = wSshTunnelUsername;
+
+ // SSH Password
+ Label wlSshTunnelPassword = new Label(wSshTunnelComp, SWT.RIGHT);
+ PropsUi.setLook(wlSshTunnelPassword);
+ wlSshTunnelPassword.setText(
+ BaseMessages.getString(PKG, "DatabaseDialog.label.SshTunnelPassword"));
+ FormData fdlSshTunnelPassword = new FormData();
+ fdlSshTunnelPassword.top = new FormAttachment(lastControl, margin);
+ fdlSshTunnelPassword.left = new FormAttachment(0, 0);
+ fdlSshTunnelPassword.right = new FormAttachment(middle, 0);
+ wlSshTunnelPassword.setLayoutData(fdlSshTunnelPassword);
+ wSshTunnelPassword =
+ new TextVar(
+ manager.getVariables(),
+ wSshTunnelComp,
+ SWT.SINGLE | SWT.LEFT | SWT.BORDER | SWT.PASSWORD);
+ PropsUi.setLook(wSshTunnelPassword);
+ FormData fdSshTunnelPassword = new FormData();
+ fdSshTunnelPassword.top = new FormAttachment(wlSshTunnelPassword, 0,
SWT.CENTER);
+ fdSshTunnelPassword.left = new FormAttachment(middle, margin);
+ fdSshTunnelPassword.right = new FormAttachment(100, 0);
+ wSshTunnelPassword.setLayoutData(fdSshTunnelPassword);
+ lastControl = wSshTunnelPassword;
+
+ // Use Private Key checkbox
+ Label wlSshTunnelUsePrivateKey = new Label(wSshTunnelComp, SWT.RIGHT);
+ PropsUi.setLook(wlSshTunnelUsePrivateKey);
+ wlSshTunnelUsePrivateKey.setText(
+ BaseMessages.getString(PKG,
"DatabaseDialog.label.SshTunnelUsePrivateKey"));
+ FormData fdlSshTunnelUsePrivateKey = new FormData();
+ fdlSshTunnelUsePrivateKey.top = new FormAttachment(lastControl, margin);
+ fdlSshTunnelUsePrivateKey.left = new FormAttachment(0, 0);
+ fdlSshTunnelUsePrivateKey.right = new FormAttachment(middle, 0);
+ wlSshTunnelUsePrivateKey.setLayoutData(fdlSshTunnelUsePrivateKey);
+ wSshTunnelUsePrivateKey = new Button(wSshTunnelComp, SWT.CHECK | SWT.LEFT);
+ PropsUi.setLook(wSshTunnelUsePrivateKey);
+ FormData fdSshTunnelUsePrivateKey = new FormData();
+ fdSshTunnelUsePrivateKey.top = new
FormAttachment(wlSshTunnelUsePrivateKey, 0, SWT.CENTER);
+ fdSshTunnelUsePrivateKey.left = new FormAttachment(middle, margin);
+ fdSshTunnelUsePrivateKey.right = new FormAttachment(100, 0);
+ wSshTunnelUsePrivateKey.setLayoutData(fdSshTunnelUsePrivateKey);
+ lastControl = wSshTunnelUsePrivateKey;
+
+ // Private Key File
+ wlSshTunnelPrivateKeyFile = new Label(wSshTunnelComp, SWT.RIGHT);
+ PropsUi.setLook(wlSshTunnelPrivateKeyFile);
+ wlSshTunnelPrivateKeyFile.setText(
+ BaseMessages.getString(PKG,
"DatabaseDialog.label.SshTunnelPrivateKeyFile"));
+ FormData fdlSshTunnelPrivateKeyFile = new FormData();
+ fdlSshTunnelPrivateKeyFile.top = new FormAttachment(lastControl, margin);
+ fdlSshTunnelPrivateKeyFile.left = new FormAttachment(0, 0);
+ fdlSshTunnelPrivateKeyFile.right = new FormAttachment(middle, 0);
+ wlSshTunnelPrivateKeyFile.setLayoutData(fdlSshTunnelPrivateKeyFile);
+ wSshTunnelPrivateKeyFile =
+ new TextVar(manager.getVariables(), wSshTunnelComp, SWT.SINGLE |
SWT.LEFT | SWT.BORDER);
+ PropsUi.setLook(wSshTunnelPrivateKeyFile);
+ FormData fdSshTunnelPrivateKeyFile = new FormData();
+ fdSshTunnelPrivateKeyFile.top = new
FormAttachment(wlSshTunnelPrivateKeyFile, 0, SWT.CENTER);
+ fdSshTunnelPrivateKeyFile.left = new FormAttachment(middle, margin);
+ fdSshTunnelPrivateKeyFile.right = new FormAttachment(100, 0);
+ wSshTunnelPrivateKeyFile.setLayoutData(fdSshTunnelPrivateKeyFile);
+ lastControl = wSshTunnelPrivateKeyFile;
+
+ // Passphrase
+ wlSshTunnelPassphrase = new Label(wSshTunnelComp, SWT.RIGHT);
+ PropsUi.setLook(wlSshTunnelPassphrase);
+ wlSshTunnelPassphrase.setText(
+ BaseMessages.getString(PKG,
"DatabaseDialog.label.SshTunnelPassphrase"));
+ FormData fdlSshTunnelPassphrase = new FormData();
+ fdlSshTunnelPassphrase.top = new FormAttachment(lastControl, margin);
+ fdlSshTunnelPassphrase.left = new FormAttachment(0, 0);
+ fdlSshTunnelPassphrase.right = new FormAttachment(middle, 0);
+ wlSshTunnelPassphrase.setLayoutData(fdlSshTunnelPassphrase);
+ wSshTunnelPassphrase =
+ new TextVar(
+ manager.getVariables(),
+ wSshTunnelComp,
+ SWT.SINGLE | SWT.LEFT | SWT.BORDER | SWT.PASSWORD);
+ PropsUi.setLook(wSshTunnelPassphrase);
+ FormData fdSshTunnelPassphrase = new FormData();
+ fdSshTunnelPassphrase.top = new FormAttachment(wlSshTunnelPassphrase, 0,
SWT.CENTER);
+ fdSshTunnelPassphrase.left = new FormAttachment(middle, margin);
+ fdSshTunnelPassphrase.right = new FormAttachment(100, 0);
+ wSshTunnelPassphrase.setLayoutData(fdSshTunnelPassphrase);
+
+ FormData fdSshTunnelComp = new FormData();
+ fdSshTunnelComp.left = new FormAttachment(0, 0);
+ fdSshTunnelComp.top = new FormAttachment(0, 0);
+ fdSshTunnelComp.right = new FormAttachment(100, 0);
+ fdSshTunnelComp.bottom = new FormAttachment(100, 0);
+ wSshTunnelComp.setLayoutData(fdSshTunnelComp);
+
+ wSshTunnelComp.layout();
+ wSshTunnelTab.setControl(wSshTunnelComp);
+ }
+
+ private void enableSshTunnelFields() {
+ boolean enabled = wSshTunnelEnabled.getSelection();
+ boolean usePrivateKey = wSshTunnelUsePrivateKey.getSelection();
+
+ wSshTunnelHost.setEnabled(enabled);
+ wSshTunnelPort.setEnabled(enabled);
+ wSshTunnelUsername.setEnabled(enabled);
+ wSshTunnelPassword.setEnabled(enabled && !usePrivateKey);
+ wSshTunnelUsePrivateKey.setEnabled(enabled);
+ wSshTunnelPrivateKeyFile.setEnabled(enabled && usePrivateKey);
+ wSshTunnelPassphrase.setEnabled(enabled && usePrivateKey);
+ wlSshTunnelPrivateKeyFile.setEnabled(enabled && usePrivateKey);
+ wlSshTunnelPassphrase.setEnabled(enabled && usePrivateKey);
+ }
+
private void enableFields() {
boolean manualUrl = false;
if (wManualUrl != null) {
@@ -888,8 +1113,19 @@ public class DatabaseMetaEditor extends
MetadataEditor<DatabaseMeta> {
wOptions.setRowNums();
wOptions.optWidth(true);
+ // SSH Tunnel fields
+ wSshTunnelEnabled.setSelection(databaseMeta.isSshTunnelEnabled());
+ wSshTunnelHost.setText(Const.NVL(databaseMeta.getSshTunnelHost(), ""));
+ wSshTunnelPort.setText(Const.NVL(databaseMeta.getSshTunnelPort(), "22"));
+ wSshTunnelUsername.setText(Const.NVL(databaseMeta.getSshTunnelUsername(),
""));
+ wSshTunnelPassword.setText(Const.NVL(databaseMeta.getSshTunnelPassword(),
""));
+
wSshTunnelUsePrivateKey.setSelection(databaseMeta.isSshTunnelUsePrivateKey());
+
wSshTunnelPrivateKeyFile.setText(Const.NVL(databaseMeta.getSshTunnelPrivateKeyFile(),
""));
+
wSshTunnelPassphrase.setText(Const.NVL(databaseMeta.getSshTunnelPassphrase(),
""));
+
updateDriverInfo();
enableFields();
+ enableSshTunnelFields();
}
@Override
@@ -938,6 +1174,16 @@ public class DatabaseMetaEditor extends
MetadataEditor<DatabaseMeta> {
String value = item.getText(2);
meta.addExtraOption(meta.getPluginId(), option, value);
}
+
+ // SSH Tunnel fields
+ meta.setSshTunnelEnabled(wSshTunnelEnabled.getSelection());
+ meta.setSshTunnelHost(wSshTunnelHost.getText());
+ meta.setSshTunnelPort(wSshTunnelPort.getText());
+ meta.setSshTunnelUsername(wSshTunnelUsername.getText());
+ meta.setSshTunnelPassword(wSshTunnelPassword.getText());
+ meta.setSshTunnelUsePrivateKey(wSshTunnelUsePrivateKey.getSelection());
+ meta.setSshTunnelPrivateKeyFile(wSshTunnelPrivateKeyFile.getText());
+ meta.setSshTunnelPassphrase(wSshTunnelPassphrase.getText());
}
/** Update JDBC driver information and version */
diff --git
a/ui/src/main/resources/org/apache/hop/ui/core/database/messages/messages_en_US.properties
b/ui/src/main/resources/org/apache/hop/ui/core/database/messages/messages_en_US.properties
index 4b39be24ea..a0eefc2449 100644
---
a/ui/src/main/resources/org/apache/hop/ui/core/database/messages/messages_en_US.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/core/database/messages/messages_en_US.properties
@@ -50,6 +50,15 @@ DatabaseDialog.label.UseIntegratedSecurity=Use integrated
security
DatabaseDialog.label.Username=Username
DatabaseDialog.label.Warehouse=Warehouse
DatabaseDialog.OptionsTab.title=Options
+DatabaseDialog.SshTunnelTab.title=SSH Tunnel
+DatabaseDialog.label.SshTunnelEnabled=Enable SSH tunnel
+DatabaseDialog.label.SshTunnelHost=SSH server host name
+DatabaseDialog.label.SshTunnelPort=SSH server port
+DatabaseDialog.label.SshTunnelUsername=SSH username
+DatabaseDialog.label.SshTunnelPassword=SSH password
+DatabaseDialog.label.SshTunnelUsePrivateKey=Use private key authentication
+DatabaseDialog.label.SshTunnelPrivateKeyFile=Private key file
+DatabaseDialog.label.SshTunnelPassphrase=Passphrase
DatabaseExplorerDialog.Toolbar.CollapseAll.Tooltip=Collapse all
DatabaseExplorerDialog.Toolbar.ExpandAll.Tooltip=Expand all
DatabaseToolbarItem.ClearDatabaseCache.ToolTip=Clear the database cache
diff --git
a/ui/src/main/resources/org/apache/hop/ui/core/database/messages/messages_it_IT.properties
b/ui/src/main/resources/org/apache/hop/ui/core/database/messages/messages_it_IT.properties
index 31d91e7322..abb93cd939 100644
---
a/ui/src/main/resources/org/apache/hop/ui/core/database/messages/messages_it_IT.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/core/database/messages/messages_it_IT.properties
@@ -48,5 +48,14 @@ DatabaseDialog.label.UseDoubleDecimalSeparator=Utilizza ..
per separare lo schem
DatabaseDialog.label.Username=Utente
DatabaseDialog.label.Warehouse=Warehouse
DatabaseDialog.OptionsTab.title=Opzioni
+DatabaseDialog.SshTunnelTab.title=Tunnel SSH
+DatabaseDialog.label.SshTunnelEnabled=Abilita tunnel SSH
+DatabaseDialog.label.SshTunnelHost=Host del server SSH
+DatabaseDialog.label.SshTunnelPort=Porta del server SSH
+DatabaseDialog.label.SshTunnelUsername=Utente SSH
+DatabaseDialog.label.SshTunnelPassword=Password SSH
+DatabaseDialog.label.SshTunnelUsePrivateKey=Usa autenticazione con chiave
privata
+DatabaseDialog.label.SshTunnelPrivateKeyFile=File chiave privata
+DatabaseDialog.label.SshTunnelPassphrase=Passphrase
DatabaseExplorerDialog.Toolbar.CollapseAll.Tooltip=Chiudi tutti
DatabaseExplorerDialog.Toolbar.ExpandAll.Tooltip=Espandi tutti