Skip to content

Commit

Permalink
Add session, refactor auth, patch tests
Browse files Browse the repository at this point in the history
  • Loading branch information
Krisjanis Veinbahs committed Feb 24, 2022
1 parent 5a5d151 commit 4cfbae6
Show file tree
Hide file tree
Showing 23 changed files with 1,181 additions and 321 deletions.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,27 @@

DIP Testbed Platform is an academic work which allows users to remotely program and experience physical, embedded devices through various virtual interfaces (uni-directional webcam stream, bi-directional serial connection stream).

## Installation
### Quick install
```bash
curl https://github.com/kshaa/dip-testbed-dist/releases/latest/download/client_install.sh | bash
```

_Note: This assumes usage of bash, AMD64 architecture, testbed.veinbahs.lv as default server_

### Manual install
- Download `https://github.com/kshaa/dip-testbed-dist/releases/latest/download/dip_client_${TARGET_ARCH}`
- Store in `${PATH}`
- Set executable bit
- Set static URL using `dip_client session-static-server -s http://testbed.veinbahs.lv`
- Set control URL using `dip_client session-control-server -s ws://testbed.veinbahs.lv`

## Usage
```bash
dip_client session-auth -u <username> -p <password>
```

## Documentation
- See 🌼 🌻 [docs](./docs/README.md) 🌻 🌼 for user-centric documentation
- See [prototypes](./prototypes/README.md) for examples of the testbed platform usage

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ case class HardwareListenerHeartbeatConfig(
)

object HardwareListenerHeartbeatConfig {
def default(): HardwareListenerHeartbeatConfig = HardwareListenerHeartbeatConfig(5.seconds, 4.seconds)
def default(): HardwareListenerHeartbeatConfig = HardwareListenerHeartbeatConfig(10.seconds, 8.seconds)
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ object Codecs {

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]

Expand Down
1 change: 1 addition & 0 deletions backend/web/app/diptestbed/web/DIPTestbedRouter.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class DIPTestbedRouter(
def routes: Routes = {
case GET(p"/") => homeController.index
case GET(p"/status") => homeController.status
case GET(p"/auth-check") => homeController.authCheck

// Users
case POST(p"/user") => userController.createUser
Expand Down
11 changes: 4 additions & 7 deletions backend/web/app/diptestbed/web/controllers/AuthController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ trait AuthController[F[_]] { self: ResultsController[F] =>
val effectMonad: Monad[F]
val userService: UserService[F]

val authorizationErrorResult = Failure("Failed to authenticate request").withHttpStatus(UNAUTHORIZED)
val authorizationErrorResult: Result = Failure("Failed to authenticate request")
.withHttpStatus(UNAUTHORIZED)
.withHeaders("WWW-Authenticate" -> "Basic")

def withRequestAuthn[R, H](request: Request[R])(handler: (Request[R], DatabaseResult[Option[User]]) => F[H]): F[H] = {
implicit val implicitEffectMonad: Monad[F] = effectMonad
Expand Down Expand Up @@ -53,12 +55,7 @@ object AuthController {
type Password = String

def extractRequestBasicAuth[R](request: Request[R]): Option[(Username, Password)] = {
// This is incorrect and has to be refactored
// "Authorization" is the actual header name
// however Scala Play has automagical processing for it
// to force usage of my functionality, I rename it
// (wow, such high quality code) https://i.imgur.com/Gk3HDSj.png
val auth = request.headers.get("Authentication")
val auth = request.headers.get("Authorization")
val authParts = auth.map(_.trim.split(" +").toList)
val usernamePassword = authParts.flatMap {
case "Basic" :: secret :: Nil =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ class HomeController(
@unused iort: IORuntime,
@unused materializer: Materializer,
) extends AbstractController(cc)
with IOController {
with IOController
with ResultsController[IO]
with AuthController[IO] {
def index: Action[AnyContent] =
Action(Success(Hello("diptestbed")).withHttpStatus(OK))

Expand All @@ -45,4 +47,8 @@ class HomeController(
status => Success(status).withHttpStatus(OK),
)
}

def authCheck: Action[AnyContent] =
IOActionAny(withRequestAuthnOrFail(_)((_, _) => EitherT.liftF(IO.pure(Success(()).withHttpStatus(OK)))))

}
2 changes: 2 additions & 0 deletions client/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ rich = "*"
pyserial = "*"
kivy = { version = "*", platform_machine = "!= 'aarch64'" }
bitstring = "*"
pyyaml = "*"
appdirs = "*"

[dev-packages]
pyinstaller = "*"
Expand Down
218 changes: 134 additions & 84 deletions client/Pipfile.lock

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions client/src/domain/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import dataclasses
from typing import Optional
from dataclasses import dataclass
from src.service.backend_config import AuthConfig
from src.service.managed_url import ManagedURL


@dataclass
class Config:
static_url: Optional[ManagedURL] = None
control_url: Optional[ManagedURL] = None
auth: Optional[AuthConfig] = None

def with_static_url(self, value: Optional[ManagedURL]):
return dataclasses.replace(self, static_url=value)

def with_control_url(self, value: Optional[ManagedURL]):
return dataclasses.replace(self, control_url=value)

def with_auth(self, value: Optional[AuthConfig]):
return dataclasses.replace(self, auth=value)
24 changes: 22 additions & 2 deletions client/src/domain/existing_file_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@
class ManagedExistingFilePathBuildError(DIPClientError):
source_value: str
type: Optional[str] = None
exception: Optional[Exception] = None

def text(self):
clarification = f" for '{self.type}'" if self.type is None else ""
return f"File path '{self.source_value}'{clarification} does not exist."
type_info = f" for '{self.type}'" if self.type is None else ""
reason_info = f", reason: {str(self.exception)}" if self.exception is not None else ""
return f"File path '{self.source_value}'{type_info} does not exist{reason_info}"

def of_type(self, type: str) -> 'ManagedExistingFilePathBuildError':
return dataclasses.replace(self, type=type)
Expand All @@ -25,8 +27,26 @@ def of_type(self, type: str) -> 'ManagedExistingFilePathBuildError':
class ExistingFilePath:
value: str

@staticmethod
def exists(value: str) -> bool:
result = ExistingFilePath.build(value)
return isinstance(result, Ok)

@staticmethod
def build(value: str) -> Result['ExistingFilePath', ManagedExistingFilePathBuildError]:
if not os.path.exists(value):
return Err(ManagedExistingFilePathBuildError(value))
return Ok(ExistingFilePath(value))

@staticmethod
def new(value: str) -> Result['ExistingFilePath', ManagedExistingFilePathBuildError]:
try:
# Create directory path to file
config_dir = os.path.dirname(value)
if not ExistingFilePath.exists(config_dir):
os.makedirs(config_dir)
# Create and return empty file
open(value, 'w').close()
return Ok(ExistingFilePath(value))
except Exception as e:
return Err(ManagedExistingFilePathBuildError(value))
12 changes: 8 additions & 4 deletions client/src/engine/engine_common_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
from src.domain.hardware_control_message import UploadMessage, InternalStartLifecycle, InternalEndLifecycle, \
InternalSucceededSoftwareDownload, InternalSucceededSoftwareUpload, UploadResultMessage, \
InternalUploadBoardSoftware, PingMessage, SerialMonitorRequest, log_hardware_message, SerialMonitorRequestStop, \
InternalSerialMonitorStarting, InternalStartedSerialMonitor, InternalReceivedSerialBytes, SerialMonitorResult
InternalSerialMonitorStarting, InternalStartedSerialMonitor, InternalReceivedSerialBytes, SerialMonitorResult, \
InternalSerialMonitorStopped
from src.domain.managed_uuid import ManagedUUID
from src.domain.monitor_message import SerialMonitorMessageToAgent, SerialMonitorMessageToClient, MonitorUnavailable
from src.domain.positive_integer import PositiveInteger
Expand All @@ -20,7 +21,7 @@
from src.engine.engine_events import DownloadingBoardSoftware, LifecycleStarted, BoardSoftwareDownloadSuccess, \
UploadingBoardSoftware, BoardUploadSuccess, LifecycleEnded, BoardState, SerialMonitorAboutToStart, \
StartSerialMonitor, SerialMonitorAlreadyConfigured, SendingBoardBytes, ReceivedSerialBytes, StoppingSerialMonitor, \
SerialMonitorStartSuccess
SerialMonitorStartSuccess, StoppedSerialMonitor
from src.engine.engine_lifecycle import EngineLifecycle
from src.engine.engine_ping import EnginePing
from src.engine.engine_serial_monitor import EngineSerialMonitor, SerialBoard
Expand Down Expand Up @@ -97,7 +98,7 @@ async def log_outgoing_until_death():
asyncio.create_task(log_outgoing_until_death())

# Backend
backend_config: BackendConfig = BackendConfig(None, None)
backend_config: BackendConfig = BackendConfig(None, None, None)
backend = TestBackend(backend_config)
backend.software_download = lambda software_id: Ok(software_path)
software_path = ExistingFilePath(src_relative_path("static/test/software.bin"))
Expand Down Expand Up @@ -181,6 +182,7 @@ async def connect_serial(device_path: ExistingFilePath, config: ManagedSerialCon
serial_request_message,
to_agent_message,
serial_stop_message,
InternalSerialMonitorStopped(),
InternalReceivedSerialBytes(from_agent_bytes),
InternalEndLifecycle(death_reason)
])
Expand All @@ -196,6 +198,7 @@ async def connect_serial(device_path: ExistingFilePath, config: ManagedSerialCon
SerialMonitorAlreadyConfigured(),
SendingBoardBytes(to_agent_bytes),
StoppingSerialMonitor(),
StoppedSerialMonitor(),
ReceivedSerialBytes(from_agent_bytes),
LifecycleEnded(death_reason)
])
Expand All @@ -205,7 +208,8 @@ async def connect_serial(device_path: ExistingFilePath, config: ManagedSerialCon
PingMessage(),
SerialMonitorResult(None),
MonitorUnavailable(reason='Hardware control stopped monitor'),
SerialMonitorMessageToClient(from_agent_bytes)
SerialMonitorMessageToClient(from_agent_bytes),
MonitorUnavailable(reason='Agent engine stopped, reason: Test finished')
]
if out_queue_memory[-1] == PingMessage():
# What a horrible hack
Expand Down
2 changes: 1 addition & 1 deletion client/src/engine/engine_serial_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ async def effect_project(self, previous_state: EngineSerialMonitorState, event:
await previous_state.active_serial.close()
if previous_state.serial_death is not None:
previous_state.serial_death.grace()
message = f"Hardware controller stopped monitor"
message = f"Hardware control stopped monitor"
if event.reason is not None:
message = f"{message}, reason: {event.reason.text()}"
await previous_state.base.outgoing_message_queue.put(MonitorUnavailable(message))
Expand Down
71 changes: 67 additions & 4 deletions client/src/protocol/s11n_json.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
"""Module containing any JSON-from/to-Python serialization-specific logic"""

import uuid
import base64
from functools import partial
from typing import Type, TypeVar, Dict, Tuple, List
from result import Result, Err, Ok
from src.domain.managed_uuid import ManagedUUID
from src.protocol.codec import CodecParseException
from src.domain import hardware_control_message, backend_entity, backend_management_message, monitor_message
from src.domain import hardware_control_message, backend_entity, backend_management_message, monitor_message, config
from src.protocol.codec_json import JSON, EncoderJSON, DecoderJSON, CodecJSON
from src.service.backend_config import UserPassAuthConfig
from src.service.config_service import ConfigService
from src.service.managed_serial_config import ManagedSerialConfig


# Unit
def unit_decode_json(value: JSON) -> Result[Dict, CodecParseException]:
"""Decode string from JSON"""
if not isinstance(value, dict):
return Err(CodecParseException("Message must be object"))
return Ok(value)


UNIT_DECODER_JSON: DecoderJSON[str] = DecoderJSON(unit_decode_json)


# String
def string_decode_json(value: JSON) -> Result[str, CodecParseException]:
"""Decode string from JSON"""
Expand Down Expand Up @@ -165,7 +177,7 @@ def upload_message_decode_json(value: JSON) -> Result[hardware_control_message.U
return Err(CodecParseException("UploadMessage must be an object"))
firmware_id_result = ManagedUUID.build(value.get("softwareId"))
if isinstance(firmware_id_result, Err):
return Err(CodecParseException(f"UploadMessage .firmware_id isn't valid UUID: {e}"))
return Err(CodecParseException(f"UploadMessage .firmware_id isn't valid UUID: {firmware_id_result.value.text()}"))
return Ok(hardware_control_message.UploadMessage(firmware_id_result.value))


Expand Down Expand Up @@ -565,3 +577,54 @@ def software_decode_json(
SOFTWARE_ENCODER_JSON = EncoderJSON(software_encode_json)
SOFTWARE_DECODER_JSON = DecoderJSON(software_decode_json)
SOFTWARE_CODEC_JSON = CodecJSON(SOFTWARE_DECODER_JSON, SOFTWARE_ENCODER_JSON)


# config.Config
def config_encode_json(value: config.Config) -> JSON:
"""Serialize UploadMessage to JSON"""
# Static URL
if value.static_url is None: static_url = None
else:
static_url_result = value.static_url.text()
if isinstance(static_url_result, Err): static_url = None
else: static_url = static_url_result.value
# Control URL
if value.control_url is None:
control_url = None
else:
control_url_result = value.control_url.text()
if isinstance(control_url_result, Err):
control_url = None
else:
control_url = control_url_result.value
# Auth
if isinstance(value.auth, UserPassAuthConfig):
username = value.auth.username
password_b64 = base64.b64encode(value.auth.password.encode()).decode("utf-8")
else:
username = None
password_b64 = None
return {
"staticUrl": static_url,
"controlUrl": control_url,
"username": username,
"passwordBase64": password_b64
}


CONFIG_ENCODER_JSON: EncoderJSON[config.Config] = EncoderJSON(config_encode_json)


# ConfigService
def config_service_encode_json(value: ConfigService) -> JSON:
if value.source_file is None:
source_file = None
else:
source_file = value.source_file.value
return {
"config": CONFIG_ENCODER_JSON.json_encode(value.config),
"sourceFile": source_file
}


CONFIG_SERVICE_ENCODER_JSON: EncoderJSON[ConfigService] = EncoderJSON(config_service_encode_json)
Loading

0 comments on commit 4cfbae6

Please sign in to comment.