Skip to main content

max / makenotwork

10.5 KB · 257 lines History Blame Raw
1 //! Publishing endpoint for the artifact store.
2 //!
3 //! One store, not one per consumer. Alloy needs three things hosted and they
4 //! were ruled separately on the same day: the hotfix RPM repository
5 //! (alloy `86cb87b9`), the mirror of the Fedora base images (`ebf30337`), and
6 //! the locked snapshot of the layered RPM set (`7ee5a694`). Three stores would
7 //! mean three uploaders, three retention policies and three signing keys, so
8 //! they are one store under three prefixes, which is what this endpoint writes
9 //! to. See wiki `mnw-package-hosting`.
10 //!
11 //! Every object is a file with an extension, and that is a design constraint
12 //! rather than a coincidence. The mirror ships **archives over S3**, not a
13 //! registry: a registry would have put `/v2/` at the host root, extensionless
14 //! digest names in the keys and a colon in the key alphabet, and would have
15 //! made a manifest's `Content-Type` load-bearing. A tarball fetched by URL and
16 //! checked against a digest needs none of that, and the only consumer is a
17 //! build script we write.
18 //!
19 //! One route: an admin asks for a presigned `PutObject` against the artifact
20 //! bucket, then PUTs the bytes straight to object storage. The server never
21 //! sees the object, and the publisher never holds an S3 credential — the
22 //! custody argument that decided this shape over putting a write key on a
23 //! laptop (alloy `32423bdd`, ruled (c)).
24 //!
25 //! Nothing here serves the store. Clients fetch by path off a GET/HEAD-only
26 //! Caddy block in front of the bucket, exactly as `cdn.makenot.work` fronts the
27 //! public image bucket. So a published fix reaches machines with no deploy at
28 //! all, and the deploy this module costs is one-time, for the code.
29 //!
30 //! **That front does not belong on a box that serves anything else.** One
31 //! outward-facing purpose per machine, because a pull spike from Alloy users
32 //! must never land on the machine serving paying creators. The bucket makes
33 //! that cheap to honour: the store is object storage plus its own front, and
34 //! neither is `alpha-west-1`.
35 //!
36 //! Auth is the SyncKit bearer token the `ota publish` path already mints,
37 //! narrowed to the configured admin. That is deliberate: [`SyncUser::user_id`]
38 //! is the same `UserId` [`crate::auth::require_admin`] checks, so the gate is
39 //! the identical value, and `mnw-cli` gets to reuse the OAuth flow it already
40 //! drives rather than growing a second way to authenticate. The `/admin*`
41 //! session gate is not usable here — it wants a browser cookie, and this caller
42 //! is a command.
43
44 use axum::{Json, extract::State, response::IntoResponse};
45 use serde::{Deserialize, Serialize};
46 use tower_governor::GovernorLayer;
47
48 use crate::{
49 AppState, AppStorage,
50 config::Config,
51 constants,
52 csrf::{CsrfRouter, post_csrf_skip},
53 error::{AppError, Result},
54 storage::S3Client,
55 synckit_auth::SyncUser,
56 };
57
58 #[derive(Deserialize)]
59 pub(crate) struct PresignRequest {
60 /// Object path relative to the bucket root, e.g.
61 /// `alloy/f43/x86_64/repodata/repomd.xml`. Validated by
62 /// [`S3Client::generate_artifact_key`], which is where the refusals live.
63 path: String,
64 /// Exact byte length of the object. Signed into the URL, so S3 itself
65 /// rejects a PUT whose body is a different size than was authorized.
66 size: i64,
67 /// Content type to sign. Optional; the publisher usually lets the server
68 /// derive it from the extension, which is the one place that mapping lives.
69 #[serde(default)]
70 content_type: Option<String>,
71 }
72
73 #[derive(Serialize)]
74 pub(crate) struct PresignResponse {
75 /// The presigned `PutObject` URL. Single-use in practice, short-lived by
76 /// [`constants::ARTIFACT_PRESIGN_EXPIRY_SECS`].
77 upload_url: String,
78 /// The key the object will land at. Echoed back because the server
79 /// normalizes nothing — if this differs from what was asked for, that is a
80 /// bug worth seeing rather than a silent rewrite.
81 object_key: String,
82 /// Where the object will be readable once uploaded, when `ARTIFACT_BASE_URL` is
83 /// configured. Operator convenience only; nothing durable is written from
84 /// it, so an unset base URL costs a printed line and nothing else.
85 public_url: Option<String>,
86 /// The content type signed into the URL. The publisher must send exactly
87 /// this on the PUT or the signature will not match.
88 content_type: String,
89 }
90
91 /// Content type for a stored object, by extension.
92 ///
93 /// `dnf` keys off `repomd.xml`'s own contents rather than transport metadata,
94 /// and an archive is verified against a digest the puller already holds, so
95 /// this is about not serving a package as something a browser would render
96 /// rather than about correctness of the fetch. Anything unrecognised is a byte
97 /// stream, which is the safe answer.
98 fn content_type_for(key: &str) -> &'static str {
99 match key
100 .rsplit_once('.')
101 .map(|(_, ext)| ext.to_ascii_lowercase())
102 {
103 Some(ext) => match ext.as_str() {
104 "rpm" => "application/x-rpm",
105 "tar" => "application/x-tar",
106 "xml" => "application/xml",
107 "yaml" => "application/yaml",
108 "gz" => "application/gzip",
109 "xz" => "application/x-xz",
110 "zst" => "application/zstd",
111 "bz2" => "application/x-bzip2",
112 "asc" | "key" | "sig" => "text/plain",
113 _ => "application/octet-stream",
114 },
115 None => "application/octet-stream",
116 }
117 }
118
119 /// The filenames that are rewritten in place rather than content-addressed.
120 ///
121 /// One entry, and the store is shaped so it stays that way. `repomd.xml` is an
122 /// RPM repository's index, matched by prefix so its `.asc` and `.key` siblings
123 /// come with it. Everything else the store holds is immutable under its own
124 /// name: a package carries name-version-release-arch, and a mirrored base image
125 /// is an archive pinned by the digest of its bytes.
126 ///
127 /// A mutable file that is not here would be cached at the edge for a year and
128 /// its publishes would stop arriving, so adding a prefix to the store means
129 /// asking whether it has an entry point and adding it if so. Prefer a prefix
130 /// that has none.
131 const MUTABLE_INDEX_NAMES: [&str; 1] = ["repomd.xml"];
132
133 /// Cache-Control to stamp on the object, by role in the repository.
134 ///
135 /// The split is the whole reason this is not one value. A package is immutable:
136 /// its name carries name-version-release-arch, and a blob is named by its own
137 /// digest, so neither ever changes and an edge may hold it forever. An index is
138 /// the opposite. It is what a client re-reads to notice a new object exists,
139 /// and caching it at the edge is exactly how a published hotfix fails to
140 /// arrive.
141 fn cache_control_for(key: &str) -> &'static str {
142 let filename = key.rsplit('/').next().unwrap_or(key);
143 if MUTABLE_INDEX_NAMES
144 .iter()
145 .any(|name| filename.starts_with(name))
146 {
147 "no-cache, must-revalidate"
148 } else {
149 "public, max-age=31536000, immutable"
150 }
151 }
152
153 /// Mint a presigned `PutObject` for one object in the RPM repository.
154 ///
155 /// `POST /api/v1/admin/artifacts/uploads`
156 ///
157 /// Nothing is recorded: unlike an OTA artifact there is no row to own, no scan
158 /// to queue and no release to attach to. The repository's state IS the bucket's
159 /// contents, and `repomd.xml` is the index — which is why the publisher uploads
160 /// packages first and `repomd.xml` last, and why a half-finished publish leaves
161 /// unreferenced objects rather than a broken repo.
162 #[tracing::instrument(skip_all, name = "rpm::presign_upload")]
163 async fn presign_upload(
164 State(config): State<Config>,
165 State(storage): State<AppStorage>,
166 sync_user: SyncUser,
167 Json(req): Json<PresignRequest>,
168 ) -> Result<impl IntoResponse> {
169 require_artifact_admin(&config, &sync_user)?;
170
171 if req.size <= 0 {
172 return Err(AppError::BadRequest("size must be positive".to_string()));
173 }
174 if req.size > constants::ARTIFACT_MAX_OBJECT_BYTES {
175 return Err(AppError::BadRequest(format!(
176 "object is {} bytes, over the {} byte ceiling",
177 req.size,
178 constants::ARTIFACT_MAX_OBJECT_BYTES
179 )));
180 }
181
182 let key = S3Client::generate_artifact_key(&req.path)?;
183 let artifact_s3 = storage.require_artifact_s3()?;
184
185 // A caller-supplied content type is signed verbatim, so it cannot be used to
186 // smuggle anything past the derivation — it only has to match on the PUT.
187 let content_type = req
188 .content_type
189 .clone()
190 .unwrap_or_else(|| content_type_for(key.as_str()).to_string());
191
192 let upload_url = artifact_s3
193 .presign_upload(
194 &key,
195 &content_type,
196 Some(constants::ARTIFACT_PRESIGN_EXPIRY_SECS),
197 Some(cache_control_for(key.as_str())),
198 Some(req.size),
199 )
200 .await?;
201
202 tracing::info!(
203 admin = %sync_user.user_id,
204 key = %key,
205 size = req.size,
206 "minted RPM repo presign"
207 );
208
209 let public_url = config
210 .artifact_base_url
211 .as_ref()
212 .map(|base| format!("{base}/{key}"));
213
214 Ok((
215 axum::http::StatusCode::CREATED,
216 Json(PresignResponse {
217 upload_url,
218 object_key: key.into_string(),
219 public_url,
220 content_type,
221 }),
222 ))
223 }
224
225 /// The admin gate, as its own function so there is one place to read it.
226 ///
227 /// Returns [`AppError::NotFound`] rather than a 403 for a non-admin, matching
228 /// [`crate::auth::require_admin`]: an operator endpoint should not confirm it
229 /// exists to a caller who may not use it. A `None` `admin_user_id` refuses
230 /// everyone, which is the same posture the `/admin*` subtree takes.
231 fn require_artifact_admin(config: &Config, sync_user: &SyncUser) -> Result<()> {
232 match config.admin_user_id {
233 Some(admin_id) if admin_id == sync_user.user_id => Ok(()),
234 _ => Err(AppError::NotFound),
235 }
236 }
237
238 /// The `/api/v1/admin/rpm/*` routes.
239 pub fn artifact_routes() -> CsrfRouter<AppState> {
240 const ARTIFACT_SKIP: &str = "rpm publish: bearer auth, no session";
241
242 let write_rate_limit = crate::helpers::rate_limiter_ms(
243 constants::ARTIFACT_WRITE_RATE_LIMIT_MS,
244 constants::ARTIFACT_WRITE_RATE_LIMIT_BURST,
245 );
246
247 // Versioned path only. The OTA routes carry an unversioned alias because
248 // shipped clients call it; nothing has ever called this one, so it starts
249 // with the single spelling it should keep.
250 CsrfRouter::new()
251 .route(
252 "/api/v1/admin/artifacts/uploads",
253 post_csrf_skip(ARTIFACT_SKIP, presign_upload),
254 )
255 .route_layer(GovernorLayer::new(write_rate_limit))
256 }
257