Implement signed API communication to improve security
build / build-linux-amd64 (push) Successful in 1m54s

This commit is contained in:
2026-05-22 12:50:42 +02:00
parent 21b25bcc1b
commit 26908c51bb
11 changed files with 835 additions and 14 deletions
+109 -1
View File
@@ -109,6 +109,56 @@ pub async fn delete(
notice_then_table(&state, lang, if ok { "ok" } else { "error" }, &msg).await
}
/// Flip `peer.managed` between 0 and 1. Same effect as calling the JSON
/// API `PUT /api/peers/:id/managed`, but rendered as an HTMX action so the
/// table refreshes in place. The handler reads the current value, flips
/// it, and writes back — this avoids a stale-toggle race where the row
/// the admin clicked on showed a stale state (e.g. TOFU just promoted it
/// in the background) and a "set to N" command would no-op silently.
pub async fn toggle_managed(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let row = state
.db
.peer_get_auth(&peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let (_pk, was_managed) = match row {
Some(r) => r,
None => {
return notice_then_table(
&state,
lang,
"error",
&tf1(lang, "devices.managed_no_peer", &peer_id),
)
.await;
}
};
let new_value = !was_managed;
state
.db
.peer_set_managed(&peer_id, new_value)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
hbb_common::log::info!(
"admin {} set peer {} managed={} via dashboard",
admin.name,
peer_id,
new_value
);
let key = if new_value {
"devices.managed_now_on"
} else {
"devices.managed_now_off"
};
notice_then_table(&state, lang, "ok", &tf1(lang, key, &peer_id)).await
}
/// Per-device detail page: hardware / OS inventory reported by hello-agent
/// alongside the standard sysinfo (CPU/RAM/OS/hostname). Replaces the
/// devices list in `#devices-region` via HTMX; a "Back to devices" button
@@ -189,6 +239,7 @@ async fn render_table(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiEr
<th class="text-left font-medium px-3 py-2">{c_ver}</th>
<th class="text-left font-medium px-3 py-2">{c_last}</th>
<th class="text-left font-medium px-3 py-2">{c_conns}</th>
<th class="text-left font-medium px-3 py-2">{c_auth}</th>
<th class="text-right font-medium px-3 py-2 w-1">{c_actions}</th>
</tr>
</thead>
@@ -202,12 +253,13 @@ async fn render_table(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiEr
c_ver = t(lang, "devices.col_version"),
c_last = t(lang, "devices.col_last_heartbeat"),
c_conns = t(lang, "devices.col_conns"),
c_auth = t(lang, "devices.col_auth"),
c_actions = t(lang, "common.actions"),
);
if devices.is_empty() {
let _ = write!(
s,
r##"<tr><td colspan="10" class="px-3 py-4 text-slate-500 text-center text-xs">{}</td></tr>"##,
r##"<tr><td colspan="11" class="px-3 py-4 text-slate-500 text-center text-xs">{}</td></tr>"##,
t(lang, "devices.no_devices"),
);
}
@@ -332,6 +384,57 @@ fn render_device_row(
dot = dot_class,
id = html_escape(&d.id),
);
// Auth badge: `Signed` (emerald) when peer.managed=1 — heartbeat /
// sysinfo posts must carry a valid Ed25519 signature; `—` (slate) when
// managed=0 and the device still posts unsigned bodies. The tooltip
// gives the operator the one-line explanation so they know what
// flipping the flag will do.
let auth_cell = if d.managed {
format!(
r##"<td class="px-3 py-2 whitespace-nowrap">
<span class="inline-flex items-center gap-1 rounded border border-emerald-700/50 bg-emerald-900/30 px-2 py-0.5 text-xs text-emerald-300" title="{tt}">{label}</span>
</td>"##,
tt = html_escape(t(lang, "devices.auth_signed_tooltip")),
label = t(lang, "devices.auth_signed"),
)
} else {
format!(
r##"<td class="px-3 py-2 whitespace-nowrap">
<span class="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-800/40 px-2 py-0.5 text-xs text-slate-400" title="{tt}">{label}</span>
</td>"##,
tt = html_escape(t(lang, "devices.auth_unsigned_tooltip")),
label = t(lang, "devices.auth_unsigned"),
)
};
// Auth toggle: the menu entry's label flips based on current state,
// and only the off→on transition needs no confirm (it strengthens
// security). on→off removes the signature requirement and reintroduces
// the spoofing surface, so we require a confirm on that direction.
let toggle_managed_item = if d.managed {
format!(
r##"<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/toggle-managed"
hx-target="#devices-region" hx-swap="innerHTML"
hx-confirm="{confirm}">
{label}
</button>"##,
id = html_escape(&d.id),
confirm = html_escape(&tf1(lang, "devices.confirm_managed_off", &d.id)),
label = t(lang, "devices.mark_unsigned"),
)
} else {
format!(
r##"<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/toggle-managed"
hx-target="#devices-region" hx-swap="innerHTML">
{label}
</button>"##,
id = html_escape(&d.id),
label = t(lang, "devices.mark_managed"),
)
};
let _ = write!(
s,
r##"<tr class="hover:bg-slate-800/40">
@@ -344,6 +447,7 @@ fn render_device_row(
<td class="px-3 py-2 text-slate-400 whitespace-nowrap">{ver}</td>
<td class="px-3 py-2 text-slate-500 text-xs">{last}</td>
<td class="px-3 py-2 text-slate-400">{n}</td>
{auth_cell}
<td class="px-3 py-2">
<details class="text-right relative">
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
@@ -358,6 +462,8 @@ fn render_device_row(
{details}
</button>
<hr class="border-slate-700 my-1" />
{toggle_managed_item}
<hr class="border-slate-700 my-1" />
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/disconnect"
hx-target="#devices-region" hx-swap="innerHTML"
@@ -394,6 +500,8 @@ fn render_device_row(
ver = html_escape(&version_label),
last = html_escape(&d.last_heartbeat_at),
n = conn_count,
auth_cell = auth_cell,
toggle_managed_item = toggle_managed_item,
connect_web = t(lang, "devices.connect_web"),
details = t(lang, "devices.details"),
confirm_disc = html_escape(&tf1(lang, "devices.confirm_disconnect", &d.id)),