Skip to content

Commit 819b4c1

Browse files
committed
pekko: support mTLS in PekkoHttpClient
Add a create overload that accepts an HttpsConnectionContext so the client presents a client certificate for mutual TLS. The context is threaded through singleRequest and used as the default for superPool, with a per-call ClientConfig.connectionContext still taking precedence. Existing callers are unaffected (default is None).
1 parent fb5b44e commit 819b4c1

2 files changed

Lines changed: 83 additions & 4 deletions

File tree

atlas-pekko/src/main/scala/com/netflix/atlas/pekko/PekkoHttpClient.scala

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,35 @@ object PekkoHttpClient {
8989
new HttpClientImpl(name)(system)
9090
}
9191

92+
/**
93+
* Create a new client instance that uses the provided connection context by default for its
94+
* requests. A per-call `ClientConfig.connectionContext` passed to `superPool` still takes
95+
* precedence over this default. This can be used to make mutual TLS (mTLS) calls. The context
96+
* is typically built from an `SSLContext` configured with the client certificate and trust
97+
* store, e.g.:
98+
*
99+
* {{{
100+
* val connectionContext = ConnectionContext.httpsClient(sslContext)
101+
* PekkoHttpClient.create("myapp", system, connectionContext)
102+
* }}}
103+
*
104+
* @param name
105+
* Name to use for access logging and metrics.
106+
* @param system
107+
* Actor system to use for processing the requests.
108+
* @param connectionContext
109+
* Connection context used to establish the client connections.
110+
* @return
111+
* New client instance.
112+
*/
113+
def create(
114+
name: String,
115+
system: ActorSystem,
116+
connectionContext: HttpsConnectionContext
117+
): PekkoHttpClient = {
118+
new HttpClientImpl(name, Some(connectionContext))(system)
119+
}
120+
92121
/**
93122
* Create a client instance that will return a fixed response for every request. Mainly
94123
* used for testing.
@@ -153,14 +182,18 @@ object PekkoHttpClient {
153182
private def isConnectException(t: Throwable): Boolean = t.isInstanceOf[ConnectException]
154183

155184
/** Default implementation based on Pekko `Http()`. */
156-
private[pekko] class HttpClientImpl(name: String)(implicit val system: ActorSystem)
185+
private[pekko] class HttpClientImpl(
186+
name: String,
187+
defaultConnectionContext: Option[HttpsConnectionContext] = None
188+
)(implicit val system: ActorSystem)
157189
extends PekkoHttpClient {
158190

159191
private implicit val ec: ExecutionContext = system.dispatcher
160192
private val http = Http()
161193

162194
protected def doSingleRequest(request: HttpRequest): Future[HttpResponse] = {
163-
http.singleRequest(request)
195+
val connectionContext = defaultConnectionContext.getOrElse(http.defaultClientHttpsContext)
196+
http.singleRequest(request, connectionContext)
164197
}
165198

166199
override def singleRequest(request: HttpRequest): Future[HttpResponse] = {
@@ -178,7 +211,9 @@ object PekkoHttpClient {
178211
override def superPool[C](
179212
config: ClientConfig
180213
): Flow[(HttpRequest, C), (Try[HttpResponse], C), NotUsed] = {
181-
val connectionContext = config.connectionContext.getOrElse(http.defaultClientHttpsContext)
214+
val connectionContext = config.connectionContext
215+
.orElse(defaultConnectionContext)
216+
.getOrElse(http.defaultClientHttpsContext)
182217
val settings = config.settings.getOrElse(ConnectionPoolSettings(system))
183218

184219
// All retries will be handled in this flow, disable in the pekko pool

atlas-pekko/src/test/scala/com/netflix/atlas/pekko/PekkoHttpClientSuite.scala

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import com.netflix.spectator.ipc.IpcStatus
2424
import munit.FunSuite
2525
import org.apache.pekko.NotUsed
2626
import org.apache.pekko.actor.ActorSystem
27+
import org.apache.pekko.http.scaladsl.ConnectionContext
2728
import org.apache.pekko.http.scaladsl.HttpsConnectionContext
2829
import org.apache.pekko.http.scaladsl.model.HttpMethods
2930
import org.apache.pekko.http.scaladsl.model.HttpRequest
@@ -38,6 +39,7 @@ import org.apache.pekko.stream.scaladsl.Source
3839
import java.util.concurrent.ArrayBlockingQueue
3940
import java.util.concurrent.TimeUnit
4041
import java.util.stream.Collectors
42+
import javax.net.ssl.SSLContext
4143
import scala.concurrent.Await
4244
import scala.concurrent.Future
4345
import scala.concurrent.duration.Duration
@@ -119,7 +121,14 @@ class PekkoHttpClientSuite extends FunSuite {
119121
.withMaxRetries(3)
120122
.withBaseConnectionBackoff(FiniteDuration(0, TimeUnit.MILLISECONDS))
121123
.withMaxConnectionBackoff(FiniteDuration(0, TimeUnit.MILLISECONDS))
122-
val config = ClientConfig(settings = Some(settings))
124+
flowRequest(client, request, ClientConfig(settings = Some(settings)))
125+
}
126+
127+
private def flowRequest(
128+
client: PekkoHttpClient,
129+
request: HttpRequest,
130+
config: ClientConfig
131+
): Try[HttpResponse] = {
123132
val future = Source
124133
.single(request)
125134
.map(r => r -> NotUsed)
@@ -165,6 +174,22 @@ class PekkoHttpClientSuite extends FunSuite {
165174
}
166175
}
167176

177+
test("superPool: uses default connection context from create") {
178+
val ctx = ConnectionContext.httpsClient(SSLContext.getDefault)
179+
val client = new ContextCapturingClient(system, Some(ctx))
180+
flowRequest(client, HttpRequest(HttpMethods.GET, Uri("/test")))
181+
assertEquals(client.lastContext, ctx)
182+
}
183+
184+
test("superPool: config connection context overrides default") {
185+
val defaultCtx = ConnectionContext.httpsClient(SSLContext.getDefault)
186+
val overrideCtx = ConnectionContext.httpsClient(SSLContext.getDefault)
187+
val client = new ContextCapturingClient(system, Some(defaultCtx))
188+
val config = ClientConfig(connectionContext = Some(overrideCtx))
189+
flowRequest(client, HttpRequest(HttpMethods.GET, Uri("/test")), config)
190+
assertEquals(client.lastContext, overrideCtx)
191+
}
192+
168193
test("superPool: ipc metrics on throttled") {
169194
val responses = List(
170195
Success(HttpResponse(StatusCodes.TooManyRequests)),
@@ -185,6 +210,25 @@ class PekkoHttpClientSuite extends FunSuite {
185210

186211
object PekkoHttpClientSuite {
187212

213+
/** Records the connection context passed to the super pool so it can be asserted on. */
214+
class ContextCapturingClient(
215+
system: ActorSystem,
216+
defaultContext: Option[HttpsConnectionContext]
217+
) extends PekkoHttpClient.HttpClientImpl("test", defaultContext)(system) {
218+
219+
@volatile var lastContext: HttpsConnectionContext = _
220+
221+
override protected def superPoolFlow[C](
222+
connectionContext: HttpsConnectionContext,
223+
settings: ConnectionPoolSettings
224+
): Flow[(HttpRequest, C), (Try[HttpResponse], C), NotUsed] = {
225+
lastContext = connectionContext
226+
Flow[(HttpRequest, C)].map {
227+
case (_, context) => Success(HttpResponse(StatusCodes.OK)) -> context
228+
}
229+
}
230+
}
231+
188232
class TestHttpClient(system: ActorSystem, responses: List[Try[HttpResponse]])
189233
extends PekkoHttpClient.HttpClientImpl("test")(system) {
190234

0 commit comments

Comments
 (0)