~jan0sch/smederee

Showing details for patch 561d59a32428ce513c34acbdd03926cf007e5987.
2022-08-22 (Mon), 5:13 PM - Jens Grassel - 561d59a32428ce513c34acbdd03926cf007e5987

SSH: Start working on a ssh server

- proof of concept
Summary of changes
3 files added
  • modules/hub/src/it/scala/de/smederee/ssh/SshServerProviderTest.scala
  • modules/hub/src/main/scala/de/smederee/ssh/SshServer.scala
  • modules/hub/src/test/scala/de/smederee/ssh/SshUsernameTest.scala
3 files modified with 42 lines added and 0 lines removed
  • build.sbt with 7 added and 0 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala with 17 added and 0 removed lines
  • modules/hub/src/main/resources/reference.conf with 18 added and 0 removed lines
diff -rN -u old-smederee/build.sbt new-smederee/build.sbt
--- old-smederee/build.sbt	2025-02-02 14:56:56.243279658 +0000
+++ new-smederee/build.sbt	2025-02-02 14:56:56.247279664 +0000
@@ -161,6 +161,9 @@
       IntegrationTest / console / scalacOptions --= Seq("-Xfatal-warnings"),
       IntegrationTest / parallelExecution := false,
       libraryDependencies ++= Seq(
+        library.apacheSshdCore,
+        library.apacheSshdSftp,
+        library.apacheSshdScp,
         library.bouncyCastleProvider % Runtime,
         library.catsCore,
         library.circeCore,
@@ -316,6 +319,7 @@
 lazy val library =
   new {
     object Version {
+      val apacheSshd      = "2.9.0"
       val bouncyCastle    = "1.71"
       val cats            = "2.8.0"
       val catsEffect      = "3.3.14"
@@ -336,6 +340,9 @@
       val simpleJavaMail  = "7.1.3"
       val springSecurity  = "5.7.2"
     }
+    val apacheSshdCore       = "org.apache.sshd"              %  "sshd-core"              % Version.apacheSshd
+    val apacheSshdSftp       = "org.apache.sshd"              %  "sshd-sftp"              % Version.apacheSshd
+    val apacheSshdScp        = "org.apache.sshd"              %  "sshd-scp"               % Version.apacheSshd
     val bouncyCastleProvider = "org.bouncycastle"             %  "bcprov-jdk15to18"       % Version.bouncyCastle
     val catsCore             = "org.typelevel"                %% "cats-core"              % Version.cats
     val catsEffect           = "org.typelevel"                %% "cats-effect"            % Version.catsEffect
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala	2025-02-02 14:56:56.243279658 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/BaseSpec.scala	2025-02-02 14:56:56.247279664 +0000
@@ -9,7 +9,10 @@
 
 package de.smederee.hub
 
+import java.net.ServerSocket
+
 import cats.effect._
+import com.comcast.ip4s._
 import com.typesafe.config.ConfigFactory
 import de.smederee.hub.config._
 import pureconfig._
@@ -69,6 +72,20 @@
     }.unsafeRunSync()
   }
 
+  /** Find and return a free port on the local machine by starting a server socket and closing it. The port
+    * number used by the socket is marked to allow reuse, considered free and returned.
+    *
+    * @return
+    *   An optional port number if a free one can be found.
+    */
+  protected def findFreePort(): Option[Port] = {
+    val socket = new ServerSocket(0)
+    val port   = socket.getLocalPort
+    socket.setReuseAddress(true) // Allow instant rebinding of the socket.
+    socket.close()               // Free the socket for further use by closing it.
+    Port.fromInt(port)
+  }
+
   /** Provide a resource with a database connection to allow db operations and proper resource release later.
     *
     * @param cfg
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/ssh/SshServerProviderTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/ssh/SshServerProviderTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/ssh/SshServerProviderTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/ssh/SshServerProviderTest.scala	2025-02-02 14:56:56.247279664 +0000
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2022 Wegtam GmbH
+ *
+ * Business Source License 1.1 - See file LICENSE for details!
+ *
+ * Change Date:    2025-06-21
+ * Change License: GNU AFFERO GENERAL PUBLIC LICENSE Version 3
+ */
+
+package de.smederee.ssh
+
+import java.nio.file._
+
+import cats.effect._
+import cats.syntax.all._
+import com.comcast.ip4s._
+import de.smederee.hub.BaseSpec
+
+import munit._
+
+final class SshServerProviderTest extends BaseSpec {
+
+  val serverKeyFile = ResourceSuiteLocalFixture(
+    "server-key-file",
+    Resource.make(
+      for {
+        path <- IO(Files.createTempFile("test-server-", ".key"))
+        _    <- IO(Files.deleteIfExists(path))
+      } yield path
+    )(_ => IO.unit)
+  )
+
+  val freePort = ResourceFixture(Resource.make(IO(findFreePort()))(_ => IO.unit))
+
+  override def munitFixtures = List(serverKeyFile)
+
+  freePort.test("run() must create and start a server with the given configuration") { port =>
+    port match {
+      case None => fail("Could not find a free port for testing!")
+      case Some(portNumber) =>
+        val keyfile = serverKeyFile()
+        val config = SshServerConfiguration(
+          enabled = true,
+          genericUser = SshUsername("darcs"),
+          host = host"localhost",
+          port = portNumber,
+          serverKeyFile = keyfile
+        )
+        val provider = new SshServerProvider(config)
+        provider.run().use { server =>
+          assert(server.isStarted(), "Server not started!")
+          assertEquals(server.getPort(), portNumber.toString.toInt)
+          assertEquals(server.getHost(), "localhost")
+          IO.unit
+        }
+    }
+  }
+}
diff -rN -u old-smederee/modules/hub/src/main/resources/reference.conf new-smederee/modules/hub/src/main/resources/reference.conf
--- old-smederee/modules/hub/src/main/resources/reference.conf	2025-02-02 14:56:56.243279658 +0000
+++ new-smederee/modules/hub/src/main/resources/reference.conf	2025-02-02 14:56:56.247279664 +0000
@@ -131,6 +131,24 @@
     password = ${?EMAIL_PASSWORD}
   }
 
+  # SSH server component settings
+  ssh {
+    enabled = true
+    # A username for generic access to services for darcs clone, pull and push 
+    # (e.g. `darcs pull genericUser@smederee-domain:accountName/repository`).
+    generic-user = "darcs"
+    # The hostname/address the SSH server will bind to.
+    host = "localhost"
+    host = ${?SSH_SERVER_HOST}
+    # The port number on which the SSH server will listen.
+    port = 30983
+    port = ${?SSH_SERVER_PORT}
+    # A path to the file from which the server key is loaded and also written to if it needs to be generated.
+    # This file should only be accessible for the user account that runs the smederee service.
+    server-key-file = /var/db/smederee/server.key
+    server-key-file = ${?SSH_SERVER_KEY}
+  }
+
   # Signup / registration related settings.
   signup {
     enabled = true
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshServer.scala new-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshServer.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshServer.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/ssh/SshServer.scala	2025-02-02 14:56:56.247279664 +0000
@@ -0,0 +1,121 @@
+/*
+ * Copyright (c) 2022 Wegtam GmbH
+ *
+ * Business Source License 1.1 - See file LICENSE for details!
+ *
+ * Change Date:    2025-06-21
+ * Change License: GNU AFFERO GENERAL PUBLIC LICENSE Version 3
+ */
+
+package de.smederee.ssh
+
+import java.nio.file._
+
+import cats._
+import cats.data._
+import cats.effect._
+import cats.syntax.all._
+import com.comcast.ip4s._
+import de.smederee.hub.config.ConfigKey
+import org.apache.sshd.server.SshServer
+import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider
+import pureconfig._
+
+import scala.util.matching.Regex
+
+opaque type SshUsername = String
+object SshUsername {
+  given Eq[SshUsername] = Eq.fromUniversalEquals
+
+  val Format: Regex = "^[a-z][a-z0-9]{2,15}$".r
+
+  /** Create an instance of SshUsername from the given String type.
+    *
+    * @param source
+    *   An instance of type String which will be returned as a SshUsername.
+    * @return
+    *   The appropriate instance of SshUsername.
+    */
+  def apply(source: String): SshUsername = source
+
+  /** Try to create an instance of SshUsername from the given String.
+    *
+    * @param source
+    *   A String that should fulfil the requirements to be converted into a SshUsername.
+    * @return
+    *   An option to the successfully converted SshUsername.
+    */
+  def from(source: String): Option[SshUsername] = Option(source).filter(string => Format.matches(string))
+}
+
+/** Configuration for the SSH server.
+  *
+  * @param enabled
+  *   Determines if the SSH server will be enabled (started).
+  * @param genericUser
+  *   A username for generic access to services for darcs clone, pull and push (e.g. `darcs pull
+  *   genericUser@smederee-domain:accountName/repository`).
+  * @param host
+  *   The hostname/address the SSH server will bind to.
+  * @param port
+  *   The port number on which the SSH server will listen.
+  * @param serverKeyFile
+  *   A path to the file from which the server key is loaded and also written to if it needs to be generated.
+  */
+final case class SshServerConfiguration(
+    enabled: Boolean,
+    genericUser: SshUsername,
+    host: Host,
+    port: Port,
+    serverKeyFile: Path
+)
+
+object SshServerConfiguration {
+  // The default configuration key under which to lookup the ssh server configuration.
+  final val parentKey: ConfigKey = ConfigKey("ssh")
+
+  given Eq[SshServerConfiguration] = Eq.fromUniversalEquals
+
+  given ConfigReader[Host]        = ConfigReader.fromStringOpt[Host](Host.fromString)
+  given ConfigReader[Port]        = ConfigReader.fromStringOpt[Port](Port.fromString)
+  given ConfigReader[SshUsername] = ConfigReader.fromStringOpt[SshUsername](SshUsername.from)
+
+  given ConfigReader[SshServerConfiguration] =
+    ConfigReader.forProduct5("enabled", "generic-user", "host", "port", "server-key-file")(
+      SshServerConfiguration.apply
+    )
+}
+
+/** A ssh server using the [Apache MINA SSHD](https://mina.apache.org/sshd-project/) library and the IO monad
+  * from cats effect.
+  *
+  * @param configuration
+  *   The configuration with all values needed to properly configure the underlying Apache sshd server.
+  */
+final class SshServerProvider(configuration: SshServerConfiguration) {
+
+  /** Create an instance of a ssh server with defaults configure it according to the given configuration.
+    *
+    * @return
+    *   An instance of a Apache MINA SSHD server which is not yet started.
+    */
+  private def createServer(): SshServer = {
+    val server = SshServer.setUpDefaultServer()
+    server.setHost(configuration.host.toString)
+    server.setPort(configuration.port.toString.toInt)
+    val keyProvider = new SimpleGeneratorHostKeyProvider(configuration.serverKeyFile)
+    keyProvider.setAlgorithm("EC")
+    keyProvider.setOverwriteAllowed(false)
+    server.setKeyPairProvider(keyProvider)
+    server
+  }
+
+  def run(): Resource[IO, SshServer] =
+    Resource.make {
+      for {
+        server <- IO.delay(createServer())
+        _      <- IO.delay(server.start())
+      } yield server
+    }(server => IO.delay(server.stop()))
+
+}
diff -rN -u old-smederee/modules/hub/src/test/scala/de/smederee/ssh/SshUsernameTest.scala new-smederee/modules/hub/src/test/scala/de/smederee/ssh/SshUsernameTest.scala
--- old-smederee/modules/hub/src/test/scala/de/smederee/ssh/SshUsernameTest.scala	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/test/scala/de/smederee/ssh/SshUsernameTest.scala	2025-02-02 14:56:56.247279664 +0000
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2022 Wegtam GmbH
+ *
+ * Business Source License 1.1 - See file LICENSE for details!
+ *
+ * Change Date:    2025-06-21
+ * Change License: GNU AFFERO GENERAL PUBLIC LICENSE Version 3
+ */
+
+package de.smederee.ssh
+
+import java.util.Locale
+
+import cats.syntax.all._
+
+import munit._
+import org.scalacheck._
+import org.scalacheck.Prop._
+
+final class SshUsernameTest extends ScalaCheckSuite {
+  val genValidSshUsername: Gen[SshUsername] =
+    for {
+      firstLetter    <- Gen.alphaChar
+      randomAlphaNum <- Gen.nonEmptyListOf(Gen.alphaNumChar).suchThat(_.length > 2)
+      name = s"$firstLetter${randomAlphaNum.take(15).mkString}".toLowerCase(Locale.ROOT)
+    } yield SshUsername(name)
+  given Arbitrary[SshUsername] = Arbitrary(genValidSshUsername)
+
+  property("SshUsername cannot be longer than 16 characters") {
+    forAll { (randomSshUsername: SshUsername) =>
+      val invalid = s"randomSshUsername${List.fill(16)("a").mkString}"
+      assert(SshUsername.from(invalid).isEmpty)
+    }
+  }
+
+  property("SshUsername must start with a letter") {
+    forAll { (randomSshUsername: SshUsername) =>
+      val invalid = s"1${randomSshUsername.toString.take(15)}"
+      assert(SshUsername.from(invalid).isEmpty)
+    }
+  }
+
+  property("SshUsername must be at least 3 characters long") {
+    forAll { (randomSshUsername: SshUsername) =>
+      val invalid = randomSshUsername.toString.take(2)
+      assert(SshUsername.from(invalid).isEmpty)
+    }
+  }
+
+  property("SshUsername.from must fail on invalid input") {
+    forAll { (string: String) =>
+      assert(SshUsername.from(string).nonEmpty === SshUsername.Format.matches(string))
+    }
+  }
+
+  property("SshUsername.fromString must succeed on valid input") {
+    forAll { (randomSshUsername: SshUsername) =>
+      SshUsername.from(randomSshUsername.toString) match {
+        case None           => fail(s"Failed to create from valid username: $randomSshUsername")
+        case Some(username) => assertEquals(username, randomSshUsername)
+      }
+    }
+  }
+}