diff --git a/README.MD b/README.MD index 81c3120..2e601cc 100644 --- a/README.MD +++ b/README.MD @@ -27,22 +27,27 @@ releases. For the time being use your own instance or a public one._ This application requires [Maven](https://maven.apache.org) and at least Java 21 to run. Use the following environment variables to adjust the application to your needs: -| EnvVar | Description | Default | -|----------------------------------------|-----------------------------------------------------------------------------------------------|-----------------| -| AUTH_JWKS_URL | URL to retrieve a JWKS for verifying JWTs. | | -| HUB_AUTH_BASE_URL | Base URL to reach the Hub's core component. | | -| HUB_AUTH_ROBOT_ID | Robot ID associated with the node. | | -| HUB_AUTH_ROBOT_SECRET_FILE | Path to the file containing the secret of the node's associated robot account, as plain text. | | -| HUB_BASE_URL | Base URL to reach the Hub's auth component. | | -| HUB_MESSENGER_BASE_URL | Base URL to reach the Hub's messenger component. | | -| LOG_LEVEL | Log level being used. Can be either of `trace`, `debug`, `info`, `warn` or `error`. | `info` | -| MANAGEMENT_SERVER_PORT | Port being used by the management server (providing health check endpoints etc.) | `8090` | -| PERSISTENCE_DATABASE_NAME | Database name to use when connecting to a MongoDB instance. | `messagebroker` | -| PERSISTENCE_HOSTNAME | Hostname to use to connect to a MongoDB instance. | `localhost` | -| PERSISTENCE_PORT | Port to use to connect to a MongoDB instance. | `17017` | -| SECURITY_ADDITIONAL_TRUSTED_CERTS_FILE | Path to a certificate bundle containing additional certificates to be loaded during startup. | | -| SECURITY_NODE_PRIVATE_ECDH_KEY_FILE | Path to the file containing the node's private EC key in PEM format, as plain text. | | -| SERVER_PORT | Port being used by the Web server. | `8080` | +| EnvVar | Description | Default | +|----------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------| +| AUTH_JWKS_URL | URL to retrieve a JWKS for verifying JWTs. | | +| HUB_AUTH_BASE_URL | Base URL to reach the Hub's core component. | | +| HUB_AUTH_ROBOT_ID | Robot ID associated with the node. | | +| HUB_AUTH_ROBOT_SECRET_FILE | Path to the file containing the secret of the node's associated robot account, as plain text. | | +| HUB_BASE_URL | Base URL to reach the Hub's auth component. | | +| HUB_MESSENGER_BASE_URL | Base URL to reach the Hub's messenger component. | | +| LOG_LEVEL | Log level being used. Can be either of `trace`, `debug`, `info`, `warn` or `error`. | `info` | +| MANAGEMENT_SERVER_PORT | Port being used by the management server (providing health check endpoints etc.) | `8090` | +| PERSISTENCE_DATABASE_NAME | Database name to use when connecting to a MongoDB instance. | `messagebroker` | +| PERSISTENCE_HOSTNAME | Hostname to use to connect to a MongoDB instance. | `localhost` | +| PERSISTENCE_PORT | Port to use to connect to a MongoDB instance. | `17017` | +| PROXY_HOST | FQDN of the proxy to use. | | +| PROXY_PORT | Port of the proxy to use. | | +| PROXY_WHITELIST | A regex pattern (Java) to describe hosts that bypass the proxy to be reached directly. See [JavaDocs](https://docs.oracle.com/javase/8/docs/api/java/util/regex/Pattern.html) for more information about the pattern usage. | | +| PROXY_USERNAME | Username being used when authenticating against the proxy. | | +| PROXY_PASSWORD_FILE | Path to the file containing the password used when authenticating against the proxy. | | +| SECURITY_ADDITIONAL_TRUSTED_CERTS_FILE | Path to a certificate bundle containing additional certificates to be loaded during startup. | | +| SECURITY_NODE_PRIVATE_ECDH_KEY_FILE | Path to the file containing the node's private EC key in PEM format, as plain text. | | +| SERVER_PORT | Port being used by the Web server. | `8080` | ## Endpoint Documentation diff --git a/dev/config/proxy/nginx.conf b/dev/config/proxy/nginx.conf new file mode 100644 index 0000000..e66faae --- /dev/null +++ b/dev/config/proxy/nginx.conf @@ -0,0 +1,15 @@ +events { + worker_connections 4096; +} + +http { + server { + listen 8888; + + location / { + resolver 8.8.8.8; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://$http_host$uri$is_args$args; + } + } +} diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index e6389a1..f737ddd 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -11,8 +11,8 @@ services: keycloak: image: keycloak/keycloak:24.0.4@sha256:ff02c932f0249c58f32b8ff1b188a48cc90809779a3a05931ab67f5672400ad0 ports: - - 18080:8080 - - 10433:8433 + - "18080:8080" + - "10433:8433" command: - start-dev - --health-enabled @@ -23,3 +23,11 @@ services: KEYCLOAK_ADMIN_PASSWORD: admin volumes: - ./config/keycloak/private-aim-realm.json:/opt/keycloak/data/import/default-path-realm.json:ro + proxy: + image: nginx:1.29.0-alpine@sha256:d67ea0d64d518b1bb04acde3b00f722ac3e9764b3209a9b0a98924ba35e4b779 + ports: + - "8888:8888" + volumes: + - ./config/proxy/nginx.conf:/etc/nginx/nginx.conf:ro + command: [nginx-debug, '-g', 'daemon off;'] + diff --git a/src/main/java/de/privateaim/node_message_broker/common/CommonSpringConfig.java b/src/main/java/de/privateaim/node_message_broker/common/CommonSpringConfig.java index c194322..3684cf3 100644 --- a/src/main/java/de/privateaim/node_message_broker/common/CommonSpringConfig.java +++ b/src/main/java/de/privateaim/node_message_broker/common/CommonSpringConfig.java @@ -6,6 +6,8 @@ import de.privateaim.node_message_broker.common.hub.HttpHubClient; import de.privateaim.node_message_broker.common.hub.HubClient; import de.privateaim.node_message_broker.common.hub.auth.HubOIDCAuthenticator; +import io.netty.handler.ssl.SslContext; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -18,10 +20,15 @@ import org.springframework.web.reactive.function.client.ExchangeFilterFunction; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.util.DefaultUriBuilderFactory; +import reactor.netty.http.client.HttpClient; +import reactor.netty.transport.ProxyProvider; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; import java.util.List; +@Slf4j @Configuration public class CommonSpringConfig { @@ -40,6 +47,21 @@ public class CommonSpringConfig { @Value("${app.hub.auth.robotSecretFile}") private String hubAuthRobotSecretFile; + @Value("${app.proxy.host}") + private String proxyHost; + + @Value("${app.proxy.port}") + private Integer proxyPort; + + @Value("${app.proxy.whitelist}") + private String proxyWhitelist; + + @Value("${app.proxy.username}") + private String proxyUsername; + + @Value("${app.proxy.passwordFile}") + private String proxyPasswordFile; + @Qualifier("HUB_AUTH_ROBOT_SECRET") @Bean public String hubAuthRobotSecret() throws IOException { @@ -58,11 +80,70 @@ HttpRetryConfig exchangeRetryConfig() { return new HttpRetryConfig(EXCHANGE__MAX_RETRIES, EXCHANGE__MAX_RETRY_DELAY_MS); } + @Qualifier("CORE_HTTP_CLIENT") + @Bean + HttpClient decoratedHttpClient(@Qualifier("COMMON_NETTY_SSL_CONTEXT") SslContext sslContext) { + var client = HttpClient.create(); + client = decorateClientWithSSLContext(client, sslContext); + client = decorateClientWithProxySettings(client); + + return client; + } + + private HttpClient decorateClientWithSSLContext(HttpClient client, SslContext sslContext) { + return client.secure(t -> t.sslContext(sslContext)); + } + + private HttpClient decorateClientWithProxySettings(HttpClient client) { + if (!proxyHost.isBlank() && proxyPort != null) { + log.info("configuring usage of proxy at `{}:{}` with the following hosts whitelisted (via regex): `{}`", + proxyHost, proxyPort, proxyWhitelist); + return client.proxy(proxy -> { + var proxyBuilder = proxy.type(ProxyProvider.Proxy.HTTP) + .host(proxyHost) + .port(proxyPort); + + if (!proxyWhitelist.isBlank()) { + log.info("configuring whitelist for proxy at `{}:{}`", proxyHost, proxyPort); + proxyBuilder.nonProxyHosts(proxyWhitelist); + } else { + log.info("skipping whitelist configuration for proxy at `{}:{}` since no whitelist " + + "is configured", proxyHost, proxyPort); + } + + if (!proxyUsername.isBlank() && !proxyPasswordFile.isBlank()) { + try { + log.info("configuring authentication for proxy"); + var proxyPassword = Files.readString(Paths.get(proxyPasswordFile)); + proxyBuilder.username(proxyUsername) + .password((_username) -> proxyPassword); + } catch (IOException e) { + log.error("cannot read password file for proxy at `{}`", proxyPasswordFile, e); + throw new RuntimeException(e); + } + } else { + log.info("skipping authentication configuration for proxy at `{}:{}` since no " + + "credentials are configured", proxyHost, proxyPort); + } + } + ); + } else { + log.info("skipping proxy configuration due to no specified settings"); + return client; + } + } + + @Qualifier("CORE_HTTP_CONNECTOR") + @Bean + ReactorClientHttpConnector httpConnector(@Qualifier("CORE_HTTP_CLIENT") HttpClient httpClient) { + return new ReactorClientHttpConnector(httpClient); + } + @Qualifier("HUB_CORE_WEB_CLIENT") @Bean public WebClient alwaysReAuthenticatedWebClient( @Qualifier("HUB_AUTHENTICATION_MIDDLEWARE") ExchangeFilterFunction authenticationMiddleware, - @Qualifier("BASE_SSL_HTTP_CLIENT_CONNECTOR") ReactorClientHttpConnector baseSslHttpClientConnector) { + @Qualifier("CORE_HTTP_CONNECTOR") ReactorClientHttpConnector httpConnector) { // We can't use Spring's default security mechanisms out-of-the-box here since HUB uses a non-standard grant // type which is not supported. There's a way by using a custom grant type accompanied by a client manager. // However, this endeavour is not pursued for the sake of simplicity. @@ -76,7 +157,7 @@ public WebClient alwaysReAuthenticatedWebClient( .uriBuilderFactory(factory) .defaultHeaders(httpHeaders -> httpHeaders.setAccept(List.of(MediaType.APPLICATION_JSON))) .filter(authenticationMiddleware) - .clientConnector(baseSslHttpClientConnector) + .clientConnector(httpConnector) .build(); } @@ -91,11 +172,11 @@ public HubClient hubClient( @Qualifier("HUB_AUTH_WEB_CLIENT") @Bean WebClient hubAuthWebClient( - @Qualifier("BASE_SSL_HTTP_CLIENT_CONNECTOR") ReactorClientHttpConnector baseSslHttpClientConnector) { + @Qualifier("CORE_HTTP_CONNECTOR") ReactorClientHttpConnector httpConnector) { return WebClient.builder() .baseUrl(hubAuthBaseUrl) .defaultHeaders(httpHeaders -> httpHeaders.setAccept(List.of(MediaType.APPLICATION_JSON))) - .clientConnector(baseSslHttpClientConnector) + .clientConnector(httpConnector) .build(); } diff --git a/src/main/java/de/privateaim/node_message_broker/common/CommonSslSpringConfig.java b/src/main/java/de/privateaim/node_message_broker/common/CommonSslSpringConfig.java index 974fb5f..f1b3ba8 100644 --- a/src/main/java/de/privateaim/node_message_broker/common/CommonSslSpringConfig.java +++ b/src/main/java/de/privateaim/node_message_broker/common/CommonSslSpringConfig.java @@ -7,8 +7,6 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; -import reactor.netty.http.client.HttpClient; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManagerFactory; @@ -45,15 +43,6 @@ public class CommonSslSpringConfig { @Value("${app.security.additionalTrustedCertsFile}") private String additionalTrustedCertsFile; - @Qualifier("BASE_SSL_HTTP_CLIENT_CONNECTOR") - @Bean - ReactorClientHttpConnector baseHttpClientConnector(@Qualifier("COMMON_NETTY_SSL_CONTEXT") SslContext sslContext) { - return new ReactorClientHttpConnector( - HttpClient - .create() - .secure(t -> t.sslContext(sslContext))); - } - @Qualifier("COMMON_NETTY_SSL_CONTEXT") @Bean SslContext sslContextNetty(@Qualifier("COMMON_TRUST_MANAGER_FACTORY") TrustManagerFactory tmf) throws IOException { diff --git a/src/main/java/de/privateaim/node_message_broker/message/MessageSpringConfig.java b/src/main/java/de/privateaim/node_message_broker/message/MessageSpringConfig.java index 9e7dc4a..6a62250 100644 --- a/src/main/java/de/privateaim/node_message_broker/message/MessageSpringConfig.java +++ b/src/main/java/de/privateaim/node_message_broker/message/MessageSpringConfig.java @@ -18,6 +18,7 @@ import io.socket.client.Manager; import io.socket.client.Socket; import lombok.extern.slf4j.Slf4j; +import okhttp3.Credentials; import okhttp3.OkHttpClient; import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; import org.bouncycastle.openssl.PEMKeyPair; @@ -38,8 +39,12 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStreamReader; +import java.net.InetSocketAddress; +import java.net.Proxy; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; import java.security.SecureRandom; import java.security.interfaces.ECPrivateKey; import java.util.Base64; @@ -47,6 +52,7 @@ import java.util.List; import java.util.concurrent.TimeUnit; import java.util.function.Function; +import java.util.regex.Pattern; @Slf4j @Configuration @@ -60,19 +66,80 @@ class MessageSpringConfig { @Value("${app.hub.auth.robotId}") private String selfRobotId; + @Value("${app.proxy.host}") + private String proxyHost; + + @Value("${app.proxy.port}") + private Integer proxyPort; + + @Value("${app.proxy.whitelist}") + private String proxyWhitelist; + + @Value("${app.proxy.username}") + private String proxyUsername; + + @Value("${app.proxy.passwordFile}") + private String proxyPasswordFile; + private static final String SOCKET_RECEIVE_HUB_MESSAGE_IDENTIFIER = "send"; @Qualifier("HUB_MESSENGER_UNDERLYING_SOCKET_SECURE_CLIENT") @Bean - OkHttpClient socketBaseClient(@Qualifier("COMMON_JAVA_SSL_CONTEXT") SSLContext sslCtx, - @Qualifier("COMMON_TRUST_MANAGER_FACTORY") TrustManagerFactory tmf) { - return new OkHttpClient.Builder() - .sslSocketFactory(sslCtx.getSocketFactory(), (X509TrustManager) tmf.getTrustManagers()[0]) - .readTimeout(1, TimeUnit.MINUTES) - .build(); + OkHttpClient decoratedSocketBaseClient(@Qualifier("COMMON_JAVA_SSL_CONTEXT") SSLContext sslCtx, + @Qualifier("COMMON_TRUST_MANAGER_FACTORY") TrustManagerFactory tmf) { + + var clientBuilder = new OkHttpClient.Builder() + .readTimeout(1, TimeUnit.MINUTES); + decorateClientWithSSLContext(clientBuilder, sslCtx, tmf); + decorateClientWithProxySettings(clientBuilder); + + return clientBuilder.build(); + } + + + // Additional SSL configuration that's orthogonal to the one used in the core HTTP client. + // The reason for that is that socket.io decided to go with a specific HTTP client instead of using an interface. + // Hence, we're bound to using that client which also comes with a specific way of configuring it. + private void decorateClientWithSSLContext(OkHttpClient.Builder clientBuilder, SSLContext sslCtx, + TrustManagerFactory tmf) { + clientBuilder.sslSocketFactory(sslCtx.getSocketFactory(), (X509TrustManager) tmf.getTrustManagers()[0]); } + // Additional proxy configuration that's orthogonal to the one used in the core HTTP client. + // The reason for that is that socket.io decided to go with a specific HTTP client instead of using an interface. + // Hence, we're bound to using that client which also comes with a specific way of configuring it. + private void decorateClientWithProxySettings(OkHttpClient.Builder clientBuilder) { + var proxyWhitelistPattern = Pattern.compile(proxyWhitelist); + if (proxyWhitelistPattern.matcher(proxyHost).matches()) { + log.warn("skipping proxy configuration for message socket due to the host `{}` matching the proxy " + + "whitelist with pattern `{}`", proxyHost, proxyWhitelistPattern); + return; + } + + log.info("configuring usage of proxy for message socket at `{}:{}`", proxyHost, proxyPort); + var proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)); + clientBuilder.proxy(proxy); + + if (!proxyUsername.isBlank() && !proxyPasswordFile.isBlank()) { + try { + var proxyPassword = Files.readString(Paths.get(proxyPasswordFile)); + + log.info("configuring authentication for proxy of message socket"); + clientBuilder.proxyAuthenticator((route, response) -> { + var proxyCredentials = Credentials.basic(proxyUsername, proxyPassword); + return response.request().newBuilder() + .header("Proxy-Authorization", proxyCredentials) + .build(); + }); + } catch (IOException e) { + log.error("cannot read password file for proxy at `{}`", proxyPasswordFile, e); + throw new RuntimeException(e); + } + } + } + + @Qualifier("HUB_MESSENGER_UNDERLYING_SOCKET") @Bean(destroyMethod = "disconnect") public Socket underlyingMessengerSocket( @@ -235,10 +302,10 @@ public MessageService messageService( @Qualifier("HUB_MESSAGE_RECEIVE_FORWARD_WEB_CLIENT") @Bean public WebClient messageForwardWebClient( - @Qualifier("BASE_SSL_HTTP_CLIENT_CONNECTOR") ReactorClientHttpConnector baseSslHttpClientConnector) { + @Qualifier("CORE_HTTP_CONNECTOR") ReactorClientHttpConnector httpConnector) { return WebClient.builder() .defaultHeaders(httpHeaders -> httpHeaders.setAccept(List.of(MediaType.APPLICATION_JSON))) - .clientConnector(baseSslHttpClientConnector) + .clientConnector(httpConnector) .build(); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a1236b9..74cce4d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -36,6 +36,12 @@ logging: org.springframework.web: ${LOG_LEVEL:info} app: + proxy: + host: ${PROXY_HOST:} + port: ${PROXY_PORT:} + whitelist: ${PROXY_WHITELIST:} + username: ${PROXY_USERNAME:} + passwordFile: ${PROXY_PASSWORD_FILE:} auth: jwksUrl: ${AUTH_JWKS_URL} hub: