261 lines
11 KiB
Rust
261 lines
11 KiB
Rust
//! Bootstrap host-side artifacts on every archipelago startup.
|
|
//!
|
|
//! The update pipeline swaps the archipelago binary but does not touch
|
|
//! scripts, systemd units, or nginx configuration — those are installed
|
|
//! once by the ISO builder. Without this module, changes to
|
|
//! `container-doctor.sh`, the doctor service/timer, or the nginx config
|
|
//! never reach boxes installed before the change.
|
|
//!
|
|
//! Two things are synced on startup:
|
|
//! 1. Doctor artifacts (container-doctor.sh + service + timer).
|
|
//! 2. An nginx `location /api/app-catalog` proxy block — required for
|
|
//! the App Store catalog proxy to actually reach the backend.
|
|
//!
|
|
//! Idempotent: no-ops on boxes that are already in sync. All work is
|
|
//! best-effort — failures are logged but never abort the backend.
|
|
|
|
use anyhow::{Context, Result};
|
|
use std::path::Path;
|
|
use tokio::fs;
|
|
use tracing::{debug, info, warn};
|
|
|
|
use crate::update::host_sudo;
|
|
|
|
const DOCTOR_SH: &str = include_str!("../../../scripts/container-doctor.sh");
|
|
const DOCTOR_SERVICE: &str =
|
|
include_str!("../../../image-recipe/configs/archipelago-doctor.service");
|
|
const DOCTOR_TIMER: &str = include_str!("../../../image-recipe/configs/archipelago-doctor.timer");
|
|
|
|
const DOCTOR_SH_PATH: &str = "/home/archipelago/archy/scripts/container-doctor.sh";
|
|
const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.service";
|
|
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
|
|
|
|
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
|
|
|
|
/// Inserted into every server block of the nginx config that lacks the
|
|
/// `/api/app-catalog` proxy. Kept in sync with the canonical block in
|
|
/// image-recipe/configs/nginx-archipelago.conf.
|
|
const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backend fetches from configured registries\n # so the browser doesn't hit CORS/CSP. Without this block nginx falls\n # through to the SPA index.html and the frontend gets HTML back instead\n # of JSON.\n location /api/app-catalog {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 15s;\n proxy_read_timeout 30s;\n proxy_send_timeout 15s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n\n";
|
|
|
|
/// Entry point called from main startup. Never returns an error to the caller —
|
|
/// failing to bootstrap host artifacts must not prevent the backend from serving.
|
|
pub async fn ensure_doctor_installed() {
|
|
match run().await {
|
|
Ok(changed) if changed => info!("Doctor artifacts synchronized with binary"),
|
|
Ok(_) => debug!("Doctor artifacts already in sync"),
|
|
Err(e) => warn!("Doctor bootstrap failed (non-fatal): {:#}", e),
|
|
}
|
|
match run_nginx().await {
|
|
Ok(true) => info!("Patched nginx config to proxy /api/app-catalog"),
|
|
Ok(false) => debug!("Nginx already has /api/app-catalog block"),
|
|
Err(e) => warn!("Nginx bootstrap failed (non-fatal): {:#}", e),
|
|
}
|
|
}
|
|
|
|
async fn run() -> Result<bool> {
|
|
// Dev-box guard: on contributors' laptops `/home/archipelago/archy` is
|
|
// typically a symlink into the git checkout, and writing through it
|
|
// would clobber the working tree with whatever the binary happens to
|
|
// have been compiled from. Production ISO installs materialize a real
|
|
// directory.
|
|
let home_archy = Path::new("/home/archipelago/archy");
|
|
if fs::symlink_metadata(home_archy)
|
|
.await
|
|
.map(|m| m.file_type().is_symlink())
|
|
.unwrap_or(false)
|
|
{
|
|
debug!("/home/archipelago/archy is a symlink — skipping doctor bootstrap (dev box)");
|
|
return Ok(false);
|
|
}
|
|
|
|
// Skip entirely on machines without the canonical scripts directory —
|
|
// writing orphan files there just causes confusion.
|
|
let scripts_dir = Path::new(DOCTOR_SH_PATH)
|
|
.parent()
|
|
.context("doctor script path has no parent")?;
|
|
if !scripts_dir.exists() {
|
|
debug!(
|
|
"Scripts dir {} missing — skipping doctor bootstrap",
|
|
scripts_dir.display()
|
|
);
|
|
return Ok(false);
|
|
}
|
|
|
|
let mut changed = false;
|
|
|
|
// 1. Script — lives in archipelago's home dir, user-writable.
|
|
if needs_write(DOCTOR_SH_PATH, DOCTOR_SH).await {
|
|
fs::write(DOCTOR_SH_PATH, DOCTOR_SH)
|
|
.await
|
|
.with_context(|| format!("write {}", DOCTOR_SH_PATH))?;
|
|
let _ = tokio::process::Command::new("chmod")
|
|
.args(["+x", DOCTOR_SH_PATH])
|
|
.status()
|
|
.await;
|
|
info!("Updated {}", DOCTOR_SH_PATH);
|
|
changed = true;
|
|
}
|
|
|
|
// 2. Systemd unit files — /etc is restricted; route through host_sudo.
|
|
let service_changed = write_root_if_needed(DOCTOR_SERVICE_PATH, DOCTOR_SERVICE).await?;
|
|
let timer_changed = write_root_if_needed(DOCTOR_TIMER_PATH, DOCTOR_TIMER).await?;
|
|
changed = changed || service_changed || timer_changed;
|
|
|
|
// 3. Reload + enable. Only when we actually touched units, or when the
|
|
// timer isn't enabled yet (catches fresh upgrades of boxes that predate
|
|
// the doctor entirely).
|
|
let timer_enabled = is_timer_enabled().await;
|
|
if service_changed || timer_changed || !timer_enabled {
|
|
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
|
|
warn!("daemon-reload failed: {:#}", e);
|
|
}
|
|
if let Err(e) =
|
|
host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"]).await
|
|
{
|
|
warn!("enable archipelago-doctor.timer failed: {:#}", e);
|
|
} else if !timer_enabled {
|
|
info!("Enabled archipelago-doctor.timer");
|
|
}
|
|
}
|
|
|
|
Ok(changed)
|
|
}
|
|
|
|
async fn needs_write(path: &str, expected: &str) -> bool {
|
|
match fs::read_to_string(path).await {
|
|
Ok(current) => current != expected,
|
|
Err(_) => true,
|
|
}
|
|
}
|
|
|
|
/// Write content to a root-owned path via `sudo mv` of a user-owned tmp file.
|
|
/// Returns true if a write happened.
|
|
async fn write_root_if_needed(path: &str, content: &str) -> Result<bool> {
|
|
if !needs_write(path, content).await {
|
|
return Ok(false);
|
|
}
|
|
let tmp = format!(
|
|
"/tmp/archipelago-bootstrap-{}-{}.tmp",
|
|
std::process::id(),
|
|
Path::new(path)
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("unit")
|
|
);
|
|
fs::write(&tmp, content)
|
|
.await
|
|
.with_context(|| format!("write tmp {}", tmp))?;
|
|
let status = host_sudo(&["mv", &tmp, path])
|
|
.await
|
|
.with_context(|| format!("sudo mv {} -> {}", tmp, path))?;
|
|
if !status.success() {
|
|
let _ = fs::remove_file(&tmp).await;
|
|
anyhow::bail!("sudo mv to {} exited with {}", path, status);
|
|
}
|
|
info!("Updated {}", path);
|
|
Ok(true)
|
|
}
|
|
|
|
async fn is_timer_enabled() -> bool {
|
|
tokio::process::Command::new("systemctl")
|
|
.args(["is-enabled", "--quiet", "archipelago-doctor.timer"])
|
|
.status()
|
|
.await
|
|
.map(|s| s.success())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Patch the nginx site config to add a `/api/app-catalog` proxy block if
|
|
/// it's missing. The original ISO shipped individual per-endpoint `location`
|
|
/// blocks and no catch-all `/api/`, so `/api/app-catalog` silently fell
|
|
/// through to the SPA `index.html` and the frontend got HTML instead of
|
|
/// JSON. We anchor the insert to the DWN comment that already sits right
|
|
/// after the `/api/blob` block, so the new block lands in both the HTTP
|
|
/// and HTTPS server blocks.
|
|
///
|
|
/// Validates via `nginx -t` before reloading. On failure the patch is
|
|
/// rolled back from a backup written just before the write.
|
|
async fn run_nginx() -> Result<bool> {
|
|
// Skip on dev symlinks — we don't want to touch `/etc/nginx` on laptops.
|
|
let home_archy = Path::new("/home/archipelago/archy");
|
|
if fs::symlink_metadata(home_archy)
|
|
.await
|
|
.map(|m| m.file_type().is_symlink())
|
|
.unwrap_or(false)
|
|
{
|
|
return Ok(false);
|
|
}
|
|
|
|
if !Path::new(NGINX_CONF_PATH).exists() {
|
|
debug!("{} missing — skipping nginx bootstrap", NGINX_CONF_PATH);
|
|
return Ok(false);
|
|
}
|
|
|
|
let content = fs::read_to_string(NGINX_CONF_PATH)
|
|
.await
|
|
.with_context(|| format!("read {}", NGINX_CONF_PATH))?;
|
|
if content.contains("location /api/app-catalog") {
|
|
return Ok(false);
|
|
}
|
|
|
|
// The DWN comment sits at the same indent right after the `/api/blob`
|
|
// block in both server blocks — a stable anchor that existed on every
|
|
// ISO shipped to date. If it's absent (config got heavily customized),
|
|
// we bail rather than guess where to splice.
|
|
let anchor = " # DWN endpoints — peer access over Tor (no auth)";
|
|
if !content.contains(anchor) {
|
|
warn!("nginx conf missing DWN anchor — skipping /api/app-catalog patch");
|
|
return Ok(false);
|
|
}
|
|
|
|
let replacement = format!("{}{}", NGINX_APP_CATALOG_BLOCK, anchor);
|
|
let patched = content.replace(anchor, &replacement);
|
|
|
|
// Write patched config via a user-owned tmp + sudo mv, after stashing
|
|
// a backup so we can revert if `nginx -t` hates what we produced.
|
|
let pid = std::process::id();
|
|
let tmp = format!("/tmp/archipelago-nginx-{}.conf", pid);
|
|
fs::write(&tmp, &patched)
|
|
.await
|
|
.with_context(|| format!("write {}", tmp))?;
|
|
|
|
let backup = format!("/tmp/archipelago-nginx-backup-{}.conf", pid);
|
|
if let Err(e) = host_sudo(&["cp", NGINX_CONF_PATH, &backup]).await {
|
|
let _ = fs::remove_file(&tmp).await;
|
|
return Err(e.context("backup nginx conf"));
|
|
}
|
|
|
|
let mv = host_sudo(&["mv", &tmp, NGINX_CONF_PATH]).await;
|
|
match mv {
|
|
Ok(s) if s.success() => {}
|
|
Ok(s) => {
|
|
let _ = fs::remove_file(&tmp).await;
|
|
anyhow::bail!("sudo mv nginx conf exited with {}", s);
|
|
}
|
|
Err(e) => {
|
|
let _ = fs::remove_file(&tmp).await;
|
|
return Err(e.context("mv tmp -> nginx conf"));
|
|
}
|
|
}
|
|
|
|
// Validate.
|
|
let test = host_sudo(&["nginx", "-t"]).await;
|
|
let valid = matches!(&test, Ok(s) if s.success());
|
|
if !valid {
|
|
warn!("nginx -t failed after patch — reverting");
|
|
let _ = host_sudo(&["mv", &backup, NGINX_CONF_PATH]).await;
|
|
if let Err(e) = test {
|
|
return Err(e.context("nginx -t"));
|
|
}
|
|
anyhow::bail!("nginx config invalid after patch — reverted");
|
|
}
|
|
|
|
// Reload nginx so the new block takes effect immediately. Reload (not
|
|
// restart) keeps in-flight connections alive.
|
|
if let Err(e) = host_sudo(&["systemctl", "reload", "nginx"]).await {
|
|
warn!("nginx reload failed (non-fatal): {:#}", e);
|
|
}
|
|
let _ = host_sudo(&["rm", "-f", &backup]).await;
|
|
Ok(true)
|
|
}
|