aboutsummaryrefslogtreecommitdiffstats
path: root/src/api
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-09 02:24:51 +0000
committertv <tv@krebsco.de>2026-11-09 02:24:51 +0000
commit8a6ed7cc711e797542a531091b3bdbc73e5f61e4 (patch)
treea599731bdab3b3cbe07a8bf1d42887da907a2ea7 /src/api
parent764d3e07400854b333583b2efaa1c9eae18ae5e1 (diff)
dashboard: keys edited, the last removed with a password, failures kept
The credentials dialog listed a user's keys whole, to add one or remove one but the last, and any failure replaced the dialog with the error, the form lost. * A key is shown short: its type, the first and last four characters of what tells it apart (an Ed25519 key's base64 after the part all of them share), its comment; the whole line in its tooltip. * A key can be edited, its name and its line: saved under the name, a new name written first and the old one removed. * A user with a password on this node (`--htpasswd`) may remove its last key: it logs in by the password. GET /auth/keys says so (`password`), and DELETE /auth/keys/{id} takes it then; the rules no longer keep the last key on every node (no other node can tell who has a password where; under a strict policy the signed entry keeps one still). * What fails says why beside where it was asked (the add form, the edited row, the removed one), which stays as it was, to be set right and tried again.
Diffstat (limited to 'src/api')
-rw-r--r--src/api/auth.rs7
-rw-r--r--src/api/dashboard/dashboard.html225
-rw-r--r--src/api/mod.rs7
-rw-r--r--src/api/users.rs26
4 files changed, 207 insertions, 58 deletions
diff --git a/src/api/auth.rs b/src/api/auth.rs
index a5a987c..ac70b82 100644
--- a/src/api/auth.rs
+++ b/src/api/auth.rs
@@ -154,6 +154,13 @@ impl Range {
}
impl AuthState {
+ /// Whether `user` has a password on this node (`--htpasswd`): it logs in by it.
+ pub(crate) fn has_password(&self, user: &str) -> bool {
+ self.users
+ .as_ref()
+ .is_some_and(|users| users.contains_key(user))
+ }
+
/// From the options; None if there is neither an htpasswd file nor a user header
/// (no users: no HTTP API, for a relay).
pub(crate) fn from_settings(
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index c4e6276..0be3526 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -258,6 +258,22 @@
#dialog table { border-collapse: collapse; width: 100%; }
#dialog td { padding: 2px 6px 2px 0; vertical-align: top; }
#dialog td.key { font-family: var(--mono, monospace); word-break: break-all; color: var(--muted); }
+ /* The keys as wide as the dialog lets them, never wider: the name and the actions
+ their widths, the key the rest, cut in the middle (`keyLine`) as much as it must. */
+ #dialog table.keys { table-layout: fixed; }
+ #dialog table.keys col.name { width: 7em; }
+ #dialog table.keys col.actions { width: 4em; }
+ #dialog table.keys td.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ #dialog table.keys td.actions { text-align: right; padding-right: 0; }
+ /* Being edited: its fields and buttons across the row. */
+ #dialog .key-edit { display: flex; gap: 6px; align-items: baseline; margin: 2px 0; }
+ #dialog .key-edit .key-name { width: 7em; flex: none; }
+ #dialog .key-edit .key-value { flex: 1; min-width: 0; }
+ #dialog table.keys td.key { word-break: normal; overflow: hidden; }
+ .key-line { display: flex; min-width: 0; }
+ .key-line .start { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ min-width: 0; }
+ .key-line .end { flex: none; white-space: pre; }
#dialog form { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
#dialog input { font: inherit; }
#dialog input[name="line"] { flex: 1; min-width: 16em; }
@@ -281,8 +297,9 @@
.editor input[type=text], .editor textarea { width: 100%; font: inherit; color: var(--fg);
background: var(--bg); border: 1px solid var(--line); border-radius: 4px; padding: 2px 4px; }
.editor button { font: inherit; }
- .editor button.delete { color: #fff; background: var(--bad); border: 1px solid var(--bad);
- border-radius: 3px; }
+ .editor button.delete, #dialog button.delete { color: #fff; background: var(--bad);
+ border: 1px solid var(--bad); border-radius: 3px; }
+ #dialog button.delete:disabled { opacity: .5; }
.tree details.editing > :not(summary):not(.editor) { display: none; }
.tree .leaf[title] { cursor: text; }
table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
@@ -454,13 +471,19 @@ const handOver = (() => {
// The nodes whose own links this page reads from them (`follow`), kept in the browser,
// and how each goes: declared before anything draws.
const SUBSCRIPTIONS = "continuum-subscriptions";
+// Opened from another node's page (`handOver`), that node is subscribed to too: its
+// links and messages were that page's own, and are another's here.
const subscriptions = (() => {
+ let kept = new Set();
try {
- const kept = JSON.parse(localStorage.getItem(SUBSCRIPTIONS) || "[]");
- return new Set(Array.isArray(kept) ? kept.filter(id => typeof id === "string") : []);
- } catch (_) {
- return new Set();
+ const listed = JSON.parse(localStorage.getItem(SUBSCRIPTIONS) || "[]");
+ kept = new Set(Array.isArray(listed) ? listed.filter(id => typeof id === "string") : []);
+ } catch (_) { /* none kept */ }
+ if (handOver && typeof handOver.from === "string" && !kept.has(handOver.from)) {
+ kept.add(handOver.from);
+ try { localStorage.setItem(SUBSCRIPTIONS, JSON.stringify([...kept])); } catch (_) {}
}
+ return kept;
})();
// Each subscription followed: `status` (`connecting`, `live`, `unreachable`), `why`
// not, its latest `report` and when it came (`at`), and how to stop it (`stop`).
@@ -1635,6 +1658,31 @@ async function readStore(path, shallow) {
const EMPTY = { map: "{}", list: "[]", text: "\"\"" };
const EMPTY_VALUE = { map: () => ({}), list: () => [], text: () => "" };
+// A red `Delete` that acts on a second click, from half a second to 3 s after the first
+// (`Delete: sure?`): a double-click doesn't delete. `act()`: whether it did; if not, the
+// button is `Delete` again.
+function deleteButton(act) {
+ const button = el("button", { type: "button", class: "delete" }, "Delete");
+ let sure = null, askedAt = 0;
+ button.addEventListener("click", async () => {
+ if (sure === null) {
+ button.textContent = "Delete: sure?";
+ askedAt = performance.now();
+ sure = setTimeout(() => { sure = null; button.textContent = "Delete"; }, 3000);
+ return;
+ }
+ if (performance.now() - askedAt < 500) return;
+ clearTimeout(sure);
+ button.disabled = true;
+ if (!(await act())) {
+ sure = null;
+ button.textContent = "Delete";
+ button.disabled = false;
+ }
+ });
+ return button;
+}
+
// --- Editing: `Edit value` in an entry's menu turns it into a field holding its contents;
// saved with PUT /store/<path> (the whole value, objects too) ---
// A string is edited as text (sent as text/plain), anything else as JSON; "JSON"
@@ -1701,21 +1749,9 @@ function editor(path, value, done, options = {}) {
};
save.addEventListener("click", submit);
cancel.addEventListener("click", () => done(false));
- // Editing what's there: it can be deleted too (DELETE /store/<path>), after a second
- // click to be sure, not too soon after the first: a double-click doesn't delete.
- const remove = options.extra ? null : el("button", { type: "button", class: "delete" },
- "Delete");
- let sure = null, askedAt = 0;
- remove?.addEventListener("click", async () => {
- if (sure === null) {
- remove.textContent = "Delete: sure?";
- askedAt = performance.now();
- sure = setTimeout(() => { sure = null; remove.textContent = "Delete"; }, 3000);
- return;
- }
- if (performance.now() - askedAt < 500) return;
- clearTimeout(sure);
- remove.disabled = save.disabled = true;
+ // Editing what's there: it can be deleted too (DELETE /store/<path>, `deleteButton`).
+ const remove = options.extra ? null : deleteButton(async () => {
+ save.disabled = true;
message.className = "note";
message.textContent = "Deleting…";
try {
@@ -1726,11 +1762,11 @@ function editor(path, value, done, options = {}) {
throw new Error("HTTP " + response.status + (why ? ": " + why : ""));
}
done("deleted", response);
+ return true;
} catch (error) {
- sure = null;
- remove.textContent = "Delete";
- remove.disabled = save.disabled = false;
+ save.disabled = false;
fail("Not deleted: " + error.message);
+ return false;
}
});
// Enter in the short field saves; in the area, Ctrl or ⌘ and Enter does.
@@ -2291,6 +2327,8 @@ function handOff(id, handed) {
pinned: [...pinned],
view: { ...view },
focused: focused === id ? null : focused,
+ // The node this page is of: the next page subscribes to it.
+ from: state ? state.node.id : undefined,
settings,
legend: !document.getElementById("legends").hidden,
figures: [...open],
@@ -2592,8 +2630,10 @@ async function logOut() {
location.assign("../login");
}
}
-// The user's credentials: its keys, added and removed (`/auth/keys`), but the last;
-// under a strict policy, how to sign them anew; where its password is.
+// The user's credentials: its keys (`/auth/keys`): each edited (its name, its line),
+// removed (its last only where it has a password on this node, which it logs in by
+// then), or one added; under a strict policy, how to sign them anew; where its password
+// is. What fails says so where it was asked, which stays as it was, to be set right.
async function credentials() {
const password = el("p", {}, "Your password is in each node's password file " +
"(--htpasswd): its operator changes it there.");
@@ -2607,49 +2647,134 @@ async function credentials() {
return;
}
const keys = Object.entries(answer.keys || {});
- const rows = keys.map(([id, line]) => {
- const remove = el("button", { type: "button" }, "remove");
- if (answer.signed || keys.length < 2) {
- remove.disabled = true;
- remove.title = answer.signed ? "signed: see below" : "your last key: add another first";
- }
- remove.addEventListener("click", () => changeKey(id, null));
- return el("tr", {}, el("td", {}, id), el("td", { class: "key" }, line), el("td", {}, remove));
- });
+ const signed = !!answer.signed;
+ // The columns' widths set once, whatever its rows (one edited spans them all).
+ const table = el("table", { class: "keys" }, el("colgroup", {},
+ el("col", { class: "name" }), el("col", {}), el("col", { class: "actions" })));
+ const row = (id, line) => {
+ const tr = el("tr", {});
+ // Its name, as much of the key as fits, and `edit`, at the right.
+ const shown = () => {
+ const edit = el("button", { type: "button" }, "edit");
+ if (signed) {
+ edit.disabled = true;
+ edit.title = "signed: see below";
+ }
+ edit.addEventListener("click", editing);
+ tr.replaceChildren(el("td", { class: "name", title: id }, id),
+ el("td", { class: "key", title: line }, keyLine(line)),
+ el("td", { class: "actions" }, edit));
+ };
+ // Its name and line in fields, across the row; saved under the name (a new name:
+ // written under it, then the old one removed), or deleted (`deleteButton`; its last
+ // only where the user has a password on this node).
+ const editing = () => {
+ const name = el("input", { size: 12, spellcheck: "false", class: "key-name" });
+ name.value = id;
+ const value = el("input", { spellcheck: "false", class: "key-value" });
+ value.value = line;
+ const save = el("button", { type: "button" }, "save");
+ const cancel = el("button", { type: "button" }, "cancel");
+ const failed = el("span", { class: "error" });
+ const remove = deleteButton(async () => {
+ save.disabled = true;
+ const why = await keyRequest(id, null);
+ if (why === null) { credentials(); return true; }
+ save.disabled = false;
+ failed.textContent = "Not deleted: " + why;
+ return false;
+ });
+ if (keys.length < 2 && !answer.password) {
+ remove.disabled = true;
+ remove.title = "your last key, and you have no password on this node: add another first";
+ }
+ const submit = async () => {
+ const to = name.value.trim(), text = value.value.trim();
+ if (to === "" || text === "") { failed.textContent = "A name and a key, please."; return; }
+ if (to !== id && keys.some(([other]) => other === to)) {
+ failed.textContent = "\"" + to + "\" is a key of yours already."; return;
+ }
+ save.disabled = true;
+ failed.textContent = "";
+ let why = await keyRequest(to, text);
+ if (why === null && to !== id) {
+ const old = await keyRequest(id, null);
+ if (old !== null) why = "saved as \"" + to + "\", but \"" + id + "\" not removed: " + old;
+ }
+ if (why === null) credentials();
+ else { save.disabled = false; failed.textContent = "Not saved: " + why; }
+ };
+ save.addEventListener("click", submit);
+ cancel.addEventListener("click", shown);
+ for (const field of [name, value]) {
+ field.addEventListener("keydown", event => {
+ if (event.key === "Enter") { event.preventDefault(); submit(); }
+ else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); shown(); }
+ });
+ }
+ tr.replaceChildren(el("td", { colspan: "3" },
+ el("div", { class: "key-edit" }, name, value),
+ el("div", { class: "key-edit" }, save, cancel, remove, failed)));
+ value.focus();
+ };
+ shown();
+ return tr;
+ };
+ table.append(...keys.map(([id, line]) => row(id, line)));
const parts = [password, el("h3", {}, "Your keys"),
- keys.length ? el("table", {}, ...rows) : el("p", { class: "note" }, "none")];
- if (answer.signed) {
+ keys.length ? table : el("p", { class: "note" }, "none")];
+ if (signed) {
parts.push(el("p", {}, "This swarm has its users sign their entries: change your " +
"keys by signing yours anew, with a key you hold: continuum-signed key-add NAME " +
"FILE.pub, continuum-signed key-remove NAME."));
} else {
+ const failed = el("p", { class: "error" });
const form = el("form", {},
el("input", { name: "id", placeholder: "name (laptop)", required: "", size: 12 }),
el("input", { name: "line", placeholder: "ssh-ed25519 AAAA… (its .pub)", required: "",
spellcheck: "false" }),
el("button", {}, "add"));
- form.addEventListener("submit", event => {
+ form.addEventListener("submit", async event => {
event.preventDefault();
- changeKey(form.elements.id.value.trim(), form.elements.line.value.trim());
+ const id = form.elements.id.value.trim(), line = form.elements.line.value.trim();
+ if (keys.some(([other]) => other === id)) {
+ failed.textContent = "\"" + id + "\" is a key of yours already: edit it instead.";
+ return;
+ }
+ failed.textContent = "";
+ const why = await keyRequest(id, line);
+ if (why === null) credentials();
+ else failed.textContent = "Not added: " + why;
});
- parts.push(form);
+ parts.push(form, failed);
}
say("Credentials", ...parts);
}
-// Key `id` of the user's set to `line`, or removed (`line` null); the dialog anew.
-async function changeKey(id, line) {
+// A key as much of it as fits: its type and what tells it apart (an Ed25519 key's
+// base64 after the part every one of them shares), cut in the middle where it doesn't
+// fit, its last eight characters and its comment always shown.
+const ED25519_COMMON = "AAAAC3NzaC1lZDI1NTE5AAAAI";
+function keyLine(line) {
+ const [type = "", data = "", ...comment] = line.trim().split(/\s+/);
+ const own = data.startsWith(ED25519_COMMON) ? data.slice(ED25519_COMMON.length) : data;
+ const cut = Math.max(0, own.length - 8);
+ return el("span", { class: "key-line" },
+ el("span", { class: "start" }, type + " " + own.slice(0, cut)),
+ el("span", { class: "end" }, own.slice(cut) + (comment.length ? " " + comment.join(" ") : "")));
+}
+// Key `id` of the user's set to `line`, or removed (`line` null): null if done, else why
+// not.
+async function keyRequest(id, line) {
const init = line === null ? { method: "DELETE" }
: { method: "PUT", headers: { "Content-Type": "text/plain" }, body: line };
- const response = await fetch("../auth/keys/" + encodeURIComponent(id), withToken(init))
- .catch(error => ({ ok: false, statusText: error.message, json: async () => ({}) }));
- if (!response.ok) {
+ try {
+ const response = await fetch("../auth/keys/" + encodeURIComponent(id), withToken(init));
+ if (response.ok) return null;
const answer = await response.json().catch(() => ({}));
- say("Credentials", el("p", { class: "error" },
- (line === null ? "Couldn't remove " : "Couldn't add ") + id + ": " +
- (answer.error || response.status + " " + response.statusText)));
- return;
+ return answer.error || response.status + " " + response.statusText;
+ } catch (error) {
+ return error.message;
}
- credentials();
}
async function obtainTokens() {
clearTimeout(tokenTimer);
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 0dde808..5c7c8e7 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -114,7 +114,12 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) ->
state.clone(),
access::authorize,
))
- .layer(middleware::from_fn_with_state(auth_state, basic_auth))
+ .layer(middleware::from_fn_with_state(
+ auth_state.clone(),
+ basic_auth,
+ ))
+ // Who has a password here (`/auth/keys`: a user's last key goes only then).
+ .layer(axum::Extension(auth_state))
// Outside authentication: a token, if the request brings one, names the user.
.layer(middleware::from_fn_with_state(state.clone(), login::bearer))
// Outermost: pages of other origins, and their preflights, which bring no
diff --git a/src/api/users.rs b/src/api/users.rs
index 1dda33b..d6950af 100644
--- a/src/api/users.rs
+++ b/src/api/users.rs
@@ -24,8 +24,10 @@
//! * `GET /auth/keys`: the caller's keys, its entry, and the envelope holding it under a
//! strict policy (`signed`), to sign the next one against.
//! * `PUT /auth/keys/{id}`: one added or replaced (the line as the body);
-//! `DELETE /auth/keys/{id}` removes one. Under a strict policy `409`: the user signs
-//! its entry with the keys changed, nothing else (`continuum-signed key-add`).
+//! `DELETE /auth/keys/{id}` removes one, the last only of a user with a password on
+//! this node (`--htpasswd`, `password` in `GET /auth/keys`), which logs in by it. Under
+//! a strict policy `409`: the user signs its entry with the keys changed, nothing else
+//! (`continuum-signed key-add`).
//!
//! Looking needs `read` on `_continuum/users`, changing `write` on it (in a swarm
//! without a registry, being in `--protocol-writers`). Every change is one change to
@@ -142,6 +144,7 @@ fn shown(doc: &impl crate::document::ReadDoc, name: &str) -> Option<JsonValue> {
pub(crate) async fn own_keys(
State(state): State<AppState>,
Extension(user): Extension<AuthenticatedUser>,
+ Extension(auth): Extension<super::auth::AuthState>,
) -> Result<Json<JsonValue>, ApiError> {
let replica = state.doc.read().await;
let path = registry::user_path(&user.0);
@@ -157,6 +160,7 @@ pub(crate) async fn own_keys(
"keys": entry.get("keys").cloned().unwrap_or_else(|| json!({})),
"entry": entry,
"signed": signed,
+ "password": auth.has_password(&user.0),
})))
}
@@ -168,24 +172,28 @@ pub(crate) async fn put_own_key(
body: String,
) -> Result<StatusCode, ApiError> {
let key = crate::access::sshsig::PublicKey::parse(body.trim()).map_err(|why| bad(&why))?;
- own_key(&state, &user.0, id, Some(key.line())).await
+ own_key(&state, &user.0, id, Some(key.line()), false).await
}
/// `DELETE /auth/keys/{id}`.
pub(crate) async fn delete_own_key(
State(state): State<AppState>,
Extension(user): Extension<AuthenticatedUser>,
+ Extension(auth): Extension<super::auth::AuthState>,
Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
- own_key(&state, &user.0, id, None).await
+ let password = auth.has_password(&user.0);
+ own_key(&state, &user.0, id, None, password).await
}
-/// `user`'s key `id` set to `line`, or removed: one change, as `user`.
+/// `user`'s key `id` set to `line`, or removed: one change, as `user`. Its last key only
+/// with `password` (it has one here, and logs in by it).
async fn own_key(
state: &AppState,
user: &str,
id: String,
line: Option<String>,
+ password: bool,
) -> Result<StatusCode, ApiError> {
if !registry::valid_name(&id) {
return Err(bad("not a key's name: 1 to 64 of A-Z a-z 0-9 . _ @ -"));
@@ -216,7 +224,7 @@ async fn own_key(
Some("no entry of yours".to_owned()),
));
}
- if line.is_none() {
+ if line.is_none() && !password {
let mut keys = entry.clone();
keys.push("keys".to_owned());
let others = store::keys_at(&*doc, &keys)?
@@ -227,7 +235,11 @@ async fn own_key(
if others == 0 {
return Err(ApiError(
StatusCode::CONFLICT,
- Some("your last key: add another first".to_owned()),
+ Some(
+ "your last key, and you have no password on this node: add \
+ another first"
+ .to_owned(),
+ ),
));
}
}