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

Reply via email to