180 lines
8.0 KiB
Rust
180 lines
8.0 KiB
Rust
//! The application layer on top of the payload-agnostic [`RelayClient`].
|
|
//!
|
|
//! `RelayApp` owns the Skald-specific semantics that the transport crate
|
|
//! deliberately knows nothing about: the E2E JSON payload schemas (`payloads`),
|
|
//! the `InboxApi` dispatch, and the authorization policy. It consumes
|
|
//! `client.events()` and calls `client.send(...)`; the client handles the wire,
|
|
//! crypto, counters, and device registry.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::Result;
|
|
use tokio::sync::broadcast;
|
|
use tokio_util::sync::CancellationToken;
|
|
use tracing::{debug, warn};
|
|
|
|
use core_api::inbox::InboxApi;
|
|
use skald_relay_client::{ClientState, RelayClient, RelayEvent};
|
|
|
|
use crate::PLUGIN_ID;
|
|
use crate::payloads::{self, ClientPayload};
|
|
|
|
/// Glue between the relay transport ([`RelayClient`]) and Skald's Inbox.
|
|
pub struct RelayApp {
|
|
client: Arc<RelayClient>,
|
|
inbox: Arc<dyn InboxApi>,
|
|
/// When true, a freshly paired device stays Pending until a human confirms;
|
|
/// when false, the app auto-authorizes on `ClientPaired`.
|
|
require_device_confirmation: bool,
|
|
}
|
|
|
|
impl RelayApp {
|
|
pub fn new(
|
|
client: Arc<RelayClient>,
|
|
inbox: Arc<dyn InboxApi>,
|
|
require_device_confirmation: bool,
|
|
) -> Self {
|
|
Self { client, inbox, require_device_confirmation }
|
|
}
|
|
|
|
/// The underlying transport client (used by the `RelayAgent` impl + router).
|
|
pub fn client(&self) -> &Arc<RelayClient> {
|
|
&self.client
|
|
}
|
|
|
|
// ── Inbox → clients ───────────────────────────────────────────────────────
|
|
|
|
/// Build the Inbox snapshot and send it (encrypted) to every Authorized
|
|
/// client. `live=false` so the relay stores-and-forwards + pushes to offline
|
|
/// phones.
|
|
pub async fn broadcast_inbox(&self) -> Result<()> {
|
|
let snapshot = self.inbox.list_pending().await;
|
|
let plaintext = serde_json::to_vec(&payloads::build_inbox_update(&snapshot))?;
|
|
self.broadcast_plaintext(&plaintext).await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Build and send a generic notification to all Authorized clients.
|
|
pub async fn broadcast_notification(&self, title: &str, body: &str) -> Result<()> {
|
|
let plaintext = serde_json::to_vec(&payloads::build_notification(title, body))?;
|
|
self.broadcast_plaintext(&plaintext).await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Send an opaque plaintext to every Authorized device (`live=false`).
|
|
async fn broadcast_plaintext(&self, plaintext: &[u8]) {
|
|
for c in self
|
|
.client
|
|
.list_clients()
|
|
.await
|
|
.into_iter()
|
|
.filter(|c| c.state == ClientState::Authorized)
|
|
{
|
|
if let Err(e) = self.client.send(&c.ed25519_pub, plaintext, false).await {
|
|
warn!(plugin = PLUGIN_ID, error = %e, "failed to send to client");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Send the current Inbox snapshot to a single client (the targeted reply to
|
|
/// `inbox_request`). `live=true`: the requester is online by construction.
|
|
async fn send_inbox_to(&self, client_ed25519_pub: &[u8; 32]) -> Result<()> {
|
|
let snapshot = self.inbox.list_pending().await;
|
|
let plaintext = serde_json::to_vec(&payloads::build_inbox_update(&snapshot))?;
|
|
self.client.send(client_ed25519_pub, &plaintext, true).await
|
|
}
|
|
|
|
// ── Clients → Inbox ───────────────────────────────────────────────────────
|
|
|
|
/// Apply a decoded client payload to the Inbox. `payload` is the clean inner
|
|
/// JSON the client already decrypted + de-framed.
|
|
async fn apply_client_payload(&self, from: &[u8; 32], payload: &[u8]) {
|
|
match payloads::parse_client_payload(payload) {
|
|
ClientPayload::ApprovalResponse { request_id, approved, reason } => {
|
|
if approved {
|
|
self.inbox.approve(request_id).await;
|
|
} else {
|
|
self.inbox.reject(request_id, reason.unwrap_or_default()).await;
|
|
}
|
|
let _ = self.broadcast_inbox().await;
|
|
}
|
|
ClientPayload::ClarificationResponse { request_id, answer } => {
|
|
self.inbox.answer(request_id, answer).await;
|
|
let _ = self.broadcast_inbox().await;
|
|
}
|
|
ClientPayload::ElicitationResponse { request_id, action, content } => {
|
|
// `content` may hold a secret (SSH/sudo password): hand it straight
|
|
// to the Inbox; never log/persist it in clear (payloads.md §3.1).
|
|
self.inbox.resolve_elicitation(request_id, action, content).await;
|
|
let _ = self.broadcast_inbox().await;
|
|
}
|
|
ClientPayload::Hello { device_info } => {
|
|
if let Err(e) = self.client.set_device_info(from, &device_info.to_string()).await {
|
|
warn!(plugin = PLUGIN_ID, error = %e, "failed to persist device_info");
|
|
}
|
|
}
|
|
ClientPayload::InboxRequest => {
|
|
if let Err(e) = self.send_inbox_to(from).await {
|
|
warn!(plugin = PLUGIN_ID, error = %e, "failed to send targeted inbox snapshot");
|
|
}
|
|
}
|
|
ClientPayload::Logout => {
|
|
if let Err(e) = self.client.revoke(from).await {
|
|
warn!(plugin = PLUGIN_ID, error = %e, "logout revoke failed");
|
|
}
|
|
}
|
|
ClientPayload::Unknown => {
|
|
debug!(plugin = PLUGIN_ID, "unknown/ignored client payload");
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Event loop ────────────────────────────────────────────────────────────
|
|
|
|
/// Consume the client's [`RelayEvent`] stream until `cancel` fires. This is
|
|
/// where the authorization policy and Inbox application live.
|
|
pub async fn run_event_loop(
|
|
self: Arc<Self>,
|
|
mut rx: broadcast::Receiver<RelayEvent>,
|
|
cancel: CancellationToken,
|
|
) {
|
|
loop {
|
|
tokio::select! {
|
|
_ = cancel.cancelled() => break,
|
|
ev = rx.recv() => match ev {
|
|
Ok(RelayEvent::Message { from, payload, .. }) => {
|
|
self.apply_client_payload(&from, &payload).await;
|
|
}
|
|
Ok(RelayEvent::ClientPaired { ed25519_pub, .. }) => {
|
|
if self.require_device_confirmation {
|
|
debug!(
|
|
plugin = PLUGIN_ID,
|
|
device = %hex::encode(ed25519_pub),
|
|
"new device paired (pending manual confirmation)"
|
|
);
|
|
let _ = self
|
|
.broadcast_notification(
|
|
"Nuovo device",
|
|
"Un nuovo device è in attesa di conferma",
|
|
)
|
|
.await;
|
|
} else if let Err(e) = self.client.authorize(&ed25519_pub).await {
|
|
warn!(plugin = PLUGIN_ID, error = %e, "auto-authorize failed");
|
|
} else {
|
|
// Send the newly-authorized device the current snapshot.
|
|
let _ = self.broadcast_inbox().await;
|
|
}
|
|
}
|
|
Ok(RelayEvent::ClientRevoked { .. })
|
|
| Ok(RelayEvent::Connected)
|
|
| Ok(RelayEvent::Disconnected) => {}
|
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
|
warn!(plugin = PLUGIN_ID, skipped = n, "relay event stream lagged");
|
|
}
|
|
Err(broadcast::error::RecvError::Closed) => break,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|