Implement signed API communication to improve security
build / build-linux-amd64 (push) Successful in 1m50s
build / build-linux-amd64 (push) Successful in 1m50s
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,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)),
|
||||
|
||||
Reference in New Issue
Block a user