diff --git a/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraAuthProjection.scala b/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraAuthProjection.scala new file mode 100644 index 0000000..17bb75f --- /dev/null +++ b/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraAuthProjection.scala @@ -0,0 +1,22 @@ +package diptestbed.domain + +import cats.Monad +import cats.implicits._ +import diptestbed.domain.HardwareCameraEvent._ +import diptestbed.domain.HardwareCameraMessage._ + +object HardwareCameraAuthProjection { + def project[F[_]: Monad, A]( + auth: (String, String) => F[Either[String, User]], + send: (A, HardwareCameraMessage) => F[Unit] + )(state: HardwareCameraState[A], event: HardwareCameraEvent[A]): Option[F[Unit]] = { + event match { + case CheckingAuth(username, password) => + Some(auth(username, password).flatMap(_.bimap( + error => send(state.self, AuthFailure(error)), + user => send(state.self, AuthSuccess(user)) + ).merge)) + case _ => None + } + } +} diff --git a/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraError.scala b/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraError.scala index c273651..dfbed8d 100644 --- a/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraError.scala +++ b/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraError.scala @@ -2,5 +2,6 @@ package diptestbed.domain sealed trait HardwareCameraError object HardwareCameraError { + case class NoReaction() extends HardwareCameraError case class RequestInquirerObligatory() extends HardwareCameraError } diff --git a/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraEvent.scala b/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraEvent.scala index 01aef90..c8996b8 100644 --- a/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraEvent.scala +++ b/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraEvent.scala @@ -5,6 +5,9 @@ object HardwareCameraEvent { // Lifecycle case class Started[A]() extends HardwareCameraEvent[A] case class Ended[A]() extends HardwareCameraEvent[A] + case class CheckingAuth[A](username: String, password: String) extends HardwareCameraEvent[A] + case class AuthSucceeded[A](user: User) extends HardwareCameraEvent[A] + case class AuthFailed[A](reason: String) extends HardwareCameraEvent[A] // Camera content case class ChunkReceived[A](chunk: Array[Byte]) extends HardwareCameraEvent[A] diff --git a/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraEventEngine.scala b/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraEventEngine.scala index 171316b..1a0990d 100644 --- a/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraEventEngine.scala +++ b/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraEventEngine.scala @@ -8,6 +8,7 @@ object HardwareCameraEventEngine { def onMessage[A, F[_]: Temporal: Parallel]( lastState: => HardwareCameraState[A], die: F[Unit], + auth: (String, String) => F[Either[String, User]], send: (A, Any) => F[Unit], publish: (PubSubTopic, Any) => F[Unit], subscriptionMessage: (PubSubTopic, A) => Any, @@ -20,6 +21,7 @@ object HardwareCameraEventEngine { List( HardwareCameraMailProjection.project(die, send, publish, subscriptionMessage), HardwareCameraHeartbeatProjection.project(send, publish), + HardwareCameraAuthProjection.project(auth, send), ), ), HardwareCameraEventStateProjection.project, diff --git a/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraEventStateProjection.scala b/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraEventStateProjection.scala index 119ea1f..26918c7 100644 --- a/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraEventStateProjection.scala +++ b/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraEventStateProjection.scala @@ -12,6 +12,9 @@ object HardwareCameraEventStateProjection { case _ => previousState } + case AuthSucceeded(user) => previousState.copy(auth = Some(user)) + case AuthFailed(_) => previousState.copy(auth = None) + case Subscription(_) => previousState.copy(broadcasting = true) case BroadcastStopped() => previousState.copy(broadcasting = false, initialChunks = List.empty) @@ -19,7 +22,7 @@ object HardwareCameraEventStateProjection { case CameraListenerHeartbeatReceived() => previousState.copy(listenerHeartbeatsReceived = previousState.listenerHeartbeatsReceived + 1) - case _: CameraPinged[A] | _: CameraListenerHeartbeatFinished[A] | _: CameraDropped[A] | _: Started[A] | _: Ended[A] => + case _: CheckingAuth[A] | _: CameraPinged[A] | _: CameraListenerHeartbeatFinished[A] | _: CameraDropped[A] | _: Started[A] | _: Ended[A] => previousState } } diff --git a/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraMailProjection.scala b/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraMailProjection.scala index b9160b1..15ea8a7 100644 --- a/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraMailProjection.scala +++ b/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraMailProjection.scala @@ -4,7 +4,7 @@ import cats.effect.Temporal import cats.implicits._ import diptestbed.domain.HardwareCameraEvent._ import diptestbed.domain.HardwareCameraListenerMessage.{ListenerCameraChunk, ListenerCameraDropped} -import diptestbed.domain.HardwareCameraMessage.{CameraSubscription, StopBroadcasting} +import diptestbed.domain.HardwareCameraMessage._ object HardwareCameraMailProjection { def project[F[_]: Temporal, A]( @@ -14,8 +14,13 @@ object HardwareCameraMailProjection { subscriptionMessage: (PubSubTopic, A) => Any )(state: HardwareCameraState[A], event: HardwareCameraEvent[A]): Option[F[Unit]] = { event match { - case _: CameraPinged[A] | _: CameraListenerHeartbeatEvent[A] => + case _: CheckingAuth[A] | _: CameraPinged[A] | _: CameraListenerHeartbeatEvent[A] => None + case AuthSucceeded(_) => Some( + send(state.camera, AuthResult(None)) >> + send(state.self, StartLifecycle())) + case AuthFailed(reason) => Some(send(state.camera, AuthResult(Some(reason)))) + case BroadcastStopped() => Some(send(state.camera, StopBroadcasting())) case Started() => Some(state.hardwareIds.map(hardwareId => diff --git a/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraMessage.scala b/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraMessage.scala index 9ecb9bb..23ae464 100644 --- a/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraMessage.scala +++ b/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraMessage.scala @@ -3,6 +3,11 @@ package diptestbed.domain sealed abstract class HardwareCameraMessage sealed abstract class HardwareCameraMessageExternal extends HardwareCameraMessage object HardwareCameraMessage { + case class AuthRequest(username: String, password: String) extends HardwareCameraMessageExternal + case class AuthSuccess(user: User) extends HardwareCameraMessage + case class AuthFailure(reason: String) extends HardwareCameraMessage + case class AuthResult(error: Option[String]) extends HardwareCameraMessageExternal + case class StartLifecycle() extends HardwareCameraMessage case class EndLifecycle() extends HardwareCameraMessage diff --git a/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraMessageHandler.scala b/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraMessageHandler.scala index 250f3b9..f0b301f 100644 --- a/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraMessageHandler.scala +++ b/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraMessageHandler.scala @@ -15,18 +15,27 @@ object HardwareCameraMessageHandler { def handle[A]( inquirer: => Option[A], )(@unused state: HardwareCameraState[A], message: HardwareCameraMessage): HardwareCameraResult[A] = - message match { - case _: Ping => Right(NonEmptyList.of(CameraPinged())) - case _: StartLifecycle => Right(NonEmptyList.of(Started())) - case _: EndLifecycle => Right(NonEmptyList.of(Ended())) - case CameraSubscription() => - NonEmptyList.fromList(inquirer.map(Subscription(_)).toList).toRight(RequestInquirerObligatory()) - case StopBroadcasting() => Right(NonEmptyList.of(BroadcastStopped())) - case CameraUnavailable(reason) => Right(NonEmptyList.of(CameraDropped(reason))) - case CameraChunk(chunk) => Right(NonEmptyList.of(ChunkReceived(chunk))) - case CameraListenersHeartbeatStart() => Right(NonEmptyList.of(CameraListenerHeartbeatStarted())) - case CameraListenersHeartbeatPong() => Right(NonEmptyList.of(CameraListenerHeartbeatReceived())) - case CameraListenersHeartbeatFinish() => Right(NonEmptyList.of(CameraListenerHeartbeatFinished())) + state.auth match { + case None => + message match { + case m: AuthRequest => Right(NonEmptyList.of(CheckingAuth(m.username, m.password))) + case m: AuthSuccess => Right(NonEmptyList.of(AuthSucceeded(m.user))) + case m: AuthFailure => Right(NonEmptyList.of(AuthFailed(m.reason))) + case _ => Left(NoReaction()) + } + case Some(_) => + message match { + case _: Ping => Right(NonEmptyList.of(CameraPinged())) + case _: StartLifecycle => Right(NonEmptyList.of(Started())) + case _: EndLifecycle => Right(NonEmptyList.of(Ended())) + case CameraSubscription() => + NonEmptyList.fromList(inquirer.map(Subscription(_)).toList).toRight(RequestInquirerObligatory()) + case StopBroadcasting() => Right(NonEmptyList.of(BroadcastStopped())) + case CameraUnavailable(reason) => Right(NonEmptyList.of(CameraDropped(reason))) + case CameraChunk(chunk) => Right(NonEmptyList.of(ChunkReceived(chunk))) + case CameraListenersHeartbeatStart() => Right(NonEmptyList.of(CameraListenerHeartbeatStarted())) + case CameraListenersHeartbeatPong() => Right(NonEmptyList.of(CameraListenerHeartbeatReceived())) + case CameraListenersHeartbeatFinish() => Right(NonEmptyList.of(CameraListenerHeartbeatFinished())) + } } - } diff --git a/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraState.scala b/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraState.scala index 9cf2179..17bf004 100644 --- a/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraState.scala +++ b/backend/domain/src/main/scala/diptestbed/domain/HardwareCameraState.scala @@ -3,6 +3,7 @@ package diptestbed.domain case class HardwareCameraState[A]( self: A, camera: A, + auth: Option[User], pubSubMediator: A, hardwareIds: List[HardwareId], listenerHeartbeatsReceived: Int, @@ -27,6 +28,7 @@ object HardwareCameraState { HardwareCameraState( self, camera, + None, pubSubMediator, hardwareIds, 0, diff --git a/backend/domain/src/main/scala/diptestbed/domain/HardwareControlAuthProjection.scala b/backend/domain/src/main/scala/diptestbed/domain/HardwareControlAuthProjection.scala new file mode 100644 index 0000000..123b8fe --- /dev/null +++ b/backend/domain/src/main/scala/diptestbed/domain/HardwareControlAuthProjection.scala @@ -0,0 +1,25 @@ +package diptestbed.domain + +import cats.Monad +import cats.implicits._ +import diptestbed.domain.HardwareControlEvent._ +import diptestbed.domain.HardwareControlMessageExternalBinary._ +import diptestbed.domain.HardwareControlMessageExternalNonBinary._ +import diptestbed.domain.HardwareControlMessageInternal._ +import diptestbed.domain.HardwareSerialMonitorMessageBinary._ + +object HardwareControlAuthProjection { + def project[F[_]: Monad, A]( + auth: (String, String) => F[Either[String, User]], + send: (A, HardwareControlMessage) => F[Unit] + )(state: HardwareControlState[A], event: HardwareControlEvent[A]): Option[F[Unit]] = { + event match { + case CheckingAuth(username, password) => + Some(auth(username, password).flatMap(_.bimap( + error => send(state.self, AuthFailure(error)), + user => send(state.self, AuthSuccess(user)) + ).merge)) + case _ => None + } + } +} diff --git a/backend/domain/src/main/scala/diptestbed/domain/HardwareControlEvent.scala b/backend/domain/src/main/scala/diptestbed/domain/HardwareControlEvent.scala index dac583d..8a02877 100644 --- a/backend/domain/src/main/scala/diptestbed/domain/HardwareControlEvent.scala +++ b/backend/domain/src/main/scala/diptestbed/domain/HardwareControlEvent.scala @@ -2,6 +2,11 @@ package diptestbed.domain sealed abstract class HardwareControlEvent[A] object HardwareControlEvent { + // Auth + case class CheckingAuth[A](username: String, password: String) extends HardwareControlEvent[A] + case class AuthSucceeded[A](user: User) extends HardwareControlEvent[A] + case class AuthFailed[A](reason: String) extends HardwareControlEvent[A] + // Lifecycle case class Started[A]() extends HardwareControlEvent[A] case class Ended[A]() extends HardwareControlEvent[A] diff --git a/backend/domain/src/main/scala/diptestbed/domain/HardwareControlEventEngine.scala b/backend/domain/src/main/scala/diptestbed/domain/HardwareControlEventEngine.scala index 746ae58..28b23f6 100644 --- a/backend/domain/src/main/scala/diptestbed/domain/HardwareControlEventEngine.scala +++ b/backend/domain/src/main/scala/diptestbed/domain/HardwareControlEventEngine.scala @@ -33,6 +33,7 @@ object HardwareControlEventEngine { def onMessage[A, F[_]: Temporal: Parallel]( lastState: => HardwareControlState[A], + auth: (String, String) => F[Either[String, User]], send: (A, HardwareControlMessage) => F[Unit], publish: (PubSubTopic, HardwareControlMessage) => F[Unit], inquirer: => Option[A], @@ -44,6 +45,7 @@ object HardwareControlEventEngine { List( HardwareControlMailProjection.project(send, publish), HardwareControlHeartbeatProjection.project(send, publish), + HardwareControlAuthProjection.project(auth, send), ), ), HardwareControlEventStateProjection.project, diff --git a/backend/domain/src/main/scala/diptestbed/domain/HardwareControlEventStateProjection.scala b/backend/domain/src/main/scala/diptestbed/domain/HardwareControlEventStateProjection.scala index f027db8..e0d9478 100644 --- a/backend/domain/src/main/scala/diptestbed/domain/HardwareControlEventStateProjection.scala +++ b/backend/domain/src/main/scala/diptestbed/domain/HardwareControlEventStateProjection.scala @@ -9,6 +9,9 @@ object HardwareControlEventStateProjection { case UploadStarted(inquirer, _) => previousState.copy(agentState = Uploading(inquirer)) case UploadFinished(_, _) => previousState.copy(agentState = Initial()) + case AuthSucceeded(user) => previousState.copy(auth = Some(user)) + case AuthFailed(_) => previousState.copy(auth = None) + case MonitorConfigurationStarted(inquirer, _) => previousState.copy(agentState = ConfiguringMonitor(inquirer)) case MonitorConfigurationFinished(_, _) => previousState.copy(agentState = Initial()) @@ -16,7 +19,7 @@ object HardwareControlEventStateProjection { case ListenerHeartbeatReceived() => previousState.copy(listenerHeartbeatsReceived = previousState.listenerHeartbeatsReceived + 1) - case _: MonitorDropped[A] | _: MonitorDropExpected[A] | _: MonitorMessageToClient[A] | _: MonitorMessageToAgent[A] | + case _: CheckingAuth[A] | _: MonitorDropped[A] | _: MonitorDropExpected[A] | _: MonitorMessageToClient[A] | _: MonitorMessageToAgent[A] | _: MonitorMessageToClient[A] | _: MonitorMessageToAgent[A] | _: ListenerHeartbeatFinished[A] | _: Started[A] | _: Ended[A] => previousState diff --git a/backend/domain/src/main/scala/diptestbed/domain/HardwareControlMailProjection.scala b/backend/domain/src/main/scala/diptestbed/domain/HardwareControlMailProjection.scala index 347e972..74a2f37 100644 --- a/backend/domain/src/main/scala/diptestbed/domain/HardwareControlMailProjection.scala +++ b/backend/domain/src/main/scala/diptestbed/domain/HardwareControlMailProjection.scala @@ -14,10 +14,15 @@ object HardwareControlMailProjection { publish: (PubSubTopic, HardwareControlMessage) => F[Unit], )(state: HardwareControlState[A], event: HardwareControlEvent[A]): Option[F[Unit]] = { event match { - case Started() | Ended() | ListenerHeartbeatFinished() | ListenerHeartbeatReceived() | + case CheckingAuth(_, _) | Started() | Ended() | ListenerHeartbeatFinished() | ListenerHeartbeatReceived() | ListenerHeartbeatStarted() => None + case AuthSucceeded(_) => Some( + send(state.agent, AuthResult(None)) >> + send(state.self, StartLifecycle())) + case AuthFailed(reason) => Some(send(state.agent, AuthResult(Some(reason)))) + case UploadStarted(_, softwareId) => Some(send(state.agent, UploadSoftwareRequest(softwareId))) case UploadFinished(oldInquirer, error) => Some(oldInquirer.traverse(send(_, UploadSoftwareResult(error))).void) diff --git a/backend/domain/src/main/scala/diptestbed/domain/HardwareControlMessage.scala b/backend/domain/src/main/scala/diptestbed/domain/HardwareControlMessage.scala index 21902ee..1019157 100644 --- a/backend/domain/src/main/scala/diptestbed/domain/HardwareControlMessage.scala +++ b/backend/domain/src/main/scala/diptestbed/domain/HardwareControlMessage.scala @@ -11,6 +11,13 @@ sealed trait HardwareControlMessageNonBinary extends HardwareControlMessage */ sealed trait HardwareControlMessageInternal extends HardwareControlMessageNonBinary object HardwareControlMessageInternal { + // Goes to agent + case class AuthResult(error: Option[String]) extends HardwareControlMessageInternal + + // Goes back into actor + case class AuthSuccess(user: User) extends HardwareControlMessageInternal + case class AuthFailure(reason: String) extends HardwareControlMessageInternal + case class StartLifecycle() extends HardwareControlMessageInternal case class EndLifecycle() extends HardwareControlMessageInternal @@ -34,6 +41,7 @@ sealed trait HardwareControlMessageExternalNonBinary extends HardwareControlMessageExternal with HardwareControlMessageNonBinary object HardwareControlMessageExternalNonBinary { + case class AuthRequest(username: String, password: String) extends HardwareControlMessageExternalNonBinary case class UploadSoftwareRequest(softwareId: SoftwareId) extends HardwareControlMessageExternalNonBinary case class UploadSoftwareResult(error: Option[String]) extends HardwareControlMessageExternalNonBinary object UploadSoftwareResult { diff --git a/backend/domain/src/main/scala/diptestbed/domain/HardwareControlMessageHandler.scala b/backend/domain/src/main/scala/diptestbed/domain/HardwareControlMessageHandler.scala index bf4f6a6..3094a90 100644 --- a/backend/domain/src/main/scala/diptestbed/domain/HardwareControlMessageHandler.scala +++ b/backend/domain/src/main/scala/diptestbed/domain/HardwareControlMessageHandler.scala @@ -16,24 +16,37 @@ object HardwareControlMessageHandler { def handle[A]( inquirer: => Option[A], - )(state: HardwareControlState[A], message: HardwareControlMessage): HardwareControlResult[A] = - message match { - case _: StartLifecycle => Right(NonEmptyList.of(Started())) - case _: EndLifecycle => Right(NonEmptyList.of(Ended())) - case m: UploadSoftwareRequest => handleUploadSoftwareRequest(state, inquirer, m) - case m: UploadSoftwareResult => handleUploadSoftwareResult(state, m) - case m: SerialMonitorRequest => handleSerialMonitorRequest(state, inquirer, m) - case _: SerialMonitorRequestStop => handleSerialMonitorRequestStop() - case m: SerialMonitorResult => handleSerialMonitorResult(state, m) - case m: SerialMonitorMessageToClient => handleSerialMonitorMessageToClient(m) - case m: SerialMonitorMessageToAgent => handleSerialMonitorMessageToAgent(m) - case _: SerialMonitorListenersHeartbeatStart => handleSerialMonitorListenersHeartbeatStart() - case _: SerialMonitorListenersHeartbeatPing => Left(NoReaction) - case _: SerialMonitorListenersHeartbeatPong => handleSerialMonitorListenersHeartbeatPong() - case _: SerialMonitorListenersHeartbeatFinish => handleSerialMonitorListenersHeartbeatFinish() - case m: SerialMonitorUnavailable => handleMonitorUnavailable(m) - case _: Ping => Left(NoReaction) + )(state: HardwareControlState[A], message: HardwareControlMessage): HardwareControlResult[A] = { + state.auth match { + case None => + message match { + case m: AuthRequest => Right(NonEmptyList.of(CheckingAuth(m.username, m.password))) + case m: AuthSuccess => Right(NonEmptyList.of(AuthSucceeded(m.user))) + case m: AuthFailure => Right(NonEmptyList.of(AuthFailed(m.reason))) + case _ => Left(NoReaction) + } + case Some(_) => + message match { + case _: StartLifecycle => Right(NonEmptyList.of(Started())) + case _: EndLifecycle => Right(NonEmptyList.of(Ended())) + case m: UploadSoftwareRequest => handleUploadSoftwareRequest(state, inquirer, m) + case m: UploadSoftwareResult => handleUploadSoftwareResult(state, m) + case m: SerialMonitorRequest => handleSerialMonitorRequest(state, inquirer, m) + case _: SerialMonitorRequestStop => handleSerialMonitorRequestStop() + case m: SerialMonitorResult => handleSerialMonitorResult(state, m) + case m: SerialMonitorMessageToClient => handleSerialMonitorMessageToClient(m) + case m: SerialMonitorMessageToAgent => handleSerialMonitorMessageToAgent(m) + case _: SerialMonitorListenersHeartbeatStart => handleSerialMonitorListenersHeartbeatStart() + case _: SerialMonitorListenersHeartbeatPing => Left(NoReaction) + case _: SerialMonitorListenersHeartbeatPong => handleSerialMonitorListenersHeartbeatPong() + case _: SerialMonitorListenersHeartbeatFinish => handleSerialMonitorListenersHeartbeatFinish() + case m: SerialMonitorUnavailable => handleMonitorUnavailable(m) + case _: Ping => Left(NoReaction) + case _: AuthRequest | _: AuthSuccess | _ :AuthFailure => Left(NoReaction) + + } } + } def handleUploadSoftwareRequest[A]( state: HardwareControlState[A], diff --git a/backend/domain/src/main/scala/diptestbed/domain/HardwareControlState.scala b/backend/domain/src/main/scala/diptestbed/domain/HardwareControlState.scala index 1ba9a41..cd4e28a 100644 --- a/backend/domain/src/main/scala/diptestbed/domain/HardwareControlState.scala +++ b/backend/domain/src/main/scala/diptestbed/domain/HardwareControlState.scala @@ -3,6 +3,7 @@ package diptestbed.domain import diptestbed.domain.HardwareControlAgentState._ case class HardwareControlState[A]( + auth: Option[User], self: A, agent: A, hardwareId: HardwareId, @@ -19,6 +20,7 @@ object HardwareControlState { listenerHeartbeatConfig: HardwareListenerHeartbeatConfig, ): HardwareControlState[A] = HardwareControlState( + None, self, agent, hardwareId, diff --git a/backend/domain/src/main/scala/diptestbed/domain/HardwareSerialMonitorMessage.scala b/backend/domain/src/main/scala/diptestbed/domain/HardwareSerialMonitorMessage.scala index 2d64145..0b57ce8 100644 --- a/backend/domain/src/main/scala/diptestbed/domain/HardwareSerialMonitorMessage.scala +++ b/backend/domain/src/main/scala/diptestbed/domain/HardwareSerialMonitorMessage.scala @@ -16,6 +16,7 @@ object HardwareSerialMonitorMessageBinary { sealed trait HardwareSerialMonitorMessageNonBinary extends HardwareSerialMonitorMessage object HardwareSerialMonitorMessageNonBinary { + case class AuthResult(error: Option[String]) extends HardwareSerialMonitorMessageNonBinary case class MonitorUnavailable(reason: String) extends HardwareSerialMonitorMessageNonBinary case class ConnectionClosed(reason: String) extends HardwareSerialMonitorMessageNonBinary } diff --git a/backend/protocol/src/main/scala/diptestbed/protocol/DomainCodecs.scala b/backend/protocol/src/main/scala/diptestbed/protocol/DomainCodecs.scala new file mode 100644 index 0000000..89982cc --- /dev/null +++ b/backend/protocol/src/main/scala/diptestbed/protocol/DomainCodecs.scala @@ -0,0 +1,48 @@ +package diptestbed.protocol + +import cats.implicits._ +import diptestbed.domain._ +import diptestbed.protocol.WebResult._ +import io.circe.generic.extras.semiauto.deriveUnwrappedCodec +import io.circe.generic.semiauto.deriveCodec +import io.circe.syntax._ +import io.circe.{Codec, Decoder, DecodingFailure, Encoder} + +object DomainCodecs { + implicit val domainTimeZoneIdCodec: Codec[DomainTimeZoneId] = Codec.from( + _.as[String].flatMap(DomainTimeZoneId.fromString(_).leftMap(x => DecodingFailure(x.getMessage, List.empty))), + _.value.asJson, + ) + implicit val userIdCodec: Codec[UserId] = deriveUnwrappedCodec[UserId] + implicit val userCodec: Codec[User] = deriveCodec[User] + implicit val hardwareIdCodec: Codec[HardwareId] = deriveUnwrappedCodec[HardwareId] + implicit val hardwareCodec: Codec[Hardware] = deriveCodec[Hardware] + implicit val softwareIdCodec: Codec[SoftwareId] = deriveUnwrappedCodec[SoftwareId] + implicit val softwareMetaCodec: Codec[SoftwareMeta] = deriveCodec[SoftwareMeta] + implicit val namedMessageCodec: Codec[NamedMessage] = deriveCodec[NamedMessage] + implicit val serialConfigCodec: Codec[SerialConfig] = deriveCodec[SerialConfig] + implicit val unitCodec: Codec[Unit] = deriveCodec[Unit] + + + implicit def webResultSuccessCodec[A: Encoder: Decoder]: Codec[Success[A]] = + Codec.forProduct1[Success[A], A]("success")(Success(_))(_.value) + implicit def webResultFailureCodec[A: Encoder: Decoder]: Codec[Failure[A]] = + Codec.forProduct1[Failure[A], A]("failure")(Failure(_))(_.value) + protected def webResultEncoder[A: Encoder: Decoder]: Encoder[WebResult[A]] = + Encoder.instance { + case success: Success[A] => success.asJson + case failure: Failure[A] => failure.asJson + } + protected def webResultDecoder[A: Encoder: Decoder]: Decoder[WebResult[A]] = + List[Decoder[WebResult[A]]]( + Decoder[Success[A]].widen, + Decoder[Failure[A]].widen, + ).reduceLeft(_ or _) + implicit def webResultCodec[A: Encoder: Decoder]: Codec[WebResult[A]] = + Codec.from(webResultDecoder, webResultEncoder) + + implicit val helloCodec: Codec[Hello] = Codec.forProduct1[Hello, String]("hello")(Hello)(_.recipient) + implicit val serviceStatusCodec: Codec[ServiceStatus] = deriveCodec[ServiceStatus] + implicit val createUserCodec: Codec[CreateUser] = deriveCodec[CreateUser] + implicit val createHardwareCodec: Codec[CreateHardware] = deriveCodec[CreateHardware] +} diff --git a/backend/protocol/src/main/scala/diptestbed/protocol/HardwareCameraCodecs.scala b/backend/protocol/src/main/scala/diptestbed/protocol/HardwareCameraCodecs.scala new file mode 100644 index 0000000..fd7a84c --- /dev/null +++ b/backend/protocol/src/main/scala/diptestbed/protocol/HardwareCameraCodecs.scala @@ -0,0 +1,50 @@ +package diptestbed.protocol + +import cats.implicits._ +import diptestbed.protocol.DomainCodecs._ +import diptestbed.domain.HardwareCameraMessage._ +import diptestbed.domain._ +import io.circe.generic.semiauto.deriveCodec +import io.circe.syntax._ +import io.circe.{Codec, Decoder, Encoder} + +object HardwareCameraCodecs { + + private implicit val startLifecycleCodec: Codec[StartLifecycle] = deriveCodec[StartLifecycle] + private implicit val endLifecycleCodec: Codec[EndLifecycle] = deriveCodec[EndLifecycle] + + private implicit val pingCodec: Codec[Ping] = deriveCodec[Ping] + + private implicit val cameraAuthRequestCodec: Codec[AuthRequest] = deriveCodec[AuthRequest] + private implicit val cameraAuthResultCodec: Codec[AuthResult] = deriveCodec[AuthResult] + + private implicit val cameraUnavailableCodec: Codec[CameraUnavailable] = deriveCodec[CameraUnavailable] + private implicit val stopBroadcastingCodec: Codec[StopBroadcasting] = deriveCodec[StopBroadcasting] + private implicit val cameraSubscriptionCodec: Codec[CameraSubscription] = deriveCodec[CameraSubscription] + + implicit val hardwareCameraExternalMessageEncoder: Encoder[HardwareCameraMessageExternal] = + Encoder.instance { + case c: AuthRequest => NamedMessage("authRequest", c.asJson).asJson + case c: AuthResult => NamedMessage("authResult", c.asJson).asJson + case c: CameraUnavailable => NamedMessage("cameraUnavailable", c.asJson).asJson + case c: StopBroadcasting => NamedMessage("stopBroadcasting", c.asJson).asJson + case c: CameraSubscription => NamedMessage("cameraSubscription", c.asJson).asJson + case c: Ping => NamedMessage("ping", c.asJson).asJson + } + implicit val hardwareCameraExternalMessageDecoder: Decoder[HardwareCameraMessageExternal] = + Decoder[NamedMessage].emap { m => + { + val codec: Option[Decoder[HardwareCameraMessageExternal]] = m.command match { + case "authRequest" => Decoder[AuthRequest].widen[HardwareCameraMessageExternal].some + case "authResult" => Decoder[AuthResult].widen[HardwareCameraMessageExternal].some + case "cameraUnavailable" => Decoder[CameraUnavailable].widen[HardwareCameraMessageExternal].some + case "stopBroadcasting" => Decoder[StopBroadcasting].widen[HardwareCameraMessageExternal].some + case "cameraSubscription" => Decoder[CameraSubscription].widen[HardwareCameraMessageExternal].some + case "ping" => Decoder[Ping].widen[HardwareCameraMessageExternal].some + case _ => None + } + codec.toRight("Unknown command").flatMap(_.decodeJson(m.payload).leftMap(_.message)) + } + } + +} diff --git a/backend/protocol/src/main/scala/diptestbed/protocol/Codecs.scala b/backend/protocol/src/main/scala/diptestbed/protocol/HardwareControlCodecs.scala similarity index 55% rename from backend/protocol/src/main/scala/diptestbed/protocol/Codecs.scala rename to backend/protocol/src/main/scala/diptestbed/protocol/HardwareControlCodecs.scala index c8f4667..e4d5025 100644 --- a/backend/protocol/src/main/scala/diptestbed/protocol/Codecs.scala +++ b/backend/protocol/src/main/scala/diptestbed/protocol/HardwareControlCodecs.scala @@ -1,39 +1,15 @@ package diptestbed.protocol -import io.circe.generic.semiauto.deriveCodec -import io.circe.generic.extras.semiauto.deriveUnwrappedCodec -import io.circe.{Codec, Decoder, DecodingFailure, Encoder} -import io.circe.syntax._ import cats.implicits._ -import diptestbed.domain.HardwareCameraMessage.{Ping => CameraPing, CameraSubscription, CameraUnavailable, StopBroadcasting} -import diptestbed.domain._ -import diptestbed.domain.HardwareControlMessageInternal._ +import diptestbed.protocol.DomainCodecs._ import diptestbed.domain.HardwareControlMessageExternalNonBinary._ -import diptestbed.domain.HardwareSerialMonitorMessageNonBinary._ -import diptestbed.protocol.WebResult._ - -object Codecs { - implicit val domainTimeZoneIdCodec: Codec[DomainTimeZoneId] = Codec.from( - _.as[String].flatMap(DomainTimeZoneId.fromString(_).leftMap(x => DecodingFailure(x.getMessage, List.empty))), - _.value.asJson, - ) - implicit val userIdCodec: Codec[UserId] = deriveUnwrappedCodec[UserId] - implicit val userCodec: Codec[User] = deriveCodec[User] - implicit val hardwareIdCodec: Codec[HardwareId] = deriveUnwrappedCodec[HardwareId] - implicit val hardwareCodec: Codec[Hardware] = deriveCodec[Hardware] - implicit val softwareIdCodec: Codec[SoftwareId] = deriveUnwrappedCodec[SoftwareId] - implicit val softwareMetaCodec: Codec[SoftwareMeta] = deriveCodec[SoftwareMeta] - - implicit val namedMessageCodec: Codec[NamedMessage] = deriveCodec[NamedMessage] - - implicit val serialConfigCodec: Codec[SerialConfig] = deriveCodec[SerialConfig] - - implicit val unitCodec: Codec[Unit] = deriveCodec[Unit] - - - private implicit val monitorUnavailableCodec: Codec[MonitorUnavailable] = deriveCodec[MonitorUnavailable] - private implicit val connectionClosedCodec: Codec[ConnectionClosed] = deriveCodec[ConnectionClosed] +import diptestbed.domain.HardwareControlMessageInternal._ +import diptestbed.domain._ +import io.circe.generic.semiauto.deriveCodec +import io.circe.syntax._ +import io.circe.{Codec, Decoder, Encoder} +object HardwareControlCodecs { private implicit val uploadSoftwareRequestCodec: Codec[UploadSoftwareRequest] = deriveCodec[UploadSoftwareRequest] private implicit val uploadSoftwareResultCodec: Codec[UploadSoftwareResult] = deriveCodec[UploadSoftwareResult] @@ -56,8 +32,13 @@ object Codecs { private implicit val pingCodec: Codec[Ping] = deriveCodec[Ping] + private implicit val controlAuthRequestCodec: Codec[AuthRequest] = deriveCodec[AuthRequest] + private implicit val controlAuthResultCodec: Codec[AuthResult] = deriveCodec[AuthResult] + private implicit val hardwareControlMessageInternalEncoder: Encoder[HardwareControlMessageInternal] = Encoder.instance { + case c: AuthResult => NamedMessage("authResult", c.asJson).asJson + case c: StartLifecycle => NamedMessage("startLifecycle", c.asJson).asJson case c: EndLifecycle => NamedMessage("endLifecycle", c.asJson).asJson @@ -72,6 +53,8 @@ object Codecs { private implicit val hardwareControlMessageExternalNonBinaryEncoder : Encoder[HardwareControlMessageExternalNonBinary] = Encoder.instance { + case c: AuthRequest => NamedMessage("authRequest", c.asJson).asJson + case c: UploadSoftwareRequest => NamedMessage("uploadSoftwareRequest", c.asJson).asJson case c: UploadSoftwareResult => NamedMessage("uploadSoftwareResult", c.asJson).asJson @@ -88,6 +71,9 @@ object Codecs { Decoder[NamedMessage].emap { m => { val codec: Option[Decoder[HardwareControlMessageExternalNonBinary]] = m.command match { + case "authRequest" => + Decoder[AuthRequest].widen[HardwareControlMessageExternalNonBinary].some + case "uploadSoftwareRequest" => Decoder[UploadSoftwareRequest].widen[HardwareControlMessageExternalNonBinary].some case "uploadSoftwareResult" => @@ -112,6 +98,7 @@ object Codecs { Decoder[NamedMessage].emap { m => { val codec: Option[Decoder[HardwareControlMessageInternal]] = m.command match { + case "authResult" => Decoder[AuthResult].widen[HardwareControlMessageInternal].some case "startLifecycle" => Decoder[StartLifecycle].widen[HardwareControlMessageInternal].some case "endLifecycle" => Decoder[EndLifecycle].widen[HardwareControlMessageInternal].some case "serialMonitorRequestStop" => @@ -128,23 +115,6 @@ object Codecs { } } - implicit val hardwareSerialMonitorMessageNonBinaryEncoder: Encoder[HardwareSerialMonitorMessageNonBinary] = - Encoder.instance { - case c: MonitorUnavailable => NamedMessage("monitorUnavailable", c.asJson).asJson - case c: ConnectionClosed => NamedMessage("connectionClosed", c.asJson).asJson - } - implicit val hardwareSerialMonitorMessageNonBinaryDecoder: Decoder[HardwareSerialMonitorMessageNonBinary] = - Decoder[NamedMessage].emap { m => - { - val codec: Option[Decoder[HardwareSerialMonitorMessageNonBinary]] = m.command match { - case "monitorUnavailable" => Decoder[MonitorUnavailable].widen[HardwareSerialMonitorMessageNonBinary].some - case "connectionClosed" => Decoder[ConnectionClosed].widen[HardwareSerialMonitorMessageNonBinary].some - case _ => None - } - codec.toRight("Unknown command").flatMap(_.decodeJson(m.payload).leftMap(_.message)) - } - } - implicit val hardwareControlMessageNonBinaryEncoder: Encoder[HardwareControlMessageNonBinary] = Encoder.instance { case m: HardwareControlMessageExternalNonBinary => m.asJson @@ -156,51 +126,4 @@ object Codecs { .widen[HardwareControlMessageNonBinary] .orElse(Decoder[HardwareControlMessageInternal].widen[HardwareControlMessageNonBinary]) - - private implicit val cameraUnavailableCodec: Codec[CameraUnavailable] = deriveCodec[CameraUnavailable] - private implicit val stopBroadcastingCodec: Codec[StopBroadcasting] = deriveCodec[StopBroadcasting] - private implicit val cameraSubscriptionCodec: Codec[CameraSubscription] = deriveCodec[CameraSubscription] - private implicit val cameraPingCodec: Codec[CameraPing] = deriveCodec[CameraPing] - implicit val hardwareCameraExternalMessageEncoder: Encoder[HardwareCameraMessageExternal] = - Encoder.instance { - case c: CameraUnavailable => NamedMessage("cameraUnavailable", c.asJson).asJson - case c: StopBroadcasting => NamedMessage("stopBroadcasting", c.asJson).asJson - case c: CameraSubscription => NamedMessage("cameraSubscription", c.asJson).asJson - case c: CameraPing => NamedMessage("ping", c.asJson).asJson - } - implicit val hardwareCameraExternalMessageDecoder: Decoder[HardwareCameraMessageExternal] = - Decoder[NamedMessage].emap { m => - { - val codec: Option[Decoder[HardwareCameraMessageExternal]] = m.command match { - case "cameraUnavailable" => Decoder[CameraUnavailable].widen[HardwareCameraMessageExternal].some - case "stopBroadcasting" => Decoder[StopBroadcasting].widen[HardwareCameraMessageExternal].some - case "cameraSubscription" => Decoder[CameraSubscription].widen[HardwareCameraMessageExternal].some - case "ping" => Decoder[CameraPing].widen[HardwareCameraMessageExternal].some - case _ => None - } - codec.toRight("Unknown command").flatMap(_.decodeJson(m.payload).leftMap(_.message)) - } - } - - implicit def webResultSuccessCodec[A: Encoder: Decoder]: Codec[Success[A]] = - Codec.forProduct1[Success[A], A]("success")(Success(_))(_.value) - implicit def webResultFailureCodec[A: Encoder: Decoder]: Codec[Failure[A]] = - Codec.forProduct1[Failure[A], A]("failure")(Failure(_))(_.value) - protected def webResultEncoder[A: Encoder: Decoder]: Encoder[WebResult[A]] = - Encoder.instance { - case success: Success[A] => success.asJson - case failure: Failure[A] => failure.asJson - } - protected def webResultDecoder[A: Encoder: Decoder]: Decoder[WebResult[A]] = - List[Decoder[WebResult[A]]]( - Decoder[Success[A]].widen, - Decoder[Failure[A]].widen, - ).reduceLeft(_ or _) - implicit def webResultCodec[A: Encoder: Decoder]: Codec[WebResult[A]] = - Codec.from(webResultDecoder, webResultEncoder) - - implicit val helloCodec: Codec[Hello] = Codec.forProduct1[Hello, String]("hello")(Hello)(_.recipient) - implicit val serviceStatusCodec: Codec[ServiceStatus] = deriveCodec[ServiceStatus] - implicit val createUserCodec: Codec[CreateUser] = deriveCodec[CreateUser] - implicit val createHardwareCodec: Codec[CreateHardware] = deriveCodec[CreateHardware] } diff --git a/backend/protocol/src/main/scala/diptestbed/protocol/HardwareSerialMonitorCodecs.scala b/backend/protocol/src/main/scala/diptestbed/protocol/HardwareSerialMonitorCodecs.scala new file mode 100644 index 0000000..aa96850 --- /dev/null +++ b/backend/protocol/src/main/scala/diptestbed/protocol/HardwareSerialMonitorCodecs.scala @@ -0,0 +1,34 @@ +package diptestbed.protocol + +import cats.implicits._ +import diptestbed.protocol.DomainCodecs._ +import diptestbed.domain.HardwareSerialMonitorMessageNonBinary._ +import diptestbed.domain._ +import io.circe.generic.semiauto.deriveCodec +import io.circe.syntax._ +import io.circe.{Codec, Decoder, Encoder} + +object HardwareSerialMonitorCodecs { + private implicit val monitorUnavailableCodec: Codec[MonitorUnavailable] = deriveCodec[MonitorUnavailable] + private implicit val connectionClosedCodec: Codec[ConnectionClosed] = deriveCodec[ConnectionClosed] + private implicit val authResultCodec: Codec[AuthResult] = deriveCodec[AuthResult] + + implicit val hardwareSerialMonitorMessageNonBinaryEncoder: Encoder[HardwareSerialMonitorMessageNonBinary] = + Encoder.instance { + case c: AuthResult => NamedMessage("authResult", c.asJson).asJson + case c: MonitorUnavailable => NamedMessage("monitorUnavailable", c.asJson).asJson + case c: ConnectionClosed => NamedMessage("connectionClosed", c.asJson).asJson + } + implicit val hardwareSerialMonitorMessageNonBinaryDecoder: Decoder[HardwareSerialMonitorMessageNonBinary] = + Decoder[NamedMessage].emap { m => + { + val codec: Option[Decoder[HardwareSerialMonitorMessageNonBinary]] = m.command match { + case "authResult" => Decoder[AuthResult].widen[HardwareSerialMonitorMessageNonBinary].some + case "monitorUnavailable" => Decoder[MonitorUnavailable].widen[HardwareSerialMonitorMessageNonBinary].some + case "connectionClosed" => Decoder[ConnectionClosed].widen[HardwareSerialMonitorMessageNonBinary].some + case _ => None + } + codec.toRight("Unknown command").flatMap(_.decodeJson(m.payload).leftMap(_.message)) + } + } +} diff --git a/backend/protocol/src/main/scala/diptestbed/protocol/WebResult.scala b/backend/protocol/src/main/scala/diptestbed/protocol/WebResult.scala index 9dd3839..bbc53e6 100644 --- a/backend/protocol/src/main/scala/diptestbed/protocol/WebResult.scala +++ b/backend/protocol/src/main/scala/diptestbed/protocol/WebResult.scala @@ -2,7 +2,7 @@ package diptestbed.protocol import io.circe.syntax._ import io.circe.{Decoder, Encoder} -import diptestbed.protocol.Codecs._ +import diptestbed.protocol.DomainCodecs._ sealed trait WebResult[A] { val value: A diff --git a/backend/protocol/src/test/scala/diptestbed/protocol/CodecsSpec.scala b/backend/protocol/src/test/scala/diptestbed/protocol/CodecsSpec.scala index c916e00..6c9dfbe 100644 --- a/backend/protocol/src/test/scala/diptestbed/protocol/CodecsSpec.scala +++ b/backend/protocol/src/test/scala/diptestbed/protocol/CodecsSpec.scala @@ -1,13 +1,14 @@ package diptestbed.protocol -import diptestbed.domain.HardwareSerialMonitorMessage.MonitorUnavailable import org.scalatest.matchers.should.Matchers import org.scalatest.freespec.AnyFreeSpec import io.circe.parser._ import io.circe.syntax._ -import diptestbed.domain.{DomainTimeZoneId, HardwareControlMessage, HardwareSerialMonitorMessage, SerialConfig, SoftwareId} +import diptestbed.domain._ +import diptestbed.protocol.DomainCodecs._ +import diptestbed.protocol.HardwareSerialMonitorCodecs._ +import diptestbed.protocol.HardwareControlCodecs._ import diptestbed.protocol.WebResult._ -import diptestbed.protocol.Codecs._ class CodecsSpec extends AnyFreeSpec with Matchers { @@ -45,18 +46,18 @@ class CodecsSpec extends AnyFreeSpec with Matchers { val softwareUUID = "16d7ce54-2d10-11ec-a35e-d79560b12f04" val serialized = "{\"command\":\"uploadSoftwareRequest\",\"payload\":{\"softwareId\":\"" + softwareUUID + "\"}}" val softwareId = SoftwareId.fromString(softwareUUID).toOption.get - val unserialized: HardwareControlMessage = HardwareControlMessage.UploadSoftwareRequest(softwareId) + val unserialized: HardwareControlMessageNonBinary = HardwareControlMessageExternalNonBinary.UploadSoftwareRequest(softwareId) unserialized.asJson.noSpaces.shouldEqual(serialized) - decode[HardwareControlMessage](serialized).shouldEqual(Right(unserialized)) + decode[HardwareControlMessageNonBinary](serialized).shouldEqual(Right(unserialized)) } "hardware control upload result messages should encode and decode" in { // {"command":"uploadSoftwareResult","payload":{"error":null}} // {"command":"uploadSoftwareResult","payload":{"error":"lp0 on fire"}} val serialized = "{\"command\":\"uploadSoftwareResult\",\"payload\":{\"error\":null}}" - val unserialized: HardwareControlMessage = HardwareControlMessage.UploadSoftwareResult(None) + val unserialized: HardwareControlMessageNonBinary = HardwareControlMessageExternalNonBinary.UploadSoftwareResult(None) unserialized.asJson.noSpaces.shouldEqual(serialized) - decode[HardwareControlMessage](serialized).shouldEqual(Right(unserialized)) + decode[HardwareControlMessageNonBinary](serialized).shouldEqual(Right(unserialized)) } "hardware monitor request messages should encode and decode" in { @@ -70,29 +71,37 @@ class CodecsSpec extends AnyFreeSpec with Matchers { "}" + "}" + "}" - val unserialized: HardwareControlMessage = - HardwareControlMessage.SerialMonitorRequest(Some(SerialConfig(115200, 1, 0.3f))) + val unserialized: HardwareControlMessageNonBinary = + HardwareControlMessageExternalNonBinary.SerialMonitorRequest(Some(SerialConfig(115200, 1, 0.3f))) unserialized.asJson.noSpaces.shouldEqual(serialized) - decode[HardwareControlMessage](serialized).shouldEqual(Right(unserialized)) + decode[HardwareControlMessageNonBinary](serialized).shouldEqual(Right(unserialized)) } "hardware monitor result messages should encode and decode" in { // {"command":"serialMonitorResult","payload":{"error":null}} // {"command":"serialMonitorResult","payload":{"error":"lp0 on fire"}} val serialized = "{\"command\":\"serialMonitorResult\",\"payload\":{\"error\":null}}" - val unserialized: HardwareControlMessage = HardwareControlMessage.SerialMonitorResult(None) + val unserialized: HardwareControlMessageNonBinary = HardwareControlMessageExternalNonBinary.SerialMonitorResult(None) unserialized.asJson.noSpaces.shouldEqual(serialized) - decode[HardwareControlMessage](serialized).shouldEqual(Right(unserialized)) + decode[HardwareControlMessageNonBinary](serialized).shouldEqual(Right(unserialized)) } "hardware control monitor unavailable messages should encode and decode" in { // {"command":"monitorUnavailable","payload":{"reason":"lp0 on fire"}} val serialized = "{\"command\":\"monitorUnavailable\",\"payload\":{\"reason\":\"lp0 on fire\"}}" - val unserialized: HardwareSerialMonitorMessage = MonitorUnavailable("lp0 on fire") + val unserialized: HardwareSerialMonitorMessageNonBinary = HardwareSerialMonitorMessageNonBinary.MonitorUnavailable("lp0 on fire") unserialized.asJson.noSpaces.shouldEqual(serialized) - decode[HardwareSerialMonitorMessage](serialized).shouldEqual(Right(unserialized)) + decode[HardwareSerialMonitorMessageNonBinary](serialized).shouldEqual(Right(unserialized)) + } + + "hardware control auth messages should encode and decode" in { + // {"command":"authRequest","payload":{"username":"user","password":"pass"}} + val serialized = "{\"command\":\"authRequest\",\"payload\":{\"username\":\"user\",\"password\":\"pass\"}}" + val unserialized: HardwareControlMessageNonBinary = HardwareControlMessageExternalNonBinary.AuthRequest("user", "pass") + unserialized.asJson.noSpaces.shouldEqual(serialized) + decode[HardwareControlMessageNonBinary](serialized).shouldEqual(Right(unserialized)) } } diff --git a/backend/web/app/diptestbed/web/actors/HardwareCameraActor.scala b/backend/web/app/diptestbed/web/actors/HardwareCameraActor.scala index 80399c9..505dec3 100644 --- a/backend/web/app/diptestbed/web/actors/HardwareCameraActor.scala +++ b/backend/web/app/diptestbed/web/actors/HardwareCameraActor.scala @@ -2,22 +2,26 @@ package diptestbed.web.actors import akka.actor._ import akka.util.ByteString +import cats.data.EitherT import cats.effect.IO import cats.effect.unsafe.IORuntime -import cats.implicits.toBifunctorOps import io.circe.parser.decode import com.typesafe.scalalogging.LazyLogging +import diptestbed.database.services.{HardwareService, UserService} import diptestbed.domain.EventEngine.MessageResult -import diptestbed.domain.HardwareCameraMessage.{CameraChunk, EndLifecycle, StartLifecycle} +import diptestbed.domain.HardwareCameraMessage._ import diptestbed.domain._ import diptestbed.web.actors.ActorHelper.websocketFlowTransformer import play.api.http.websocket.{BinaryMessage, CloseCodes, CloseMessage, TextMessage} import play.api.mvc.WebSocket.MessageFlowTransformer import io.circe.syntax._ -import diptestbed.protocol.Codecs._ +import cats.implicits._ +import diptestbed.protocol.HardwareCameraCodecs._ class HardwareCameraActor( val appConfig: DIPTestbedConfig, + val userService: UserService[IO], + val hardwareService: HardwareService[IO], val pubSubMediator: ActorRef, val camera: ActorRef, val hardwareIds: List[HardwareId], @@ -40,12 +44,26 @@ class HardwareCameraActor( case message: HardwareCameraMessage => (Some(sender()), message) } + def auth(username: String, password: String): IO[Either[String, User]] = + (for { + user <- EitherT(userService.getUserWithPassword(username, password)) + .leftMap(e => f"Database error: ${e.message}") + existingUser <- EitherT.fromEither[IO](user.toRight(f"User auth failure")) + _ <- EitherT.fromEither[IO](Either.cond( + existingUser.canInteractHardware, (), "Missing permission: Hardware access")) + // Inefficient - queries should be batched into one + hardware <- hardwareIds.traverse(id => EitherT(hardwareService.getHardware(Some(existingUser), id, write = true))) // Controlling hardware requires write permission + .leftMap(e => f"Database error: ${e.message}") + _ <- EitherT.fromEither[IO](Either.cond(hardware.exists(_.isDefined), (), f"Hardware does not exist or you're missing permissions")) + } yield existingUser).value + def onMessage( inquirer: => Option[ActorRef], ): HardwareCameraMessage => MessageResult[IO, HardwareCameraEvent[ActorRef], HardwareCameraState[ActorRef]] = HardwareCameraEventEngine.onMessage[ActorRef, IO]( state, IO(state.self ! PoisonPill), + auth, send, publish, subscriptionMessage, @@ -55,17 +73,23 @@ class HardwareCameraActor( object HardwareCameraActor { def props( appConfig: DIPTestbedConfig, + userService: UserService[IO], + hardwareService: HardwareService[IO], pubSubMediator: ActorRef, out: ActorRef, hardwareIds: List[HardwareId], )(implicit iort: IORuntime, - ): Props = Props(new HardwareCameraActor(appConfig, pubSubMediator, out, hardwareIds)) + ): Props = Props(new HardwareCameraActor(appConfig, userService, hardwareService, pubSubMediator, out, hardwareIds)) val cameraControlTransformer: MessageFlowTransformer[HardwareCameraMessage, HardwareCameraMessage] = websocketFlowTransformer({ case TextMessage(text) => decode[HardwareCameraMessageExternal](text) - .leftMap(e => CloseMessage(Some(CloseCodes.Unacceptable), e.getMessage)) + .flatMap { + case AuthResult(_) => Left(new Exception("Can't force auth externally")) + case other => Right(other) + } + .leftMap(e => CloseMessage(Some(CloseCodes.Unacceptable), e.getMessage)) case BinaryMessage(bytes: ByteString) => Right(CameraChunk(bytes.toArray)) }, { diff --git a/backend/web/app/diptestbed/web/actors/HardwareControlActor.scala b/backend/web/app/diptestbed/web/actors/HardwareControlActor.scala index c3d4c64..467f5ba 100644 --- a/backend/web/app/diptestbed/web/actors/HardwareControlActor.scala +++ b/backend/web/app/diptestbed/web/actors/HardwareControlActor.scala @@ -5,24 +5,27 @@ import diptestbed.domain._ import play.api.http.websocket._ import play.api.mvc.WebSocket.MessageFlowTransformer import cats.effect.IO -import cats.implicits._ import cats.effect.unsafe.IORuntime import akka.util.{ByteString, Timeout} import cats.data.EitherT +import cats.implicits.toBifunctorOps import com.typesafe.scalalogging.LazyLogging +import diptestbed.database.services.{HardwareService, UserService} import diptestbed.domain.EventEngine.MessageResult import diptestbed.domain.HardwareControlMessageExternalBinary._ import diptestbed.domain.HardwareControlMessageExternalNonBinary._ import diptestbed.domain.HardwareControlMessageInternal._ import diptestbed.domain.HardwareSerialMonitorMessageBinary._ import io.circe.parser.decode -import diptestbed.protocol.Codecs._ import io.circe.syntax.EncoderOps import diptestbed.web.actors.ActorHelper._ import diptestbed.web.actors.QueryActor.Promise +import diptestbed.protocol.HardwareControlCodecs._ class HardwareControlActor( val appConfig: DIPTestbedConfig, + val userService: UserService[IO], + val hardwareService: HardwareService[IO], val pubSubMediator: ActorRef, val agent: ActorRef, val hardwareId: HardwareId, @@ -48,27 +51,47 @@ class HardwareControlActor( (Some(inquirer), message) } + def auth(username: String, password: String): IO[Either[String, User]] = + (for { + user <- EitherT(userService.getUserWithPassword(username, password)) + .leftMap(e => f"Database error: ${e.message}") + existingUser <- EitherT.fromEither[IO](user.toRight(f"User auth failure")) + _ <- EitherT.fromEither[IO](Either.cond( + existingUser.canInteractHardware, (), "Missing permission: Hardware access")) + hardware <- EitherT(hardwareService.getHardware(Some(existingUser), hardwareId, write = true)) // Controlling hardware requires write permission + .leftMap(e => f"Database error: ${e.message}") + _ <- EitherT.fromEither[IO](hardware.toRight(f"Hardware does not exist or you're missing permissions")) + } yield existingUser).value + def onMessage( inquirer: => Option[ActorRef], ): HardwareControlMessage => MessageResult[IO, HardwareControlEvent[ActorRef], HardwareControlState[ActorRef]] = - HardwareControlEventEngine.onMessage[ActorRef, IO](state, send, publish, inquirer) + HardwareControlEventEngine.onMessage[ActorRef, IO](state, auth, send, publish, inquirer) } object HardwareControlActor { def props( appConfig: DIPTestbedConfig, + userService: UserService[IO], + hardwareService: HardwareService[IO], pubSubMediator: ActorRef, out: ActorRef, hardwareId: HardwareId, )(implicit iort: IORuntime, - ): Props = Props(new HardwareControlActor(appConfig, pubSubMediator, out, hardwareId)) + ): Props = Props(new HardwareControlActor(appConfig, userService, hardwareService, pubSubMediator, out, hardwareId)) val controlTransformer: MessageFlowTransformer[HardwareControlMessage, HardwareControlMessage] = websocketFlowTransformer( { case TextMessage(text) => decode[HardwareControlMessageNonBinary](text) + .flatMap { + case AuthResult(_) => Left(new Exception("Can't force auth externally")) + case AuthSuccess(_) => Left(new Exception("Can't force auth externally")) + case AuthFailure(_) => Left(new Exception("Can't force auth externally")) + case other => Right(other) + } .leftMap(e => CloseMessage(Some(CloseCodes.Unacceptable), e.getMessage)) case BinaryMessage(bytes: ByteString) => Right(SerialMonitorMessageToClient(SerialMessageToClient(bytes.toArray))) diff --git a/backend/web/app/diptestbed/web/actors/HardwareSerialMonitorListenerActor.scala b/backend/web/app/diptestbed/web/actors/HardwareSerialMonitorListenerActor.scala index f5adead..d338773 100644 --- a/backend/web/app/diptestbed/web/actors/HardwareSerialMonitorListenerActor.scala +++ b/backend/web/app/diptestbed/web/actors/HardwareSerialMonitorListenerActor.scala @@ -3,16 +3,18 @@ package diptestbed.web.actors import akka.actor._ import diptestbed.domain._ import io.circe.syntax.EncoderOps -import diptestbed.protocol.Codecs._ import akka.cluster.pubsub.DistributedPubSubMediator.Subscribe import cats.effect.IO import diptestbed.web.actors.HardwareControlActor._ import cats.effect.unsafe.IORuntime import akka.util.{ByteString, Timeout} +import cats.data.EitherT +import cats.implicits.toBifunctorOps import io.circe.parser.decode -import cats.implicits._ + import scala.annotation.unused import com.typesafe.scalalogging.LazyLogging +import diptestbed.database.services.{HardwareService, UserService} import diptestbed.domain.HardwareControlMessageExternalBinary._ import diptestbed.domain.HardwareControlMessageExternalNonBinary._ import diptestbed.domain.HardwareSerialMonitorMessageBinary._ @@ -22,9 +24,13 @@ import diptestbed.web.actors.ActorHelper.websocketFlowTransformer import play.api.http.websocket._ import play.api.mvc.WebSocket.MessageFlowTransformer import scala.concurrent.duration.DurationInt +import diptestbed.protocol.HardwareControlCodecs._ +import diptestbed.protocol.HardwareSerialMonitorCodecs._ class HardwareSerialMonitorListenerActor( pubSubMediator: ActorRef, + userService: UserService[IO], + hardwareService: HardwareService[IO], out: ActorRef, hardwareId: HardwareId, serialConfig: Option[SerialConfig], @@ -36,8 +42,10 @@ class HardwareSerialMonitorListenerActor( with LazyLogging { logger.info(s"Serial monitor listener for hardware #${hardwareId} spawned") + var auth: Option[User] = None + // Send monitor actor request for listening w/ a given baudrate and react accordingly - requestSerialMonitor(hardwareId, serialConfig).value + def requestMonitorInit(): IO[Unit] = requestSerialMonitor(hardwareId, serialConfig).value .flatMap { case Right(_) => // Start listening to serial monitor topic @@ -50,7 +58,6 @@ class HardwareSerialMonitorListenerActor( killListener("Monitor unavailable") } .void - .unsafeRunAsync(_ => ()) def sendToListener(message: HardwareSerialMonitorMessage): IO[Unit] = IO(out ! message) @@ -63,6 +70,18 @@ class HardwareSerialMonitorListenerActor( IO(self ! PoisonPill) } + def checkAuth(username: String, password: String): IO[Either[String, User]] = + (for { + user <- EitherT(userService.getUserWithPassword(username, password)) + .leftMap(e => f"Database error: ${e.message}") + existingUser <- EitherT.fromEither[IO](user.toRight(f"User auth failure")) + _ <- EitherT.fromEither[IO](Either.cond( + existingUser.canInteractHardware, (), "Missing permission: Hardware access")) + hardware <- EitherT(hardwareService.getHardware(Some(existingUser), hardwareId, write = false)) + .leftMap(e => f"Database error: ${e.message}") + _ <- EitherT.fromEither[IO](hardware.toRight(f"Hardware does not exist or you're missing permissions")) + } yield existingUser).value + def receive: Receive = { // Initial hardware monitoring request to hardware control failed case badMessage: HardwareSerialMonitorMessageNonBinary.MonitorUnavailable => @@ -75,21 +94,42 @@ class HardwareSerialMonitorListenerActor( (sendToListener(message) >> killListener("Monitor not valid anymore")).unsafeRunAsync(_ => ()) // Hardware control published message to listeners case serialToClientMessage: SerialMonitorMessageToClient => - val message: HardwareSerialMonitorMessage = SerialMessageToClient(serialToClientMessage.message.bytes) - sendToListener(message).unsafeRunAsync(_ => ()) + if (auth.isDefined) { + val message: HardwareSerialMonitorMessage = SerialMessageToClient(serialToClientMessage.message.bytes) + sendToListener(message).unsafeRunAsync(_ => ()) + } // Hardware control published heartbeat request to listeners case _: SerialMonitorListenersHeartbeatPing => - println("Received ping, responding pong") sendToHardwareActor(hardwareId, SerialMonitorListenersHeartbeatPong()).value.unsafeRunAsync(_ => ()) // Listener is sending a message to hardware control case serialToAgentMessage: SerialMonitorMessageToAgent => - sendToHardwareActor(hardwareId, serialToAgentMessage).value.unsafeRunAsync(_ => ()) + if (auth.isDefined) { + sendToHardwareActor(hardwareId, serialToAgentMessage).value.unsafeRunAsync(_ => ()) + } + // Listener sent auth request, validate, write in state and respond with result + case m: HardwareControlMessageExternalNonBinary.AuthRequest => + checkAuth(m.username, m.password).flatMap[Unit] { + case Left(reason) => + IO { + auth = None + } >> + sendToListener(AuthResult(Some(reason))) >> + killListener("Auth failed") + case Right(user) => + IO { + auth = Some(user) + } >> + requestMonitorInit() >> + sendToListener(AuthResult(None)) + }.unsafeRunAsync(_ => ()) } } object HardwareSerialMonitorListenerActor { def props( pubSubMediator: ActorRef, + userService: UserService[IO], + hardwareService: HardwareService[IO], out: ActorRef, hardwareId: HardwareId, serialConfig: Option[SerialConfig], @@ -97,13 +137,20 @@ object HardwareSerialMonitorListenerActor { iort: IORuntime, timeout: Timeout, actorSystem: ActorSystem, - ): Props = Props(new HardwareSerialMonitorListenerActor(pubSubMediator, out, hardwareId, serialConfig)) + ): Props = Props(new HardwareSerialMonitorListenerActor( + pubSubMediator, userService, hardwareService, out, hardwareId, serialConfig)) val listenerTransformer: MessageFlowTransformer[HardwareControlMessage, HardwareSerialMonitorMessage] = websocketFlowTransformer( { case TextMessage(text) => decode[HardwareControlMessageNonBinary](text) + .flatMap { + case HardwareControlMessageInternal.AuthResult(_) => Left(new Exception("Can't force auth externally")) + case HardwareControlMessageInternal.AuthSuccess(_) => Left(new Exception("Can't force auth externally")) + case HardwareControlMessageInternal.AuthFailure(_) => Left(new Exception("Can't force auth externally")) + case other => Right(other) + } .leftMap(e => CloseMessage(Some(CloseCodes.Unacceptable), e.getMessage)) case BinaryMessage(bytes: ByteString) => Right(SerialMonitorMessageToAgent(SerialMessageToAgent(bytes.toArray))) diff --git a/backend/web/app/diptestbed/web/control/ApiHardwareController.scala b/backend/web/app/diptestbed/web/control/ApiHardwareController.scala index 82086d3..00a0980 100644 --- a/backend/web/app/diptestbed/web/control/ApiHardwareController.scala +++ b/backend/web/app/diptestbed/web/control/ApiHardwareController.scala @@ -11,8 +11,8 @@ import cats.effect.unsafe.IORuntime import cats.implicits._ import diptestbed.database.services.{HardwareService, UserService} import diptestbed.domain.{DIPTestbedConfig, HardwareCameraMessage, HardwareControlMessage, HardwareId, HardwareSerialMonitorMessage, SerialConfig, SoftwareId} +import diptestbed.protocol.DomainCodecs._ import diptestbed.protocol._ -import diptestbed.protocol.Codecs._ import diptestbed.protocol.WebResult._ import diptestbed.web.actors.HardwareCameraActor.cameraControlTransformer import diptestbed.web.actors.HardwareControlActor.controlTransformer @@ -22,6 +22,7 @@ import diptestbed.web.ioControls.PipelineOps._ import diptestbed.web.ioControls._ import play.api.mvc.WebSocket.MessageFlowTransformer import play.api.mvc._ + import scala.concurrent.duration.DurationInt class ApiHardwareController( @@ -81,7 +82,7 @@ class ApiHardwareController( controlTransformer WebSocket.accept[HardwareControlMessage, HardwareControlMessage](_ => { BetterActorFlow.actorRef( - subscriber => HardwareControlActor.props(appConfig, pubSubMediator, subscriber, hardwareId), + subscriber => HardwareControlActor.props(appConfig, userService, hardwareService, pubSubMediator, subscriber, hardwareId), maybeName = hardwareId.actorId().text().some, ) }) @@ -109,7 +110,7 @@ class ApiHardwareController( WebSocket.accept[HardwareControlMessage, HardwareSerialMonitorMessage](_ => { implicit val timeout: Timeout = 60.seconds BetterActorFlow.actorRef(subscriber => - HardwareSerialMonitorListenerActor.props(pubSubMediator, subscriber, hardwareId, serialConfig), + HardwareSerialMonitorListenerActor.props(pubSubMediator, userService, hardwareService, subscriber, hardwareId, serialConfig), ) }) } @@ -120,7 +121,7 @@ class ApiHardwareController( cameraControlTransformer WebSocket.accept[HardwareCameraMessage, HardwareCameraMessage](_ => { BetterActorFlow.actorRef(subscriber => - HardwareCameraActor.props(appConfig, pubSubMediator, subscriber, hardwareIds), + HardwareCameraActor.props(appConfig, userService, hardwareService, pubSubMediator, subscriber, hardwareIds), ) }) } diff --git a/backend/web/app/diptestbed/web/control/ApiHomeController.scala b/backend/web/app/diptestbed/web/control/ApiHomeController.scala index dea9ef5..3516c30 100644 --- a/backend/web/app/diptestbed/web/control/ApiHomeController.scala +++ b/backend/web/app/diptestbed/web/control/ApiHomeController.scala @@ -12,8 +12,8 @@ import diptestbed.domain.DIPTestbedConfig import diptestbed.web.ioControls.PipelineOps._ import diptestbed.web.ioControls._ import diptestbed.protocol.{Hello, ServiceStatus} -import diptestbed.protocol.Codecs._ import diptestbed.protocol.WebResult._ +import diptestbed.protocol.DomainCodecs._ class ApiHomeController( val appConfig: DIPTestbedConfig, diff --git a/backend/web/app/diptestbed/web/control/ApiSoftwareController.scala b/backend/web/app/diptestbed/web/control/ApiSoftwareController.scala index 5db84c0..11c4625 100644 --- a/backend/web/app/diptestbed/web/control/ApiSoftwareController.scala +++ b/backend/web/app/diptestbed/web/control/ApiSoftwareController.scala @@ -7,8 +7,8 @@ import cats.effect.unsafe.IORuntime import cats.implicits._ import diptestbed.database.services.{SoftwareService, UserService} import diptestbed.domain.{DIPTestbedConfig, SoftwareId} -import diptestbed.protocol.Codecs._ import diptestbed.protocol.WebResult._ +import diptestbed.protocol.DomainCodecs._ import diptestbed.web.ioControls.PipelineOps._ import diptestbed.web.ioControls._ import play.api.libs.Files diff --git a/backend/web/app/diptestbed/web/control/ApiUserController.scala b/backend/web/app/diptestbed/web/control/ApiUserController.scala index 3d4f79f..b44eebd 100644 --- a/backend/web/app/diptestbed/web/control/ApiUserController.scala +++ b/backend/web/app/diptestbed/web/control/ApiUserController.scala @@ -11,10 +11,10 @@ import play.api.mvc._ import diptestbed.database.services.UserService import diptestbed.domain.{DIPTestbedConfig, HashedPassword, UserId} import diptestbed.protocol._ -import diptestbed.protocol.Codecs._ import diptestbed.protocol.WebResult._ import diptestbed.web.ioControls.PipelineOps._ import diptestbed.web.ioControls._ +import diptestbed.protocol.DomainCodecs._ class ApiUserController( val appConfig: DIPTestbedConfig, diff --git a/client/src/domain/hardware_control_message.py b/client/src/domain/hardware_control_message.py index 39aae34..d190348 100644 --- a/client/src/domain/hardware_control_message.py +++ b/client/src/domain/hardware_control_message.py @@ -5,7 +5,8 @@ from dataclasses import dataclass from src.domain.dip_client_error import DIPClientError from src.domain.existing_file_path import ExistingFilePath -from src.domain.hardware_shared_message import InternalStartLifecycle, InternalEndLifecycle, PingMessage +from src.domain.hardware_shared_message import InternalStartLifecycle, InternalEndLifecycle, PingMessage, AuthRequest, \ + AuthResult from src.domain.managed_uuid import ManagedUUID from src.domain.monitor_message import SerialMonitorMessageToAgent, SerialMonitorMessageToClient from src.domain.noisy_message import NoisyMessage @@ -122,13 +123,14 @@ class SerialMonitorResult(ExternalHardwareControlMessage): # Messages incoming and outgoing to and from control server COMMON_INCOMING_MESSAGE = Union[ + AuthResult, InternalStartLifecycle, InternalEndLifecycle, UploadMessage, SerialMonitorRequest, SerialMonitorRequestStop, SerialMonitorMessageToAgent] -COMMON_OUTGOING_MESSAGE = Union[UploadResultMessage, PingMessage, SerialMonitorResult, SerialMonitorMessageToClient] +COMMON_OUTGOING_MESSAGE = Union[AuthRequest, UploadResultMessage, PingMessage, SerialMonitorResult, SerialMonitorMessageToClient] def log_hardware_message(logger: LOGGER, message: Any): diff --git a/client/src/domain/hardware_shared_event.py b/client/src/domain/hardware_shared_event.py index 67078ec..9472fe0 100644 --- a/client/src/domain/hardware_shared_event.py +++ b/client/src/domain/hardware_shared_event.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from src.domain.dip_client_error import DIPClientError from src.engine.engine_state import EngineState +from src.service.backend_config import UserPassAuthConfig @dataclass(frozen=True) @@ -11,8 +12,25 @@ class LifecycleStarted: state: EngineState +@dataclass(frozen=True) +class StartingAuth: + """First message emitted into engines""" + auth: UserPassAuthConfig + + @dataclass(frozen=True) class LifecycleEnded: """Last message emitted into engines""" reason: Optional[DIPClientError] = None + +@dataclass(frozen=True) +class AuthSucceeded: + pass + + +@dataclass(frozen=True) +class AuthFailed: + """First message emitted into engines""" + reason: str + diff --git a/client/src/domain/hardware_shared_message.py b/client/src/domain/hardware_shared_message.py index 7a92a00..a186755 100644 --- a/client/src/domain/hardware_shared_message.py +++ b/client/src/domain/hardware_shared_message.py @@ -16,6 +16,23 @@ class InternalEndLifecycle(): reason: Optional[DIPClientError] = None +@dataclass(frozen=True) +class AuthRequest(): + username: str + password: str + + def __str__(self): + return f"AuthRequest(...)" + + def __repr__(self): + return self.__str__() + + +@dataclass(frozen=True) +class AuthResult(): + error: Optional[str] + + @dataclass(frozen=True) class PingMessage(NoisyMessage): """Message for sending heartbeats to server""" diff --git a/client/src/domain/hardware_video_message.py b/client/src/domain/hardware_video_message.py index c6617a6..42e287d 100644 --- a/client/src/domain/hardware_video_message.py +++ b/client/src/domain/hardware_video_message.py @@ -2,7 +2,8 @@ from typing import TypeVar, Union, Optional, Any from dataclasses import dataclass from src.domain.dip_client_error import DIPClientError -from src.domain.hardware_shared_message import InternalStartLifecycle, InternalEndLifecycle, PingMessage +from src.domain.hardware_shared_message import InternalStartLifecycle, InternalEndLifecycle, PingMessage, AuthResult, \ + AuthRequest from src.domain.noisy_message import NoisyMessage from src.service.managed_video_stream import ManagedVideoStream from src.util import log @@ -72,9 +73,10 @@ class StopBroadcasting(ExternalHardwareVideoMessage): # Messages incoming and outgoing to and from control server COMMON_INCOMING_VIDEO_MESSAGE = Union[ + AuthResult, CameraSubscription, StopBroadcasting] -COMMON_OUTGOING_VIDEO_MESSAGE = Union[CameraChunk, PingMessage, CameraUnavailable] +COMMON_OUTGOING_VIDEO_MESSAGE = Union[AuthRequest, CameraChunk, PingMessage, CameraUnavailable] def log_video_message(logger: LOGGER, message: Any): diff --git a/client/src/domain/monitor_message.py b/client/src/domain/monitor_message.py index 97337b8..e1f744f 100644 --- a/client/src/domain/monitor_message.py +++ b/client/src/domain/monitor_message.py @@ -3,6 +3,7 @@ from typing import Union from dataclasses import dataclass +from src.domain.hardware_shared_message import AuthRequest, AuthResult from src.domain.noisy_message import NoisyMessage @@ -30,5 +31,5 @@ class SerialMonitorMessageToClient(MonitorMessage, NoisyMessage): content_bytes: bytes -MONITOR_LISTENER_INCOMING_MESSAGE = Union[MonitorUnavailable, SerialMonitorMessageToClient] -MONITOR_LISTENER_OUTGOING_MESSAGE = Union[SerialMonitorMessageToAgent] +MONITOR_LISTENER_INCOMING_MESSAGE = Union[AuthResult, MonitorUnavailable, SerialMonitorMessageToClient] +MONITOR_LISTENER_OUTGOING_MESSAGE = Union[AuthRequest, SerialMonitorMessageToAgent] diff --git a/client/src/engine/board/anvyl/engine_anvyl_state.py b/client/src/engine/board/anvyl/engine_anvyl_state.py index cbd4e7a..a2e5875 100644 --- a/client/src/engine/board/anvyl/engine_anvyl_state.py +++ b/client/src/engine/board/anvyl/engine_anvyl_state.py @@ -7,6 +7,7 @@ from src.domain.managed_uuid import ManagedUUID from src.domain.positive_integer import PositiveInteger from src.service.backend import BackendServiceInterface +from src.service.backend_config import UserPassAuthConfig from src.service.managed_serial import ManagedSerial @@ -26,5 +27,7 @@ class EngineAnvylState(EngineCommonState): heartbeat_seconds: PositiveInteger board_state: EngineAnvylBoardState + auth: UserPassAuthConfig + active_serial: Optional[ManagedSerial] = None serial_death: Death = None diff --git a/client/src/engine/board/engine_common.py b/client/src/engine/board/engine_common.py index a3ce912..725da73 100644 --- a/client/src/engine/board/engine_common.py +++ b/client/src/engine/board/engine_common.py @@ -1,4 +1,4 @@ -"""Anvyl FPGA client functionality.""" +"""Generic board engine client functionality.""" from dataclasses import dataclass from typing import List, TypeVar, Optional from result import Result @@ -6,6 +6,7 @@ from src.engine.engine import Engine from src.engine.board.engine_common_state import EngineCommonState from src.domain.hardware_control_event import COMMON_ENGINE_EVENT, log_event +from src.engine.engine_auth import EngineAuth from src.engine.engine_lifecycle import EngineLifecycle from src.engine.engine_ping import EnginePing from src.domain.hardware_control_message import COMMON_INCOMING_MESSAGE, COMMON_OUTGOING_MESSAGE, log_hardware_message, \ @@ -29,6 +30,7 @@ class EngineCommon(Engine[PI, PO, S, E, X]): engine_upload: EngineUpload engine_ping: EnginePing engine_serial_monitor: EngineSerialMonitor + engine_auth: EngineAuth async def start(self): await self.state.base.incoming_message_queue.put(InternalStartLifecycle()) @@ -50,7 +52,8 @@ def message_project( return self.multi_message_project([ self.engine_lifecycle.handle_message, self.engine_upload.handle_message, - self.engine_serial_monitor.handle_message + self.engine_serial_monitor.handle_message, + self.engine_auth.handle_message ], previous_state, message) def state_project(self, previous_state: EngineCommonState, event: COMMON_ENGINE_EVENT) -> S: @@ -62,7 +65,8 @@ async def effect_project(self, previous_state: EngineCommonState, event: COMMON_ self.engine_lifecycle.effect_project, self.engine_upload.effect_project, self.engine_ping.effect_project, - self.engine_serial_monitor.effect_project + self.engine_serial_monitor.effect_project, + self.engine_auth.effect_project ] return await Engine.multi_effect_project(projections, previous_state, event) diff --git a/client/src/engine/board/engine_common_state.py b/client/src/engine/board/engine_common_state.py index 50ac635..f339536 100644 --- a/client/src/engine/board/engine_common_state.py +++ b/client/src/engine/board/engine_common_state.py @@ -3,16 +3,18 @@ from typing import Optional from src.agent.agent_error import AgentExecutionError from src.domain.death import Death +from src.engine.engine_auth import EngineAuth from src.engine.engine_ping import EnginePingState from src.engine.board.engine_serial_monitor import EngineSerialMonitorState, SerialBoard from src.engine.engine_state import EngineState, ManagedQueue from src.domain.managed_uuid import ManagedUUID from src.domain.positive_integer import PositiveInteger from src.service.backend import BackendServiceInterface +from src.service.backend_config import UserPassAuthConfig from src.service.managed_serial import ManagedSerial -class EngineCommonState(EngineState, EnginePingState, EngineSerialMonitorState): +class EngineCommonState(EngineState, EnginePingState, EngineSerialMonitorState, EngineAuth): death: Death[AgentExecutionError] incoming_message_queue: ManagedQueue outgoing_message_queue: ManagedQueue @@ -26,3 +28,5 @@ class EngineCommonState(EngineState, EnginePingState, EngineSerialMonitorState): active_serial: Optional[ManagedSerial] serial_death: Death + auth: UserPassAuthConfig + diff --git a/client/src/engine/board/fake/engine_fake.py b/client/src/engine/board/fake/engine_fake.py index 4127960..2533587 100644 --- a/client/src/engine/board/fake/engine_fake.py +++ b/client/src/engine/board/fake/engine_fake.py @@ -14,6 +14,7 @@ from src.engine.engine_state import EngineBase from src.engine.board.engine_upload import EngineUpload from src.service.backend import BackendServiceInterface +from src.service.backend_config import UserPassAuthConfig from src.service.managed_serial import ManagedSerial from src.service.managed_serial_config import ManagedSerialConfig from src.util.sh import src_relative_path @@ -33,6 +34,8 @@ class EngineFakeState(EngineCommonState): heartbeat_seconds: PositiveInteger board_state: EngineFakeBoardState + auth: UserPassAuthConfig + active_serial: Optional[ManagedSerial] = None serial_death: Death = None diff --git a/client/src/engine/board/nrf52/engine_nrf52_state.py b/client/src/engine/board/nrf52/engine_nrf52_state.py index f265b73..412f2d9 100644 --- a/client/src/engine/board/nrf52/engine_nrf52_state.py +++ b/client/src/engine/board/nrf52/engine_nrf52_state.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from src.engine.engine_state import EngineBase from src.service.backend import BackendServiceInterface +from src.service.backend_config import UserPassAuthConfig from src.service.managed_serial import ManagedSerial @@ -25,5 +26,7 @@ class EngineNRF52State(EngineCommonState): heartbeat_seconds: PositiveInteger board_state: EngineNRF52BoardState + auth: UserPassAuthConfig + active_serial: Optional[ManagedSerial] = None serial_death: Death = None diff --git a/client/src/engine/engine_auth.py b/client/src/engine/engine_auth.py new file mode 100644 index 0000000..070ac12 --- /dev/null +++ b/client/src/engine/engine_auth.py @@ -0,0 +1,40 @@ +"""Engine auth functionality.""" +from dataclasses import dataclass +from typing import List, Any +from result import Result, Ok +from src.domain.hardware_shared_event import StartingAuth, AuthSucceeded, AuthFailed +from src.domain.hardware_shared_message import InternalStartLifecycle, AuthResult, AuthRequest, InternalEndLifecycle +from src.engine.engine_state import EngineState, EngineBase +from src.domain.dip_client_error import DIPClientError, GenericClientError +from src.service.backend_config import UserPassAuthConfig + + +class EngineAuthState: + base: EngineBase + auth: UserPassAuthConfig + + +@dataclass +class EngineAuth: + @staticmethod + def handle_message( + previous_state: EngineAuthState, + message: Any + ) -> Result[List[Any], DIPClientError]: + if isinstance(message, InternalStartLifecycle): + return Ok([StartingAuth(previous_state.auth)]) + elif isinstance(message, AuthResult): + if message.error is None: + return Ok([AuthSucceeded()]) + else: + return Ok([AuthFailed(message.error)]) + else: + return Ok([]) + + async def effect_project(self, previous_state: EngineState, event: Any): + if isinstance(event, StartingAuth): + await previous_state.base.outgoing_message_queue.put( + AuthRequest(event.auth.username, event.auth.password)) + elif isinstance(event, AuthFailed): + await previous_state.base.incoming_message_queue.put( + InternalEndLifecycle(GenericClientError(f"Auth failed: {event.reason}"))) diff --git a/client/src/engine/video/engine_video.py b/client/src/engine/video/engine_video.py index 03de37b..de3017d 100644 --- a/client/src/engine/video/engine_video.py +++ b/client/src/engine/video/engine_video.py @@ -6,6 +6,7 @@ from src.domain.hardware_video_message import COMMON_INCOMING_VIDEO_MESSAGE, COMMON_OUTGOING_VIDEO_MESSAGE, \ HardwareVideoMessage, log_video_message, InternalStartLifecycle, InternalEndLifecycle from src.engine.engine import Engine +from src.engine.engine_auth import EngineAuth from src.engine.engine_lifecycle import EngineLifecycle from src.engine.engine_ping import EnginePing from src.domain.hardware_video_event import COMMON_ENGINE_EVENT, log_event @@ -28,6 +29,7 @@ class EngineVideo(Engine[ engine_lifecycle: EngineLifecycle engine_ping: EnginePing engine_video_stream: EngineVideoStream + engine_auth: EngineAuth async def start(self): await self.state.base.incoming_message_queue.put(InternalStartLifecycle()) @@ -49,6 +51,7 @@ def message_project( return self.multi_message_project([ self.engine_lifecycle.handle_message, self.engine_video_stream.handle_message, + self.engine_auth.handle_message, ], previous_state, message) def state_project(self, previous_state: EngineVideoState, event: COMMON_ENGINE_EVENT) -> EngineVideoState: @@ -60,6 +63,7 @@ async def effect_project(self, previous_state: EngineVideoState, event: COMMON_E self.engine_lifecycle.effect_project, self.engine_video_stream.effect_project, self.engine_ping.effect_project, + self.engine_auth.effect_project, ] return await Engine.multi_effect_project(projections, previous_state, event) diff --git a/client/src/engine/video/engine_video_state.py b/client/src/engine/video/engine_video_state.py index 2c1dfa3..0fc7199 100644 --- a/client/src/engine/video/engine_video_state.py +++ b/client/src/engine/video/engine_video_state.py @@ -1,21 +1,24 @@ from dataclasses import dataclass from typing import Optional from src.domain.death import Death +from src.engine.engine_auth import EngineAuthState from src.engine.engine_ping import EnginePingState from src.engine.engine_state import EngineBase, EngineState from src.domain.managed_uuid import ManagedUUID from src.domain.positive_integer import PositiveInteger from src.engine.video.engine_video_stream import EngineVideoStreamState +from src.service.backend_config import UserPassAuthConfig from src.service.managed_video_stream import VideoStreamConfig, ManagedVideoStream @dataclass -class EngineVideoState(EngineState, EnginePingState, EngineVideoStreamState): +class EngineVideoState(EngineState, EnginePingState, EngineVideoStreamState, EngineAuthState): base: EngineBase hardware_id: ManagedUUID heartbeat_seconds: PositiveInteger initial_stream_config: VideoStreamConfig stream: Optional[ManagedVideoStream] stream_death: Death + auth: UserPassAuthConfig diff --git a/client/src/monitor/monitor_serial.py b/client/src/monitor/monitor_serial.py index f99982c..e81066d 100644 --- a/client/src/monitor/monitor_serial.py +++ b/client/src/monitor/monitor_serial.py @@ -1,12 +1,19 @@ """Module for functionality related to serial socket monitor""" from dataclasses import dataclass from typing import Any, Optional -from src.domain.dip_client_error import DIPClientError +from result import Err +from src.domain.death import Death +from src.domain.dip_client_error import DIPClientError, GenericClientError from src.domain.dip_runnable import DIPRunnable +from src.domain.hardware_shared_message import AuthRequest, AuthResult from src.domain.monitor_message import SerialMonitorMessageToClient, SerialMonitorMessageToAgent, \ MONITOR_LISTENER_INCOMING_MESSAGE, MONITOR_LISTENER_OUTGOING_MESSAGE, MonitorUnavailable +from src.service.backend_config import UserPassAuthConfig from src.service.ws import SocketInterface from src.protocol.codec import CodecParseException +from src.util import log + +LOGGER = log.timed_named_logger("serial_monitor") class MonitorSerialHelperInterface: @@ -32,6 +39,22 @@ def createSerialMonitorMessageToAgent(payload: bytes) -> Any: """Create createSerialMonitorMessageToAgent from bytes""" pass + @staticmethod + async def sendAuth( + socket: SocketInterface[MONITOR_LISTENER_INCOMING_MESSAGE, MONITOR_LISTENER_OUTGOING_MESSAGE], + auth: UserPassAuthConfig + ): + """Send auth request into socket""" + pass + + @staticmethod + async def expectAuthResult( + death: Death, + socket: SocketInterface[MONITOR_LISTENER_INCOMING_MESSAGE, MONITOR_LISTENER_OUTGOING_MESSAGE] + ) -> Optional[DIPClientError]: + """Wait for auth result response from socket""" + pass + class MonitorSerialHelper(MonitorSerialHelperInterface): """Helper for managing monitor messages""" @@ -56,12 +79,51 @@ def createSerialMonitorMessageToAgent(payload: bytes) -> Any: """Create createSerialMonitorMessageToAgent from bytes""" return SerialMonitorMessageToAgent(payload) + @staticmethod + async def sendAuth( + socket: SocketInterface[MONITOR_LISTENER_INCOMING_MESSAGE, MONITOR_LISTENER_OUTGOING_MESSAGE], + auth: UserPassAuthConfig + ): + await socket.tx(AuthRequest(auth.username, auth.password)) + + @staticmethod + async def expectAuthResult( + death: Death, + socket: SocketInterface[MONITOR_LISTENER_INCOMING_MESSAGE, MONITOR_LISTENER_OUTGOING_MESSAGE] + ) -> Optional[DIPClientError]: + # Wait for new messages + try: + death_or_message = await death.or_awaitable(socket.rx()) + except Exception as e: + raise e + + # Handle potential death (and stop expecting auth) + if isinstance(death_or_message, Err): + LOGGER.debug(f"Serial monitor stopping auth expecation, reason: {death_or_message.value}") + return GenericClientError("Serial monitor auth was killed by death") + + # Handle connection close (and stop receiving) + incoming_result = death_or_message.value + if isinstance(incoming_result, Err): + return GenericClientError( + f"Serial monitor auth failed to receive response, reason: {incoming_result.value}") + + # Handle valid message (and continue receiving) + message = incoming_result.value + if isinstance(message, AuthResult) and message.error is None: + return None + elif isinstance(message, AuthResult) and message.error is not None: + return GenericClientError(f"Serial monitor auth failed: {message.error}") + else: + return GenericClientError(f"Serial monitor auth received non-auth response: {message}") + @dataclass class MonitorSerial(DIPRunnable): """Interface for serial socket monitors""" helper: MonitorSerialHelperInterface socket: SocketInterface[MONITOR_LISTENER_INCOMING_MESSAGE, MONITOR_LISTENER_OUTGOING_MESSAGE] + auth: UserPassAuthConfig async def run(self) -> Optional[DIPClientError]: """Receive serial monitor websocket messages & implement user interfacing""" diff --git a/client/src/monitor/monitor_serial_button_led_bytes.py b/client/src/monitor/monitor_serial_button_led_bytes.py index ee80741..46ee2c2 100644 --- a/client/src/monitor/monitor_serial_button_led_bytes.py +++ b/client/src/monitor/monitor_serial_button_led_bytes.py @@ -84,13 +84,24 @@ def render_message_data_or_finish( async def run(self) -> Optional[DIPClientError]: """Receive serial monitor websocket messages & implement user interfacing""" + # Create UI app state + state = AppState() + + # Handle signal interrupts + for signame in ('SIGINT', 'SIGTERM'): + asyncio_loop = asyncio.get_event_loop() + asyncio_loop.add_signal_handler(getattr(signal, signame), partial(state.death.grace)) + # Start socket connect_error = await self.socket.connect() if connect_error is not None: return GenericClientError(f"Failed connecting to control server, reason: {connect_error}") - # Create UI app state - state = AppState() + # Send auth request and wait for response + await self.helper.sendAuth(self.socket, self.auth) + auth_error = await self.helper.expectAuthResult(state.death, self.socket) + if auth_error is not None: + return auth_error # Register button click handler def send_on_button_click(button_index: int): diff --git a/client/src/monitor/monitor_serial_hex_bytes.py b/client/src/monitor/monitor_serial_hex_bytes.py index c7352cc..2bb9070 100644 --- a/client/src/monitor/monitor_serial_hex_bytes.py +++ b/client/src/monitor/monitor_serial_hex_bytes.py @@ -2,7 +2,7 @@ import sys import asyncio -from asyncio import Task +from asyncio import Task, StreamReader from typing import Any, Callable, Optional import termios import tty @@ -20,6 +20,10 @@ from src.domain.death import Death from functools import partial +from src.util import log + +LOGGER = log.timed_named_logger("hexbytes_monitor") + class MonitorSerialHexbytes(MonitorSerial): """Serial socket monitor, which sends keyboard keys as bytes & prints incoming data as hex bytes""" @@ -41,14 +45,19 @@ def unsilence_stdin(tattr: list): termios.tcsetattr(stdin, termios.TCSANOW, tattr) @staticmethod - async def keep_transmitting_to_agent( - socketlike: SocketInterface[MONITOR_LISTENER_INCOMING_MESSAGE, MONITOR_LISTENER_OUTGOING_MESSAGE] - ): - """Send keyboard data from stdin straight to serial monitor socket""" + async def create_stdin_reader() -> StreamReader: asyncio_loop = asyncio.get_event_loop() stdin_reader = asyncio.StreamReader() stdin_protocol = asyncio.StreamReaderProtocol(stdin_reader) await asyncio_loop.connect_read_pipe(lambda: stdin_protocol, sys.stdin) + return stdin_reader + + @staticmethod + async def keep_transmitting_to_agent( + stdin_reader: StreamReader, + socketlike: SocketInterface[MONITOR_LISTENER_INCOMING_MESSAGE, MONITOR_LISTENER_OUTGOING_MESSAGE] + ): + """Send keyboard data from stdin straight to serial monitor socket""" while True: read_bytes = await stdin_reader.read(1) message = SerialMonitorMessageToAgent(read_bytes) @@ -112,29 +121,40 @@ def render_message_data_or_finish( async def run(self) -> Optional[DIPClientError]: """Receive serial monitor websocket messages & implement user interfacing""" + # Define end-of-monitor on sigkill/sigterm + asyncio_loop = asyncio.get_event_loop() + death = Death() + for signame in ('SIGINT', 'SIGTERM'): + asyncio_loop.add_signal_handler(getattr(signal, signame), partial(death.grace)) + # Start socket connect_error = await self.socket.connect() if connect_error is not None: return GenericClientError(f"Failed connecting to control server, reason: {connect_error}") + # Send auth request and wait for response + await self.helper.sendAuth(self.socket, self.auth) + auth_error = await self.helper.expectAuthResult(death, self.socket) + if auth_error is not None: + return auth_error + # Silence stdin tattr = MonitorSerialHexbytes.silence_stdin() # Redirect stdin to serial monitor socket - asyncio_loop = asyncio.get_event_loop() + stdin_reader = await MonitorSerialHexbytes.create_stdin_reader() + silencer_code = await stdin_reader.read(7) # The first 7 bytes are the stdin silencer codes + LOGGER.debug(f"Suppressed 7 stdin silencer bytes: {silencer_code}") stdin_capture_task = asyncio_loop.create_task( - self.keep_transmitting_to_agent(self.socket)) + self.keep_transmitting_to_agent(stdin_reader, self.socket)) - # Define end-of-monitor handler - death = Death() + # Define end-of-hexbytes handler handle_finish = partial( MonitorSerialHexbytes.handle_finish, self.socket, death, stdin_capture_task, tattr) - - # Handle signal interrupts for signame in ('SIGINT', 'SIGTERM'): asyncio_loop.add_signal_handler(getattr(signal, signame), handle_finish) diff --git a/client/src/monitor/monitor_type.py b/client/src/monitor/monitor_type.py index 7040038..06b601e 100755 --- a/client/src/monitor/monitor_type.py +++ b/client/src/monitor/monitor_type.py @@ -9,6 +9,7 @@ from src.monitor.monitor_serial import MonitorSerial, MonitorSerialHelper from src.monitor.monitor_serial_button_led_bytes import MonitorSerialButtonLedBytes from src.monitor.monitor_serial_hex_bytes import MonitorSerialHexbytes +from src.service.backend_config import UserPassAuthConfig from src.service.ws import SocketInterface from src.util import pymodules @@ -52,12 +53,13 @@ def build(value: str) -> Result['MonitorType', MonitorTypeBuildError]: def resolve( self, - socket: SocketInterface + socket: SocketInterface, + auth: UserPassAuthConfig ) -> Result[MonitorSerial, MonitorResolutionError]: # Monitor implementation resolution monitor: Optional[MonitorSerial] = None if self is MonitorType.hexbytes: - monitor = MonitorSerialHexbytes(MonitorSerialHelper(), socket) + monitor = MonitorSerialHexbytes(MonitorSerialHelper(), socket, auth) elif self is MonitorType.buttonleds: - monitor = MonitorSerialButtonLedBytes(MonitorSerialHelper(), socket) + monitor = MonitorSerialButtonLedBytes(MonitorSerialHelper(), socket, auth) return Ok(monitor) diff --git a/client/src/protocol/s11n_hybrid.py b/client/src/protocol/s11n_hybrid.py index 059e513..76f3bb3 100644 --- a/client/src/protocol/s11n_hybrid.py +++ b/client/src/protocol/s11n_hybrid.py @@ -55,6 +55,7 @@ def hybrid_decoder( # protocol.CommonIncomingMessage COMMON_INCOMING_MESSAGE_ENCODER = hybrid_encoder({ + hardware_shared_message.AuthResult: s11n_json.COMMON_INCOMING_MESSAGE_ENCODER_JSON, hardware_control_message.UploadMessage: s11n_json.COMMON_INCOMING_MESSAGE_ENCODER_JSON, hardware_control_message.SerialMonitorRequest: s11n_json.COMMON_INCOMING_MESSAGE_ENCODER_JSON, hardware_control_message.SerialMonitorRequestStop: s11n_json.COMMON_INCOMING_MESSAGE_ENCODER_JSON, @@ -67,6 +68,7 @@ def hybrid_decoder( # protocol.CommonOutgoingMessage COMMON_OUTGOING_MESSAGE_ENCODER = hybrid_encoder({ + hardware_shared_message.AuthRequest: s11n_json.COMMON_OUTGOING_MESSAGE_ENCODER_JSON, hardware_control_message.UploadResultMessage: s11n_json.COMMON_OUTGOING_MESSAGE_ENCODER_JSON, hardware_shared_message.PingMessage: s11n_json.COMMON_OUTGOING_MESSAGE_ENCODER_JSON, hardware_control_message.SerialMonitorResult: s11n_json.COMMON_OUTGOING_MESSAGE_ENCODER_JSON, @@ -82,6 +84,7 @@ def hybrid_decoder( # protocol.CommonOutgoingVideoMessage COMMON_OUTGOING_VIDEO_MESSAGE_ENCODER = hybrid_encoder({ + hardware_shared_message.AuthRequest: s11n_json.COMMON_OUTGOING_VIDEO_MESSAGE_ENCODER_JSON, hardware_shared_message.PingMessage: s11n_json.COMMON_OUTGOING_VIDEO_MESSAGE_ENCODER_JSON, hardware_video_message.CameraUnavailable: s11n_json.COMMON_OUTGOING_VIDEO_MESSAGE_ENCODER_JSON, hardware_video_message.CameraChunk: s11n_binary.CAMERA_CHUNK_ENCODER_BINARY @@ -95,6 +98,7 @@ def hybrid_decoder( # protocol.MonitorListenerIncomingMessage MONITOR_LISTENER_INCOMING_MESSAGE_ENCODER = hybrid_encoder({ + hardware_shared_message.AuthResult: s11n_json.MONITOR_LISTENER_INCOMING_MESSAGE_ENCODER_JSON, monitor_message.MonitorUnavailable: s11n_json.MONITOR_LISTENER_INCOMING_MESSAGE_ENCODER_JSON, monitor_message.SerialMonitorMessageToClient: s11n_binary.SERIAL_MONITOR_MESSAGE_TO_CLIENT_ENCODER_BINARY }) @@ -107,11 +111,12 @@ def hybrid_decoder( # protocol.MonitorListenerOutgoingMessage MONITOR_LISTENER_OUTGOING_MESSAGE_ENCODER = hybrid_encoder({ + hardware_shared_message.AuthRequest: s11n_json.MONITOR_LISTENER_OUTGOING_MESSAGE_ENCODER_JSON, monitor_message.SerialMonitorMessageToAgent: s11n_binary.SERIAL_MONITOR_MESSAGE_TO_AGENT_ENCODER_BINARY }) MONITOR_LISTENER_OUTGOING_MESSAGE_DECODER = hybrid_decoder( s11n_binary.SERIAL_MONITOR_MESSAGE_TO_AGENT_DECODER_BINARY, - DecoderJSON(lambda x: CodecParseException("Invalid message type")) + s11n_json.MONITOR_LISTENER_OUTGOING_MESSAGE_DECODER_JSON, ) MONITOR_LISTENER_OUTGOING_MESSAGE_CODEC = CodecHybrid( MONITOR_LISTENER_OUTGOING_MESSAGE_DECODER, diff --git a/client/src/protocol/s11n_json.py b/client/src/protocol/s11n_json.py index 4468d6a..ac7ffea 100644 --- a/client/src/protocol/s11n_json.py +++ b/client/src/protocol/s11n_json.py @@ -313,7 +313,72 @@ def failure_message_decoder_json( return DecoderJSON(partial(failure_message_decode_json, decoder)) -# protocol.SerialMonitorRequest +# hardware_shared_message.AuthRequest +def auth_request_encode_json(value: hardware_shared_message.AuthRequest) -> JSON: + """Serialize AuthRequest to JSON""" + return { + "username": value.username, + "password": value.password + } + + +def auth_request_decode_json( + value: JSON +) -> Result[hardware_shared_message.AuthRequest, CodecParseException]: + """Un-serialize AuthRequest from JSON""" + if not isinstance(value, dict): + return Err(CodecParseException("AuthRequest must be an object")) + + username = value.get("username") + if not isinstance(username, str): + return Err(CodecParseException("AuthRequest .username must be string")) + + password = value.get("password") + if not isinstance(password, str): + return Err(CodecParseException("AuthRequest .password must be string")) + + return Ok(hardware_shared_message.AuthRequest(username, password)) + + +AUTH_REQUEST_ENCODER_JSON: EncoderJSON[hardware_shared_message.AuthRequest] = \ + EncoderJSON(auth_request_encode_json) +AUTH_REQUEST_DECODER_JSON: DecoderJSON[hardware_shared_message.AuthRequest] = \ + DecoderJSON(auth_request_decode_json) +AUTH_REQUEST_CODEC_JSON: CodecJSON[hardware_shared_message.AuthRequest] = \ + CodecJSON(AUTH_REQUEST_DECODER_JSON, AUTH_REQUEST_ENCODER_JSON) + + +# hardware_shared_message.AuthResult +def auth_result_encode_json(value: hardware_shared_message.AuthResult) -> JSON: + """Serialize AuthResult to JSON""" + return { + "error": value.error + } + + +def auth_result_decode_json( + value: JSON +) -> Result[hardware_shared_message.AuthResult, CodecParseException]: + """Un-serialize AuthResult from JSON""" + if not isinstance(value, dict): + return Err(CodecParseException("AuthRequest must be an object")) + + error = value.get("error") + if not isinstance(error, str) and error is not None: + return Err(CodecParseException("AuthRequest .error must be string or null")) + + return Ok(hardware_shared_message.AuthResult(error)) + + +AUTH_RESULT_ENCODER_JSON: EncoderJSON[hardware_shared_message.AuthResult] = \ + EncoderJSON(auth_result_encode_json) +AUTH_RESULT_DECODER_JSON: DecoderJSON[hardware_shared_message.AuthResult] = \ + DecoderJSON(auth_result_decode_json) +AUTH_RESULT_CODEC_JSON: CodecJSON[hardware_shared_message.AuthResult] = \ + CodecJSON(AUTH_RESULT_DECODER_JSON, AUTH_RESULT_ENCODER_JSON) + + +# hardware_control_message.SerialMonitorRequest def serial_monitor_request_encode_json(value: hardware_control_message.SerialMonitorRequest) -> JSON: """Serialize SerialMonitorRequest to JSON""" return { @@ -441,12 +506,14 @@ def monitor_unavailable_decode_json( # protocol.CommonIncomingMessage COMMON_INCOMING_MESSAGE_ENCODER_JSON = named_message_union_encoder_json({ + hardware_shared_message.AuthResult: ("authResult", AUTH_RESULT_ENCODER_JSON), hardware_control_message.UploadMessage: ("uploadSoftwareRequest", UPLOAD_MESSAGE_ENCODER_JSON), hardware_control_message.SerialMonitorRequest: ("serialMonitorRequest", SERIAL_MONITOR_REQUEST_ENCODER_JSON), hardware_control_message.SerialMonitorRequestStop: ("serialMonitorRequestStop", SERIAL_MONITOR_REQUEST_STOP_ENCODER_JSON), }) COMMON_INCOMING_MESSAGE_DECODER_JSON = named_message_union_decoder_json({ + hardware_shared_message.AuthResult: ("authResult", AUTH_RESULT_DECODER_JSON), hardware_control_message.UploadMessage: ("uploadSoftwareRequest", UPLOAD_MESSAGE_DECODER_JSON), hardware_control_message.SerialMonitorRequest: ("serialMonitorRequest", SERIAL_MONITOR_REQUEST_DECODER_JSON), hardware_control_message.SerialMonitorRequestStop: @@ -458,12 +525,14 @@ def monitor_unavailable_decode_json( # protocol.CommonOutgoingMessage COMMON_OUTGOING_MESSAGE_ENCODER_JSON = named_message_union_encoder_json({ + hardware_shared_message.AuthRequest: ("authRequest", AUTH_REQUEST_ENCODER_JSON), hardware_control_message.UploadResultMessage: ("uploadSoftwareResult", UPLOAD_RESULT_MESSAGE_ENCODER_JSON), hardware_shared_message.PingMessage: ("ping", PING_MESSAGE_ENCODER_JSON), hardware_control_message.SerialMonitorResult: ("serialMonitorResult", SERIAL_MONITOR_RESULT_ENCODER_JSON), monitor_message.MonitorUnavailable: ("monitorUnavailable", MONITOR_UNAVAILABLE_ENCODER_JSON) }) COMMON_OUTGOING_MESSAGE_DECODER_JSON = named_message_union_decoder_json({ + hardware_shared_message.AuthRequest: ("authRequest", AUTH_REQUEST_DECODER_JSON), hardware_control_message.UploadResultMessage: ("uploadSoftwareResult", UPLOAD_RESULT_MESSAGE_DECODER_JSON), hardware_shared_message.PingMessage: ("ping", PING_MESSAGE_DECODER_JSON), hardware_control_message.SerialMonitorResult: ("serialMonitorResult", SERIAL_MONITOR_RESULT_DECODER_JSON), @@ -509,27 +578,42 @@ def camera_subscription_decode_json( # protocol.CommonIncomingVideoMessage COMMON_INCOMING_VIDEO_MESSAGE_DECODER_JSON = named_message_union_decoder_json({ + hardware_shared_message.AuthResult: ("authResult", AUTH_RESULT_DECODER_JSON), hardware_video_message.StopBroadcasting: ("stopBroadcasting", STOP_BROADCASTING_MESSAGE_DECODER_JSON), hardware_video_message.CameraSubscription: ("cameraSubscription", CAMERA_SUBSCRIPTION_MESSAGE_DECODER_JSON), }) # protocol.CommonOutgoingVideoMessage COMMON_OUTGOING_VIDEO_MESSAGE_ENCODER_JSON = named_message_union_encoder_json({ + hardware_shared_message.AuthRequest: ("authRequest", AUTH_REQUEST_ENCODER_JSON), hardware_shared_message.PingMessage: ("ping", PING_MESSAGE_ENCODER_JSON), hardware_video_message.CameraUnavailable: ("cameraUnavailable", CAMERA_UNAVAILABLE_ENCODER_JSON) }) # protocol.MonitorListenerIncomingMessage MONITOR_LISTENER_INCOMING_MESSAGE_ENCODER_JSON = named_message_union_encoder_json({ + hardware_shared_message.AuthResult: ("authResult", AUTH_RESULT_ENCODER_JSON), monitor_message.MonitorUnavailable: ("monitorUnavailable", MONITOR_UNAVAILABLE_ENCODER_JSON), }) MONITOR_LISTENER_INCOMING_MESSAGE_DECODER_JSON = named_message_union_decoder_json({ + hardware_shared_message.AuthResult: ("authResult", AUTH_RESULT_DECODER_JSON), monitor_message.MonitorUnavailable: ("monitorUnavailable", MONITOR_UNAVAILABLE_DECODER_JSON), }) MONITOR_LISTENER_INCOMING_MESSAGE_CODEC_JSON = CodecJSON( MONITOR_LISTENER_INCOMING_MESSAGE_DECODER_JSON, MONITOR_LISTENER_INCOMING_MESSAGE_ENCODER_JSON) +# protocol.MonitorListenerOutgoingMessage +MONITOR_LISTENER_OUTGOING_MESSAGE_ENCODER_JSON = named_message_union_encoder_json({ + hardware_shared_message.AuthRequest: ("authRequest", AUTH_REQUEST_ENCODER_JSON) +}) +MONITOR_LISTENER_OUTGOING_MESSAGE_DECODER_JSON = named_message_union_decoder_json({ + hardware_shared_message.AuthRequest: ("authRequest", AUTH_REQUEST_DECODER_JSON) +}) +MONITOR_LISTENER_OUTGOING_MESSAGE_CODEC_JSON = CodecJSON( + MONITOR_LISTENER_OUTGOING_MESSAGE_DECODER_JSON, + MONITOR_LISTENER_OUTGOING_MESSAGE_ENCODER_JSON) + # backend_domain.User def user_encode_json(value: backend_entity.User) -> JSON: diff --git a/client/src/service/backend.py b/client/src/service/backend.py index a9d6dd2..b25ffab 100644 --- a/client/src/service/backend.py +++ b/client/src/service/backend.py @@ -1,9 +1,7 @@ """Module for backend management service definitions""" import os -import urllib.request from typing import List, TypeVar, Dict, Optional from dataclasses import dataclass -import base64 from uuid import UUID from result import Result, Ok, Err from requests import Response diff --git a/client/src/service/backend_config.py b/client/src/service/backend_config.py index 7c07024..c1631e8 100644 --- a/client/src/service/backend_config.py +++ b/client/src/service/backend_config.py @@ -4,16 +4,24 @@ from dataclasses import dataclass from src.service.managed_url import ManagedURL + @dataclass class AuthConfig: def auth_headers(self) -> Dict: pass + @dataclass class UserPassAuthConfig(AuthConfig): username: str password: str + def __str__(self): + return f"UserPassAuthConfig(...)" + + def __repr__(self): + return self.__str__() + def auth_headers(self) -> Dict: """Create authentication header""" token = base64.b64encode(f"{self.username}:{self.password}".encode()).decode("utf-8") @@ -21,6 +29,7 @@ def auth_headers(self) -> Dict: "Authorization": f"Basic {token}" } + @dataclass(frozen=True) class BackendConfig: """Backend management service configuration""" diff --git a/client/src/service/cli.py b/client/src/service/cli.py index 0436a5b..34df5f7 100755 --- a/client/src/service/cli.py +++ b/client/src/service/cli.py @@ -27,6 +27,7 @@ from src.engine.board.nrf52.engine_nrf52 import EngineNRF52 from src.engine.board.nrf52.engine_nrf52_state import EngineNRF52State, EngineNRF52BoardState from src.engine.board.nrf52.engine_nrf52_upload import EngineNRF52Upload +from src.engine.engine_auth import EngineAuth from src.engine.engine_lifecycle import EngineLifecycle from src.engine.engine_ping import EnginePing from src.engine.board.engine_serial_monitor import EngineSerialMonitor @@ -241,7 +242,9 @@ def hardware_serial_monitor( config_path_str: Optional[str], control_server_str: Optional[str], hardware_id_str: str, - monitor_type_str: str + monitor_type_str: str, + username_str: Optional[str], + password_str: Optional[str], ): pass @@ -275,7 +278,9 @@ async def agent_hardware_camera( video_buffer_size: Optional[int], audio_sample_rate: Optional[int], audio_buffer_size: Optional[int], - port: Optional[int] + port: Optional[int], + username_str: Optional[str], + password_str: Optional[str], ) -> Result[Agent, DIPClientError]: pass @@ -552,12 +557,15 @@ async def agent_nrf52( # Engine base = await EngineBase.build() board_state = EngineNRF52BoardState(device_path) - engine_state = EngineNRF52State(base, hardware_id, backend, heartbeat_seconds, board_state) + engine_state = \ + EngineNRF52State(base, hardware_id, backend, heartbeat_seconds, board_state, backend.config.auth) engine_lifecycle = EngineLifecycle() engine_upload = EngineNRF52Upload(backend) engine_ping = EnginePing() engine_serial_monitor = EngineSerialMonitor() - engine = EngineNRF52(engine_state, engine_lifecycle, engine_upload, engine_ping, engine_serial_monitor) + engine_auth = EngineAuth() + engine = \ + EngineNRF52(engine_state, engine_lifecycle, engine_upload, engine_ping, engine_serial_monitor, engine_auth) # Agent with engine construction encoder = COMMON_OUTGOING_MESSAGE_ENCODER @@ -590,12 +598,15 @@ async def agent_anvyl( # Engine base = await EngineBase.build() board_state = EngineAnvylBoardState(device_name_str, device_path, scan_chain_index) - engine_state = EngineAnvylState(base, hardware_id, backend, heartbeat_seconds, board_state) + engine_state = \ + EngineAnvylState(base, hardware_id, backend, heartbeat_seconds, board_state, backend.config.auth) engine_lifecycle = EngineLifecycle() engine_upload = EngineAnvylUpload(backend) engine_ping = EnginePing() engine_serial_monitor = EngineSerialMonitor() - engine = EngineAnvyl(engine_state, engine_lifecycle, engine_upload, engine_ping, engine_serial_monitor) + engine_auth = EngineAuth() + engine = \ + EngineAnvyl(engine_state, engine_lifecycle, engine_upload, engine_ping, engine_serial_monitor, engine_auth) # Agent with engine construction encoder = COMMON_OUTGOING_MESSAGE_ENCODER @@ -626,12 +637,14 @@ async def agent_fake( # Engine base = await EngineBase.build() board_state = EngineFakeBoardState(device_path) - engine_state = EngineFakeState(base, hardware_id, backend, heartbeat_seconds, board_state) + engine_state = EngineFakeState(base, hardware_id, backend, heartbeat_seconds, board_state, backend.config.auth) engine_lifecycle = EngineLifecycle() engine_upload = EngineFakeUpload(backend) engine_ping = EnginePing() engine_serial_monitor = EngineFakeSerialMonitor() - engine = EngineFake(engine_state, engine_lifecycle, engine_upload, engine_ping, engine_serial_monitor) + engine_auth = EngineAuth() + engine = \ + EngineFake(engine_state, engine_lifecycle, engine_upload, engine_ping, engine_serial_monitor, engine_auth) # Agent with engine construction encoder = COMMON_OUTGOING_MESSAGE_ENCODER @@ -778,18 +791,21 @@ def hardware_serial_monitor( config_path_str: Optional[str], control_server_str: Optional[str], hardware_id_str: str, - monitor_type_str: str + monitor_type_str: str, + username_str: Optional[str], + password_str: Optional[str], ) -> Result[MonitorSerial, DIPClientError]: # Build backend - backend_result = CLI.parsed_backend(config_path_str, control_server_str, None, None, None) + backend_result = CLI.parsed_backend(config_path_str, control_server_str, None, username_str, password_str) if isinstance(backend_result, Err): return Err(backend_result.value) + backend = backend_result.value # Hardware id hardware_id_result = ManagedUUID.build(hardware_id_str) if isinstance(hardware_id_result, Err): return Err(hardware_id_result.value.of_type("hardware")) # Build URL - url_result = backend_result.value.hardware_serial_monitor_url(hardware_id_result.value) + url_result = backend.hardware_serial_monitor_url(hardware_id_result.value) if isinstance(url_result, Err): return Err(url_result.value) # Monitor type @@ -801,7 +817,7 @@ def hardware_serial_monitor( decoder = s11n_hybrid.MONITOR_LISTENER_INCOMING_MESSAGE_DECODER encoder = s11n_hybrid.MONITOR_LISTENER_OUTGOING_MESSAGE_ENCODER websocket = WebSocket(url_result.value, decoder, encoder) - return monitor_serial.resolve(websocket) + return monitor_serial.resolve(websocket, backend.config.auth) @staticmethod async def quick_run( @@ -890,11 +906,13 @@ async def agent_hardware_camera( video_buffer_size: Optional[int], audio_sample_rate: Optional[int], audio_buffer_size: Optional[int], - port: Optional[int] + port: Optional[int], + username_str: Optional[str], + password_str: Optional[str], ) -> Result[Agent, DIPClientError]: # Common agent input common_agent_input_result: Result = CLI.parsed_agent_input( - config_path_str, hardware_id_str, control_server_str, None, None, None, + config_path_str, hardware_id_str, control_server_str, None, username_str, password_str, heartbeat_seconds, None, True) if isinstance(common_agent_input_result, Err): return common_agent_input_result (hardware_id, heartbeat_seconds, backend, _, _) = \ @@ -922,11 +940,13 @@ async def agent_hardware_camera( # Engine base = await EngineBase.build() - engine_state = EngineVideoState(base, hardware_id, heartbeat_seconds, video_config_result.value, None, Death()) + engine_state = EngineVideoState( + base, hardware_id, heartbeat_seconds, video_config_result.value, None, Death(), backend.config.auth) engine_lifecycle = EngineLifecycle() engine_ping = EnginePing() engine_video_stream = EngineVideoStream() - engine = EngineVideo(engine_state, engine_lifecycle, engine_ping, engine_video_stream) + engine_auth = EngineAuth() + engine = EngineVideo(engine_state, engine_lifecycle, engine_ping, engine_video_stream, engine_auth) # Agent with engine construction encoder = COMMON_OUTGOING_VIDEO_MESSAGE_ENCODER diff --git a/client/src/service/click.py b/client/src/service/click.py index 79acabd..9314387 100755 --- a/client/src/service/click.py +++ b/client/src/service/click.py @@ -577,11 +577,15 @@ def hardware_software_upload( @CONTROL_SERVER_OPTION @HARDWARE_ID_OPTION @MONITOR_TYPE_OPTION +@USERNAME_OPTION +@PASSWORD_OPTION def hardware_serial_monitor( config_path_str: Optional[str], control_server_str: Optional[str], hardware_id_str: str, - monitor_type_str: str + monitor_type_str: str, + username_str: Optional[str], + password_str: Optional[str], ): """Monitor hardware's serial port""" async def exec(): @@ -589,7 +593,9 @@ async def exec(): config_path_str, control_server_str, hardware_id_str, - monitor_type_str), "Finished monitoring") + monitor_type_str, + username_str, + password_str), "Finished monitoring") asyncio.run(exec()) @@ -646,6 +652,8 @@ async def exec(): @STREAM_SAMPLE_RATE_OPTION @STREAM_AUDIO_BUFFER_OPTION @STREAM_PORT_OPTION +@USERNAME_OPTION +@PASSWORD_OPTION def agent_hardware_video( config_path_str: Optional[str], hardware_id_str: str, @@ -661,7 +669,9 @@ def agent_hardware_video( video_buffer_size: Optional[int], audio_sample_rate: Optional[int], audio_buffer_size: Optional[int], - port: Optional[int] + port: Optional[int], + username_str: Optional[str], + password_str: Optional[str], ): """Video stream broadcast (Linux specific)""" async def exec(): @@ -681,10 +691,13 @@ async def exec(): video_buffer_size, audio_sample_rate, audio_buffer_size, - port + port, + username_str, + password_str, ), "Hardware camera agent finished work") asyncio.run(exec()) + @CLI_COMMAND @CONFIG_PATH_OPTION @HARDWARE_ID_OPTION