Browse Source

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
pull/7743/head
Buseong Kim 1 week ago
parent
commit
fcdf79205c
  1. 5
      .env.template
  2. 5
      src/api/admin.rs
  3. 56
      src/api/admin/updates.rs
  4. 1
      src/api/web.rs
  5. 4
      src/config.rs
  6. 103
      src/static/scripts/admin_updates.js
  7. 11
      src/static/templates/admin/diagnostics.hbs

5
.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 ###
#########################

5
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<Route> {
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(),

56
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<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);
}

1
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")))

4
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;

103
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();
});

11
src/static/templates/admin/diagnostics.hbs

@ -8,7 +8,11 @@
<dl class="row">
<dt class="col-sm-5">Server Installed
<span class="badge bg-success d-none abbr-badge" id="server-success" title="Latest version is installed.">Ok</span>
{{#if page_data.updater_enabled}}
<button type="button" class="badge bg-warning text-dark border-0 d-none" id="server-warning" title="Update Vaultwarden" disabled>Update</button>
{{else}}
<span class="badge bg-warning text-dark d-none abbr-badge" id="server-warning" title="An update is available.">Update</span>
{{/if}}
<span class="badge bg-info text-dark d-none abbr-badge" id="server-branch" title="This is a branched version.">Branched</span>
</dt>
<dd class="col-sm-7">
@ -50,6 +54,10 @@
</div>
</div>
{{#if page_data.updater_enabled}}
<p id="server-update-status" role="status" aria-live="polite"></p>
{{/if}}
<h3>Checks</h3>
<div class="row">
<div class="col-md">
@ -254,4 +262,7 @@
</div>
</main>
<script src="{{urlpath}}/vw_static/admin_diagnostics.js"></script>
{{#if page_data.updater_enabled}}
<script src="{{urlpath}}/vw_static/admin_updates.js"></script>
{{/if}}
<script type="application/json" id="diagnostics_json">{{to_json page_data}}</script>

Loading…
Cancel
Save