//! The HTTP surface. Everything except the bootstrap route below is served //! over the pinned-TLS listener, because every one of these either is, or //! decides, the bytes that get handed to `REQUEST_INSTALL_PACKAGES` next. //! //! ```text //! GET /manifest[?recheck=false] every app, its newest build, its variants //! GET /apps/{key} one of them, for a card that just //! acted and wants only itself back //! GET /suggestions projects found under the configured roots //! POST /apps {path} add the project at that path //! DELETE /apps/{key} remove it again //! PUT /roots {roots} set the directories /suggestions scans //! GET /apps/{key}/apk[?variant=] the payload itself (ranged) //! POST /apps/{key}/pull fetch, fast-forward, and build //! ?force=true resets onto the upstream //! POST /apps/{key}/prepare run the on-demand build step, if any //! ?component= restricts it to one //! POST /apps/{key}/build run it whether or not it looks stale //! ?component= restricts it to one //! POST /apps/{key}/approve accept the build step it asks for //! POST /apps/{key}/recheck ask that one project's remote and //! services again //! PUT /apps/{key}/settings {gitIpv4} //! this machine's preferences for it //! GET /apps/{key}/components/{name}/logs[?lines=&generation=] //! what that component wrote //! POST /apps/{key}/components/{name}/{action} //! install|uninstall|start|stop|restart //! a server component, on the *build //! machine* //! GET /apps/{key}/status poll that build //! GET /self is there a newer build of the app //! itself -- the frozen rescue contract //! GET /self/apk that build's bytes (ranged) //! ``` //! //! # The rescue contract //! //! `/self` and `/self/apk` are how the app updates *itself*, and they are //! deliberately the smallest thing that can do it. Everything else here //! is free to change shape; these two are not. //! //! The reason is that every other route is reachable only by an app new //! enough to understand it. When this server changes what `/manifest` //! says, an older app can fail to read the list -- and the list is where //! the button that would replace that app lives. The way out is then a //! reinstall over the plain-HTTP bootstrap port, by hand, on the machine. //! So the path that fetches a newer app must not depend on anything this //! server is likely to change: two fields, no nesting, no variants, no //! query parameters. //! //! What it does not survive is a change to the CA, the port, or the //! bearer token, since those break the connection before any route is //! reached. Those remain one-way doors and the bootstrap port remains //! their answer. //! //! **So: add fields here at your peril, and never remove or rename one.** //! Anything richer belongs on a new route that an old app never calls. //! //! Apps are addressed by `{key}` rather than each getting its own route, //! because the set is now mutable at runtime -- there is no fixed table to //! register at startup. use std::path::PathBuf; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::Context; use axum::body::Body; use axum::extract::{Path as UrlPath, Query, State}; use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post, put}; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncReadExt, AsyncSeekExt}; use tokio_util::io::ReaderStream; use crate::build_state::BuildStatus; use crate::discover; use crate::registry::{AppEntry, AppState}; use crate::strip::resolve_serveable_path; pub fn tls_router(state: Arc) -> Router { Router::new() .route("/manifest", get(manifest)) .route("/suggestions", get(suggestions)) .route("/apps", post(add_app)) .route("/apps/{key}", get(app).delete(remove_app)) .route("/apps/{key}/apk", get(serve_apk)) .route("/apps/{key}/pull", post(build_pull)) .route("/apps/{key}/prepare", post(build_prepare)) .route("/apps/{key}/build", post(build_now)) .route("/apps/{key}/approve", post(approve_declaration)) .route("/apps/{key}/recheck", post(recheck_app)) .route("/apps/{key}/settings", put(set_settings)) // Before the {action} route, so `logs` is not swallowed as an // action -- axum matches a literal segment ahead of a capture, // but declaring it first says so to a reader too. .route("/apps/{key}/components/{name}/logs", get(component_logs)) .route( "/apps/{key}/components/{name}/{action}", post(service_action), ) .route("/apps/{key}/status", get(build_status)) // The rescue contract; see this module's own note. `serve_apk` // with no key already means the built-in entry -- the same // handler the bootstrap port uses -- so the download half needs // nothing of its own, including its ranges and the guard that // keeps a restart from cutting it off. .route("/self", get(self_build)) .route("/self/apk", get(serve_apk)) .route("/roots", put(set_roots)) .with_state(state) } /// The one thing a human ever hits on the plain-HTTP bootstrap port: the /// updater app's own APK, at a bare `/`. Deliberately not the whole API -- /// nothing unauthenticated and unpinned should be able to change what this /// server serves. Having no `{key}` in its path is what makes `serve_apk` /// resolve it to the self entry; see `lookup`. pub fn bootstrap_router(state: Arc) -> Router { Router::new().route("/", get(serve_apk)).with_state(state) } #[derive(Debug, thiserror::Error)] enum ApiError { #[error("no app named {0}")] UnknownApp(String), #[error("{0} has no build yet")] NotBuilt(String), /// A project that produces more than one APK, asked for "the" APK. /// Refused rather than answered with the first: which of two clients /// somebody meant is not something to guess, and a guess here would /// install the wrong app while looking like it worked. #[error("{0} builds more than one app -- say which with ?component=")] AmbiguousApk(String), #[error("{0} has no component named {1}")] UnknownComponent(String, String), #[error("{0} has no on-demand build step configured")] NoBuildStep(String), #[error("{0}")] BadRequest(String), #[error("range not satisfiable")] RangeNotSatisfiable, #[error(transparent)] Internal(#[from] anyhow::Error), } impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = match self { Self::UnknownApp(_) | Self::NotBuilt(_) | Self::NoBuildStep(_) | Self::UnknownComponent(..) => StatusCode::NOT_FOUND, Self::AmbiguousApk(_) => StatusCode::BAD_REQUEST, Self::BadRequest(_) => StatusCode::BAD_REQUEST, Self::RangeNotSatisfiable => StatusCode::RANGE_NOT_SATISFIABLE, Self::Internal(err) => { // The only variant whose real cause isn't safe to hand back // verbatim, and the only one worth a log line. tracing::error!("{err:#}"); return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } }; (status, self.to_string()).into_response() } } /// An `anyhow` error from a user-driven action (adding an app, picking a /// variant) is a message written *for* the phone -- "no built APK found /// under ...", "already added" -- not an internal fault, so it comes back /// as a 400 with that message rather than a 500 and a log line. fn bad_request(err: anyhow::Error) -> ApiError { ApiError::BadRequest(format!("{err:#}")) } /// The installable half of a component, absent for a `Server`. /// /// Nested rather than flattened onto the component with every field /// optional, because "this component has an APK" is one fact rather than /// six: a server has no build to install, no package to replace and no /// variants to choose between, and saying that once is what stops the /// phone having to work it out from a size of zero. /// /// Per component and not per project. Two clients built from one checkout /// install over different packages and are worth stripping to different /// degrees, so a project-level answer would be the first component's, /// presented as the project's. #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ManifestApk { /// What the download is saved as on the device. filename: String, /// Absent until a build has been read for it -- a project can be added /// before it has ever been built, and claiming a package before then /// would be inventing one. #[serde(skip_serializing_if = "Option::is_none")] package: Option, /// What this component used to install over, when it has been renamed /// since this server started. /// /// Android treats a renamed `applicationId` as an unrelated app, so /// the old one is still installed and nothing will ever replace it. /// The phone offers to remove it -- and knows whether it is still /// there, which is why nothing here has to be cleared. #[serde(skip_serializing_if = "Option::is_none")] previous_package: Option, /// False when this component has no APK yet (never built, or cleaned). /// It is still drawn -- it was declared deliberately, and a row saying /// so is a better answer than one that silently vanished -- with /// `mtime`/`size` at zero and nothing to download. built: bool, /// Epoch seconds of the raw build's mtime. Always the *raw* build's, /// even when a stripped copy is what's served: that's the number that /// actually moves when something is rebuilt, which is what the app /// compares against the installed copy. mtime: f64, /// Of the file that would be served as things stand -- the slim copy /// where one has already been produced. Close to the bytes about to be /// downloaded rather than exactly them, because finding out exactly /// would mean running the strip pipeline here; see /// `strip::serveable_now`. size: u64, variants: Vec, } /// One component, as the card needs it: what it is called and which kind /// it is. Nothing else -- what it *does* is the build step, which is not /// the phone's business, and how far along it is arrives on the status. #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ManifestComponent { name: String, kind: &'static str, /// For a server component, what its script last said it was doing. /// Absent for an APK, and absent for a server nobody has managed to /// ask yet. #[serde(skip_serializing_if = "Option::is_none")] state: Option, /// Its script is being asked right now, so `state` is the previous /// answer. #[serde(skip_serializing_if = "std::ops::Not::not")] checking: bool, /// Why the last ask produced no answer, when it produced none. The /// script's own words: it knows why it could not say, and this does /// not. #[serde(skip_serializing_if = "Option::is_none")] error: Option, /// There is at least one log of either kind, so the card can show the /// button that opens them. The OR of the two below, sent rather than /// left for the phone to work out because it is the one question the /// card itself asks. has_logs: bool, /// This server wrote a build log for this component. A component that /// has never been built here has none, which is not the same as a /// build that produced no output. has_build_logs: bool, /// This component's script reports a runtime log. False for an APK, /// which does not run here, and false for a script that does not /// implement `logs` -- which every script written before it existed /// does not, and which is a first-class answer rather than a failure. /// The phone shows the tab either way and says which of the two it /// is, since "no log yet" and "this service offers none" are /// different things to be told. has_runtime_logs: bool, /// The last build stopped at this component, so its build log is the /// one worth opening first. False on every component of a build that /// succeeded, which is what makes the runtime log the ordinary /// default. build_failed: bool, /// Whether what is built is current with the checkout. /// /// Sent for every component, including the unknown case, because /// unknown is an answer the card has to draw differently from either /// of the others rather than fall back to. freshness: crate::build_state::Freshness, /// Where Uninstall's "remove data" and "remove config" would delete, /// from what the project said about itself, and whether anything is /// there. /// /// Sent so the dialog can show the path beside each toggle. That /// display is not decoration: a path is removed wherever it points, /// with no containment check, so showing it before the button can be /// pressed is the whole of what stands between an accepted /// declaration and the wrong directory. /// /// `None` has three causes and the phone is told which, because they /// are different things to do about it: still being read /// ([`Self::resources_checking`]), could not be read /// ([`Self::resources_error`]), or the project simply does not say -- /// which is the ordinary case and not a fault. Nothing is filled in /// from the checkout's directory name or the config key; that guess /// is what this replaced. #[serde(skip_serializing_if = "Option::is_none")] data_path: Option, #[serde(skip_serializing_if = "Option::is_none")] config_path: Option, /// Whether that path exists right now. A `stat`, which the manifest /// path can afford where a spawn would not be -- and the answer is /// what greys the toggle out rather than hiding it, so "there is /// nothing to remove" is something the dialog says rather than /// something it leaves the reader to infer from a missing control. data_present: bool, config_present: bool, /// The project's resources are being read right now. resources_checking: bool, /// Why they could not be read. A failed read must not look like a /// project that keeps nothing. #[serde(skip_serializing_if = "Option::is_none")] resources_error: Option, /// What there is to install, for a component that produces an APK. #[serde(skip_serializing_if = "Option::is_none")] apk: Option, } impl ManifestComponent { /// Async only for the APK's size, which is a `stat` of whatever is on /// disk -- never a strip run to find out what the slim copy would /// weigh, because this path is fetched on every open, resume and /// Refresh. async fn read( state: &AppState, key: &str, entry: &AppEntry, component: &crate::config::Component, ) -> Result { let name = component.name().to_string(); let is_server = matches!(component, crate::config::Component::Server { .. }); // A build log is a file this server wrote, so its existence is a // stat rather than a spawn and can be checked here. The runtime // half comes from the cached background answer, because asking a // script costs a process and this path is fetched on every open, // resume and Refresh. let has_build_logs = !crate::logs::build_logs(key, &name).is_empty(); let has_runtime_logs = is_server && !state.service_checks.logs(key, &name).is_empty(); // Only a server keeps anything on this machine, and only a // project that says where. Both halves have to be true before // there is a path to show. let state_paths = is_server .then(|| state.resource_checks.facts(key)) .flatten() .map(|facts| crate::purge::paths(&facts, &entry.project_path)); Ok(Self { kind: if is_server { "server" } else { "apk" }, state: is_server .then(|| state.service_checks.state(key, &name)) .flatten(), // Read after the state, so a check landing between the two // reports its answer rather than that one is still coming. checking: is_server && state.service_checks.is_checking(key, &name), error: is_server .then(|| state.service_checks.error(key, &name)) .flatten(), // Unknown for a project with no build machinery at all: // nothing could have recorded a commit, which is not the same // as being up to date. has_logs: has_build_logs || has_runtime_logs, has_build_logs, has_runtime_logs, build_failed: entry .build .as_ref() .is_some_and(|build| build.build_failed(&name)), freshness: entry .build .as_ref() .map(|build| build.freshness(component)) .unwrap_or(crate::build_state::Freshness::Unknown), data_path: state_paths .as_ref() .and_then(|paths| paths.data.as_deref()) .map(|path| path.display().to_string()), config_path: state_paths .as_ref() .and_then(|paths| paths.config.as_deref()) .map(|path| path.display().to_string()), data_present: state_paths .as_ref() .and_then(|paths| paths.data.as_deref()) .is_some_and(std::path::Path::exists), config_present: state_paths .as_ref() .and_then(|paths| paths.config.as_deref()) .is_some_and(std::path::Path::exists), resources_checking: is_server && state.resource_checks.is_checking(key), resources_error: is_server .then(|| state.resource_checks.error(key)) .flatten(), apk: match is_server { true => None, false => Some(ManifestApk::read(state, key, entry, component).await?), }, name, }) } } impl ManifestApk { async fn read( state: &AppState, key: &str, entry: &AppEntry, component: &crate::config::Component, ) -> Result { let name = component.name(); let newest = entry.resolve_apk(component, None); let size = match &newest { Some(apk) => { tokio::fs::metadata(crate::strip::serveable_now(&apk.path, component.strip())) .await .context("stat the apk to be served")? .len() } None => 0, }; Ok(Self { filename: newest .as_ref() .map(|apk| entry.filename(&apk.path)) .unwrap_or_else(|| format!("{key}-{name}.apk")), package: component.package().map(str::to_string), previous_package: state.previous_package(key, name), built: newest.is_some(), mtime: newest .as_ref() .map(|apk| epoch_secs(apk.modified)) .unwrap_or(0.0), size, variants: entry .variants(component) .into_iter() .map(|candidate| ManifestVariant { path: candidate.path.to_string_lossy().into_owned(), variant: candidate.variant, mtime: epoch_secs(candidate.modified), }) .collect(), }) } } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ManifestResponse { apps: Vec, /// Echoed back so the app's "add" screen can show and edit them without /// a second request. repo_roots: Vec, /// A check is still running somewhere in this list -- a remote, or a /// service being asked what it is doing -- so `newCommits` or a /// component's `state` may change shortly. The phone's cue to look /// again, rather than this response having waited for the answer. checks_pending: bool, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ManifestApp { key: String, label: String, /// Where this project's APKs are fetched from. Which of them is said /// with the request (`?component=`), so this stays one route per /// project rather than one string per component that differs only in /// its query. route: String, project_path: String, needs_build: bool, /// This machine's preferences for the project, so the card's settings /// can show what is currently set rather than a guess at it. git_ipv4: bool, /// True for this server's own app, which has no Remove button. built_in: bool, /// Whether *anything* this project produces has been built. Per /// component is on the component (`ManifestApk::built`); this is what /// the card's own "nothing here yet" line reads, and what keeps a /// project with one built client out of it. built: bool, /// Present when the project is in a git repository at all: the branch, /// how far behind it is as of the last fetch, and whether the tree is /// dirty. Absent for a project nobody keeps in git. #[serde(skip_serializing_if = "Option::is_none")] git: Option, /// Whether this app offers a Pull button (`gitPull`). can_pull: bool, /// Whether the remote has something this checkout doesn't, as of the /// last check. Not a count: answering "how many" needs the objects, /// and downloading them is what Pull is for. new_commits: bool, /// This checkout's remote is being asked right now, so `newCommits` /// above is the previous answer and may change. Per app rather than /// one flag for the list, so a card says whether *it* is the one still /// being worked out. check_pending: bool, /// What this project produces, in the order it is built. One entry is /// the ordinary case and the card shows it inline; more than one is /// what the phone draws as a nested list. components: Vec, /// Why the last check produced no answer, when it produced none. /// Absent in the ordinary case, including before anything has been /// asked. /// /// Sent rather than only logged because `newCommits: false` is /// indistinguishable from "asked, nothing there" on the phone, and the /// phone is where this is being read -- whoever is looking at the card /// has no access to this server's log. #[serde(skip_serializing_if = "Option::is_none")] check_error: Option, /// The build step this project asks for that nobody has accepted yet, /// as RON for the phone to show verbatim -- the same form it was /// written in. Present only while something is waiting to be read, and /// while it is, this app runs no build step at all. /// /// Re-serialized from what would actually be stored rather than passed /// through as the file's own bytes, so what a person reads is what /// would run -- a stray key this server ignores can't sit in the /// display looking meaningful, and neither can a comment claiming /// something the keys don't say. #[serde(skip_serializing_if = "Option::is_none")] pending_declaration: Option, } /// One build found under a project. Which of them a device wants is that /// device's business, so nothing here says which is chosen -- the phone /// marks its own and sends it with the download. #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ManifestVariant { path: String, variant: String, mtime: f64, } fn epoch_secs(time: SystemTime) -> f64 { time.duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs_f64() } #[derive(Deserialize)] struct ManifestQuery { /// Ask the remotes again before answering. On by default, so a plain /// `GET /manifest` -- a person opening, resuming or refreshing the /// list -- gets a fresh answer. The phone passes `false` when it is /// only looking to see whether an outstanding answer has landed; see /// [`refresh_checkouts`]. /// /// That flag is what stops the looking-again from being endless: a /// poll that started a fresh check would find one outstanding every /// time, forever. recheck: Option, } async fn manifest( State(state): State>, Query(query): Query, ) -> Result, ApiError> { if query.recheck.unwrap_or(true) { refresh_checkouts(&state, &state.entries()); } let mut apps = Vec::new(); for entry in state.entries() { apps.push(describe(&state, &entry).await?); } Ok(Json(ManifestResponse { // Derived from the apps rather than tracked alongside them, so the // list-level flag and the per-card ones cannot disagree about // whether anything is still outstanding. // // Services count as well as remotes. This is what the phone looks // again on, and a service whose answer lands after the phone has // stopped looking leaves that component with no state -- which is // drawn as a row with no buttons at all, since which buttons to // offer is exactly what the answer decides. Remote checks // ordinarily outlast service ones and hid this; a list where // nothing can be pulled has no remote check to hide behind. checks_pending: apps.iter().any(|app| { app.check_pending || app .components .iter() .any(|c| c.checking || c.resources_checking) }), apps, repo_roots: state .repo_roots() .iter() .map(|root| crate::config::contract_tilde(root)) .collect(), })) } /// One app, for a phone that acted on one card and wants only that card /// back. /// /// Never rechecks. Asking a remote is what `/apps/{key}/recheck` is for, /// and a card that just had something done to it is asking what happened, /// not for its checkout to be looked at again. async fn app( State(state): State>, UrlPath(key): UrlPath, ) -> Result, ApiError> { let entry = state.entry(&key).ok_or(ApiError::UnknownApp(key))?; Ok(Json(describe(&state, &entry).await?)) } /// One entry as the phone needs it. The only place a card is built, so the /// whole list and a single card cannot come to describe the same app /// differently. async fn describe(state: &Arc, entry: &AppEntry) -> Result { // A loop rather than a map because each component's APK is a `stat`, // and they are described in declaration order -- which is build order, // and the order the card draws them in. let mut components = Vec::with_capacity(entry.components.len()); for component in &entry.components { components.push(ManifestComponent::read(state, &entry.key, entry, component).await?); } let git = crate::git::status(&entry.project_path); // An upstream is part of it: a branch that tracks nothing has nothing // to be pulled from, so `pull` refuses and asking a remote about it is // asking about a remote it doesn't have. Without this the card said so // twice -- "no upstream" on the branch line, and the check's own // complaint about the same thing underneath it. let can_pull = entry.git_pull && git.as_ref().is_some_and(|git| git.upstream.is_some()); let pending = entry.pending_declaration(); Ok(ManifestApp { // Read from the cache a refresh populated, rather than asking here. new_commits: can_pull && state.remote_checks.new_commits(&entry.project_path), // Read after `new_commits`, so a check that lands between the // two reports its answer rather than that it is still coming. check_pending: can_pull && state.remote_checks.is_checking(&entry.project_path), built: components .iter() .any(|component| component.apk.as_ref().is_some_and(|apk| apk.built)), components, check_error: can_pull .then(|| state.remote_checks.error(&entry.project_path)) .flatten(), can_pull, git, route: format!("/apps/{}/apk", entry.key), key: entry.key.clone(), label: entry.label.clone(), // Shown on a phone, where the home prefix is the least // interesting part of a long path. expand_tilde accepts this // form back, so it stays copy-pasteable into "add by path". project_path: crate::config::contract_tilde(&entry.project_path), // False while something is waiting to be accepted: the command // won't run until it is, so offering to build would be a // button that does nothing. needs_build: pending.is_none() && entry .build .as_ref() .is_some_and(|build| build.has_command()), git_ipv4: entry.git_ipv4, built_in: entry.built_in, pending_declaration: pending .as_ref() .and_then(|components| crate::config::render_request(components).ok()), }) } /// Starts the off-request checks for `entries`: the remote of every app /// that offers a Pull button, and every server component's script. /// /// Returns at once -- the answers land in the background and each app's /// `checkPending`, or each component's `checking`, says whose is still /// outstanding. Never waits: the manifest is about which apps have builds /// and whether this phone has them, all of it local and fast, and making /// it wait on a round trip to a git host meant every reopen of the app /// stalled behind one. /// /// Takes the entries rather than reading them from `state`, so that one /// card's Refresh and the whole list's are the same code with a different /// slice -- there is no second path that could drift from this one, or /// start a check the other doesn't. pub(crate) fn refresh_checkouts(state: &Arc, entries: &[Arc]) { let projects: Vec = entries .iter() .filter(|entry| entry.git_pull) .map(|entry| crate::git::Checkout { path: entry.project_path.clone(), ipv4: entry.git_ipv4, }) .collect(); state.remote_checks.refresh(&projects); // Same trip, same reasoning: asking a service manager costs a process // spawn, so it happens here off the request rather than while the // manifest is being built. let services: Vec = entries .iter() .flat_map(|entry| entry.service_targets()) .collect(); state.service_checks.refresh(services); // And the same for what each project says about itself: a `Script` // resources declaration is a process spawn, which is the thing this // path exists to keep out of the manifest. let resources: Vec = entries .iter() .filter_map(|entry| entry.resource_target()) .collect(); state.resource_checks.refresh(resources); } /// Re-check one project, for a card's own Refresh. /// /// Answers as soon as the checks are *started*, like the manifest's own /// refresh does -- they run off the request path, and the phone collects /// what they found through a later `/manifest`. async fn recheck_app( State(state): State>, UrlPath(key): UrlPath, ) -> Result { let entries = state.entries(); let entry = entries .iter() .find(|entry| entry.key == key) .ok_or(ApiError::UnknownApp(key))?; refresh_checkouts(&state, std::slice::from_ref(entry)); Ok(StatusCode::NO_CONTENT) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct SuggestionsResponse { roots: Vec, suggestions: Vec, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct Suggestion { path: String, name: String, apk_count: usize, mtime: f64, /// True when this project is already in the app list, so the add screen /// can show it as such instead of offering a duplicate. added: bool, } /// Scans the configured roots fresh on every call rather than caching: /// it's a few milliseconds (see `crate::discover`), and a cache would be /// wrong precisely when it matters -- right after a first build of the /// project someone is trying to add. async fn suggestions( State(state): State>, ) -> Result, ApiError> { let roots = state.repo_roots(); let added: Vec = state .entries() .iter() .map(|entry| entry.project_path.clone()) .collect(); let scanned = roots.clone(); let found = tokio::task::spawn_blocking(move || discover::scan_roots(&scanned)) .await .context("suggestion scan panicked")?; Ok(Json(SuggestionsResponse { roots: roots .iter() .map(|root| crate::config::contract_tilde(root)) .collect(), suggestions: found .into_iter() .map(|item| Suggestion { added: added.contains(&item.path), path: crate::config::contract_tilde(&item.path), name: item.name, apk_count: item.apk_count, mtime: epoch_secs(item.newest), }) .collect(), })) } #[derive(Deserialize)] struct PathBody { path: String, } #[derive(Serialize)] struct AddedResponse { key: String, label: String, } /// Every config mutation runs here rather than inline: each one writes the /// config file, and adding or re-pointing an app also spawns `aapt2`, none /// of which belongs on an async worker thread. async fn mutate( state: Arc, action: impl FnOnce(&AppState) -> anyhow::Result + Send + 'static, ) -> Result { tokio::task::spawn_blocking(move || action(&state)) .await .context("config mutation panicked")? .map_err(bad_request) } async fn add_app( State(state): State>, Json(body): Json, ) -> Result, ApiError> { let path = crate::config::expand_tilde(&body.path); let entry = mutate(state, move |state| state.add_app(&path)).await?; tracing::info!( "added {} ({}) from {}", entry.key, entry .apk_component(None) .and_then(crate::config::Component::package) .unwrap_or("package not read yet"), entry.project_path.display() ); Ok(Json(AddedResponse { key: entry.key.clone(), label: entry.label.clone(), })) } async fn remove_app( State(state): State>, UrlPath(key): UrlPath, ) -> Result { mutate(state, move |state| { state.remove_app(&key)?; tracing::info!("removed {key}"); Ok(()) }) .await?; Ok(StatusCode::NO_CONTENT) } /// The actions a server component's script is driven through. /// /// A closed set, checked here rather than passed through: the subcommand /// becomes an argument to a script running on the build machine, so the /// phone naming one of its own is exactly what must not be possible. const ACTIONS: [&str; 5] = ["install", "uninstall", "start", "stop", "restart"]; /// Runs one of them, and reports what the component is doing afterwards. /// /// Synchronous, unlike a build: these are quick, and the answer the phone /// wants is the state that resulted -- which is worth having in the reply /// rather than after the next background check. async fn service_action( State(state): State>, UrlPath((key, name, action)): UrlPath<(String, String, String)>, Query(purge): Query, ) -> Result, ApiError> { let entry = state .entry(&key) .ok_or_else(|| ApiError::UnknownApp(key.clone()))?; if !ACTIONS.contains(&action.as_str()) { return Err(ApiError::BadRequest(format!( "{action} is not something a service can be asked to do" ))); } // Nothing runs for a project whose declaration is still waiting to be // read: `service` is a command from that same file, and the gate is // about commands rather than about which field they sit in. if entry.pending_declaration().is_some() { return Err(ApiError::BadRequest(format!( "{}'s build step hasn't been accepted yet", entry.label ))); } let component = entry .component(&name) .ok_or_else(|| ApiError::BadRequest(format!("{} has no component {name}", entry.label)))?; let script = crate::service::driver(&key, component) .ok_or_else(|| ApiError::BadRequest(format!("{name} is not a server")))?; let project = entry.project_path.clone(); let cwd = component.cwd().map(std::path::Path::to_path_buf); let wanted = crate::purge::Wanted { logs: purge.logs, data: purge.data, config: purge.config, }; if !wanted.nothing() && action != "uninstall" { return Err(ApiError::BadRequest(format!( "{action} removes nothing; only uninstall does" ))); } // From the same answer the dialog was drawn from, so what is removed // is what was shown. A project whose resources could not be read has // no paths, and the two toggles that need them are not offered. let purging = (!wanted.nothing()) .then(|| state.resource_checks.facts(&key)) .flatten() .map(|facts| crate::purge::paths(&facts, &entry.project_path)) // Logs are this server's own files and need nothing from the // project, so a project that says nothing can still have them // removed. .or_else(|| (!wanted.nothing()).then(crate::purge::StatePaths::default)); // Restarting *this* server is deferred rather than run inline: the // script would kill this process before it could reply, so a restart // that worked would reach the phone as a failed request. Answered // first, restarted after -- the same order, and by way of the same // function, the build path uses. Which of the two restarts it turns // into is `restart::deferred`'s to decide, so the button and a // self-build cannot come to behave differently. // // The state reported is still the script's: whether a service manager // has this server is its question, not this process's. if entry.built_in && action == "restart" { let asked = (script.clone(), project.clone(), cwd.clone()); let resulting = tokio::task::spawn_blocking(move || { let (script, project, cwd) = asked; crate::service::status(&script, &project, cwd.as_deref()) }) .await .context("service status task")? .map_err(ApiError::BadRequest)?; state.service_checks.mark(&key, &name, resulting); crate::restart::deferred( Some(crate::restart::Handover { script, project, cwd, }), Arc::clone(&state.downloads), ); return Ok(Json(ServiceActionResponse { state: resulting, left_behind: Vec::new(), })); } let asked = (key.clone(), name.clone()); let ran = tokio::task::spawn_blocking(move || { let (key, name) = asked; // Asked before the uninstall, because the script that reports // where a service's log lives is the thing about to be removed. // The build log is this server's own file and would survive // either way, but both kinds are "the logs" to whoever ticked the // box, so they are collected as one list. let log_files = purging .as_ref() .map(|_| { let mut files = crate::logs::build_logs(&key, &name); files.extend(crate::service::logs(&script, &project, cwd.as_deref())); files }) .unwrap_or_default(); crate::service::run(&script, &project, cwd.as_deref(), &action)?; let left_behind = match &purging { Some(paths) => crate::purge::remove(paths, &log_files, wanted), None => Vec::new(), }; crate::service::status(&script, &project, cwd.as_deref()) .map(|resulting| (resulting, left_behind)) }) .await .context("service action task")?; match ran { Ok((resulting, left_behind)) => { state.service_checks.mark(&key, &name, resulting); for problem in &left_behind { tracing::warn!("uninstalling {key}'s {name}: {problem}"); } Ok(Json(ServiceActionResponse { state: resulting, left_behind, })) } // The script's own words, which is what the card shows. Err(message) => Err(ApiError::BadRequest(message)), } } /// Which log, and how much of it. #[derive(Deserialize)] struct LogQuery { /// Lines from the end. Absent is 100; zero means as much as the /// server is willing to read, which it says when it stops. lines: Option, /// 0 is the current log, 1 the one before it. An index rather than a /// "previous" flag, because the script reports a list and the phone /// should not have to assume how long it is. Counted within a kind: /// the build log's previous generation is not the runtime log's. generation: Option, /// Which log. Absent means the build log, which is what the one /// combined list used to answer with at generation 0 -- so a phone /// built before this existed keeps getting exactly what it got /// before rather than something new. kind: Option, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct LogResponse { /// Where this came from, so a person at the build machine can open /// the whole thing rather than the tail. path: String, text: String, /// There is more than what is here. Said rather than implied: a /// silently shortened log reads as a complete one that simply does /// not explain the crash. truncated: bool, /// How many generations exist, so the phone knows whether to offer a /// previous one at all. generations: usize, } /// The tail of one component's log. /// /// Reading is this server's job rather than the script's: the script says /// where, and how much of it a phone wants is a question the phone /// answers. It is also why this is a GET -- nothing here changes anything. async fn component_logs( State(state): State>, UrlPath((key, name)): UrlPath<(String, String)>, Query(query): Query, ) -> Result, ApiError> { let entry = state .entry(&key) .ok_or_else(|| ApiError::UnknownApp(key.clone()))?; let paths = tokio::task::spawn_blocking({ let entry = Arc::clone(&entry); let name = name.clone(); let kind = query.kind.unwrap_or(crate::logs::LogKind::Build); move || entry.component_logs(&name, kind) }) .await .context("asking for log paths")?; let generation = query.generation.unwrap_or(0); let path = paths.get(generation).ok_or_else(|| { ApiError::BadRequest(format!( "{}'s {name} has no {} log to show yet", entry.label, match query.kind.unwrap_or(crate::logs::LogKind::Build) { crate::logs::LogKind::Build => "build", crate::logs::LogKind::Runtime => "runtime", } )) })?; let lines = query.lines.unwrap_or(100); let read = { let path = path.clone(); tokio::task::spawn_blocking(move || crate::logs::tail(&path, lines)) .await .context("reading the log")? }; let tail = read.map_err(bad_request)?; Ok(Json(LogResponse { path: crate::config::contract_tilde(path), text: tail.text, truncated: tail.truncated, generations: paths.len(), })) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ServiceActionResponse { state: crate::service::ServiceState, /// What Uninstall was asked to take away and could not, one line /// each, empty on every other action. /// /// Not an error: the service itself is gone by then, so failing the /// request would report a successful uninstall as a failure. The card /// says what is still on disk instead, which is the thing the person /// would otherwise have to go and find. #[serde(skip_serializing_if = "Vec::is_empty")] left_behind: Vec, } /// What Uninstall should take away besides the service. /// /// Each defaults to false, so a phone that says nothing gets what /// uninstalling has always done. Only `uninstall` accepts them, and /// naming them on anything else is refused rather than ignored -- a flag /// that silently does nothing is how somebody comes to believe they /// cleaned up. #[derive(Deserialize, Default)] struct PurgeQuery { #[serde(default)] logs: bool, #[serde(default)] data: bool, #[serde(default)] config: bool, } /// Whether a pull may throw this checkout's own history away. /// /// Off unless the phone says otherwise, and it only says so after being /// told the checkout and its upstream are unrelated -- there is no /// fast-forward for that ever, so the alternative to this is a card that /// can never be pulled again. Destructive, and confirmed on the phone /// against the branch it names before it is ever sent. #[derive(Deserialize)] struct PullQuery { #[serde(default)] force: bool, } /// Which build a download wants, and whose. /// /// `component` names the APK on a project that produces more than one; /// absent is the only APK, which is every project with one and the frozen /// `/self/apk`, which cannot say a component. A project with two and a /// request that names neither is refused rather than served the first -- /// see [`ApiError::AmbiguousApk`]. /// /// `variant` absent means the newest, which is what every phone gets until /// it says otherwise. #[derive(Deserialize)] struct VariantQuery { component: Option, variant: Option, } #[derive(Deserialize)] struct RootsBody { roots: Vec, } /// Accepts the build step an app's project asks for. Until this is /// pressed, that app runs nothing -- see `crate::config::ProjectConfig` /// for why a project can't simply hand this server a command to run. async fn approve_declaration( State(state): State>, UrlPath(key): UrlPath, ) -> Result { mutate(state, move |state| { state.approve_declaration(&key)?; tracing::info!("accepted the build step {key} asks for"); Ok(()) }) .await?; Ok(StatusCode::NO_CONTENT) } /// What the card's settings can change. One field so far; the shape is a /// body rather than a query so the next one is a field here rather than a /// second route. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SettingsBody { git_ipv4: bool, } /// Replaces this machine's preferences for one project. /// /// A whole-object PUT rather than a patch per field: the phone has just /// shown the person every setting there is, so what it sends back is the /// complete answer, and a partial update would need a rule for what a /// missing field means. async fn set_settings( State(state): State>, UrlPath(key): UrlPath, Json(body): Json, ) -> Result { mutate(state, move |state| { state.set_project_settings(&key, body.git_ipv4)?; Ok(()) }) .await?; Ok(StatusCode::NO_CONTENT) } async fn set_roots( State(state): State>, Json(body): Json, ) -> Result { // Blank entries are what an emptied text field sends; dropping them // here keeps a stray "" out of the config and out of every later scan. let roots = body .roots .iter() .map(|root| root.trim()) .filter(|root| !root.is_empty()) .map(crate::config::expand_tilde) .collect(); mutate(state, move |state| state.set_repo_roots(roots)).await?; Ok(StatusCode::NO_CONTENT) } /// Resolves the app a request is about: the `{key}` in the path, or the /// self entry on the bootstrap listener, which has no key in its path. fn lookup(state: &AppState, key: Option>) -> Result, ApiError> { let key = key .map(|UrlPath(key)| key) .unwrap_or_else(|| crate::registry::SELF_KEY.to_string()); state.entry(&key).ok_or(ApiError::UnknownApp(key)) } /// Which component a `/prepare` or `/build` request is about, when it is /// about one rather than the whole project. /// /// Absent means every component with a build command, which is what both /// routes have always done and what a project with one component (still /// nearly all of them) never has reason to change. A project with more /// than one -- two independent Android clients sharing a checkout, say -- /// otherwise pays for every component's build to get one of them: this is /// how the phone opts out of that, by naming the one it actually wants /// before it is willing to wait on the others too. #[derive(Deserialize)] struct ComponentQuery { component: Option, } /// Resolves a `?component=` against the project's own components, or says /// why it could not -- the one place both build routes turn a name from /// the phone into something [`crate::build_state::BuildState`] accepts, so /// they cannot disagree about what an unknown name means. fn named_component<'a>( entry: &'a AppEntry, named: Option<&'a str>, ) -> Result, ApiError> { match named { Some(name) if entry .components .iter() .any(|component| component.name() == name) => { Ok(Some(name)) } Some(name) => Err(ApiError::UnknownComponent( entry.label.clone(), name.to_string(), )), None => Ok(None), } } /// The callback a build reports each finished component through. /// /// Made here because this is where both halves are in scope: the app list /// to write to, and the key of the app being built. The `Arc` it captures /// is held only for the life of the build task, so nothing in the list /// ends up pointing back at the list. fn records_builds(state: &Arc, key: &str) -> crate::build_state::RecordBuilt { let state = Arc::clone(state); let key = key.to_string(); Arc::new(move |component: &str, sha: String| state.record_built(&key, component, sha)) } async fn build_prepare( State(state): State>, key: UrlPath, Query(query): Query, ) -> Result, ApiError> { let entry = lookup(&state, Some(key))?; // Checked here rather than trusted from the entry's build state: the // project's file can have changed since that state was made, and this // is the moment its command would actually run. if entry.pending_declaration().is_some() { return Err(ApiError::BadRequest(format!( "{} is asking to run a build step that hasn't been accepted yet -- read it on the \ app's card and accept it first", entry.label, ))); } let component = named_component(&entry, query.component.as_deref())?; let build = entry .build .as_ref() .ok_or_else(|| ApiError::NoBuildStep(entry.label.clone()))?; build.trigger_if_needed(component, records_builds(&state, &entry.key)); Ok(Json(build.status())) } /// Builds because somebody asked, not because anything looked stale. /// /// Separate from `prepare` rather than a flag on it: `prepare` runs before /// a download and must stay cheap when there is nothing to do, while this /// is a person pressing a button and should do what it says every time. /// /// Gated on acceptance like every other path that runs a project's /// command -- the button is not an authorisation. async fn build_now( State(state): State>, key: UrlPath, Query(query): Query, ) -> Result, ApiError> { let entry = lookup(&state, Some(key))?; if entry.pending_declaration().is_some() { return Err(ApiError::BadRequest(format!( "{} is asking to run a build step that hasn't been accepted yet -- read it on the \ app's card and accept it first", entry.label, ))); } let component = named_component(&entry, query.component.as_deref())?; let build = entry .build .as_ref() .ok_or_else(|| ApiError::NoBuildStep(entry.label.clone()))?; build.build_now(component, records_builds(&state, &entry.key)); Ok(Json(build.status())) } /// Fetches, fast-forwards, and builds -- reported through the same status /// the phone already polls for a build, so one progress path covers both. /// /// Pulling stays available while a project's build step is waiting to be /// accepted, and stops after the fast-forward: taking commits runs git, /// not the project's command, and refusing would leave a project whose /// request you haven't accepted impossible to update at all. It is also /// how the newest version of that request arrives to be read. async fn build_pull( State(state): State>, key: UrlPath, Query(query): Query, ) -> Result, ApiError> { let entry = lookup(&state, Some(key))?; let build = entry .build .as_ref() .ok_or_else(|| ApiError::NoBuildStep(entry.label.clone()))?; // Asked after the fast-forward, not now: the declaration the gate // reads is the one the pull is about to rewrite, so the answer from // here is the answer for the commit being replaced. let gate = Arc::clone(&entry); build.pull_and_build( query.force, move || gate.pending_declaration().is_none(), records_builds(&state, &entry.key), ); Ok(Json(build.status())) } async fn build_status( State(state): State>, key: UrlPath, ) -> Result, ApiError> { let entry = lookup(&state, Some(key))?; let build = entry .build .as_ref() .ok_or_else(|| ApiError::NoBuildStep(entry.label.clone()))?; Ok(Json(build.status())) } /// A reader that keeps its download counted for as long as it is being /// read. /// /// The count has to end when the *body* does, not when the handler /// returns -- the handler returns as soon as the stream is handed to /// axum, with the bytes still to go. Wrapping the reader means the guard /// is dropped exactly when the body is finished or the client goes away, /// which is when this server has genuinely stopped sending. /// /// An `AsyncRead` wrapper rather than a `Stream` one so this needs no /// dependency that is not already here. struct Counted { inner: R, _guard: crate::restart::DownloadGuard, } impl tokio::io::AsyncRead for Counted { fn poll_read( mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &mut tokio::io::ReadBuf<'_>, ) -> std::task::Poll> { std::pin::Pin::new(&mut self.inner).poll_read(cx, buf) } } struct ByteRange { start: u64, end: u64, is_partial: bool, } /// Parses a `Range: bytes=...` header, clamped to `size`. Anything that /// isn't a well-formed single byte-range -- including a missing header -- /// falls back to the full file; only a range that's present, well-formed, /// and unsatisfiable (`start > end`) is an error. fn parse_range(header: Option<&HeaderValue>, size: u64) -> Result { let full = ByteRange { start: 0, end: size.saturating_sub(1), is_partial: false, }; let Some(spec) = header .and_then(|h| h.to_str().ok()) .and_then(|h| h.strip_prefix("bytes=")) else { return Ok(full); }; let Some((start_s, end_s)) = spec.split_once('-') else { return Ok(full); }; if start_s.is_empty() && end_s.is_empty() { return Ok(full); } let (start, end) = if !start_s.is_empty() { let Ok(start) = start_s.parse::() else { return Ok(full); }; let end = if end_s.is_empty() { size.saturating_sub(1) } else { match end_s.parse::() { Ok(end) => end, Err(_) => return Ok(full), } }; (start, end) } else { // Suffix range: last N bytes. let Ok(suffix) = end_s.parse::() else { return Ok(full); }; (size.saturating_sub(suffix), size.saturating_sub(1)) }; let end = end.min(size.saturating_sub(1)); if start > end { return Err(ApiError::RangeNotSatisfiable); } Ok(ByteRange { start, end, is_partial: true, }) } /// What the app needs to know about the newer copy of itself, and /// nothing else. **This shape is frozen** -- see the module note. #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct SelfBuild { /// When the APK on disk was built, in seconds since the epoch. The /// phone compares it against its own `PackageInfo.lastUpdateTime`, /// which is the same freshness rule the list uses: these are ad hoc /// rebuilds with nothing bumping a version code. mtime: f64, /// Bytes, so a download can show progress rather than a spinner. size: u64, } /// Is there a build of the updater app here, and how new is it. /// /// Deliberately does not go through `describe`: that is where a card is /// built, and a card is exactly the thing whose shape changes. This reads /// the file and answers two numbers. async fn self_build(State(state): State>) -> Result, ApiError> { let entry = lookup(&state, None)?; // No component named, because this route is frozen and cannot carry // one -- and this server's own project produces exactly one APK, // which is what makes that an answer rather than a guess. let component = apk_component(&entry, None)?; let apk = entry .resolve_apk(component, None) .ok_or_else(|| ApiError::NotBuilt(entry.label.clone()))?; let size = tokio::fs::metadata(&apk.path) .await .context("stat the updater's own apk")? .len(); Ok(Json(SelfBuild { mtime: epoch_secs(apk.modified), size, })) } /// The APK component a request is about, or why there isn't one. /// /// The single place a name from a phone becomes a component, so the /// download and anything that follows it cannot disagree about which APK /// was meant. fn apk_component<'a>( entry: &'a AppEntry, named: Option<&str>, ) -> Result<&'a crate::config::Component, ApiError> { if let Some(component) = entry.apk_component(named) { return Ok(component); } Err(match named { Some(name) => ApiError::UnknownComponent(entry.label.clone(), name.to_string()), // Nothing named, and not one obvious answer: either the project // builds no APK at all, or it builds several and the request has // to say which. None if entry.apk_components().next().is_none() => ApiError::NotBuilt(entry.label.clone()), None => ApiError::AmbiguousApk(entry.label.clone()), }) } async fn serve_apk( State(state): State>, key: Option>, method: Method, headers: HeaderMap, Query(query): Query, ) -> Result { let entry = lookup(&state, key)?; let component = apk_component(&entry, query.component.as_deref())?; // Which build this device wants, if it has a preference. Its own // preference, travelling with the request: two phones enrolled against // one server must not change what the other gets. Validated against // *this component's* builds, so naming another one's path is a // fallback to this one's newest rather than a way to be served it. let requested = query.variant.map(PathBuf::from); let apk = entry .resolve_apk(component, requested.as_deref()) .ok_or_else(|| ApiError::NotBuilt(entry.label.clone()))?; // A fresh download, not a range continuation: the one moment this // server can notice that a local rebuild changed what the APK installs // over. Off the request, so it costs the download nothing. if headers.get(header::RANGE).is_none() { state.refresh_package(&entry.key, component.name(), apk.path.clone()); } let resolved = resolve_serveable_path(&apk.path, component.strip()).await?; let size = tokio::fs::metadata(&resolved) .await .context("stat resolved apk")? .len(); let range = parse_range(headers.get(header::RANGE), size)?; let length = range.end - range.start + 1; let mut response = Response::builder() .status(if range.is_partial { StatusCode::PARTIAL_CONTENT } else { StatusCode::OK }) .header( header::CONTENT_TYPE, "application/vnd.android.package-archive", ) .header( header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", entry.filename(&apk.path)), ) .header(header::ACCEPT_RANGES, "bytes") .header(header::CONTENT_LENGTH, length); if range.is_partial { response = response.header( header::CONTENT_RANGE, format!("bytes {}-{}/{size}", range.start, range.end), ); } if method == Method::HEAD { return Ok(response .body(Body::empty()) .expect("response with an empty body is valid")); } let mut file = tokio::fs::File::open(&resolved) .await .context("open resolved apk")?; file.seek(std::io::SeekFrom::Start(range.start)) .await .context("seek resolved apk")?; // Counted from here rather than at the top of the handler: a HEAD, a // 404 or an unsatisfiable range sends no bytes and must not hold a // restart back. let stream = ReaderStream::new(Counted { inner: file.take(length), _guard: state.downloads.start(), }); Ok(response .body(Body::from_stream(stream)) .expect("response with a streaming body is valid")) }