feat(adapters): from_wss consumer adapter behind the wss feature (ADR-070)
- FromWss: dial wss:// -> split_tungstenite_to_bytes (client-side twin of the axum WS byte-adapter; one seam, both directions, OQ-01) -> Connection::from_bidi(b"alk/channels") -> alkcall ChannelClient (channel 0 install + dispatch loop) -> alkcall from_call importer. No protocol fork: specs mirror the remote, provenance FromCall. - Drop semantics (OQ-03 v1): session drop -> monitor fails all in-flight pendings retryable CONNECTION_CLOSED (WsPumps::read_eof Notify); no 30s-deadline hang. - Bearer token via constructor/assembly layer (ADR-014 no-env-vars). Production fix in the WS server half (upgrade.rs): the upgrade identity now propagates to channel 0's CallConnection (was AuthContext::anonymous -> dispatcher saw no identity, ACL checks ran unauthenticated; services/list filtered scoped ops for all callers). 9 in-module tests incl. full round-trip consumer<->server (both halves of the adapter together), ACL end-to-end, drop-no-hang. Verified: cargo test (227 lib default), --all-features (227 lib + 5 MCP + 10 WS integration), clippy -D warnings (both), fmt.
This commit is contained in:
@@ -33,10 +33,11 @@
|
||||
use std::{
|
||||
io,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use axum::extract::ws::{CloseFrame, Message, WebSocket};
|
||||
use axum::extract::ws::{CloseFrame, Message as AxumMessage, WebSocket};
|
||||
use futures::channel::mpsc as futures_mpsc;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
@@ -80,6 +81,7 @@ pub struct WsByteStream {
|
||||
pub struct WsPumps {
|
||||
read_task: tokio::task::JoinHandle<()>,
|
||||
write_task: tokio::task::JoinHandle<()>,
|
||||
read_eof: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl WsPumps {
|
||||
@@ -87,6 +89,13 @@ impl WsPumps {
|
||||
self.read_task.abort();
|
||||
self.write_task.abort();
|
||||
}
|
||||
|
||||
/// Fires when the WS read side reaches EOF (socket close from either
|
||||
/// side) — used by `from_wss`'s connection-drop monitor to await
|
||||
/// socket EOF (ADR-070).
|
||||
pub(crate) async fn read_eof(&self) {
|
||||
self.read_eof.notified().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a `WebSocket` into the byte stream + the pump tasks. The
|
||||
@@ -97,26 +106,30 @@ pub fn split_ws_to_bytes(socket: WebSocket) -> (WsByteStream, WsPumps) {
|
||||
let (read_tx, read_rx) = mpsc::channel::<Vec<u8>>(READ_SLOTS);
|
||||
let (write_tx, mut write_rx) = futures_mpsc::channel::<WriteMsg>(WRITE_SLOTS);
|
||||
|
||||
let read_eof = Arc::new(tokio::sync::Notify::new());
|
||||
|
||||
let write_tx_for_read = write_tx.clone();
|
||||
let read_eof_for_task = Arc::clone(&read_eof);
|
||||
let read_task = tokio::spawn(async move {
|
||||
while let Some(msg) = ws_stream.next().await {
|
||||
match msg {
|
||||
Ok(Message::Binary(b)) => {
|
||||
Ok(AxumMessage::Binary(b)) => {
|
||||
if read_tx.send(b.to_vec()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Message::Text(_)) => {
|
||||
Ok(AxumMessage::Text(_)) => {
|
||||
let _ = write_tx_for_read
|
||||
.clone()
|
||||
.send(WriteMsg::CloseWith(WS_PROTOCOL_ERROR))
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
Ok(Message::Close(_)) | Err(_) => break,
|
||||
Ok(AxumMessage::Close(_)) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
read_eof_for_task.notify_waiters();
|
||||
});
|
||||
|
||||
let write_task = tokio::spawn(async move {
|
||||
@@ -125,7 +138,7 @@ pub fn split_ws_to_bytes(socket: WebSocket) -> (WsByteStream, WsPumps) {
|
||||
match msg {
|
||||
WriteMsg::CloseWith(code) => {
|
||||
let _ = ws_sink
|
||||
.send(Message::Close(Some(CloseFrame {
|
||||
.send(AxumMessage::Close(Some(CloseFrame {
|
||||
code,
|
||||
reason: "text messages not supported".into(),
|
||||
})))
|
||||
@@ -147,7 +160,7 @@ pub fn split_ws_to_bytes(socket: WebSocket) -> (WsByteStream, WsPumps) {
|
||||
let chunk: Vec<u8> = pending.drain(..total).collect();
|
||||
for piece in chunk.chunks(WS_MESSAGE_CAP) {
|
||||
if ws_sink
|
||||
.send(Message::Binary(piece.to_vec().into()))
|
||||
.send(AxumMessage::Binary(piece.to_vec().into()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
@@ -171,6 +184,7 @@ pub fn split_ws_to_bytes(socket: WebSocket) -> (WsByteStream, WsPumps) {
|
||||
WsPumps {
|
||||
read_task,
|
||||
write_task,
|
||||
read_eof,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -253,3 +267,107 @@ impl AsyncWrite for WsByteStream {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Client-side split for a tokio-tungstenite `WebSocketStream`
|
||||
/// (`from_wss`, ADR-070): the same WS↔byte-stream seam as
|
||||
/// [`split_ws_to_bytes`], over the tungstenite socket instead of axum's
|
||||
/// server-side `WebSocket`. Message semantics are identical: binary
|
||||
/// messages carry the byte stream, text is a protocol error (close
|
||||
/// 1002), close → read EOF.
|
||||
#[cfg(any(test, feature = "wss"))]
|
||||
pub fn split_tungstenite_to_bytes<S>(
|
||||
socket: tokio_tungstenite::WebSocketStream<S>,
|
||||
) -> (WsByteStream, WsPumps)
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let (mut ws_sink, mut ws_stream) = socket.split();
|
||||
let (read_tx, read_rx) = mpsc::channel::<Vec<u8>>(READ_SLOTS);
|
||||
let (write_tx, mut write_rx) = futures_mpsc::channel::<WriteMsg>(WRITE_SLOTS);
|
||||
|
||||
let read_eof = Arc::new(tokio::sync::Notify::new());
|
||||
|
||||
let write_tx_for_read = write_tx.clone();
|
||||
let read_eof_for_task = Arc::clone(&read_eof);
|
||||
let read_task = tokio::spawn(async move {
|
||||
while let Some(msg) = ws_stream.next().await {
|
||||
match msg {
|
||||
Ok(tokio_tungstenite::tungstenite::Message::Binary(b)) => {
|
||||
if read_tx.send(b.to_vec()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(tokio_tungstenite::tungstenite::Message::Text(_)) => {
|
||||
let _ = write_tx_for_read
|
||||
.clone()
|
||||
.send(WriteMsg::CloseWith(WS_PROTOCOL_ERROR))
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
Ok(tokio_tungstenite::tungstenite::Message::Close(_)) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
read_eof_for_task.notify_waiters();
|
||||
});
|
||||
|
||||
let write_task = tokio::spawn(async move {
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
while let Some(msg) = write_rx.next().await {
|
||||
match msg {
|
||||
WriteMsg::CloseWith(code) => {
|
||||
let _ = ws_sink
|
||||
.send(tokio_tungstenite::tungstenite::Message::Close(Some(
|
||||
tokio_tungstenite::tungstenite::protocol::CloseFrame {
|
||||
code: code.into(),
|
||||
reason: "text messages not supported".into(),
|
||||
},
|
||||
)))
|
||||
.await;
|
||||
break;
|
||||
}
|
||||
WriteMsg::Bytes(b) => pending.extend_from_slice(&b),
|
||||
}
|
||||
loop {
|
||||
if pending.len() < 8 {
|
||||
break;
|
||||
}
|
||||
let len =
|
||||
u32::from_be_bytes([pending[4], pending[5], pending[6], pending[7]]) as usize;
|
||||
let total = 8 + len;
|
||||
if pending.len() < total {
|
||||
break;
|
||||
}
|
||||
let chunk: Vec<u8> = pending.drain(..total).collect();
|
||||
for piece in chunk.chunks(WS_MESSAGE_CAP) {
|
||||
if ws_sink
|
||||
.send(tokio_tungstenite::tungstenite::Message::Binary(
|
||||
piece.to_vec().into(),
|
||||
))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = ws_sink.close().await;
|
||||
});
|
||||
|
||||
(
|
||||
WsByteStream {
|
||||
read_rx,
|
||||
read_buf: Vec::new(),
|
||||
read_pos: 0,
|
||||
eof: false,
|
||||
write_tx,
|
||||
write_open: true,
|
||||
},
|
||||
WsPumps {
|
||||
read_task,
|
||||
write_task,
|
||||
read_eof,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ pub mod byte_adapter;
|
||||
pub mod upgrade;
|
||||
|
||||
pub use byte_adapter::{
|
||||
split_ws_to_bytes, WsByteStream, WsPumps, WS_MESSAGE_CAP, WS_PROTOCOL_ERROR,
|
||||
split_tungstenite_to_bytes, split_ws_to_bytes, WsByteStream, WsPumps, WS_MESSAGE_CAP,
|
||||
WS_PROTOCOL_ERROR,
|
||||
};
|
||||
pub use upgrade::{run_channels_session, ws_bearer_auth, ws_upgrade_handler};
|
||||
|
||||
|
||||
@@ -35,10 +35,15 @@ pub async fn run_channels_session(
|
||||
) {
|
||||
let (byte_stream, _pumps) = split_ws_to_bytes(socket);
|
||||
let conn = Connection::from_bidi(byte_stream, b"alk/channels".to_vec(), None);
|
||||
let _ = conn.set_identity(identity);
|
||||
let _ = conn.set_identity(identity.clone());
|
||||
|
||||
let adapter = ChannelsAdapter::new(install_channel_zero(registry), policy);
|
||||
let auth = AuthContext::anonymous(b"alk/channels");
|
||||
let auth = AuthContext {
|
||||
identity: Some(identity),
|
||||
alpn: b"alk/channels".to_vec(),
|
||||
remote_addr: None,
|
||||
tls_client_fingerprint: None,
|
||||
};
|
||||
if let Err(e) = ProtocolHandler::handle(&adapter, conn, &auth).await {
|
||||
tracing::warn!(error = %e, "channels session ended");
|
||||
}
|
||||
@@ -57,9 +62,18 @@ pub async fn run_channels_session(
|
||||
fn install_channel_zero(
|
||||
registry: Arc<OperationRegistry>,
|
||||
) -> alkcall::channels::adapter::InstallChannelZero {
|
||||
Arc::new(move |_manager, channel0_conn, _auth| {
|
||||
Arc::new(move |_manager, channel0_conn, auth| {
|
||||
let registry = Arc::clone(®istry);
|
||||
tokio::spawn(async move {
|
||||
// The WS identity rides the upgrade request; propagate it to
|
||||
// channel 0's `CallConnection` so the dispatcher's
|
||||
// `resolve_identity` (and thus `AccessControl::check` on
|
||||
// `services/list` and every operation) sees it. Without this
|
||||
// the freshly constructed channel-0 `Connection` carries no
|
||||
// identity and all ACL-restricted ops look unauthenticated.
|
||||
if let Some(identity) = auth.identity.clone() {
|
||||
let _ = channel0_conn.set_identity(identity);
|
||||
}
|
||||
let channel0_bidi = match channel0_conn.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
|
||||
Reference in New Issue
Block a user