//! Publishing endpoint for the Alloy hotfix RPM repository. //! //! One route: an admin asks for a presigned `PutObject` against the RPM bucket, //! then PUTs the bytes straight to object storage. The server never sees the //! package, and the publisher never holds an S3 credential — the custody //! argument that decided this shape over putting a write key on a laptop //! (alloy `32423bdd`, ruled (c)). //! //! Nothing here serves the repo. `dnf` and `rpm-ostree` fetch //! `repodata/repomd.xml` and a `.rpm` by path off a GET/HEAD-only Caddy block in //! front of the bucket, exactly as `cdn.makenot.work` fronts the public image //! bucket. So a published fix reaches machines with no deploy at all, and the //! deploy this module costs is one-time, for the code. //! //! Auth is the SyncKit bearer token the `ota publish` path already mints, //! narrowed to the configured admin. That is deliberate: [`SyncUser::user_id`] //! is the same `UserId` [`crate::auth::require_admin`] checks, so the gate is //! the identical value, and `mnw-cli` gets to reuse the OAuth flow it already //! drives rather than growing a second way to authenticate. The `/admin*` //! session gate is not usable here — it wants a browser cookie, and this caller //! is a command. use axum::{Json, extract::State, response::IntoResponse}; use serde::{Deserialize, Serialize}; use tower_governor::GovernorLayer; use crate::{ AppState, AppStorage, config::Config, constants, csrf::{CsrfRouter, post_csrf_skip}, error::{AppError, Result}, storage::S3Client, synckit_auth::SyncUser, }; #[derive(Deserialize)] pub(crate) struct PresignRequest { /// Object path relative to the bucket root, e.g. /// `alloy/f43/x86_64/repodata/repomd.xml`. Validated by /// [`S3Client::generate_rpm_key`], which is where the refusals live. path: String, /// Exact byte length of the object. Signed into the URL, so S3 itself /// rejects a PUT whose body is a different size than was authorized. size: i64, /// Content type to sign. Optional; the publisher usually lets the server /// derive it from the extension, which is the one place that mapping lives. #[serde(default)] content_type: Option, } #[derive(Serialize)] pub(crate) struct PresignResponse { /// The presigned `PutObject` URL. Single-use in practice, short-lived by /// [`constants::RPM_PRESIGN_EXPIRY_SECS`]. upload_url: String, /// The key the object will land at. Echoed back because the server /// normalizes nothing — if this differs from what was asked for, that is a /// bug worth seeing rather than a silent rewrite. object_key: String, /// Where the object will be readable once uploaded, when `RPM_BASE_URL` is /// configured. Operator convenience only; nothing durable is written from /// it, so an unset base URL costs a printed line and nothing else. public_url: Option, /// The content type signed into the URL. The publisher must send exactly /// this on the PUT or the signature will not match. content_type: String, } /// Content type for an RPM-repo object, by extension. /// /// `dnf` keys off `repomd.xml`'s own contents rather than transport metadata, /// so this is about not serving a package as something a browser would render. /// Anything unrecognised is a byte stream, which is the safe answer. fn content_type_for(key: &str) -> &'static str { match key .rsplit_once('.') .map(|(_, ext)| ext.to_ascii_lowercase()) { Some(ext) => match ext.as_str() { "rpm" => "application/x-rpm", "xml" => "application/xml", "yaml" => "application/yaml", "gz" => "application/gzip", "xz" => "application/x-xz", "zst" => "application/zstd", "bz2" => "application/x-bzip2", "asc" | "key" | "sig" => "text/plain", _ => "application/octet-stream", }, None => "application/octet-stream", } } /// Cache-Control to stamp on the object, by role in the repository. /// /// The split is the whole reason this is not one value. A package is immutable: /// its name carries name-version-release-arch, so a given `.rpm` never changes /// and an edge may hold it forever. Metadata under `repodata/` is the opposite — /// it is what a client re-reads to notice a new package exists, and caching it /// at the edge is exactly how a published hotfix fails to arrive. `repomd.xml` /// is re-fetched constantly and must not be held; the hashed metadata files it /// points at are content-addressed, so they cache like packages. fn cache_control_for(key: &str) -> &'static str { let filename = key.rsplit('/').next().unwrap_or(key); if filename.starts_with("repomd.xml") { // Covers repomd.xml and its .asc/.key siblings, which are rewritten in // place on every publish. "no-cache, must-revalidate" } else { "public, max-age=31536000, immutable" } } /// Mint a presigned `PutObject` for one object in the RPM repository. /// /// `POST /api/v1/admin/rpm/uploads` /// /// Nothing is recorded: unlike an OTA artifact there is no row to own, no scan /// to queue and no release to attach to. The repository's state IS the bucket's /// contents, and `repomd.xml` is the index — which is why the publisher uploads /// packages first and `repomd.xml` last, and why a half-finished publish leaves /// unreferenced objects rather than a broken repo. #[tracing::instrument(skip_all, name = "rpm::presign_upload")] async fn presign_upload( State(config): State, State(storage): State, sync_user: SyncUser, Json(req): Json, ) -> Result { require_rpm_admin(&config, &sync_user)?; if req.size <= 0 { return Err(AppError::BadRequest("size must be positive".to_string())); } if req.size > constants::RPM_MAX_OBJECT_BYTES { return Err(AppError::BadRequest(format!( "object is {} bytes, over the {} byte ceiling", req.size, constants::RPM_MAX_OBJECT_BYTES ))); } let key = S3Client::generate_rpm_key(&req.path)?; let rpm_s3 = storage.require_rpm_s3()?; // A caller-supplied content type is signed verbatim, so it cannot be used to // smuggle anything past the derivation — it only has to match on the PUT. let content_type = req .content_type .clone() .unwrap_or_else(|| content_type_for(key.as_str()).to_string()); let upload_url = rpm_s3 .presign_upload( &key, &content_type, Some(constants::RPM_PRESIGN_EXPIRY_SECS), Some(cache_control_for(key.as_str())), Some(req.size), ) .await?; tracing::info!( admin = %sync_user.user_id, key = %key, size = req.size, "minted RPM repo presign" ); let public_url = config .rpm_base_url .as_ref() .map(|base| format!("{base}/{key}")); Ok(( axum::http::StatusCode::CREATED, Json(PresignResponse { upload_url, object_key: key.into_string(), public_url, content_type, }), )) } /// The admin gate, as its own function so there is one place to read it. /// /// Returns [`AppError::NotFound`] rather than a 403 for a non-admin, matching /// [`crate::auth::require_admin`]: an operator endpoint should not confirm it /// exists to a caller who may not use it. A `None` `admin_user_id` refuses /// everyone, which is the same posture the `/admin*` subtree takes. fn require_rpm_admin(config: &Config, sync_user: &SyncUser) -> Result<()> { match config.admin_user_id { Some(admin_id) if admin_id == sync_user.user_id => Ok(()), _ => Err(AppError::NotFound), } } /// The `/api/v1/admin/rpm/*` routes. pub fn rpm_routes() -> CsrfRouter { const RPM_SKIP: &str = "rpm publish: bearer auth, no session"; let write_rate_limit = crate::helpers::rate_limiter_ms( constants::RPM_WRITE_RATE_LIMIT_MS, constants::RPM_WRITE_RATE_LIMIT_BURST, ); // Versioned path only. The OTA routes carry an unversioned alias because // shipped clients call it; nothing has ever called this one, so it starts // with the single spelling it should keep. CsrfRouter::new() .route( "/api/v1/admin/rpm/uploads", post_csrf_skip(RPM_SKIP, presign_upload), ) .route_layer(GovernorLayer::new(write_rate_limit)) }