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