Skip to content

Commit f7ea173

Browse files
committed
server: add status REST API polling endpoint
1 parent eb36cc8 commit f7ea173

2 files changed

Lines changed: 229 additions & 36 deletions

File tree

crates/bonded-server/src/status.rs

Lines changed: 227 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ use crate::frame_forwarder::{
33
UdpSessionTracker,
44
};
55
use crate::session_registry::SessionRegistry;
6-
use tokio::io::AsyncWriteExt;
6+
use serde_json::json;
7+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
78
use tokio::net::TcpListener;
89
use tracing::{error, info};
910

@@ -26,21 +27,67 @@ pub async fn run_status_server(
2627
}
2728
};
2829

29-
let page = render_status_page(
30-
&sessions,
31-
&udp_tracker.snapshot(),
32-
&tcp_tracker.snapshot(),
33-
&icmp_tracker.snapshot(),
34-
);
35-
let response = format!(
36-
"HTTP/1.1 200 OK\r\ncontent-type: text/html; charset=utf-8\r\ncache-control: no-store\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
37-
page.len(),
38-
page
39-
);
40-
41-
if let Err(err) = stream.write_all(response.as_bytes()).await {
42-
error!(peer = %peer, error = %err, "failed to write status response");
43-
}
30+
let sessions = sessions.clone();
31+
let udp_tracker = udp_tracker.clone();
32+
let tcp_tracker = tcp_tracker.clone();
33+
let icmp_tracker = icmp_tracker.clone();
34+
35+
tokio::spawn(async move {
36+
// Drain the request so the kernel can send FIN instead of RST.
37+
let mut request = Vec::with_capacity(1024);
38+
let mut buf = [0u8; 1024];
39+
loop {
40+
match stream.read(&mut buf).await {
41+
Ok(0) | Err(_) => break,
42+
Ok(n) => {
43+
request.extend_from_slice(&buf[..n]);
44+
// HTTP headers end with \r\n\r\n; stop reading once seen.
45+
if request.windows(4).any(|w| w == b"\r\n\r\n") || request.len() >= 8192 {
46+
break;
47+
}
48+
}
49+
}
50+
}
51+
52+
let target = parse_request_target(&request).unwrap_or("/");
53+
let udp_flows = udp_tracker.snapshot();
54+
let tcp_flows = tcp_tracker.snapshot();
55+
let icmp_probes = icmp_tracker.snapshot();
56+
57+
let (status_line, content_type, body) = match target {
58+
"/" => (
59+
"200 OK",
60+
"text/html; charset=utf-8",
61+
render_status_page(&sessions, &udp_flows, &tcp_flows, &icmp_probes),
62+
),
63+
"/api/status" => (
64+
"200 OK",
65+
"application/json; charset=utf-8",
66+
render_status_json(&sessions, &udp_flows, &tcp_flows, &icmp_probes),
67+
),
68+
_ => (
69+
"404 Not Found",
70+
"text/plain; charset=utf-8",
71+
"not found".to_owned(),
72+
),
73+
};
74+
75+
let body_bytes = body.as_bytes();
76+
let response = format!(
77+
"HTTP/1.1 {}\r\ncontent-type: {}\r\ncache-control: no-store\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
78+
status_line,
79+
content_type,
80+
body_bytes.len(),
81+
body
82+
);
83+
84+
if let Err(err) = stream.write_all(response.as_bytes()).await {
85+
error!(peer = %peer, error = %err, "failed to write status response");
86+
return;
87+
}
88+
let _ = stream.flush().await;
89+
let _ = stream.shutdown().await;
90+
});
4491
}
4592
}
4693

@@ -114,12 +161,11 @@ fn render_status_page(
114161
.join("\n");
115162

116163
format!(
117-
"<!doctype html>
164+
r#"<!doctype html>
118165
<html>
119166
<head>
120-
<meta charset=\"utf-8\" />
121-
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
122-
<meta http-equiv=\"refresh\" content=\"2\" />
167+
<meta charset="utf-8" />
168+
<meta name="viewport" content="width=device-width, initial-scale=1" />
123169
<title>Bonded Server Status</title>
124170
<style>
125171
body {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; margin: 24px; background: #f8fafc; color: #111827; }}
@@ -133,37 +179,101 @@ small {{ color: #6b7280; }}
133179
</head>
134180
<body>
135181
<h1>Bonded Server Status</h1>
136-
<small>Auto-refreshes every 2 seconds.</small>
137-
<div class=\"card\">
138-
<h2>Authenticated Sessions ({})</h2>
182+
<small>Live updates every 2 seconds via <code>/api/status</code>.</small>
183+
<div class="card">
184+
<h2 id="sessions-title">Authenticated Sessions ({})</h2>
139185
<table>
140186
<thead><tr><th>Session ID</th><th>Client Key</th></tr></thead>
141-
<tbody>{}</tbody>
187+
<tbody id="sessions-body">{}</tbody>
142188
</table>
143189
</div>
144-
<div class=\"card\">
145-
<h2>Active UDP Flows ({})</h2>
190+
<div class="card">
191+
<h2 id="udp-title">Active UDP Flows ({})</h2>
146192
<table>
147193
<thead><tr><th>Session ID</th><th>Client Source</th><th>Remote Target</th><th>Server UDP Socket</th><th>Created</th><th>Last Client Packet</th><th>Last Remote Packet</th></tr></thead>
148-
<tbody>{}</tbody>
194+
<tbody id="udp-body">{}</tbody>
149195
</table>
150196
</div>
151-
<div class=\"card\">
152-
<h2>Active TCP Flows ({})</h2>
197+
<div class="card">
198+
<h2 id="tcp-title">Active TCP Flows ({})</h2>
153199
<table>
154200
<thead><tr><th>Session ID</th><th>Client Source</th><th>Remote Target</th><th>Created</th><th>Last Activity</th></tr></thead>
155-
<tbody>{}</tbody>
201+
<tbody id="tcp-body">{}</tbody>
156202
</table>
157203
</div>
158-
<div class=\"card\">
159-
<h2>Recent ICMP Probes ({})</h2>
204+
<div class="card">
205+
<h2 id="icmp-title">Recent ICMP Probes ({})</h2>
160206
<table>
161207
<thead><tr><th>Session ID</th><th>Client Source</th><th>Remote Target</th><th>Echo ID</th><th>Seq</th><th>Outcome</th><th>Observed</th></tr></thead>
162-
<tbody>{}</tbody>
208+
<tbody id="icmp-body">{}</tbody>
163209
</table>
164210
</div>
211+
<script>
212+
const el = (id) => document.getElementById(id);
213+
const escapeHtml = (value) => String(value)
214+
.replaceAll("&", "&amp;")
215+
.replaceAll("<", "&lt;")
216+
.replaceAll(">", "&gt;")
217+
.replaceAll('"', "&quot;")
218+
.replaceAll("'", "&#x27;");
219+
220+
function setRows(id, rows, emptyMessage, columns) {{
221+
if (!rows.length) {{
222+
el(id).innerHTML = `<tr><td colspan="${{columns}}">${{escapeHtml(emptyMessage)}}</td></tr>`;
223+
return;
224+
}}
225+
el(id).innerHTML = rows.join("\n");
226+
}}
227+
228+
function render(data) {{
229+
el("sessions-title").textContent = `Authenticated Sessions (${{data.sessions_count}})`;
230+
el("udp-title").textContent = `Active UDP Flows (${{data.udp_flows_count}})`;
231+
el("tcp-title").textContent = `Active TCP Flows (${{data.tcp_flows_count}})`;
232+
el("icmp-title").textContent = `Recent ICMP Probes (${{data.icmp_probes_count}})`;
233+
234+
setRows(
235+
"sessions-body",
236+
data.sessions.map((entry) => `<tr><td>${{escapeHtml(entry.session_id)}}</td><td>${{escapeHtml(entry.client_key_abbrev)}}</td></tr>`),
237+
"No active authenticated sessions.",
238+
2
239+
);
240+
setRows(
241+
"udp-body",
242+
data.udp_flows.map((entry) => `<tr><td>${{escapeHtml(entry.session_id)}}</td><td>${{escapeHtml(entry.client_src)}}</td><td>${{escapeHtml(entry.client_dst)}}</td><td>${{escapeHtml(entry.bound_socket)}}</td><td>${{escapeHtml(entry.created_ago)}}</td><td>${{escapeHtml(entry.last_client_ago)}}</td><td>${{escapeHtml(entry.last_remote_ago ?? "never")}}</td></tr>`),
243+
"No active UDP flows.",
244+
7
245+
);
246+
setRows(
247+
"tcp-body",
248+
data.tcp_flows.map((entry) => `<tr><td>${{escapeHtml(entry.session_id)}}</td><td>${{escapeHtml(entry.client_src)}}</td><td>${{escapeHtml(entry.client_dst)}}</td><td>${{escapeHtml(entry.created_ago)}}</td><td>${{escapeHtml(entry.last_activity_ago)}}</td></tr>`),
249+
"No active TCP flows.",
250+
5
251+
);
252+
setRows(
253+
"icmp-body",
254+
data.icmp_probes.map((entry) => `<tr><td>${{escapeHtml(entry.session_id)}}</td><td>${{escapeHtml(entry.client_src)}}</td><td>${{escapeHtml(entry.client_dst)}}</td><td>${{escapeHtml(entry.echo_identifier)}}</td><td>${{escapeHtml(entry.echo_sequence)}}</td><td>${{escapeHtml(entry.outcome)}}</td><td>${{escapeHtml(entry.observed_ago)}}</td></tr>`),
255+
"No recent ICMP probes.",
256+
7
257+
);
258+
}}
259+
260+
async function refresh() {{
261+
try {{
262+
const response = await fetch("/api/status", {{ cache: "no-store" }});
263+
if (!response.ok) {{
264+
return;
265+
}}
266+
const data = await response.json();
267+
render(data);
268+
}} catch (_) {{
269+
// Keep the last rendered state on transient failures.
270+
}}
271+
}}
272+
273+
setInterval(refresh, 2000);
274+
</script>
165275
</body>
166-
</html>",
276+
</html>"#,
167277
sessions.active_sessions(),
168278
if session_rows.is_empty() {
169279
"<tr><td colspan=\"2\">No active authenticated sessions.</td></tr>".to_owned()
@@ -191,6 +301,89 @@ small {{ color: #6b7280; }}
191301
)
192302
}
193303

304+
fn render_status_json(
305+
sessions: &SessionRegistry,
306+
udp_flows: &[UdpFlowSnapshot],
307+
tcp_flows: &[TcpFlowSnapshot],
308+
icmp_probes: &[IcmpProbeSnapshot],
309+
) -> String {
310+
let sessions = sessions
311+
.snapshot()
312+
.into_iter()
313+
.map(|entry| {
314+
json!({
315+
"session_id": entry.session_id,
316+
"client_key": entry.client_key,
317+
"client_key_abbrev": abbreviate_key(&entry.client_key),
318+
})
319+
})
320+
.collect::<Vec<_>>();
321+
322+
let udp_flows = udp_flows
323+
.iter()
324+
.map(|entry| {
325+
json!({
326+
"session_id": entry.session_id,
327+
"client_src": entry.client_src,
328+
"client_dst": entry.client_dst,
329+
"bound_socket": entry.bound_socket,
330+
"created_ago": entry.created_ago,
331+
"last_client_ago": entry.last_client_ago,
332+
"last_remote_ago": entry.last_remote_ago,
333+
})
334+
})
335+
.collect::<Vec<_>>();
336+
337+
let tcp_flows = tcp_flows
338+
.iter()
339+
.map(|entry| {
340+
json!({
341+
"session_id": entry.session_id,
342+
"client_src": entry.client_src,
343+
"client_dst": entry.client_dst,
344+
"created_ago": entry.created_ago,
345+
"last_activity_ago": entry.last_activity_ago,
346+
})
347+
})
348+
.collect::<Vec<_>>();
349+
350+
let icmp_probes = icmp_probes
351+
.iter()
352+
.take(64)
353+
.map(|entry| {
354+
json!({
355+
"session_id": entry.session_id,
356+
"client_src": entry.client_src,
357+
"client_dst": entry.client_dst,
358+
"echo_identifier": entry.echo_identifier,
359+
"echo_sequence": entry.echo_sequence,
360+
"outcome": entry.outcome,
361+
"observed_ago": entry.observed_ago,
362+
})
363+
})
364+
.collect::<Vec<_>>();
365+
366+
json!({
367+
"sessions_count": sessions.len(),
368+
"udp_flows_count": udp_flows.len(),
369+
"tcp_flows_count": tcp_flows.len(),
370+
"icmp_probes_count": icmp_probes.len(),
371+
"sessions": sessions,
372+
"udp_flows": udp_flows,
373+
"tcp_flows": tcp_flows,
374+
"icmp_probes": icmp_probes,
375+
})
376+
.to_string()
377+
}
378+
379+
fn parse_request_target(request: &[u8]) -> Option<&str> {
380+
let request = std::str::from_utf8(request).ok()?;
381+
let request_line = request.lines().next()?;
382+
let mut parts = request_line.split_whitespace();
383+
let _method = parts.next()?;
384+
parts.next()
385+
}
386+
194387
fn escape_html(value: &str) -> String {
195388
value
196389
.replace('&', "&amp;")

docs/design/implementation-plan.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Implementation Plan — Server, Linux Client, Android Client
22

33
**Status:** In Progress
4-
**Last Updated:** 2026-04-06 (session 23)
4+
**Last Updated:** 2026-04-06 (session 24)
55

66
This is a living document. Update the status column and notes as work progresses.
77

@@ -267,7 +267,7 @@ Build the server binary on top of `bonded-core`.
267267
| 2.14 | Rust-only localhost E2E HTTP diagnostic harness | completed | Added ignored/manual `bonded-server` integration test that boots `run_server` on localhost, resolves `example.com`, drives synthetic IPv4 TCP handshake + HTTP GET over packet relay, and asserts a valid HTTP status line in returned payload |
268268
| 2.15 | Rust-only localhost E2E SMTP diagnostic harness | completed | Added ignored/manual `bonded-server` integration test that boots `run_server` on localhost, resolves `smtp.gmail.com:587`, drives synthetic IPv4 TCP handshake, sends SMTP `EHLO` and `QUIT`, and asserts SMTP reply codes in returned payload |
269269
| 2.16 | UDP flow sessions with idle timeout and async return path | completed | Replaced one-shot UDP forwarding with per-session flow table keyed by 4-tuple; each flow now uses a persistent connected ephemeral UDP socket, stays alive for 4 minutes since last client packet, and forwards all upstream UDP packets back to the client asynchronously while active |
270-
| 2.17 | Status webpage endpoint for live connection state | completed | Added dedicated status listener (`status_bind`, env override `BONDED_STATUS_BIND`) that serves a live HTML page showing authenticated sessions, active UDP/TCP flows, and recent ICMP probe outcomes for runtime diagnostics |
270+
| 2.17 | Status webpage endpoint for live connection state | completed | Added dedicated status listener (`status_bind`, env override `BONDED_STATUS_BIND`) with a live HTML dashboard and `/api/status` JSON endpoint; the page now polls REST data every 2s instead of full-page refreshes, while showing authenticated sessions, active UDP/TCP flows, and recent ICMP probe outcomes |
271271

272272
Acceptance gate:
273273

0 commit comments

Comments
 (0)