From fcdf79205c7652e5eac3727cc057c25ce8a38c7d Mon Sep 17 00:00:00 2001 From: Buseong Kim Date: Wed, 16 Sep 2026 11:16:47 +0900 Subject: [PATCH] feat(admin): Make the existing update badge actionable - 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 outages --- .env.template | 5 + src/api/admin.rs | 5 + src/api/admin/updates.rs | 56 +++++++++++ src/api/web.rs | 1 + src/config.rs | 4 + src/static/scripts/admin_updates.js | 103 +++++++++++++++++++++ src/static/templates/admin/diagnostics.hbs | 11 +++ 7 files changed, 185 insertions(+) create mode 100644 src/api/admin/updates.rs create mode 100644 src/static/scripts/admin_updates.js diff --git a/.env.template b/.env.template index d22145b8..62129139 100644 --- a/.env.template +++ b/.env.template @@ -45,6 +45,11 @@ # WEB_VAULT_FOLDER=web-vault/ # WEB_VAULT_ENABLED=true +## Optional Docker updater. See docker/updater/README.md for host setup. +## Mount only the updater socket directory into Vaultwarden, never the Docker socket. +## Disabled unless configured; DISABLE_ADMIN_TOKEN=true is not supported. +# UPDATER_SOCKET=/run/vaultwarden-updater/updater.sock + ######################### ### Database settings ### ######################### diff --git a/src/api/admin.rs b/src/api/admin.rs index 4bdf8e71..00a5a385 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -12,6 +12,8 @@ use rocket::{ use serde::de::DeserializeOwned; use serde_json::Value; +mod updates; + use crate::{ CONFIG, VERSION, api::{ @@ -71,6 +73,8 @@ pub fn routes() -> Vec { get_diagnostics_config, resend_user_invite, get_diagnostics_http, + updates::update_status, + updates::start_update, ] } @@ -781,6 +785,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A let diagnostics_json = json!({ "dns_resolved": dns_resolved, "current_release": VERSION, + "updater_enabled": CONFIG.updater_socket().is_some() && !CONFIG.disable_admin_token(), "latest_release": latest_vw_release, "latest_commit": latest_vw_commit, "web_vault_enabled": &CONFIG.web_vault_enabled(), diff --git a/src/api/admin/updates.rs b/src/api/admin/updates.rs new file mode 100644 index 00000000..6e9d1624 --- /dev/null +++ b/src/api/admin/updates.rs @@ -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) -> 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) -> 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) -> JsonResult { + err_code!("The host updater requires a Unix socket.", 503); +} diff --git a/src/api/web.rs b/src/api/web.rs index d6d8d62c..59b5ffe7 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -289,6 +289,7 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro "admin.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/admin.css"))), "admin.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin.js"))), "admin_settings.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_settings.js"))), + "admin_updates.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_updates.js"))), "admin_users.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_users.js"))), "admin_organizations.js" => { Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_organizations.js"))) diff --git a/src/config.rs b/src/config.rs index 37fc3e85..f1097c0e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -524,6 +524,10 @@ make_config! { /// Enable websocket notifications enable_websocket: bool, false, def, true; }, + updates { + /// Updater socket |> Unix socket of the optional host Docker updater. Requires admin authentication. + updater_socket: String, false, option; + }, push { /// Enable push notifications push_enabled: bool, false, def, false; diff --git a/src/static/scripts/admin_updates.js b/src/static/scripts/admin_updates.js new file mode 100644 index 00000000..27fff80f --- /dev/null +++ b/src/static/scripts/admin_updates.js @@ -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(); +}); diff --git a/src/static/templates/admin/diagnostics.hbs b/src/static/templates/admin/diagnostics.hbs index 0c889353..c15529e0 100644 --- a/src/static/templates/admin/diagnostics.hbs +++ b/src/static/templates/admin/diagnostics.hbs @@ -8,7 +8,11 @@
Server Installed Ok + {{#if page_data.updater_enabled}} + + {{else}} Update + {{/if}} Branched
@@ -50,6 +54,10 @@ + {{#if page_data.updater_enabled}} +

+ {{/if}} +

Checks

@@ -254,4 +262,7 @@
+{{#if page_data.updater_enabled}} + +{{/if}}