Skip to main content

max / makenotwork

8.4 KB · 219 lines History Blame Raw
1 //! Publishing endpoint for the Alloy hotfix RPM repository.
2 //!
3 //! One route: an admin asks for a presigned `PutObject` against the RPM bucket,
4 //! then PUTs the bytes straight to object storage. The server never sees the
5 //! package, and the publisher never holds an S3 credential — the custody
6 //! argument that decided this shape over putting a write key on a laptop
7 //! (alloy `32423bdd`, ruled (c)).
8 //!
9 //! Nothing here serves the repo. `dnf` and `rpm-ostree` fetch
10 //! `repodata/repomd.xml` and a `.rpm` by path off a GET/HEAD-only Caddy block in
11 //! front of the bucket, exactly as `cdn.makenot.work` fronts the public image
12 //! bucket. So a published fix reaches machines with no deploy at all, and the
13 //! deploy this module costs is one-time, for the code.
14 //!
15 //! Auth is the SyncKit bearer token the `ota publish` path already mints,
16 //! narrowed to the configured admin. That is deliberate: [`SyncUser::user_id`]
17 //! is the same `UserId` [`crate::auth::require_admin`] checks, so the gate is
18 //! the identical value, and `mnw-cli` gets to reuse the OAuth flow it already
19 //! drives rather than growing a second way to authenticate. The `/admin*`
20 //! session gate is not usable here — it wants a browser cookie, and this caller
21 //! is a command.
22
23 use axum::{Json, extract::State, response::IntoResponse};
24 use serde::{Deserialize, Serialize};
25 use tower_governor::GovernorLayer;
26
27 use crate::{
28 AppState, AppStorage,
29 config::Config,
30 constants,
31 csrf::{CsrfRouter, post_csrf_skip},
32 error::{AppError, Result},
33 storage::S3Client,
34 synckit_auth::SyncUser,
35 };
36
37 #[derive(Deserialize)]
38 pub(crate) struct PresignRequest {
39 /// Object path relative to the bucket root, e.g.
40 /// `alloy/f43/x86_64/repodata/repomd.xml`. Validated by
41 /// [`S3Client::generate_rpm_key`], which is where the refusals live.
42 path: String,
43 /// Exact byte length of the object. Signed into the URL, so S3 itself
44 /// rejects a PUT whose body is a different size than was authorized.
45 size: i64,
46 /// Content type to sign. Optional; the publisher usually lets the server
47 /// derive it from the extension, which is the one place that mapping lives.
48 #[serde(default)]
49 content_type: Option<String>,
50 }
51
52 #[derive(Serialize)]
53 pub(crate) struct PresignResponse {
54 /// The presigned `PutObject` URL. Single-use in practice, short-lived by
55 /// [`constants::RPM_PRESIGN_EXPIRY_SECS`].
56 upload_url: String,
57 /// The key the object will land at. Echoed back because the server
58 /// normalizes nothing — if this differs from what was asked for, that is a
59 /// bug worth seeing rather than a silent rewrite.
60 object_key: String,
61 /// Where the object will be readable once uploaded, when `RPM_BASE_URL` is
62 /// configured. Operator convenience only; nothing durable is written from
63 /// it, so an unset base URL costs a printed line and nothing else.
64 public_url: Option<String>,
65 /// The content type signed into the URL. The publisher must send exactly
66 /// this on the PUT or the signature will not match.
67 content_type: String,
68 }
69
70 /// Content type for an RPM-repo object, by extension.
71 ///
72 /// `dnf` keys off `repomd.xml`'s own contents rather than transport metadata,
73 /// so this is about not serving a package as something a browser would render.
74 /// Anything unrecognised is a byte stream, which is the safe answer.
75 fn content_type_for(key: &str) -> &'static str {
76 match key
77 .rsplit_once('.')
78 .map(|(_, ext)| ext.to_ascii_lowercase())
79 {
80 Some(ext) => match ext.as_str() {
81 "rpm" => "application/x-rpm",
82 "xml" => "application/xml",
83 "yaml" => "application/yaml",
84 "gz" => "application/gzip",
85 "xz" => "application/x-xz",
86 "zst" => "application/zstd",
87 "bz2" => "application/x-bzip2",
88 "asc" | "key" | "sig" => "text/plain",
89 _ => "application/octet-stream",
90 },
91 None => "application/octet-stream",
92 }
93 }
94
95 /// Cache-Control to stamp on the object, by role in the repository.
96 ///
97 /// The split is the whole reason this is not one value. A package is immutable:
98 /// its name carries name-version-release-arch, so a given `.rpm` never changes
99 /// and an edge may hold it forever. Metadata under `repodata/` is the opposite —
100 /// it is what a client re-reads to notice a new package exists, and caching it
101 /// at the edge is exactly how a published hotfix fails to arrive. `repomd.xml`
102 /// is re-fetched constantly and must not be held; the hashed metadata files it
103 /// points at are content-addressed, so they cache like packages.
104 fn cache_control_for(key: &str) -> &'static str {
105 let filename = key.rsplit('/').next().unwrap_or(key);
106 if filename.starts_with("repomd.xml") {
107 // Covers repomd.xml and its .asc/.key siblings, which are rewritten in
108 // place on every publish.
109 "no-cache, must-revalidate"
110 } else {
111 "public, max-age=31536000, immutable"
112 }
113 }
114
115 /// Mint a presigned `PutObject` for one object in the RPM repository.
116 ///
117 /// `POST /api/v1/admin/rpm/uploads`
118 ///
119 /// Nothing is recorded: unlike an OTA artifact there is no row to own, no scan
120 /// to queue and no release to attach to. The repository's state IS the bucket's
121 /// contents, and `repomd.xml` is the index — which is why the publisher uploads
122 /// packages first and `repomd.xml` last, and why a half-finished publish leaves
123 /// unreferenced objects rather than a broken repo.
124 #[tracing::instrument(skip_all, name = "rpm::presign_upload")]
125 async fn presign_upload(
126 State(config): State<Config>,
127 State(storage): State<AppStorage>,
128 sync_user: SyncUser,
129 Json(req): Json<PresignRequest>,
130 ) -> Result<impl IntoResponse> {
131 require_rpm_admin(&config, &sync_user)?;
132
133 if req.size <= 0 {
134 return Err(AppError::BadRequest("size must be positive".to_string()));
135 }
136 if req.size > constants::RPM_MAX_OBJECT_BYTES {
137 return Err(AppError::BadRequest(format!(
138 "object is {} bytes, over the {} byte ceiling",
139 req.size,
140 constants::RPM_MAX_OBJECT_BYTES
141 )));
142 }
143
144 let key = S3Client::generate_rpm_key(&req.path)?;
145 let rpm_s3 = storage.require_rpm_s3()?;
146
147 // A caller-supplied content type is signed verbatim, so it cannot be used to
148 // smuggle anything past the derivation — it only has to match on the PUT.
149 let content_type = req
150 .content_type
151 .clone()
152 .unwrap_or_else(|| content_type_for(key.as_str()).to_string());
153
154 let upload_url = rpm_s3
155 .presign_upload(
156 &key,
157 &content_type,
158 Some(constants::RPM_PRESIGN_EXPIRY_SECS),
159 Some(cache_control_for(key.as_str())),
160 Some(req.size),
161 )
162 .await?;
163
164 tracing::info!(
165 admin = %sync_user.user_id,
166 key = %key,
167 size = req.size,
168 "minted RPM repo presign"
169 );
170
171 let public_url = config
172 .rpm_base_url
173 .as_ref()
174 .map(|base| format!("{base}/{key}"));
175
176 Ok((
177 axum::http::StatusCode::CREATED,
178 Json(PresignResponse {
179 upload_url,
180 object_key: key.into_string(),
181 public_url,
182 content_type,
183 }),
184 ))
185 }
186
187 /// The admin gate, as its own function so there is one place to read it.
188 ///
189 /// Returns [`AppError::NotFound`] rather than a 403 for a non-admin, matching
190 /// [`crate::auth::require_admin`]: an operator endpoint should not confirm it
191 /// exists to a caller who may not use it. A `None` `admin_user_id` refuses
192 /// everyone, which is the same posture the `/admin*` subtree takes.
193 fn require_rpm_admin(config: &Config, sync_user: &SyncUser) -> Result<()> {
194 match config.admin_user_id {
195 Some(admin_id) if admin_id == sync_user.user_id => Ok(()),
196 _ => Err(AppError::NotFound),
197 }
198 }
199
200 /// The `/api/v1/admin/rpm/*` routes.
201 pub fn rpm_routes() -> CsrfRouter<AppState> {
202 const RPM_SKIP: &str = "rpm publish: bearer auth, no session";
203
204 let write_rate_limit = crate::helpers::rate_limiter_ms(
205 constants::RPM_WRITE_RATE_LIMIT_MS,
206 constants::RPM_WRITE_RATE_LIMIT_BURST,
207 );
208
209 // Versioned path only. The OTA routes carry an unversioned alias because
210 // shipped clients call it; nothing has ever called this one, so it starts
211 // with the single spelling it should keep.
212 CsrfRouter::new()
213 .route(
214 "/api/v1/admin/rpm/uploads",
215 post_csrf_skip(RPM_SKIP, presign_upload),
216 )
217 .route_layer(GovernorLayer::new(write_rate_limit))
218 }
219