~jan0sch/smederee
Showing details for patch 561d59a32428ce513c34acbdd03926cf007e5987.
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) + } + } + } +}