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

This commit is contained in:
2026-05-22 12:50:42 +02:00
parent 21b25bcc1b
commit 475da0e950
12 changed files with 906 additions and 24 deletions
+77
View File
@@ -801,6 +801,83 @@ pub fn t(lang: Lang, key: &str) -> &'static str {
"Dispozitivul a fost deja șters.",
"El dispositivo ya se eliminó.",
),
"devices.col_auth" => (
"Auth",
"Auth",
"Auth",
"Auth",
"Auth",
),
"devices.auth_signed" => (
"Signed",
"Signiert",
"Signé",
"Semnat",
"Firmado",
),
"devices.auth_signed_tooltip" => (
"Heartbeat and sysinfo posts must carry a valid Ed25519 signature. Unsigned requests for this peer are rejected.",
"Heartbeat- und Sysinfo-Posts müssen eine gültige Ed25519-Signatur enthalten. Unsignierte Anfragen für dieses Gerät werden abgelehnt.",
"Les requêtes heartbeat et sysinfo doivent porter une signature Ed25519 valide. Les requêtes non signées pour ce pair sont rejetées.",
"Cererile heartbeat și sysinfo trebuie să poarte o semnătură Ed25519 validă. Cererile nesemnate pentru acest peer sunt respinse.",
"Las solicitudes heartbeat y sysinfo deben llevar una firma Ed25519 válida. Las solicitudes sin firma para este par se rechazan.",
),
"devices.auth_unsigned" => (
"Unsigned",
"Unsigniert",
"Non signé",
"Nesemnat",
"Sin firma",
),
"devices.auth_unsigned_tooltip" => (
"Legacy / stock-client path. The server accepts unsigned heartbeat and sysinfo posts identifying as this peer — any caller knowing the id+uuid could inject inventory. The first valid signature flips this to Signed automatically.",
"Legacy-/Stock-Client-Pfad. Der Server akzeptiert unsignierte Heartbeat- und Sysinfo-Posts mit dieser Peer-Identität — jeder Aufrufer, der id+uuid kennt, kann Inventar einschleusen. Die erste gültige Signatur schaltet automatisch auf Signiert.",
"Chemin client hérité / standard. Le serveur accepte des requêtes heartbeat et sysinfo non signées identifiées comme ce pair — tout appelant connaissant id+uuid peut injecter de l'inventaire. La première signature valide bascule automatiquement sur Signé.",
"Calea client legacy / standard. Serverul acceptă cereri heartbeat și sysinfo nesemnate care se identifică drept acest peer — orice apelant care cunoaște id+uuid poate injecta inventar. Prima semnătură validă comută automat la Semnat.",
"Ruta cliente heredada / estándar. El servidor acepta solicitudes heartbeat y sysinfo sin firmar identificadas como este par — cualquier llamador que conozca id+uuid puede inyectar inventario. La primera firma válida cambia automáticamente a Firmado.",
),
"devices.mark_managed" => (
"Require signed API",
"Signierte API erzwingen",
"Exiger l'API signée",
"Cere API semnat",
"Requerir API firmada",
),
"devices.mark_unsigned" => (
"Allow unsigned API",
"Unsignierte API erlauben",
"Autoriser l'API non signée",
"Permite API nesemnat",
"Permitir API sin firma",
),
"devices.confirm_managed_off" => (
"Downgrade {0} to the unsigned API path? Anyone who knows the id+uuid will again be able to post inventory and heartbeats as this device.",
"{0} auf den unsignierten API-Pfad herabstufen? Jeder, der id+uuid kennt, kann dann wieder Inventar und Heartbeats als dieses Gerät posten.",
"Rétrograder {0} vers le chemin API non signé ? Toute personne connaissant id+uuid pourra de nouveau publier de l'inventaire et des heartbeats en tant que cet appareil.",
"Retrogradați {0} la calea API nesemnat? Oricine cunoaște id+uuid va putea din nou să publice inventar și heartbeat-uri ca acest dispozitiv.",
"¿Degradar {0} a la ruta API sin firma? Cualquiera que conozca id+uuid podrá volver a publicar inventario y heartbeats como este dispositivo.",
),
"devices.managed_now_on" => (
"{0} now requires signed API requests.",
"{0} erfordert nun signierte API-Anfragen.",
"{0} exige désormais des requêtes API signées.",
"{0} cere acum cereri API semnate.",
"{0} ahora requiere solicitudes API firmadas.",
),
"devices.managed_now_off" => (
"{0} now accepts unsigned API requests.",
"{0} akzeptiert nun unsignierte API-Anfragen.",
"{0} accepte désormais des requêtes API non signées.",
"{0} acceptă acum cereri API nesemnate.",
"{0} ahora acepta solicitudes API sin firma.",
),
"devices.managed_no_peer" => (
"Peer {0} not found in the rendezvous identity table — cannot set managed flag. The agent must complete a rendezvous handshake first.",
"Peer {0} nicht in der Rendezvous-Identitätstabelle gefunden — Managed-Flag kann nicht gesetzt werden. Der Agent muss zuerst einen Rendezvous-Handshake abschließen.",
"Pair {0} introuvable dans la table d'identité rendezvous — impossible de définir l'indicateur managed. L'agent doit d'abord effectuer un handshake rendezvous.",
"Peer-ul {0} nu a fost găsit în tabelul de identitate rendezvous — nu se poate seta indicatorul managed. Agentul trebuie să finalizeze mai întâi un handshake rendezvous.",
"Par {0} no encontrado en la tabla de identidad rendezvous — no se puede establecer el indicador managed. El agente debe completar primero un apretón de manos rendezvous.",
),
"devices.back" => (
"← Back to devices",
"← Zurück zu Geräten",
+4
View File
@@ -113,6 +113,10 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
"/admin/pages/devices/:peer_id/delete",
post(pages::devices::delete),
)
.route(
"/admin/pages/devices/:peer_id/toggle-managed",
post(pages::devices::toggle_managed),
)
// Groups
.route("/admin/pages/groups/create", post(pages::groups::create))
.route("/admin/pages/groups/:id/delete", post(pages::groups::delete))
+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)),