Add support for https and wss (#7937)

* Add support for https and wss

Preliminary support for https and wss. During language server startup we
will read the application config and search for the `https` config with
necessary env vars set.

The configuration supports two modes of creating ssl-context - via
PKCS12 format and certificat+private key.

Fixes #7839.

* Added tests, improved documentation

Generic improvements along with actual tests.

* lint

* more docs + wss support

* changelog

* Apply suggestions from code review

Co-authored-by: Dmitry Bushev <bushevdv@gmail.com>

* PR comment

* typo

* lint

* make windows line endings happy

---------

Co-authored-by: Dmitry Bushev <bushevdv@gmail.com>
This commit is contained in:
Hubert Plociniczak 2023-10-12 00:03:34 +02:00 committed by GitHub
parent bf76be6e6b
commit cfba3c6887
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 844 additions and 105 deletions

View File

@ -978,6 +978,7 @@
- [New `project/status` route for reporting LS state][7801]
- [Add Enso-specific assertions][7883]
- [Modules can be `private`][7840]
- [HTTPS and WSS support in Language Server][7937]
- [Export of non-existing symbols results in error][7960]
[3227]: https://github.com/enso-org/enso/pull/3227
@ -1126,6 +1127,7 @@
[7861]: https://github.com/enso-org/enso/pull/7861
[7883]: https://github.com/enso-org/enso/pull/7883
[7840]: https://github.com/enso-org/enso/pull/7840
[7937]: https://github.com/enso-org/enso/pull/7937
[7960]: https://github.com/enso-org/enso/pull/7960
# Enso 2.0.0-alpha.18 (2021-10-12)

View File

@ -484,6 +484,7 @@ val junitIfVersion = "0.13.2"
val hamcrestVersion = "1.3"
val netbeansApiVersion = "RELEASE180"
val fansiVersion = "0.4.0"
val httpComponentsVersion = "4.4.1"
// ============================================================================
// === Internal Libraries =====================================================
@ -951,10 +952,15 @@ lazy val `json-rpc-server` = project
libraryDependencies ++= akka ++ logbackTest,
libraryDependencies ++= circe,
libraryDependencies ++= Seq(
"io.circe" %% "circe-literal" % circeVersion,
"com.typesafe.scala-logging" %% "scala-logging" % scalaLoggingVersion,
"io.circe" %% "circe-literal" % circeVersion,
"com.typesafe.scala-logging" %% "scala-logging" % scalaLoggingVersion,
akkaTestkit % Test,
"org.scalatest" %% "scalatest" % scalatestVersion % Test
"org.scalatest" %% "scalatest" % scalatestVersion % Test,
"junit" % "junit" % junitVersion % Test,
"com.github.sbt" % "junit-interface" % junitIfVersion % Test,
"org.apache.httpcomponents" % "httpclient" % httpComponentsVersion % Test,
"org.apache.httpcomponents" % "httpcore" % httpComponentsVersion % Test,
"commons-io" % "commons-io" % commonsIoVersion % Test
)
)

View File

@ -751,7 +751,7 @@ content root to be provided (`--root-id` and `--path` options). Command-line
interface of the runner prints all server options when you execute it with
`--help` option.
Below are options uses by the Language Server:
Below are options used by the Language Server:
- `--server`: Runs the Language Server
- `--root-id <uuid>`: Content root id. The Language Server chooses one randomly,
@ -763,6 +763,10 @@ Below are options uses by the Language Server:
value is 8080.
- `--data-port <port>`: Data port for visualization protocol. Default value
is 8081.
- `--secure-rpc-port <port>`: (optional) Secure RPC port for processing all
incoming connections.
- `--secure-data-port <port>`: (optional) Secure data port for visualization
protocol.
To run the Language Server on 127.0.0.1:8080 type:

View File

@ -168,3 +168,21 @@ Reset the idle time of the language server.
<
OK
```
# HTTPS endpoints
Language server can expose HTTPS endpoints when configured appropriately:
1. Project-manager must be told that project's language server should be started
with https/wss support
- `NETWORK_ENABLE_HTTPS=true`
2. User should provide appropriate secure configuration. Currently supported are
PKCS12 bundle with password and certificate with private key. Depending on
the configuration present, either choice will be sufficient.
If a project-manager is started with `ENSO_HTTPS_PUBLIC_CERTIFICATE` and
`ENSO_HTTPS_PRIVATE_KEY` env variables, SSL context will be created from a
certificate and a private key, respectively. If a project-manager is started
with `ENSO_HTTPS_PKCS12_PATH` and `ENSO_HTTPS_PKCS12_PASSWORD` env variables,
SSL context will be created from a file in PKCS12 format and a password to it,
respectively.

View File

@ -677,6 +677,10 @@ process for spawning and connecting to an engine instance is as follows:
data connection, passing its client identifier as it does so. See
[`session/initDataConnection`](./protocol-language-server.md#sessioninitdataconnection)
below more information.
5. **Secure connections:** The language server can expose secure endpoints
(HTTPS and WSS), when configured appropriately. See
[HTTPS endpoints](./language-server-http-endpoints.md#https-endpoints) for
details.
## Service Connection Teardown

View File

@ -242,11 +242,21 @@ the action.
*/
languageServerJsonAddress: IPWithSocket;
/**
* The optional endpoint used for secure JSON-RPC protocol.
*/
languageServerSecureJsonAddress?: IPWithSocket;
/**
* The endpoint used for binary protocol.
*/
languageServerBinaryAddress: IPWithSocket;
/**
* The optional endpoint used for secure binary protocol.
*/
languageServerSecureBinaryAddress?: IPWithSocket;
// The name of the project as it is opened.
projectName: String;

View File

@ -9,6 +9,18 @@ akka {
websocket.periodic-keep-alive-max-idle = 1 second
}
}
https {
pkcs12-file = ${?ENSO_HTTPS_PKCS12_PATH}
pkcs12-password = ${?ENSO_HTTPS_PKCS12_PASSWORD}
public-certificate = ${?ENSO_HTTPS_PUBLIC_CERTIFICATE}
public-certificate-algorithm = "X.509"
public-certificate-algorithm = ${?ENSO_HTTPS_PUBLIC_CERTIFICATE_ALGORITHM}
private-key = ${?ENSO_HTTPS_PRIVATE_KEY}
ssl-type = "TLS"
ssl-type = ${?ENSO_HTTPS_SSL}
trust-self-signed = true
trust-self-sgined = ${?ENSO_HTTPS_TRUST_SELF_SIGNED}
}
log-dead-letters = 1
log-dead-letters-during-shutdown = off
}

View File

@ -45,22 +45,57 @@ class LanguageServerComponent(config: LanguageServerConfig, logLevel: Level)
binding <- module.jsonRpcServer.bind(config.interface, config.rpcPort)
_ <- Future { logger.debug("Json RPC server initialized.") }
} yield binding
val bindSecureJsonServer: Future[Option[Http.ServerBinding]] = {
config.secureRpcPort match {
case Some(port) =>
module.jsonRpcServer
.bind(config.interface, port, secure = true)
.map(Some(_))
case None =>
Future.successful(None)
}
}
val bindBinaryServer =
for {
binding <- module.binaryServer.bind(config.interface, config.dataPort)
_ <- Future { logger.debug("Binary server initialized.") }
} yield binding
val bindSecureBinaryServer: Future[Option[Http.ServerBinding]] = {
config.secureDataPort match {
case Some(port) =>
module.binaryServer
.bind(config.interface, port, secure = true)
.map(Some(_))
case None =>
Future.successful(None)
}
}
for {
jsonBinding <- bindJsonServer
binaryBinding <- bindBinaryServer
jsonBinding <- bindJsonServer
secureJsonBinding <- bindSecureJsonServer
binaryBinding <- bindBinaryServer
secureBinaryBinding <- bindSecureBinaryServer
_ <- Future {
maybeServerCtx =
Some(ServerContext(sampler, module, jsonBinding, binaryBinding))
maybeServerCtx = Some(
ServerContext(
sampler,
module,
jsonBinding,
secureJsonBinding,
binaryBinding,
secureBinaryBinding
)
)
}
_ <- Future {
logger.info(
s"Started server at json:${config.interface}:${config.rpcPort}, " +
s"binary:${config.interface}:${config.dataPort}"
s"Started server at json:${config.interface}${config.rpcPort}, ${config.secureRpcPort
.map(p => s"secure-jsons:${config.interface}$p")
.getOrElse("")}, " +
s"binary:${config.interface}:${config.dataPort}${config.secureDataPort
.map(p => s", secure-binary:${config.interface}$p")
.getOrElse("")}"
)
}
} yield ComponentStarted
@ -156,13 +191,17 @@ object LanguageServerComponent {
* @param sampler a sampler gathering the application performance statistics
* @param mainModule a main module containing all components of the server
* @param jsonBinding a http binding for rpc protocol
* @param secureJsonBinding an optional https binding for rpc protocol
* @param binaryBinding a http binding for data protocol
* @param secureBinaryBinding an optional https binding for data protocol
*/
case class ServerContext(
sampler: MethodsSampler,
mainModule: MainModule,
jsonBinding: Http.ServerBinding,
binaryBinding: Http.ServerBinding
secureJsonBinding: Option[Http.ServerBinding],
binaryBinding: Http.ServerBinding,
secureBinaryBinding: Option[Http.ServerBinding]
)
}

View File

@ -8,7 +8,9 @@ import scala.concurrent.{ExecutionContext, ExecutionContextExecutor}
*
* @param interface a interface that the server listen to
* @param rpcPort a rpc port that the server listen to
* @param secureRpcPort an optional secure rpc port that the server listen to
* @param dataPort a data port that the server listen to
* @param secureDataPort an optional secure data port that the server listen to
* @param contentRootUuid an id of content root
* @param contentRootPath a path to the content root
* @param profilingConfig an application profiling configuration
@ -17,7 +19,9 @@ import scala.concurrent.{ExecutionContext, ExecutionContextExecutor}
case class LanguageServerConfig(
interface: String,
rpcPort: Int,
secureRpcPort: Option[Int],
dataPort: Int,
secureDataPort: Option[Int],
contentRootUuid: UUID,
contentRootPath: String,
profilingConfig: ProfilingConfig,

View File

@ -2,6 +2,7 @@ package org.enso.languageserver.boot
import akka.actor.ActorSystem
import buildinfo.Info
import com.typesafe.config.ConfigFactory
import org.enso.distribution.locking.{
ResourceManager,
ThreadSafeFileLockManager
@ -10,7 +11,7 @@ import org.enso.distribution.{DistributionManager, Environment, LanguageHome}
import org.enso.editions.EditionResolver
import org.enso.editions.updater.EditionManager
import org.enso.filewatcher.WatcherAdapterFactory
import org.enso.jsonrpc.JsonRpcServer
import org.enso.jsonrpc.{JsonRpcServer, SecureConnectionConfig}
import org.enso.languageserver.capability.CapabilityRouter
import org.enso.languageserver.data._
import org.enso.languageserver.effect
@ -455,12 +456,23 @@ class MainModule(serverConfig: LanguageServerConfig, logLevel: Level) {
jsonRpcControllerFactory
)
val secureConfig = SecureConnectionConfig
.fromApplicationConfig(applicationConfig())
.fold(
v => v.flatMap(msg => { log.warn(s"invalid secure config: $msg"); None }),
Some(_)
)
val jsonRpcServer =
new JsonRpcServer(
jsonRpcProtocolFactory,
jsonRpcControllerFactory,
JsonRpcServer
.Config(outgoingBufferSize = 10000, lazyMessageTimeout = 10.seconds),
.Config(
outgoingBufferSize = 10000,
lazyMessageTimeout = 10.seconds,
secureConfig = secureConfig
),
List(healthCheckEndpoint, idlenessEndpoint)
)
log.trace("Created JSON RPC Server [{}].", jsonRpcServer)
@ -472,7 +484,8 @@ class MainModule(serverConfig: LanguageServerConfig, logLevel: Level) {
new BinaryConnectionControllerFactory(fileManager),
BinaryWebSocketServer.Config(
outgoingBufferSize = 100,
lazyMessageTimeout = 10.seconds
lazyMessageTimeout = 10.seconds,
secureConfig = secureConfig
)
)
log.trace("Created Binary WebSocket Server [{}].", binaryServer)
@ -488,4 +501,13 @@ class MainModule(serverConfig: LanguageServerConfig, logLevel: Level) {
context.close()
log.info("Closed Language Server main module.")
}
private def applicationConfig(): com.typesafe.config.Config = {
val empty = ConfigFactory.empty().atPath("akka.https")
ConfigFactory
.load()
.withFallback(empty)
.getConfig("akka")
.getConfig("https")
}
}

View File

@ -2,7 +2,6 @@ package org.enso.languageserver.http.server
import akka.NotUsed
import akka.actor.{ActorRef, ActorSystem}
import akka.http.scaladsl.Http
import akka.http.scaladsl.model.RemoteAddress
import akka.http.scaladsl.model.StatusCodes.InternalServerError
import akka.http.scaladsl.model.ws.{BinaryMessage, Message, TextMessage}
@ -12,6 +11,7 @@ import akka.stream.scaladsl.{Flow, Sink, Source}
import akka.stream.{CompletionStrategy, Materializer, OverflowStrategy}
import akka.util.ByteString
import com.typesafe.scalalogging.LazyLogging
import org.enso.jsonrpc.{SecureConnectionConfig, Server}
import org.enso.languageserver.http.server.BinaryWebSocketControlProtocol.{
CloseConnection,
ConnectionClosed,
@ -26,7 +26,7 @@ import org.enso.languageserver.util.binary.{
}
import scala.concurrent.duration._
import scala.concurrent.{ExecutionContext, Future}
import scala.concurrent.ExecutionContext
/** A web socket server using a binary protocol.
*
@ -49,7 +49,8 @@ class BinaryWebSocketServer[A, B](
)(
implicit val system: ActorSystem,
implicit val materializer: Materializer
) extends LazyLogging {
) extends Server
with LazyLogging {
implicit val ec: ExecutionContext = system.dispatcher
@ -67,18 +68,6 @@ class BinaryWebSocketServer[A, B](
}
}
/** Binds this server instance to a given port and interface, allowing
* future connections.
*
* @param interface the interface to bind to.
* @param port the port to bind to.
* @return a representation of the binding state of the server.
*/
def bind(interface: String, port: Int): Future[Http.ServerBinding] =
Http()
.newServerAt(interface, port)
.bind(route)
private def newConnection(
ip: RemoteAddress.IP
): Flow[Message, Message, NotUsed] = {
@ -148,6 +137,10 @@ class BinaryWebSocketServer[A, B](
}
}
override protected def serverRoute(port: Int): Route = route
override protected def secureConfig(): Option[SecureConnectionConfig] =
config.secureConfig
}
object BinaryWebSocketServer {
@ -163,6 +156,7 @@ object BinaryWebSocketServer {
case class Config(
outgoingBufferSize: Int,
lazyMessageTimeout: FiniteDuration,
secureConfig: Option[SecureConnectionConfig],
path: String = ""
)
@ -173,6 +167,10 @@ object BinaryWebSocketServer {
* @return a default config.
*/
def default: Config =
Config(outgoingBufferSize = 10, lazyMessageTimeout = 10.seconds)
Config(
outgoingBufferSize = 10,
lazyMessageTimeout = 10.seconds,
secureConfig = None
)
}
}

View File

@ -206,6 +206,13 @@ object LauncherApplication {
"RPC port for processing all incoming connections. Defaults to 8080."
)
.withDefault(8080)
val secureRpcPort =
Opts
.optionalParameter[Int](
"secure-rpc-port",
"SECURE_RPC_PORT",
"Secure RPC port for processing all incoming connections."
)
val dataPort =
Opts
.optionalParameter[Int](
@ -214,13 +221,22 @@ object LauncherApplication {
"Data port for visualization protocol. Defaults to 8081."
)
.withDefault(8081)
val secureDataPort =
Opts
.optionalParameter[Int](
"secure-data-port",
"SECURE_DATA_PORT",
"Secure data port for visualization protocol."
)
val additionalArgs = Opts.additionalArguments()
(
rootId,
path,
interface,
rpcPort,
secureRpcPort,
dataPort,
secureDataPort,
versionOverride,
engineLogLevel,
systemJVMOverride,
@ -232,7 +248,9 @@ object LauncherApplication {
path,
interface,
rpcPort,
secureRpcPort,
dataPort,
secureDataPort,
versionOverride,
engineLogLevel,
systemJVMOverride,
@ -241,10 +259,12 @@ object LauncherApplication {
) => (config: Config) =>
Launcher(config).runLanguageServer(
options = LanguageServerOptions(
rootId = rootId,
interface = interface,
rpcPort = rpcPort,
dataPort = dataPort
rootId = rootId,
interface = interface,
rpcPort = rpcPort,
secureRpcPort = secureRpcPort,
dataPort = dataPort,
secureDataPort = secureDataPort
),
contentRoot = path,
versionOverride = versionOverride,

View File

@ -286,10 +286,12 @@ class LauncherRunnerSpec extends RuntimeVersionManagerTest with FlakySpec {
newProject("test", projectPath, version)
val options = LanguageServerOptions(
rootId = UUID.randomUUID(),
interface = "127.0.0.2",
rpcPort = 1234,
dataPort = 4321
rootId = UUID.randomUUID(),
interface = "127.0.0.2",
rpcPort = 1234,
secureRpcPort = None,
dataPort = 4321,
secureDataPort = None
)
val runSettings = runner
.languageServer(

View File

@ -1,5 +1,6 @@
package org.enso.runner
import com.typesafe.scalalogging.Logger
import org.enso.languageserver.boot.{
LanguageServerComponent,
LanguageServerConfig
@ -7,7 +8,6 @@ import org.enso.languageserver.boot.{
import org.slf4j.event.Level
import java.util.concurrent.Semaphore
import scala.concurrent.{Await, ExecutionContext, Future}
import scala.concurrent.duration._
import scala.io.StdIn
@ -16,7 +16,8 @@ import scala.io.StdIn
*/
object LanguageServerApp {
private val semaphore = new Semaphore(1)
private val semaphore = new Semaphore(1)
private lazy val logger = Logger[LanguageServerApp.type]
/** Runs a Language Server
*
@ -31,7 +32,7 @@ object LanguageServerApp {
): Unit = {
val server = new LanguageServerComponent(config, logLevel)
Runtime.getRuntime.addShutdownHook(new Thread(() => {
stop(server)(config.computeExecutionContext)
stop(server, "shutdown hook")(config.computeExecutionContext)
}))
Await.result(server.start(), 1.minute)
if (deamonize) {
@ -41,7 +42,7 @@ object LanguageServerApp {
}
} else {
StdIn.readLine()
stop(server)(config.computeExecutionContext)
stop(server, "stopped by the user")(config.computeExecutionContext)
}
}
@ -51,8 +52,10 @@ object LanguageServerApp {
* @param ec the execution context
*/
private def stop(
server: LanguageServerComponent
server: LanguageServerComponent,
reason: String
)(implicit ec: ExecutionContext): Unit = {
logger.info("Stopping Language Server: {}", reason)
Await.ready(synchronize(server.stop()), 40.seconds)
}

View File

@ -57,6 +57,8 @@ object Main {
private val INTERFACE_OPTION = "interface"
private val RPC_PORT_OPTION = "rpc-port"
private val DATA_PORT_OPTION = "data-port"
private val SECURE_RPC_PORT_OPTION = "secure-rpc-port"
private val SECURE_DATA_PORT_OPTION = "secure-data-port"
private val ROOT_ID_OPTION = "root-id"
private val ROOT_PATH_OPTION = "path"
private val IN_PROJECT_OPTION = "in-project"
@ -220,6 +222,13 @@ object Main {
.argName("rpc-port")
.desc("RPC port for processing all incoming connections")
.build()
val secureRpcPortOption = CliOption.builder
.longOpt(SECURE_RPC_PORT_OPTION)
.hasArg(true)
.numberOfArgs(1)
.argName("rpc-port")
.desc("A secure RPC port for processing all incoming connections")
.build()
val dataPortOption = CliOption.builder
.longOpt(DATA_PORT_OPTION)
.hasArg(true)
@ -227,6 +236,13 @@ object Main {
.argName("data-port")
.desc("Data port for visualization protocol")
.build()
val secureDataPortOption = CliOption.builder
.longOpt(SECURE_DATA_PORT_OPTION)
.hasArg(true)
.numberOfArgs(1)
.argName("data-port")
.desc("A secure data port for visualization protocol")
.build()
val uuidOption = CliOption.builder
.hasArg(true)
.numberOfArgs(1)
@ -412,6 +428,8 @@ object Main {
.addOption(interfaceOption)
.addOption(rpcPortOption)
.addOption(dataPortOption)
.addOption(secureRpcPortOption)
.addOption(secureDataPortOption)
.addOption(uuidOption)
.addOption(pathOption)
.addOption(inProjectOption)
@ -958,6 +976,18 @@ object Main {
dataPort <- Either
.catchNonFatal(dataPortStr.toInt)
.leftMap(_ => "Port must be integer")
secureRpcPortStr = Option(line.getOptionValue(SECURE_RPC_PORT_OPTION))
.map(Some(_))
.getOrElse(None)
secureRpcPort <- Either
.catchNonFatal(secureRpcPortStr.map(_.toInt))
.leftMap(_ => "Port must be integer")
secureDataPortStr = Option(line.getOptionValue(SECURE_DATA_PORT_OPTION))
.map(Some(_))
.getOrElse(None)
secureDataPort <- Either
.catchNonFatal(secureDataPortStr.map(_.toInt))
.leftMap(_ => "Port must be integer")
profilingPathStr =
Option(line.getOptionValue(LANGUAGE_SERVER_PROFILING_PATH))
profilingPath <- Either
@ -979,7 +1009,9 @@ object Main {
} yield boot.LanguageServerConfig(
interface,
rpcPort,
secureRpcPort,
dataPort,
secureDataPort,
rootId,
rootPath,
ProfilingConfig(profilingEventsLogPath, profilingPath, profilingTime),

View File

@ -2,7 +2,6 @@ package org.enso.jsonrpc
import akka.NotUsed
import akka.actor.{ActorRef, ActorSystem, Props}
import akka.http.scaladsl.Http
import akka.http.scaladsl.model.ws.{BinaryMessage, Message, TextMessage}
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
@ -11,9 +10,8 @@ import akka.stream.{Materializer, OverflowStrategy}
import com.typesafe.scalalogging.LazyLogging
import java.util.UUID
import scala.concurrent.duration._
import scala.concurrent.{ExecutionContext, Future}
import scala.concurrent.ExecutionContext
/** Exposes a multi-client JSON RPC Server instance over WebSocket connections.
*
@ -32,7 +30,8 @@ class JsonRpcServer(
)(
implicit val system: ActorSystem,
implicit val materializer: Materializer
) extends LazyLogging {
) extends Server
with LazyLogging {
implicit val ec: ExecutionContext = system.dispatcher
@ -92,7 +91,7 @@ class JsonRpcServer(
Flow.fromSinkAndSource(incomingMessages, outgoingMessages)
}
private def route(port: Int): Route = {
override protected def serverRoute(port: Int): Route = {
val webSocketEndpoint =
path(config.path) {
get { handleWebSocketMessages(newUser(port)) }
@ -103,17 +102,8 @@ class JsonRpcServer(
}
}
/** Binds this server instance to a given port and interface, allowing
* future connections.
*
* @param interface the interface to bind to.
* @param port the port to bind to.
* @return a server binding object.
*/
def bind(interface: String, port: Int): Future[Http.ServerBinding] =
Http()
.newServerAt(interface, port)
.bind(route(port))
override protected def secureConfig(): Option[SecureConnectionConfig] =
config.secureConfig
}
object JsonRpcServer {
@ -129,6 +119,7 @@ object JsonRpcServer {
case class Config(
outgoingBufferSize: Int,
lazyMessageTimeout: FiniteDuration,
secureConfig: Option[SecureConnectionConfig],
path: String = ""
)
@ -139,7 +130,11 @@ object JsonRpcServer {
* @return a default config.
*/
def default: Config =
Config(outgoingBufferSize = 1000, lazyMessageTimeout = 10.seconds)
Config(
outgoingBufferSize = 1000,
lazyMessageTimeout = 10.seconds,
secureConfig = None
)
}
case class WebConnect(webActor: ActorRef, port: Int)

View File

@ -0,0 +1,209 @@
package org.enso.jsonrpc
import com.typesafe.config.Config
import java.io.{ByteArrayInputStream, File, FileInputStream, InputStream}
import java.security.cert.{CertificateFactory, X509Certificate}
import java.security.spec.PKCS8EncodedKeySpec
import java.security.{KeyFactory, KeyStore, SecureRandom}
import java.util.Base64
import javax.net.ssl.{
KeyManagerFactory,
SSLContext,
TrustManager,
X509TrustManager
}
import scala.util.Try
/** Base class for generating custom {@link SSLContext} from configs.
*
* @param trustSelfSignedCerts true, if the SLLContext should trust all self-signed certificates
*/
abstract class SecureConnectionConfig(trustSelfSignedCerts: Boolean) {
def generateSSLContext(): Try[SSLContext]
protected def trustManagers: Array[TrustManager] = {
if (trustSelfSignedCerts) {
Array[TrustManager](new X509TrustManager {
override def checkClientTrusted(
chain: Array[X509Certificate],
authType: String
): Unit = {}
override def checkServerTrusted(
chain: Array[X509Certificate],
authType: String
): Unit = {}
override def getAcceptedIssuers: Array[X509Certificate] = new Array(0)
})
} else {
null
}
}
}
object SecureConnectionConfig {
/** Infers secure configuration from application's config.
* If the config has a `pkcs12-file` key, the configuration will be for the PKCS12-formatted certificate.
* If the config has a `public-certificate` key, the configuration will be inferred for the public/public certificate/key.
* If none of the above, returns a failure since no secure configuration is present in the application's config.
*
* @param config application.conf config
* @return left value with a failure or right value with a validated secure configuration
*/
def fromApplicationConfig(
config: Config
): Either[Option[String], SecureConnectionConfig] = {
if (config.hasPath("pkcs12-file")) {
(for {
pkcs12 <- getStringFieldOpt(config, "pkcs12-file")
pkcs12File = new File(pkcs12)
password <- getStringFieldOpt(config, "pkcs12-password")
trustSelfSigned <- getBooleanFieldOpt(config, "trust-self-signed")
} yield SecureConnectionConfigForPKCS12(pkcs12File, password)(
trustSelfSigned
)).left.map(Some(_))
} else if (config.hasPath("public-certificate")) {
(for {
publicKeyCertificate <- getStringFieldOpt(config, "public-certificate")
publicKeyCertificateAlg <- getStringFieldOpt(
config,
"public-certificate-algorithm"
)
privateKey <- getStringFieldOpt(config, "private-key")
trustSelfSigned <- getBooleanFieldOpt(config, "trust-self-signed")
} yield SecureConnectionConfigForPublicPrivateCert(
publicKeyCertificate,
publicKeyCertificateAlg,
privateKey
)(trustSelfSigned)).left.map(Some(_))
} else Left(None)
}
private def getStringFieldOpt(
config: Config,
fieldName: String
): Either[String, String] = {
if (config.hasPath(fieldName)) {
val v = config.getString(fieldName)
if (v == null || v.isEmpty) Left(s"field $fieldName is empty")
else Right(v)
} else {
Left(s"missing $fieldName")
}
}
private def getBooleanFieldOpt(
config: Config,
fieldName: String
): Either[String, Boolean] = {
if (config.hasPath(fieldName)) {
Right(config.getBoolean(fieldName))
} else {
Left(s"missing $fieldName")
}
}
/** Configuration for SSLContext from PKCS12 format with a corresponding private password.
* Generation of the SSLContext will create a local Java keystore on-the-fly, based on the provided PKCS12 bundle.
*
* @param pkcsInputStream input stream to PKCS12-formatted object
* @param password password to a certificate
* @param trustSelfSignedCertificates true, if all self-signed certificates should be trusted
*/
case class SecureConnectionConfigForPKCS12(
pkcsInputStream: InputStream,
password: String
)(trustSelfSignedCertificates: Boolean)
extends SecureConnectionConfig(trustSelfSignedCertificates) {
private val keystoreType = "PKCS12"
private val keyManagerFactoryAlgorithm = "SunX509"
private val sslType = "TLS"
def generateSSLContext(): Try[SSLContext] = Try {
val keyStore = KeyStore.getInstance(keystoreType)
val passwordChars = password.toCharArray
keyStore.load(pkcsInputStream, passwordChars)
val kmf = KeyManagerFactory.getInstance(keyManagerFactoryAlgorithm)
kmf.init(keyStore, passwordChars)
val keyManagers = kmf.getKeyManagers
val sslContext = SSLContext.getInstance(sslType)
sslContext.init(keyManagers, trustManagers, new SecureRandom())
sslContext
}
}
object SecureConnectionConfigForPKCS12 {
def apply(
pkcsFile: File,
password: String
)(trustSelfSignedCertificates: Boolean): SecureConnectionConfig =
SecureConnectionConfigForPKCS12(
new FileInputStream(pkcsFile),
password
)(trustSelfSignedCertificates)
}
/** Configuration for SSLContext from certificate and a corresponding private key.
* Generation of the SSLContext will create a local Java keystore on-the-fly, based on the provided values.
*
* @param publicCertificate contents of the certificate
* @param publicCertificateAlgorithm algorithm used in the certificate
* @param privateKey private key
* @param trustSelfSignedCertificates true, if all self-signed certificates should be trusted
*/
case class SecureConnectionConfigForPublicPrivateCert(
publicCertificate: String,
publicCertificateAlgorithm: String,
privateKey: String
)(trustSelfSignedCertificates: Boolean)
extends SecureConnectionConfig(trustSelfSignedCertificates) {
private val beginPrivateHeader = "-----BEGIN PRIVATE KEY-----"
private val endPrivateSuffix = "-----END PRIVATE KEY-----"
private val targetKeyStore = "PKCS12"
private val publicPrivateAlg = "RSA"
private val sslType = "TLS"
private val keyManagerFactoryAlgorithm = "SunX509"
override def generateSSLContext(): Try[SSLContext] = Try {
val factory = CertificateFactory.getInstance(publicCertificateAlgorithm)
val certificateStream =
new ByteArrayInputStream(publicCertificate.getBytes())
val cert = factory.generateCertificate(certificateStream)
val prefixIdx = privateKey.indexOf(beginPrivateHeader)
val privateKeyWithDroppedAttributes =
if (prefixIdx == -1) privateKey else privateKey.substring(prefixIdx)
val privateKeyPEM = privateKeyWithDroppedAttributes
.replaceAll("\\R", "")
.replace(beginPrivateHeader, "")
.replace(endPrivateSuffix, "")
val privateKeyDER = Base64.getDecoder().decode(privateKeyPEM);
val spec = new PKCS8EncodedKeySpec(privateKeyDER);
val keyFactory = KeyFactory.getInstance(publicPrivateAlg);
val storePrivateKey = keyFactory.generatePrivate(spec);
val keyStore = KeyStore.getInstance(targetKeyStore)
keyStore.load(null)
val password = "temp-keystore".toCharArray
keyStore.setKeyEntry("enso", storePrivateKey, password, Array(cert))
val kmf =
KeyManagerFactory.getInstance(keyManagerFactoryAlgorithm)
kmf.init(keyStore, password)
val keyManagers = kmf.getKeyManagers
val sslContext = SSLContext.getInstance(sslType)
sslContext.init(keyManagers, trustManagers, new SecureRandom())
sslContext
}
}
}

View File

@ -0,0 +1,55 @@
package org.enso.jsonrpc
import akka.actor.ActorSystem
import akka.http.scaladsl.{ConnectionContext, Http}
import akka.http.scaladsl.server.Route
import scala.concurrent.Future
abstract class Server(implicit private val system: ActorSystem) {
/** Binds this server instance to a given port and interface, allowing
* future connections.
*
* @param interface the interface to bind to
* @param port the port to bind to
* @param secure true if the port should refer to a secure binding
* @return a server binding object
*/
def bind(
interface: String,
port: Int,
secure: Boolean = false
): Future[Http.ServerBinding] = {
val httpServer = Http()
.newServerAt(interface, port)
if (secure) {
val httpsContext = secureConfig().flatMap(config => {
config
.generateSSLContext()
.map(ctx => ConnectionContext.httpsServer(ctx))
.toOption
})
httpsContext match {
case Some(ctx) =>
httpServer.enableHttps(ctx).bind(serverRoute(port))
case None =>
Future.failed(new RuntimeException("HTTPS misconfigured"))
}
} else {
httpServer.bind(serverRoute(port))
}
}
/** Returns handlers for http requests supported by the server.
*
* @param port port number where the server will be listening to handle requests
* @return mapping between requests and responses supported by this server
*/
protected def serverRoute(port: Int): Route
/** Returns an optional configuration for supporting secure connections. */
protected def secureConfig(): Option[SecureConnectionConfig]
}

View File

@ -0,0 +1,100 @@
package org.enso.jsonrpc;
import static org.junit.Assert.*;
import java.io.IOException;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import javax.net.ssl.SSLContext;
import org.apache.commons.io.IOUtils;
import org.apache.http.HttpStatus;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.impl.bootstrap.HttpServer;
import org.apache.http.impl.bootstrap.ServerBootstrap;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.BasicHttpClientConnectionManager;
import org.enso.jsonrpc.SecureConnectionConfig.SecureConnectionConfigForPublicPrivateCert;
import org.junit.After;
import org.junit.Test;
public class SSLContextBuilderTest {
// See https://lightbend.github.io/ssl-config/CertificateGeneration.html or
// https://docs.oracle.com/javase/8/docs/technotes/guides/security/jsse/JSSERefGuide.html#CreateKeystore
// on how to generate test public/private certificates or pkcs12 files.
HttpServer httpServer = null;
@After
public void stop() {
if (httpServer != null) {
httpServer.shutdown(0, TimeUnit.SECONDS);
httpServer = null;
}
}
private void testSSLContext(int port, SSLContext ctx) throws IOException {
var sslsf = new SSLConnectionSocketFactory(ctx, NoopHostnameVerifier.INSTANCE);
var socketFactoryRegistry =
RegistryBuilder.<ConnectionSocketFactory>create()
.register("https", sslsf)
.register("http", new PlainConnectionSocketFactory())
.build();
var connectionManager = new BasicHttpClientConnectionManager(socketFactoryRegistry);
var httpClient =
HttpClients.custom()
.setSSLSocketFactory(sslsf)
.setConnectionManager(connectionManager)
.build();
httpServer =
ServerBootstrap.bootstrap()
.setLocalAddress(InetAddress.getByName("localhost"))
.setListenerPort(port)
.setSslContext(ctx)
.setSslSetupHandler(socket -> socket.setNeedClientAuth(true))
.registerHandler(
"/*", (request, response, context) -> response.setStatusCode(HttpStatus.SC_OK))
.create();
httpServer.start();
var request = new HttpGet("https://localhost:" + port);
var response = httpClient.execute(request);
assertEquals(response.getStatusLine().getStatusCode(), 200);
}
@Test
public void testCreatingSSLContextFromPKCS12() throws IOException {
var pcksFile = this.getClass().getResourceAsStream("/example.com.p12");
assertNotNull(pcksFile);
var password = "E4FtHvrLA4";
var secureConnection =
new SecureConnectionConfig.SecureConnectionConfigForPKCS12(pcksFile, password, true);
var sslContext = secureConnection.generateSSLContext();
assertTrue(sslContext.isSuccess());
var ctx = sslContext.get();
testSSLContext(8444, ctx);
}
@Test
public void testCreatingSSLContextFromCertificate() throws IOException {
var certFile = this.getClass().getResourceAsStream("/example.com.crt");
var privateKey = this.getClass().getResourceAsStream("/example.com.key");
assertNotNull(certFile);
assertNotNull(privateKey);
var secureConnection =
new SecureConnectionConfigForPublicPrivateCert(
IOUtils.toString(certFile, StandardCharsets.UTF_8),
"X.509",
IOUtils.toString(privateKey, StandardCharsets.UTF_8),
true);
var sslContext = secureConnection.generateSSLContext();
assertTrue(sslContext.isSuccess());
var ctx = sslContext.get();
testSSLContext(8443, ctx);
}
}

View File

@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIFATCCAumgAwIBAgIJAM8h7U1r8N5xMA0GCSqGSIb3DQEBDAUAMH4xCzAJBgNV
BAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1TYW4gRnJhbmNp
c2NvMRgwFgYDVQQKEw9FeGFtcGxlIENvbXBhbnkxFDASBgNVBAsTC0V4YW1wbGUg
T3JnMRIwEAYDVQQDEwlleGFtcGxlQ0EwHhcNMjMwOTI3MTA1NTQ2WhcNMjMxMjI2
MTA1NTQ2WjCBgDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAU
BgNVBAcTDVNhbiBGcmFuY2lzY28xGDAWBgNVBAoTD0V4YW1wbGUgQ29tcGFueTEU
MBIGA1UECxMLRXhhbXBsZSBPcmcxFDASBgNVBAMTC2V4YW1wbGUuY29tMIIBIjAN
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApcZxK40JHLuZfPMlOIHqEkYIZj13
UyeiDs8TLqd+FUVWAWWqkwcAqr2gcKZL584lWVHYMgaFD6eywoyCRKLmWE0qEjn+
lD6A+UuDn4o3+6l5hJdF9OObPDGINrPLs444qNRVaGsyxtH5zwmJMM8tky5oCyva
n+YUfpvB3LB4FCflHcgc0cHcEz4syhzxTv+0iKJGH76GfLje+Y/iyI3NH0INxYG2
009B0VDbyjOTTYtebj66FjxntpFVyu3ZIBnx/ygNlBmuSIQNwmM9hDUxB8CaI/fq
HQGrnJYe156iK42IQIHLTYc3vMGfvp2zxDJ8t/vDrqeD/EAZgIXte5GDPQIDAQAB
o38wfTAdBgNVHQ4EFgQUxgi/ASjTwUmVrTXPTfxzDBN9d58wDgYDVR0PAQH/BAQD
AgWgMBYGA1UdEQQPMA2CC2V4YW1wbGUuY29tMB8GA1UdIwQYMBaAFMc9QhmtfnnL
LOtK7N/o4qFMTNDWMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA0GCSqGSIb3DQEBDAUA
A4ICAQAV//cGfHhnyh+AFYHrM8V/utHjs05b+l0yFl8l68xffVxnhPRVB1grd2zA
6AjKP5QxB2WtZjTBudCfk8j6nFvsgWtYed0r2Bx/VRG3j5acNkSgrUiwrdKNCxGq
QeHOckk3nWc/vFIuZCEjvO63XwZG3zCSmg8Ut6yZYFDAcftrzUHA7Eo5vVRS3irI
elSgpvoXmDBNe7lbd699aBhWcudT0fj7laD99A5S/f1W00WappP4KD1ifwBICDoe
aboQZNCcw46FWxKLHZ1nyyO8pp/UfKTvJMu2zn+E6Yn97QPJ7CnwtLTU1Lm90CHh
dSC0nB7zDiPNe8yvmX1zIAYO/UGx+8siLWqlm25kH8ufAmCcRIyytn04dGUv37Vp
sLRWhpebsx6xXTgyd/NYqSvXCjf7Gr3p74mP5w7XM9zpRUM825lA6gdR2cCm0QJ0
7Aq2YbgI8pkdh9dM5vlX0CmjoPEBsQ3G9rcLW+LgalMD44qzFYmTHWuHKfas06AU
2dOAAMAWsPqELDBTP5O+Oafusf5VOrpLCYTk54xzfuQ+USpqSAFPaBhhkSa+6i3g
aPmOdmtCVM27qipflHOkJ1KHFH0Hj/QRV4jPXHc+ov75R23v/MhckdDjADL+J20S
yVLwXE5iO0Yyfqpg/qd31P2PgqZboLPCfvy7mPuCN0jMClsuOw==
-----END CERTIFICATE-----

View File

@ -0,0 +1,32 @@
Bag Attributes
friendlyName: example.com
localKeyID: 54 69 6D 65 20 31 36 39 35 38 31 32 34 30 30 35 31 30
Key Attributes: <No Attributes>
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQClxnErjQkcu5l8
8yU4geoSRghmPXdTJ6IOzxMup34VRVYBZaqTBwCqvaBwpkvnziVZUdgyBoUPp7LC
jIJEouZYTSoSOf6UPoD5S4Ofijf7qXmEl0X045s8MYg2s8uzjjio1FVoazLG0fnP
CYkwzy2TLmgLK9qf5hR+m8HcsHgUJ+UdyBzRwdwTPizKHPFO/7SIokYfvoZ8uN75
j+LIjc0fQg3FgbbTT0HRUNvKM5NNi15uProWPGe2kVXK7dkgGfH/KA2UGa5IhA3C
Yz2ENTEHwJoj9+odAauclh7XnqIrjYhAgctNhze8wZ++nbPEMny3+8Oup4P8QBmA
he17kYM9AgMBAAECggEABmRbcsFya4A0T+QUOFSSEPhQVJjkI/mwPv/vDmp46xsM
UOt5o0eu1+aN7CoNXTfOqt4EBxHHSa4+r0+5qinZ1efLyn4f+dlbIcGuppUuyW5k
eB9ZHDM2hiCmzu0p2peOSbw+OtN/Vrk796a3eoFSkY1Fh8C+IlI90g/xbrADQUuk
n+7+YYZyauhJnqKp/PvZza30gRQ2+Q+0KmDJluX9G4Y8xzRw5w9zFZA3Fsq20ND4
mPPnGWfqmbcXAx9M750ZWcyqksArI+DR9yjCzD0uhbmcs5s0Jacxz2LJTpoYdnSA
CP/7IMmii+F2DUQj3b7ikoIqu5wYnSZDALTZpeCDhQKBgQDNC0VEGs12HH9akMqJ
CxZv3hxObsQ6HQaTJ9UgQa4iLHJsd2i21/x4tRGDr53V2VNgdzpazn8kBxyN3Dpv
swILed4L0ieFPzPpb3XUIcvpDYfiH2zLHbkJ/j1ctzF9hwtrtFtJZiIaDvULuAz5
VZdUYmkbOuju31swrLBdQDMm4wKBgQDO+Otcw9aE04VTzU0TggcszjUbWPisFHV9
WXv0GOXyuO4xnwTURKIX7M5SbhS3Umd2bOLvnCAjZ/cwdWfu2s3ZdmU4a6jTbZwj
HD/69gQZypgbEqJySP/ytKzFog6eQVVizZenNTjPuPEoD6nWtT3SlvEM+F8HQn3D
wLPK6minXwKBgGSQpY1Mk/7c5T13DE8AqCV/y2RQgV97QvFDtQ0YCZ7rK8e1HR+o
eUR+kjODG6d56qHCeFV3N/ZkooWVQPft1Q/p2pTzoryAjiZsq166oLcSEtY90W8h
idKz5kal9tj5NgnCMI+kTw92zIrN31ceupUBm0lmsD4QQDp0SB/EUBI/AoGBAMzV
lTmtp7S1EhKp8EoKOceiHPT/zLuhU9XGpeIichER7Mq3MjRR6In9FwSFZYM4zqRp
hv7UaQohboZK6518dpVtkyePhPoaVJh68OcSa2SLGJZSjurETGqLXSILDKSazEKI
bjpRdfQ+eIzJU7DmllTAhbfsZz/sEkOVh1qfOwvJAoGAWixb3IUGnzm+vt/MluNj
0GaNEsknkkuaLmpX93G8nsEySp89epLKsq/tclRgmCnYrkjt5jkDvO7H167uJ4Dn
gpFjfAT4EwhzDX2j7edLBZ/PiS67WDL3MajBLAd3JDHJFmbi449wJreQ1O9wPl84
OHUQNH6HX4pYG8wGvgOTz/0=
-----END PRIVATE KEY-----

View File

@ -91,6 +91,9 @@ project-manager {
max-port = 65535
max-port = ${?NETWORK_MAX_PORT}
enable-secure = false
enable-secure = ${?NETWORK_ENABLE_HTTPS}
}
server {

View File

@ -83,8 +83,14 @@ object configuration {
* @param interface an interface to listen to
* @param minPort min port for the LS
* @param maxPort max port for the LS
* @param enableSecure true, if secure connections should be enabled, false otherwise
*/
case class NetworkConfig(interface: String, minPort: Int, maxPort: Int)
case class NetworkConfig(
interface: String,
minPort: Int,
maxPort: Int,
enableSecure: Boolean
)
/** A configuration object for bootloader properties.
*

View File

@ -3,6 +3,13 @@ package org.enso.projectmanager.data
/** Sockets that a language server listens on.
*
* @param jsonSocket a socket used for JSON-RPC protocol
* @param secureJsonSocket a secure socket used for JSON-RPC protocol
* @param binarySocket a socket used for the binary protocol
* @param secureBinarySocket a secure socket used for the binary protocol
*/
case class LanguageServerSockets(jsonSocket: Socket, binarySocket: Socket)
case class LanguageServerSockets(
jsonSocket: Socket,
secureJsonSocket: Option[Socket],
binarySocket: Socket,
secureBinarySocket: Option[Socket]
)

View File

@ -2,11 +2,14 @@ package org.enso.projectmanager.infrastructure.http
import akka.NotUsed
import akka.actor.{ActorRef, ActorSystem, Props}
import akka.http.scaladsl.Http
import akka.http.scaladsl.{ConnectionContext, Http}
import akka.http.scaladsl.model.ws._
import akka.pattern.pipe
import akka.stream.scaladsl.{Flow, Sink, Source}
import akka.stream.{CompletionStrategy, OverflowStrategy}
import com.typesafe.scalalogging.Logger
import org.enso.jsonrpc.SecureConnectionConfig
import org.enso.projectmanager.infrastructure.http
import org.enso.projectmanager.infrastructure.http.AkkaBasedWebSocketConnection._
import org.enso.projectmanager.infrastructure.http.FanOutReceiver.{
Attach,
@ -24,10 +27,15 @@ import org.enso.projectmanager.infrastructure.http.WebSocketConnection.{
* @param address a server address
* @param system an actor system
*/
class AkkaBasedWebSocketConnection(address: String)(implicit
class AkkaBasedWebSocketConnection(
address: String,
secureConfig: Option[SecureConnectionConfig]
)(implicit
system: ActorSystem
) extends WebSocketConnection {
private lazy val logger = Logger[http.AkkaBasedWebSocketConnection.type]
import system.dispatcher
private val receiver = system.actorOf(Props(new FanOutReceiver))
@ -76,8 +84,25 @@ class AkkaBasedWebSocketConnection(address: String)(implicit
/** @inheritdoc */
def connect(): Unit = {
val server = Http()
secureConfig
.flatMap { config =>
{
val ctx = config
.generateSSLContext()
.map(sslContext => ConnectionContext.httpsClient(sslContext))
if (ctx.isFailure) {
logger.warn(
"failed to establish requested secure context: {}",
ctx.failed.get.getMessage
)
}
ctx.toOption
}
}
.foreach(ctx => server.setDefaultClientHttpsContext(ctx))
val (future, _) =
Http()
server
.singleWebSocketRequest(
WebSocketRequest(address),
flow

View File

@ -1,5 +1,6 @@
package org.enso.projectmanager.infrastructure.http
import akka.actor.ActorSystem
import org.enso.jsonrpc.SecureConnectionConfig
import org.enso.projectmanager.data.Socket
/** A factory of Akka-based web socket connections.
@ -9,6 +10,19 @@ class AkkaBasedWebSocketConnectionFactory(implicit system: ActorSystem)
/** @inheritdoc */
override def createConnection(socket: Socket): WebSocketConnection =
new AkkaBasedWebSocketConnection(s"ws://${socket.host}:${socket.port}")
new AkkaBasedWebSocketConnection(
s"ws://${socket.host}:${socket.port}",
None
)
/** @inheritdoc */
override def createSecureConnection(
socket: Socket,
secureConfig: SecureConnectionConfig
): WebSocketConnection = {
new AkkaBasedWebSocketConnection(
s"wss://${socket.host}:${socket.port}",
Some(secureConfig)
)
}
}

View File

@ -1,5 +1,6 @@
package org.enso.projectmanager.infrastructure.http
import org.enso.jsonrpc.SecureConnectionConfig
import org.enso.projectmanager.data.Socket
/** Abstract connection factory.
@ -13,4 +14,14 @@ trait WebSocketConnectionFactory {
*/
def createConnection(socket: Socket): WebSocketConnection
/** Creates a secure web socket connection.
*
* @param socket a server address
* @return a secure connection
*/
def createSecureConnection(
socket: Socket,
secureConfig: SecureConnectionConfig
): WebSocketConnection
}

View File

@ -36,7 +36,9 @@ object ExecutorWithUnlimitedPool extends LanguageServerExecutor {
descriptor: LanguageServerDescriptor,
progressTracker: ActorRef,
rpcPort: Int,
secureRpcPort: Option[Int],
dataPort: Int,
secureDataPort: Option[Int],
lifecycleListener: LanguageServerExecutor.LifecycleListener
): Unit = {
val runnable: Runnable = { () =>
@ -45,7 +47,9 @@ object ExecutorWithUnlimitedPool extends LanguageServerExecutor {
descriptor,
progressTracker,
rpcPort,
secureRpcPort,
dataPort,
secureDataPort,
lifecycleListener
)
} catch {
@ -66,7 +70,9 @@ object ExecutorWithUnlimitedPool extends LanguageServerExecutor {
descriptor: LanguageServerDescriptor,
progressTracker: ActorRef,
rpcPort: Int,
secureRpcPort: Option[Int],
dataPort: Int,
secureDataPort: Option[Int],
lifecycleListener: LanguageServerExecutor.LifecycleListener
): Unit = {
val distributionConfiguration = descriptor.distributionConfiguration
@ -76,10 +82,12 @@ object ExecutorWithUnlimitedPool extends LanguageServerExecutor {
val inheritedLogLevel =
LoggingServiceManager.currentLogLevelForThisApplication()
val options = LanguageServerOptions(
rootId = descriptor.rootId,
interface = descriptor.networkConfig.interface,
rpcPort = rpcPort,
dataPort = dataPort
rootId = descriptor.rootId,
interface = descriptor.networkConfig.interface,
rpcPort = rpcPort,
secureRpcPort = secureRpcPort,
dataPort = dataPort,
secureDataPort = secureDataPort
)
val configurationManager = new GlobalRunnerConfigurationManager(
versionManager,

View File

@ -74,20 +74,32 @@ class LanguageServerBootLoader(
while (binaryPort == jsonRpcPort) {
binaryPort = findPort()
}
var secureJsonRpcPort: Option[Int] = None
var secureBinaryPort: Option[Int] = None
if (descriptor.networkConfig.enableSecure) {
val regularPorts = Set(jsonRpcPort, binaryPort)
secureJsonRpcPort = Some(findPort(regularPorts))
secureBinaryPort =
Some(findPort(regularPorts + secureJsonRpcPort.get))
}
logger.info(
"Found sockets for the language server " +
"[json:{}:{}, binary:{}:{}].",
"[json:{}:{}:{}, binary:{}:{}:{}].",
descriptor.networkConfig.interface,
jsonRpcPort,
secureJsonRpcPort.getOrElse("none"),
descriptor.networkConfig.interface,
binaryPort
binaryPort,
secureBinaryPort.getOrElse("none")
)
self ! Boot
context.become(
bootingFirstTime(
rpcPort = jsonRpcPort,
dataPort = binaryPort,
retryCount = retry
rpcPort = jsonRpcPort,
secureRpcPort = secureJsonRpcPort,
dataPort = binaryPort,
secureDataPort = secureBinaryPort,
retryCount = retry
)
)
@ -102,16 +114,20 @@ class LanguageServerBootLoader(
*/
private def bootingFirstTime(
rpcPort: Int,
secureRpcPort: Option[Int],
dataPort: Int,
secureDataPort: Option[Int],
retryCount: Int
): Receive =
LoggingReceive.withLabel("bootingFirstTime") {
booting(
rpcPort = rpcPort,
dataPort = dataPort,
shouldRetry = true,
retryCount = retryCount,
bootRequester = context.parent
rpcPort = rpcPort,
secureRpcPort = secureRpcPort,
dataPort = dataPort,
secureDataPort = secureDataPort,
shouldRetry = true,
retryCount = retryCount,
bootRequester = context.parent
)
}
@ -123,7 +139,9 @@ class LanguageServerBootLoader(
*/
private def booting(
rpcPort: Int,
secureRpcPort: Option[Int],
dataPort: Int,
secureDataPort: Option[Int],
shouldRetry: Boolean,
retryCount: Int,
bootRequester: ActorRef
@ -136,7 +154,9 @@ class LanguageServerBootLoader(
descriptor = descriptor,
bootTimeout = bootTimeout,
rpcPort = rpcPort,
secureRpcPort = secureRpcPort,
dataPort = dataPort,
secureDataPort = secureDataPort,
executor = executor
),
s"process-wrapper-${descriptor.name}"
@ -164,8 +184,10 @@ class LanguageServerBootLoader(
case LanguageServerProcess.ServerConfirmedFinishedBooting =>
val connectionInfo = LanguageServerConnectionInfo(
descriptor.networkConfig.interface,
rpcPort = rpcPort,
dataPort = dataPort
rpcPort = rpcPort,
secureRpcPort = secureRpcPort,
dataPort = dataPort,
secureDataPort = secureDataPort
)
logger.info("Language server booted [{}].", connectionInfo)
@ -241,11 +263,13 @@ class LanguageServerBootLoader(
): Receive =
LoggingReceive.withLabel("rebooting") {
booting(
rpcPort = connectionInfo.rpcPort,
dataPort = connectionInfo.dataPort,
shouldRetry = false,
retryCount = config.numberOfRetries,
bootRequester = rebootRequester
rpcPort = connectionInfo.rpcPort,
secureRpcPort = connectionInfo.secureRpcPort,
dataPort = connectionInfo.dataPort,
secureDataPort = connectionInfo.secureDataPort,
shouldRetry = false,
retryCount = config.numberOfRetries,
bootRequester = rebootRequester
)
}
@ -281,11 +305,12 @@ class LanguageServerBootLoader(
}
}
private def findPort(): Int =
private def findPort(excludePorts: Set[Int] = Set.empty): Int =
Tcp.findAvailablePort(
descriptor.networkConfig.interface,
descriptor.networkConfig.minPort,
descriptor.networkConfig.maxPort
descriptor.networkConfig.maxPort,
excludePorts
)
private case object FindFreeSocket

View File

@ -6,5 +6,7 @@ package org.enso.projectmanager.infrastructure.languageserver
case class LanguageServerConnectionInfo(
interface: String,
rpcPort: Int,
dataPort: Int
secureRpcPort: Option[Int],
dataPort: Int,
secureDataPort: Option[Int]
)

View File

@ -195,7 +195,13 @@ class LanguageServerController(
sender() ! ServerStarted(
LanguageServerSockets(
Socket(connectionInfo.interface, connectionInfo.rpcPort),
Socket(connectionInfo.interface, connectionInfo.dataPort)
connectionInfo.secureRpcPort.map(port =>
Socket(connectionInfo.interface, port)
),
Socket(connectionInfo.interface, connectionInfo.dataPort),
connectionInfo.secureDataPort.map(port =>
Socket(connectionInfo.interface, port)
)
)
)
context.become(

View File

@ -12,7 +12,8 @@ trait LanguageServerExecutor {
* @param progressTracker reference to an actor that should be notifed of any
* locks
* @param rpcPort port to use for the RPC channel
* @param dataPort port to use for the binary channel
* @param secureRpcPort port to use for the RPC channel
* @param secureDataPort port to use for the binary channel
* @param lifecycleListener a listener that will be notified when the process
* is started and terminated
*/
@ -20,7 +21,9 @@ trait LanguageServerExecutor {
descriptor: LanguageServerDescriptor,
progressTracker: ActorRef,
rpcPort: Int,
secureRpcPort: Option[Int],
dataPort: Int,
secureDataPort: Option[Int],
lifecycleListener: LifecycleListener
): Unit
}

View File

@ -27,7 +27,9 @@ import scala.concurrent.duration.{DurationInt, FiniteDuration}
* related to initializing the engine
* @param descriptor a LS descriptor
* @param rpcPort port to bind for RPC connections
* @param secureRpcPort an optional port to bind for secure RPC connections
* @param dataPort port to bind for binary connections
* @param secureDataPort an optional port to bind for secure binary connections
* @param bootTimeout maximum time permitted to wait for the process to finish
* initializing; if the initialization heartbeat is not
* received within this time the boot is treated as failed
@ -39,7 +41,9 @@ class LanguageServerProcess(
progressTracker: ActorRef,
descriptor: LanguageServerDescriptor,
rpcPort: Int,
secureRpcPort: Option[Int],
dataPort: Int,
secureDataPort: Option[Int],
bootTimeout: FiniteDuration,
executor: LanguageServerExecutor
) extends Actor
@ -74,7 +78,9 @@ class LanguageServerProcess(
descriptor = descriptor,
progressTracker = progressTracker,
rpcPort = rpcPort,
secureRpcPort = secureRpcPort,
dataPort = dataPort,
secureDataPort = secureDataPort,
lifecycleListener = LifecycleListener
)
context.become(startingStage)
@ -199,7 +205,9 @@ object LanguageServerProcess {
progressTracker: ActorRef,
descriptor: LanguageServerDescriptor,
rpcPort: Int,
secureRpcPort: Option[Int],
dataPort: Int,
secureDataPort: Option[Int],
bootTimeout: FiniteDuration,
executor: LanguageServerExecutor
): Props = Props(
@ -207,7 +215,9 @@ object LanguageServerProcess {
progressTracker,
descriptor,
rpcPort,
secureRpcPort,
dataPort,
secureDataPort,
bootTimeout,
executor
)

View File

@ -15,16 +15,22 @@ object Tcp {
* @param host a host
* @param minPort a minimum value of port
* @param maxPort a maximum value of port
* @param excludeSet a set of ports that should never be selected
* @return a port that is available to bind
*/
@tailrec
def findAvailablePort(host: String, minPort: Int, maxPort: Int): Int = {
def findAvailablePort(
host: String,
minPort: Int,
maxPort: Int,
excludeSet: Set[Int] = Set.empty
): Int = {
val random = Random.nextInt(maxPort - minPort + 1)
val port = minPort + random
if (isPortAvailable(host, port)) {
if (!excludeSet.contains(port) && isPortAvailable(host, port)) {
port
} else {
findAvailablePort(host, minPort, maxPort)
findAvailablePort(host, minPort, maxPort, excludeSet + port)
}
}

View File

@ -82,7 +82,9 @@ object ProjectManagementApi {
case class Result(
engineVersion: SemVer,
languageServerJsonAddress: Socket,
languageServerSecureJsonAddress: Option[Socket],
languageServerBinaryAddress: Socket,
languageServerSecureBinaryAddress: Option[Socket],
projectName: String,
projectNormalizedName: String,
projectNamespace: String

View File

@ -55,12 +55,14 @@ class ProjectOpenHandler[F[+_, +_]: Exec: CovariantFlatMap](
missingComponentAction = missingComponentAction
)
} yield ProjectOpen.Result(
engineVersion = server.engineVersion,
languageServerJsonAddress = server.sockets.jsonSocket,
languageServerBinaryAddress = server.sockets.binarySocket,
projectName = server.projectName,
projectNormalizedName = server.projectNormalizedName,
projectNamespace = server.projectNamespace
engineVersion = server.engineVersion,
languageServerJsonAddress = server.sockets.jsonSocket,
languageServerSecureJsonAddress = server.sockets.secureJsonSocket,
languageServerBinaryAddress = server.sockets.binarySocket,
languageServerSecureBinaryAddress = server.sockets.secureBinarySocket,
projectName = server.projectName,
projectNormalizedName = server.projectNormalizedName,
projectNamespace = server.projectNamespace
)
}

View File

@ -96,7 +96,9 @@ trait ProjectManagementOps { this: BaseServerSpec =>
ProjectOpen.Result(
engineVer,
jsonSock,
None,
binSock,
None,
projectName,
normalizedName,
namespace

View File

@ -136,7 +136,9 @@ class LanguageServerSupervisorSpec
LanguageServerConnectionInfo(
testHost,
testRpcPort,
testDataPort
secureRpcPort = None,
testDataPort,
secureDataPort = None
)
val supervisionConfig =

View File

@ -7,11 +7,15 @@ import java.util.UUID
* @param rootId an id of content root
* @param interface a interface that the server listen to
* @param rpcPort an RPC port that the server listen to
* @param secureRpcPort an option secure RPC port that the server listen to
* @param dataPort a data port that the server listen to
* @param secureDataPort an optional secure data port that the server listen to
*/
case class LanguageServerOptions(
rootId: UUID,
interface: String,
rpcPort: Int,
dataPort: Int
secureRpcPort: Option[Int],
dataPort: Int,
secureDataPort: Option[Int]
)

View File

@ -127,8 +127,13 @@ class Runner(
options.dataPort.toString,
"--log-level",
logLevel.name
) ++
Option.unless(logMasking)("--no-log-masking")
) ++ options.secureRpcPort
.map(port => Seq("--secure-rpc-port", port.toString))
.getOrElse(Seq.empty) ++
options.secureDataPort
.map(port => Seq("--secure-data-port", port.toString))
.getOrElse(Seq.empty) ++
Option.unless(logMasking)(Seq("--no-log-masking")).getOrElse(Seq.empty)
RunSettings(
version,
arguments ++ additionalArguments,