aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-09 05:22:14 +0000
committertv <tv@krebsco.de>2026-11-09 06:30:44 +0100
commitb2c5096744c99453856f7281251b3f0cb4b9baae (patch)
treecf1ff79be48513eda4b901c7197b452c557547d7
parentd7e3d9f8310aa40903f43272fbb30f37a1f68c2b (diff)
dashboard: a subscribed node's message written out, read from it when shown
A subscribed node's messages come without being written out (their stream leaves the text out: up to 8 KiB each, 500 at a time), so the figures box said only when, both ends, type and size of one. Now the node answers for one message by its number, `GET /admin/dashboard/messages/own/{seq}` (`{"text": …}`, `404` once it isn't kept), which pages of other origins may call as they do its streams (`cors::open`), and the page reads it when the message is shown, with its narrow token, and keeps it on the message. Where it can't be read, the box says why.
-rw-r--r--docs/access.md3
-rw-r--r--docs/dashboard.md5
-rw-r--r--docs/openapi.yaml27
-rw-r--r--src/api/cors.rs25
-rw-r--r--src/api/dashboard/dashboard.html35
-rw-r--r--src/api/dashboard/mod.rs15
-rw-r--r--src/transport/traffic.rs10
7 files changed, 102 insertions, 18 deletions
diff --git a/docs/access.md b/docs/access.md
index a4c5375..38ae180 100644
--- a/docs/access.md
+++ b/docs/access.md
@@ -172,7 +172,8 @@ it may do.
Pages of other origins, the dashboards of other members, may call a node with a token:
its own links (`/admin/dashboard/links/own`, and its events) and messages
-(`/admin/dashboard/messages/own/events`), `/auth/refresh` and `/auth/whoami`, nothing
+(`/admin/dashboard/messages/own/events`, and one written out,
+`/admin/dashboard/messages/own/{seq}`), `/auth/refresh` and `/auth/whoami`, nothing
else. A node allows the origins of the dashboards its member
list names (`--dashboard-url`) and those of `--cors-origins`; without credentials (no
cookie or password crosses origins: the page sends its token).
diff --git a/docs/dashboard.md b/docs/dashboard.md
index 5eee5c9..590f72f 100644
--- a/docs/dashboard.md
+++ b/docs/dashboard.md
@@ -399,8 +399,9 @@ A subscribed node's messages with its peers are listed too, as they come from it
named by both ends, the receiver first (`b ← a`), each once, as on the map (those
with this node only from this node, between two subscribed nodes from the sender); its
tooltip names the node that logged it. Shown in the figures box, it says when, both
-ends, its type and size: the message itself is written out only on the node that
-logged it.
+ends, its type, size and address, and the message written out, read from the node that
+logged it as it is shown (`/admin/dashboard/messages/own/{seq}` there, with the page's
+token), while that node still keeps it (its latest 1000).
A click on a message shows it in the figures box: when, with whom, its type and size,
and the message written out (its bytes as sizes). A click on it again, or on anything on
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 0fad136..03afa2f 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -1293,6 +1293,33 @@ paths:
'401':
$ref: '#/components/responses/Unauthorized'
+ /admin/dashboard/messages/own/{seq}:
+ get:
+ tags: [Admin]
+ summary: One of this node's messages, written out
+ description: |
+ The message `seq` (its number in `messages/own/events`) written out, as this
+ node's own console shows it, for a page subscribed to this node showing it (CORS,
+ with a token). Kept while the message log is: the latest 1000, while one looks.
+ parameters:
+ - name: seq
+ in: path
+ required: true
+ schema: { type: integer }
+ responses:
+ '200':
+ description: The message written out.
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ text: { type: string }
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ description: Not kept any more.
+
/admin/dashboard/refresh:
post:
tags: [Admin]
diff --git a/src/api/cors.rs b/src/api/cors.rs
index 406b7be..fc8270d 100644
--- a/src/api/cors.rs
+++ b/src/api/cors.rs
@@ -1,6 +1,6 @@
//! Pages of other origins reading this node: the dashboards of other members, which a
//! page subscribes to for their own links and messages (`/admin/dashboard/links/own`,
-//! `/admin/dashboard/messages/own/events`), refreshing its
+//! `/admin/dashboard/messages/own/events`, and one written out), refreshing its
//! token on any node (`/auth/refresh`, `/auth/whoami`). Only those endpoints, only for
//! the origins of the dashboards the member list names and those of `--cors-origins`,
//! and without credentials: a page sends its token itself (`Authorization: Bearer`),
@@ -18,14 +18,19 @@ use crate::swarm::members;
/// The endpoints other origins may call.
fn open(path: &str) -> bool {
- matches!(
- path,
- "/admin/dashboard/links/own"
- | "/admin/dashboard/links/own/events"
- | "/admin/dashboard/messages/own/events"
- | "/auth/refresh"
- | "/auth/whoami"
- )
+ // A message written out (`/admin/dashboard/messages/own/{seq}`).
+ let message = path
+ .strip_prefix("/admin/dashboard/messages/own/")
+ .is_some_and(|seq| !seq.is_empty() && seq.bytes().all(|byte| byte.is_ascii_digit()));
+ message
+ || matches!(
+ path,
+ "/admin/dashboard/links/own"
+ | "/admin/dashboard/links/own/events"
+ | "/admin/dashboard/messages/own/events"
+ | "/auth/refresh"
+ | "/auth/whoami"
+ )
}
/// `url`'s origin (`scheme://host[:port]`), if it is a URL with one.
@@ -138,6 +143,8 @@ mod tests {
#[test]
fn only_some_endpoints() {
assert!(open("/admin/dashboard/links/own"));
+ assert!(open("/admin/dashboard/messages/own/42"));
+ assert!(!open("/admin/dashboard/messages/own/x"));
assert!(open("/auth/refresh"));
assert!(!open("/auth/login"));
assert!(!open("/store"));
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index 9cfb384..6d32d55 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -876,8 +876,9 @@ function renderStats() {
el("span", { class: "key" }, key + ": "), value);
title.replaceChildren("Message: ", what);
retitle(title, title.textContent);
- // A subscribed node's: logged there, the message itself not read from it.
+ // A subscribed node's: logged there, written out as read from it.
if (message.node) {
+ const text = el("pre", { class: "message" }, "reading it from " + name(message.node) + "…");
document.getElementById("stats").replaceChildren(
field("at", clockTime(new Date(message.at))),
field("from", message.from),
@@ -886,7 +887,12 @@ function renderStats() {
...(message.address ? [field("address", message.address)] : []),
field("type", message.type),
field("bytes", String(message.bytes)),
- el("div", { class: "note" }, "Written out only on the node that logged it (its console)."));
+ text);
+ writtenOut(message).then(written => { text.textContent = written; },
+ error => {
+ text.replaceWith(el("div", { class: "note" }, "Not read from " + name(message.node) +
+ ": " + (error.message || String(error))));
+ });
return;
}
document.getElementById("stats").replaceChildren(
@@ -2842,9 +2848,7 @@ function follow(id, delay = 2000) {
const member = state && state.graph.members[id];
if (!member || !member.dashboard) return fail("it publishes no dashboard (--dashboard-url)");
if (!narrowToken) return fail(tokenWhy);
- // Its endpoints, beside its page (`…/admin/dashboard` → `…/admin/dashboard/…`).
- const base = member.dashboard.split("#")[0].replace(/\/+$/, "");
- const at = path => new URL(path, base).href;
+ const at = path => endpointOf(id, path);
let url;
try {
url = at("dashboard/links/own/events");
@@ -2881,6 +2885,25 @@ function follow(id, delay = 2000) {
};
followMessages();
}
+// A member's endpoint, beside the page it publishes (`…/admin/dashboard` →
+// `…/admin/dashboard/<path>`); throws where its address isn't one.
+function endpointOf(id, path) {
+ const member = state && state.graph.members[id];
+ const base = ((member && member.dashboard) || "").split("#")[0].replace(/\/+$/, "");
+ return new URL(path, base).href;
+}
+// A subscribed node's message written out, read from it (its console's
+// `messages/own/{seq}`) with this page's narrow token, kept on the message once read.
+async function writtenOut(message) {
+ if (typeof message.text === "string") return message.text;
+ if (!narrowToken) throw new Error(tokenWhy);
+ const response = await fetch(endpointOf(message.node, "dashboard/messages/own/" + message.ownSeq),
+ { cache: "no-store", headers: { Authorization: "Bearer " + narrowToken.token } });
+ const answer = await response.json().catch(() => ({}));
+ if (!response.ok) throw new Error(response.status + " " + (answer.error || response.statusText));
+ message.text = String(answer.text);
+ return message.text;
+}
// Server-sent events from `url`, read with this page's narrow token (`EventSource`
// can't send one): `opened` once answered, `event(kind, data)` for each; rejects when it
// fails or ends (`offline`: the node stopped).
@@ -3581,7 +3604,7 @@ function remoteMessagesArrived(id, entry, list, sent) {
for (const message of list) {
if (message.way === "refused") continue;
const [from, to] = message.way === "sent" ? [id, message.peer] : [message.peer, id];
- const made = { ...message, seq: id + ":" + message.seq, node: id, from, to,
+ const made = { ...message, seq: id + ":" + message.seq, ownSeq: message.seq, node: id, from, to,
related: message.related ? id + ":" + message.related : null };
if (message.way === "sent") noteDelivery(from, to, message.counted, "sent");
else noteDelivery(from, to, message.counted, "got");
diff --git a/src/api/dashboard/mod.rs b/src/api/dashboard/mod.rs
index 7a643d0..3a50ca6 100644
--- a/src/api/dashboard/mod.rs
+++ b/src/api/dashboard/mod.rs
@@ -509,6 +509,7 @@ pub(crate) fn router(state: AppState) -> Router<AppState> {
"/admin/dashboard/messages/own/events",
get(own_messages_events),
)
+ .route("/admin/dashboard/messages/own/{seq}", get(own_message))
.layer(Extension(Hub::new(state, closing)))
}
@@ -573,6 +574,20 @@ impl Drop for Watching {
}
}
+/// `GET /admin/dashboard/messages/own/{seq}`: the message `seq` of this node's
+/// (`messages/own/events`, which leaves it out) written out, `{"text": …}`, for a page
+/// subscribed to this node showing it; `404` once it isn't kept any more (the latest
+/// 1000, while someone looks).
+async fn own_message(
+ Extension(hub): Extension<Arc<Hub>>,
+ axum::extract::Path(seq): axum::extract::Path<u64>,
+) -> axum::response::Response {
+ match hub.state.traffic.log.text_of(seq) {
+ Some(text) => axum::Json(json!({ "text": text })).into_response(),
+ None => super::error_response(axum::http::StatusCode::NOT_FOUND, "not kept any more", None),
+ }
+}
+
/// `GET /admin/dashboard/messages/own/events`: server-sent events, `messages` with the
/// messages this node sent and received since the last, as they come (gathered for
/// `MESSAGES_GATHERED` after the first), as the page's own `messages` events have them,
diff --git a/src/transport/traffic.rs b/src/transport/traffic.rs
index bac0da7..5ff588e 100644
--- a/src/transport/traffic.rs
+++ b/src/transport/traffic.rs
@@ -325,6 +325,16 @@ impl MessageLog {
}
/// The messages after `seq`, the latest `most` of them.
+ /// The message `seq` written out, while it is kept.
+ pub(crate) fn text_of(&self, seq: u64) -> Option<String> {
+ let inner = self.inner.lock_or_recover();
+ inner
+ .entries
+ .iter()
+ .find(|entry| entry.seq == seq)
+ .map(|entry| entry.text.clone())
+ }
+
pub(crate) fn after(&self, seq: u64, most: usize) -> Vec<Logged> {
let inner = self.inner.lock_or_recover();
let newer: Vec<&Logged> = inner.entries.iter().filter(|e| e.seq > seq).collect();