Skip to content

Commit

Permalink
#828 Add support for custom SocketFactory in connection string and da…
Browse files Browse the repository at this point in the history
…ta sources
  • Loading branch information
mrotteveel committed Dec 18, 2024
1 parent 4cc2d47 commit 3e53124
Show file tree
Hide file tree
Showing 15 changed files with 468 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

== Status

* Draft
* Proposed for: Jaybird 6
* Published: 2024-12-18
* Implemented in: Jaybird 6

== Type

Expand All @@ -15,7 +15,7 @@ Jaybird 5 and earlier directly create `Socket` instances.
There are use-cases where it might be worthwhile to have more control over socket creation.
For example, for SOCKS proxy creation with custom instead of global config (see https://github.com/FirebirdSQL/jaybird/issues/826[#826]), or to allow TLS connections with a TLS proxy (gateway) instead of relying on built-in wire-encryption.

Adding support for a custom `javax.net.SocketFactory` would allow users to override socket creation.
Adding support for a custom `javax.net.SocketFactory` allows users to override socket creation.

As shown by the SOCKS proxy example of https://github.com/FirebirdSQL/jaybird/issues/826[#826], having some way to expose connection-specific information would also be useful.
This should not expose *all* connection information, but only that information that the user explicitly wants to pass to the custom socket factory.
Expand All @@ -26,9 +26,8 @@ Jaybird will add a connection property `socketFactory`, which accepts the name o
If the property is not set (the default), the default `SocketFactory` (`SocketFactory.getDefault()`) is used.
The `SocketFactory` will be created anew for each connection.

The implementation either has a parameterless constructor, or a constructor accepting a `java.util.Properties` object.
This `Properties` object is used to pass custom properties to the socket factory.
It is populated by selecting the connection properties with the suffix `@socketFactory` and including the non-``null`` string values in the `Properties` object.
The implementation either has a public single-arg constructor accepting a `java.util.Properties` object, or a public no-arg constructor.
This `Properties` object is used to pass custom properties to the socket factory, and is populated by selecting the connection properties with the suffix `@socketFactory` and including the non-``null`` string values in the `Properties` object.
The suffix is retained for the property names (this reduces ambiguity, and will also allow us to include other properties in the future).

We explicitly and intentionally do not add support to set a `SocketFactory` instance (e.g. on a `DataSource`).
Expand All @@ -41,4 +40,4 @@ This support is limited to the pure Java implementation.
The `socketFactory` option and passing of configuration must be documented in the Jaybird manual.

Currently, Jaybird always creates unconnected sockets (that is, `SocketFactory.createSocket()`).
We recommend that implementations that don't support the other `createSocket` methods to throw an `UnsupportedOperationException` or an `IOException` with a clear message that the socket factory does not support the method.
We recommend that implementations that don't support the other `createSocket` methods to throw an `UnsupportedOperationException` with a clear message that the socket factory does not support the method.
46 changes: 46 additions & 0 deletions src/docs/asciidoc/release_notes.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,52 @@ We're considering to make server-side scrollable cursors the default in a future

See also https://github.com/FirebirdSQL/jaybird/blob/master/devdoc/jdp/jdp-2024-05-behavior-of-updatable-result-sets.adoc[jdp-2024-05: Behaviour of Updatable Result Sets^].

[#custom-socket-factory]
=== Custom socket factory for pure Java connections

A custom socket factory can now be specified, to customize the creation of the `java.net.Socket` instance of a pure Java database or service connection.

The connection property `socketFactory` accepts the class name of an implementation of `javax.net.SocketFactory`.
This socket factory is created anew for each connection.
If `socketFactory` is not specified, Jaybird will use `SocketFactory.getDefault()` as its factory.

The `SocketFactory` implementation must adhere to the following rules:

- The class must have a public constructor accepting a `java.util.Properties` object, or a public no-arg constructor.
- The implementation of `SocketFactory#createSocket()` must return an unconnected socket;
the other `createSocket` methods are not called by Jaybird.
+
If you don't want to implement the other `createSocket` methods, we recommend throwing `java.lang.UnsupportedOperationException` with a clear message from those methods.

It is possible to pass custom connection properties to the socket factory if it has a public single-arg constructor accepting a `Properties` object.
Jaybird will instantiate the socket factory with a `Properties` object containing _only_ the connection properties with the suffix `@socketFactory` and a non-``null`` values;
non-string values are converted to string.
In the future, we may also -- selectively -- pass other connection properties, but for now we only expose those properties that are explicitly set for the socket factory.

For example, say we have some custom socket factory called `org.example.CustomProxySocketFactory` with a `CustomProxySocketFactory(Properties)` constructor:

[source,java]
----
var props = new Properties()
props.setProperty("user", "sysdba");
props.setProperty("password", "masterkey");
props.setProperty("socketFactory", "org.example.CustomProxySocketFactory");
props.setProperty("proxyHost@socketFactory", "localhost");
props.setProperty("proxyPort@socketFactory", "1234");
props.setProperty("proxyUser@socketFactory", "proxy-user");
props.setProperty("proxyPassword@socketFactory", "proxy-password");
try (var connection = DriverManager.getConnection(
"jdbc:firebird://remoteserver.example.org/db", props)) {
// use connection
}
----

This will create the specified socket factory, passing a `Properties` object containing *only* the four custom properties ending in `@socketFactory`.
The other properties -- here `user`, `password` and `socketFactory` -- are *not* passed to the socket factory.

See also https://github.com/FirebirdSQL/jaybird/blob/master/devdoc/jdp/jdp-2024-09-custom-socket-factory-for-pure-java-connections.adoc[jdp-2024-09: Custom socket factory for pure Java connections]

// TODO add major changes

[#other-fixes-and-changes]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,16 @@ public void setParallelWorkers(int parallelWorkers) {
FirebirdConnectionProperties.super.setParallelWorkers(parallelWorkers);
}

@Override
public String getSocketFactory() {
return FirebirdConnectionProperties.super.getSocketFactory();
}

@Override
public void setSocketFactory(String socketFactory) {
FirebirdConnectionProperties.super.setSocketFactory(socketFactory);
}

@Override
public boolean isUseCatalogAsPackage() {
return FirebirdConnectionProperties.super.isUseCatalogAsPackage();
Expand Down
3 changes: 3 additions & 0 deletions src/main/org/firebirdsql/gds/JaybirdErrorCodes.java
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ public interface JaybirdErrorCodes {
int jb_noAuthenticationPlugin = 337248344;
int jb_asyncChannelAlreadyEstablished = 337248345;
int jb_asyncChannelNotConnected = 337248346;
int jb_socketFactoryClassNotFound = 337248347;
int jb_socketFactoryConstructorNotFound = 337248348;
int jb_socketFactoryFailedToCreateSocket = 337248349;

@SuppressWarnings("unused")
int jb_range_end = 337264639;
Expand Down
89 changes: 76 additions & 13 deletions src/main/org/firebirdsql/gds/ng/wire/WireConnection.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,14 @@
import org.firebirdsql.gds.ng.dbcrypt.DbCryptCallback;
import org.firebirdsql.gds.ng.wire.auth.ClientAuthBlock;
import org.firebirdsql.gds.ng.wire.crypt.KnownServerKey;
import org.firebirdsql.jaybird.props.def.ConnectionProperty;
import org.firebirdsql.jaybird.util.ByteArrayHelper;

import javax.net.SocketFactory;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.security.AccessController;
Expand All @@ -54,6 +57,7 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.concurrent.TimeUnit;

import static java.lang.System.Logger.Level.DEBUG;
Expand Down Expand Up @@ -229,30 +233,25 @@ public final void resetSocketTimeout() throws SQLException {
}

/**
* Establishes the TCP/IP connection to serverName and portNumber of this
* Connection
* Establishes the TCP/IP connection to serverName and portNumber of this connection.
*
* @throws SQLTimeoutException
* If the connection cannot be established within the connect
* timeout (either explicitly set or implied by the OS timeout
* of the socket)
* if the connection cannot be established within the connect timeout (either explicitly set or implied by
* the OS timeout of the socket)
* @throws SQLException
* If the connection cannot be established.
* if the connection cannot be established.
*/
public final void socketConnect() throws SQLException {
try {
socket = new Socket();
socket = createSocket();
socket.setTcpNoDelay(true);
final int connectTimeout = attachProperties.getConnectTimeout();
final int socketConnectTimeout;
if (connectTimeout != -1) {
// connectTimeout is in seconds, need milliseconds
socketConnectTimeout = (int) TimeUnit.SECONDS.toMillis(connectTimeout);
// connectTimeout is in seconds, need milliseconds, lower bound 0 (indefinite, for overflow or not set)
final int socketConnectTimeout = Math.max(0, (int) TimeUnit.SECONDS.toMillis(connectTimeout));
if (socketConnectTimeout != 0) {
// Blocking timeout initially identical to connect timeout
socket.setSoTimeout(socketConnectTimeout);
} else {
// socket connect timeout is not set, so indefinite (0)
socketConnectTimeout = 0;
// Blocking timeout to normal socket timeout, 0 if not set
socket.setSoTimeout(Math.max(attachProperties.getSoTimeout(), 0));
}
Expand All @@ -277,6 +276,70 @@ public final void socketConnect() throws SQLException {
}
}

private Socket createSocket() throws IOException, SQLException {
try {
return createSocketFactory().createSocket();
} catch (RuntimeException e) {
throw FbExceptionBuilder
.forNonTransientConnectionException(JaybirdErrorCodes.jb_socketFactoryFailedToCreateSocket)
.messageParameter(attachProperties.getSocketFactory())
.cause(e)
.toSQLException();
}
}

private SocketFactory createSocketFactory() throws SQLException {
String socketFactoryName = attachProperties.getSocketFactory();
if (socketFactoryName == null) {
return SocketFactory.getDefault();
}
return createSocketFactory0(socketFactoryName);
}

private SocketFactory createSocketFactory0(String socketFactoryName) throws SQLException {
log.log(DEBUG, "Attempting to create custom socket factory {0}", socketFactoryName);
try {
Class<? extends SocketFactory> socketFactoryClass =
Class.forName(socketFactoryName).asSubclass(SocketFactory.class);
try {
Constructor<? extends SocketFactory> propsConstructor =
socketFactoryClass.getConstructor(Properties.class);
return propsConstructor.newInstance(getSocketFactoryProperties());
} catch (ReflectiveOperationException e) {
log.log(DEBUG, socketFactoryName
+ "has no Properties constructor, or constructor execution resulted in an exception", e);
}
try {
Constructor<? extends SocketFactory> noArgConstructor = socketFactoryClass.getConstructor();
return noArgConstructor.newInstance();
} catch (ReflectiveOperationException e) {
log.log(DEBUG, socketFactoryName
+ "has no no-arg constructor, or constructor execution resulted in an exception", e);
}
throw FbExceptionBuilder
.forNonTransientConnectionException(JaybirdErrorCodes.jb_socketFactoryConstructorNotFound)
.messageParameter(socketFactoryName)
.toSQLException();
} catch (ClassNotFoundException | ClassCastException e) {
throw FbExceptionBuilder.forNonTransientConnectionException(JaybirdErrorCodes.jb_socketFactoryClassNotFound)
.messageParameter(socketFactoryName)
.cause(e)
.toSQLException();
}
}

private Properties getSocketFactoryProperties() {
var props = new Properties();
attachProperties.connectionPropertyValues().entrySet().stream()
.filter(e ->
e.getValue() != null && e.getKey().name().endsWith("@socketFactory"))
.forEach(e -> {
ConnectionProperty connectionProperty = e.getKey();
props.setProperty(connectionProperty.name(), connectionProperty.type().asString(e.getValue()));
});
return props;
}

public final XdrStreamAccess getXdrStreamAccess() {
return streamAccess;
}
Expand Down
30 changes: 30 additions & 0 deletions src/main/org/firebirdsql/jaybird/props/AttachmentProperties.java
Original file line number Diff line number Diff line change
Expand Up @@ -465,4 +465,34 @@ default void setParallelWorkers(int parallelWorkers) {
setIntProperty(PropertyNames.parallelWorkers, parallelWorkers);
}

/**
* The class name of a custom socket factory to be used for pure Java connections.
*
* @return fully-qualified class name of a {@link javax.net.SocketFactory} implementation, or (default) {@code null}
* for the default socket factory
* @since 6
* @see #setSocketFactory(String)
*/
default String getSocketFactory() {
return getProperty(PropertyNames.socketFactory);
}

/**
* Sets the class name of a custom socket factory to be used for pure Java connections.
* <p>
* The class must extend {@link javax.net.SocketFactory} and have a public single-arg constructor accepting
* a {@link java.util.Properties}, or a public no-arg constructor. The {@code Properties} object passed in the first
* case contains custom connection properties with the suffix {@code @socketFactory}, and &mdash; possibly &mdash;
* other selected properties.
* </p>
*
* @param socketFactory
* fully-qualified class name of a {@link javax.net.SocketFactory} implementation, or {@code null} for
* the default socket factory
* @since 6
*/
default void setSocketFactory(String socketFactory) {
setProperty(PropertyNames.socketFactory, socketFactory);
}

}
1 change: 1 addition & 0 deletions src/main/org/firebirdsql/jaybird/props/PropertyNames.java
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public final class PropertyNames {
public static final String wireCompression = "wireCompression";
public static final String enableProtocol = "enableProtocol";
public static final String parallelWorkers = "parallelWorkers";
public static final String socketFactory = "socketFactory";

// database connection
public static final String sqlDialect = "sqlDialect";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ public Stream<ConnectionProperty> defineProperties() {
builder(enableProtocol),
builder(parallelWorkers).type(INT).aliases("parallel_workers", "isc_dpb_parallel_workers")
.dpbItem(isc_dpb_parallel_workers),
builder(socketFactory),

// Database properties
builder(charSet).aliases("charset", "localEncoding", "local_encoding"),
Expand Down
3 changes: 3 additions & 0 deletions src/resources/org/firebirdsql/jaybird_error_msg.properties
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,6 @@
337248344=No authentication plugin available
337248345=Asynchronous channel already established
337248346=Asynchronous channel not connected
337248347=Could not find socket factory class {0}, or class does not extend javax.net.SocketFactory
337248348=No suitable socket factory constructor found in class {0}: a public constructor accepting java.util.Properties or a public no-arg constructor is required
337248349=Socket factory {0} failed to create a socket
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,6 @@
337248344=08000
337248345=08002
337248346=08006
337248347=08001
337248348=08001
337248349=08001
62 changes: 62 additions & 0 deletions src/test/org/firebirdsql/common/BaseSocketFactory.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Firebird Open Source JDBC Driver
*
* Distributable under LGPL license.
* You may obtain a copy of the License at http://www.gnu.org/copyleft/lgpl.html
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* LGPL License for more details.
*
* This file was created by members of the firebird development team.
* All individual contributions remain the Copyright (C) of those
* individuals. Contributors to this file are either listed here or
* can be obtained from a source control history command.
*
* All rights reserved.
*/
package org.firebirdsql.common;

import javax.net.SocketFactory;
import java.io.IOException;
import java.net.InetAddress;
import java.net.Socket;

/**
* Subclass of {@link SocketFactory} throwing {@link UnsupportedOperationException} for all {@code createSocket}
* methods.
* <p>
* Intended as a base for socket factories for testing purposes.
* </p>
*
* @author Mark Rotteveel
*/
public abstract class BaseSocketFactory extends SocketFactory {

@Override
public Socket createSocket() throws IOException {
throw new UnsupportedOperationException();
}

@Override
public Socket createSocket(String host, int port) {
throw new UnsupportedOperationException();
}

@Override
public Socket createSocket(String host, int port, InetAddress localHost, int localPort) {
throw new UnsupportedOperationException();
}

@Override
public Socket createSocket(InetAddress host, int port) {
throw new UnsupportedOperationException();
}

@Override
public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) {
throw new UnsupportedOperationException();
}

}
Loading

0 comments on commit 3e53124

Please sign in to comment.