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 749b9e4794 expose ssh tunnel port, fixes #7450 (#7609)
749b9e4794 is described below

commit 749b9e47944cdd37776a32a056fcd2931ff4881e
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Fri Jul 24 10:23:22 2026 +0200

    expose ssh tunnel port, fixes #7450 (#7609)
---
 .../org/apache/hop/core/database/Database.java     | 51 +++++++++++++++++-----
 .../apache/hop/core/database/SshTunnelManager.java |  8 ++++
 .../hop/core/database/DatabaseSshTunnelTest.java   | 34 +++++++++++++++
 .../modules/ROOT/pages/database/databases.adoc     | 32 ++++++++++++++
 .../hop/ui/core/database/DatabaseMetaEditor.java   | 12 +++++
 .../database/messages/messages_en_US.properties    |  1 +
 6 files changed, 128 insertions(+), 10 deletions(-)

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 0eaf15c009..0a5d73babd 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
@@ -478,16 +478,7 @@ public class Database implements IVariables, 
ILoggingObject, AutoCloseable {
       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);
+        url = buildSshTunnelUrl(localPort);
       } else {
         url = resolve(databaseMeta.getURL(this));
       }
@@ -549,6 +540,46 @@ public class Database implements IVariables, 
ILoggingObject, AutoCloseable {
     }
   }
 
+  /**
+   * Builds the JDBC URL used to connect through an open SSH tunnel. The 
tunnel forwards a
+   * dynamically allocated local port to the remote database, so the URL 
always points at {@code
+   * localhost}.
+   *
+   * <p>When a manual JDBC URL is configured it is used as-is (it may 
reference {@code
+   * ${sshTunnel.localPort}} to inject the dynamically allocated port, for 
example inside a complex
+   * Oracle TCPS descriptor). Otherwise the standard URL is generated from the 
database dialect
+   * using {@code localhost} and the forwarded port. The port variable is 
resolved against a child
+   * variable space so it does not leak into the shared parent space.
+   *
+   * @param localPort the local port opened by the SSH tunnel
+   * @return the resolved JDBC URL pointing at the local end of the tunnel
+   * @throws HopDatabaseException if the standard URL cannot be generated
+   */
+  String buildSshTunnelUrl(int localPort) throws HopDatabaseException {
+    IVariables tunnelVariables = new Variables();
+    tunnelVariables.initializeFrom(this);
+    tunnelVariables.setVariable(
+        SshTunnelManager.VARIABLE_SSH_TUNNEL_LOCAL_PORT, 
String.valueOf(localPort));
+
+    String manualUrl = databaseMeta.getManualUrl();
+    if (!Utils.isEmpty(manualUrl)) {
+      // The manual URL points to the local end of the tunnel (localhost) and 
may reference
+      // ${sshTunnel.localPort} for the dynamically assigned port.
+      return tunnelVariables.resolve(manualUrl);
+    }
+
+    // Build the URL from the database dialect using the tunnel endpoint 
(localhost + forwarded
+    // port).
+    String tunnelUrl =
+        databaseMeta
+            .getIDatabase()
+            .getURL(
+                "localhost",
+                String.valueOf(localPort),
+                tunnelVariables.resolve(databaseMeta.getDatabaseName()));
+    return tunnelVariables.resolve(tunnelUrl);
+  }
+
   /**
    * Apply the default login/connection timeout ({@link 
Const#HOP_DATABASE_CONNECTION_TIMEOUT}, in
    * seconds) via {@link DriverManager#setLoginTimeout(int)}. This is a 
JVM-wide setting that bounds
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
index 71b00ed3c8..5530622da7 100644
--- a/core/src/main/java/org/apache/hop/core/database/SshTunnelManager.java
+++ b/core/src/main/java/org/apache/hop/core/database/SshTunnelManager.java
@@ -32,6 +32,14 @@ import org.apache.hop.core.variables.IVariables;
 /** Manages SSH tunnels for database connections using JSch port forwarding. */
 public class SshTunnelManager {
 
+  /**
+   * Name of the variable that exposes the dynamically allocated local port of 
the SSH tunnel. It
+   * can be referenced from a manually specified JDBC URL (for example a 
complex Oracle TCPS
+   * descriptor) as <code>${sshTunnel.localPort}</code> to point the driver at 
the local end of the
+   * tunnel.
+   */
+  public static final String VARIABLE_SSH_TUNNEL_LOCAL_PORT = 
"sshTunnel.localPort";
+
   private Session session;
   private int localPort;
 
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
index 5953f68e48..439dd8f794 100644
--- a/core/src/test/java/org/apache/hop/core/database/DatabaseSshTunnelTest.java
+++ b/core/src/test/java/org/apache/hop/core/database/DatabaseSshTunnelTest.java
@@ -17,6 +17,7 @@
 
 package org.apache.hop.core.database;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
@@ -114,6 +115,39 @@ class DatabaseSshTunnelTest {
     assertNull(field.get(db));
   }
 
+  @Test
+  void testBuildSshTunnelUrlUsesManualUrlWithLocalPortVariable() throws 
Exception {
+    when(meta.getManualUrl())
+        .thenReturn(
+            
"jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCPS)(HOST=localhost)"
+                + "(PORT=${sshTunnel.localPort}))(CONNECT_DATA=(SID=XYZ)))");
+
+    Database db = new Database(log, variables, meta);
+
+    String url = db.buildSshTunnelUrl(54321);
+
+    assertEquals(
+        
"jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCPS)(HOST=localhost)"
+            + "(PORT=54321))(CONNECT_DATA=(SID=XYZ)))",
+        url);
+  }
+
+  @Test
+  void testBuildSshTunnelUrlFallsBackToDialectWhenNoManualUrl() throws 
Exception {
+    when(meta.getManualUrl()).thenReturn(null);
+    when(meta.getDatabaseName()).thenReturn("mydb");
+    IDatabase iDatabase = mock(IDatabase.class);
+    when(meta.getIDatabase()).thenReturn(iDatabase);
+    when(iDatabase.getURL("localhost", "54321", "mydb"))
+        .thenReturn("jdbc:mysql://localhost:54321/mydb");
+
+    Database db = new Database(log, variables, meta);
+
+    String url = db.buildSshTunnelUrl(54321);
+
+    assertEquals("jdbc:mysql://localhost:54321/mydb", url);
+  }
+
   private Connection mockConnection() throws Exception {
     Connection conn = mock(Connection.class);
     DatabaseMetaData dbMetaData = mock(DatabaseMetaData.class);
diff --git a/docs/hop-user-manual/modules/ROOT/pages/database/databases.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/database/databases.adoc
index 238df15b29..7002a089fb 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/database/databases.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/database/databases.adoc
@@ -214,6 +214,38 @@ jdbc:sqlserver://localhost:1433;" +
 |trustServerCertificate|true
 |===
 
+== SSH tunnel
+
+When a database is only reachable through a bastion (jump) host, Hop can open 
an SSH tunnel for you before connecting, without the need for a separate, 
externally managed tunnel. Enable this on the `SSH Tunnel` tab of the database 
connection dialog.
+
+With the tunnel enabled, the `Host Name` and `Port` on the `General` tab 
describe the *remote* database as seen from the SSH server. Hop opens an SSH 
session to the configured SSH host, forwards a dynamically allocated *local* 
port to that remote database, and then connects the JDBC driver to `localhost` 
on the forwarded port.
+
+[%header, width="90%", cols="1,3"]
+|===
+|Property|Description
+|Enable SSH tunnel|Open an SSH tunnel before connecting to the database.
+|SSH server host name|Host name or IP address of the SSH (bastion) server.
+|SSH server port|SSH server port, defaults to `22`.
+|SSH username|User name to authenticate on the SSH server.
+|SSH password|Password to authenticate on the SSH server (when not using a 
private key).
+|Use private key authentication|Authenticate with a private key file instead 
of a password.
+|Private key file|Path to the private key file used for authentication.
+|Passphrase|Optional passphrase protecting the private key.
+|===
+
+=== SSH tunnel with a manual JDBC URL
+
+Because the local port of the tunnel is allocated dynamically at runtime, you 
can't hard-code it into a link:#_advanced_properties[manual JDBC URL]. For 
connection strings that can't be expressed with the standard host/port/database 
template - for example an Oracle TCPS descriptor - Hop exposes the forwarded 
port through the `${sshTunnel.localPort}` variable.
+
+Set a manual URL that points at `localhost` and reference the variable for the 
port. Hop resolves it after the tunnel is established:
+
+[source]
+----
+jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCPS)(HOST=localhost)(PORT=${sshTunnel.localPort}))(CONNECT_DATA=(SID=XYZ)))
+----
+
+The `Host Name` and `Port` on the `General` tab still define the remote end of 
the tunnel; the manual URL only describes how the driver connects to the local 
end. When no manual URL is set, Hop builds the standard URL for the database 
type using `localhost` and the forwarded port automatically.
+
 == Connection and socket timeouts
 
 If a database host becomes unreachable at the wrong moment (for example a 
half-open TCP connection that is never answered), a transform can block 
indefinitely while it waits for the driver. This makes a pipeline appear to 
"hang" during initialization, even though the databases themselves are healthy.
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 0d2fdbb23e..b5b675a7c2 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
@@ -1007,6 +1007,18 @@ public class DatabaseMetaEditor extends 
MetadataEditor<DatabaseMeta> {
     fdSshTunnelPassphrase.left = new FormAttachment(middle, margin);
     fdSshTunnelPassphrase.right = new FormAttachment(100, 0);
     wSshTunnelPassphrase.setLayoutData(fdSshTunnelPassphrase);
+    lastControl = wSshTunnelPassphrase;
+
+    // Informational note about the ${sshTunnel.localPort} variable for manual 
JDBC URLs
+    Label wlSshTunnelLocalPortInfo = new Label(wSshTunnelComp, SWT.LEFT | 
SWT.WRAP);
+    PropsUi.setLook(wlSshTunnelLocalPortInfo);
+    wlSshTunnelLocalPortInfo.setText(
+        BaseMessages.getString(PKG, 
"DatabaseDialog.label.SshTunnelLocalPortInfo"));
+    FormData fdlSshTunnelLocalPortInfo = new FormData();
+    fdlSshTunnelLocalPortInfo.top = new FormAttachment(lastControl, margin * 
2);
+    fdlSshTunnelLocalPortInfo.left = new FormAttachment(middle, margin);
+    fdlSshTunnelLocalPortInfo.right = new FormAttachment(100, 0);
+    wlSshTunnelLocalPortInfo.setLayoutData(fdlSshTunnelLocalPortInfo);
 
     FormData fdSshTunnelComp = new FormData();
     fdSshTunnelComp.left = new FormAttachment(0, 0);
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 36a0fc6b5e..41d216a9f5 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
@@ -96,6 +96,7 @@ DatabaseDialog.label.SshTunnelPassword=SSH password
 DatabaseDialog.label.SshTunnelUsePrivateKey=Use private key authentication
 DatabaseDialog.label.SshTunnelPrivateKeyFile=Private key file
 DatabaseDialog.label.SshTunnelPassphrase=Passphrase
+DatabaseDialog.label.SshTunnelLocalPortInfo=When a manual JDBC URL is set, the 
tunnel points at localhost and the dynamically allocated local port is 
available as the variable $'{'sshTunnel.localPort'}'.
 DatabaseExplorerDialog.Toolbar.CollapseAll.Tooltip=Collapse all
 DatabaseExplorerDialog.Toolbar.ExpandAll.Tooltip=Expand all
 DatabaseToolbarItem.ClearDatabaseCache.ToolTip=Clear the database cache

Reply via email to