Skip to content

Commit 8a27010

Browse files
committed
feat: add configuration options to support mtls
adding options to support mtls with the coder server. This supports adding PEM certs and keys to the tls requests, and also supports adding a CA cert to the trust store. Also allowing for an alternate hostname that may appear in the certs which is useful for testing or for non-standard cert usage.
1 parent 5e55049 commit 8a27010

File tree

5 files changed

+250
-0
lines changed

5 files changed

+250
-0
lines changed

src/main/kotlin/com/coder/gateway/CoderSettingsConfigurable.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,34 @@ class CoderSettingsConfigurable : BoundConfigurable("Coder") {
7373
CoderGatewayBundle.message("gateway.connector.settings.header-command.comment")
7474
)
7575
}.layout(RowLayout.PARENT_GRID)
76+
row(CoderGatewayBundle.message("gateway.connector.settings.tls-cert-path.title")) {
77+
textField().resizableColumn().align(AlignX.FILL)
78+
.bindText(state::tlsCertPath)
79+
.comment(
80+
CoderGatewayBundle.message("gateway.connector.settings.tls-cert-path.comment")
81+
)
82+
}.layout(RowLayout.PARENT_GRID)
83+
row(CoderGatewayBundle.message("gateway.connector.settings.tls-key-path.title")) {
84+
textField().resizableColumn().align(AlignX.FILL)
85+
.bindText(state::tlsKeyPath)
86+
.comment(
87+
CoderGatewayBundle.message("gateway.connector.settings.tls-key-path.comment")
88+
)
89+
}.layout(RowLayout.PARENT_GRID)
90+
row(CoderGatewayBundle.message("gateway.connector.settings.tls-ca-path.title")) {
91+
textField().resizableColumn().align(AlignX.FILL)
92+
.bindText(state::tlsCAPath)
93+
.comment(
94+
CoderGatewayBundle.message("gateway.connector.settings.tls-ca-path.comment")
95+
)
96+
}.layout(RowLayout.PARENT_GRID)
97+
row(CoderGatewayBundle.message("gateway.connector.settings.tls-alt-name.title")) {
98+
textField().resizableColumn().align(AlignX.FILL)
99+
.bindText(state::tlsAlternateHostname)
100+
.comment(
101+
CoderGatewayBundle.message("gateway.connector.settings.tls-alt-name.comment")
102+
)
103+
}.layout(RowLayout.PARENT_GRID)
76104
}
77105
}
78106

src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import java.nio.file.StandardCopyOption
2222
import java.security.DigestInputStream
2323
import java.security.MessageDigest
2424
import java.util.zip.GZIPInputStream
25+
import javax.net.ssl.HttpsURLConnection
2526
import javax.xml.bind.annotation.adapters.HexBinaryAdapter
2627

2728

@@ -104,6 +105,10 @@ class CoderCLIManager @JvmOverloads constructor(
104105
conn.setRequestProperty("If-None-Match", "\"$etag\"")
105106
}
106107
conn.setRequestProperty("Accept-Encoding", "gzip")
108+
if (conn is HttpsURLConnection) {
109+
conn.sslSocketFactory = coderSocketFactory()
110+
conn.hostnameVerifier = CoderHostnameVerifier()
111+
}
107112

108113
try {
109114
conn.connect()

src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,47 @@ import com.coder.gateway.sdk.v2.models.Workspace
1414
import com.coder.gateway.sdk.v2.models.WorkspaceBuild
1515
import com.coder.gateway.sdk.v2.models.WorkspaceTransition
1616
import com.coder.gateway.sdk.v2.models.toAgentModels
17+
import com.coder.gateway.services.CoderSettingsState
1718
import com.google.gson.Gson
1819
import com.google.gson.GsonBuilder
1920
import com.intellij.ide.plugins.PluginManagerCore
2021
import com.intellij.openapi.components.Service
22+
import com.intellij.openapi.components.service
2123
import com.intellij.openapi.extensions.PluginId
2224
import com.intellij.openapi.util.SystemInfo
2325
import okhttp3.OkHttpClient
26+
import okhttp3.internal.tls.OkHostnameVerifier
2427
import okhttp3.logging.HttpLoggingInterceptor
2528
import org.zeroturnaround.exec.ProcessExecutor
2629
import retrofit2.Retrofit
2730
import retrofit2.converter.gson.GsonConverterFactory
31+
import java.io.File
32+
import java.io.FileInputStream
2833
import java.net.HttpURLConnection.HTTP_CREATED
34+
import java.net.InetAddress
35+
import java.net.Socket
2936
import java.net.URL
37+
import java.security.KeyFactory
38+
import java.security.KeyStore
39+
import java.security.PrivateKey
40+
import java.security.cert.CertificateFactory
41+
import java.security.cert.X509Certificate
42+
import java.security.spec.InvalidKeySpecException
43+
import java.security.spec.PKCS8EncodedKeySpec
3044
import java.time.Instant
45+
import java.util.Base64
46+
import java.util.Locale
3147
import java.util.UUID
48+
import javax.net.ssl.HostnameVerifier
49+
import javax.net.ssl.KeyManagerFactory
50+
import javax.net.ssl.SNIHostName
51+
import javax.net.ssl.SSLContext
52+
import javax.net.ssl.SSLSession
53+
import javax.net.ssl.SSLSocket
54+
import javax.net.ssl.SSLSocketFactory
55+
import javax.net.ssl.TrustManagerFactory
56+
import javax.net.ssl.TrustManager
57+
import javax.net.ssl.X509TrustManager
3258

3359
@Service(Service.Level.APP)
3460
class CoderRestClientService {
@@ -66,7 +92,11 @@ class CoderRestClient(var url: URL, var token: String,
6692
pluginVersion = PluginManagerCore.getPlugin(PluginId.getId("com.coder.gateway"))!!.version // this is the id from the plugin.xml
6793
}
6894

95+
val socketFactory = coderSocketFactory()
96+
val trustManagers = coderTrustManagers()
6997
httpClient = OkHttpClient.Builder()
98+
.sslSocketFactory(socketFactory, trustManagers[0] as X509TrustManager)
99+
.hostnameVerifier(CoderHostnameVerifier())
70100
.addInterceptor { it.proceed(it.request().newBuilder().addHeader("Coder-Session-Token", token).build()) }
71101
.addInterceptor { it.proceed(it.request().newBuilder().addHeader("User-Agent", "Coder Gateway/${pluginVersion} (${SystemInfo.getOsNameAndVersion()}; ${SystemInfo.OS_ARCH})").build()) }
72102
.addInterceptor {
@@ -218,3 +248,168 @@ class CoderRestClient(var url: URL, var token: String,
218248
}
219249
}
220250
}
251+
252+
fun coderSocketFactory() : SSLSocketFactory {
253+
val state: CoderSettingsState = service()
254+
255+
if (state.tlsCertPath.isBlank() || state.tlsKeyPath.isBlank()) {
256+
return SSLSocketFactory.getDefault() as SSLSocketFactory
257+
}
258+
259+
val certificateFactory = CertificateFactory.getInstance("X.509")
260+
val certInputStream = FileInputStream(state.tlsCertPath)
261+
val certChain = certificateFactory.generateCertificates(certInputStream)
262+
certInputStream.close()
263+
264+
// ideally we would use something like PemReader from BouncyCastle, but
265+
// BC is used by the IDE. This makes using BC very impractical since
266+
// type casting will mismatch due to the different class loaders.
267+
val privateKeyPem = File(state.tlsKeyPath).readText()
268+
val start: Int = privateKeyPem.indexOf("-----BEGIN PRIVATE KEY-----")
269+
val end: Int = privateKeyPem.indexOf("-----END PRIVATE KEY-----", start)
270+
val pemBytes: ByteArray = Base64.getDecoder().decode(
271+
privateKeyPem.substring(start + "-----BEGIN PRIVATE KEY-----".length, end)
272+
.replace("\\s+".toRegex(), "")
273+
)
274+
275+
var privateKey : PrivateKey
276+
try {
277+
val kf = KeyFactory.getInstance("RSA")
278+
val keySpec = PKCS8EncodedKeySpec(pemBytes)
279+
privateKey = kf.generatePrivate(keySpec)
280+
} catch (e: InvalidKeySpecException) {
281+
val kf = KeyFactory.getInstance("EC")
282+
val keySpec = PKCS8EncodedKeySpec(pemBytes)
283+
privateKey = kf.generatePrivate(keySpec)
284+
}
285+
286+
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
287+
keyStore.load(null)
288+
certChain.withIndex().forEach {
289+
keyStore.setCertificateEntry("cert${it.index}", it.value as X509Certificate)
290+
}
291+
keyStore.setKeyEntry("key", privateKey, null, certChain.toTypedArray())
292+
293+
val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
294+
keyManagerFactory.init(keyStore, null)
295+
296+
val sslContext = SSLContext.getInstance("TLS")
297+
298+
val trustManagers = coderTrustManagers()
299+
sslContext.init(keyManagerFactory.keyManagers, trustManagers, null)
300+
301+
if (state.tlsAlternateHostname.isBlank()) {
302+
return sslContext.socketFactory
303+
}
304+
305+
return AlternateNameSSLSocketFactory(sslContext.socketFactory, state.tlsAlternateHostname)
306+
}
307+
308+
fun coderTrustManagers() : Array<TrustManager> {
309+
val state: CoderSettingsState = service()
310+
311+
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
312+
if (state.tlsCAPath.isBlank()) {
313+
// return default trust managers
314+
trustManagerFactory.init(null as KeyStore?)
315+
return trustManagerFactory.trustManagers
316+
}
317+
318+
319+
val certificateFactory = CertificateFactory.getInstance("X.509")
320+
val caInputStream = FileInputStream(state.tlsCAPath)
321+
val certChain = certificateFactory.generateCertificates(caInputStream)
322+
323+
val truststore = KeyStore.getInstance(KeyStore.getDefaultType())
324+
truststore.load(null)
325+
certChain.withIndex().forEach {
326+
truststore.setCertificateEntry("cert${it.index}", it.value as X509Certificate)
327+
}
328+
trustManagerFactory.init(truststore)
329+
return trustManagerFactory.trustManagers
330+
}
331+
332+
class AlternateNameSSLSocketFactory(private val delegate: SSLSocketFactory, private val alternateName: String) : SSLSocketFactory() {
333+
override fun getDefaultCipherSuites(): Array<String> {
334+
return delegate.defaultCipherSuites
335+
}
336+
337+
override fun getSupportedCipherSuites(): Array<String> {
338+
return delegate.supportedCipherSuites
339+
}
340+
341+
override fun createSocket(): Socket {
342+
val socket = delegate.createSocket() as SSLSocket
343+
customizeSocket(socket)
344+
return socket
345+
}
346+
347+
override fun createSocket(host: String?, port: Int): Socket {
348+
val socket = delegate.createSocket(host, port) as SSLSocket
349+
customizeSocket(socket)
350+
return socket
351+
}
352+
353+
override fun createSocket(host: String?, port: Int, localHost: InetAddress?, localPort: Int): Socket {
354+
val socket = delegate.createSocket(host, port, localHost, localPort) as SSLSocket
355+
customizeSocket(socket)
356+
return socket
357+
}
358+
359+
override fun createSocket(host: InetAddress?, port: Int): Socket {
360+
val socket = delegate.createSocket(host, port) as SSLSocket
361+
customizeSocket(socket)
362+
return socket
363+
}
364+
365+
override fun createSocket(address: InetAddress?, port: Int, localAddress: InetAddress?, localPort: Int): Socket {
366+
val socket = delegate.createSocket(address, port, localAddress, localPort) as SSLSocket
367+
customizeSocket(socket)
368+
return socket
369+
}
370+
371+
override fun createSocket(s: Socket?, host: String?, port: Int, autoClose: Boolean): Socket {
372+
val socket = delegate.createSocket(s, host, port, autoClose) as SSLSocket
373+
customizeSocket(socket)
374+
return socket
375+
}
376+
377+
private fun customizeSocket(socket: SSLSocket) {
378+
val params = socket.sslParameters
379+
params.serverNames = listOf(SNIHostName(alternateName))
380+
socket.sslParameters = params
381+
}
382+
}
383+
384+
class CoderHostnameVerifier() : HostnameVerifier {
385+
private val alternateName: String
386+
387+
init {
388+
val state: CoderSettingsState = service()
389+
this.alternateName = state.tlsAlternateHostname.lowercase(Locale.getDefault())
390+
}
391+
392+
override fun verify(host: String, session: SSLSession): Boolean {
393+
if (alternateName.isEmpty()) {
394+
return OkHostnameVerifier.verify(host, session)
395+
}
396+
val certs = session.peerCertificates ?: return false
397+
for (cert in certs) {
398+
if (cert !is X509Certificate) {
399+
continue
400+
}
401+
val entries = cert.subjectAlternativeNames ?: continue
402+
for (entry in entries) {
403+
val kind = entry[0] as Int
404+
if (kind != 2) { // DNS Name
405+
continue
406+
}
407+
val hostname = entry[1] as String
408+
if (hostname.lowercase(Locale.getDefault()) == alternateName) {
409+
return true
410+
}
411+
}
412+
}
413+
return false
414+
}
415+
}

src/main/kotlin/com/coder/gateway/services/CoderSettingsState.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ class CoderSettingsState : PersistentStateComponent<CoderSettingsState> {
1919
var enableDownloads: Boolean = true
2020
var enableBinaryDirectoryFallback: Boolean = false
2121
var headerCommand: String = ""
22+
var tlsCertPath: String = ""
23+
var tlsKeyPath: String = ""
24+
var tlsCAPath: String = ""
25+
var tlsAlternateHostname: String = ""
2226
override fun getState(): CoderSettingsState {
2327
return this
2428
}

src/main/resources/messages/CoderGatewayBundle.properties

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,21 @@ gateway.connector.settings.header-command.comment=An external command that \
9393
outputs additional HTTP headers added to all requests. The command must \
9494
output each header as `key=value` on its own line. The following \
9595
environment variables will be available to the process: CODER_URL.
96+
gateway.connector.settings.tls-cert-path.title=Cert Path:
97+
gateway.connector.settings.tls-cert-path.comment=Optionally set this to \
98+
the path of a certificate to use for TLS connections. The certificate \
99+
should be in X.509 PEM format.
100+
gateway.connector.settings.tls-key-path.title=Key Path:
101+
gateway.connector.settings.tls-key-path.comment=Optionally set this to \
102+
the path of the private key that corresponds to the above cert path to use \
103+
for TLS connections. The key should be in X.509 PEM format.
104+
gateway.connector.settings.tls-ca-path.title=CA Path:
105+
gateway.connector.settings.tls-ca-path.comment=Optionally set this to \
106+
the path of a file containing certificates for an alternate certificate \
107+
authority used to verify TLS certs returned by the Coder service. \
108+
The file should be in X.509 PEM format.
109+
gateway.connector.settings.tls-alt-name.title=Alt Hostname:
110+
gateway.connector.settings.tls-alt-name.comment=Optionally set this to \
111+
an alternate hostname used for verifying TLS connections. This is useful \
112+
when the hostname used to connect to the Coder service does not match the \
113+
hostname in the TLS certificate.

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy