From 625aaefc26b487237a05dcac8bacacfdc573e220 Mon Sep 17 00:00:00 2001 From: zhongl Date: Fri, 15 Feb 2019 17:55:59 +0800 Subject: [PATCH 01/24] Echo request info. --- src/main/scala/fun/zhongl/passport/Echo.scala | 8 ++++++-- src/test/scala/fun/zhongl/passport/EchoSpec.scala | 9 +++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/main/scala/fun/zhongl/passport/Echo.scala b/src/main/scala/fun/zhongl/passport/Echo.scala index 6d13a4f..d8da0d1 100644 --- a/src/main/scala/fun/zhongl/passport/Echo.scala +++ b/src/main/scala/fun/zhongl/passport/Echo.scala @@ -30,14 +30,18 @@ object Echo extends Directives { implicit val mat = ActorMaterializer() - Route.asyncHandler((get & principal) { info => + Route.asyncHandler((principal & extractRequest) { (info, req) => val html = s""" | | | Who am i | | - |

${info}

+ |

Current User

+ |

$info

+ |
+ |

${req.method.value} ${req.uri}

+ | ${req.headers.map(h => s"

${h.name()}: ${h.value()}

").mkString("\n")} | | |""".stripMargin diff --git a/src/test/scala/fun/zhongl/passport/EchoSpec.scala b/src/test/scala/fun/zhongl/passport/EchoSpec.scala index 2fc3e12..45999b2 100644 --- a/src/test/scala/fun/zhongl/passport/EchoSpec.scala +++ b/src/test/scala/fun/zhongl/passport/EchoSpec.scala @@ -1,5 +1,6 @@ package fun.zhongl.passport import akka.actor.ActorSystem +import akka.http.scaladsl.model.headers.`User-Agent` import akka.http.scaladsl.model.{ContentTypes, HttpEntity, HttpRequest, HttpResponse} import akka.http.scaladsl.server.Directives import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} @@ -18,11 +19,15 @@ class EchoSpec extends WordSpec with Matchers with BeforeAndAfterAll with Direct | Who am i | | - |

a.b

+ |

Current User

+ |

a.b

+ |
+ |

GET http://a.b

+ |

User-Agent: mock

| | |""".stripMargin - val future = Echo.handle(extractHost).apply(HttpRequest(uri = "http://a.b")) + val future = Echo.handle(extractHost).apply(HttpRequest(uri = "http://a.b", headers = List(`User-Agent`("mock")))) Await.result(future, Duration.Inf) shouldBe HttpResponse(entity = HttpEntity(ContentTypes.`text/html(UTF-8)`, html)) } } From 80c7cc502a54fa9a7c2813c6b5eb236be892ad76 Mon Sep 17 00:00:00 2001 From: zhongl Date: Fri, 15 Feb 2019 19:03:25 +0800 Subject: [PATCH 02/24] Print json format of UserInfo. --- .../scala/fun/zhongl/passport/Platforms.scala | 26 ++++++++++--------- .../fun/zhongl/passport/PlatformsSpec.scala | 16 +++++++----- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/main/scala/fun/zhongl/passport/Platforms.scala b/src/main/scala/fun/zhongl/passport/Platforms.scala index 147b1d7..15e9b56 100644 --- a/src/main/scala/fun/zhongl/passport/Platforms.scala +++ b/src/main/scala/fun/zhongl/passport/Platforms.scala @@ -27,26 +27,25 @@ import com.typesafe.config.Config import fun.zhongl.passport.Echo.cookie import spray.json._ import zhongl.stream.oauth2.FreshToken.Token -import zhongl.stream.oauth2.dingtalk.JsonSupport import zhongl.stream.oauth2.{OAuth2, dingtalk, wechat} object Platforms { type Authenticated[UserInfo] = (UserInfo, Uri) => HttpResponse type Builder[UserInfo] = UserInfo => JWTCreator.Builder - type Extractor[UserInfo] = DecodedJWT => UserInfo + type Extractor = DecodedJWT => String - abstract case class Platform[UserInfo, T <: Token](builder: Builder[UserInfo], extractor: Extractor[UserInfo]) { + abstract case class Platform[UserInfo, T <: Token](builder: Builder[UserInfo], extractor: Extractor) { final def oauth2(f: JWTCreator.Builder => HttpCookie)(implicit system: ActorSystem): OAuth2[T] = concrete { case (info, uri) => ok(uri, f, builder(info)) } - final def userInfoFromCookie(name: String): Directive1[UserInfo] = - cookieAs[UserInfo](name, extractor) + final def userInfoFromCookie(name: String): Directive1[String] = + cookieAs(name, extractor) protected def concrete(authenticated: Authenticated[UserInfo])(implicit system: ActorSystem): OAuth2[T] - private def cookieAs[T](name: String, f: DecodedJWT => T): Directive1[T] = cookie(name).map(p => JWT.decode(p.value)).map(f) + private def cookieAs(name: String, f: DecodedJWT => String): Directive1[String] = cookie(name).map(p => JWT.decode(p.value)).map(f) private def ok(uri: Uri, f: JWTCreator.Builder => HttpCookie, builder: JWTCreator.Builder) = { @inline @@ -71,11 +70,11 @@ object Platforms { } val ding: Platform[dingtalk.UserInfo, dingtalk.AccessToken] = { - val jsonSupport = new JsonSupport {} + val jsonSupport = new dingtalk.JsonSupport {} import jsonSupport._ - val extractor: Extractor[dingtalk.UserInfo] = { j => + val extractor: Extractor = { j => val name = j.getClaim("name").asString() val email = j.getClaim("email").asString() val dept = j.getClaim("dept").asArray(classOf[Integer]).toSeq.map(_.intValue()) @@ -83,7 +82,7 @@ object Platforms { val active = j.getClaim("active").asBoolean() val roles = j.getClaim("roles").asString().parseJson.convertTo[Seq[dingtalk.Role]] - dingtalk.UserInfo(j.getSubject, name, email, dept, avatar, active, roles) + dingtalk.UserInfo(j.getSubject, name, email, dept, avatar, active, roles).toJson.prettyPrint } val builder: Builder[dingtalk.UserInfo] = { info => @@ -105,6 +104,10 @@ object Platforms { } val wework: Platform[wechat.UserInfo, wechat.AccessToken] = { + val jsonSupport = new wechat.JsonSupport {} + + import jsonSupport._ + val builder: Builder[wechat.UserInfo] = { info => JWT .create() @@ -119,7 +122,7 @@ object Platforms { .withArrayClaim("dept", info.department.map(Integer.valueOf).toArray) } - val extractor: Extractor[wechat.UserInfo] = { j => + val extractor: Extractor = { j => val name = j.getClaim("name").asString() val avatar = j.getClaim("avatar").asString() val dept = j.getClaim("dept").asArray(classOf[Integer]).toSeq.map(_.intValue()) @@ -128,8 +131,7 @@ object Platforms { val isleader = j.getClaim("isleader").asInt().intValue() val enable = j.getClaim("enable").asInt().intValue() val alias = j.getClaim("alias").asString() - wechat.UserInfo(j.getSubject, name, dept, email, avatar, status, isleader, enable, alias) - + wechat.UserInfo(j.getSubject, name, dept, email, avatar, status, isleader, enable, alias).toJson.prettyPrint } new Platform[wechat.UserInfo, wechat.AccessToken](builder, extractor) { diff --git a/src/test/scala/fun/zhongl/passport/PlatformsSpec.scala b/src/test/scala/fun/zhongl/passport/PlatformsSpec.scala index a705784..46c83c0 100644 --- a/src/test/scala/fun/zhongl/passport/PlatformsSpec.scala +++ b/src/test/scala/fun/zhongl/passport/PlatformsSpec.scala @@ -7,6 +7,7 @@ import com.auth0.jwt.JWT import com.typesafe.config.ConfigFactory import fun.zhongl.passport.Platforms.{Authenticated, Builder, Extractor, Platform} import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} +import spray.json._ import zhongl.stream.oauth2.{OAuth2, dingtalk, wechat} import scala.concurrent.Await @@ -15,8 +16,7 @@ import scala.concurrent.duration.Duration class PlatformsSpec extends WordSpec with Matchers with BeforeAndAfterAll { implicit val system = ActorSystem(getClass.getSimpleName) - private val jc = JwtCookies.load(ConfigFactory.parseString( - """ + private val jc = JwtCookies.load(ConfigFactory.parseString(""" |include "common.conf" |cookie { | domain = ".a.b" @@ -42,22 +42,26 @@ class PlatformsSpec extends WordSpec with Matchers with BeforeAndAfterAll { } "have ding" in { + val jsonSupport = new dingtalk.JsonSupport {} + import jsonSupport._ val info = dingtalk.UserInfo("1", "n", "e", Seq(1), "a", true, Seq.empty) val signature = Platforms.ding.builder(info).sign(jc.algorithm) val maybeDecodedJWT = jc.unapply(HttpRequest(headers = List(Cookie(jc.name, signature)))) - maybeDecodedJWT.map(Platforms.ding.extractor).foreach(_ shouldBe info) + maybeDecodedJWT.map(Platforms.ding.extractor).foreach(_ shouldBe info.toJson.prettyPrint) } "have wework" in { + val jsonSupport = new wechat.JsonSupport {} + import jsonSupport._ val info = wechat.UserInfo("1", "n", Seq(1), "e", "a", 0, 0, 0, "") val signature = Platforms.wework.builder(info).sign(jc.algorithm) val maybeDecodedJWT = jc.unapply(HttpRequest(headers = List(Cookie(jc.name, signature)))) - maybeDecodedJWT.map(Platforms.wework.extractor).foreach(_ shouldBe info) + maybeDecodedJWT.map(Platforms.wework.extractor).foreach(_ shouldBe info.toJson.prettyPrint) } "return auto redirect html" in { - val builder: Builder[String] = s => JWT.create().withSubject(s) - val extractor: Extractor[String] = j => j.getSubject + val builder: Builder[String] = s => JWT.create().withSubject(s) + val extractor: Extractor = j => j.getSubject val p = new Platform[String, dingtalk.AccessToken](builder, extractor) { override protected def concrete(authenticated: Authenticated[String])(implicit system: ActorSystem) = new OAuth2[dingtalk.AccessToken] { From 3996b4c8eedb961de1378534c0f09e3865bf7875 Mon Sep 17 00:00:00 2001 From: zhongl Date: Fri, 15 Feb 2019 22:23:04 +0800 Subject: [PATCH 03/24] Stash. --- docker-compose.yml | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d766c4d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +version: '3' +services: + + gateway: + image: traefik:latest + command: + - "--api" + - "--entrypoints=Name:http Address::80 Redirect.EntryPoint:https" + - "--entrypoints=Name:https Address::443 TLS" + - "--defaultentrypoints=http,https" + - "--docker" + - "--docker.watch" + - "--docker.domain=${DOMAIN}" + - "--accesslog" + - "--traefikLog" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + ports: + - 80:80 + - 443:443 + + passport: + image: zhongl/passport:latest + environment: + JAVA_TOOL_OPTIONS: -Dconfig.file=/app.conf + DOMAIN: $DOMAIN + labels: + traefik.port: 8080 + traefik.frontend.rule: "HostRegexp: {subdomain:[a-z]+}.${DOMAIN}" + + web: + image: zhongl/passport:latest + command: + - "-e" + environment: + JAVA_TOOL_OPTIONS: -Dconfig.file=/app.conf + DOMAIN: $DOMAIN + labels: + passport.forward.rule: "Host: www.${DOMAIN}" + passport.forward.port: 8080 From b660f51e487cb989324aa73d2e9b1d4647a0a3fa Mon Sep 17 00:00:00 2001 From: zhongl Date: Fri, 15 Feb 2019 22:34:48 +0800 Subject: [PATCH 04/24] Clean code. --- src/main/scala/fun/zhongl/passport/Echo.scala | 2 +- src/main/scala/fun/zhongl/passport/Forward.scala | 2 +- src/main/scala/fun/zhongl/passport/Main.scala | 4 ++-- src/test/scala/fun/zhongl/passport/EchoSpec.scala | 2 +- src/test/scala/fun/zhongl/passport/ForwardSpec.scala | 8 ++++---- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/scala/fun/zhongl/passport/Echo.scala b/src/main/scala/fun/zhongl/passport/Echo.scala index d8da0d1..aeaff64 100644 --- a/src/main/scala/fun/zhongl/passport/Echo.scala +++ b/src/main/scala/fun/zhongl/passport/Echo.scala @@ -26,7 +26,7 @@ import scala.concurrent.Future object Echo extends Directives { - def handle[T](principal: Directive1[T])(implicit sys: ActorSystem): HttpRequest => Future[HttpResponse] = { + def apply[T](principal: Directive1[T])(implicit sys: ActorSystem): HttpRequest => Future[HttpResponse] = { implicit val mat = ActorMaterializer() diff --git a/src/main/scala/fun/zhongl/passport/Forward.scala b/src/main/scala/fun/zhongl/passport/Forward.scala index 29edb34..dbbcf0f 100644 --- a/src/main/scala/fun/zhongl/passport/Forward.scala +++ b/src/main/scala/fun/zhongl/passport/Forward.scala @@ -31,7 +31,7 @@ import scala.util.control.NoStackTrace object Forward { - def handle(implicit system: ActorSystem): HttpRequest => Future[HttpResponse] = { + def apply()(implicit system: ActorSystem): HttpRequest => Future[HttpResponse] = { @inline def local = diff --git a/src/main/scala/fun/zhongl/passport/Main.scala b/src/main/scala/fun/zhongl/passport/Main.scala index 74972d3..364a4d3 100644 --- a/src/main/scala/fun/zhongl/passport/Main.scala +++ b/src/main/scala/fun/zhongl/passport/Main.scala @@ -56,9 +56,9 @@ object Main extends Directives { maybeOpt map { case Opt(host, port, true) => - (host, port, Echo.handle(plugin.userInfoFromCookie(jc.name))) + (host, port, Echo(plugin.userInfoFromCookie(jc.name))) case Opt(host, port, _) => - (host, port, Forward.handle) + (host, port, Forward()) } map { case (host, port, handle) => bind(Handlers.prepend(guard, handle), host, port) } getOrElse system.terminate() diff --git a/src/test/scala/fun/zhongl/passport/EchoSpec.scala b/src/test/scala/fun/zhongl/passport/EchoSpec.scala index 45999b2..e192472 100644 --- a/src/test/scala/fun/zhongl/passport/EchoSpec.scala +++ b/src/test/scala/fun/zhongl/passport/EchoSpec.scala @@ -27,7 +27,7 @@ class EchoSpec extends WordSpec with Matchers with BeforeAndAfterAll with Direct | | |""".stripMargin - val future = Echo.handle(extractHost).apply(HttpRequest(uri = "http://a.b", headers = List(`User-Agent`("mock")))) + val future = Echo(extractHost).apply(HttpRequest(uri = "http://a.b", headers = List(`User-Agent`("mock")))) Await.result(future, Duration.Inf) shouldBe HttpResponse(entity = HttpEntity(ContentTypes.`text/html(UTF-8)`, html)) } } diff --git a/src/test/scala/fun/zhongl/passport/ForwardSpec.scala b/src/test/scala/fun/zhongl/passport/ForwardSpec.scala index 3ba2256..9ca65a7 100644 --- a/src/test/scala/fun/zhongl/passport/ForwardSpec.scala +++ b/src/test/scala/fun/zhongl/passport/ForwardSpec.scala @@ -23,13 +23,13 @@ class ForwardSpec extends WordSpec with Matchers with BeforeAndAfterAll { "Forward" should { "stop recursive forward" in { maybeAddress.foreach { addr => - val future = Forward.handle.apply(HttpRequest(headers = List(`X-Forwarded-For`(addr)))) + val future = Forward.apply.apply(HttpRequest(headers = List(`X-Forwarded-For`(addr)))) Await.result(future, Duration.Inf) shouldBe HttpResponse(LoopDetected, entity = s"Loop detected: $addr") } } "complain missing host" in { - Await.result(Forward.handle.apply(HttpRequest()), Duration.Inf) shouldBe HttpResponse(BadRequest, entity = "Missing host header") + Await.result(Forward.apply.apply(HttpRequest()), Duration.Inf) shouldBe HttpResponse(BadRequest, entity = "Missing host header") } "add forwarded for" in { @@ -61,12 +61,12 @@ class ForwardSpec extends WordSpec with Matchers with BeforeAndAfterAll { } "complain missing remote address header" in { - val future = Forward.handle.apply(HttpRequest(headers = List(Host(Uri.Host("a.b"))))) + val future = Forward.apply.apply(HttpRequest(headers = List(Host(Uri.Host("a.b"))))) Await.result(future, Duration.Inf) shouldBe HttpResponse(InternalServerError, entity = "Missing remote address") } "complain error cause" in { - Await.result(Forward.handle.apply(null), Duration.Inf) shouldBe HttpResponse(InternalServerError, entity = "java.lang.NullPointerException") + Await.result(Forward.apply.apply(null), Duration.Inf) shouldBe HttpResponse(InternalServerError, entity = "java.lang.NullPointerException") } "exclude Timeout-Access header" in { From 961daa47d01e28830175cdb0050c7673e88df323 Mon Sep 17 00:00:00 2001 From: zhongl Date: Tue, 19 Feb 2019 00:06:44 +0800 Subject: [PATCH 05/24] Stash. --- build.sbt | 19 ++- docker-compose.yml | 10 +- project/plugins.sbt | 4 +- .../fun/zhongl/passport/CachedLatest.scala | 48 ++++++ .../fun/zhongl/passport/CommandLine.scala | 24 ++- .../fun/zhongl/passport/Complainant.scala | 24 +++ .../scala/fun/zhongl/passport/Docker.scala | 116 ++++++++++++++ .../scala/fun/zhongl/passport/Dynamic.scala | 74 +++++++++ src/main/scala/fun/zhongl/passport/Echo.scala | 36 ++--- .../scala/fun/zhongl/passport/Forward.scala | 91 +---------- .../scala/fun/zhongl/passport/Handle.scala | 67 ++++++++ .../scala/fun/zhongl/passport/Handlers.scala | 50 ------ src/main/scala/fun/zhongl/passport/Main.scala | 19 +-- .../scala/fun/zhongl/passport/Platforms.scala | 7 - .../scala/fun/zhongl/passport/Rewrite.scala | 144 ++++++++++++++++++ src/test/resources/application.conf | 12 ++ .../fun/zhongl/passport/DynamicSpec.scala | 61 ++++++++ .../scala/fun/zhongl/passport/EchoSpec.scala | 26 ++-- .../fun/zhongl/passport/ForwardSpec.scala | 90 ----------- .../fun/zhongl/passport/HandlersSpec.scala | 25 +-- .../fun/zhongl/passport/RewriteSpec.scala | 64 ++++++++ 21 files changed, 690 insertions(+), 321 deletions(-) create mode 100644 src/main/scala/fun/zhongl/passport/CachedLatest.scala create mode 100644 src/main/scala/fun/zhongl/passport/Complainant.scala create mode 100644 src/main/scala/fun/zhongl/passport/Docker.scala create mode 100644 src/main/scala/fun/zhongl/passport/Dynamic.scala create mode 100644 src/main/scala/fun/zhongl/passport/Handle.scala delete mode 100644 src/main/scala/fun/zhongl/passport/Handlers.scala create mode 100644 src/main/scala/fun/zhongl/passport/Rewrite.scala create mode 100644 src/test/resources/application.conf create mode 100644 src/test/scala/fun/zhongl/passport/DynamicSpec.scala delete mode 100644 src/test/scala/fun/zhongl/passport/ForwardSpec.scala create mode 100644 src/test/scala/fun/zhongl/passport/RewriteSpec.scala diff --git a/build.sbt b/build.sbt index 713a797..c98b983 100644 --- a/build.sbt +++ b/build.sbt @@ -15,17 +15,20 @@ lazy val root = (project in file(".")) resolvers += "jitpack" at "https://jitpack.io", mainClass in Compile := Some("fun.zhongl.passport.Main"), dockerBaseImage := "openjdk:8-alpine", + dockerEnvVars := Map("DOCKER_HOST" -> "unix:///var/run/docker.sock"), + dockerExposedPorts := Seq(8080), dockerRepository := sys.props.get("docker.repository"), version in Docker := sys.props.get("docker.tag").getOrElse(version.value), libraryDependencies ++= Seq( - "com.github.scopt" %% "scopt" % "4.0.0-RC2", - "com.github.zhongl.akka-stream-oauth2" %% "dingtalk" % oauth2Version, - "com.github.zhongl.akka-stream-oauth2" %% "wechat" % oauth2Version, - "com.typesafe.akka" %% "akka-http-testkit" % akkaHttpVersion % Test, - "com.typesafe.akka" %% "akka-testkit" % akkaVersion % Test, - "com.typesafe.akka" %% "akka-stream-testkit" % akkaVersion % Test, - "org.scalatest" %% "scalatest" % "3.0.4" % Test, - "org.mockito" % "mockito-core" % "2.19.0" % Test + "com.lightbend.akka" %% "akka-stream-alpakka-unix-domain-socket" % "1.0-M2", + "com.github.scopt" %% "scopt" % "4.0.0-RC2", + "com.github.zhongl.akka-stream-oauth2" %% "dingtalk" % oauth2Version, + "com.github.zhongl.akka-stream-oauth2" %% "wechat" % oauth2Version, + "com.typesafe.akka" %% "akka-http-testkit" % akkaHttpVersion % Test, + "com.typesafe.akka" %% "akka-testkit" % akkaVersion % Test, + "com.typesafe.akka" %% "akka-stream-testkit" % akkaVersion % Test, + "org.scalatest" %% "scalatest" % "3.0.4" % Test, + "org.scalamock" %% "scalamock" % "4.1.0" % Test ) ) .enablePlugins(JavaAppPackaging, AshScriptPlugin, DockerSpotifyClientPlugin) diff --git a/docker-compose.yml b/docker-compose.yml index d766c4d..7cb8df0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,12 +21,13 @@ services: passport: image: zhongl/passport:latest + command: + - "--docker:local" environment: JAVA_TOOL_OPTIONS: -Dconfig.file=/app.conf DOMAIN: $DOMAIN - labels: - traefik.port: 8080 - traefik.frontend.rule: "HostRegexp: {subdomain:[a-z]+}.${DOMAIN}" + ports: + - 80:8080 web: image: zhongl/passport:latest @@ -36,5 +37,4 @@ services: JAVA_TOOL_OPTIONS: -Dconfig.file=/app.conf DOMAIN: $DOMAIN labels: - passport.forward.rule: "Host: www.${DOMAIN}" - passport.forward.port: 8080 + passport.rule: "www.${DOMAIN} > :8080" diff --git a/project/plugins.sbt b/project/plugins.sbt index 76075cc..4e13dac 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -2,10 +2,10 @@ addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.9.2") addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") -addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.4") +addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.17") addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.5.1") addSbtPlugin("org.scoverage" % "sbt-coveralls" % "1.2.5") -libraryDependencies += "com.spotify" % "docker-client" % "3.5.13" \ No newline at end of file +libraryDependencies += "com.spotify" % "docker-client" % "8.9.0" \ No newline at end of file diff --git a/src/main/scala/fun/zhongl/passport/CachedLatest.scala b/src/main/scala/fun/zhongl/passport/CachedLatest.scala new file mode 100644 index 0000000..8f00e0e --- /dev/null +++ b/src/main/scala/fun/zhongl/passport/CachedLatest.scala @@ -0,0 +1,48 @@ +/* + * Copyright 2019 Zhong Lunfu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fun.zhongl.passport +import akka.stream.stage.{GraphStage, GraphStageLogic, InHandler, OutHandler} +import akka.stream.{Attributes, FlowShape, Inlet, Outlet} + +final class CachedLatest[T](latest: T) extends GraphStage[FlowShape[T, T]] { + val in = Inlet[T]("CachedLatest.in") + val out = Outlet[T]("CachedLatest.out") + + override val shape = FlowShape.of(in, out) + + override def createLogic(inheritedAttributes: Attributes): GraphStageLogic = new GraphStageLogic(shape) { + private var currentValue: T = latest + + setHandlers(in, out, new InHandler with OutHandler { + override def onPush(): Unit = { + currentValue = grab(in) + pull(in) + } + + override def onPull(): Unit = { + push(out, currentValue) + } + }) + + override def preStart(): Unit = { + pull(in) + } + } +} +object CachedLatest { + def apply[T](latest: T): CachedLatest[T] = new CachedLatest(latest) +} diff --git a/src/main/scala/fun/zhongl/passport/CommandLine.scala b/src/main/scala/fun/zhongl/passport/CommandLine.scala index 9934616..cfc7616 100644 --- a/src/main/scala/fun/zhongl/passport/CommandLine.scala +++ b/src/main/scala/fun/zhongl/passport/CommandLine.scala @@ -18,14 +18,30 @@ package fun.zhongl.passport import scopt.OptionParser object CommandLine { - case class Opt(host: String = "0.0.0.0", port: Int = 8080, echo: Boolean = false) + case class Opt(host: String = "0.0.0.0", port: Int = 8080, echo: Boolean = false, dynamic: Option[String] = None) val parser = new OptionParser[Opt]("passport") { head("passport", "0.0.1") - opt[String]('h', "host").action((x, c) => c.copy(host = x)).text("listened host address, default is 0.0.0.0") - opt[Int]('p', "port").action((x, c) => c.copy(port = x)).text("listened port, default is 8080") - opt[Unit]('e', "echo").action((_, c) => c.copy(echo = true)).text("enable echo mode for debug, default is disable") + opt[String]('h', "host") + .action((x, c) => c.copy(host = x)) + .text("listened host address, default is 0.0.0.0") + + opt[Int]('p', "port") + .action((x, c) => c.copy(port = x)) + .text("listened port, default is 8080") + + opt[Unit]('e', "echo") + .action((_, c) => c.copy(echo = true)) + .text("enable echo mode for debug, default is disable") + + opt[String]('d', "dynamic") + .action((x, c) => c.copy(dynamic = Some(x))) + .validate { + case "docker" | "swarm" => success + case _ => failure("Option -d or --dynamic must be docker or swarm.") + } + .text("enable dynamic dispatch, which's value must be docker or swarm, default is disable") help("help").text("print this usage") } diff --git a/src/main/scala/fun/zhongl/passport/Complainant.scala b/src/main/scala/fun/zhongl/passport/Complainant.scala new file mode 100644 index 0000000..f197abd --- /dev/null +++ b/src/main/scala/fun/zhongl/passport/Complainant.scala @@ -0,0 +1,24 @@ +/* + * Copyright 2019 Zhong Lunfu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fun.zhongl.passport +import akka.http.scaladsl.model.HttpResponse + +import scala.util.control.NoStackTrace + +trait Complainant extends NoStackTrace { + def response: HttpResponse +} diff --git a/src/main/scala/fun/zhongl/passport/Docker.scala b/src/main/scala/fun/zhongl/passport/Docker.scala new file mode 100644 index 0000000..9a42cff --- /dev/null +++ b/src/main/scala/fun/zhongl/passport/Docker.scala @@ -0,0 +1,116 @@ +/* + * Copyright 2019 Zhong Lunfu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fun.zhongl.passport +import java.io.File +import java.net.InetSocketAddress + +import akka.NotUsed +import akka.actor.ActorSystem +import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport +import akka.http.scaladsl.model.HttpEntity.Chunked +import akka.http.scaladsl.model.Uri.{Authority, Path} +import akka.http.scaladsl.model._ +import akka.http.scaladsl.settings.{ClientConnectionSettings, ConnectionPoolSettings} +import akka.http.scaladsl.unmarshalling.{Unmarshal, Unmarshaller} +import akka.http.scaladsl.{ClientTransport, Http} +import akka.stream.ActorMaterializer +import akka.stream.alpakka.unixdomainsocket.scaladsl.UnixDomainSocket +import akka.stream.scaladsl._ +import akka.util.ByteString +import fun.zhongl.passport.Docker.{Container, Service} +import spray.json._ + +import scala.concurrent.Future + +class Docker(base: Uri, settings: ConnectionPoolSettings)(implicit system: ActorSystem) extends Docker.JsonSupport { + + private implicit val mat = ActorMaterializer() + + def events(filters: Map[String, List[String]]): Source[ByteString, NotUsed] = { + val query = Uri.Query("filters" -> filters.toJson.compactPrint) + val request = HttpRequest(uri = base.copy(path = Path / "events").withQuery(query)) + val future = Http().singleRequest(request, settings = settings) + Source + .fromFuture(future) + .flatMapConcat { + case HttpResponse(StatusCodes.OK, _, Chunked(ContentTypes.`application/json`, chunks), _) => chunks + } + .map(_.data()) + } + + def containers[T](filters: Map[String, List[String]]): Flow[T, List[Container], NotUsed] = { + list[T, List[Container]](filters, Path / "containers") + } + + def services[T](filters: Map[String, List[String]]): Flow[T, List[Service], NotUsed] = { + list[T, List[Service]](filters, Path / "services") + } + + private def list[A, B](filters: Map[String, List[String]], path: Path)(implicit u: Unmarshaller[ResponseEntity, B]) = { + val query = Uri.Query("filters" -> filters.toJson.compactPrint) + val request = HttpRequest(uri = base.copy(path = path).withQuery(query)) + Flow[A].mapAsync(1)(_ => Http().singleRequest(request, settings = settings)).mapAsync(1) { + case HttpResponse(StatusCodes.OK, _, entity, _) => Unmarshal(entity).to[B] + } + } +} + +/** + * + */ +object Docker { + + def apply(host: String = fromEnv)(implicit system: ActorSystem): Docker = { + Uri(host) match { + case u @ Uri("unix", _, Path(p), _, _) => + val transport = new UnixSocketTransport(new File(p)) + val settings = ConnectionPoolSettings(system).withTransport(transport) + new Docker(u.copy(scheme = "http", authority = Authority(Uri.Host("localhost"))), settings) + case u => + new Docker(u.copy(scheme = "http"), ConnectionPoolSettings(system)) + } + } + + private def fromEnv = { + sys.env.getOrElse("DOCKER_HOST", "unix:///var/run/docker.dock") + } + + final case class Container(`ID`: String, `Names`: List[String], `Labels`: Map[String, String]) + final case class Service(`ID`: String, `Spec`: Spec) + final case class Spec(`Name`: String, `Labels`: Map[String, String]) + + private final class UnixSocketTransport(file: File) extends ClientTransport { + override def connectTo(host: String, port: Int, settings: ClientConnectionSettings)( + implicit system: ActorSystem): Flow[ByteString, ByteString, Future[Http.OutgoingConnection]] = { + implicit val ex = system.dispatcher + + UnixDomainSocket() + .outgoingConnection(file) + .mapMaterializedValue(_.map { _ => + val address = InetSocketAddress.createUnresolved(host, port) + Http.OutgoingConnection(address, address) + }) + } + } + + trait JsonSupport extends DefaultJsonProtocol with SprayJsonSupport { + implicit val specF: JsonFormat[Spec] = jsonFormat2(Spec) + implicit val containerF: JsonFormat[Container] = jsonFormat3(Container) + implicit val serviceF: JsonFormat[Service] = jsonFormat2(Service) + } + +} diff --git a/src/main/scala/fun/zhongl/passport/Dynamic.scala b/src/main/scala/fun/zhongl/passport/Dynamic.scala new file mode 100644 index 0000000..246c29f --- /dev/null +++ b/src/main/scala/fun/zhongl/passport/Dynamic.scala @@ -0,0 +1,74 @@ +/* + * Copyright 2019 Zhong Lunfu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fun.zhongl.passport +import java.util.regex.Pattern + +import akka.NotUsed +import akka.http.scaladsl.model.headers.Host +import akka.http.scaladsl.model.{HttpResponse, StatusCodes} +import akka.stream.scaladsl.{Flow, Source} +import akka.util.ByteString + +object Dynamic { + val label = "passport.rule" + + val filterByLabel = Map("label" -> List(label)) + + def by(docker: Docker): String => Source[Host => Host, NotUsed] = { + case "docker" => + arrange( + docker.events(Map("scope" -> List("local"), "type" -> List("container"), "event" -> List("start", "destroy"))), + docker.containers(filterByLabel).map(_.map(c => c.`Labels`(label) -> c.`Names`.head.substring(1))) + ) + + case _ => + arrange( + docker.events(Map("scope" -> List("swarm"), "type" -> List("service"), "event" -> List("update", "remove"))), + docker.services(filterByLabel).map(_.map(c => c.`Spec`.`Labels`(label) -> c.`Spec`.`Name`)) + ) + } + + private def arrange(events: Source[ByteString, NotUsed], flow: Flow[Any, List[(String, String)], NotUsed]) = { + Source + .single(Unit) + .via(flow) + .map(CachedLatest(_)) + .flatMapConcat { cl => + events.via(flow).via(cl) + } + .map(redirect) + } + + private def redirect(rules: List[(String, String)]): Host => Host = { + val rs = rules.map { p => + p._1.split("\\s*\\|>\\|\\s*:", 2) match { + case Array(r, port) => (Pattern.compile(r), Host(p._2, port.toInt)) + case Array(r) => (Pattern.compile(r), Host(p._2, 0)) + } + } + + host => + rs.find(p => p._1.matcher(host.host.address()).matches()) + .map(p => p._2) + .getOrElse(throw NoMatchedHostRuleException(host)) + } + + final case class NoMatchedHostRuleException(host: Host) extends Complainant { + override def response: HttpResponse = HttpResponse(StatusCodes.BadGateway, entity = "No matched host rule") + } + +} diff --git a/src/main/scala/fun/zhongl/passport/Echo.scala b/src/main/scala/fun/zhongl/passport/Echo.scala index aeaff64..322c847 100644 --- a/src/main/scala/fun/zhongl/passport/Echo.scala +++ b/src/main/scala/fun/zhongl/passport/Echo.scala @@ -16,37 +16,29 @@ package fun.zhongl.passport +import akka.NotUsed import akka.actor.ActorSystem -import akka.http.scaladsl.model.ContentTypes.`text/html(UTF-8)` -import akka.http.scaladsl.model.{HttpEntity, HttpRequest, HttpResponse} -import akka.http.scaladsl.server.{Directive1, Directives, Route} +import akka.http.scaladsl.model.{ContentTypes, HttpEntity, HttpRequest, HttpResponse} +import akka.http.scaladsl.server.{Directives, Route} import akka.stream.ActorMaterializer +import akka.stream.scaladsl.Flow +import spray.json._ -import scala.concurrent.Future +import scala.collection.immutable -object Echo extends Directives { +object Echo extends Directives with DefaultJsonProtocol { - def apply[T](principal: Directive1[T])(implicit sys: ActorSystem): HttpRequest => Future[HttpResponse] = { + type Shape = Flow[HttpRequest, HttpResponse, NotUsed] + def apply()(implicit sys: ActorSystem): Shape = { implicit val mat = ActorMaterializer() + implicit val f = jsonFormat4(InspectedRequest) - Route.asyncHandler((principal & extractRequest) { (info, req) => - val html = s""" - | - | - | Who am i - | - | - |

Current User

- |

$info

- |
- |

${req.method.value} ${req.uri}

- | ${req.headers.map(h => s"

${h.name()}: ${h.value()}

").mkString("\n")} - | - | - |""".stripMargin - complete(HttpEntity(`text/html(UTF-8)`, html)) + Route.handlerFlow((extractRequest & entity(as[String])) { (req, body) => + val ir = InspectedRequest(req.method.value, req.uri.toString(), req.headers.map(_.toString()), body) + complete(HttpEntity(ContentTypes.`application/json`, ir.toJson.compactPrint)) }) } + final case class InspectedRequest(method: String, uri: String, headers: immutable.Seq[String], body: String) } diff --git a/src/main/scala/fun/zhongl/passport/Forward.scala b/src/main/scala/fun/zhongl/passport/Forward.scala index dbbcf0f..ee93135 100644 --- a/src/main/scala/fun/zhongl/passport/Forward.scala +++ b/src/main/scala/fun/zhongl/passport/Forward.scala @@ -15,99 +15,18 @@ */ package fun.zhongl.passport +import akka.NotUsed import akka.actor.ActorSystem import akka.http.scaladsl.Http -import akka.http.scaladsl.model.StatusCodes.{Success => _, _} -import akka.http.scaladsl.model.Uri.Authority +import akka.http.scaladsl.model.StatusCodes.{Success => _} import akka.http.scaladsl.model._ -import akka.http.scaladsl.model.headers._ -import akka.http.scaladsl.util.FastFuture -import fun.zhongl.passport.NetworkInterfaces._ +import akka.stream.scaladsl.Flow -import scala.collection.immutable import scala.concurrent.Future -import scala.util._ -import scala.util.control.NoStackTrace object Forward { - def apply()(implicit system: ActorSystem): HttpRequest => Future[HttpResponse] = { - - @inline - def local = - Try { system.settings.config.getString("forward.network.interface") }.toOption - .orElse(findFirstNetworkInterfaceHasInet4Address.map(_.getName)) - .flatMap(localAddress) - .getOrElse(throw new IllegalStateException("Unavailable local address")) - - val rewrite: Rewrite = DefaultRewrite(None, local, None, None, List.empty) - - req => - Try { - Http().singleRequest(req.headers.foldLeft(rewrite)((r, h) => r.update(h))(req)) - }.recover { - case r: Responsible => FastFuture.successful(r.response) - } match { - case Success(fr) => fr - case Failure(cause) => FastFuture.successful(HttpResponse(InternalServerError, entity = s"$cause")) - } - } - - trait Rewrite { - def update: PartialFunction[HttpHeader, Rewrite] - def apply(request: HttpRequest): HttpRequest - } - - trait Responsible extends NoStackTrace { - def response: HttpResponse - } - final case class LoopDetectException(addresses: Seq[RemoteAddress]) extends Responsible { - override def response: HttpResponse = HttpResponse(LoopDetected, entity = s"Loop detected: ${addresses.mkString(",")}") - } - final case object MissingHostException extends Responsible { - override def response: HttpResponse = HttpResponse(BadRequest, entity = s"Missing host header") - } - final case object MissingRemoteAddressException extends Responsible { - override def response: HttpResponse = HttpResponse(InternalServerError, entity = "Missing remote address") - } - - final case class DefaultRewrite(authority: Option[Authority], - local: RemoteAddress, - from: Option[RemoteAddress], - forwarded: Option[`X-Forwarded-For`], - headers: immutable.Seq[HttpHeader]) - extends Rewrite { - - override def update: PartialFunction[HttpHeader, Rewrite] = { - case h: Host => this.copy(authority = Some(Authority(h.host, h.port)), headers = h +: headers) - case LoopDetected(addresses) => throw LoopDetectException(addresses) - case `Remote-Address`(address) => this.copy(from = Some(address)) - case h: `X-Forwarded-For` => this.copy(forwarded = Some(h)) - case _: `Timeout-Access` => this - case h => this.copy(headers = h +: headers) - } - - override def apply(request: HttpRequest): HttpRequest = { - - @inline - def xForwardedFor = - forwarded - .map(f => `X-Forwarded-For`(f.addresses :+ local)) - .orElse(from.map(f => `X-Forwarded-For`(f, local))) - .getOrElse(throw MissingRemoteAddressException) // suppose to set `akka.http.server.remote-address-header = on` - - authority - .map(a => request.uri.copy(authority = a)) - .map(u => request.copy(uri = u, headers = headers :+ xForwardedFor)) - .getOrElse(throw MissingHostException) - } - - private final object LoopDetected { - def unapply(arg: HttpHeader): Option[Seq[RemoteAddress]] = arg match { - case `X-Forwarded-For`(addresses) if addresses.contains(local) => Some(addresses) - case _ => None - } - } - } + type Shape = Flow[HttpRequest, Future[HttpResponse], NotUsed] + def apply()(implicit system: ActorSystem): Shape = Flow[HttpRequest].map(Http().singleRequest(_)) } diff --git a/src/main/scala/fun/zhongl/passport/Handle.scala b/src/main/scala/fun/zhongl/passport/Handle.scala new file mode 100644 index 0000000..894a4be --- /dev/null +++ b/src/main/scala/fun/zhongl/passport/Handle.scala @@ -0,0 +1,67 @@ +/* + * Copyright 2019 Zhong Lunfu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fun.zhongl.passport +import akka.NotUsed +import akka.actor.ActorSystem +import akka.http.scaladsl.model.headers.Host +import akka.http.scaladsl.model.{HttpRequest, HttpResponse, StatusCodes} +import akka.stream.scaladsl.{Flow, GraphDSL, Merge, Source} +import akka.stream.{ActorMaterializer, FlowShape} +import fun.zhongl.passport.Rewrite.{Forwarded, IgnoreTimeoutAccess} +import zhongl.stream.oauth2.Guard + +import scala.concurrent.Future +import scala.util.Try +import scala.util.control.NonFatal + +object Handle { + + def apply(mayBeDynamic: Option[Source[Host => Host, NotUsed]])(implicit system: ActorSystem): Flow[HttpRequest, HttpResponse, NotUsed] = { + implicit val ex = system.dispatcher + implicit val mat = ActorMaterializer() + + val config = system.settings.config + val jc = JwtCookies.load(config) + val ignore = jc.unapply(_: HttpRequest).isDefined + val plugin = Platforms.bound(config) + val local = Try { config.getString("interface") }.toOption + .orElse(NetworkInterfaces.findFirstNetworkInterfaceHasInet4Address.map(_.getName)) + .flatMap(NetworkInterfaces.localAddress) + .getOrElse(throw new IllegalStateException("Unavailable local address")) + + val graph = GraphDSL.create() { implicit b => + import GraphDSL.Implicits._ + + val guard = b.add(Guard.graph(plugin.oauth2(jc.generate), ignore)) + val merge = b.add(Merge[Future[HttpResponse]](2)) + val rewrite = b.add(Rewrite(mayBeDynamic, IgnoreTimeoutAccess, Forwarded(local))) + val forward = b.add(Forward()) + + // format: OFF + guard.out0 ~> rewrite ~> forward ~> merge + guard.out1 ~> merge + // format: ON + + FlowShape(guard.in, merge.out) + } + + Flow[HttpRequest].via(graph).mapAsync(1)(identity).recover { + case c: Complainant => c.response + case NonFatal(cause) => HttpResponse(StatusCodes.InternalServerError, entity = cause.toString) + } + } +} diff --git a/src/main/scala/fun/zhongl/passport/Handlers.scala b/src/main/scala/fun/zhongl/passport/Handlers.scala deleted file mode 100644 index edda046..0000000 --- a/src/main/scala/fun/zhongl/passport/Handlers.scala +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2019 Zhong Lunfu - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fun.zhongl.passport -import akka.NotUsed -import akka.actor.ActorSystem -import akka.http.scaladsl.model.{HttpRequest, HttpResponse} -import akka.stream.scaladsl.{Flow, GraphDSL, Merge} -import akka.stream.{ActorMaterializer, FlowShape, Graph} -import zhongl.stream.oauth2.Guard - -import scala.concurrent.Future - -object Handlers { - def prepend(guard: Graph[Guard.Shape, NotUsed], handle: HttpRequest => Future[HttpResponse])( - implicit system: ActorSystem): Flow[HttpRequest, HttpResponse, NotUsed] = { - implicit val mat = ActorMaterializer() - implicit val ex = system.dispatcher - - val graph = GraphDSL.create() { implicit b => - import GraphDSL.Implicits._ - - val g = b.add(guard) - val m = b.add(Merge[Future[HttpResponse]](2)) - val h = b.add(Flow.fromFunction(handle)) - - // format: OFF - g.out0 ~> h ~> m - g.out1 ~> m - // format: ON - - FlowShape(g.in, m.out) - } - - Flow[HttpRequest].via(graph).mapAsync(1)(identity) - } -} diff --git a/src/main/scala/fun/zhongl/passport/Main.scala b/src/main/scala/fun/zhongl/passport/Main.scala index 364a4d3..d15ac1c 100644 --- a/src/main/scala/fun/zhongl/passport/Main.scala +++ b/src/main/scala/fun/zhongl/passport/Main.scala @@ -25,7 +25,6 @@ import akka.http.scaladsl.server.Directives import akka.stream.ActorMaterializer import akka.stream.scaladsl.Flow import fun.zhongl.passport.CommandLine._ -import zhongl.stream.oauth2.Guard import scala.concurrent.duration._ import scala.concurrent.{Await, Future, Promise} @@ -47,20 +46,14 @@ object Main extends Directives { private def run(maybeOpt: Option[Opt])(implicit system: ActorSystem): Try[Future[Terminated]] = Try { implicit val mat = ActorMaterializer() - implicit val ex = system.dispatcher - - val jc = JwtCookies.load(system.settings.config) - val ignore = jc.unapply(_: HttpRequest).isDefined - val plugin = Platforms.bound(system.settings.config) - val guard = Guard.graph(plugin.oauth2(jc.generate), ignore) maybeOpt map { - case Opt(host, port, true) => - (host, port, Echo(plugin.userInfoFromCookie(jc.name))) - case Opt(host, port, _) => - (host, port, Forward()) + case Opt(host, port, true, _) => + (host, port, Echo()) + case Opt(host, port, _, d) => + (host, port, Handle(d.map(Dynamic.by(Docker())))) } map { - case (host, port, handle) => bind(Handlers.prepend(guard, handle), host, port) + case (host, port, flow) => bind(flow, host, port) } getOrElse system.terminate() } @@ -83,7 +76,7 @@ object Main extends Directives { promise.future } - .flatMap(_.unbind()) + .flatMap(_.terminate(3.seconds)) .flatMap(_ => system.terminate()) .recoverWith { case cause: Throwable => diff --git a/src/main/scala/fun/zhongl/passport/Platforms.scala b/src/main/scala/fun/zhongl/passport/Platforms.scala index 15e9b56..b81d773 100644 --- a/src/main/scala/fun/zhongl/passport/Platforms.scala +++ b/src/main/scala/fun/zhongl/passport/Platforms.scala @@ -20,11 +20,9 @@ import akka.actor.ActorSystem import akka.http.scaladsl.model.ContentTypes.`text/html(UTF-8)` import akka.http.scaladsl.model._ import akka.http.scaladsl.model.headers.{HttpCookie, `Set-Cookie`} -import akka.http.scaladsl.server.Directive1 import com.auth0.jwt.interfaces.DecodedJWT import com.auth0.jwt.{JWT, JWTCreator} import com.typesafe.config.Config -import fun.zhongl.passport.Echo.cookie import spray.json._ import zhongl.stream.oauth2.FreshToken.Token import zhongl.stream.oauth2.{OAuth2, dingtalk, wechat} @@ -40,13 +38,8 @@ object Platforms { final def oauth2(f: JWTCreator.Builder => HttpCookie)(implicit system: ActorSystem): OAuth2[T] = concrete { case (info, uri) => ok(uri, f, builder(info)) } - final def userInfoFromCookie(name: String): Directive1[String] = - cookieAs(name, extractor) - protected def concrete(authenticated: Authenticated[UserInfo])(implicit system: ActorSystem): OAuth2[T] - private def cookieAs(name: String, f: DecodedJWT => String): Directive1[String] = cookie(name).map(p => JWT.decode(p.value)).map(f) - private def ok(uri: Uri, f: JWTCreator.Builder => HttpCookie, builder: JWTCreator.Builder) = { @inline def autoRedirectPage(location: Uri): ResponseEntity = { diff --git a/src/main/scala/fun/zhongl/passport/Rewrite.scala b/src/main/scala/fun/zhongl/passport/Rewrite.scala new file mode 100644 index 0000000..5059cd3 --- /dev/null +++ b/src/main/scala/fun/zhongl/passport/Rewrite.scala @@ -0,0 +1,144 @@ +/* + * Copyright 2019 Zhong Lunfu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fun.zhongl.passport +import akka.NotUsed +import akka.http.scaladsl.model._ +import akka.http.scaladsl.model.headers._ +import akka.stream.FlowShape +import akka.stream.scaladsl.{Flow, GraphDSL, Source, ZipWith2} + +import scala.collection.immutable.Seq + +object Rewrite { + + type Shape = Flow[HttpRequest, HttpRequest, NotUsed] + + def apply(mayBeDynamic: Option[Source[Host => Host, NotUsed]], more: Action*): Shape = { + mayBeDynamic.map(graph(more)).getOrElse(Flow[HttpRequest].map(doRewrite(_, CompoundAction(List(HostOfUri()) ++ more)))) + } + + private def graph(more: scala.Seq[Action]): Source[Host => Host, NotUsed] => Shape = { dynamic => + Flow.fromGraph(GraphDSL.create() { implicit b => + import GraphDSL.Implicits._ + + val source = b.add(dynamic.map(f => HostOfUri(redirect = f)).map(a => CompoundAction(List(a) ++ more))) + val zip = b.add(new ZipWith2[HttpRequest, Action, HttpRequest](doRewrite)) + + // format: OFF + source ~> zip.in1 + // format: ON + + FlowShape(zip.in0, zip.out) + }) + } + + private def apply(action: Action): Shape = Flow[HttpRequest].map(doRewrite(_, action)) + + private def doRewrite(req: HttpRequest, action: Action) = { + val (hs, rewrite) = req.headers.foldLeft((List.empty[HttpHeader], action)) { + case ((acc, r), h) => + r.accumulate(h) match { + case (None, r0) => (acc, r0) + case (Some(h0), r0) => (h0 :: acc, r0) + } + } + rewrite(req.copy(headers = hs)) + } + + trait Action extends (HttpRequest => HttpRequest) { + def accumulate(header: HttpHeader): (Option[HttpHeader], Action) + } + + final case class HostOfUri(host: Option[Host] = None, redirect: Host => Host = identity) extends Action { + override def accumulate(header: HttpHeader): (Option[HttpHeader], Action) = header match { + case h: Host => (Some(h), HostOfUri(Some(h), redirect)) + case h => (Some(h), this) + } + + override def apply(req: HttpRequest): HttpRequest = { + host + .map(redirect) + .map(h => req.uri.authority.copy(host = h.host, port = h.port)) + .map(a => req.uri.copy(authority = a)) + .map(u => req.copy(uri = u)) + .getOrElse(throw MissingHostException) + } + + } + + /** + * + */ + final case class Forwarded(local: RemoteAddress, remote: Option[RemoteAddress] = None, forwarded: Option[`X-Forwarded-For`] = None) extends Action { + override def accumulate(header: HttpHeader): (Option[HttpHeader], Action) = header match { + case LoopDetected(addresses) => throw LoopDetectException(addresses) + case h: `X-Forwarded-For` => (None, copy(forwarded = Some(h))) + case `Remote-Address`(address) => (None, copy(remote = Some(address))) + case h => (Some(h), this) + } + + override def apply(req: HttpRequest): HttpRequest = { + val h = forwarded + .map(f => `X-Forwarded-For`(f.addresses :+ local)) + .orElse(remote.map(f => `X-Forwarded-For`(f, local))) + .getOrElse(throw MissingRemoteAddressException) + req.copy(headers = h +: req.headers) + } + + object LoopDetected { + def unapply(arg: HttpHeader): Option[Seq[RemoteAddress]] = arg match { + case `X-Forwarded-For`(addresses) if addresses.contains(local) => Some(addresses) + case _ => None + } + } + + } + + /** + * + */ + object IgnoreTimeoutAccess extends Action { + override def accumulate(header: HttpHeader): (Option[HttpHeader], Action) = header match { + case _: `Timeout-Access` => (None, this) + case h => (Some(h), this) + } + + override def apply(req: HttpRequest): HttpRequest = req + } + + final case class CompoundAction(seq: Seq[_ <: Action]) extends Action { + override def accumulate(header: HttpHeader): (Option[HttpHeader], Action) = { + val (hs, cs) = seq.map(_.accumulate(header)).unzip + (hs.find(_.isEmpty).getOrElse(Some(header)), CompoundAction(cs)) + } + + override def apply(req: HttpRequest): HttpRequest = seq.foldLeft(req)((r, f) => f(r)) + } + + final case class LoopDetectException(addresses: Seq[RemoteAddress]) extends Complainant { + override def response: HttpResponse = HttpResponse(StatusCodes.LoopDetected, entity = addresses.mkString(",")) + } + + final case object MissingRemoteAddressException extends Complainant { + override def response: HttpResponse = HttpResponse(StatusCodes.InternalServerError, entity = "Missing remote address") + } + + final object MissingHostException extends Complainant { + override def response: HttpResponse = HttpResponse(StatusCodes.BadRequest, entity = "Missing host header") + } + +} diff --git a/src/test/resources/application.conf b/src/test/resources/application.conf new file mode 100644 index 0000000..e574c85 --- /dev/null +++ b/src/test/resources/application.conf @@ -0,0 +1,12 @@ +include "wechat.conf" + +cookie { + domain = ".foo.bar" + secret = "****" +} + +wechat { + corp = "****" + agent = "****" + secret = "***" +} diff --git a/src/test/scala/fun/zhongl/passport/DynamicSpec.scala b/src/test/scala/fun/zhongl/passport/DynamicSpec.scala new file mode 100644 index 0000000..bc845cc --- /dev/null +++ b/src/test/scala/fun/zhongl/passport/DynamicSpec.scala @@ -0,0 +1,61 @@ +package fun.zhongl.passport +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.http.scaladsl.Http.ServerBinding +import akka.http.scaladsl.model.ContentTypes +import akka.http.scaladsl.model.HttpEntity.{Chunk, Chunked} +import akka.http.scaladsl.model.headers.Host +import akka.http.scaladsl.server.{Directives, Route} +import akka.stream.ActorMaterializer +import akka.stream.scaladsl.{Sink, Source} +import akka.util.ByteString +import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} + +import scala.concurrent.Await +import scala.concurrent.duration._ + +class DynamicSpec extends WordSpec with Matchers with BeforeAndAfterAll with Directives with Docker.JsonSupport { + + private implicit val system = ActorSystem(getClass.getSimpleName) + private implicit val mat = ActorMaterializer() + + private val docker = Docker("tcp://localhost:12306") + + var bound: ServerBinding = _ + + "Dynamic" should { + "by docker local" in { + val f = Dynamic.by(docker)("docker").runWith(Sink.head) + Await.result(f, Duration.Inf)(Host("foo.bar")) shouldBe Host("demo", 8080) + } + + "by docker swarm" in { + val f = Dynamic.by(docker)("swarm").runWith(Sink.head) + Await.result(f, Duration.Inf)(Host("foo.bar")) shouldBe Host("demo", 0) + } + } + + def mockDockerDaemon: Route = get { + concat( + (path("events") & parameter("filters")) { _ => + complete(Chunked(ContentTypes.`application/json`, Source.repeat(Chunk(ByteString(" "))))) + }, + (path("containers") & parameter("filters")) { _ => + complete(List(Docker.Container("id", List("/demo"), Map("passport.rule" -> ".+|>|:8080")))) + }, + (path("services") & parameter("filters")) { _ => + complete(List(Docker.Service("id", Docker.Spec("demo", Map("passport.rule" -> ".+"))))) + } + ) + } + + override protected def beforeAll(): Unit = { + val f = Http().bindAndHandle(mockDockerDaemon, "localhost", 12306) + bound = Await.result(f, 1.second) + } + + override protected def afterAll(): Unit = { + bound.terminate(1.second) + system.terminate() + } +} diff --git a/src/test/scala/fun/zhongl/passport/EchoSpec.scala b/src/test/scala/fun/zhongl/passport/EchoSpec.scala index e192472..d02b6b2 100644 --- a/src/test/scala/fun/zhongl/passport/EchoSpec.scala +++ b/src/test/scala/fun/zhongl/passport/EchoSpec.scala @@ -3,6 +3,8 @@ import akka.actor.ActorSystem import akka.http.scaladsl.model.headers.`User-Agent` import akka.http.scaladsl.model.{ContentTypes, HttpEntity, HttpRequest, HttpResponse} import akka.http.scaladsl.server.Directives +import akka.stream.ActorMaterializer +import akka.stream.scaladsl.{Sink, Source} import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} import scala.concurrent.Await @@ -10,25 +12,17 @@ import scala.concurrent.duration.Duration class EchoSpec extends WordSpec with Matchers with BeforeAndAfterAll with Directives { implicit val system = ActorSystem(getClass.getSimpleName) + implicit val mat = ActorMaterializer() "Echo" should { "handle" in { - val html = """ - | - | - | Who am i - | - | - |

Current User

- |

a.b

- |
- |

GET http://a.b

- |

User-Agent: mock

- | - | - |""".stripMargin - val future = Echo(extractHost).apply(HttpRequest(uri = "http://a.b", headers = List(`User-Agent`("mock")))) - Await.result(future, Duration.Inf) shouldBe HttpResponse(entity = HttpEntity(ContentTypes.`text/html(UTF-8)`, html)) + val future = Source + .single(HttpRequest(uri = "http://a.b", headers = List(`User-Agent`("mock")))) + .via(Echo()) + .runWith(Sink.head) + Await.result(future, Duration.Inf) shouldBe HttpResponse( + entity = HttpEntity(ContentTypes.`application/json`, """{"body":"","headers":["User-Agent: mock"],"method":"GET","uri":"http://a.b"}""") + ) } } diff --git a/src/test/scala/fun/zhongl/passport/ForwardSpec.scala b/src/test/scala/fun/zhongl/passport/ForwardSpec.scala deleted file mode 100644 index 9ca65a7..0000000 --- a/src/test/scala/fun/zhongl/passport/ForwardSpec.scala +++ /dev/null @@ -1,90 +0,0 @@ -package fun.zhongl.passport - -import akka.actor.ActorSystem -import akka.http.javadsl.model -import akka.http.scaladsl.TimeoutAccess -import akka.http.scaladsl.model.StatusCodes._ -import akka.http.scaladsl.model.headers._ -import akka.http.scaladsl.model.{HttpRequest, HttpResponse, Uri} -import akka.japi -import fun.zhongl.passport.Forward.{DefaultRewrite, Rewrite} -import fun.zhongl.passport.NetworkInterfaces._ -import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} - -import scala.concurrent.Await -import scala.concurrent.duration._ - -class ForwardSpec extends WordSpec with Matchers with BeforeAndAfterAll { - - implicit val system = ActorSystem(getClass.getSimpleName) - - private val maybeAddress = findFirstNetworkInterfaceHasInet4Address.flatMap(i => localAddress(i.getName)) - - "Forward" should { - "stop recursive forward" in { - maybeAddress.foreach { addr => - val future = Forward.apply.apply(HttpRequest(headers = List(`X-Forwarded-For`(addr)))) - Await.result(future, Duration.Inf) shouldBe HttpResponse(LoopDetected, entity = s"Loop detected: $addr") - } - } - - "complain missing host" in { - Await.result(Forward.apply.apply(HttpRequest()), Duration.Inf) shouldBe HttpResponse(BadRequest, entity = "Missing host header") - } - - "add forwarded for" in { - maybeAddress.foreach { addr => - val host = Host(Uri.Host("a.b")) - val client = "192.168.2.1" - - val rewrite = List(`Remote-Address`(client), host) - .foldLeft[Rewrite](DefaultRewrite(None, addr, None, None, List.empty))(_.update(_)) - - val req = rewrite(HttpRequest(uri = "http://b.c")) - - req shouldBe HttpRequest(uri = s"http://${host.host}", headers = List(host, `X-Forwarded-For`(client, addr))) - } - } - - "append forwarded for" in { - maybeAddress.foreach { addr => - val host = Host(Uri.Host("a.b")) - val client = "192.168.2.67" - val proxy = "192.168.2.1" - - val rewrite = List(host, `X-Forwarded-For`(client, proxy)) - .foldLeft[Rewrite](DefaultRewrite(None, addr, None, None, List.empty))(_.update(_)) - - val req = rewrite(HttpRequest(uri = "http://b.c")) - req shouldBe HttpRequest(uri = s"http://${host.host}", headers = List(host, `X-Forwarded-For`(client, proxy, addr))) - } - } - - "complain missing remote address header" in { - val future = Forward.apply.apply(HttpRequest(headers = List(Host(Uri.Host("a.b"))))) - Await.result(future, Duration.Inf) shouldBe HttpResponse(InternalServerError, entity = "Missing remote address") - } - - "complain error cause" in { - Await.result(Forward.apply.apply(null), Duration.Inf) shouldBe HttpResponse(InternalServerError, entity = "java.lang.NullPointerException") - } - - "exclude Timeout-Access header" in { - maybeAddress.foreach { addr => - val ta = new TimeoutAccess { - override def timeout: Duration = 1.hour - override def updateTimeout(timeout: Duration): Unit = {} - override def updateHandler(handler: HttpRequest => HttpResponse): Unit = {} - override def update(timeout: Duration, handler: HttpRequest => HttpResponse): Unit = {} - override def updateHandler(handler: japi.Function[model.HttpRequest, model.HttpResponse]): Unit = {} - override def update(timeout: Duration, handler: japi.Function[model.HttpRequest, model.HttpResponse]): Unit = {} - } - val rewrite = DefaultRewrite(None, addr, None, None, List.empty) - rewrite.update(`Timeout-Access`(ta)) shouldBe rewrite - } - } - - } - - override protected def afterAll(): Unit = system.terminate() -} diff --git a/src/test/scala/fun/zhongl/passport/HandlersSpec.scala b/src/test/scala/fun/zhongl/passport/HandlersSpec.scala index d334c6e..d8a58e0 100644 --- a/src/test/scala/fun/zhongl/passport/HandlersSpec.scala +++ b/src/test/scala/fun/zhongl/passport/HandlersSpec.scala @@ -1,34 +1,23 @@ package fun.zhongl.passport -import akka.NotUsed import akka.actor.ActorSystem -import akka.http.scaladsl.model.{HttpRequest, HttpResponse} -import akka.http.scaladsl.util.FastFuture -import akka.stream.scaladsl.{Flow, GraphDSL, Sink, Source} -import akka.stream.{ActorMaterializer, FanOutShape2, Graph} +import akka.http.scaladsl.model.{HttpRequest, HttpResponse, StatusCodes} +import akka.stream.ActorMaterializer +import akka.stream.scaladsl.{Sink, Source} import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} -import zhongl.stream.oauth2.Guard +import scala.concurrent.Await import scala.concurrent.duration.Duration -import scala.concurrent.{Await, Future} class HandlersSpec extends WordSpec with Matchers with BeforeAndAfterAll { implicit val system = ActorSystem(getClass.getSimpleName) implicit val mat = ActorMaterializer() "Handlers" should { - "prepend guard" in { - val res = HttpResponse() - val guard: Graph[Guard.Shape, NotUsed] = GraphDSL.create() { implicit b => - val f = b.add(Flow.fromFunction[HttpRequest, HttpRequest](identity)) - val e = b.add(Source.empty[Future[HttpResponse]]) - - new FanOutShape2(f.in, f.out, e.out) - } - - val flow = Handlers.prepend(guard, _ => FastFuture.successful(res)) + "create a flow with guard" in { + val flow = Handle(None) val future = Source.single(HttpRequest()).via(flow).runWith(Sink.head) - Await.result(future, Duration.Inf) shouldBe res + Await.result(future, Duration.Inf) shouldBe HttpResponse(StatusCodes.Unauthorized) } } diff --git a/src/test/scala/fun/zhongl/passport/RewriteSpec.scala b/src/test/scala/fun/zhongl/passport/RewriteSpec.scala new file mode 100644 index 0000000..559c99b --- /dev/null +++ b/src/test/scala/fun/zhongl/passport/RewriteSpec.scala @@ -0,0 +1,64 @@ +package fun.zhongl.passport + +import java.net.InetAddress + +import akka.actor.ActorSystem +import akka.http.scaladsl.TimeoutAccess +import akka.http.scaladsl.model.headers._ +import akka.http.scaladsl.model.{HttpRequest, RemoteAddress} +import fun.zhongl.passport.NetworkInterfaces._ +import org.scalamock.scalatest.MockFactory +import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} + +class RewriteSpec extends WordSpec with Matchers with BeforeAndAfterAll with MockFactory { + + private implicit val system = ActorSystem(getClass.getSimpleName) + + private val local = RemoteAddress(InetAddress.getLoopbackAddress) + + "Rewrite" should { + "complain missing host" in { + intercept[Rewrite.MissingHostException.type] { + Rewrite.HostOfUri().apply(HttpRequest()) + } + } + + "stop recursive forward" in { + intercept[Rewrite.LoopDetectException] { + Rewrite.Forwarded(local).accumulate(`X-Forwarded-For`(local)) + } + } + + "add forwarded for" in { + val client = "192.168.2.1" + + Rewrite.Forwarded(local).accumulate(`Remote-Address`(client)) match { + case (None, f) => f.apply(HttpRequest()).headers shouldBe List(`X-Forwarded-For`(client, local)) + } + } + + "append forwarded for" in { + val client = "192.168.2.67" + val proxy = "192.168.2.1" + + Rewrite.Forwarded(local).accumulate(`X-Forwarded-For`(client, proxy)) match { + case (None, f) => f.apply(HttpRequest()).headers shouldBe List(`X-Forwarded-For`(client, proxy, local)) + } + } + + "complain missing remote address header" in { + intercept[Rewrite.MissingRemoteAddressException.type] { + Rewrite.Forwarded(local).apply(HttpRequest()) + } + } + + "exclude Timeout-Access header" in { + Rewrite.IgnoreTimeoutAccess.accumulate(`Timeout-Access`(mock[TimeoutAccess])) match { + case (None, _) => + } + } + + } + + override protected def afterAll(): Unit = system.terminate() +} From 0ac876269545a09565959ec22c332d795a49e3fa Mon Sep 17 00:00:00 2001 From: zhongl Date: Tue, 19 Feb 2019 10:19:44 +0800 Subject: [PATCH 06/24] Improve coverage. --- src/main/scala/fun/zhongl/passport/Rewrite.scala | 2 -- .../scala/fun/zhongl/passport/RewriteSpec.scala | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/scala/fun/zhongl/passport/Rewrite.scala b/src/main/scala/fun/zhongl/passport/Rewrite.scala index 5059cd3..e16a554 100644 --- a/src/main/scala/fun/zhongl/passport/Rewrite.scala +++ b/src/main/scala/fun/zhongl/passport/Rewrite.scala @@ -46,8 +46,6 @@ object Rewrite { }) } - private def apply(action: Action): Shape = Flow[HttpRequest].map(doRewrite(_, action)) - private def doRewrite(req: HttpRequest, action: Action) = { val (hs, rewrite) = req.headers.foldLeft((List.empty[HttpHeader], action)) { case ((acc, r), h) => diff --git a/src/test/scala/fun/zhongl/passport/RewriteSpec.scala b/src/test/scala/fun/zhongl/passport/RewriteSpec.scala index 559c99b..7fb83d1 100644 --- a/src/test/scala/fun/zhongl/passport/RewriteSpec.scala +++ b/src/test/scala/fun/zhongl/passport/RewriteSpec.scala @@ -6,17 +6,33 @@ import akka.actor.ActorSystem import akka.http.scaladsl.TimeoutAccess import akka.http.scaladsl.model.headers._ import akka.http.scaladsl.model.{HttpRequest, RemoteAddress} +import akka.stream.ActorMaterializer +import akka.stream.scaladsl.{Sink, Source} import fun.zhongl.passport.NetworkInterfaces._ import org.scalamock.scalatest.MockFactory import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} +import scala.concurrent.Await +import scala.concurrent.duration.Duration + class RewriteSpec extends WordSpec with Matchers with BeforeAndAfterAll with MockFactory { private implicit val system = ActorSystem(getClass.getSimpleName) + private implicit val mat = ActorMaterializer() private val local = RemoteAddress(InetAddress.getLoopbackAddress) "Rewrite" should { + "do normal" in { + val f = Source + .single(HttpRequest(headers = List(Host("localhost"), `Remote-Address`(local)))) + .via(Rewrite(Option(Source.single(identity)), Rewrite.Forwarded(local), Rewrite.IgnoreTimeoutAccess)) + .runWith(Sink.head) + Await.result(f, Duration.Inf) shouldBe HttpRequest( + uri = "//localhost/", headers = List(`X-Forwarded-For`(local, local),Host("localhost")) + ) + } + "complain missing host" in { intercept[Rewrite.MissingHostException.type] { Rewrite.HostOfUri().apply(HttpRequest()) From 30496679a544e49ba67dd8c43eaebe391fc42dad Mon Sep 17 00:00:00 2001 From: zhongl Date: Tue, 19 Feb 2019 10:47:14 +0800 Subject: [PATCH 07/24] Clean code. --- src/main/scala/fun/zhongl/passport/Docker.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/scala/fun/zhongl/passport/Docker.scala b/src/main/scala/fun/zhongl/passport/Docker.scala index 9a42cff..f6b3461 100644 --- a/src/main/scala/fun/zhongl/passport/Docker.scala +++ b/src/main/scala/fun/zhongl/passport/Docker.scala @@ -22,7 +22,7 @@ import akka.NotUsed import akka.actor.ActorSystem import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport import akka.http.scaladsl.model.HttpEntity.Chunked -import akka.http.scaladsl.model.Uri.{Authority, Path} +import akka.http.scaladsl.model.Uri.Path import akka.http.scaladsl.model._ import akka.http.scaladsl.settings.{ClientConnectionSettings, ConnectionPoolSettings} import akka.http.scaladsl.unmarshalling.{Unmarshal, Unmarshaller} @@ -76,10 +76,10 @@ object Docker { def apply(host: String = fromEnv)(implicit system: ActorSystem): Docker = { Uri(host) match { - case u @ Uri("unix", _, Path(p), _, _) => + case Uri("unix", _, Path(p), _, _) => val transport = new UnixSocketTransport(new File(p)) val settings = ConnectionPoolSettings(system).withTransport(transport) - new Docker(u.copy(scheme = "http", authority = Authority(Uri.Host("localhost"))), settings) + new Docker(Uri("http://localhost"), settings) case u => new Docker(u.copy(scheme = "http"), ConnectionPoolSettings(system)) } From a4cea4c51c34db07e274aab16d483af35eab58d1 Mon Sep 17 00:00:00 2001 From: zhongl Date: Tue, 19 Feb 2019 10:49:57 +0800 Subject: [PATCH 08/24] Clean code. --- src/test/scala/fun/zhongl/passport/DynamicSpec.scala | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/test/scala/fun/zhongl/passport/DynamicSpec.scala b/src/test/scala/fun/zhongl/passport/DynamicSpec.scala index bc845cc..88f9a71 100644 --- a/src/test/scala/fun/zhongl/passport/DynamicSpec.scala +++ b/src/test/scala/fun/zhongl/passport/DynamicSpec.scala @@ -1,7 +1,6 @@ package fun.zhongl.passport import akka.actor.ActorSystem import akka.http.scaladsl.Http -import akka.http.scaladsl.Http.ServerBinding import akka.http.scaladsl.model.ContentTypes import akka.http.scaladsl.model.HttpEntity.{Chunk, Chunked} import akka.http.scaladsl.model.headers.Host @@ -21,7 +20,7 @@ class DynamicSpec extends WordSpec with Matchers with BeforeAndAfterAll with Dir private val docker = Docker("tcp://localhost:12306") - var bound: ServerBinding = _ + private val bound = Await.result(Http().bindAndHandle(mockDockerDaemon, "localhost", 12306), 1.second) "Dynamic" should { "by docker local" in { @@ -49,11 +48,6 @@ class DynamicSpec extends WordSpec with Matchers with BeforeAndAfterAll with Dir ) } - override protected def beforeAll(): Unit = { - val f = Http().bindAndHandle(mockDockerDaemon, "localhost", 12306) - bound = Await.result(f, 1.second) - } - override protected def afterAll(): Unit = { bound.terminate(1.second) system.terminate() From 011feebb38b8ab18473becdbf4d9b70fb0d7a8dc Mon Sep 17 00:00:00 2001 From: zhongl Date: Tue, 19 Feb 2019 11:11:51 +0800 Subject: [PATCH 09/24] Clean code. --- docker-compose.yml | 25 +++---------------- .../scala/fun/zhongl/passport/Handle.scala | 2 +- .../{JwtCookies.scala => JwtCookie.scala} | 6 ++--- .../fun/zhongl/passport/PlatformsSpec.scala | 2 +- 4 files changed, 8 insertions(+), 27 deletions(-) rename src/main/scala/fun/zhongl/passport/{JwtCookies.scala => JwtCookie.scala} (83%) diff --git a/docker-compose.yml b/docker-compose.yml index 7cb8df0..52d1330 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,28 +1,12 @@ version: '3' services: - gateway: - image: traefik:latest - command: - - "--api" - - "--entrypoints=Name:http Address::80 Redirect.EntryPoint:https" - - "--entrypoints=Name:https Address::443 TLS" - - "--defaultentrypoints=http,https" - - "--docker" - - "--docker.watch" - - "--docker.domain=${DOMAIN}" - - "--accesslog" - - "--traefikLog" - volumes: - - /var/run/docker.sock:/var/run/docker.sock - ports: - - 80:80 - - 443:443 - passport: image: zhongl/passport:latest command: - - "--docker:local" + - "-d:docker" + volumes: + - /var/run/docker.sock:/var/run/docker.sock environment: JAVA_TOOL_OPTIONS: -Dconfig.file=/app.conf DOMAIN: $DOMAIN @@ -33,8 +17,5 @@ services: image: zhongl/passport:latest command: - "-e" - environment: - JAVA_TOOL_OPTIONS: -Dconfig.file=/app.conf - DOMAIN: $DOMAIN labels: passport.rule: "www.${DOMAIN} > :8080" diff --git a/src/main/scala/fun/zhongl/passport/Handle.scala b/src/main/scala/fun/zhongl/passport/Handle.scala index 894a4be..d34d30d 100644 --- a/src/main/scala/fun/zhongl/passport/Handle.scala +++ b/src/main/scala/fun/zhongl/passport/Handle.scala @@ -35,7 +35,7 @@ object Handle { implicit val mat = ActorMaterializer() val config = system.settings.config - val jc = JwtCookies.load(config) + val jc = JwtCookie.apply(config) val ignore = jc.unapply(_: HttpRequest).isDefined val plugin = Platforms.bound(config) val local = Try { config.getString("interface") }.toOption diff --git a/src/main/scala/fun/zhongl/passport/JwtCookies.scala b/src/main/scala/fun/zhongl/passport/JwtCookie.scala similarity index 83% rename from src/main/scala/fun/zhongl/passport/JwtCookies.scala rename to src/main/scala/fun/zhongl/passport/JwtCookie.scala index 1d44579..601dfe6 100644 --- a/src/main/scala/fun/zhongl/passport/JwtCookies.scala +++ b/src/main/scala/fun/zhongl/passport/JwtCookie.scala @@ -23,11 +23,11 @@ import zhongl.stream.oauth2.JwtCookie import scala.concurrent.duration.FiniteDuration -object JwtCookies { - def load(conf: Config): JwtCookie = { +object JwtCookie { + def apply(conf: Config): JwtCookie = { val unit = TimeUnit.DAYS val days = conf.getDuration("cookie.expires_in", unit) val algorithm = Algorithm.HMAC256(conf.getString("cookie.secret")) - JwtCookie(conf.getString("cookie.name"), conf.getString("cookie.domain"), algorithm, FiniteDuration(days, unit)) + zhongl.stream.oauth2.JwtCookie(conf.getString("cookie.name"), conf.getString("cookie.domain"), algorithm, FiniteDuration(days, unit)) } } diff --git a/src/test/scala/fun/zhongl/passport/PlatformsSpec.scala b/src/test/scala/fun/zhongl/passport/PlatformsSpec.scala index 46c83c0..2ff329c 100644 --- a/src/test/scala/fun/zhongl/passport/PlatformsSpec.scala +++ b/src/test/scala/fun/zhongl/passport/PlatformsSpec.scala @@ -16,7 +16,7 @@ import scala.concurrent.duration.Duration class PlatformsSpec extends WordSpec with Matchers with BeforeAndAfterAll { implicit val system = ActorSystem(getClass.getSimpleName) - private val jc = JwtCookies.load(ConfigFactory.parseString(""" + private val jc = JwtCookie.apply(ConfigFactory.parseString(""" |include "common.conf" |cookie { | domain = ".a.b" From c7300f789a4fee8c68b8ab64b6d451752d8eadb5 Mon Sep 17 00:00:00 2001 From: zhongl Date: Tue, 19 Feb 2019 11:13:51 +0800 Subject: [PATCH 10/24] Rename package. --- build.sbt | 2 +- .../zhongl/passport/CachedLatest.scala | 3 +- .../zhongl/passport/CommandLine.scala | 3 +- .../zhongl/passport/Complainant.scala | 3 +- .../{fun => }/zhongl/passport/Docker.scala | 5 ++-- .../{fun => }/zhongl/passport/Dynamic.scala | 3 +- .../{fun => }/zhongl/passport/Echo.scala | 2 +- .../{fun => }/zhongl/passport/Forward.scala | 3 +- .../{fun => }/zhongl/passport/Handle.scala | 5 ++-- .../{fun => }/zhongl/passport/JwtCookie.scala | 3 +- .../{fun => }/zhongl/passport/Main.scala | 4 +-- .../zhongl/passport/NetworkInterfaces.scala | 3 +- .../{fun => }/zhongl/passport/Platforms.scala | 2 +- .../{fun => }/zhongl/passport/Rewrite.scala | 3 +- .../fun/zhongl/passport/CommandLineSpec.scala | 12 -------- .../zhongl/passport/CommandLineSpec.scala | 29 +++++++++++++++++++ .../zhongl/passport/DynamicSpec.scala | 19 +++++++++++- .../{fun => }/zhongl/passport/EchoSpec.scala | 19 +++++++++++- .../zhongl/passport/HandlersSpec.scala | 18 +++++++++++- .../zhongl/passport/PlatformsSpec.scala | 21 ++++++++++++-- .../zhongl/passport/RewriteSpec.scala | 20 +++++++++++-- 21 files changed, 146 insertions(+), 36 deletions(-) rename src/main/scala/{fun => }/zhongl/passport/CachedLatest.scala (98%) rename src/main/scala/{fun => }/zhongl/passport/CommandLine.scala (98%) rename src/main/scala/{fun => }/zhongl/passport/Complainant.scala (96%) rename src/main/scala/{fun => }/zhongl/passport/Docker.scala (98%) rename src/main/scala/{fun => }/zhongl/passport/Dynamic.scala (98%) rename src/main/scala/{fun => }/zhongl/passport/Echo.scala (98%) rename src/main/scala/{fun => }/zhongl/passport/Forward.scala (97%) rename src/main/scala/{fun => }/zhongl/passport/Handle.scala (96%) rename src/main/scala/{fun => }/zhongl/passport/JwtCookie.scala (97%) rename src/main/scala/{fun => }/zhongl/passport/Main.scala (97%) rename src/main/scala/{fun => }/zhongl/passport/NetworkInterfaces.scala (98%) rename src/main/scala/{fun => }/zhongl/passport/Platforms.scala (99%) rename src/main/scala/{fun => }/zhongl/passport/Rewrite.scala (99%) delete mode 100644 src/test/scala/fun/zhongl/passport/CommandLineSpec.scala create mode 100644 src/test/scala/zhongl/passport/CommandLineSpec.scala rename src/test/scala/{fun => }/zhongl/passport/DynamicSpec.scala (74%) rename src/test/scala/{fun => }/zhongl/passport/EchoSpec.scala (63%) rename src/test/scala/{fun => }/zhongl/passport/HandlersSpec.scala (56%) rename src/test/scala/{fun => }/zhongl/passport/PlatformsSpec.scala (84%) rename src/test/scala/{fun => }/zhongl/passport/RewriteSpec.scala (78%) diff --git a/build.sbt b/build.sbt index c98b983..8388655 100644 --- a/build.sbt +++ b/build.sbt @@ -13,7 +13,7 @@ lazy val root = (project in file(".")) version := "0.0.1", scalacOptions += "-deprecation", resolvers += "jitpack" at "https://jitpack.io", - mainClass in Compile := Some("fun.zhongl.passport.Main"), + mainClass in Compile := Some("zhongl.passport.Main"), dockerBaseImage := "openjdk:8-alpine", dockerEnvVars := Map("DOCKER_HOST" -> "unix:///var/run/docker.sock"), dockerExposedPorts := Seq(8080), diff --git a/src/main/scala/fun/zhongl/passport/CachedLatest.scala b/src/main/scala/zhongl/passport/CachedLatest.scala similarity index 98% rename from src/main/scala/fun/zhongl/passport/CachedLatest.scala rename to src/main/scala/zhongl/passport/CachedLatest.scala index 8f00e0e..2274aa3 100644 --- a/src/main/scala/fun/zhongl/passport/CachedLatest.scala +++ b/src/main/scala/zhongl/passport/CachedLatest.scala @@ -14,7 +14,8 @@ * limitations under the License. */ -package fun.zhongl.passport +package zhongl.passport + import akka.stream.stage.{GraphStage, GraphStageLogic, InHandler, OutHandler} import akka.stream.{Attributes, FlowShape, Inlet, Outlet} diff --git a/src/main/scala/fun/zhongl/passport/CommandLine.scala b/src/main/scala/zhongl/passport/CommandLine.scala similarity index 98% rename from src/main/scala/fun/zhongl/passport/CommandLine.scala rename to src/main/scala/zhongl/passport/CommandLine.scala index cfc7616..c6f020e 100644 --- a/src/main/scala/fun/zhongl/passport/CommandLine.scala +++ b/src/main/scala/zhongl/passport/CommandLine.scala @@ -14,7 +14,8 @@ * limitations under the License. */ -package fun.zhongl.passport +package zhongl.passport + import scopt.OptionParser object CommandLine { diff --git a/src/main/scala/fun/zhongl/passport/Complainant.scala b/src/main/scala/zhongl/passport/Complainant.scala similarity index 96% rename from src/main/scala/fun/zhongl/passport/Complainant.scala rename to src/main/scala/zhongl/passport/Complainant.scala index f197abd..cc192ba 100644 --- a/src/main/scala/fun/zhongl/passport/Complainant.scala +++ b/src/main/scala/zhongl/passport/Complainant.scala @@ -14,7 +14,8 @@ * limitations under the License. */ -package fun.zhongl.passport +package zhongl.passport + import akka.http.scaladsl.model.HttpResponse import scala.util.control.NoStackTrace diff --git a/src/main/scala/fun/zhongl/passport/Docker.scala b/src/main/scala/zhongl/passport/Docker.scala similarity index 98% rename from src/main/scala/fun/zhongl/passport/Docker.scala rename to src/main/scala/zhongl/passport/Docker.scala index f6b3461..3338b1c 100644 --- a/src/main/scala/fun/zhongl/passport/Docker.scala +++ b/src/main/scala/zhongl/passport/Docker.scala @@ -14,7 +14,8 @@ * limitations under the License. */ -package fun.zhongl.passport +package zhongl.passport + import java.io.File import java.net.InetSocketAddress @@ -31,8 +32,8 @@ import akka.stream.ActorMaterializer import akka.stream.alpakka.unixdomainsocket.scaladsl.UnixDomainSocket import akka.stream.scaladsl._ import akka.util.ByteString -import fun.zhongl.passport.Docker.{Container, Service} import spray.json._ +import zhongl.passport.Docker.{Container, Service} import scala.concurrent.Future diff --git a/src/main/scala/fun/zhongl/passport/Dynamic.scala b/src/main/scala/zhongl/passport/Dynamic.scala similarity index 98% rename from src/main/scala/fun/zhongl/passport/Dynamic.scala rename to src/main/scala/zhongl/passport/Dynamic.scala index 246c29f..60f8362 100644 --- a/src/main/scala/fun/zhongl/passport/Dynamic.scala +++ b/src/main/scala/zhongl/passport/Dynamic.scala @@ -14,7 +14,8 @@ * limitations under the License. */ -package fun.zhongl.passport +package zhongl.passport + import java.util.regex.Pattern import akka.NotUsed diff --git a/src/main/scala/fun/zhongl/passport/Echo.scala b/src/main/scala/zhongl/passport/Echo.scala similarity index 98% rename from src/main/scala/fun/zhongl/passport/Echo.scala rename to src/main/scala/zhongl/passport/Echo.scala index 322c847..b2c011c 100644 --- a/src/main/scala/fun/zhongl/passport/Echo.scala +++ b/src/main/scala/zhongl/passport/Echo.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package fun.zhongl.passport +package zhongl.passport import akka.NotUsed import akka.actor.ActorSystem diff --git a/src/main/scala/fun/zhongl/passport/Forward.scala b/src/main/scala/zhongl/passport/Forward.scala similarity index 97% rename from src/main/scala/fun/zhongl/passport/Forward.scala rename to src/main/scala/zhongl/passport/Forward.scala index ee93135..1a83563 100644 --- a/src/main/scala/fun/zhongl/passport/Forward.scala +++ b/src/main/scala/zhongl/passport/Forward.scala @@ -14,7 +14,8 @@ * limitations under the License. */ -package fun.zhongl.passport +package zhongl.passport + import akka.NotUsed import akka.actor.ActorSystem import akka.http.scaladsl.Http diff --git a/src/main/scala/fun/zhongl/passport/Handle.scala b/src/main/scala/zhongl/passport/Handle.scala similarity index 96% rename from src/main/scala/fun/zhongl/passport/Handle.scala rename to src/main/scala/zhongl/passport/Handle.scala index d34d30d..35b5096 100644 --- a/src/main/scala/fun/zhongl/passport/Handle.scala +++ b/src/main/scala/zhongl/passport/Handle.scala @@ -14,14 +14,15 @@ * limitations under the License. */ -package fun.zhongl.passport +package zhongl.passport + import akka.NotUsed import akka.actor.ActorSystem import akka.http.scaladsl.model.headers.Host import akka.http.scaladsl.model.{HttpRequest, HttpResponse, StatusCodes} import akka.stream.scaladsl.{Flow, GraphDSL, Merge, Source} import akka.stream.{ActorMaterializer, FlowShape} -import fun.zhongl.passport.Rewrite.{Forwarded, IgnoreTimeoutAccess} +import zhongl.passport.Rewrite.IgnoreTimeoutAccess import zhongl.stream.oauth2.Guard import scala.concurrent.Future diff --git a/src/main/scala/fun/zhongl/passport/JwtCookie.scala b/src/main/scala/zhongl/passport/JwtCookie.scala similarity index 97% rename from src/main/scala/fun/zhongl/passport/JwtCookie.scala rename to src/main/scala/zhongl/passport/JwtCookie.scala index 601dfe6..42075bf 100644 --- a/src/main/scala/fun/zhongl/passport/JwtCookie.scala +++ b/src/main/scala/zhongl/passport/JwtCookie.scala @@ -14,7 +14,8 @@ * limitations under the License. */ -package fun.zhongl.passport +package zhongl.passport + import java.util.concurrent.TimeUnit import com.auth0.jwt.algorithms.Algorithm diff --git a/src/main/scala/fun/zhongl/passport/Main.scala b/src/main/scala/zhongl/passport/Main.scala similarity index 97% rename from src/main/scala/fun/zhongl/passport/Main.scala rename to src/main/scala/zhongl/passport/Main.scala index d15ac1c..aab5d7c 100644 --- a/src/main/scala/fun/zhongl/passport/Main.scala +++ b/src/main/scala/zhongl/passport/Main.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package fun.zhongl.passport +package zhongl.passport import akka.NotUsed import akka.actor.{ActorSystem, Terminated} @@ -24,7 +24,7 @@ import akka.http.scaladsl.model._ import akka.http.scaladsl.server.Directives import akka.stream.ActorMaterializer import akka.stream.scaladsl.Flow -import fun.zhongl.passport.CommandLine._ +import zhongl.passport.CommandLine._ import scala.concurrent.duration._ import scala.concurrent.{Await, Future, Promise} diff --git a/src/main/scala/fun/zhongl/passport/NetworkInterfaces.scala b/src/main/scala/zhongl/passport/NetworkInterfaces.scala similarity index 98% rename from src/main/scala/fun/zhongl/passport/NetworkInterfaces.scala rename to src/main/scala/zhongl/passport/NetworkInterfaces.scala index 4580fb6..2df74fe 100644 --- a/src/main/scala/fun/zhongl/passport/NetworkInterfaces.scala +++ b/src/main/scala/zhongl/passport/NetworkInterfaces.scala @@ -14,7 +14,8 @@ * limitations under the License. */ -package fun.zhongl.passport +package zhongl.passport + import java.net.{Inet4Address, InetAddress, NetworkInterface} import akka.http.scaladsl.model.RemoteAddress diff --git a/src/main/scala/fun/zhongl/passport/Platforms.scala b/src/main/scala/zhongl/passport/Platforms.scala similarity index 99% rename from src/main/scala/fun/zhongl/passport/Platforms.scala rename to src/main/scala/zhongl/passport/Platforms.scala index b81d773..9d81b69 100644 --- a/src/main/scala/fun/zhongl/passport/Platforms.scala +++ b/src/main/scala/zhongl/passport/Platforms.scala @@ -14,7 +14,7 @@ * limitations under the License. */ -package fun.zhongl.passport +package zhongl.passport import akka.actor.ActorSystem import akka.http.scaladsl.model.ContentTypes.`text/html(UTF-8)` diff --git a/src/main/scala/fun/zhongl/passport/Rewrite.scala b/src/main/scala/zhongl/passport/Rewrite.scala similarity index 99% rename from src/main/scala/fun/zhongl/passport/Rewrite.scala rename to src/main/scala/zhongl/passport/Rewrite.scala index e16a554..8eb077a 100644 --- a/src/main/scala/fun/zhongl/passport/Rewrite.scala +++ b/src/main/scala/zhongl/passport/Rewrite.scala @@ -14,7 +14,8 @@ * limitations under the License. */ -package fun.zhongl.passport +package zhongl.passport + import akka.NotUsed import akka.http.scaladsl.model._ import akka.http.scaladsl.model.headers._ diff --git a/src/test/scala/fun/zhongl/passport/CommandLineSpec.scala b/src/test/scala/fun/zhongl/passport/CommandLineSpec.scala deleted file mode 100644 index 695eb60..0000000 --- a/src/test/scala/fun/zhongl/passport/CommandLineSpec.scala +++ /dev/null @@ -1,12 +0,0 @@ -package fun.zhongl.passport -import org.scalatest.{Matchers, WordSpec} - -class CommandLineSpec extends WordSpec with Matchers { - val default = CommandLine.Opt() - - "CommandLine" should { - "parse opt" in { - CommandLine.parser.parse(Seq(), default) shouldBe Some(default) - } - } -} diff --git a/src/test/scala/zhongl/passport/CommandLineSpec.scala b/src/test/scala/zhongl/passport/CommandLineSpec.scala new file mode 100644 index 0000000..a5b6b10 --- /dev/null +++ b/src/test/scala/zhongl/passport/CommandLineSpec.scala @@ -0,0 +1,29 @@ +/* + * Copyright 2019 Zhong Lunfu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zhongl.passport + +import org.scalatest.{Matchers, WordSpec} + +class CommandLineSpec extends WordSpec with Matchers { + val default = CommandLine.Opt() + + "CommandLine" should { + "parse opt" in { + CommandLine.parser.parse(Seq(), default) shouldBe Some(default) + } + } +} diff --git a/src/test/scala/fun/zhongl/passport/DynamicSpec.scala b/src/test/scala/zhongl/passport/DynamicSpec.scala similarity index 74% rename from src/test/scala/fun/zhongl/passport/DynamicSpec.scala rename to src/test/scala/zhongl/passport/DynamicSpec.scala index 88f9a71..a7e1f73 100644 --- a/src/test/scala/fun/zhongl/passport/DynamicSpec.scala +++ b/src/test/scala/zhongl/passport/DynamicSpec.scala @@ -1,4 +1,21 @@ -package fun.zhongl.passport +/* + * Copyright 2019 Zhong Lunfu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zhongl.passport + import akka.actor.ActorSystem import akka.http.scaladsl.Http import akka.http.scaladsl.model.ContentTypes diff --git a/src/test/scala/fun/zhongl/passport/EchoSpec.scala b/src/test/scala/zhongl/passport/EchoSpec.scala similarity index 63% rename from src/test/scala/fun/zhongl/passport/EchoSpec.scala rename to src/test/scala/zhongl/passport/EchoSpec.scala index d02b6b2..b0e0d20 100644 --- a/src/test/scala/fun/zhongl/passport/EchoSpec.scala +++ b/src/test/scala/zhongl/passport/EchoSpec.scala @@ -1,4 +1,21 @@ -package fun.zhongl.passport +/* + * Copyright 2019 Zhong Lunfu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zhongl.passport + import akka.actor.ActorSystem import akka.http.scaladsl.model.headers.`User-Agent` import akka.http.scaladsl.model.{ContentTypes, HttpEntity, HttpRequest, HttpResponse} diff --git a/src/test/scala/fun/zhongl/passport/HandlersSpec.scala b/src/test/scala/zhongl/passport/HandlersSpec.scala similarity index 56% rename from src/test/scala/fun/zhongl/passport/HandlersSpec.scala rename to src/test/scala/zhongl/passport/HandlersSpec.scala index d8a58e0..7985069 100644 --- a/src/test/scala/fun/zhongl/passport/HandlersSpec.scala +++ b/src/test/scala/zhongl/passport/HandlersSpec.scala @@ -1,4 +1,20 @@ -package fun.zhongl.passport +/* + * Copyright 2019 Zhong Lunfu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zhongl.passport import akka.actor.ActorSystem import akka.http.scaladsl.model.{HttpRequest, HttpResponse, StatusCodes} diff --git a/src/test/scala/fun/zhongl/passport/PlatformsSpec.scala b/src/test/scala/zhongl/passport/PlatformsSpec.scala similarity index 84% rename from src/test/scala/fun/zhongl/passport/PlatformsSpec.scala rename to src/test/scala/zhongl/passport/PlatformsSpec.scala index 2ff329c..c50bf0d 100644 --- a/src/test/scala/fun/zhongl/passport/PlatformsSpec.scala +++ b/src/test/scala/zhongl/passport/PlatformsSpec.scala @@ -1,13 +1,30 @@ -package fun.zhongl.passport +/* + * Copyright 2019 Zhong Lunfu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zhongl.passport + import akka.actor.ActorSystem import akka.http.scaladsl.model._ import akka.http.scaladsl.model.headers.{Cookie, Location} import akka.http.scaladsl.util.FastFuture import com.auth0.jwt.JWT import com.typesafe.config.ConfigFactory -import fun.zhongl.passport.Platforms.{Authenticated, Builder, Extractor, Platform} import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} import spray.json._ +import zhongl.passport.Platforms.{Authenticated, Builder, Extractor, Platform} import zhongl.stream.oauth2.{OAuth2, dingtalk, wechat} import scala.concurrent.Await diff --git a/src/test/scala/fun/zhongl/passport/RewriteSpec.scala b/src/test/scala/zhongl/passport/RewriteSpec.scala similarity index 78% rename from src/test/scala/fun/zhongl/passport/RewriteSpec.scala rename to src/test/scala/zhongl/passport/RewriteSpec.scala index 7fb83d1..bb00573 100644 --- a/src/test/scala/fun/zhongl/passport/RewriteSpec.scala +++ b/src/test/scala/zhongl/passport/RewriteSpec.scala @@ -1,4 +1,20 @@ -package fun.zhongl.passport +/* + * Copyright 2019 Zhong Lunfu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zhongl.passport import java.net.InetAddress @@ -8,9 +24,9 @@ import akka.http.scaladsl.model.headers._ import akka.http.scaladsl.model.{HttpRequest, RemoteAddress} import akka.stream.ActorMaterializer import akka.stream.scaladsl.{Sink, Source} -import fun.zhongl.passport.NetworkInterfaces._ import org.scalamock.scalatest.MockFactory import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} +import zhongl.passport.NetworkInterfaces._ import scala.concurrent.Await import scala.concurrent.duration.Duration From a484275054e9e2bf030d0fa7f7d0adca4b667819 Mon Sep 17 00:00:00 2001 From: zhongl Date: Tue, 19 Feb 2019 11:30:11 +0800 Subject: [PATCH 11/24] Fix typo. --- src/main/scala/zhongl/passport/Handle.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scala/zhongl/passport/Handle.scala b/src/main/scala/zhongl/passport/Handle.scala index 35b5096..921eac3 100644 --- a/src/main/scala/zhongl/passport/Handle.scala +++ b/src/main/scala/zhongl/passport/Handle.scala @@ -22,7 +22,7 @@ import akka.http.scaladsl.model.headers.Host import akka.http.scaladsl.model.{HttpRequest, HttpResponse, StatusCodes} import akka.stream.scaladsl.{Flow, GraphDSL, Merge, Source} import akka.stream.{ActorMaterializer, FlowShape} -import zhongl.passport.Rewrite.IgnoreTimeoutAccess +import zhongl.passport.Rewrite._ import zhongl.stream.oauth2.Guard import scala.concurrent.Future From d56f72039d0fbb61e8240b1e66f647cae207afa1 Mon Sep 17 00:00:00 2001 From: zhongl Date: Tue, 19 Feb 2019 16:14:00 +0800 Subject: [PATCH 12/24] Fix permission problem. --- project/plugins.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/project/plugins.sbt b/project/plugins.sbt index 4e13dac..83976c2 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -2,7 +2,7 @@ addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.9.2") addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") -addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.17") +addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.18") addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.5.1") From 79b873b0887b95a388933d719621337e53ae064d Mon Sep 17 00:00:00 2001 From: zhongl Date: Tue, 19 Feb 2019 16:14:26 +0800 Subject: [PATCH 13/24] Fix permission problem. --- build.sbt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/build.sbt b/build.sbt index 8388655..417e5b5 100644 --- a/build.sbt +++ b/build.sbt @@ -1,3 +1,4 @@ + lazy val akkaHttpVersion = "10.1.6" lazy val akkaVersion = "2.5.19" lazy val oauth2Version = "0.1.8" @@ -14,9 +15,12 @@ lazy val root = (project in file(".")) scalacOptions += "-deprecation", resolvers += "jitpack" at "https://jitpack.io", mainClass in Compile := Some("zhongl.passport.Main"), + maintainer in Docker := "zhong.lunfu@gmail.com", dockerBaseImage := "openjdk:8-alpine", dockerEnvVars := Map("DOCKER_HOST" -> "unix:///var/run/docker.sock"), dockerExposedPorts := Seq(8080), + daemonUserUid in Docker := None, + daemonUser in Docker := "root", dockerRepository := sys.props.get("docker.repository"), version in Docker := sys.props.get("docker.tag").getOrElse(version.value), libraryDependencies ++= Seq( From f1501e0c2dc385f9b6610f9f134eb551a68241d2 Mon Sep 17 00:00:00 2001 From: zhongl Date: Tue, 19 Feb 2019 16:15:04 +0800 Subject: [PATCH 14/24] Fix unix domain socket. --- .../scala/zhongl/passport/CachedLatest.scala | 13 +++-- src/main/scala/zhongl/passport/Docker.scala | 48 ++++++------------- src/main/scala/zhongl/passport/Dynamic.scala | 11 ++--- .../scala/zhongl/passport/DynamicSpec.scala | 32 +++++++++---- 4 files changed, 51 insertions(+), 53 deletions(-) diff --git a/src/main/scala/zhongl/passport/CachedLatest.scala b/src/main/scala/zhongl/passport/CachedLatest.scala index 2274aa3..29683e2 100644 --- a/src/main/scala/zhongl/passport/CachedLatest.scala +++ b/src/main/scala/zhongl/passport/CachedLatest.scala @@ -19,23 +19,28 @@ package zhongl.passport import akka.stream.stage.{GraphStage, GraphStageLogic, InHandler, OutHandler} import akka.stream.{Attributes, FlowShape, Inlet, Outlet} -final class CachedLatest[T](latest: T) extends GraphStage[FlowShape[T, T]] { +final class CachedLatest[T] extends GraphStage[FlowShape[T, T]] { val in = Inlet[T]("CachedLatest.in") val out = Outlet[T]("CachedLatest.out") override val shape = FlowShape.of(in, out) override def createLogic(inheritedAttributes: Attributes): GraphStageLogic = new GraphStageLogic(shape) { - private var currentValue: T = latest + private var currentValue: T = _ + private var waitingFirstValue = true setHandlers(in, out, new InHandler with OutHandler { override def onPush(): Unit = { currentValue = grab(in) + if (waitingFirstValue) { + waitingFirstValue = false + if (isAvailable(out)) push(out, currentValue) + } pull(in) } override def onPull(): Unit = { - push(out, currentValue) + if (!waitingFirstValue) push(out, currentValue) } }) @@ -45,5 +50,5 @@ final class CachedLatest[T](latest: T) extends GraphStage[FlowShape[T, T]] { } } object CachedLatest { - def apply[T](latest: T): CachedLatest[T] = new CachedLatest(latest) + def apply[T](): CachedLatest[T] = new CachedLatest() } diff --git a/src/main/scala/zhongl/passport/Docker.scala b/src/main/scala/zhongl/passport/Docker.scala index 3338b1c..a3ce159 100644 --- a/src/main/scala/zhongl/passport/Docker.scala +++ b/src/main/scala/zhongl/passport/Docker.scala @@ -17,54 +17,49 @@ package zhongl.passport import java.io.File -import java.net.InetSocketAddress import akka.NotUsed import akka.actor.ActorSystem +import akka.http.scaladsl.Http import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport import akka.http.scaladsl.model.HttpEntity.Chunked import akka.http.scaladsl.model.Uri.Path import akka.http.scaladsl.model._ -import akka.http.scaladsl.settings.{ClientConnectionSettings, ConnectionPoolSettings} +import akka.http.scaladsl.model.headers.Host import akka.http.scaladsl.unmarshalling.{Unmarshal, Unmarshaller} -import akka.http.scaladsl.{ClientTransport, Http} -import akka.stream.ActorMaterializer +import akka.stream.Materializer import akka.stream.alpakka.unixdomainsocket.scaladsl.UnixDomainSocket import akka.stream.scaladsl._ import akka.util.ByteString import spray.json._ import zhongl.passport.Docker.{Container, Service} -import scala.concurrent.Future - -class Docker(base: Uri, settings: ConnectionPoolSettings)(implicit system: ActorSystem) extends Docker.JsonSupport { - - private implicit val mat = ActorMaterializer() +final class Docker(base: Uri, outgoing: () => Flow[HttpRequest, HttpResponse, _]) extends Docker.JsonSupport { def events(filters: Map[String, List[String]]): Source[ByteString, NotUsed] = { val query = Uri.Query("filters" -> filters.toJson.compactPrint) val request = HttpRequest(uri = base.copy(path = Path / "events").withQuery(query)) - val future = Http().singleRequest(request, settings = settings) Source - .fromFuture(future) + .single(request) + .via(outgoing()) .flatMapConcat { case HttpResponse(StatusCodes.OK, _, Chunked(ContentTypes.`application/json`, chunks), _) => chunks } .map(_.data()) } - def containers[T](filters: Map[String, List[String]]): Flow[T, List[Container], NotUsed] = { + def containers[T](filters: Map[String, List[String]])(implicit mat: Materializer): Flow[T, List[Container], NotUsed] = { list[T, List[Container]](filters, Path / "containers") } - def services[T](filters: Map[String, List[String]]): Flow[T, List[Service], NotUsed] = { + def services[T](filters: Map[String, List[String]])(implicit mat: Materializer): Flow[T, List[Service], NotUsed] = { list[T, List[Service]](filters, Path / "services") } - private def list[A, B](filters: Map[String, List[String]], path: Path)(implicit u: Unmarshaller[ResponseEntity, B]) = { + private def list[A, B](filters: Map[String, List[String]], path: Path)(implicit u: Unmarshaller[ResponseEntity, B], mat: Materializer) = { val query = Uri.Query("filters" -> filters.toJson.compactPrint) val request = HttpRequest(uri = base.copy(path = path).withQuery(query)) - Flow[A].mapAsync(1)(_ => Http().singleRequest(request, settings = settings)).mapAsync(1) { + Flow[A].map(_ => request).via(outgoing()).mapAsync(1) { case HttpResponse(StatusCodes.OK, _, entity, _) => Unmarshal(entity).to[B] } } @@ -78,11 +73,12 @@ object Docker { def apply(host: String = fromEnv)(implicit system: ActorSystem): Docker = { Uri(host) match { case Uri("unix", _, Path(p), _, _) => - val transport = new UnixSocketTransport(new File(p)) - val settings = ConnectionPoolSettings(system).withTransport(transport) - new Docker(Uri("http://localhost"), settings) + val file = new File(p) + val http = Http().clientLayer(Host("localhost")).atop(TLSPlacebo()) + val uds = UnixDomainSocket() + new Docker(Uri("http://localhost"), () => http.join(uds.outgoingConnection(file))) case u => - new Docker(u.copy(scheme = "http"), ConnectionPoolSettings(system)) + throw new IllegalStateException(s"Unsupported $host, it must be started with unix://") } } @@ -94,20 +90,6 @@ object Docker { final case class Service(`ID`: String, `Spec`: Spec) final case class Spec(`Name`: String, `Labels`: Map[String, String]) - private final class UnixSocketTransport(file: File) extends ClientTransport { - override def connectTo(host: String, port: Int, settings: ClientConnectionSettings)( - implicit system: ActorSystem): Flow[ByteString, ByteString, Future[Http.OutgoingConnection]] = { - implicit val ex = system.dispatcher - - UnixDomainSocket() - .outgoingConnection(file) - .mapMaterializedValue(_.map { _ => - val address = InetSocketAddress.createUnresolved(host, port) - Http.OutgoingConnection(address, address) - }) - } - } - trait JsonSupport extends DefaultJsonProtocol with SprayJsonSupport { implicit val specF: JsonFormat[Spec] = jsonFormat2(Spec) implicit val containerF: JsonFormat[Container] = jsonFormat3(Container) diff --git a/src/main/scala/zhongl/passport/Dynamic.scala b/src/main/scala/zhongl/passport/Dynamic.scala index 60f8362..78fa774 100644 --- a/src/main/scala/zhongl/passport/Dynamic.scala +++ b/src/main/scala/zhongl/passport/Dynamic.scala @@ -21,6 +21,7 @@ import java.util.regex.Pattern import akka.NotUsed import akka.http.scaladsl.model.headers.Host import akka.http.scaladsl.model.{HttpResponse, StatusCodes} +import akka.stream.Materializer import akka.stream.scaladsl.{Flow, Source} import akka.util.ByteString @@ -29,7 +30,7 @@ object Dynamic { val filterByLabel = Map("label" -> List(label)) - def by(docker: Docker): String => Source[Host => Host, NotUsed] = { + def by(docker: Docker)(implicit mat: Materializer): String => Source[Host => Host, NotUsed] = { case "docker" => arrange( docker.events(Map("scope" -> List("local"), "type" -> List("container"), "event" -> List("start", "destroy"))), @@ -45,12 +46,10 @@ object Dynamic { private def arrange(events: Source[ByteString, NotUsed], flow: Flow[Any, List[(String, String)], NotUsed]) = { Source - .single(Unit) + .single(ByteString.empty) + .orElse(events) .via(flow) - .map(CachedLatest(_)) - .flatMapConcat { cl => - events.via(flow).via(cl) - } + .via(CachedLatest()) .map(redirect) } diff --git a/src/test/scala/zhongl/passport/DynamicSpec.scala b/src/test/scala/zhongl/passport/DynamicSpec.scala index a7e1f73..cefbaf9 100644 --- a/src/test/scala/zhongl/passport/DynamicSpec.scala +++ b/src/test/scala/zhongl/passport/DynamicSpec.scala @@ -16,14 +16,17 @@ package zhongl.passport +import java.io.File + import akka.actor.ActorSystem import akka.http.scaladsl.Http -import akka.http.scaladsl.model.ContentTypes import akka.http.scaladsl.model.HttpEntity.{Chunk, Chunked} import akka.http.scaladsl.model.headers.Host +import akka.http.scaladsl.model.{ContentTypes, Uri} import akka.http.scaladsl.server.{Directives, Route} import akka.stream.ActorMaterializer -import akka.stream.scaladsl.{Sink, Source} +import akka.stream.alpakka.unixdomainsocket.scaladsl.UnixDomainSocket +import akka.stream.scaladsl.{Sink, Source, TLSPlacebo} import akka.util.ByteString import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} @@ -35,20 +38,26 @@ class DynamicSpec extends WordSpec with Matchers with BeforeAndAfterAll with Dir private implicit val system = ActorSystem(getClass.getSimpleName) private implicit val mat = ActorMaterializer() - private val docker = Docker("tcp://localhost:12306") + private val file = new File("target", "passport.sock") + + private val bound = { + val flow = mockDockerDaemon.join(Http().serverLayer()).join(TLSPlacebo()) + Await.result(UnixDomainSocket().bindAndHandle(flow, file), Duration.Inf) + } - private val bound = Await.result(Http().bindAndHandle(mockDockerDaemon, "localhost", 12306), 1.second) + private val docker = Docker(Uri(file.toURI.toString).withScheme("unix").toString()) "Dynamic" should { "by docker local" in { - val f = Dynamic.by(docker)("docker").runWith(Sink.head) + val f = Dynamic.by(docker).apply("docker").runWith(Sink.head) Await.result(f, Duration.Inf)(Host("foo.bar")) shouldBe Host("demo", 8080) } - "by docker swarm" in { - val f = Dynamic.by(docker)("swarm").runWith(Sink.head) - Await.result(f, Duration.Inf)(Host("foo.bar")) shouldBe Host("demo", 0) - } +// "by docker swarm" in { +// val f = Dynamic.by(docker).apply("swarm").runWith(Sink.head) +// Await.result(f, Duration.Inf)(Host("foo.bar")) shouldBe Host("demo", 0) +// } + } def mockDockerDaemon: Route = get { @@ -61,12 +70,15 @@ class DynamicSpec extends WordSpec with Matchers with BeforeAndAfterAll with Dir }, (path("services") & parameter("filters")) { _ => complete(List(Docker.Service("id", Docker.Spec("demo", Map("passport.rule" -> ".+"))))) + }, + pathEndOrSingleSlash { + complete("ok") } ) } override protected def afterAll(): Unit = { - bound.terminate(1.second) + bound.unbind() system.terminate() } } From 5d315a0cec5017557ee03e73eded1cb6f56e7870 Mon Sep 17 00:00:00 2001 From: zhongl Date: Tue, 19 Feb 2019 16:34:43 +0800 Subject: [PATCH 15/24] Uncomment code. --- README.md | 1 + docker-compose.yml | 35 ++++++++++++++++--- .../scala/zhongl/passport/DynamicSpec.scala | 8 ++--- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4141dd0..21a40c5 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Passport 是一个超轻量级统一认证网关, 面向使用 [钉钉](https://www.dingtalk.com) 或是 [企业微信](https://work.weixin.qq.com/) 的创业团队提供手机扫码登录访问内部服务. + # 部署 ## 配置 app.conf diff --git a/docker-compose.yml b/docker-compose.yml index 52d1330..e826ec1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,21 +1,46 @@ -version: '3' +version: '3.3' services: + gateway: + image: traefik:latest + command: + - "--api" + - "--entrypoints=Name:http Address::80 Redirect.EntryPoint:https" + - "--entrypoints=Name:https Address::443 TLS" + - "--defaultentrypoints=http,https" + - "--docker" + - "--docker.watch" + - "--docker.domain=${DOMAIN}" + - "--accesslog" + - "--traefikLog" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + ports: + - 80:80 + - 443:443 + passport: image: zhongl/passport:latest command: - "-d:docker" volumes: - /var/run/docker.sock:/var/run/docker.sock + labels: + traefik.port: 8080 + traefik.frontend.rule: "HostRegexp: {subdomain:[a-z]+}.${DOMAIN}" environment: - JAVA_TOOL_OPTIONS: -Dconfig.file=/app.conf + JAVA_TOOL_OPTIONS: -Dconfig.file=/run/secrets/conf DOMAIN: $DOMAIN - ports: - - 80:8080 + secrets: + - conf - web: + echo: image: zhongl/passport:latest command: - "-e" labels: passport.rule: "www.${DOMAIN} > :8080" + +secrets: + conf: + file: ./app.conf \ No newline at end of file diff --git a/src/test/scala/zhongl/passport/DynamicSpec.scala b/src/test/scala/zhongl/passport/DynamicSpec.scala index cefbaf9..236d720 100644 --- a/src/test/scala/zhongl/passport/DynamicSpec.scala +++ b/src/test/scala/zhongl/passport/DynamicSpec.scala @@ -53,10 +53,10 @@ class DynamicSpec extends WordSpec with Matchers with BeforeAndAfterAll with Dir Await.result(f, Duration.Inf)(Host("foo.bar")) shouldBe Host("demo", 8080) } -// "by docker swarm" in { -// val f = Dynamic.by(docker).apply("swarm").runWith(Sink.head) -// Await.result(f, Duration.Inf)(Host("foo.bar")) shouldBe Host("demo", 0) -// } + "by docker swarm" in { + val f = Dynamic.by(docker).apply("swarm").runWith(Sink.head) + Await.result(f, Duration.Inf)(Host("foo.bar")) shouldBe Host("demo", 0) + } } From c06b8303c73c7567e4e629623ac6a8b2b023b4b6 Mon Sep 17 00:00:00 2001 From: zhongl Date: Tue, 19 Feb 2019 23:10:06 +0800 Subject: [PATCH 16/24] Stash. --- docker-compose.yml | 44 ++++---- .../scala/zhongl/passport/Complainant.scala | 25 ----- src/main/scala/zhongl/passport/Docker.scala | 22 +++- src/main/scala/zhongl/passport/Dynamic.scala | 13 +-- .../scala/zhongl/passport/EitherFork.scala | 39 +++++++ src/main/scala/zhongl/passport/Handle.scala | 14 ++- src/main/scala/zhongl/passport/Rewrite.scala | 106 +++++++++--------- .../scala/zhongl/passport/DynamicSpec.scala | 6 +- .../scala/zhongl/passport/RewriteSpec.scala | 40 ++++--- 9 files changed, 172 insertions(+), 137 deletions(-) delete mode 100644 src/main/scala/zhongl/passport/Complainant.scala create mode 100644 src/main/scala/zhongl/passport/EitherFork.scala diff --git a/docker-compose.yml b/docker-compose.yml index e826ec1..1f02db7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,23 +1,23 @@ version: '3.3' services: - gateway: - image: traefik:latest - command: - - "--api" - - "--entrypoints=Name:http Address::80 Redirect.EntryPoint:https" - - "--entrypoints=Name:https Address::443 TLS" - - "--defaultentrypoints=http,https" - - "--docker" - - "--docker.watch" - - "--docker.domain=${DOMAIN}" - - "--accesslog" - - "--traefikLog" - volumes: - - /var/run/docker.sock:/var/run/docker.sock - ports: - - 80:80 - - 443:443 +# gateway: +# image: traefik:latest +# command: +# - "--api" +# - "--entrypoints=Name:http Address::80 Redirect.EntryPoint:https" +# - "--entrypoints=Name:https Address::443 TLS" +# - "--defaultentrypoints=http,https" +# - "--docker" +# - "--docker.watch" +# - "--docker.domain=${DOMAIN}" +# - "--accesslog" +# - "--traefikLog" +# volumes: +# - /var/run/docker.sock:/var/run/docker.sock +# ports: +# - 80:80 +# - 443:443 passport: image: zhongl/passport:latest @@ -25,21 +25,23 @@ services: - "-d:docker" volumes: - /var/run/docker.sock:/var/run/docker.sock - labels: - traefik.port: 8080 - traefik.frontend.rule: "HostRegexp: {subdomain:[a-z]+}.${DOMAIN}" +# labels: +# traefik.port: 8080 +# traefik.frontend.rule: "HostRegexp: {subdomain:[a-z]+}.${DOMAIN}" environment: JAVA_TOOL_OPTIONS: -Dconfig.file=/run/secrets/conf DOMAIN: $DOMAIN secrets: - conf + ports: + - 80:8080 echo: image: zhongl/passport:latest command: - "-e" labels: - passport.rule: "www.${DOMAIN} > :8080" + passport.rule: "www.${DOMAIN}|>|:8080" secrets: conf: diff --git a/src/main/scala/zhongl/passport/Complainant.scala b/src/main/scala/zhongl/passport/Complainant.scala deleted file mode 100644 index cc192ba..0000000 --- a/src/main/scala/zhongl/passport/Complainant.scala +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2019 Zhong Lunfu - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package zhongl.passport - -import akka.http.scaladsl.model.HttpResponse - -import scala.util.control.NoStackTrace - -trait Complainant extends NoStackTrace { - def response: HttpResponse -} diff --git a/src/main/scala/zhongl/passport/Docker.scala b/src/main/scala/zhongl/passport/Docker.scala index a3ce159..cd77d19 100644 --- a/src/main/scala/zhongl/passport/Docker.scala +++ b/src/main/scala/zhongl/passport/Docker.scala @@ -20,6 +20,7 @@ import java.io.File import akka.NotUsed import akka.actor.ActorSystem +import akka.event.Logging import akka.http.scaladsl.Http import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport import akka.http.scaladsl.model.HttpEntity.Chunked @@ -27,9 +28,9 @@ import akka.http.scaladsl.model.Uri.Path import akka.http.scaladsl.model._ import akka.http.scaladsl.model.headers.Host import akka.http.scaladsl.unmarshalling.{Unmarshal, Unmarshaller} -import akka.stream.Materializer import akka.stream.alpakka.unixdomainsocket.scaladsl.UnixDomainSocket import akka.stream.scaladsl._ +import akka.stream.{Attributes, Materializer} import akka.util.ByteString import spray.json._ import zhongl.passport.Docker.{Container, Service} @@ -41,15 +42,18 @@ final class Docker(base: Uri, outgoing: () => Flow[HttpRequest, HttpResponse, _] val request = HttpRequest(uri = base.copy(path = Path / "events").withQuery(query)) Source .single(request) + .log("Docker Events").withAttributes(Attributes.logLevels(Logging.InfoLevel)) .via(outgoing()) + .log("Docker Events").withAttributes(Attributes.logLevels(Logging.InfoLevel)) .flatMapConcat { case HttpResponse(StatusCodes.OK, _, Chunked(ContentTypes.`application/json`, chunks), _) => chunks } + .log("Docker Events") .map(_.data()) } def containers[T](filters: Map[String, List[String]])(implicit mat: Materializer): Flow[T, List[Container], NotUsed] = { - list[T, List[Container]](filters, Path / "containers") + list[T, List[Container]](filters, Path / "containers" / "json") } def services[T](filters: Map[String, List[String]])(implicit mat: Materializer): Flow[T, List[Service], NotUsed] = { @@ -59,9 +63,15 @@ final class Docker(base: Uri, outgoing: () => Flow[HttpRequest, HttpResponse, _] private def list[A, B](filters: Map[String, List[String]], path: Path)(implicit u: Unmarshaller[ResponseEntity, B], mat: Materializer) = { val query = Uri.Query("filters" -> filters.toJson.compactPrint) val request = HttpRequest(uri = base.copy(path = path).withQuery(query)) - Flow[A].map(_ => request).via(outgoing()).mapAsync(1) { - case HttpResponse(StatusCodes.OK, _, entity, _) => Unmarshal(entity).to[B] - } + Flow[A] + .map(_ => request) + .log(s"Docker $path").withAttributes(Attributes.logLevels(Logging.InfoLevel)) + .via(outgoing()) + .log(s"Docker $path").withAttributes(Attributes.logLevels(Logging.InfoLevel)) + .mapAsync(1) { + case HttpResponse(StatusCodes.OK, _, entity, _) => Unmarshal(entity).to[B] + } + .log(s"Docker $path") } } @@ -86,7 +96,7 @@ object Docker { sys.env.getOrElse("DOCKER_HOST", "unix:///var/run/docker.dock") } - final case class Container(`ID`: String, `Names`: List[String], `Labels`: Map[String, String]) + final case class Container(`Id`: String, `Names`: List[String], `Labels`: Map[String, String]) final case class Service(`ID`: String, `Spec`: Spec) final case class Spec(`Name`: String, `Labels`: Map[String, String]) diff --git a/src/main/scala/zhongl/passport/Dynamic.scala b/src/main/scala/zhongl/passport/Dynamic.scala index 78fa774..ca3dce8 100644 --- a/src/main/scala/zhongl/passport/Dynamic.scala +++ b/src/main/scala/zhongl/passport/Dynamic.scala @@ -19,10 +19,10 @@ package zhongl.passport import java.util.regex.Pattern import akka.NotUsed +import akka.event.Logging import akka.http.scaladsl.model.headers.Host -import akka.http.scaladsl.model.{HttpResponse, StatusCodes} -import akka.stream.Materializer import akka.stream.scaladsl.{Flow, Source} +import akka.stream.{Attributes, Materializer} import akka.util.ByteString object Dynamic { @@ -30,7 +30,7 @@ object Dynamic { val filterByLabel = Map("label" -> List(label)) - def by(docker: Docker)(implicit mat: Materializer): String => Source[Host => Host, NotUsed] = { + def by(docker: Docker)(implicit mat: Materializer): String => Source[Host => Option[Host], NotUsed] = { case "docker" => arrange( docker.events(Map("scope" -> List("local"), "type" -> List("container"), "event" -> List("start", "destroy"))), @@ -49,11 +49,12 @@ object Dynamic { .single(ByteString.empty) .orElse(events) .via(flow) + .log("Dynamic").withAttributes(Attributes.logLevels(Logging.InfoLevel)) .via(CachedLatest()) .map(redirect) } - private def redirect(rules: List[(String, String)]): Host => Host = { + private def redirect(rules: List[(String, String)]): Host => Option[Host] = { val rs = rules.map { p => p._1.split("\\s*\\|>\\|\\s*:", 2) match { case Array(r, port) => (Pattern.compile(r), Host(p._2, port.toInt)) @@ -64,11 +65,7 @@ object Dynamic { host => rs.find(p => p._1.matcher(host.host.address()).matches()) .map(p => p._2) - .getOrElse(throw NoMatchedHostRuleException(host)) } - final case class NoMatchedHostRuleException(host: Host) extends Complainant { - override def response: HttpResponse = HttpResponse(StatusCodes.BadGateway, entity = "No matched host rule") - } } diff --git a/src/main/scala/zhongl/passport/EitherFork.scala b/src/main/scala/zhongl/passport/EitherFork.scala new file mode 100644 index 0000000..393362b --- /dev/null +++ b/src/main/scala/zhongl/passport/EitherFork.scala @@ -0,0 +1,39 @@ +/* + * Copyright 2019 Zhong Lunfu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zhongl.passport +import akka.NotUsed +import akka.stream.scaladsl.{Flow, GraphDSL, Partition} +import akka.stream.{FanOutShape2, Graph} + +object EitherFork { + def apply[A, B](): Graph[FanOutShape2[Either[A, B], A, B], NotUsed] = { + GraphDSL.create() { implicit b => + import GraphDSL.Implicits._ + + val partition = b.add(Partition[Either[A, B]](2, t => if (t.isLeft) 0 else 1)) + val left = b.add(Flow[Either[A, B]].map(_.left.get)) + val right = b.add(Flow[Either[A, B]].map(_.right.get)) + + // format: OFF + partition.out(0) ~> left + partition.out(1) ~> right + // format: ON + + new FanOutShape2(partition.in, left.out, right.out) + } + } +} diff --git a/src/main/scala/zhongl/passport/Handle.scala b/src/main/scala/zhongl/passport/Handle.scala index 921eac3..1aa813c 100644 --- a/src/main/scala/zhongl/passport/Handle.scala +++ b/src/main/scala/zhongl/passport/Handle.scala @@ -20,6 +20,7 @@ import akka.NotUsed import akka.actor.ActorSystem import akka.http.scaladsl.model.headers.Host import akka.http.scaladsl.model.{HttpRequest, HttpResponse, StatusCodes} +import akka.http.scaladsl.util.FastFuture import akka.stream.scaladsl.{Flow, GraphDSL, Merge, Source} import akka.stream.{ActorMaterializer, FlowShape} import zhongl.passport.Rewrite._ @@ -31,7 +32,7 @@ import scala.util.control.NonFatal object Handle { - def apply(mayBeDynamic: Option[Source[Host => Host, NotUsed]])(implicit system: ActorSystem): Flow[HttpRequest, HttpResponse, NotUsed] = { + def apply(mayBeDynamic: Option[Source[Host => Option[Host], NotUsed]])(implicit system: ActorSystem): Flow[HttpRequest, HttpResponse, NotUsed] = { implicit val ex = system.dispatcher implicit val mat = ActorMaterializer() @@ -48,20 +49,23 @@ object Handle { import GraphDSL.Implicits._ val guard = b.add(Guard.graph(plugin.oauth2(jc.generate), ignore)) - val merge = b.add(Merge[Future[HttpResponse]](2)) + val merge = b.add(Merge[Future[HttpResponse]](3)) val rewrite = b.add(Rewrite(mayBeDynamic, IgnoreTimeoutAccess, Forwarded(local))) + val fork = b.add(EitherFork[HttpResponse, HttpRequest]()) + val future = b.add(Flow[HttpResponse].map(FastFuture.successful)) val forward = b.add(Forward()) // format: OFF - guard.out0 ~> rewrite ~> forward ~> merge - guard.out1 ~> merge + guard.out0 ~> rewrite ~> fork.in + fork.out0 ~> future ~> merge + fork.out1 ~> forward ~> merge + guard.out1 ~> merge // format: ON FlowShape(guard.in, merge.out) } Flow[HttpRequest].via(graph).mapAsync(1)(identity).recover { - case c: Complainant => c.response case NonFatal(cause) => HttpResponse(StatusCodes.InternalServerError, entity = cause.toString) } } diff --git a/src/main/scala/zhongl/passport/Rewrite.scala b/src/main/scala/zhongl/passport/Rewrite.scala index 8eb077a..2bb1932 100644 --- a/src/main/scala/zhongl/passport/Rewrite.scala +++ b/src/main/scala/zhongl/passport/Rewrite.scala @@ -17,88 +17,98 @@ package zhongl.passport import akka.NotUsed +import akka.http.scaladsl.model.StatusCodes.{BadGateway, BadRequest, InternalServerError, LoopDetected} import akka.http.scaladsl.model._ import akka.http.scaladsl.model.headers._ -import akka.stream.FlowShape import akka.stream.scaladsl.{Flow, GraphDSL, Source, ZipWith2} +import akka.stream.{FlowShape, Graph} import scala.collection.immutable.Seq object Rewrite { - type Shape = Flow[HttpRequest, HttpRequest, NotUsed] + type Shape = FlowShape[HttpRequest, Either[HttpResponse, HttpRequest]] - def apply(mayBeDynamic: Option[Source[Host => Host, NotUsed]], more: Action*): Shape = { - mayBeDynamic.map(graph(more)).getOrElse(Flow[HttpRequest].map(doRewrite(_, CompoundAction(List(HostOfUri()) ++ more)))) + trait Action extends (HttpRequest => Either[HttpResponse, HttpRequest]) { + def accumulate(header: HttpHeader): (Option[HttpHeader], Action) + } + + def apply(mayBeDynamic: Option[Source[Host => Option[Host], NotUsed]], more: Action*): Graph[Shape, NotUsed] = { + mayBeDynamic.map(dynamicGraph(more)).getOrElse(Flow[HttpRequest].map(doRewrite(_, CompoundAction(List(HostOfUri()) ++ more)))) } - private def graph(more: scala.Seq[Action]): Source[Host => Host, NotUsed] => Shape = { dynamic => - Flow.fromGraph(GraphDSL.create() { implicit b => + private def dynamicGraph(more: scala.Seq[Action]): Source[Host => Option[Host], NotUsed] => Graph[Shape, NotUsed] = { dynamic => + GraphDSL.create() { implicit b => import GraphDSL.Implicits._ val source = b.add(dynamic.map(f => HostOfUri(redirect = f)).map(a => CompoundAction(List(a) ++ more))) - val zip = b.add(new ZipWith2[HttpRequest, Action, HttpRequest](doRewrite)) + val zip = b.add(new ZipWith2[HttpRequest, Action, Either[HttpResponse, HttpRequest]](doRewrite)) // format: OFF source ~> zip.in1 // format: ON - FlowShape(zip.in0, zip.out) - }) - } - - private def doRewrite(req: HttpRequest, action: Action) = { - val (hs, rewrite) = req.headers.foldLeft((List.empty[HttpHeader], action)) { - case ((acc, r), h) => - r.accumulate(h) match { - case (None, r0) => (acc, r0) - case (Some(h0), r0) => (h0 :: acc, r0) - } + new FlowShape(zip.in0, zip.out) } - rewrite(req.copy(headers = hs)) } - trait Action extends (HttpRequest => HttpRequest) { - def accumulate(header: HttpHeader): (Option[HttpHeader], Action) + private def doRewrite(req: HttpRequest, action: Action): Either[HttpResponse, HttpRequest] = { + req.headers + .foldLeft(List.empty[HttpHeader] -> action) { + case ((acc, r), h) => + r.accumulate(h) match { + case (None, r0) => acc -> r0 + case (Some(h0), r0) => (h0 :: acc) -> r0 + } + } match { + case (hs, rewrite) => rewrite(req.copy(headers = hs)) + } } - final case class HostOfUri(host: Option[Host] = None, redirect: Host => Host = identity) extends Action { + final case class HostOfUri(host: Option[Host] = None, redirect: Host => Option[Host] = Some(_)) extends Action { override def accumulate(header: HttpHeader): (Option[HttpHeader], Action) = header match { - case h: Host => (Some(h), HostOfUri(Some(h), redirect)) - case h => (Some(h), this) + case h: Host => Some(h) -> HostOfUri(Some(h), redirect) + case h => Some(h) -> this } - override def apply(req: HttpRequest): HttpRequest = { + override def apply(req: HttpRequest): Either[HttpResponse, HttpRequest] = { host + .toRight(HttpResponse(BadRequest, entity = "Missing host header")) .map(redirect) + .flatMap(_.toRight(HttpResponse(BadGateway, entity = "No matched host rule"))) .map(h => req.uri.authority.copy(host = h.host, port = h.port)) .map(a => req.uri.copy(authority = a)) .map(u => req.copy(uri = u)) - .getOrElse(throw MissingHostException) } - } /** * */ - final case class Forwarded(local: RemoteAddress, remote: Option[RemoteAddress] = None, forwarded: Option[`X-Forwarded-For`] = None) extends Action { + final case class Forwarded( + local: RemoteAddress, + remote: Option[RemoteAddress] = None, + forwarded: Option[`X-Forwarded-For`] = None, + error: Option[HttpResponse] = None + ) extends Action { override def accumulate(header: HttpHeader): (Option[HttpHeader], Action) = header match { - case LoopDetected(addresses) => throw LoopDetectException(addresses) + case DetectedLoop(addresses) => (None, copy(error = Some(HttpResponse(LoopDetected, entity = addresses.mkString(","))))) case h: `X-Forwarded-For` => (None, copy(forwarded = Some(h))) case `Remote-Address`(address) => (None, copy(remote = Some(address))) case h => (Some(h), this) } - override def apply(req: HttpRequest): HttpRequest = { - val h = forwarded - .map(f => `X-Forwarded-For`(f.addresses :+ local)) - .orElse(remote.map(f => `X-Forwarded-For`(f, local))) - .getOrElse(throw MissingRemoteAddressException) - req.copy(headers = h +: req.headers) + override def apply(req: HttpRequest): Either[HttpResponse, HttpRequest] = { + error.toLeft(Unit).flatMap { _ => + forwarded + .map(f => `X-Forwarded-For`(f.addresses :+ local)) + .orElse(remote.map(f => `X-Forwarded-For`(f, local))) + .map(h => req.copy(headers = h +: req.headers)) + .toRight(HttpResponse(InternalServerError, entity = "Missing remote address")) + } } - object LoopDetected { + object DetectedLoop { def unapply(arg: HttpHeader): Option[Seq[RemoteAddress]] = arg match { case `X-Forwarded-For`(addresses) if addresses.contains(local) => Some(addresses) case _ => None @@ -112,11 +122,11 @@ object Rewrite { */ object IgnoreTimeoutAccess extends Action { override def accumulate(header: HttpHeader): (Option[HttpHeader], Action) = header match { - case _: `Timeout-Access` => (None, this) - case h => (Some(h), this) + case _: `Timeout-Access` => None -> this + case h => Some(h) -> this } - override def apply(req: HttpRequest): HttpRequest = req + override def apply(req: HttpRequest): Either[HttpResponse, HttpRequest] = Right(req) } final case class CompoundAction(seq: Seq[_ <: Action]) extends Action { @@ -125,19 +135,9 @@ object Rewrite { (hs.find(_.isEmpty).getOrElse(Some(header)), CompoundAction(cs)) } - override def apply(req: HttpRequest): HttpRequest = seq.foldLeft(req)((r, f) => f(r)) - } - - final case class LoopDetectException(addresses: Seq[RemoteAddress]) extends Complainant { - override def response: HttpResponse = HttpResponse(StatusCodes.LoopDetected, entity = addresses.mkString(",")) - } - - final case object MissingRemoteAddressException extends Complainant { - override def response: HttpResponse = HttpResponse(StatusCodes.InternalServerError, entity = "Missing remote address") - } - - final object MissingHostException extends Complainant { - override def response: HttpResponse = HttpResponse(StatusCodes.BadRequest, entity = "Missing host header") + override def apply(req: HttpRequest): Either[HttpResponse, HttpRequest] = seq.foldLeft[Either[HttpResponse, HttpRequest]](Right(req)) { + case (Right(r), f) => f(r) + case (e, _) => e + } } - } diff --git a/src/test/scala/zhongl/passport/DynamicSpec.scala b/src/test/scala/zhongl/passport/DynamicSpec.scala index 236d720..98a9ebb 100644 --- a/src/test/scala/zhongl/passport/DynamicSpec.scala +++ b/src/test/scala/zhongl/passport/DynamicSpec.scala @@ -50,12 +50,12 @@ class DynamicSpec extends WordSpec with Matchers with BeforeAndAfterAll with Dir "Dynamic" should { "by docker local" in { val f = Dynamic.by(docker).apply("docker").runWith(Sink.head) - Await.result(f, Duration.Inf)(Host("foo.bar")) shouldBe Host("demo", 8080) + Await.result(f, Duration.Inf)(Host("foo.bar")) shouldBe Some(Host("demo", 8080)) } "by docker swarm" in { val f = Dynamic.by(docker).apply("swarm").runWith(Sink.head) - Await.result(f, Duration.Inf)(Host("foo.bar")) shouldBe Host("demo", 0) + Await.result(f, Duration.Inf)(Host("foo.bar")) shouldBe Some(Host("demo", 0)) } } @@ -65,7 +65,7 @@ class DynamicSpec extends WordSpec with Matchers with BeforeAndAfterAll with Dir (path("events") & parameter("filters")) { _ => complete(Chunked(ContentTypes.`application/json`, Source.repeat(Chunk(ByteString(" "))))) }, - (path("containers") & parameter("filters")) { _ => + (path("containers" / "json") & parameter("filters")) { _ => complete(List(Docker.Container("id", List("/demo"), Map("passport.rule" -> ".+|>|:8080")))) }, (path("services") & parameter("filters")) { _ => diff --git a/src/test/scala/zhongl/passport/RewriteSpec.scala b/src/test/scala/zhongl/passport/RewriteSpec.scala index bb00573..a34bb50 100644 --- a/src/test/scala/zhongl/passport/RewriteSpec.scala +++ b/src/test/scala/zhongl/passport/RewriteSpec.scala @@ -20,8 +20,9 @@ import java.net.InetAddress import akka.actor.ActorSystem import akka.http.scaladsl.TimeoutAccess +import akka.http.scaladsl.model.StatusCodes.{BadGateway, BadRequest, InternalServerError, LoopDetected} import akka.http.scaladsl.model.headers._ -import akka.http.scaladsl.model.{HttpRequest, RemoteAddress} +import akka.http.scaladsl.model.{HttpRequest, HttpResponse, RemoteAddress} import akka.stream.ActorMaterializer import akka.stream.scaladsl.{Sink, Source} import org.scalamock.scalatest.MockFactory @@ -34,7 +35,7 @@ import scala.concurrent.duration.Duration class RewriteSpec extends WordSpec with Matchers with BeforeAndAfterAll with MockFactory { private implicit val system = ActorSystem(getClass.getSimpleName) - private implicit val mat = ActorMaterializer() + private implicit val mat = ActorMaterializer() private val local = RemoteAddress(InetAddress.getLoopbackAddress) @@ -42,30 +43,39 @@ class RewriteSpec extends WordSpec with Matchers with BeforeAndAfterAll with Moc "do normal" in { val f = Source .single(HttpRequest(headers = List(Host("localhost"), `Remote-Address`(local)))) - .via(Rewrite(Option(Source.single(identity)), Rewrite.Forwarded(local), Rewrite.IgnoreTimeoutAccess)) + .via(Rewrite(Option(Source.single(Option(_))), Rewrite.Forwarded(local), Rewrite.IgnoreTimeoutAccess)) .runWith(Sink.head) - Await.result(f, Duration.Inf) shouldBe HttpRequest( - uri = "//localhost/", headers = List(`X-Forwarded-For`(local, local),Host("localhost")) - ) + Await.result(f, Duration.Inf) shouldBe Right( + HttpRequest( + uri = "//localhost/", + headers = List(`X-Forwarded-For`(local, local), Host("localhost")) + )) + } + + "complain no matched host" in { + val f = Source + .single(HttpRequest(headers = List(Host("localhost")))) + .via(Rewrite(Option(Source.single(_ => None)))) + .runWith(Sink.head) + Await.result(f, Duration.Inf) shouldBe Left(HttpResponse(BadGateway, entity = "No matched host rule")) } "complain missing host" in { - intercept[Rewrite.MissingHostException.type] { - Rewrite.HostOfUri().apply(HttpRequest()) - } + Rewrite.HostOfUri().apply(HttpRequest()) shouldBe Left(HttpResponse(BadRequest, entity = "Missing host header")) } "stop recursive forward" in { - intercept[Rewrite.LoopDetectException] { - Rewrite.Forwarded(local).accumulate(`X-Forwarded-For`(local)) + Rewrite.Forwarded(local).accumulate(`X-Forwarded-For`(local)) match { + case (None, action) => action.apply(HttpRequest()) shouldBe Left(HttpResponse(LoopDetected, entity = s"$local")) } + } "add forwarded for" in { val client = "192.168.2.1" Rewrite.Forwarded(local).accumulate(`Remote-Address`(client)) match { - case (None, f) => f.apply(HttpRequest()).headers shouldBe List(`X-Forwarded-For`(client, local)) + case (None, f) => f.apply(HttpRequest()) shouldBe Right(HttpRequest(headers = List(`X-Forwarded-For`(client, local)))) } } @@ -74,14 +84,12 @@ class RewriteSpec extends WordSpec with Matchers with BeforeAndAfterAll with Moc val proxy = "192.168.2.1" Rewrite.Forwarded(local).accumulate(`X-Forwarded-For`(client, proxy)) match { - case (None, f) => f.apply(HttpRequest()).headers shouldBe List(`X-Forwarded-For`(client, proxy, local)) + case (None, f) => f.apply(HttpRequest()) shouldBe Right(HttpRequest(headers = List(`X-Forwarded-For`(client, proxy, local)))) } } "complain missing remote address header" in { - intercept[Rewrite.MissingRemoteAddressException.type] { - Rewrite.Forwarded(local).apply(HttpRequest()) - } + Rewrite.Forwarded(local).apply(HttpRequest()) shouldBe Left(HttpResponse(InternalServerError, entity = "Missing remote address")) } "exclude Timeout-Access header" in { From 5125e2b35b77754fe2384ebe01275295ed4a8c4b Mon Sep 17 00:00:00 2001 From: zhongl Date: Wed, 20 Feb 2019 19:04:42 +0800 Subject: [PATCH 17/24] Clean code. --- src/main/scala/zhongl/passport/Echo.scala | 37 ++++++++++------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/src/main/scala/zhongl/passport/Echo.scala b/src/main/scala/zhongl/passport/Echo.scala index b2c011c..61d0a76 100644 --- a/src/main/scala/zhongl/passport/Echo.scala +++ b/src/main/scala/zhongl/passport/Echo.scala @@ -18,27 +18,22 @@ package zhongl.passport import akka.NotUsed import akka.actor.ActorSystem -import akka.http.scaladsl.model.{ContentTypes, HttpEntity, HttpRequest, HttpResponse} -import akka.http.scaladsl.server.{Directives, Route} -import akka.stream.ActorMaterializer -import akka.stream.scaladsl.Flow -import spray.json._ - -import scala.collection.immutable - -object Echo extends Directives with DefaultJsonProtocol { - - type Shape = Flow[HttpRequest, HttpResponse, NotUsed] - - def apply()(implicit sys: ActorSystem): Shape = { - implicit val mat = ActorMaterializer() - implicit val f = jsonFormat4(InspectedRequest) - - Route.handlerFlow((extractRequest & entity(as[String])) { (req, body) => - val ir = InspectedRequest(req.method.value, req.uri.toString(), req.headers.map(_.toString()), body) - complete(HttpEntity(ContentTypes.`application/json`, ir.toJson.compactPrint)) - }) +import akka.http.scaladsl.Http +import akka.http.scaladsl.model.headers.Host +import akka.http.scaladsl.model.{HttpRequest, HttpResponse} +import akka.stream.scaladsl.{Flow, TLSPlacebo} +import akka.stream.{FlowShape, Graph} +import akka.util.ByteString + +object Echo extends { + + type Shape = FlowShape[HttpRequest, HttpResponse] + + def apply()(implicit sys: ActorSystem): Graph[Shape, NotUsed] = { + val echo = Flow[ByteString].map { bs => + ByteString(s"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: ${bs.size}\r\n\r\n") ++ bs + } + Http().clientLayer(Host("echo")).atop(TLSPlacebo()).join(echo) } - final case class InspectedRequest(method: String, uri: String, headers: immutable.Seq[String], body: String) } From c388e38ce03f4d522924f19073a83c991dc0fa56 Mon Sep 17 00:00:00 2001 From: zhongl Date: Wed, 20 Feb 2019 21:28:05 +0800 Subject: [PATCH 18/24] Stash. --- src/main/scala/zhongl/passport/Docker.scala | 30 +++++++++++++------- src/main/scala/zhongl/passport/Dynamic.scala | 4 +-- src/main/scala/zhongl/passport/Echo.scala | 5 +--- src/main/scala/zhongl/passport/Forward.scala | 5 +--- 4 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/main/scala/zhongl/passport/Docker.scala b/src/main/scala/zhongl/passport/Docker.scala index cd77d19..cad4c6e 100644 --- a/src/main/scala/zhongl/passport/Docker.scala +++ b/src/main/scala/zhongl/passport/Docker.scala @@ -42,13 +42,15 @@ final class Docker(base: Uri, outgoing: () => Flow[HttpRequest, HttpResponse, _] val request = HttpRequest(uri = base.copy(path = Path / "events").withQuery(query)) Source .single(request) - .log("Docker Events").withAttributes(Attributes.logLevels(Logging.InfoLevel)) + .log("Docker events request") + .withAttributes(Attributes.logLevels(Logging.InfoLevel)) .via(outgoing()) - .log("Docker Events").withAttributes(Attributes.logLevels(Logging.InfoLevel)) + .log("Docker events response") + .withAttributes(Attributes.logLevels(Logging.InfoLevel)) .flatMapConcat { case HttpResponse(StatusCodes.OK, _, Chunked(ContentTypes.`application/json`, chunks), _) => chunks } - .log("Docker Events") + .log("Docker events push") .map(_.data()) } @@ -65,13 +67,15 @@ final class Docker(base: Uri, outgoing: () => Flow[HttpRequest, HttpResponse, _] val request = HttpRequest(uri = base.copy(path = path).withQuery(query)) Flow[A] .map(_ => request) - .log(s"Docker $path").withAttributes(Attributes.logLevels(Logging.InfoLevel)) + .log(s"Docker $path request") + .withAttributes(Attributes.logLevels(Logging.InfoLevel)) .via(outgoing()) - .log(s"Docker $path").withAttributes(Attributes.logLevels(Logging.InfoLevel)) + .log(s"Docker $path response") + .withAttributes(Attributes.logLevels(Logging.InfoLevel)) .mapAsync(1) { case HttpResponse(StatusCodes.OK, _, entity, _) => Unmarshal(entity).to[B] } - .log(s"Docker $path") + .log(s"Docker $path unmarshal") } } @@ -83,15 +87,19 @@ object Docker { def apply(host: String = fromEnv)(implicit system: ActorSystem): Docker = { Uri(host) match { case Uri("unix", _, Path(p), _, _) => - val file = new File(p) - val http = Http().clientLayer(Host("localhost")).atop(TLSPlacebo()) - val uds = UnixDomainSocket() - new Docker(Uri("http://localhost"), () => http.join(uds.outgoingConnection(file))) + new Docker(Uri("http://localhost"), unixDomainSocket(new File(p))) + case u => - throw new IllegalStateException(s"Unsupported $host, it must be started with unix://") + new Docker(u.withScheme("http"), () => Forward().mapAsync(1)(identity)) } } + private def unixDomainSocket(file: File)(implicit system: ActorSystem) = { () => + val http = Http().clientLayer(Host("localhost")).atop(TLSPlacebo()) + val transport = UnixDomainSocket().outgoingConnection(file).mapMaterializedValue(_ => NotUsed) + http.join(transport) + } + private def fromEnv = { sys.env.getOrElse("DOCKER_HOST", "unix:///var/run/docker.dock") } diff --git a/src/main/scala/zhongl/passport/Dynamic.scala b/src/main/scala/zhongl/passport/Dynamic.scala index ca3dce8..d87b0a0 100644 --- a/src/main/scala/zhongl/passport/Dynamic.scala +++ b/src/main/scala/zhongl/passport/Dynamic.scala @@ -21,7 +21,7 @@ import java.util.regex.Pattern import akka.NotUsed import akka.event.Logging import akka.http.scaladsl.model.headers.Host -import akka.stream.scaladsl.{Flow, Source} +import akka.stream.scaladsl.{Flow, Keep, Source} import akka.stream.{Attributes, Materializer} import akka.util.ByteString @@ -48,7 +48,7 @@ object Dynamic { Source .single(ByteString.empty) .orElse(events) - .via(flow) + .viaMat(flow)(Keep.right) .log("Dynamic").withAttributes(Attributes.logLevels(Logging.InfoLevel)) .via(CachedLatest()) .map(redirect) diff --git a/src/main/scala/zhongl/passport/Echo.scala b/src/main/scala/zhongl/passport/Echo.scala index 61d0a76..4498619 100644 --- a/src/main/scala/zhongl/passport/Echo.scala +++ b/src/main/scala/zhongl/passport/Echo.scala @@ -22,14 +22,11 @@ import akka.http.scaladsl.Http import akka.http.scaladsl.model.headers.Host import akka.http.scaladsl.model.{HttpRequest, HttpResponse} import akka.stream.scaladsl.{Flow, TLSPlacebo} -import akka.stream.{FlowShape, Graph} import akka.util.ByteString object Echo extends { - type Shape = FlowShape[HttpRequest, HttpResponse] - - def apply()(implicit sys: ActorSystem): Graph[Shape, NotUsed] = { + def apply()(implicit sys: ActorSystem): Flow[HttpRequest, HttpResponse, NotUsed] = { val echo = Flow[ByteString].map { bs => ByteString(s"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: ${bs.size}\r\n\r\n") ++ bs } diff --git a/src/main/scala/zhongl/passport/Forward.scala b/src/main/scala/zhongl/passport/Forward.scala index 1a83563..a8c2b3c 100644 --- a/src/main/scala/zhongl/passport/Forward.scala +++ b/src/main/scala/zhongl/passport/Forward.scala @@ -26,8 +26,5 @@ import akka.stream.scaladsl.Flow import scala.concurrent.Future object Forward { - - type Shape = Flow[HttpRequest, Future[HttpResponse], NotUsed] - - def apply()(implicit system: ActorSystem): Shape = Flow[HttpRequest].map(Http().singleRequest(_)) + def apply()(implicit system: ActorSystem): Flow[HttpRequest, Future[HttpResponse], NotUsed] = Flow[HttpRequest].map(Http().singleRequest(_)) } From c767ccd9673320f6e67803d2a4a3d5aa55360eaf Mon Sep 17 00:00:00 2001 From: zhongl Date: Fri, 22 Feb 2019 19:27:49 +0800 Subject: [PATCH 19/24] Clean code. --- src/main/scala/zhongl/passport/Rewrite.scala | 152 ++++++------------ .../scala/zhongl/passport/RewriteSpec.scala | 69 +++----- 2 files changed, 74 insertions(+), 147 deletions(-) diff --git a/src/main/scala/zhongl/passport/Rewrite.scala b/src/main/scala/zhongl/passport/Rewrite.scala index 2bb1932..d9a31ad 100644 --- a/src/main/scala/zhongl/passport/Rewrite.scala +++ b/src/main/scala/zhongl/passport/Rewrite.scala @@ -16,128 +16,78 @@ package zhongl.passport -import akka.NotUsed -import akka.http.scaladsl.model.StatusCodes.{BadGateway, BadRequest, InternalServerError, LoopDetected} +import akka.http.scaladsl.model.StatusCodes._ import akka.http.scaladsl.model._ import akka.http.scaladsl.model.headers._ -import akka.stream.scaladsl.{Flow, GraphDSL, Source, ZipWith2} -import akka.stream.{FlowShape, Graph} import scala.collection.immutable.Seq +import scala.language.implicitConversions +import scala.util.control.ControlThrowable object Rewrite { - type Shape = FlowShape[HttpRequest, Either[HttpResponse, HttpRequest]] + type Rewrite[A, B] = A => Either[B, A] + type Request = Rewrite[HttpRequest, HttpResponse] + type Headers = Rewrite[Seq[HttpHeader], HttpResponse] - trait Action extends (HttpRequest => Either[HttpResponse, HttpRequest]) { - def accumulate(header: HttpHeader): (Option[HttpHeader], Action) - } - - def apply(mayBeDynamic: Option[Source[Host => Option[Host], NotUsed]], more: Action*): Graph[Shape, NotUsed] = { - mayBeDynamic.map(dynamicGraph(more)).getOrElse(Flow[HttpRequest].map(doRewrite(_, CompoundAction(List(HostOfUri()) ++ more)))) - } - - private def dynamicGraph(more: scala.Seq[Action]): Source[Host => Option[Host], NotUsed] => Graph[Shape, NotUsed] = { dynamic => - GraphDSL.create() { implicit b => - import GraphDSL.Implicits._ + implicit def asRequest(f: Headers): Request = r => f(r.headers).right.map(r.withHeaders) - val source = b.add(dynamic.map(f => HostOfUri(redirect = f)).map(a => CompoundAction(List(a) ++ more))) - val zip = b.add(new ZipWith2[HttpRequest, Action, Either[HttpResponse, HttpRequest]](doRewrite)) - - // format: OFF - source ~> zip.in1 - // format: ON - - new FlowShape(zip.in0, zip.out) - } + implicit final class RequestOps(val f: Request) extends AnyVal { + def &(g: Request): Request = r => f(r).right.flatMap(g) } - private def doRewrite(req: HttpRequest, action: Action): Either[HttpResponse, HttpRequest] = { - req.headers - .foldLeft(List.empty[HttpHeader] -> action) { - case ((acc, r), h) => - r.accumulate(h) match { - case (None, r0) => acc -> r0 - case (Some(h0), r0) => (h0 :: acc) -> r0 - } - } match { - case (hs, rewrite) => rewrite(req.copy(headers = hs)) - } - } - - final case class HostOfUri(host: Option[Host] = None, redirect: Host => Option[Host] = Some(_)) extends Action { - override def accumulate(header: HttpHeader): (Option[HttpHeader], Action) = header match { - case h: Host => Some(h) -> HostOfUri(Some(h), redirect) - case h => Some(h) -> this - } - - override def apply(req: HttpRequest): Either[HttpResponse, HttpRequest] = { - host - .toRight(HttpResponse(BadRequest, entity = "Missing host header")) - .map(redirect) - .flatMap(_.toRight(HttpResponse(BadGateway, entity = "No matched host rule"))) - .map(h => req.uri.authority.copy(host = h.host, port = h.port)) - .map(a => req.uri.copy(authority = a)) - .map(u => req.copy(uri = u)) - } + implicit final class HeadersOps(val f: Headers) extends AnyVal { + def &(g: Request): Request = r => f(r).right.flatMap(g) } - /** - * - */ - final case class Forwarded( - local: RemoteAddress, - remote: Option[RemoteAddress] = None, - forwarded: Option[`X-Forwarded-For`] = None, - error: Option[HttpResponse] = None - ) extends Action { - override def accumulate(header: HttpHeader): (Option[HttpHeader], Action) = header match { - case DetectedLoop(addresses) => (None, copy(error = Some(HttpResponse(LoopDetected, entity = addresses.mkString(","))))) - case h: `X-Forwarded-For` => (None, copy(forwarded = Some(h))) - case `Remote-Address`(address) => (None, copy(remote = Some(address))) - case h => (Some(h), this) - } - - override def apply(req: HttpRequest): Either[HttpResponse, HttpRequest] = { - error.toLeft(Unit).flatMap { _ => - forwarded - .map(f => `X-Forwarded-For`(f.addresses :+ local)) - .orElse(remote.map(f => `X-Forwarded-For`(f, local))) - .map(h => req.copy(headers = h +: req.headers)) - .toRight(HttpResponse(InternalServerError, entity = "Missing remote address")) + object XForwardedFor { + def apply(local: RemoteAddress): Headers = { + object DetectedLoop { + def unapply(arg: HttpHeader): Option[Seq[RemoteAddress]] = arg match { + case `X-Forwarded-For`(addresses) if addresses.contains(local) => Some(addresses) + case _ => None + } } - } - object DetectedLoop { - def unapply(arg: HttpHeader): Option[Seq[RemoteAddress]] = arg match { - case `X-Forwarded-For`(addresses) if addresses.contains(local) => Some(addresses) - case _ => None - } + in => + try { + in.foldLeft((List.empty[HttpHeader], Aggregated())) { + case (_, DetectedLoop(addresses)) => throw LoopDetectedException(addresses) + case ((hs, a), `Remote-Address`(address)) => (hs, a.copy(remote = Some(address))) + case ((hs, a), h: `X-Forwarded-For`) => (hs, a.copy(origin = Some(h))) + case ((hs, a), h) => (h :: hs, a) + } match { + case (hs, a) => + a.origin + .map(f => `X-Forwarded-For`(f.addresses :+ local)) + .orElse(a.remote.map(f => `X-Forwarded-For`(f, local))) + .map(h => h :: hs) + .map(_.reverse) + .toRight(HttpResponse(InternalServerError, entity = "Missing remote address")) + } + } catch { + case LoopDetectedException(addresses) => Left(HttpResponse(LoopDetected, entity = addresses.mkString(","))) + } } + private final case class Aggregated(origin: Option[`X-Forwarded-For`] = None, remote: Option[RemoteAddress] = None) + private final case class LoopDetectedException(addressed: Seq[RemoteAddress]) extends ControlThrowable } - /** - * - */ - object IgnoreTimeoutAccess extends Action { - override def accumulate(header: HttpHeader): (Option[HttpHeader], Action) = header match { - case _: `Timeout-Access` => None -> this - case h => Some(h) -> this - } - - override def apply(req: HttpRequest): Either[HttpResponse, HttpRequest] = Right(req) + object IgnoreHeader { + def apply(f: HttpHeader => Boolean): Headers = hs => Right(hs.filterNot(f)) } - final case class CompoundAction(seq: Seq[_ <: Action]) extends Action { - override def accumulate(header: HttpHeader): (Option[HttpHeader], Action) = { - val (hs, cs) = seq.map(_.accumulate(header)).unzip - (hs.find(_.isEmpty).getOrElse(Some(header)), CompoundAction(cs)) - } - - override def apply(req: HttpRequest): Either[HttpResponse, HttpRequest] = seq.foldLeft[Either[HttpResponse, HttpRequest]](Right(req)) { - case (Right(r), f) => f(r) - case (e, _) => e + object HostOfUri { + def apply(redirect: Host => Option[Host] = Some(_)): Request = { r => + r.headers + .collectFirst { case h: Host => h } + .toRight(HttpResponse(BadRequest, entity = "Missing host header")) + .flatMap(h => redirect(h).toRight(HttpResponse(BadGateway, entity = "No matched host rule"))) + .map(h => r.uri.authority.copy(h.host, h.port)) + .map(r.uri.withAuthority) + .map(r.withUri) } } + } diff --git a/src/test/scala/zhongl/passport/RewriteSpec.scala b/src/test/scala/zhongl/passport/RewriteSpec.scala index a34bb50..b0a3ea2 100644 --- a/src/test/scala/zhongl/passport/RewriteSpec.scala +++ b/src/test/scala/zhongl/passport/RewriteSpec.scala @@ -18,87 +18,64 @@ package zhongl.passport import java.net.InetAddress -import akka.actor.ActorSystem import akka.http.scaladsl.TimeoutAccess -import akka.http.scaladsl.model.StatusCodes.{BadGateway, BadRequest, InternalServerError, LoopDetected} +import akka.http.scaladsl.model.StatusCodes._ import akka.http.scaladsl.model.headers._ -import akka.http.scaladsl.model.{HttpRequest, HttpResponse, RemoteAddress} -import akka.stream.ActorMaterializer -import akka.stream.scaladsl.{Sink, Source} +import akka.http.scaladsl.model.{HttpHeader, HttpRequest, HttpResponse, RemoteAddress} import org.scalamock.scalatest.MockFactory -import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} +import org.scalatest.{Matchers, WordSpec} import zhongl.passport.NetworkInterfaces._ +import zhongl.passport.Rewrite._ -import scala.concurrent.Await -import scala.concurrent.duration.Duration - -class RewriteSpec extends WordSpec with Matchers with BeforeAndAfterAll with MockFactory { - - private implicit val system = ActorSystem(getClass.getSimpleName) - private implicit val mat = ActorMaterializer() +class RewriteSpec extends WordSpec with Matchers with MockFactory { private val local = RemoteAddress(InetAddress.getLoopbackAddress) "Rewrite" should { - "do normal" in { - val f = Source - .single(HttpRequest(headers = List(Host("localhost"), `Remote-Address`(local)))) - .via(Rewrite(Option(Source.single(Option(_))), Rewrite.Forwarded(local), Rewrite.IgnoreTimeoutAccess)) - .runWith(Sink.head) - Await.result(f, Duration.Inf) shouldBe Right( - HttpRequest( - uri = "//localhost/", - headers = List(`X-Forwarded-For`(local, local), Host("localhost")) - )) + + "compound request with header by &" in { + val f = HostOfUri() & IgnoreHeader(_.isInstanceOf[Host]) + f(HttpRequest(headers = List(Host("localhost")))) shouldBe Right(HttpRequest(uri = "//localhost/")) } - "complain no matched host" in { - val f = Source - .single(HttpRequest(headers = List(Host("localhost")))) - .via(Rewrite(Option(Source.single(_ => None)))) - .runWith(Sink.head) - Await.result(f, Duration.Inf) shouldBe Left(HttpResponse(BadGateway, entity = "No matched host rule")) + "compound header with request by &" in { + val f = IgnoreHeader(_ => false) & HostOfUri() + f(HttpRequest(headers = List(Host("localhost")))) shouldBe Right(HttpRequest(uri = "//localhost/", headers = List(Host("localhost")))) } "complain missing host" in { - Rewrite.HostOfUri().apply(HttpRequest()) shouldBe Left(HttpResponse(BadRequest, entity = "Missing host header")) + HostOfUri()(HttpRequest()) shouldBe Left(HttpResponse(BadRequest, entity = "Missing host header")) } - "stop recursive forward" in { - Rewrite.Forwarded(local).accumulate(`X-Forwarded-For`(local)) match { - case (None, action) => action.apply(HttpRequest()) shouldBe Left(HttpResponse(LoopDetected, entity = s"$local")) - } + "complain no matched host rule" in { + HostOfUri(_ => None)(HttpRequest(headers = List(Host("localhost")))) shouldBe Left(HttpResponse(BadGateway, entity = "No matched host rule")) + } + "stop recursive forward" in { + XForwardedFor(local)(List(`X-Forwarded-For`(local))) shouldBe Left(HttpResponse(LoopDetected, entity = s"$local")) } "add forwarded for" in { val client = "192.168.2.1" - - Rewrite.Forwarded(local).accumulate(`Remote-Address`(client)) match { - case (None, f) => f.apply(HttpRequest()) shouldBe Right(HttpRequest(headers = List(`X-Forwarded-For`(client, local)))) - } + XForwardedFor(local)(List(`Remote-Address`(client))) shouldBe Right(List(`X-Forwarded-For`(client, local))) } "append forwarded for" in { val client = "192.168.2.67" val proxy = "192.168.2.1" - Rewrite.Forwarded(local).accumulate(`X-Forwarded-For`(client, proxy)) match { - case (None, f) => f.apply(HttpRequest()) shouldBe Right(HttpRequest(headers = List(`X-Forwarded-For`(client, proxy, local)))) - } + XForwardedFor(local)(List(`X-Forwarded-For`(client, proxy))) shouldBe Right(List(`X-Forwarded-For`(client, proxy, local))) } "complain missing remote address header" in { - Rewrite.Forwarded(local).apply(HttpRequest()) shouldBe Left(HttpResponse(InternalServerError, entity = "Missing remote address")) + XForwardedFor(local)(List(Host("localhost"))) shouldBe Left(HttpResponse(InternalServerError, entity = "Missing remote address")) } "exclude Timeout-Access header" in { - Rewrite.IgnoreTimeoutAccess.accumulate(`Timeout-Access`(mock[TimeoutAccess])) match { - case (None, _) => - } + val f = IgnoreHeader(_.isInstanceOf[`Timeout-Access`]) + f(List(`Timeout-Access`(mock[TimeoutAccess]))) shouldBe Right(List.empty[HttpHeader]) } } - override protected def afterAll(): Unit = system.terminate() } From 4d049d61a789db941cf93776f0400386e229e833 Mon Sep 17 00:00:00 2001 From: zhongl Date: Fri, 22 Feb 2019 22:19:10 +0800 Subject: [PATCH 20/24] Stash. --- .jvmopts | 4 + src/main/resources/common.conf | 13 ++- src/main/scala/zhongl/passport/Docker.scala | 101 +++++++++++------- src/main/scala/zhongl/passport/Dynamic.scala | 40 ++----- src/main/scala/zhongl/passport/Echo.scala | 8 +- src/main/scala/zhongl/passport/Handle.scala | 57 ++++++---- src/main/scala/zhongl/passport/Main.scala | 5 +- .../zhongl/passport/RewriteRequestActor.scala | 93 ++++++++++++++++ .../scala/zhongl/passport/DynamicSpec.scala | 33 ++++-- src/test/scala/zhongl/passport/EchoSpec.scala | 16 +-- .../zhongl/passport/EitherForkSpec.scala | 39 +++++++ 11 files changed, 299 insertions(+), 110 deletions(-) create mode 100644 .jvmopts create mode 100644 src/main/scala/zhongl/passport/RewriteRequestActor.scala create mode 100644 src/test/scala/zhongl/passport/EitherForkSpec.scala diff --git a/.jvmopts b/.jvmopts new file mode 100644 index 0000000..3ba1c35 --- /dev/null +++ b/.jvmopts @@ -0,0 +1,4 @@ +-Xms2048m +-Xmx2048m +-XX:ReservedCodeCacheSize=256m +-XX:MaxMetaspaceSize=512m \ No newline at end of file diff --git a/src/main/resources/common.conf b/src/main/resources/common.conf index 27e1708..6ec33a9 100644 --- a/src/main/resources/common.conf +++ b/src/main/resources/common.conf @@ -1,7 +1,16 @@ -akka.http { - server.remote-address-header = on +akka { + // loglevel = "DEBUG" + + stream.materializer { + subscription-timeout.mode = warn + } + + http { + server.remote-address-header = on + } } + cookie { name = "jwt" expires_in = 15d diff --git a/src/main/scala/zhongl/passport/Docker.scala b/src/main/scala/zhongl/passport/Docker.scala index cad4c6e..c4e7348 100644 --- a/src/main/scala/zhongl/passport/Docker.scala +++ b/src/main/scala/zhongl/passport/Docker.scala @@ -17,65 +17,76 @@ package zhongl.passport import java.io.File +import java.net.InetSocketAddress import akka.NotUsed import akka.actor.ActorSystem import akka.event.Logging -import akka.http.scaladsl.Http import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport -import akka.http.scaladsl.model.HttpEntity.Chunked +import akka.http.scaladsl.model.HttpEntity.{ChunkStreamPart, Chunked} import akka.http.scaladsl.model.Uri.Path import akka.http.scaladsl.model._ import akka.http.scaladsl.model.headers.Host +import akka.http.scaladsl.settings.ClientConnectionSettings import akka.http.scaladsl.unmarshalling.{Unmarshal, Unmarshaller} +import akka.http.scaladsl.{ClientTransport, Http} import akka.stream.alpakka.unixdomainsocket.scaladsl.UnixDomainSocket import akka.stream.scaladsl._ -import akka.stream.{Attributes, Materializer} +import akka.stream.{ActorMaterializer, Attributes, FlowShape, Graph} import akka.util.ByteString import spray.json._ import zhongl.passport.Docker.{Container, Service} -final class Docker(base: Uri, outgoing: () => Flow[HttpRequest, HttpResponse, _]) extends Docker.JsonSupport { +import scala.concurrent.Future - def events(filters: Map[String, List[String]]): Source[ByteString, NotUsed] = { +class Docker(base: Uri, outgoing: () => Graph[FlowShape[HttpRequest, HttpResponse], Any])(implicit system: ActorSystem) extends Docker.JsonSupport { + + private implicit val mat = ActorMaterializer() + private implicit val ex = system.dispatcher + + def events(filters: Map[String, List[String]]): Source[ByteString, Any] = { val query = Uri.Query("filters" -> filters.toJson.compactPrint) val request = HttpRequest(uri = base.copy(path = Path / "events").withQuery(query)) + + @inline def chunks: HttpResponse => Source[ChunkStreamPart, Any] = { + case HttpResponse(StatusCodes.OK, _, Chunked(ContentTypes.`application/json`, chunks), _) => chunks + } + Source .single(request) - .log("Docker events request") - .withAttributes(Attributes.logLevels(Logging.InfoLevel)) .via(outgoing()) - .log("Docker events response") - .withAttributes(Attributes.logLevels(Logging.InfoLevel)) - .flatMapConcat { - case HttpResponse(StatusCodes.OK, _, Chunked(ContentTypes.`application/json`, chunks), _) => chunks - } - .log("Docker events push") + .log(s"docker events") + .withAttributes(Attributes.logLevels(Logging.WarningLevel)) + .flatMapConcat(chunks) + .log("docker flat") + .withAttributes(Attributes.logLevels(Logging.WarningLevel)) .map(_.data()) } - def containers[T](filters: Map[String, List[String]])(implicit mat: Materializer): Flow[T, List[Container], NotUsed] = { - list[T, List[Container]](filters, Path / "containers" / "json") + def containers(filters: Map[String, List[String]]): Flow[Any, List[Container], NotUsed] = { + list[List[Container]](filters, Path / "containers" / "json") } - def services[T](filters: Map[String, List[String]])(implicit mat: Materializer): Flow[T, List[Service], NotUsed] = { - list[T, List[Service]](filters, Path / "services") + def services(filters: Map[String, List[String]]): Flow[Any, List[Service], NotUsed] = { + list[List[Service]](filters, Path / "services") } - private def list[A, B](filters: Map[String, List[String]], path: Path)(implicit u: Unmarshaller[ResponseEntity, B], mat: Materializer) = { + private def list[T](filters: Map[String, List[String]], path: Path)(implicit u: Unmarshaller[ResponseEntity, T]) = { val query = Uri.Query("filters" -> filters.toJson.compactPrint) val request = HttpRequest(uri = base.copy(path = path).withQuery(query)) - Flow[A] - .map(_ => request) - .log(s"Docker $path request") - .withAttributes(Attributes.logLevels(Logging.InfoLevel)) + + @inline def unmarshal: HttpResponse => Future[T] = { + case HttpResponse(StatusCodes.OK, _, entity, _) => Unmarshal(entity).to[T] + } + + val get = Source + .single(request) .via(outgoing()) - .log(s"Docker $path response") + .log(s"docker $path") .withAttributes(Attributes.logLevels(Logging.InfoLevel)) - .mapAsync(1) { - case HttpResponse(StatusCodes.OK, _, entity, _) => Unmarshal(entity).to[B] - } - .log(s"Docker $path unmarshal") + .mapAsync(1)(unmarshal) + .log(s"docker $path") + Flow.fromSinkAndSource(Sink.ignore, get) } } @@ -87,19 +98,16 @@ object Docker { def apply(host: String = fromEnv)(implicit system: ActorSystem): Docker = { Uri(host) match { case Uri("unix", _, Path(p), _, _) => - new Docker(Uri("http://localhost"), unixDomainSocket(new File(p))) - + val file = new File(p) + val bidi = Http().clientLayer(Host("localhost")).atop(TLSPlacebo()) + // TODO the same outgoing connection (flow) could cause materialization twice. + new Docker(Uri("http://localhost"), () => { bidi.join(UnixDomainSocket().outgoingConnection(file)) }) case u => - new Docker(u.withScheme("http"), () => Forward().mapAsync(1)(identity)) + val forward = Forward().mapAsync(1)(identity) + new Docker(u.copy(scheme = "http"), () => forward) } } - private def unixDomainSocket(file: File)(implicit system: ActorSystem) = { () => - val http = Http().clientLayer(Host("localhost")).atop(TLSPlacebo()) - val transport = UnixDomainSocket().outgoingConnection(file).mapMaterializedValue(_ => NotUsed) - http.join(transport) - } - private def fromEnv = { sys.env.getOrElse("DOCKER_HOST", "unix:///var/run/docker.dock") } @@ -108,6 +116,27 @@ object Docker { final case class Service(`ID`: String, `Spec`: Spec) final case class Spec(`Name`: String, `Labels`: Map[String, String]) + final class UnixSocketTransport(file: File) extends ClientTransport { + override def connectTo( + host: String, + port: Int, + settings: ClientConnectionSettings + )(implicit system: ActorSystem): Flow[ByteString, ByteString, Future[Http.OutgoingConnection]] = { + + implicit val ex = system.dispatcher + val address = InetSocketAddress.createUnresolved(host, port) + + system.log.error(new Exception(), "connect") + + UnixDomainSocket() + .outgoingConnection(file) + .mapMaterializedValue(_.map { _ => + system.log.error(new Exception(), "materialize") + Http.OutgoingConnection(address, address) + }) + } + } + trait JsonSupport extends DefaultJsonProtocol with SprayJsonSupport { implicit val specF: JsonFormat[Spec] = jsonFormat2(Spec) implicit val containerF: JsonFormat[Container] = jsonFormat3(Container) diff --git a/src/main/scala/zhongl/passport/Dynamic.scala b/src/main/scala/zhongl/passport/Dynamic.scala index d87b0a0..0a846bd 100644 --- a/src/main/scala/zhongl/passport/Dynamic.scala +++ b/src/main/scala/zhongl/passport/Dynamic.scala @@ -16,13 +16,8 @@ package zhongl.passport -import java.util.regex.Pattern - import akka.NotUsed -import akka.event.Logging -import akka.http.scaladsl.model.headers.Host -import akka.stream.scaladsl.{Flow, Keep, Source} -import akka.stream.{Attributes, Materializer} +import akka.stream.scaladsl.{Flow, Source} import akka.util.ByteString object Dynamic { @@ -30,41 +25,24 @@ object Dynamic { val filterByLabel = Map("label" -> List(label)) - def by(docker: Docker)(implicit mat: Materializer): String => Source[Host => Option[Host], NotUsed] = { + def by(docker: Docker): String => Source[List[(String, String)], NotUsed] = { case "docker" => - arrange( + source( docker.events(Map("scope" -> List("local"), "type" -> List("container"), "event" -> List("start", "destroy"))), docker.containers(filterByLabel).map(_.map(c => c.`Labels`(label) -> c.`Names`.head.substring(1))) ) - case _ => - arrange( + source( docker.events(Map("scope" -> List("swarm"), "type" -> List("service"), "event" -> List("update", "remove"))), docker.services(filterByLabel).map(_.map(c => c.`Spec`.`Labels`(label) -> c.`Spec`.`Name`)) ) } - private def arrange(events: Source[ByteString, NotUsed], flow: Flow[Any, List[(String, String)], NotUsed]) = { - Source - .single(ByteString.empty) - .orElse(events) - .viaMat(flow)(Keep.right) - .log("Dynamic").withAttributes(Attributes.logLevels(Logging.InfoLevel)) - .via(CachedLatest()) - .map(redirect) - } - - private def redirect(rules: List[(String, String)]): Host => Option[Host] = { - val rs = rules.map { p => - p._1.split("\\s*\\|>\\|\\s*:", 2) match { - case Array(r, port) => (Pattern.compile(r), Host(p._2, port.toInt)) - case Array(r) => (Pattern.compile(r), Host(p._2, 0)) - } - } - - host => - rs.find(p => p._1.matcher(host.host.address()).matches()) - .map(p => p._2) + private def source( + events: Source[ByteString, Any], + get: Flow[Any, List[(String, String)], NotUsed] + ): Source[List[(String, String)], NotUsed] = { + Source.single(ByteString.empty).concat(events).via(get) } diff --git a/src/main/scala/zhongl/passport/Echo.scala b/src/main/scala/zhongl/passport/Echo.scala index 4498619..10b3b69 100644 --- a/src/main/scala/zhongl/passport/Echo.scala +++ b/src/main/scala/zhongl/passport/Echo.scala @@ -19,7 +19,7 @@ package zhongl.passport import akka.NotUsed import akka.actor.ActorSystem import akka.http.scaladsl.Http -import akka.http.scaladsl.model.headers.Host +import akka.http.scaladsl.model.headers.{Host, `Timeout-Access`} import akka.http.scaladsl.model.{HttpRequest, HttpResponse} import akka.stream.scaladsl.{Flow, TLSPlacebo} import akka.util.ByteString @@ -27,10 +27,14 @@ import akka.util.ByteString object Echo extends { def apply()(implicit sys: ActorSystem): Flow[HttpRequest, HttpResponse, NotUsed] = { + import Rewrite._ val echo = Flow[ByteString].map { bs => ByteString(s"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: ${bs.size}\r\n\r\n") ++ bs } - Http().clientLayer(Host("echo")).atop(TLSPlacebo()).join(echo) + Flow[HttpRequest] + .map(IgnoreHeader(_.isInstanceOf[`Timeout-Access`])) + .map(_.right.get) + .via(Http().clientLayer(Host("echo")).atop(TLSPlacebo()).join(echo)) } } diff --git a/src/main/scala/zhongl/passport/Handle.scala b/src/main/scala/zhongl/passport/Handle.scala index 1aa813c..06b8332 100644 --- a/src/main/scala/zhongl/passport/Handle.scala +++ b/src/main/scala/zhongl/passport/Handle.scala @@ -18,39 +18,28 @@ package zhongl.passport import akka.NotUsed import akka.actor.ActorSystem -import akka.http.scaladsl.model.headers.Host +import akka.http.scaladsl.model.headers.`Timeout-Access` import akka.http.scaladsl.model.{HttpRequest, HttpResponse, StatusCodes} import akka.http.scaladsl.util.FastFuture -import akka.stream.scaladsl.{Flow, GraphDSL, Merge, Source} -import akka.stream.{ActorMaterializer, FlowShape} -import zhongl.passport.Rewrite._ +import akka.stream.FlowShape +import akka.stream.scaladsl.{Flow, GraphDSL, Merge} +import akka.util.Timeout import zhongl.stream.oauth2.Guard import scala.concurrent.Future +import scala.concurrent.duration._ import scala.util.Try import scala.util.control.NonFatal object Handle { - def apply(mayBeDynamic: Option[Source[Host => Option[Host], NotUsed]])(implicit system: ActorSystem): Flow[HttpRequest, HttpResponse, NotUsed] = { - implicit val ex = system.dispatcher - implicit val mat = ActorMaterializer() - - val config = system.settings.config - val jc = JwtCookie.apply(config) - val ignore = jc.unapply(_: HttpRequest).isDefined - val plugin = Platforms.bound(config) - val local = Try { config.getString("interface") }.toOption - .orElse(NetworkInterfaces.findFirstNetworkInterfaceHasInet4Address.map(_.getName)) - .flatMap(NetworkInterfaces.localAddress) - .getOrElse(throw new IllegalStateException("Unavailable local address")) - + def apply(dynamic: Option[String])(implicit system: ActorSystem): Flow[HttpRequest, HttpResponse, NotUsed] = { val graph = GraphDSL.create() { implicit b => import GraphDSL.Implicits._ - val guard = b.add(Guard.graph(plugin.oauth2(jc.generate), ignore)) + val guard = b.add(guardShape()) val merge = b.add(Merge[Future[HttpResponse]](3)) - val rewrite = b.add(Rewrite(mayBeDynamic, IgnoreTimeoutAccess, Forwarded(local))) + val rewrite = b.add(rewriteShape(dynamic)) val fork = b.add(EitherFork[HttpResponse, HttpRequest]()) val future = b.add(Flow[HttpResponse].map(FastFuture.successful)) val forward = b.add(Forward()) @@ -69,4 +58,32 @@ object Handle { case NonFatal(cause) => HttpResponse(StatusCodes.InternalServerError, entity = cause.toString) } } -} + + private def guardShape()(implicit system: ActorSystem) = { + val config = system.settings.config + val jc = JwtCookie.apply(config) + val ignore = jc.unapply(_: HttpRequest).isDefined + val plugin = Platforms.bound(config) + + Guard.graph(plugin.oauth2(jc.generate), ignore)(system.dispatcher) + } + + private def rewriteShape(dynamic: Option[String])(implicit system: ActorSystem) = { + import Rewrite._ + + implicit val timeout = Timeout(2.seconds) + + val local = Try { system.settings.config.getString("interface") }.toOption + .orElse(NetworkInterfaces.findFirstNetworkInterfaceHasInet4Address.map(_.getName)) + .flatMap(NetworkInterfaces.localAddress) + .getOrElse(throw new IllegalStateException("Unavailable local address")) + + val base = IgnoreHeader(_.isInstanceOf[`Timeout-Access`]) & XForwardedFor(local) + + dynamic + .map(Dynamic.by(Docker())) + .map(s => system.actorOf(RewriteRequestActor.props(base, s), "RewriteRequest")) + .map(a => Flow[HttpRequest].ask[Either[HttpResponse, HttpRequest]](a)) + .getOrElse(Flow[HttpRequest].map(base & HostOfUri())) + } +} \ No newline at end of file diff --git a/src/main/scala/zhongl/passport/Main.scala b/src/main/scala/zhongl/passport/Main.scala index aab5d7c..07f5c9d 100644 --- a/src/main/scala/zhongl/passport/Main.scala +++ b/src/main/scala/zhongl/passport/Main.scala @@ -16,7 +16,6 @@ package zhongl.passport -import akka.NotUsed import akka.actor.{ActorSystem, Terminated} import akka.http.scaladsl.Http import akka.http.scaladsl.Http.ServerBinding @@ -51,14 +50,14 @@ object Main extends Directives { case Opt(host, port, true, _) => (host, port, Echo()) case Opt(host, port, _, d) => - (host, port, Handle(d.map(Dynamic.by(Docker())))) + (host, port, Handle(d)) } map { case (host, port, flow) => bind(flow, host, port) } getOrElse system.terminate() } - private def bind(flow: Flow[HttpRequest, HttpResponse, NotUsed], host: String, port: Int)(implicit system: ActorSystem) = { + private def bind(flow: Flow[HttpRequest, HttpResponse, Any], host: String, port: Int)(implicit system: ActorSystem) = { implicit val mat = ActorMaterializer() implicit val ex = system.dispatcher diff --git a/src/main/scala/zhongl/passport/RewriteRequestActor.scala b/src/main/scala/zhongl/passport/RewriteRequestActor.scala new file mode 100644 index 0000000..2e2f369 --- /dev/null +++ b/src/main/scala/zhongl/passport/RewriteRequestActor.scala @@ -0,0 +1,93 @@ +/* + * Copyright 2019 Zhong Lunfu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zhongl.passport + +import java.util.regex.Pattern + +import akka.actor.Status.{Failure, Success} +import akka.actor.{Actor, ActorLogging, Props, Stash} +import akka.event.Logging +import akka.http.scaladsl.model.HttpRequest +import akka.http.scaladsl.model.headers.Host +import akka.stream.scaladsl.{Sink, Source} +import akka.stream.{ActorMaterializer, Attributes} +import zhongl.passport.Rewrite.Request + +final class RewriteRequestActor private (base: Rewrite.Request, source: Source[List[(String, String)], Any]) + extends Actor + with Stash + with ActorLogging { + + import Rewrite._ + import RewriteRequestActor._ + + private implicit val mat = ActorMaterializer()(context) + private implicit val ex = context.dispatcher + + source.log("update").withAttributes(Attributes.logLevels(Logging.InfoLevel)).map(locate).runWith(Sink.actorRef(self, Complete)) + + override def receive: Receive = { + case Failure(cause) => dying(cause) + case Locate(g) => unstashAll(); context.become(rewrite(HostOfUri(g) & base)) + case Complete => log.info(s"init receive complete"); context.stop(self) + case m => log.info(s"init receive $m"); stash() + } + + private def dying(cause: Throwable): Receive = { + log.warning("enter dying") + + { + case _: HttpRequest => + sender() ! Failure(cause) + log.error(cause, "Stop actor cause by source failure") + context.stop(self) + } + } + + private def rewrite(f: Request): Receive = { + log.info("enter rewrite") + + { + case r: HttpRequest => sender() ! Success(f(r)) + case Failure(cause) => dying(cause) + case Locate(g) => context.become(rewrite(HostOfUri(g) & base)) + } + } + + private def locate(rules: List[(String, String)]): Host => Option[Host] = { + val rs = rules.map { p => + p._1.split("\\s*\\|>\\|\\s*:", 2) match { + case Array(r, port) => (Pattern.compile(r), Host(p._2, port.toInt)) + case Array(r) => (Pattern.compile(r), Host(p._2, 0)) + } + } + + host => + rs.find(p => p._1.matcher(host.host.address()).matches()) + .map(p => p._2) + } + +} + +object RewriteRequestActor { + + def props(base: Request, source: Source[List[(String, String)], Any]): Props = Props(new RewriteRequestActor(base, source)) + + private sealed trait Message + private final case class Locate(g: Host => Option[Host]) extends Message + private case object Complete extends Message +} diff --git a/src/test/scala/zhongl/passport/DynamicSpec.scala b/src/test/scala/zhongl/passport/DynamicSpec.scala index 98a9ebb..8ab85fb 100644 --- a/src/test/scala/zhongl/passport/DynamicSpec.scala +++ b/src/test/scala/zhongl/passport/DynamicSpec.scala @@ -16,12 +16,11 @@ package zhongl.passport -import java.io.File +import java.nio.file.Files import akka.actor.ActorSystem import akka.http.scaladsl.Http -import akka.http.scaladsl.model.HttpEntity.{Chunk, Chunked} -import akka.http.scaladsl.model.headers.Host +import akka.http.scaladsl.model.HttpEntity.Chunked import akka.http.scaladsl.model.{ContentTypes, Uri} import akka.http.scaladsl.server.{Directives, Route} import akka.stream.ActorMaterializer @@ -37,8 +36,14 @@ class DynamicSpec extends WordSpec with Matchers with BeforeAndAfterAll with Dir private implicit val system = ActorSystem(getClass.getSimpleName) private implicit val mat = ActorMaterializer() + private implicit val ex = system.dispatcher - private val file = new File("target", "passport.sock") + private val file = { + val f = Files.createTempFile("passport", "sock").toFile + f.delete() + f.deleteOnExit() + f + } private val bound = { val flow = mockDockerDaemon.join(Http().serverLayer()).join(TLSPlacebo()) @@ -48,22 +53,30 @@ class DynamicSpec extends WordSpec with Matchers with BeforeAndAfterAll with Dir private val docker = Docker(Uri(file.toURI.toString).withScheme("unix").toString()) "Dynamic" should { - "by docker local" in { + + "111" in { + val f = Dynamic.by(docker).apply("docker").runForeach(println) + Await.result(f, Duration.Inf) + + } + + "by docker local" ignore { val f = Dynamic.by(docker).apply("docker").runWith(Sink.head) - Await.result(f, Duration.Inf)(Host("foo.bar")) shouldBe Some(Host("demo", 8080)) + Await.result(f, Duration.Inf) shouldBe List(".+" -> "demo:8080") + } - "by docker swarm" in { + "by docker swarm" ignore { val f = Dynamic.by(docker).apply("swarm").runWith(Sink.head) - Await.result(f, Duration.Inf)(Host("foo.bar")) shouldBe Some(Host("demo", 0)) + Await.result(f, Duration.Inf) shouldBe List(".+" -> "demo") } } def mockDockerDaemon: Route = get { concat( - (path("events") & parameter("filters")) { _ => - complete(Chunked(ContentTypes.`application/json`, Source.repeat(Chunk(ByteString(" "))))) + path("events") { + complete(Chunked.fromData(ContentTypes.`application/json`, Source.repeat(ByteString("1")).delay(1.second))) }, (path("containers" / "json") & parameter("filters")) { _ => complete(List(Docker.Container("id", List("/demo"), Map("passport.rule" -> ".+|>|:8080")))) diff --git a/src/test/scala/zhongl/passport/EchoSpec.scala b/src/test/scala/zhongl/passport/EchoSpec.scala index b0e0d20..51f18d4 100644 --- a/src/test/scala/zhongl/passport/EchoSpec.scala +++ b/src/test/scala/zhongl/passport/EchoSpec.scala @@ -17,11 +17,14 @@ package zhongl.passport import akka.actor.ActorSystem -import akka.http.scaladsl.model.headers.`User-Agent` -import akka.http.scaladsl.model.{ContentTypes, HttpEntity, HttpRequest, HttpResponse} +import akka.http.scaladsl.model.ContentType.WithMissingCharset +import akka.http.scaladsl.model.HttpEntity.Strict +import akka.http.scaladsl.model.headers.Host +import akka.http.scaladsl.model.{HttpRequest, HttpResponse, MediaTypes} import akka.http.scaladsl.server.Directives import akka.stream.ActorMaterializer import akka.stream.scaladsl.{Sink, Source} +import akka.util.ByteString import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} import scala.concurrent.Await @@ -34,12 +37,13 @@ class EchoSpec extends WordSpec with Matchers with BeforeAndAfterAll with Direct "Echo" should { "handle" in { val future = Source - .single(HttpRequest(uri = "http://a.b", headers = List(`User-Agent`("mock")))) + .single(HttpRequest(uri = "http://foo.bar", headers = List(Host("foo.bar")))) .via(Echo()) .runWith(Sink.head) - Await.result(future, Duration.Inf) shouldBe HttpResponse( - entity = HttpEntity(ContentTypes.`application/json`, """{"body":"","headers":["User-Agent: mock"],"method":"GET","uri":"http://a.b"}""") - ) + Await.result(future, Duration.Inf) match { + case HttpResponse(_, _, Strict(WithMissingCharset(MediaTypes.`text/plain`), bs), _) => + bs.decodeString(ByteString.UTF_8) shouldBe "GET http://foo.bar HTTP/1.1\r\nHost: foo.bar\r\nUser-Agent: akka-http/10.1.6\r\n\r\n" + } } } diff --git a/src/test/scala/zhongl/passport/EitherForkSpec.scala b/src/test/scala/zhongl/passport/EitherForkSpec.scala new file mode 100644 index 0000000..5e9534d --- /dev/null +++ b/src/test/scala/zhongl/passport/EitherForkSpec.scala @@ -0,0 +1,39 @@ +package zhongl.passport + +import akka.actor.ActorSystem +import akka.stream.scaladsl.{Flow, GraphDSL, Merge, Sink, Source} +import akka.stream.{ActorMaterializer, FlowShape} +import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} + +import scala.concurrent.Await +import scala.concurrent.duration.Duration + +class EitherForkSpec extends WordSpec with BeforeAndAfterAll with Matchers { + implicit val system = ActorSystem(getClass.getSimpleName) + implicit val mat = ActorMaterializer() + + "EitherFork" should { + "fork" in { + + val flow = Flow.fromGraph(GraphDSL.create() { implicit b => + import GraphDSL.Implicits._ + + val fork = b.add(EitherFork[Int, String]()) + val toS = b.add(Flow[Int].map(_.toString)) + val merge = b.add(Merge[String](2)) + + // format: OFF + fork.out0 ~> toS ~> merge + fork.out1 ~> merge + // format: ON + + new FlowShape(fork.in, merge.out) + }) + + val f = Source(List[Either[Int, String]](Left(1), Right("2"))).via(flow).runWith(Sink.seq) + Await.result(f, Duration.Inf) shouldBe Vector("1", "2") + } + } + + override protected def afterAll(): Unit = system.terminate() +} From 9c7fb9991658e1f5fda2db689cbbdf321915d0ac Mon Sep 17 00:00:00 2001 From: zhongl Date: Fri, 22 Feb 2019 22:59:44 +0800 Subject: [PATCH 21/24] Stash. --- src/main/scala/zhongl/passport/Docker.scala | 9 ++-- src/main/scala/zhongl/passport/Dynamic.scala | 49 ------------------- src/main/scala/zhongl/passport/Handle.scala | 3 +- .../zhongl/passport/RewriteRequestActor.scala | 40 +++++++++++---- ...ec.scala => RewriteRequestActorSpec.scala} | 22 ++------- 5 files changed, 37 insertions(+), 86 deletions(-) delete mode 100644 src/main/scala/zhongl/passport/Dynamic.scala rename src/test/scala/zhongl/passport/{DynamicSpec.scala => RewriteRequestActorSpec.scala} (78%) diff --git a/src/main/scala/zhongl/passport/Docker.scala b/src/main/scala/zhongl/passport/Docker.scala index c4e7348..edf99bc 100644 --- a/src/main/scala/zhongl/passport/Docker.scala +++ b/src/main/scala/zhongl/passport/Docker.scala @@ -56,10 +56,8 @@ class Docker(base: Uri, outgoing: () => Graph[FlowShape[HttpRequest, HttpRespons .single(request) .via(outgoing()) .log(s"docker events") - .withAttributes(Attributes.logLevels(Logging.WarningLevel)) + .withAttributes(Attributes.logLevels(Logging.InfoLevel)) .flatMapConcat(chunks) - .log("docker flat") - .withAttributes(Attributes.logLevels(Logging.WarningLevel)) .map(_.data()) } @@ -79,14 +77,13 @@ class Docker(base: Uri, outgoing: () => Graph[FlowShape[HttpRequest, HttpRespons case HttpResponse(StatusCodes.OK, _, entity, _) => Unmarshal(entity).to[T] } - val get = Source - .single(request) + Flow[Any] + .map(_ => request) .via(outgoing()) .log(s"docker $path") .withAttributes(Attributes.logLevels(Logging.InfoLevel)) .mapAsync(1)(unmarshal) .log(s"docker $path") - Flow.fromSinkAndSource(Sink.ignore, get) } } diff --git a/src/main/scala/zhongl/passport/Dynamic.scala b/src/main/scala/zhongl/passport/Dynamic.scala deleted file mode 100644 index 0a846bd..0000000 --- a/src/main/scala/zhongl/passport/Dynamic.scala +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2019 Zhong Lunfu - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package zhongl.passport - -import akka.NotUsed -import akka.stream.scaladsl.{Flow, Source} -import akka.util.ByteString - -object Dynamic { - val label = "passport.rule" - - val filterByLabel = Map("label" -> List(label)) - - def by(docker: Docker): String => Source[List[(String, String)], NotUsed] = { - case "docker" => - source( - docker.events(Map("scope" -> List("local"), "type" -> List("container"), "event" -> List("start", "destroy"))), - docker.containers(filterByLabel).map(_.map(c => c.`Labels`(label) -> c.`Names`.head.substring(1))) - ) - case _ => - source( - docker.events(Map("scope" -> List("swarm"), "type" -> List("service"), "event" -> List("update", "remove"))), - docker.services(filterByLabel).map(_.map(c => c.`Spec`.`Labels`(label) -> c.`Spec`.`Name`)) - ) - } - - private def source( - events: Source[ByteString, Any], - get: Flow[Any, List[(String, String)], NotUsed] - ): Source[List[(String, String)], NotUsed] = { - Source.single(ByteString.empty).concat(events).via(get) - } - - -} diff --git a/src/main/scala/zhongl/passport/Handle.scala b/src/main/scala/zhongl/passport/Handle.scala index 06b8332..a16eda3 100644 --- a/src/main/scala/zhongl/passport/Handle.scala +++ b/src/main/scala/zhongl/passport/Handle.scala @@ -81,8 +81,7 @@ object Handle { val base = IgnoreHeader(_.isInstanceOf[`Timeout-Access`]) & XForwardedFor(local) dynamic - .map(Dynamic.by(Docker())) - .map(s => system.actorOf(RewriteRequestActor.props(base, s), "RewriteRequest")) + .map(s => system.actorOf(RewriteRequestActor.props(base, Docker())(s), "RewriteRequest")) .map(a => Flow[HttpRequest].ask[Either[HttpResponse, HttpRequest]](a)) .getOrElse(Flow[HttpRequest].map(base & HostOfUri())) } diff --git a/src/main/scala/zhongl/passport/RewriteRequestActor.scala b/src/main/scala/zhongl/passport/RewriteRequestActor.scala index 2e2f369..81c0be9 100644 --- a/src/main/scala/zhongl/passport/RewriteRequestActor.scala +++ b/src/main/scala/zhongl/passport/RewriteRequestActor.scala @@ -18,13 +18,14 @@ package zhongl.passport import java.util.regex.Pattern +import akka.NotUsed import akka.actor.Status.{Failure, Success} import akka.actor.{Actor, ActorLogging, Props, Stash} -import akka.event.Logging import akka.http.scaladsl.model.HttpRequest import akka.http.scaladsl.model.headers.Host -import akka.stream.scaladsl.{Sink, Source} -import akka.stream.{ActorMaterializer, Attributes} +import akka.stream.ActorMaterializer +import akka.stream.scaladsl.{Flow, Sink, Source} +import akka.util.ByteString import zhongl.passport.Rewrite.Request final class RewriteRequestActor private (base: Rewrite.Request, source: Source[List[(String, String)], Any]) @@ -38,12 +39,12 @@ final class RewriteRequestActor private (base: Rewrite.Request, source: Source[L private implicit val mat = ActorMaterializer()(context) private implicit val ex = context.dispatcher - source.log("update").withAttributes(Attributes.logLevels(Logging.InfoLevel)).map(locate).runWith(Sink.actorRef(self, Complete)) + source.map(locate).runWith(Sink.actorRef(self, Complete)) override def receive: Receive = { case Failure(cause) => dying(cause) case Locate(g) => unstashAll(); context.become(rewrite(HostOfUri(g) & base)) - case Complete => log.info(s"init receive complete"); context.stop(self) + case Complete => context.stop(self) case m => log.info(s"init receive $m"); stash() } @@ -68,7 +69,7 @@ final class RewriteRequestActor private (base: Rewrite.Request, source: Source[L } } - private def locate(rules: List[(String, String)]): Host => Option[Host] = { + private def locate(rules: List[(String, String)]): Locate = { val rs = rules.map { p => p._1.split("\\s*\\|>\\|\\s*:", 2) match { case Array(r, port) => (Pattern.compile(r), Host(p._2, port.toInt)) @@ -76,16 +77,35 @@ final class RewriteRequestActor private (base: Rewrite.Request, source: Source[L } } - host => - rs.find(p => p._1.matcher(host.host.address()).matches()) - .map(p => p._2) + Locate(host => rs.find(p => p._1.matcher(host.host.address()).matches()).map(p => p._2)) } } object RewriteRequestActor { - def props(base: Request, source: Source[List[(String, String)], Any]): Props = Props(new RewriteRequestActor(base, source)) + type Filters = Map[String, List[String]] + + private val label = "passport.rule" + + def props(base: Request, docker: Docker): String => Props = { + case "docker" => + val filters = Map("scope" -> List("local"), "type" -> List("container"), "event" -> List("start", "destroy")) + val update = docker.containers(_: Filters).map(_.map(c => c.`Labels`(label) -> c.`Names`.head.substring(1))) + props(base, source(docker.events(filters), update)) + case _ => + val filters = Map("scope" -> List("swarm"), "type" -> List("service"), "event" -> List("update", "remove")) + val update = docker.services(_: Filters).map(_.map(c => c.`Spec`.`Labels`(label) -> c.`Spec`.`Name`)) + props(base, source(docker.events(filters), update)) + } + + def props(base: Request, source: Source[List[(String, String)], Any]): Props = { + Props(new RewriteRequestActor(base, source)) + } + + private def source(events: Source[ByteString, Any], update: Filters => Flow[Any, List[(String, String)], NotUsed]) = { + Source.single(ByteString.empty).concat(events).via(update(Map("label" -> List(label)))) + } private sealed trait Message private final case class Locate(g: Host => Option[Host]) extends Message diff --git a/src/test/scala/zhongl/passport/DynamicSpec.scala b/src/test/scala/zhongl/passport/RewriteRequestActorSpec.scala similarity index 78% rename from src/test/scala/zhongl/passport/DynamicSpec.scala rename to src/test/scala/zhongl/passport/RewriteRequestActorSpec.scala index 8ab85fb..8845299 100644 --- a/src/test/scala/zhongl/passport/DynamicSpec.scala +++ b/src/test/scala/zhongl/passport/RewriteRequestActorSpec.scala @@ -25,14 +25,14 @@ import akka.http.scaladsl.model.{ContentTypes, Uri} import akka.http.scaladsl.server.{Directives, Route} import akka.stream.ActorMaterializer import akka.stream.alpakka.unixdomainsocket.scaladsl.UnixDomainSocket -import akka.stream.scaladsl.{Sink, Source, TLSPlacebo} +import akka.stream.scaladsl.{Source, TLSPlacebo} import akka.util.ByteString import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} import scala.concurrent.Await import scala.concurrent.duration._ -class DynamicSpec extends WordSpec with Matchers with BeforeAndAfterAll with Directives with Docker.JsonSupport { +class RewriteRequestActorSpec extends WordSpec with Matchers with BeforeAndAfterAll with Directives with Docker.JsonSupport { private implicit val system = ActorSystem(getClass.getSimpleName) private implicit val mat = ActorMaterializer() @@ -52,24 +52,8 @@ class DynamicSpec extends WordSpec with Matchers with BeforeAndAfterAll with Dir private val docker = Docker(Uri(file.toURI.toString).withScheme("unix").toString()) - "Dynamic" should { + "RewriteRequestActor" should { - "111" in { - val f = Dynamic.by(docker).apply("docker").runForeach(println) - Await.result(f, Duration.Inf) - - } - - "by docker local" ignore { - val f = Dynamic.by(docker).apply("docker").runWith(Sink.head) - Await.result(f, Duration.Inf) shouldBe List(".+" -> "demo:8080") - - } - - "by docker swarm" ignore { - val f = Dynamic.by(docker).apply("swarm").runWith(Sink.head) - Await.result(f, Duration.Inf) shouldBe List(".+" -> "demo") - } } From 1c780f2c75effdd7d616f46f360d4d657d95d35d Mon Sep 17 00:00:00 2001 From: zhongl Date: Fri, 22 Feb 2019 23:00:23 +0800 Subject: [PATCH 22/24] Stash. --- .../scala/zhongl/passport/CachedLatest.scala | 54 ------------------- 1 file changed, 54 deletions(-) delete mode 100644 src/main/scala/zhongl/passport/CachedLatest.scala diff --git a/src/main/scala/zhongl/passport/CachedLatest.scala b/src/main/scala/zhongl/passport/CachedLatest.scala deleted file mode 100644 index 29683e2..0000000 --- a/src/main/scala/zhongl/passport/CachedLatest.scala +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2019 Zhong Lunfu - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package zhongl.passport - -import akka.stream.stage.{GraphStage, GraphStageLogic, InHandler, OutHandler} -import akka.stream.{Attributes, FlowShape, Inlet, Outlet} - -final class CachedLatest[T] extends GraphStage[FlowShape[T, T]] { - val in = Inlet[T]("CachedLatest.in") - val out = Outlet[T]("CachedLatest.out") - - override val shape = FlowShape.of(in, out) - - override def createLogic(inheritedAttributes: Attributes): GraphStageLogic = new GraphStageLogic(shape) { - private var currentValue: T = _ - private var waitingFirstValue = true - - setHandlers(in, out, new InHandler with OutHandler { - override def onPush(): Unit = { - currentValue = grab(in) - if (waitingFirstValue) { - waitingFirstValue = false - if (isAvailable(out)) push(out, currentValue) - } - pull(in) - } - - override def onPull(): Unit = { - if (!waitingFirstValue) push(out, currentValue) - } - }) - - override def preStart(): Unit = { - pull(in) - } - } -} -object CachedLatest { - def apply[T](): CachedLatest[T] = new CachedLatest() -} From a2395938d4ff77b1b5b4ec63a4ca963f4f0077aa Mon Sep 17 00:00:00 2001 From: zhongl Date: Sat, 23 Feb 2019 09:03:20 +0800 Subject: [PATCH 23/24] Change dynamic opt to arg. --- src/main/scala/zhongl/passport/CommandLine.scala | 11 ++++++----- src/main/scala/zhongl/passport/Handle.scala | 10 ++++------ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/main/scala/zhongl/passport/CommandLine.scala b/src/main/scala/zhongl/passport/CommandLine.scala index c6f020e..49cf48e 100644 --- a/src/main/scala/zhongl/passport/CommandLine.scala +++ b/src/main/scala/zhongl/passport/CommandLine.scala @@ -19,7 +19,7 @@ package zhongl.passport import scopt.OptionParser object CommandLine { - case class Opt(host: String = "0.0.0.0", port: Int = 8080, echo: Boolean = false, dynamic: Option[String] = None) + case class Opt(host: String = "0.0.0.0", port: Int = 8080, echo: Boolean = false, dynamic: String = "docker") val parser = new OptionParser[Opt]("passport") { head("passport", "0.0.1") @@ -36,13 +36,14 @@ object CommandLine { .action((_, c) => c.copy(echo = true)) .text("enable echo mode for debug, default is disable") - opt[String]('d', "dynamic") - .action((x, c) => c.copy(dynamic = Some(x))) + arg[String]("") + .required() + .action((x, c) => c.copy(dynamic = x)) .validate { case "docker" | "swarm" => success - case _ => failure("Option -d or --dynamic must be docker or swarm.") + case _ => failure("dynamic mode must be docker or swarm.") } - .text("enable dynamic dispatch, which's value must be docker or swarm, default is disable") + .text("enable dynamic dispatch, which's value must be docker or swarm, default is docker") help("help").text("print this usage") } diff --git a/src/main/scala/zhongl/passport/Handle.scala b/src/main/scala/zhongl/passport/Handle.scala index a16eda3..ada0dfe 100644 --- a/src/main/scala/zhongl/passport/Handle.scala +++ b/src/main/scala/zhongl/passport/Handle.scala @@ -33,7 +33,7 @@ import scala.util.control.NonFatal object Handle { - def apply(dynamic: Option[String])(implicit system: ActorSystem): Flow[HttpRequest, HttpResponse, NotUsed] = { + def apply(dynamic: String)(implicit system: ActorSystem): Flow[HttpRequest, HttpResponse, NotUsed] = { val graph = GraphDSL.create() { implicit b => import GraphDSL.Implicits._ @@ -68,7 +68,7 @@ object Handle { Guard.graph(plugin.oauth2(jc.generate), ignore)(system.dispatcher) } - private def rewriteShape(dynamic: Option[String])(implicit system: ActorSystem) = { + private def rewriteShape(dynamic: String)(implicit system: ActorSystem) = { import Rewrite._ implicit val timeout = Timeout(2.seconds) @@ -80,9 +80,7 @@ object Handle { val base = IgnoreHeader(_.isInstanceOf[`Timeout-Access`]) & XForwardedFor(local) - dynamic - .map(s => system.actorOf(RewriteRequestActor.props(base, Docker())(s), "RewriteRequest")) - .map(a => Flow[HttpRequest].ask[Either[HttpResponse, HttpRequest]](a)) - .getOrElse(Flow[HttpRequest].map(base & HostOfUri())) + val ref = system.actorOf(RewriteRequestActor.props(base, Docker())(dynamic), "RewriteRequest") + Flow[HttpRequest].ask[Either[HttpResponse, HttpRequest]](ref) } } \ No newline at end of file From b8d631349d434578fd12d2db900e81cca52ecb65 Mon Sep 17 00:00:00 2001 From: zhongl Date: Sat, 23 Feb 2019 21:37:07 +0800 Subject: [PATCH 24/24] Support docker and swarm. --- README.md | 19 +-- docker-compose.yml | 47 ++++---- .../scala/zhongl/passport/CommandLine.scala | 6 +- src/main/scala/zhongl/passport/Docker.scala | 72 +++++++---- src/main/scala/zhongl/passport/Handle.scala | 8 +- src/main/scala/zhongl/passport/Main.scala | 2 +- .../zhongl/passport/RewriteRequestActor.scala | 86 ++++---------- .../scala/zhongl/passport/DockerSpec.scala | 103 ++++++++++++++++ .../scala/zhongl/passport/HandlersSpec.scala | 2 +- .../passport/RewriteRequestActorSpec.scala | 112 ++++++++---------- 10 files changed, 268 insertions(+), 189 deletions(-) create mode 100644 src/test/scala/zhongl/passport/DockerSpec.scala diff --git a/README.md b/README.md index 21a40c5..6ae2874 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,19 @@ Passport 是一个超轻量级统一认证网关, 面向使用 [钉钉](https://www.dingtalk.com) 或是 [企业微信](https://work.weixin.qq.com/) 的创业团队提供手机扫码登录访问内部服务. +## 跑起来 + +```sh +curl -LkO https://github.com/zhongl/passport/raw/master/docker-compose.yml +curl -LkO https://github.com/zhongl/passport/raw/master/app.conf +DOMAIN=foo.bar docker-compose up -d +curl -k -v https://localhost -H 'Host: www.foo.bar' -H 'Cookie: jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJwYXNzcG9ydCIsIm5hbWUiOiJ6aG9uZ2wiLCJleHAiOjE4NjYxNzI3MjV9.FomLr4SgRvHuI6iUnVZc2-Q9YQbNrh4eDWGbM09xoC8' +``` + +- 细节请详见 [docker-compose.yml](https://github.com/zhongl/passport/blob/master/docker-compose.yml) . -# 部署 -## 配置 app.conf +## 配置 ### 钉钉 @@ -62,12 +71,6 @@ wechat { > 参见[企业内部开发](https://work.weixin.qq.com/api/doc#90000/90003/90487), 创建**应用**. -## 运行 - -```sh -docker run -d -v $(pwd)/app.conf:/app.conf -e JAVA_OPTS=-Dconfig.file=/app.conf zhongl/passport -``` - ## Echo调试 若需要在真正部署之前进行调试验证, 可在运行时指定`-e`: diff --git a/docker-compose.yml b/docker-compose.yml index 1f02db7..e2d88e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,47 +1,44 @@ version: '3.3' services: -# gateway: -# image: traefik:latest -# command: -# - "--api" -# - "--entrypoints=Name:http Address::80 Redirect.EntryPoint:https" -# - "--entrypoints=Name:https Address::443 TLS" -# - "--defaultentrypoints=http,https" -# - "--docker" -# - "--docker.watch" -# - "--docker.domain=${DOMAIN}" -# - "--accesslog" -# - "--traefikLog" -# volumes: -# - /var/run/docker.sock:/var/run/docker.sock -# ports: -# - 80:80 -# - 443:443 + gateway: + image: traefik:latest + command: + - "--api" + - "--entrypoints=Name:http Address::80 Redirect.EntryPoint:https" + - "--entrypoints=Name:https Address::443 TLS" + - "--defaultentrypoints=http,https" + - "--docker" + - "--docker.watch" + - "--docker.domain=${DOMAIN}" + - "--accesslog" + - "--traefikLog" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + ports: + - 80:80 + - 443:443 passport: image: zhongl/passport:latest - command: - - "-d:docker" volumes: - /var/run/docker.sock:/var/run/docker.sock -# labels: -# traefik.port: 8080 -# traefik.frontend.rule: "HostRegexp: {subdomain:[a-z]+}.${DOMAIN}" + labels: + traefik.port: 8080 + traefik.frontend.rule: "HostRegexp: {subdomain:[a-z]+}.${DOMAIN}" environment: JAVA_TOOL_OPTIONS: -Dconfig.file=/run/secrets/conf DOMAIN: $DOMAIN secrets: - conf - ports: - - 80:8080 echo: image: zhongl/passport:latest command: - "-e" labels: - passport.rule: "www.${DOMAIN}|>|:8080" + passport.rule: "www.${DOMAIN}" + passport.port: 8080 secrets: conf: diff --git a/src/main/scala/zhongl/passport/CommandLine.scala b/src/main/scala/zhongl/passport/CommandLine.scala index 49cf48e..bf2e946 100644 --- a/src/main/scala/zhongl/passport/CommandLine.scala +++ b/src/main/scala/zhongl/passport/CommandLine.scala @@ -19,7 +19,7 @@ package zhongl.passport import scopt.OptionParser object CommandLine { - case class Opt(host: String = "0.0.0.0", port: Int = 8080, echo: Boolean = false, dynamic: String = "docker") + case class Opt(host: String = "0.0.0.0", port: Int = 8080, echo: Boolean = false, dynamic: Docker.Mode.Value = Docker.Mode.Local) val parser = new OptionParser[Opt]("passport") { head("passport", "0.0.1") @@ -37,8 +37,8 @@ object CommandLine { .text("enable echo mode for debug, default is disable") arg[String]("") - .required() - .action((x, c) => c.copy(dynamic = x)) + .action { case (Docker.Mode(value), c) => c.copy(dynamic = value) } + .optional() .validate { case "docker" | "swarm" => success case _ => failure("dynamic mode must be docker or swarm.") diff --git a/src/main/scala/zhongl/passport/Docker.scala b/src/main/scala/zhongl/passport/Docker.scala index edf99bc..30f9219 100644 --- a/src/main/scala/zhongl/passport/Docker.scala +++ b/src/main/scala/zhongl/passport/Docker.scala @@ -17,19 +17,17 @@ package zhongl.passport import java.io.File -import java.net.InetSocketAddress import akka.NotUsed import akka.actor.ActorSystem import akka.event.Logging +import akka.http.scaladsl.Http import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport import akka.http.scaladsl.model.HttpEntity.{ChunkStreamPart, Chunked} import akka.http.scaladsl.model.Uri.Path import akka.http.scaladsl.model._ import akka.http.scaladsl.model.headers.Host -import akka.http.scaladsl.settings.ClientConnectionSettings import akka.http.scaladsl.unmarshalling.{Unmarshal, Unmarshaller} -import akka.http.scaladsl.{ClientTransport, Http} import akka.stream.alpakka.unixdomainsocket.scaladsl.UnixDomainSocket import akka.stream.scaladsl._ import akka.stream.{ActorMaterializer, Attributes, FlowShape, Graph} @@ -38,11 +36,11 @@ import spray.json._ import zhongl.passport.Docker.{Container, Service} import scala.concurrent.Future +import scala.util.matching.Regex class Docker(base: Uri, outgoing: () => Graph[FlowShape[HttpRequest, HttpResponse], Any])(implicit system: ActorSystem) extends Docker.JsonSupport { private implicit val mat = ActorMaterializer() - private implicit val ex = system.dispatcher def events(filters: Map[String, List[String]]): Source[ByteString, Any] = { val query = Uri.Query("filters" -> filters.toJson.compactPrint) @@ -113,31 +111,57 @@ object Docker { final case class Service(`ID`: String, `Spec`: Spec) final case class Spec(`Name`: String, `Labels`: Map[String, String]) - final class UnixSocketTransport(file: File) extends ClientTransport { - override def connectTo( - host: String, - port: Int, - settings: ClientConnectionSettings - )(implicit system: ActorSystem): Flow[ByteString, ByteString, Future[Http.OutgoingConnection]] = { + trait JsonSupport extends DefaultJsonProtocol with SprayJsonSupport { + implicit val specF: JsonFormat[Spec] = jsonFormat2(Spec) + implicit val containerF: JsonFormat[Container] = jsonFormat3(Container) + implicit val serviceF: JsonFormat[Service] = jsonFormat2(Service) + } + + object Mode { - implicit val ex = system.dispatcher - val address = InetSocketAddress.createUnresolved(host, port) + type Rules = List[(Regex, Host)] + type Filters = Map[String, List[String]] - system.log.error(new Exception(), "connect") + private val rule = "passport.rule" - UnixDomainSocket() - .outgoingConnection(file) - .mapMaterializedValue(_.map { _ => - system.log.error(new Exception(), "materialize") - Http.OutgoingConnection(address, address) - }) + def unapply(arg: String): Option[Value] = arg match { + case "docker" => Some(Local) + case "swarm" => Some(Swarm) + case _ => None + } + + sealed trait Value { + def apply(docker: Docker): Source[Rules, NotUsed] + } + + case object Local extends Value { + override def apply(docker: Docker): Source[Rules, NotUsed] = { + val filters = Map("scope" -> List("local"), "type" -> List("container"), "event" -> List("start", "destroy")) + val update = docker + .containers(_: Filters) + .map(_.map(c => c.`Labels`(rule).r -> Host(c.`Names`.head.substring(1), port(c.`Labels`)))) + source(docker.events(filters), update) + } + } + + case object Swarm extends Value { + override def apply(docker: Docker): Source[Rules, NotUsed] = { + val filters = Map("scope" -> List("swarm"), "type" -> List("service"), "event" -> List("update", "remove")) + val update = docker + .services(_: Filters) + .map(_.map(c => c.`Spec`.`Labels`(rule).r -> Host(c.`Spec`.`Name`, port(c.`Spec`.`Labels`)))) + source(docker.events(filters), update) + } + } + + private def port(labels: Map[String, String]): Int = { + labels.get("passport.port").map(_.toInt).getOrElse(0) + } + + private def source(events: Source[ByteString, Any], update: Filters => Flow[Any, Rules, NotUsed]) = { + Source.single(ByteString.empty).concat(events).via(update(Map("label" -> List(rule)))) } - } - trait JsonSupport extends DefaultJsonProtocol with SprayJsonSupport { - implicit val specF: JsonFormat[Spec] = jsonFormat2(Spec) - implicit val containerF: JsonFormat[Container] = jsonFormat3(Container) - implicit val serviceF: JsonFormat[Service] = jsonFormat2(Service) } } diff --git a/src/main/scala/zhongl/passport/Handle.scala b/src/main/scala/zhongl/passport/Handle.scala index ada0dfe..ba7169c 100644 --- a/src/main/scala/zhongl/passport/Handle.scala +++ b/src/main/scala/zhongl/passport/Handle.scala @@ -22,7 +22,7 @@ import akka.http.scaladsl.model.headers.`Timeout-Access` import akka.http.scaladsl.model.{HttpRequest, HttpResponse, StatusCodes} import akka.http.scaladsl.util.FastFuture import akka.stream.FlowShape -import akka.stream.scaladsl.{Flow, GraphDSL, Merge} +import akka.stream.scaladsl.{Flow, GraphDSL, Merge, Source} import akka.util.Timeout import zhongl.stream.oauth2.Guard @@ -33,7 +33,7 @@ import scala.util.control.NonFatal object Handle { - def apply(dynamic: String)(implicit system: ActorSystem): Flow[HttpRequest, HttpResponse, NotUsed] = { + def apply(dynamic: Source[Docker.Mode.Rules, Any])(implicit system: ActorSystem): Flow[HttpRequest, HttpResponse, NotUsed] = { val graph = GraphDSL.create() { implicit b => import GraphDSL.Implicits._ @@ -68,7 +68,7 @@ object Handle { Guard.graph(plugin.oauth2(jc.generate), ignore)(system.dispatcher) } - private def rewriteShape(dynamic: String)(implicit system: ActorSystem) = { + private def rewriteShape(dynamic: Source[Docker.Mode.Rules, Any])(implicit system: ActorSystem) = { import Rewrite._ implicit val timeout = Timeout(2.seconds) @@ -80,7 +80,7 @@ object Handle { val base = IgnoreHeader(_.isInstanceOf[`Timeout-Access`]) & XForwardedFor(local) - val ref = system.actorOf(RewriteRequestActor.props(base, Docker())(dynamic), "RewriteRequest") + val ref = system.actorOf(RewriteRequestActor.props(dynamic, Some(base)), "RewriteRequest") Flow[HttpRequest].ask[Either[HttpResponse, HttpRequest]](ref) } } \ No newline at end of file diff --git a/src/main/scala/zhongl/passport/Main.scala b/src/main/scala/zhongl/passport/Main.scala index 07f5c9d..b4560ff 100644 --- a/src/main/scala/zhongl/passport/Main.scala +++ b/src/main/scala/zhongl/passport/Main.scala @@ -50,7 +50,7 @@ object Main extends Directives { case Opt(host, port, true, _) => (host, port, Echo()) case Opt(host, port, _, d) => - (host, port, Handle(d)) + (host, port, Handle(d(Docker()))) } map { case (host, port, flow) => bind(flow, host, port) } getOrElse system.terminate() diff --git a/src/main/scala/zhongl/passport/RewriteRequestActor.scala b/src/main/scala/zhongl/passport/RewriteRequestActor.scala index 81c0be9..3a8ce83 100644 --- a/src/main/scala/zhongl/passport/RewriteRequestActor.scala +++ b/src/main/scala/zhongl/passport/RewriteRequestActor.scala @@ -16,98 +16,62 @@ package zhongl.passport -import java.util.regex.Pattern - -import akka.NotUsed import akka.actor.Status.{Failure, Success} import akka.actor.{Actor, ActorLogging, Props, Stash} import akka.http.scaladsl.model.HttpRequest -import akka.http.scaladsl.model.headers.Host import akka.stream.ActorMaterializer -import akka.stream.scaladsl.{Flow, Sink, Source} -import akka.util.ByteString -import zhongl.passport.Rewrite.Request +import akka.stream.scaladsl.{Sink, Source} +import zhongl.passport.Rewrite._ -final class RewriteRequestActor private (base: Rewrite.Request, source: Source[List[(String, String)], Any]) +final class RewriteRequestActor private (source: Source[Docker.Mode.Rules, Any], mayBeRequest: Option[Request]) extends Actor with Stash with ActorLogging { - import Rewrite._ import RewriteRequestActor._ private implicit val mat = ActorMaterializer()(context) - private implicit val ex = context.dispatcher - source.map(locate).runWith(Sink.actorRef(self, Complete)) + source + .map(locate) + .map(g => mayBeRequest.map(_ & g).getOrElse(g)) + .map(Locate) + .runWith(Sink.actorRef(self, Complete)) override def receive: Receive = { - case Failure(cause) => dying(cause) - case Locate(g) => unstashAll(); context.become(rewrite(HostOfUri(g) & base)) + case Failure(cause) => unstashAll(); context.become(dying(cause)) + case Locate(f) => unstashAll(); context.become(rewrite(f)) case Complete => context.stop(self) - case m => log.info(s"init receive $m"); stash() + case _ => stash() } private def dying(cause: Throwable): Receive = { - log.warning("enter dying") - - { - case _: HttpRequest => - sender() ! Failure(cause) - log.error(cause, "Stop actor cause by source failure") - context.stop(self) - } + case _: HttpRequest => + sender() ! Failure(cause) + log.error(cause, "Stop actor cause by source failure") + context.stop(self) } private def rewrite(f: Request): Receive = { - log.info("enter rewrite") - - { - case r: HttpRequest => sender() ! Success(f(r)) - case Failure(cause) => dying(cause) - case Locate(g) => context.become(rewrite(HostOfUri(g) & base)) - } - } - - private def locate(rules: List[(String, String)]): Locate = { - val rs = rules.map { p => - p._1.split("\\s*\\|>\\|\\s*:", 2) match { - case Array(r, port) => (Pattern.compile(r), Host(p._2, port.toInt)) - case Array(r) => (Pattern.compile(r), Host(p._2, 0)) - } - } - - Locate(host => rs.find(p => p._1.matcher(host.host.address()).matches()).map(p => p._2)) + case r: HttpRequest => sender() ! Success(f(r)) + case Locate(g) => context.become(rewrite(g)) + case Complete => context.stop(self) + case Failure(cause) => context.become(dying(cause)) } } object RewriteRequestActor { - type Filters = Map[String, List[String]] - - private val label = "passport.rule" - - def props(base: Request, docker: Docker): String => Props = { - case "docker" => - val filters = Map("scope" -> List("local"), "type" -> List("container"), "event" -> List("start", "destroy")) - val update = docker.containers(_: Filters).map(_.map(c => c.`Labels`(label) -> c.`Names`.head.substring(1))) - props(base, source(docker.events(filters), update)) - case _ => - val filters = Map("scope" -> List("swarm"), "type" -> List("service"), "event" -> List("update", "remove")) - val update = docker.services(_: Filters).map(_.map(c => c.`Spec`.`Labels`(label) -> c.`Spec`.`Name`)) - props(base, source(docker.events(filters), update)) - } - - def props(base: Request, source: Source[List[(String, String)], Any]): Props = { - Props(new RewriteRequestActor(base, source)) + def props(source: Source[Docker.Mode.Rules, Any], mayBeRequest: Option[Request] = None): Props = { + Props(new RewriteRequestActor(source, mayBeRequest)) } - private def source(events: Source[ByteString, Any], update: Filters => Flow[Any, List[(String, String)], NotUsed]) = { - Source.single(ByteString.empty).concat(events).via(update(Map("label" -> List(label)))) + def locate(rules: Docker.Mode.Rules): Request = { + Rewrite.HostOfUri(h => rules.find(p => p._1.pattern.matcher(h.host.address()).matches()).map(p => p._2)) } private sealed trait Message - private final case class Locate(g: Host => Option[Host]) extends Message - private case object Complete extends Message + private final case class Locate(g: Rewrite.Request) extends Message + private case object Complete extends Message } diff --git a/src/test/scala/zhongl/passport/DockerSpec.scala b/src/test/scala/zhongl/passport/DockerSpec.scala new file mode 100644 index 0000000..5d0dce2 --- /dev/null +++ b/src/test/scala/zhongl/passport/DockerSpec.scala @@ -0,0 +1,103 @@ +/* + * Copyright 2019 Zhong Lunfu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zhongl.passport + +import java.nio.file.Files + +import akka.actor.ActorSystem +import akka.http.scaladsl.Http +import akka.http.scaladsl.model.HttpEntity.Chunked +import akka.http.scaladsl.model.headers.Host +import akka.http.scaladsl.model.{ContentTypes, Uri} +import akka.http.scaladsl.server.{Directives, Route} +import akka.stream.ActorMaterializer +import akka.stream.alpakka.unixdomainsocket.scaladsl.UnixDomainSocket +import akka.stream.scaladsl.{Sink, Source, TLSPlacebo} +import akka.util.ByteString +import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} + +import scala.concurrent.Await +import scala.concurrent.duration._ + +class DockerSpec extends WordSpec with Matchers with BeforeAndAfterAll with Directives with Docker.JsonSupport { + + private implicit val system = ActorSystem(getClass.getSimpleName) + private implicit val mat = ActorMaterializer() + private implicit val ex = system.dispatcher + + private val file = { + val f = Files.createTempFile("passport", "sock").toFile + f.delete() + f.deleteOnExit() + f + } + + private val bound = { + val flow = mockDockerDaemon.join(Http().serverLayer()).join(TLSPlacebo()) + Await.result(UnixDomainSocket().bindAndHandle(flow, file), Duration.Inf) + } + + private val docker = Docker(Uri(file.toURI.toString).withScheme("unix").toString()) + + "Docker" should { + "run local mode" in { + "docker" match { + case Docker.Mode(local) => + Await.result(local(docker).runWith(Sink.head), Duration.Inf) match { + case List((r, Host(h, 8080))) => + r.regex shouldBe ".+" + h.address() shouldBe "demo" + } + + } + } + + "run swarm mode" in { + "swarm" match { + case Docker.Mode(swarm) => + Await.result(swarm(docker).runWith(Sink.head), Duration.Inf) match { + case List((r , Host(h, 0))) => + r.regex shouldBe ".+" + h.address() shouldBe "demo" + } + } + } + + } + + def mockDockerDaemon: Route = get { + concat( + path("events") { + complete(Chunked.fromData(ContentTypes.`application/json`, Source.repeat(ByteString("1")).delay(1.second))) + }, + (path("containers" / "json") & parameter("filters")) { _ => + complete(List(Docker.Container("id", List("/demo"), Map("passport.rule" -> ".+", "passport.port" -> "8080")))) + }, + (path("services") & parameter("filters")) { _ => + complete(List(Docker.Service("id", Docker.Spec("demo", Map("passport.rule" -> ".+"))))) + }, + pathEndOrSingleSlash { + complete("ok") + } + ) + } + + override protected def afterAll(): Unit = { + bound.unbind() + system.terminate() + } +} diff --git a/src/test/scala/zhongl/passport/HandlersSpec.scala b/src/test/scala/zhongl/passport/HandlersSpec.scala index 7985069..9f16083 100644 --- a/src/test/scala/zhongl/passport/HandlersSpec.scala +++ b/src/test/scala/zhongl/passport/HandlersSpec.scala @@ -31,7 +31,7 @@ class HandlersSpec extends WordSpec with Matchers with BeforeAndAfterAll { "Handlers" should { "create a flow with guard" in { - val flow = Handle(None) + val flow = Handle(Source.repeat(List.empty)) val future = Source.single(HttpRequest()).via(flow).runWith(Sink.head) Await.result(future, Duration.Inf) shouldBe HttpResponse(StatusCodes.Unauthorized) } diff --git a/src/test/scala/zhongl/passport/RewriteRequestActorSpec.scala b/src/test/scala/zhongl/passport/RewriteRequestActorSpec.scala index 8845299..267a4f7 100644 --- a/src/test/scala/zhongl/passport/RewriteRequestActorSpec.scala +++ b/src/test/scala/zhongl/passport/RewriteRequestActorSpec.scala @@ -1,81 +1,69 @@ -/* - * Copyright 2019 Zhong Lunfu - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - package zhongl.passport -import java.nio.file.Files - import akka.actor.ActorSystem -import akka.http.scaladsl.Http -import akka.http.scaladsl.model.HttpEntity.Chunked -import akka.http.scaladsl.model.{ContentTypes, Uri} -import akka.http.scaladsl.server.{Directives, Route} +import akka.http.scaladsl.model.headers.Host +import akka.http.scaladsl.model.{HttpRequest, HttpResponse, StatusCodes} import akka.stream.ActorMaterializer -import akka.stream.alpakka.unixdomainsocket.scaladsl.UnixDomainSocket -import akka.stream.scaladsl.{Source, TLSPlacebo} -import akka.util.ByteString -import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpec} +import akka.stream.scaladsl.{Sink, Source} +import akka.testkit.{ImplicitSender, TestKit, TestProbe} +import akka.util.Timeout +import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpecLike} import scala.concurrent.Await import scala.concurrent.duration._ +import scala.util.control.{NoStackTrace, NonFatal} -class RewriteRequestActorSpec extends WordSpec with Matchers with BeforeAndAfterAll with Directives with Docker.JsonSupport { - - private implicit val system = ActorSystem(getClass.getSimpleName) - private implicit val mat = ActorMaterializer() - private implicit val ex = system.dispatcher +class RewriteRequestActorSpec + extends TestKit(ActorSystem("RewriteRequest")) + with WordSpecLike + with Matchers + with ImplicitSender + with BeforeAndAfterAll { - private val file = { - val f = Files.createTempFile("passport", "sock").toFile - f.delete() - f.deleteOnExit() - f - } + private implicit val mat = ActorMaterializer() + private implicit val timeout = Timeout(3.seconds) - private val bound = { - val flow = mockDockerDaemon.join(Http().serverLayer()).join(TLSPlacebo()) - Await.result(UnixDomainSocket().bindAndHandle(flow, file), Duration.Inf) - } + "RewriteRequestActor" should { - private val docker = Docker(Uri(file.toURI.toString).withScheme("unix").toString()) + "handle request after locate function updated" in { + val ref = system.actorOf(RewriteRequestActor.props(Source.repeat(List(".+".r -> Host("demo"))).delay(1.seconds))) + val f = Source + .single(HttpRequest(uri = "http://localhost", headers = List(Host("localhost")))) + .ask[Either[HttpResponse, HttpRequest]](ref) + .runWith(Sink.head) + Await.result(f, Duration.Inf) shouldBe Right(HttpRequest(uri = "http://demo", headers = List(Host("localhost")))) + } - "RewriteRequestActor" should { + "stop self after rule source failed" in { + def test(source: Source[Docker.Mode.Rules, Any])(cause: Throwable) = { + val result = Left(HttpResponse(StatusCodes.InternalServerError)) + val ref = system.actorOf(RewriteRequestActor.props(source)) + val f = Source + .single(HttpRequest()).delay(1.second) + .ask[Either[HttpResponse, HttpRequest]](ref) + .recover { case NonFatal(`cause`) => result } + .runWith(Sink.head) + Await.result(f, Duration.Inf) shouldBe result + } + val cause = new Exception with NoStackTrace - } + test(Source.failed(cause))(cause) + test(Source(0 to 1).map(i => if (i == 0) List(".+".r -> Host("demo")) else throw cause))(cause) + } - def mockDockerDaemon: Route = get { - concat( - path("events") { - complete(Chunked.fromData(ContentTypes.`application/json`, Source.repeat(ByteString("1")).delay(1.second))) - }, - (path("containers" / "json") & parameter("filters")) { _ => - complete(List(Docker.Container("id", List("/demo"), Map("passport.rule" -> ".+|>|:8080")))) - }, - (path("services") & parameter("filters")) { _ => - complete(List(Docker.Service("id", Docker.Spec("demo", Map("passport.rule" -> ".+"))))) - }, - pathEndOrSingleSlash { - complete("ok") + "stop self after rules source complete" in { + def test(source: Source[Docker.Mode.Rules, Any]) = { + val probe = TestProbe() + val ref = system.actorOf(RewriteRequestActor.props(source)) + probe.watch(ref) + probe.expectTerminated(ref) } - ) - } - override protected def afterAll(): Unit = { - bound.unbind() - system.terminate() + test(Source.empty) + test(Source.single(List(".+".r -> Host("demo")))) + } } + + override protected def afterAll(): Unit = TestKit.shutdownActorSystem(system) }