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