max / makenotwork
| 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 ; |
| 45 | use ; |
| 46 | use GovernorLayer; |
| 47 | |
| 48 | use crate:: |
| 49 | AppState, AppStorage, |
| 50 | Config, |
| 51 | constants, |
| 52 | , |
| 53 | , |
| 54 | S3Client, |
| 55 | SyncUser, |
| 56 | ; |
| 57 | |
| 58 | |
| 59 | pub |
| 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: , |
| 71 | |
| 72 | |
| 73 | |
| 74 | pub |
| 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: , |
| 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 | |
| 99 | match key |
| 100 | .rsplit_once |
| 101 | .map |
| 102 | |
| 103 | Some => 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: = ; |
| 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 | |
| 142 | let filename = key.rsplit.next.unwrap_or; |
| 143 | if MUTABLE_INDEX_NAMES |
| 144 | .iter |
| 145 | .any |
| 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 | |
| 163 | async |
| 164 | State: , |
| 165 | State: , |
| 166 | sync_user: SyncUser, |
| 167 | Json: , |
| 168 | |
| 169 | require_artifact_admin?; |
| 170 | |
| 171 | if req.size <= 0 |
| 172 | return Err; |
| 173 | |
| 174 | if req.size > ARTIFACT_MAX_OBJECT_BYTES |
| 175 | return Err |
| 176 | "object is {} bytes, over the {} byte ceiling" |
| 177 | req.size, |
| 178 | ARTIFACT_MAX_OBJECT_BYTES |
| 179 | ); |
| 180 | |
| 181 | |
| 182 | let key = generate_artifact_key?; |
| 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; |
| 191 | |
| 192 | let upload_url = artifact_s3 |
| 193 | .presign_upload |
| 194 | &key, |
| 195 | &content_type, |
| 196 | Some, |
| 197 | Some, |
| 198 | Some, |
| 199 | |
| 200 | .await?; |
| 201 | |
| 202 | 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; |
| 213 | |
| 214 | Ok |
| 215 | CREATED, |
| 216 | Json |
| 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 | |
| 232 | match config.admin_user_id |
| 233 | Some if admin_id == sync_user.user_id => Ok, |
| 234 | _ => Err, |
| 235 | |
| 236 | |
| 237 | |
| 238 | /// The `/api/v1/admin/rpm/*` routes. |
| 239 | |
| 240 | const ARTIFACT_SKIP: &str = "rpm publish: bearer auth, no session"; |
| 241 | |
| 242 | let write_rate_limit = craterate_limiter_ms |
| 243 | ARTIFACT_WRITE_RATE_LIMIT_MS, |
| 244 | 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 | new |
| 251 | .route |
| 252 | "/api/v1/admin/artifacts/uploads", |
| 253 | post_csrf_skip, |
| 254 | |
| 255 | .route_layer |
| 256 | |
| 257 |