All Downloads are FREE. Search and download functionalities are using the official Maven repository.

plugins.envoy.scala Maven / Gradle / Ivy

package otoroshi.plugins.envoy

import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger}
import akka.http.scaladsl.model.Uri
import akka.stream.Materializer
import akka.stream.scaladsl.{Sink, Source}
import akka.util.ByteString
import otoroshi.env.Env
import otoroshi.models.ServiceDescriptor
import otoroshi.next.plugins.api.{NgPluginCategory, NgPluginVisibility, NgStep}
import otoroshi.script.{
  AfterRequestContext,
  BeforeRequestContext,
  HttpRequest,
  RequestTransformer,
  TransformerRequestBodyContext,
  TransformerRequestContext
}
import play.api.libs.json.{JsArray, JsNull, JsObject, JsString, JsValue, Json}
import play.api.mvc.{Result, Results}
import otoroshi.utils.syntax.implicits._
import otoroshi.ssl.Cert
import otoroshi.utils.cache.types.UnboundedTrieMap

import scala.collection.concurrent.TrieMap
import scala.concurrent.{ExecutionContext, Future, Promise}

// DEPRECATED
class EnvoyControlPlane extends RequestTransformer {

  override def deprecated: Boolean = true

  private val awaitingRequests = new UnboundedTrieMap[String, Promise[Source[ByteString, _]]]()

  override def name: String = "[DEPRECATED] Envoy Control Plane"

  override def visibility: NgPluginVisibility    = NgPluginVisibility.NgUserLand
  override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.Experimental)
  override def steps: Seq[NgStep]                = Seq(NgStep.TransformRequest)

  override def defaultConfig: Option[JsObject] =
    Json
      .obj(
        "EnvoyControlPlane" -> Json.obj(
          "enabled" -> true
        )
      )
      .some

  override def description: Option[String] =
    """This plugin will expose the otoroshi state to envoy instances using the xDS V3 API`.
    |
    |Right now, all the features of otoroshi cannot be exposed as is through Envoy.
    |
    |This plugin can accept the following configuration
    |
    |```json
    |{
    |  "EnvoyControlPlane": {
    |    "enabled": true
    |  }
    |}
    |```
  """.stripMargin.some

  def certificateToJson(certificate: Cert): JsObject = {
    Json
      .obj(
        "certificate_chain" -> Json.obj("inline_string" -> certificate.chain)
      )
      .applyOnIf(certificate.privateKey.trim.nonEmpty) { obj =>
        obj ++ Json.obj("private_key" -> Json.obj("inline_string" -> certificate.privateKey))
      }
      .applyOnIf(certificate.password.isDefined) { obj =>
        obj ++ Json.obj("password" -> Json.obj("inline_string" -> certificate.password.get))
      }
  }

  def handleClusterDiscovery(
      body: JsValue
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Result] = {

    def serviceToCluster(service: ServiceDescriptor): JsObject = {
      Json
        .obj(
          "@type"           -> "type.googleapis.com/envoy.config.cluster.v3.Cluster",
          "name"            -> s"target_${service.id}",
          "connect_timeout" -> "10s", // TODO: tune it according to desc
          "type"            -> "STRICT_DNS",
          // "dns_lookup_family": "V4_ONLY",
          "lb_policy"       -> "ROUND_ROBIN", // TODO: tune it according to desc
          "load_assignment" -> Json.obj(
            "cluster_name" -> s"target_${service.id}",
            "endpoints"    -> Json.arr(
              Json.obj(
                "lb_endpoints" -> JsArray(
                  service.targets.map { target =>
                    Json.obj(
                      "load_balancing_weight" -> target.weight,
                      "endpoint"              -> Json.obj(
                        "address" -> Json.obj(
                          "socket_address" -> Json.obj(
                            "address"    -> target.ipAddress.getOrElse(target.theHost).asInstanceOf[String],
                            "port_value" -> target.thePort
                          )
                        )
                      )
                    )
                  }
                )
              )
            )
          )
        )
        .applyOnIf(service.targets.exists(_.scheme.toLowerCase() == "https")) { obj =>
          val mtls        = service.targets.exists(_.mtlsConfig.mtls)
          val clientCert  = service.targets.flatMap(_.mtlsConfig.actualCerts).headOption
          val trustedCert = service.targets.flatMap(_.mtlsConfig.actualTrustedCerts.filter(_.ca)).headOption
          val trustAll    = service.targets.exists(_.mtlsConfig.trustAll)
          obj ++ Json.obj(
            "transport_socket" -> Json.obj(
              "name"         -> "envoy.transport_sockets.tls",
              "typed_config" -> Json
                .obj(
                  // TODO: plug mtls support
                  "@type" -> "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext",
                  "sni"   -> service.targets
                    .find(_.scheme.toLowerCase() == "https")
                    .map(_.theHost)
                    .getOrElse(service.targets.head.theHost)
                    .asInstanceOf[String]
                )
                .applyOnIf(mtls && clientCert.isDefined) { obj =>
                  obj ++ Json.obj(
                    "common_tls_context" -> Json.obj(
                      "tls_certificates" -> JsArray(Seq(certificateToJson(clientCert.get)))
                    ),
                    "validation_context" -> Json
                      .obj(
                        "trust_chain_verification" -> (if (trustAll) "ACCEPT_UNTRUSTED" else "VERIFY_TRUST_CHAIN")
                          .asInstanceOf[String]
                      )
                      .applyOnIf(trustedCert.isDefined) { obj =>
                        obj ++ Json.obj("trusted_ca" -> Json.obj("inline_string" -> trustedCert.get.chain))
                      }
                  )
                }
            )
          )
        }
    }

    env.datastores.serviceDescriptorDataStore.findAll().map { services =>
      val clusters = services.map(serviceToCluster)
      Results.Ok(
        Json.obj(
          "version_info" -> "1.0",
          "type_url"     -> "type.googleapis.com/envoy.config.cluster.v3.Cluster",
          "resources"    -> JsArray(clusters)
        )
      )
    }
  }

  def handleListenerDiscovery(
      body: JsValue
  )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Result] = {

    def extractFilters(service: ServiceDescriptor): JsArray = {
      var arr = Json.arr();
      if (service.buildMode) {
        arr :+ Json.obj(
          "name"         -> "envoy.filters.http.lua",
          "typed_config" -> Json.obj(
            "@type"       -> "type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua",
            "inline_code" ->
            """function envoy_on_request(request_handle)
                |  request_handle:respond(
                |    {[":status"] = "403",
                |     ["upstream_foo"] = "foo"},
                |    "nope")
                |end
                |function envoy_on_response(response_handle)
                |  response_handle:respond(
                |    {[":status"] = "403",
                |     ["upstream_foo"] = "foo"},
                |    "nope")
                |end
                |""".stripMargin
          )
        )
      }
      arr
    }

    def serviceToJson(service: ServiceDescriptor): JsObject = {
      // https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#envoy-v3-api-msg-config-route-v3-route
      Json.obj(
        "name"    -> s"service_${service.id}",
        "domains" -> JsArray(service.allHosts.distinct.map(JsString.apply)),
        "routes"  -> Json.arr(
          Json
            .obj(
              "match" -> Json.obj(
                "prefix" -> service.matchingRoot.getOrElse("/").asInstanceOf[String]
              )
            )
            .applyOnIf(service.additionalHeaders.nonEmpty) { obj =>
              obj ++ Json.obj("request_headers_to_add" -> JsArray(service.additionalHeaders.toSeq.map {
                case (key, value) => Json.obj("header" -> Json.obj("key" -> key, "value" -> value), "append" -> true)
              }))
            }
            .applyOnIf(service.removeHeadersIn.nonEmpty) { obj =>
              obj ++ Json.obj("request_headers_to_remove" -> JsArray(service.removeHeadersIn.map(JsString.apply)))
            }
            .applyOnIf(service.additionalHeadersOut.nonEmpty) { obj =>
              obj ++ Json.obj("response_headers_to_add" -> JsArray(service.additionalHeadersOut.toSeq.map {
                case (key, value) => Json.obj("header" -> Json.obj("key" -> key, "value" -> value), "append" -> true)
              }))
            }
            .applyOnIf(service.removeHeadersOut.nonEmpty) { obj =>
              obj ++ Json.obj("response_headers_to_remove" -> JsArray(service.removeHeadersOut.map(JsString.apply)))
            }
            .applyOnIf(!service.redirection.enabled && !service.buildMode && !service.maintenanceMode) { obj =>
              obj ++ Json.obj(
                "route" -> Json.obj(
                  "auto_host_rewrite" -> service.overrideHost,
                  "cluster"           -> s"target_${service.id}"
                )
              )
            }
            .applyOnIf(!service.redirection.enabled && service.buildMode && !service.maintenanceMode) { obj =>
              obj ++ Json.obj(
                "direct_response" -> Json.obj(
                  "status" -> 503,
                  "body"   -> Json.obj(
                    "inline_string" -> "

Service under construction

" ) ) ) } .applyOnIf(!service.redirection.enabled && !service.buildMode && service.maintenanceMode) { obj => obj ++ Json.obj( "direct_response" -> Json.obj( "status" -> 503, "body" -> Json.obj( "inline_string" -> "

Service in maintenance

" ) ) ) } .applyOnIf(service.redirection.enabled && !service.buildMode && !service.maintenanceMode) { obj => val uri = Uri(service.redirection.to) obj ++ Json.obj( "redirect" -> Json.obj( "response_code" -> (service.redirection.code match { case 301 => "MOVED_PERMANENTLY" case 302 => "FOUND" case 303 => "SEE_OTHER" case 307 => "TEMPORARY_REDIRECT" case 308 => "PERMANENT_REDIRECT" case _ => "SEE_OTHER" }), "scheme_redirect" -> uri.scheme, "host_redirect" -> uri.authority.host.toString(), "port_redirect" -> uri.authority.port, "path_redirect" -> uri.path.toString() ) ) } ) ) } def httpsListener(id: String, port: Int, services: Seq[ServiceDescriptor], certificates: Seq[Cert]): JsObject = { // TODO: try to group with wildcard certificates val chains = services.flatMap(s => s.allHosts.map(h => (h, s))).groupBy(_._1).mapValues(_.map(_._2)).map { case (host, servs) => val certs = certificates.filter(_.matchesDomain(host)).sortWith((c1, c2) => c1.allDomains.exists(_.contains("*"))) (host, (certs, servs)) } def filterChain(t: (String, (Seq[Cert], Seq[ServiceDescriptor]))): Option[JsObject] = t match { case (host, (certs, servs)) => certs.headOption.map { cert => Json.obj( "filter_chain_match" -> Json.obj( "server_names" -> Json.arr(host) ), "filters" -> Json.arr( Json.obj( "name" -> "envoy.filters.network.http_connection_manager", "typed_config" -> Json.obj( "@type" -> "type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager", "stat_prefix" -> "ingress_http", "codec_type" -> "AUTO", "route_config" -> Json.obj( "name" -> s"otoroshi", "virtual_hosts" -> JsArray( servs.map(serviceToJson) ) ), "http_filters" -> Json.arr( Json.obj( "name" -> "envoy.filters.http.router" ) ) ) ) ), "transport_socket" -> Json.obj( "name" -> "envoy.transport_sockets.tls", "typed_config" -> Json.obj( "require_client_certificate" -> true, // TODO: plug mtls support "@type" -> "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext", "common_tls_context" -> Json.obj( "tls_certificates" -> JsArray(Seq(certificateToJson(cert))) ) ) ) ) } } val jsChains: Seq[JsValue] = chains.toSeq.flatMap(filterChain) Json.obj( "@type" -> "type.googleapis.com/envoy.config.listener.v3.Listener", "name" -> s"listener_$id", "address" -> Json.obj( "socket_address" -> Json.obj( "address" -> "0.0.0.0", "port_value" -> port ) ), "listener_filters" -> Json.arr( Json.obj( "name" -> "envoy.filters.listener.tls_inspector", "typed_config" -> Json.obj() ) ), "filter_chains" -> JsArray(jsChains) ) } def httpListener(id: String, port: Int, services: Seq[ServiceDescriptor]): JsObject = { Json.obj( "@type" -> "type.googleapis.com/envoy.config.listener.v3.Listener", "name" -> s"listener_$id", "address" -> Json.obj( "socket_address" -> Json.obj( "address" -> "0.0.0.0", "port_value" -> port ) ), "filter_chains" -> Json.obj( "filters" -> Json.arr( Json.obj( "name" -> "envoy.filters.network.http_connection_manager", "typed_config" -> Json.obj( "@type" -> "type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager", "stat_prefix" -> "ingress_http", "codec_type" -> "AUTO", "route_config" -> Json.obj( "name" -> s"otoroshi", "virtual_hosts" -> JsArray( services.map(serviceToJson) ) ), "http_filters" -> Json.arr( Json.obj( "name" -> "envoy.filters.http.router" ) ) ) ) ) ) ) } env.datastores.certificatesDataStore.findAll().flatMap { __certs => env.datastores.serviceDescriptorDataStore.findAll().map { _services => val services = _services.filter(_.enabled) val _certs = __certs.sortWith((a, b) => a.id.compareTo(b.id) > 0).map(_.enrich()) val certs = _certs .filterNot(_.keypair) .filterNot(_.ca) .filterNot(_.privateKey.trim.isEmpty) val listeners = Seq( httpListener("http", 10080 /*env.port*/, services), httpsListener("https", 10443 /*env.httpsPort*/, services, certs) ) Results.Ok( Json.obj( "version_info" -> "1.0", "type_url" -> "type.googleapis.com/envoy.config.listener.v3.Listener", "resources" -> JsArray(listeners) ) ) } } } override def beforeRequest( ctx: BeforeRequestContext )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Unit] = { awaitingRequests.putIfAbsent(ctx.snowflake, Promise[Source[ByteString, _]]) funit } override def afterRequest( ctx: AfterRequestContext )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Unit] = { awaitingRequests.remove(ctx.snowflake) funit } override def transformRequestBodyWithCtx( ctx: TransformerRequestBodyContext )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Source[ByteString, _] = { awaitingRequests.get(ctx.snowflake).map(_.trySuccess(ctx.body)) ctx.body } def withBody(ctx: TransformerRequestContext)( f: JsValue => Future[Result] )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Either[Result, HttpRequest]] = { awaitingRequests.get(ctx.snowflake).map { promise => val bodySource: Source[ByteString, _] = Source .future(promise.future) .flatMapConcat(s => s) bodySource.runFold(ByteString.empty)(_ ++ _).flatMap { body => f(Json.parse(body.utf8String)) } } getOrElse { Results.BadRequest(Json.obj("error" -> "no body provided")).future } map (r => Left(r)) } override def transformRequestWithCtx( ctx: TransformerRequestContext )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Either[Result, HttpRequest]] = { (ctx.request.method, ctx.request.path) match { case ("POST", "/v3/discovery:clusters") => withBody(ctx)(handleClusterDiscovery) case ("POST", "/v3/discovery:listeners") => withBody(ctx)(handleListenerDiscovery) case _ => Left(Results.NotFound(Json.obj("error" -> "resource not found !"))).future } } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy