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

script.requesthandler.scala Maven / Gradle / Ivy

The newest version!
package otoroshi.script

import akka.http.scaladsl.model.Uri
import akka.stream.scaladsl.{Flow, Source}
import akka.util.ByteString
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import org.joda.time.DateTime
import otoroshi.env.Env
import otoroshi.events.{DataInOut, GatewayEvent, Header, Location}
import otoroshi.models.RemainingQuotas
import otoroshi.next.plugins.api.{NgPluginCategory, NgPluginVisibility, NgStep}
import otoroshi.utils.http.Implicits.BetterStandaloneWSResponse
import otoroshi.utils.http.RequestImplicits._
import otoroshi.utils.http.WSCookieWithSameSite
import otoroshi.utils.syntax.implicits._
import play.api.http.HttpEntity
import play.api.http.websocket.{Message => PlayWSMessage}
import play.api.libs.json.{JsObject, Json}
import play.api.mvc.Results.Status
import play.api.mvc._

import java.util.UUID
import scala.concurrent.{ExecutionContext, Future}

trait RequestHandler extends StartableAndStoppable with NamedPlugin {
  override def pluginType: PluginType                                                                       = PluginType.RequestHandlerType
  def handledDomains(implicit ec: ExecutionContext, env: Env): Seq[String]                                  = Seq.empty[String]
  def handle(
      request: Request[Source[ByteString, _]],
      defaultRouting: Request[Source[ByteString, _]] => Future[Result]
  )(implicit ec: ExecutionContext, env: Env): Future[Result]                                                = defaultRouting(request)
  def handleWs(
      request: RequestHeader,
      defaultRouting: RequestHeader => Future[Either[Result, Flow[PlayWSMessage, PlayWSMessage, _]]]
  )(implicit ec: ExecutionContext, env: Env): Future[Either[Result, Flow[PlayWSMessage, PlayWSMessage, _]]] =
    defaultRouting(request)
}

class ForwardTrafficHandler extends RequestHandler {

  override def name: String = "Forward traffic"

  override def visibility: NgPluginVisibility    = NgPluginVisibility.NgInternal
  override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.Other)
  override def steps: Seq[NgStep]                = Seq(NgStep.HandlesRequest)

  override def description: Option[String] =
    "This plugin can be use to perform a raw traffic forward to an URL without passing through otoroshi routing".some

  override def configRoot: Option[String] = "ForwardTrafficHandler".some

  override def defaultConfig: Option[JsObject] = Json
    .obj(
      configRoot.get -> Json.obj(
        "domains" -> Json.obj(
          "my.domain.tld" -> Json.obj(
            "baseUrl" -> "https://my.otherdomain.tld",
            "secret"  -> "jwt signing secret",
            "service" -> Json.obj(
              "id"   -> "service id for analytics",
              "name" -> "service name for analytics"
            )
          )
        )
      )
    )
    .some

  def hasBody(request: Request[_]): Boolean = request.theHasBody

  override def handledDomains(implicit ec: ExecutionContext, env: Env): Seq[String] = {
    val config                         = env.datastores.globalConfigDataStore.latest().plugins.config.select(configRoot.get)
    val domains: Map[String, JsObject] = config.select("domains").asOpt[Map[String, JsObject]].getOrElse(Map.empty)
    domains.keys.toSeq
  }

  override def handle(
      request: Request[Source[ByteString, _]],
      defaultRouting: Request[Source[ByteString, _]] => Future[Result]
  )(implicit ec: ExecutionContext, env: Env): Future[Result] = {
    val config                         = env.datastores.globalConfigDataStore.latest().plugins.config.select(configRoot.get)
    val domains: Map[String, JsObject] = config.select("domains").asOpt[Map[String, JsObject]].getOrElse(Map.empty)
    domains.get(request.theDomain) match {
      case None      => defaultRouting(request)
      case Some(obj) => {
        val start           = System.currentTimeMillis()
        val baseUrl         = obj.select("baseUrl").asString
        val secret          = obj.select("secret").asString
        val service         = obj.select("service").asObject
        val serviceId       = service.select("id").asString
        val serviceName     = service.select("name").asString
        val issuer          = obj.select("jwtIssuer").asOpt[String].getOrElse(env.Headers.OtoroshiIssuer)
        val stateHeaderName = obj.select("stateHeaderName").asOpt[String].getOrElse(env.Headers.OtoroshiState)
        val claimHeaderName = obj.select("claimHeaderName").asOpt[String].getOrElse(env.Headers.OtoroshiClaim)
        val date            = DateTime.now()
        val reqId           = UUID.randomUUID().toString
        val alg             = Algorithm.HMAC512(secret)
        val token           = JWT.create().withIssuer(issuer).sign(alg)
        val path            = request.thePath
        val baseUri         = Uri(baseUrl)
        val host            = baseUri.authority.host.toString()
        val headers         = request.headers.toSimpleMap.toSeq
          // .filterNot(_._1.toLowerCase == "content-type")
          .filterNot(_._1.toLowerCase == "timeout-access")
          .filterNot(_._1.toLowerCase == "tls-session-info")
          .filterNot(_._1.toLowerCase == "host") ++ Seq(
          (stateHeaderName -> reqId),
          (claimHeaderName -> token),
          ("Host"          -> host)
        )
        val cookies         = request.cookies.toSeq.map { c =>
          WSCookieWithSameSite(
            name = c.name,
            value = c.value,
            domain = c.domain,
            path = Option(c.path),
            maxAge = c.maxAge.map(_.toLong),
            secure = c.secure,
            httpOnly = c.httpOnly,
            sameSite = c.sameSite
          )
        }
        val overhead        = System.currentTimeMillis() - start
        var builder         = env.gatewayClient
          .akkaUrl(s"$baseUrl$path")
          .withHttpHeaders(headers: _*)
          .withCookies(cookies: _*)
          .withMethod(request.method)
          .withFollowRedirects(false)

        if (hasBody(request)) {
          builder = builder.withBody(request.body)
        }

        val httpcallstart = System.currentTimeMillis()
        builder
          .stream()
          .map { resp =>
            val calldur                      = System.currentTimeMillis() - httpcallstart
            val duration                     = System.currentTimeMillis() - start
            val ctypeOut                     = resp.headers.get("Content-Type").orElse(resp.headers.get("content-type")).map(_.last)
            val clenOut                      =
              resp.headers.get("Content-Length").orElse(resp.headers.get("content-length")).map(_.last).map(_.toLong)
            val headersOut                   = resp.headers
              .mapValues(_.last)
              .filterNot { case (key, _) =>
                key.toLowerCase == "content-length"
              }
              .filterNot { case (key, _) =>
                key.toLowerCase == "content-type"
              }
              .toSeq
            val transferEncoding             =
              resp.headers.get("Transfer-Encoding").orElse(resp.headers.get("transfer-encoding")).map(_.last)
            val hasChunkedHeader             = transferEncoding.exists(h => h.toLowerCase().contains("chunked"))
            val isContentLengthZero: Boolean = resp.headers.getIgnoreCase("Content-Length").contains("0")
            val isChunked: Boolean           = resp.isChunked() match { // don't know if actualy legit ...
              case _ if isContentLengthZero                                                        => false
              case Some(chunked)                                                                   => chunked
              case None if !env.emptyContentLengthIsChunked                                        =>
                hasChunkedHeader // false
              case None if env.emptyContentLengthIsChunked && hasChunkedHeader                     =>
                true
              case None if env.emptyContentLengthIsChunked && !hasChunkedHeader && clenOut.isEmpty =>
                true
              case _                                                                               => false
            }
            val cookiesOut                   = resp.cookies.map {
              case c: WSCookieWithSameSite =>
                Cookie(
                  name = c.name,
                  value = c.value,
                  maxAge = c.maxAge.map(_.toInt),
                  path = c.path.getOrElse("/"),
                  domain = c.domain,
                  secure = c.secure,
                  httpOnly = c.httpOnly,
                  sameSite = c.sameSite
                )
              case c                       => {
                val sameSite: Option[Cookie.SameSite] =
                  resp.headers.get("Set-Cookie").orElse(resp.headers.get("set-cookie")).flatMap { values => // legit
                    values
                      .find { sc =>
                        sc.startsWith(s"${c.name}=${c.value}")
                      }
                      .flatMap { sc =>
                        sc.split(";")
                          .map(_.trim)
                          .find(p => p.toLowerCase.startsWith("samesite="))
                          .map(_.replace("samesite=", "").replace("SameSite=", ""))
                          .flatMap(Cookie.SameSite.parse)
                      }
                  }
                Cookie(
                  name = c.name,
                  value = c.value,
                  maxAge = c.maxAge.map(_.toInt),
                  path = c.path.getOrElse("/"),
                  domain = c.domain,
                  secure = c.secure,
                  httpOnly = c.httpOnly,
                  sameSite = sameSite
                )
              }
            }
            GatewayEvent(
              `@id` = reqId,
              `@timestamp` = date,
              `@calledAt` = date,
              reqId = reqId,
              parentReqId = None,
              protocol = request.version,
              to = Location(
                scheme = request.theProtocol,
                host = request.theHost,
                uri = request.relativeUri
              ),
              target = Location(
                scheme = request.theProtocol,
                host = host,
                uri = request.relativeUri
              ),
              url = request.theUrl,
              method = request.method,
              from = request.theIpAddress,
              env = env.env,
              backendDuration = calldur,
              duration = duration,
              overhead = overhead,
              cbDuration = 0,
              overheadWoCb = overhead,
              callAttempts = 1,
              data = DataInOut(0, 0),
              status = resp.status,
              headers = request.headers.toSimpleMap.toSeq.map(Header.apply),
              headersOut = resp.headers.mapValues(_.last).toSeq.map(Header.apply),
              otoroshiHeadersIn = headers.map(Header.apply),
              otoroshiHeadersOut = resp.headers.mapValues(_.last).toSeq.map(Header.apply),
              extraInfos = None,
              responseChunked = false,
              identity = None,
              gwError = None,
              err = false,
              `@serviceId` = serviceId,
              `@service` = serviceName,
              descriptor = None,
              remainingQuotas = RemainingQuotas(),
              viz = None,
              clientCertChain = Seq.empty[String],
              userAgentInfo = None,
              geolocationInfo = None,
              extraAnalyticsData = None
            ).toAnalytics()
            isChunked match {
              case true  => {
                // stream out
                val res = Status(resp.status)
                  .chunked(resp.bodyAsSource)
                  .withHeaders(headersOut: _*)
                  .withCookies(cookiesOut: _*)
                ctypeOut match {
                  case None      => res
                  case Some(ctp) => res.as(ctp)
                }
              }
              case false => {
                val res = Results
                  .Status(resp.status)
                  .sendEntity(
                    HttpEntity.Streamed(
                      resp.bodyAsSource,
                      clenOut,
                      ctypeOut
                    )
                  )
                  .withHeaders(headersOut: _*)
                  .withCookies(cookiesOut: _*)
                ctypeOut match {
                  case None      => res
                  case Some(ctp) => res.as(ctp)
                }
              }
            }
          }
          .recoverWith { case e =>
            defaultRouting(request)
          }
      }
    }
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy