forked from iii-hq/workers
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathproxy.rs
More file actions
333 lines (310 loc) · 13.1 KB
/
Copy pathproxy.rs
File metadata and controls
333 lines (310 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
//! Transparent WebSocket proxy at `/ws`.
//!
//! For every incoming `/ws` upgrade we open a fresh outbound WebSocket
//! to the iii engine (`engine_url`) and pump frames between the two
//! sockets until either side closes. Frame types are translated 1:1
//! between [`axum::extract::ws::Message`] and
//! [`tokio_tungstenite::tungstenite::Message`].
//!
//! The proxy is intentionally dumb — no buffering, no auth — with TWO
//! exceptions on the browser→engine leg:
//!
//! 1. `registerfunction` messages get `metadata.internal = true` stamped
//! on (see [`stamp_internal_registration`]). Everything a console page
//! registers is a live-update delivery target for that page, never a
//! discoverable API, and stamping here (not just in the SPA) means
//! stale/cached bundles can't pollute `engine::functions::list` either.
//! 2. `registertriggertype` frames are dropped (see
//! [`is_trigger_type_registration`]). Trigger-type ownership is
//! last-writer-wins engine-wide — a hostile page could re-register
//! `console:script` and intercept every UI-asset registration. The SPA
//! never sends this frame, so dropping it breaks nothing.
use std::sync::Arc;
use axum::extract::ws::{CloseFrame as AxumCloseFrame, Message as AxumMessage, WebSocket};
use axum::extract::{State, WebSocketUpgrade};
use axum::response::Response;
use futures_util::sink::SinkExt;
use futures_util::stream::StreamExt;
use tokio_tungstenite::tungstenite::protocol::{CloseFrame as TungCloseFrame, WebSocketConfig};
use tokio_tungstenite::tungstenite::Message as TungMessage;
/// Axum handler. Splits the upgraded socket into a sender + receiver
/// and spawns a single `handle_ws` future for the lifetime of the
/// connection.
///
/// Both legs run without message/frame size limits: the proxy must not
/// impose caps the endpoints themselves don't have. With the default
/// tungstenite limits (16 MiB frame / 64 MiB message) a single large
/// engine response (e.g. a traces read on a long-running stack) killed
/// the connection, and the browser SDK then reconnected, refetched the
/// same payload, and looped forever.
pub async fn ws_proxy(ws: WebSocketUpgrade, State(engine_url): State<Arc<String>>) -> Response {
ws.max_message_size(usize::MAX)
.max_frame_size(usize::MAX)
.on_upgrade(move |socket| handle_ws(socket, engine_url))
}
async fn handle_ws(client: WebSocket, engine_url: Arc<String>) {
let engine_ws_config = WebSocketConfig {
max_message_size: None,
max_frame_size: None,
..Default::default()
};
let (engine, _resp) = match tokio_tungstenite::connect_async_with_config(
engine_url.as_str(),
Some(engine_ws_config),
false,
)
.await
{
Ok(pair) => pair,
Err(e) => {
tracing::warn!(
error = %e,
engine_url = %engine_url,
"failed to dial iii engine WebSocket; closing browser WS"
);
// Best-effort close; ignore the result. axum will drop the
// socket once `client` goes out of scope anyway.
let mut client = client;
let _ = client
.send(AxumMessage::Close(Some(AxumCloseFrame {
code: 1011, // 1011 = "internal error"
reason: "engine WS dial failed".into(),
})))
.await;
return;
}
};
let (mut client_tx, mut client_rx) = client.split();
let (mut engine_tx, mut engine_rx) = engine.split();
let client_to_engine = async {
while let Some(msg) = client_rx.next().await {
let msg = match msg {
Ok(m) => m,
Err(e) => {
tracing::warn!(error = %e, "browser -> engine: client read error; closing proxied WS");
break;
}
};
let is_close = matches!(msg, AxumMessage::Close(_));
let msg = match msg {
AxumMessage::Text(t) => {
if is_trigger_type_registration(&t) {
tracing::warn!(
"dropped browser-originated registertriggertype frame \
(trigger-type ownership is not available through the /ws proxy)"
);
continue;
}
match stamp_internal_registration(&t) {
Some(stamped) => AxumMessage::Text(stamped),
None => AxumMessage::Text(t),
}
}
other => other,
};
if let Some(out) = axum_to_tungstenite(msg) {
if let Err(e) = engine_tx.send(out).await {
tracing::debug!(error = %e, "browser -> engine: engine send error");
break;
}
}
if is_close {
break;
}
}
let _ = engine_tx.close().await;
};
let engine_to_client = async {
while let Some(msg) = engine_rx.next().await {
let msg = match msg {
Ok(m) => m,
Err(e) => {
tracing::warn!(error = %e, "engine -> browser: engine read error; closing proxied WS");
break;
}
};
let is_close = matches!(msg, TungMessage::Close(_));
if let Some(out) = tungstenite_to_axum(msg) {
if let Err(e) = client_tx.send(out).await {
tracing::debug!(error = %e, "engine -> browser: client send error");
break;
}
}
if is_close {
break;
}
}
let _ = client_tx.close().await;
};
tokio::select! {
_ = client_to_engine => {}
_ = engine_to_client => {}
}
}
/// `true` if `text` is a wire `registertriggertype` message — the one
/// frame the proxy refuses to forward (tab-originated trigger-type
/// hijack; worker-originated hijack remains an RBAC concern).
pub(crate) fn is_trigger_type_registration(text: &str) -> bool {
if !text.contains("\"registertriggertype\"") {
return false;
}
let Ok(msg) = serde_json::from_str::<serde_json::Value>(text) else {
return false;
};
msg.get("type").and_then(|t| t.as_str()) == Some("registertriggertype")
}
/// If `text` is a wire `registerfunction` message, return a copy with
/// `metadata.internal = true` merged in; `None` means "forward the
/// original untouched" (not a registration, unparseable, or a metadata
/// shape we don't understand).
pub(crate) fn stamp_internal_registration(text: &str) -> Option<String> {
// Fast path: skip the JSON parse for the overwhelming majority of
// frames (invocations, results, stream sends).
if !text.contains("\"registerfunction\"") {
return None;
}
let mut msg: serde_json::Value = serde_json::from_str(text).ok()?;
if msg.get("type").and_then(|t| t.as_str()) != Some("registerfunction") {
return None;
}
let obj = msg.as_object_mut()?;
match obj.get_mut("metadata") {
Some(serde_json::Value::Object(meta)) => {
meta.insert("internal".into(), serde_json::Value::Bool(true));
}
// Unexpected metadata shape — don't rewrite what we don't understand.
Some(_) => return None,
None => {
obj.insert("metadata".into(), serde_json::json!({ "internal": true }));
}
}
serde_json::to_string(&msg).ok()
}
/// Convert an axum `Message` into a tungstenite `Message`. Returns
/// `None` when the variant has no useful tungstenite equivalent.
pub(crate) fn axum_to_tungstenite(msg: AxumMessage) -> Option<TungMessage> {
Some(match msg {
AxumMessage::Text(t) => TungMessage::Text(t),
AxumMessage::Binary(b) => TungMessage::Binary(b),
AxumMessage::Ping(p) => TungMessage::Ping(p),
AxumMessage::Pong(p) => TungMessage::Pong(p),
AxumMessage::Close(frame) => TungMessage::Close(frame.map(|f| TungCloseFrame {
code: f.code.into(),
reason: f.reason.into_owned().into(),
})),
})
}
/// Convert a tungstenite `Message` into an axum `Message`. Returns
/// `None` for `Frame` (raw frames are an internal tungstenite escape
/// hatch the proxy never produces or forwards).
pub(crate) fn tungstenite_to_axum(msg: TungMessage) -> Option<AxumMessage> {
Some(match msg {
TungMessage::Text(t) => AxumMessage::Text(t),
TungMessage::Binary(b) => AxumMessage::Binary(b),
TungMessage::Ping(p) => AxumMessage::Ping(p),
TungMessage::Pong(p) => AxumMessage::Pong(p),
TungMessage::Close(frame) => AxumMessage::Close(frame.map(|f| AxumCloseFrame {
code: u16::from(f.code),
reason: f.reason.into_owned().into(),
})),
TungMessage::Frame(_) => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_roundtrips_both_directions() {
let original = AxumMessage::Text("hello".into());
let tung = axum_to_tungstenite(original.clone()).unwrap();
assert!(matches!(&tung, TungMessage::Text(s) if s == "hello"));
let back = tungstenite_to_axum(tung).unwrap();
assert!(matches!(&back, AxumMessage::Text(s) if s == "hello"));
}
#[test]
fn binary_roundtrips_both_directions() {
let original = AxumMessage::Binary(vec![1, 2, 3, 4]);
let tung = axum_to_tungstenite(original.clone()).unwrap();
assert!(matches!(&tung, TungMessage::Binary(b) if b == &vec![1, 2, 3, 4]));
let back = tungstenite_to_axum(tung).unwrap();
assert!(matches!(&back, AxumMessage::Binary(b) if b == &vec![1, 2, 3, 4]));
}
#[test]
fn ping_pong_pass_through() {
let p = axum_to_tungstenite(AxumMessage::Ping(vec![9])).unwrap();
assert!(matches!(&p, TungMessage::Ping(b) if b == &vec![9]));
let p = axum_to_tungstenite(AxumMessage::Pong(vec![10])).unwrap();
assert!(matches!(&p, TungMessage::Pong(b) if b == &vec![10]));
}
#[test]
fn close_preserves_code_and_reason() {
let original = AxumMessage::Close(Some(AxumCloseFrame {
code: 1001,
reason: "going away".into(),
}));
let tung = axum_to_tungstenite(original).unwrap();
let frame = match tung {
TungMessage::Close(f) => f.unwrap(),
_ => panic!("expected close"),
};
assert_eq!(u16::from(frame.code), 1001);
assert_eq!(frame.reason, "going away");
let back = tungstenite_to_axum(TungMessage::Close(Some(frame))).unwrap();
match back {
AxumMessage::Close(Some(f)) => {
assert_eq!(f.code, 1001);
assert_eq!(f.reason, "going away");
}
_ => panic!("expected close"),
}
}
#[test]
fn stamp_adds_internal_metadata_when_absent() {
let wire = r#"{"type":"registerfunction","id":"console::harness-watch::r0::console-abc"}"#;
let out = stamp_internal_registration(wire).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["metadata"]["internal"], serde_json::json!(true));
assert_eq!(v["id"], "console::harness-watch::r0::console-abc");
}
#[test]
fn stamp_merges_into_existing_metadata() {
let wire = r#"{"type":"registerfunction","id":"x","metadata":{"tenant":"acme"}}"#;
let out = stamp_internal_registration(wire).unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["metadata"]["internal"], serde_json::json!(true));
assert_eq!(v["metadata"]["tenant"], "acme");
}
#[test]
fn stamp_ignores_other_messages_and_bad_input() {
// Different message type — even one that mentions registerfunction in a payload.
assert!(stamp_internal_registration(
r#"{"type":"invokefunction","payload":"\"registerfunction\""}"#
)
.is_none());
// Not JSON.
assert!(stamp_internal_registration("registerfunction{").is_none());
// Metadata of an unexpected shape is left alone.
assert!(stamp_internal_registration(
r#"{"type":"registerfunction","id":"x","metadata":"weird"}"#
)
.is_none());
}
#[test]
fn trigger_type_registration_is_detected() {
assert!(is_trigger_type_registration(
r#"{"type":"registertriggertype","id":"console:script","description":"x"}"#
));
// Payload mentions it, but the frame is a different type — forward.
assert!(!is_trigger_type_registration(
r#"{"type":"invokefunction","payload":"\"registertriggertype\""}"#
));
assert!(!is_trigger_type_registration("registertriggertype{"));
}
#[test]
fn raw_frame_is_dropped() {
// Raw frames are an internal tungstenite construct; we don't
// forward them. Use a real Frame ctor — `default()` panics.
let frame = tokio_tungstenite::tungstenite::protocol::frame::Frame::ping(vec![]);
assert!(tungstenite_to_axum(TungMessage::Frame(frame)).is_none());
}
}