~jan0sch/smederee
Showing details for patch 4e4251a937b2899ccaceaa74bc29f9b7ded65547.
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala --- old-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala 2025-01-16 02:50:40.458308641 +0000 +++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/HubServer.scala 2025-01-16 02:50:40.458308641 +0000 @@ -28,7 +28,7 @@ import cats.syntax.all._ import com.typesafe.config._ import de.smederee.darcs._ -import de.smederee.email.SimpleJavaMailMiddleware +import de.smederee.email.{ EmailAddress, SimpleJavaMailMiddleware } import de.smederee.html.LinkTools._ import de.smederee.html._ import de.smederee.hub.config._ @@ -47,6 +47,59 @@ import pureconfig._ import scodec.bits.ByteVector +/** Commands that may be passed to the hub server to trigger specific behaviour. + * + * @param help + * A string containing basic help information for the command. + * @param subcommands + * A list of subcommands available. + */ +enum HubCommand(val help: String, val subcommands: List[HubSubcommand]) { + case user extends HubCommand(help = "Manage user accounts.", subcommands = List(HubSubcommand.list)) +} + +/** Subcommands that are grouped below a [[HubCommand]]. + * + * @param arguments + * A list of possible arguments for the subcommand. + * @param help + * A string containing basic help information for the subcommand. + */ +enum HubSubcommand(val arguments: List[HubArgument], val help: String) { + case delete + extends HubSubcommand( + arguments = List(HubArgument.DryRun, HubArgument.Username), + help = "Delete the account from the database. This is not reversible!" + ) + case find + extends HubSubcommand( + arguments = List(HubArgument.Email, HubArgument.Username), + help = "Find a user account in the database." + ) + case list extends HubSubcommand(arguments = Nil, help = "List all accounts in the database.") + case lock + extends HubSubcommand( + arguments = List(HubArgument.DryRun, HubArgument.Username), + help = "Lock the account in the database." + ) + case unlock + extends HubSubcommand( + arguments = List(HubArgument.DryRun, HubArgument.Username), + help = "Unlock the account in the database." + ) +} + +/** Possible arguments for commands and subcommands. + * + * @param help + * A string containing basic help information for the subcommand. + */ +enum HubArgument(val help: String) { + case DryRun extends HubArgument(help = "--dry-run") + case Email extends HubArgument(help = "<email>") + case Username extends HubArgument(help = "<username>") +} + /** This is the main entry point for the hub service. * * It initialises the application (configuration parsing, database migrations) and starts the HTTP service eventually. @@ -95,199 +148,349 @@ _ <- IO(Files.setPosixFilePermissions(csrfKeyFile, PosixFilePermissions.fromString("rw-------"))) } yield key - def run(args: List[String]): IO[ExitCode] = { - val _ = Locale.setDefault(Locale.ENGLISH) // TODO: Make this configurable. + /** Run migrations on the databases in the given configurations and return their results. + * + * @param hubConfiguration + * Configuration for the Smederee hub service. + * @param ticketsConfiguration + * Configuration for the Smederee ticket service. + * @return + * A tuple holding the migration results. + */ + private def migrateDatabases( + hubConfiguration: SmedereeHubConfig, + ticketsConfiguration: SmedereeTicketsConfiguration + ) = { val hubDatabaseMigrator = new de.smederee.hub.DatabaseMigrator[IO] val ticketsDatabaseMigrator = new de.smederee.tickets.config.DatabaseMigrator[IO] for { - hubConfiguration <- IO( - ConfigSource - .fromConfig(ConfigFactory.load(getClass.getClassLoader)) - .at(SmedereeHubConfig.location) - .loadOrThrow[SmedereeHubConfig] - ) - ticketsConfiguration <- IO( - ConfigSource - .fromConfig(ConfigFactory.load(getClass.getClassLoader)) - .at(SmedereeTicketsConfiguration.location) - .loadOrThrow[SmedereeTicketsConfiguration] - ) - _ <- IO { - val defaultSecret = "CHANGEME".getBytes(StandardCharsets.UTF_8) - if (java.util.Arrays.equals(defaultSecret, hubConfiguration.service.authentication.cookieSecret.toArray)) - log.warn("SERVICE IS USING DEFAULT COOKIE SECRET! PLEASE CONFIGURE A SECURE ONE!") - } - repoCheck <- IO { - val repositoriesDirectory = hubConfiguration.service.darcs.repositoriesDirectory.toPath - if (Files.exists(repositoriesDirectory)) { - if (Files.isDirectory(repositoriesDirectory)) { - Right(s"Using repositories directory at: $repositoriesDirectory") - } else { - Left(s"Path to repositories directory exists but is not a directory: $repositoriesDirectory") - } - } else { - log.warn(s"Repositories directory does not exist, trying to create it: $repositoriesDirectory") - Files.createDirectories( - repositoriesDirectory, - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxr-x---")) - ) - Right(s"Using repositories directory at: $repositoriesDirectory") - } - } - _ <- repoCheck match { - case Left(error) => IO.raiseError(new RuntimeException(error)) - case Right(message) => IO(log.info(message)) - } - _ <- hubDatabaseMigrator.migrate( + hubMigrations <- hubDatabaseMigrator.migrate( hubConfiguration.database.url, hubConfiguration.database.user, hubConfiguration.database.pass ) - _ <- ticketsDatabaseMigrator.migrate( + ticketMigrations <- ticketsDatabaseMigrator.migrate( ticketsConfiguration.database.url, ticketsConfiguration.database.user, ticketsConfiguration.database.pass ) - hubTransactor = Transactor.fromDriverManager[IO]( - hubConfiguration.database.driver, - hubConfiguration.database.url, - hubConfiguration.database.user, - hubConfiguration.database.pass - ) - ticketsTransactor = Transactor.fromDriverManager[IO]( - ticketsConfiguration.database.driver, - ticketsConfiguration.database.url, - ticketsConfiguration.database.user, - ticketsConfiguration.database.pass - ) - ticketServiceApi = new DoobieTicketServiceApi[IO](ticketsTransactor) - ticketLabelsRepo = new DoobieLabelRepository[IO](ticketsTransactor) - ticketMilestonesRepo = new DoobieMilestoneRepository[IO](ticketsTransactor) - ticketProjectsRepo = new DoobieProjectRepository[IO](ticketsTransactor) - ticketsRepo = new DoobieTicketRepository[IO](ticketsTransactor) - ticketRoutes = new TicketRoutes[IO]( - ticketsConfiguration, - ticketLabelsRepo, - ticketMilestonesRepo, - ticketProjectsRepo, - ticketsRepo - ) - ticketLabelRoutes = new LabelRoutes[IO](ticketsConfiguration, ticketLabelsRepo, ticketProjectsRepo) - ticketMilestoneRoutes = new MilestoneRoutes[IO](ticketsConfiguration, ticketMilestonesRepo, ticketProjectsRepo) - cryptoClock = java.time.Clock.systemUTC - csrfKey <- loadOrCreateCsrfKey(hubConfiguration.service.csrfKeyFile) - csrfOriginCheck = createCsrfOriginCheck( - NonEmptyList(hubConfiguration.service.external, List(ticketsConfiguration.externalUrl)) - ) - csrfBuilder = CSRF[IO, IO](csrfKey, csrfOriginCheck) - /* The idea behind the `onFailure` part of the CSRF protection middleware is - * that we simply remove the CSRF cookie and redirect the user to the frontpage. - * This is done to avoid frustration for users after a server restart because - * the CSRF secret key will change then and thus all requests are invalid. - */ - csrfMiddleware = csrfBuilder - .withClock(cryptoClock) - .withCookieDomain(Option(hubConfiguration.service.external.host.toString)) - .withCookieName(Constants.csrfCookieName.toString) - .withCookiePath(Option("/")) - .withCSRFCheck(CSRF.checkCSRFinHeaderAndForm[IO, IO](Constants.csrfCookieName.toString, FunctionK.id)) - .withOnFailure( - Response[IO]( - headers = Headers(List(headers.Location(hubConfiguration.service.external.createFullUri(uri"/")))), - status = Status.SeeOther - ).removeCookie(Constants.csrfCookieName.toString) - ) - .build - signAndValidate = SignAndValidate(hubConfiguration.service.authentication.cookieSecret) - assetsRoutes = resourceServiceBuilder[IO](Constants.assetsPath.path.toAbsolute.toString).toRoutes - authenticationRepo = new DoobieAuthenticationRepository[IO](hubTransactor) - authenticationWithFallThrough = AuthMiddleware.withFallThrough( - authenticateUserWithFallThrough( - authenticationRepo, - signAndValidate, - hubConfiguration.service.authentication.timeouts + } yield (hubMigrations, ticketMigrations) + } + + private def generateHelp(): String = + s""" + |user delete\t[--dry-run] <name>\t- Delete a locked(!) user account from the database. + |user find\t<email> || <name>\t- Find a user account with the given email or name. + |user list\t\t\t\t- List all unlocked(!) user accounts in the database. + |user lock\t[--dry-run] <name>\t- Lock the user account. + |user unlock\t[--dry-run] <name>\t- Unlock the user account. + |""".stripMargin + + def run(args: List[String]): IO[ExitCode] = { + val _ = Locale.setDefault(Locale.ENGLISH) // TODO: Make this configurable. + val hubConfiguration = ConfigSource + .fromConfig(ConfigFactory.load(getClass.getClassLoader)) + .at(SmedereeHubConfig.location) + .loadOrThrow[SmedereeHubConfig] + args match { + case List("help") | List("--help") | List("-h") => + // TODO: Add help information! + for { + _ <- IO(println("Welcome to the Smederee Hub Server.")) + _ <- IO(println("===================================")) + _ <- IO(println("To simply start the server just run this command without arguments.")) + _ <- IO(println(generateHelp())) + } yield ExitCode.Success + case commandString :: parameters => + val hubTransactor = Transactor.fromDriverManager[IO]( + hubConfiguration.database.driver, + hubConfiguration.database.url, + hubConfiguration.database.user, + hubConfiguration.database.pass ) - ) - darcsWrapper = new DarcsCommands[IO](hubConfiguration.service.darcs.executable) - emailMiddleware = new SimpleJavaMailMiddleware(hubConfiguration.service.email) - accountManagementRepo = new DoobieAccountManagementRepository[IO](hubTransactor) - accountManagementRoutes = new AccountManagementRoutes[IO]( - accountManagementRepo, - hubConfiguration.service, - emailMiddleware, - signAndValidate, - ticketServiceApi - ) - authenticationRoutes = new AuthenticationRoutes[IO]( - cryptoClock, - hubConfiguration.service.authentication, - hubConfiguration.service.external, - authenticationRepo, - signAndValidate - ) - signUpRepo = new DoobieSignupRepository[IO](hubTransactor) - signUpRoutes = new SignupRoutes[IO](hubConfiguration.service, signUpRepo) - landingPages = new LandingPageRoutes[IO](hubConfiguration.service) - vcsMetadataRepo = new DoobieVcsMetadataRepository[IO](hubTransactor) - vcsRepoRoutes = new VcsRepositoryRoutes[IO]( - hubConfiguration.service, - darcsWrapper, - vcsMetadataRepo, - ticketProjectsRepo - ) - protectedRoutesWithFallThrough = authenticationWithFallThrough( - authenticationRoutes.protectedRoutes <+> - accountManagementRoutes.protectedRoutes <+> - signUpRoutes.protectedRoutes <+> - ticketLabelRoutes.protectedRoutes <+> - ticketMilestoneRoutes.protectedRoutes <+> - ticketRoutes.protectedRoutes <+> - vcsRepoRoutes.protectedRoutes <+> - landingPages.protectedRoutes - ) - hubWebService = Router( - Constants.assetsPath.path.toAbsolute.toString -> assetsRoutes, - "/" -> (protectedRoutesWithFallThrough <+> - authenticationRoutes.routes <+> - accountManagementRoutes.routes <+> - signUpRoutes.routes <+> - ticketLabelRoutes.routes <+> - ticketMilestoneRoutes.routes <+> - ticketRoutes.routes <+> - vcsRepoRoutes.routes <+> - landingPages.routes) - ).orNotFound - // Create our ssh server fiber (or a dummy one if disabled). - sshServerProvider = hubConfiguration.service.ssh.enabled match { - case false => None - case true => - Option( - new SshServerProvider( - hubConfiguration.service.darcs, - hubConfiguration.database, - hubConfiguration.service.ssh + val authenticationRepo = new DoobieAuthenticationRepository[IO](hubTransactor) + HubCommand.valueOf(commandString.toLowerCase(Locale.ROOT)) match { + case HubCommand.user => + parameters match { + case subcommandString :: arguments => + HubSubcommand.valueOf(subcommandString.toLowerCase(Locale.ROOT)) match { + case HubSubcommand.delete => + val accountManagementRepo = new DoobieAccountManagementRepository[IO](hubTransactor) + val dryRun = arguments.headOption.exists(_ === "--dry-run") + val name = + if (dryRun) + arguments.drop(1).headOption.flatMap(Username.from) + else + arguments.headOption.flatMap(Username.from) + for { + user <- name.traverse(name => authenticationRepo.findLockedAccount(name)(None)) + _ <- user.flatten.fold(IO(println("No such locked user account found!")))(account => + IO(println(s"Going to delete user ${account.name} (${account.email})!")) + ) + deleted <- + if (dryRun) + IO.pure(0.some) + else + user.flatten.traverse(account => accountManagementRepo.deleteAccount(account.uid)) + result = deleted.getOrElse(0) match { + case 0 => ExitCode.Error // If not database rows were updated we assume an error. + case _ => ExitCode.Success + } + } yield result + case HubSubcommand.find => + val email = arguments.headOption.flatMap(EmailAddress.from) + val name = arguments.headOption.flatMap(Username.from) + for { + userByEmail <- email.traverse(authenticationRepo.findAccountByEmail) + userByName <- name.traverse(authenticationRepo.findAccountByName) + users = List(userByEmail.flatten, userByName.flatten).collect { case Some(user) => + user + } + _ <- IO( + users.map(user => + println(s"${user.name},${user.email},${user.validatedEmail},${user.language}") + ) + ) + } yield ExitCode.Success + case HubSubcommand.list => + val users = authenticationRepo.allAccounts() + users + .foreach(user => + IO(println(s"${user.name},${user.email},${user.validatedEmail},${user.language}")) + ) + .compile + .drain + .as(ExitCode.Success) + case HubSubcommand.lock => + val dryRun = arguments.headOption.exists(_ === "--dry-run") + val name = + if (dryRun) + arguments.drop(1).headOption.flatMap(Username.from) + else + arguments.headOption.flatMap(Username.from) + for { + user <- name.traverse(authenticationRepo.findAccountByName) + _ <- user.flatten.fold(IO(println("No such unlocked user account found!")))(account => + IO(println(s"Going to lock user ${account.name} (${account.email})!")) + ) + locked <- + if (dryRun) + IO.pure(0.some) + else + user.flatten.traverse(account => + authenticationRepo.lockAccount(account.uid)(UnlockToken.generate.some) + ) + result = locked.getOrElse(0) match { + case 0 => ExitCode.Error // If not database rows were updated we assume an error. + case _ => ExitCode.Success + } + } yield result + case HubSubcommand.unlock => + val dryRun = arguments.headOption.exists(_ === "--dry-run") + val name = + if (dryRun) + arguments.drop(1).headOption.flatMap(Username.from) + else + arguments.headOption.flatMap(Username.from) + for { + user <- name.traverse(name => authenticationRepo.findLockedAccount(name)(None)) + _ <- user.flatten.fold(IO(println("No such locked user account found!")))(account => + IO(println(s"Going to unlock user ${account.name} (${account.email})!")) + ) + unlocked <- + if (dryRun) + IO.pure(0.some) + else + user.flatten.traverse(account => authenticationRepo.unlockAccount(account.uid)) + result = unlocked.getOrElse(0) match { + case 0 => ExitCode.Error // If not database rows were updated we assume an error. + case _ => ExitCode.Success + } + } yield result + } + case _ => IO.raiseError(new IllegalArgumentException("Missing subcommand/options!")) + } + } + case _ => + for { + ticketsConfiguration <- IO( + ConfigSource + .fromConfig(ConfigFactory.load(getClass.getClassLoader)) + .at(SmedereeTicketsConfiguration.location) + .loadOrThrow[SmedereeTicketsConfiguration] + ) + _ <- migrateDatabases(hubConfiguration, ticketsConfiguration) + _ <- IO { + val defaultSecret = "CHANGEME".getBytes(StandardCharsets.UTF_8) + if (java.util.Arrays.equals(defaultSecret, hubConfiguration.service.authentication.cookieSecret.toArray)) + log.warn("SERVICE IS USING DEFAULT COOKIE SECRET! PLEASE CONFIGURE A SECURE ONE!") + } + repoCheck <- IO { + val repositoriesDirectory = hubConfiguration.service.darcs.repositoriesDirectory.toPath + if (Files.exists(repositoriesDirectory)) { + if (Files.isDirectory(repositoriesDirectory)) { + Right(s"Using repositories directory at: $repositoriesDirectory") + } else { + Left(s"Path to repositories directory exists but is not a directory: $repositoriesDirectory") + } + } else { + log.warn(s"Repositories directory does not exist, trying to create it: $repositoriesDirectory") + Files.createDirectories( + repositoriesDirectory, + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxr-x---")) + ) + Right(s"Using repositories directory at: $repositoriesDirectory") + } + } + _ <- repoCheck match { + case Left(error) => IO.raiseError(new RuntimeException(error)) + case Right(message) => IO(log.info(message)) + } + hubTransactor = Transactor.fromDriverManager[IO]( + hubConfiguration.database.driver, + hubConfiguration.database.url, + hubConfiguration.database.user, + hubConfiguration.database.pass + ) + ticketsTransactor = Transactor.fromDriverManager[IO]( + ticketsConfiguration.database.driver, + ticketsConfiguration.database.url, + ticketsConfiguration.database.user, + ticketsConfiguration.database.pass + ) + ticketServiceApi = new DoobieTicketServiceApi[IO](ticketsTransactor) + ticketLabelsRepo = new DoobieLabelRepository[IO](ticketsTransactor) + ticketMilestonesRepo = new DoobieMilestoneRepository[IO](ticketsTransactor) + ticketProjectsRepo = new DoobieProjectRepository[IO](ticketsTransactor) + ticketsRepo = new DoobieTicketRepository[IO](ticketsTransactor) + ticketRoutes = new TicketRoutes[IO]( + ticketsConfiguration, + ticketLabelsRepo, + ticketMilestonesRepo, + ticketProjectsRepo, + ticketsRepo + ) + ticketLabelRoutes = new LabelRoutes[IO](ticketsConfiguration, ticketLabelsRepo, ticketProjectsRepo) + ticketMilestoneRoutes = new MilestoneRoutes[IO]( + ticketsConfiguration, + ticketMilestonesRepo, + ticketProjectsRepo + ) + cryptoClock = java.time.Clock.systemUTC + csrfKey <- loadOrCreateCsrfKey(hubConfiguration.service.csrfKeyFile) + csrfOriginCheck = createCsrfOriginCheck( + NonEmptyList(hubConfiguration.service.external, List(ticketsConfiguration.externalUrl)) + ) + csrfBuilder = CSRF[IO, IO](csrfKey, csrfOriginCheck) + /* The idea behind the `onFailure` part of the CSRF protection middleware is + * that we simply remove the CSRF cookie and redirect the user to the frontpage. + * This is done to avoid frustration for users after a server restart because + * the CSRF secret key will change then and thus all requests are invalid. + */ + csrfMiddleware = csrfBuilder + .withClock(cryptoClock) + .withCookieDomain(Option(hubConfiguration.service.external.host.toString)) + .withCookieName(Constants.csrfCookieName.toString) + .withCookiePath(Option("/")) + .withCSRFCheck(CSRF.checkCSRFinHeaderAndForm[IO, IO](Constants.csrfCookieName.toString, FunctionK.id)) + .withOnFailure( + Response[IO]( + headers = Headers(List(headers.Location(hubConfiguration.service.external.createFullUri(uri"/")))), + status = Status.SeeOther + ).removeCookie(Constants.csrfCookieName.toString) + ) + .build + signAndValidate = SignAndValidate(hubConfiguration.service.authentication.cookieSecret) + assetsRoutes = resourceServiceBuilder[IO](Constants.assetsPath.path.toAbsolute.toString).toRoutes + authenticationRepo = new DoobieAuthenticationRepository[IO](hubTransactor) + authenticationWithFallThrough = AuthMiddleware.withFallThrough( + authenticateUserWithFallThrough( + authenticationRepo, + signAndValidate, + hubConfiguration.service.authentication.timeouts ) ) - } - sshServer = sshServerProvider.fold(IO.unit.as(ExitCode.Success))( - _.run().use(server => - IO(log.info(s"SSH-Server started at ${server.getHost()}:${server.getPort()}.")) >> IO.never.as( - ExitCode.Success + darcsWrapper = new DarcsCommands[IO](hubConfiguration.service.darcs.executable) + emailMiddleware = new SimpleJavaMailMiddleware(hubConfiguration.service.email) + accountManagementRepo = new DoobieAccountManagementRepository[IO](hubTransactor) + accountManagementRoutes = new AccountManagementRoutes[IO]( + accountManagementRepo, + hubConfiguration.service, + emailMiddleware, + signAndValidate, + ticketServiceApi ) - ) - ) - // Create our webserver fiber. - resource = EmberServerBuilder - .default[IO] - .withHost(hubConfiguration.service.host) - .withPort(hubConfiguration.service.port) - .withHttpApp(csrfMiddleware.validate()(hubWebService)) - .build - webServer = resource.use(server => - IO(log.info("Server started at {}", server.address)) >> IO.never.as(ExitCode.Success) - ) - executeFibers <- (webServer, sshServer).parTupled // We run both fibers. - (exitCode, _) = executeFibers - } yield exitCode + authenticationRoutes = new AuthenticationRoutes[IO]( + cryptoClock, + hubConfiguration.service.authentication, + hubConfiguration.service.external, + authenticationRepo, + signAndValidate + ) + signUpRepo = new DoobieSignupRepository[IO](hubTransactor) + signUpRoutes = new SignupRoutes[IO](hubConfiguration.service, signUpRepo) + landingPages = new LandingPageRoutes[IO](hubConfiguration.service) + vcsMetadataRepo = new DoobieVcsMetadataRepository[IO](hubTransactor) + vcsRepoRoutes = new VcsRepositoryRoutes[IO]( + hubConfiguration.service, + darcsWrapper, + vcsMetadataRepo, + ticketProjectsRepo + ) + protectedRoutesWithFallThrough = authenticationWithFallThrough( + authenticationRoutes.protectedRoutes <+> + accountManagementRoutes.protectedRoutes <+> + signUpRoutes.protectedRoutes <+> + ticketLabelRoutes.protectedRoutes <+> + ticketMilestoneRoutes.protectedRoutes <+> + ticketRoutes.protectedRoutes <+> + vcsRepoRoutes.protectedRoutes <+> + landingPages.protectedRoutes + ) + hubWebService = Router( + Constants.assetsPath.path.toAbsolute.toString -> assetsRoutes, + "/" -> (protectedRoutesWithFallThrough <+> + authenticationRoutes.routes <+> + accountManagementRoutes.routes <+> + signUpRoutes.routes <+> + ticketLabelRoutes.routes <+> + ticketMilestoneRoutes.routes <+> + ticketRoutes.routes <+> + vcsRepoRoutes.routes <+> + landingPages.routes) + ).orNotFound + // Create our ssh server fiber (or a dummy one if disabled). + sshServerProvider = hubConfiguration.service.ssh.enabled match { + case false => None + case true => + Option( + new SshServerProvider( + hubConfiguration.service.darcs, + hubConfiguration.database, + hubConfiguration.service.ssh + ) + ) + } + sshServer = sshServerProvider.fold(IO.unit.as(ExitCode.Success))( + _.run().use(server => + IO(log.info(s"SSH-Server started at ${server.getHost()}:${server.getPort()}.")) >> IO.never.as( + ExitCode.Success + ) + ) + ) + // Create our webserver fiber. + resource = EmberServerBuilder + .default[IO] + .withHost(hubConfiguration.service.host) + .withPort(hubConfiguration.service.port) + .withHttpApp(csrfMiddleware.validate()(hubWebService)) + .build + webServer = resource.use(server => + IO(log.info("Server started at {}", server.address)) >> IO.never.as(ExitCode.Success) + ) + executeFibers <- (webServer, sshServer).parTupled // We run both fibers. + (exitCode, _) = executeFibers + } yield exitCode + } } }