Browse Source
- Reuse the Diagnostics Update badge without adding a separate deployment screen - Show Updating while the host replaces the container and reload after a healthy restart - Bridge authenticated admin requests to an explicitly configured Unix socket - Keep updates disabled without the socket or when admin authentication is disabled - Preserve error feedback and reconnect status polling across temporary outagespull/7743/head
7 changed files with 185 additions and 0 deletions
@ -0,0 +1,56 @@ |
|||
//! Admin-authenticated bridge to the host updater. Docker access stays outside Vaultwarden.
|
|||
use reqwest::Method; |
|||
use rocket::serde::json::Json; |
|||
use serde_json::Value; |
|||
|
|||
use super::AdminToken; |
|||
use crate::{CONFIG, api::JsonResult}; |
|||
|
|||
#[get("/updates/status")] |
|||
pub(super) async fn update_status(_token: AdminToken) -> JsonResult { |
|||
updater_request(Method::GET, "/status", None).await |
|||
} |
|||
|
|||
#[post("/updates/start", format = "json")] |
|||
pub(super) async fn start_update(_token: AdminToken) -> JsonResult { |
|||
updater_request(Method::POST, "/update", Some(json!({}))).await |
|||
} |
|||
|
|||
async fn updater_request(method: Method, path: &str, body: Option<Value>) -> JsonResult { |
|||
if CONFIG.disable_admin_token() { |
|||
err_code!("Docker updates require admin authentication.", 403); |
|||
} |
|||
let Some(socket) = CONFIG.updater_socket() else { |
|||
err_code!("The host updater has not been configured.", 503); |
|||
}; |
|||
send_request(socket, method, path, body).await |
|||
} |
|||
|
|||
#[cfg(unix)] |
|||
async fn send_request(socket: String, method: Method, path: &str, body: Option<Value>) -> JsonResult { |
|||
let client = reqwest::Client::builder() |
|||
.unix_socket(socket) |
|||
.no_proxy() |
|||
.redirect(reqwest::redirect::Policy::none()) |
|||
.timeout(std::time::Duration::from_secs(10)) |
|||
.build()?; |
|||
let mut request = client.request(method, format!("http://localhost{path}")); |
|||
if let Some(body) = body { |
|||
request = request.json(&body); |
|||
} |
|||
let Ok(response) = request.send().await else { |
|||
err_code!("Cannot reach the host updater. Check its service and socket permissions.", 503); |
|||
}; |
|||
let status = response.status(); |
|||
let data: Value = response.json().await?; |
|||
if !status.is_success() { |
|||
let message = data.get("error").and_then(Value::as_str).unwrap_or("The host updater rejected the request."); |
|||
err_code!(message, status.as_u16()); |
|||
} |
|||
Ok(Json(data)) |
|||
} |
|||
|
|||
#[cfg(not(unix))] |
|||
async fn send_request(_socket: String, _method: Method, _path: &str, _body: Option<Value>) -> JsonResult { |
|||
err_code!("The host updater requires a Unix socket.", 503); |
|||
} |
|||
@ -0,0 +1,103 @@ |
|||
"use strict"; |
|||
/* global BASE_URL */ |
|||
|
|||
document.addEventListener("DOMContentLoaded", () => { |
|||
const button = document.getElementById("server-warning"); |
|||
const feedback = document.getElementById("server-update-status"); |
|||
const pollingInterval = 3000; |
|||
let timer = null; |
|||
let stopped = false; |
|||
let updating = false; |
|||
let pending = false; |
|||
let unavailableSince = null; |
|||
// Keep the existing version badge usable for the configured image channel.
|
|||
button.classList.remove("d-none"); |
|||
|
|||
async function request(path, method = "GET") { |
|||
const response = await fetch(`${BASE_URL}/admin/updates/${path}`, { |
|||
method, |
|||
headers: { "Accept": "application/json", "Content-Type": "application/json" }, |
|||
cache: "no-store", |
|||
signal: AbortSignal.timeout(15000), |
|||
}); |
|||
if (response.status === 401) { |
|||
stopped = true; |
|||
throw new Error("Sign in again to check the update result."); |
|||
} |
|||
if (!response.ok) { |
|||
throw new Error("Cannot reach the updater. Retrying automatically…"); |
|||
} |
|||
return await response.json(); |
|||
} |
|||
|
|||
function scheduleRefresh() { |
|||
clearTimeout(timer); |
|||
if (!stopped) { |
|||
timer = setTimeout(refresh, pollingInterval); |
|||
} |
|||
} |
|||
|
|||
async function refresh() { |
|||
if (pending || stopped) { |
|||
return; |
|||
} |
|||
pending = true; |
|||
try { |
|||
const state = await request("status"); |
|||
unavailableSince = null; |
|||
button.disabled = state.busy || state.recovery_required; |
|||
button.textContent = state.busy ? "Updating…" : "Update"; |
|||
if (state.busy) { |
|||
updating = true; |
|||
feedback.textContent = ""; |
|||
} else if (state.recovery_required) { |
|||
feedback.textContent = "Update needs attention on the server. Contact the administrator."; |
|||
} else if (updating && state.result === "updated") { |
|||
stopped = true; |
|||
window.location.reload(); |
|||
} else { |
|||
updating = false; |
|||
feedback.textContent = state.result === "updated" ? "" : state.message; |
|||
} |
|||
} catch (error) { |
|||
button.disabled = true; |
|||
button.textContent = updating ? "Updating…" : "Update"; |
|||
// A short outage is expected while the container restarts.
|
|||
if (unavailableSince === null) { |
|||
unavailableSince = Date.now(); |
|||
} |
|||
const expectedRestart = updating && !stopped && Date.now() - unavailableSince < 60000; |
|||
feedback.textContent = expectedRestart ? "" : error.message; |
|||
} finally { |
|||
pending = false; |
|||
scheduleRefresh(); |
|||
} |
|||
} |
|||
|
|||
button.addEventListener("click", async () => { |
|||
if (pending || button.disabled) { |
|||
return; |
|||
} |
|||
clearTimeout(timer); |
|||
pending = true; |
|||
updating = true; |
|||
button.disabled = true; |
|||
button.textContent = "Updating…"; |
|||
feedback.textContent = ""; |
|||
try { |
|||
await request("start", "POST"); |
|||
} catch (error) { |
|||
// Do not retry a mutation: it may have been accepted before the connection dropped.
|
|||
feedback.textContent = error.message; |
|||
} finally { |
|||
pending = false; |
|||
scheduleRefresh(); |
|||
} |
|||
}); |
|||
window.addEventListener("pagehide", () => { |
|||
stopped = true; |
|||
clearTimeout(timer); |
|||
}); |
|||
// This runner owns error reporting and polling.
|
|||
refresh(); |
|||
}); |
|||
Loading…
Reference in new issue