2026-04-22 11:03:47 -04:00
//! Bootstrap host-side artifacts on every archipelago startup.
2026-04-22 08:29:56 -04:00
//!
//! The update pipeline swaps the archipelago binary but does not touch
2026-04-22 11:03:47 -04:00
//! 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.
2026-04-22 08:29:56 -04:00
//!
2026-04-22 11:03:47 -04:00
//! 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.
2026-04-22 08:29:56 -04:00
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" ;
2026-04-22 11:03:47 -04:00
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 " ;
2026-04-22 08:29:56 -04:00
/// Entry point called from main startup. Never returns an error to the caller —
2026-04-22 11:03:47 -04:00
/// failing to bootstrap host artifacts must not prevent the backend from serving.
2026-04-22 08:29:56 -04:00
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 ),
}
2026-04-22 11:03:47 -04:00
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 ),
}
2026-04-22 08:29:56 -04:00
}
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 )
}
2026-04-22 11:03:47 -04:00
/// 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 )
}